Skip to main content

soma_application/providers/
filesystem.rs

1use std::{
2    collections::BTreeSet,
3    fs,
4    path::{Path, PathBuf},
5};
6
7use serde_json::Value;
8use sha2::{Digest, Sha256};
9use soma_domain::provider_validation::{validate_manifest_schema, validate_provider_manifest};
10use soma_provider_adapters::manifest_file;
11use soma_provider_core::{ProviderCatalog, ProviderKind};
12
13use crate::{
14    provider_registry::{DynamicResourceTemplate, Provider, SharedAdapter},
15    providers::resource_files::{ResourceFileError, ResourceFileProvider},
16};
17
18/// Product env-namespace forwarded to shared adapters that resolve
19/// `EnvRequirement`s (ai-sdk, python) — see `soma_provider_adapters::sidecar
20/// ::collect_provider_env`'s docs for why this crate has no hard-coded
21/// product prefix of its own.
22const PROVIDER_ENV_PREFIX: &str = "SOMA";
23
24#[path = "filesystem_prompts.rs"]
25mod filesystem_prompts;
26#[path = "filesystem_resources.rs"]
27mod filesystem_resources;
28#[path = "filesystem_uniqueness.rs"]
29mod filesystem_uniqueness;
30#[path = "filesystem_wasm.rs"]
31mod filesystem_wasm;
32
33/// File-backed provider source rooted at a provider directory.
34#[derive(Debug, Clone)]
35pub struct FileProviderSource {
36    root: PathBuf,
37}
38
39/// Result of a non-executing inspection of a provider directory.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ProviderDirectoryInspection {
42    /// Root directory that was inspected.
43    pub root: PathBuf,
44    /// Whether the root directory exists.
45    pub exists: bool,
46    /// Per-file inspection results, sorted by file name.
47    pub files: Vec<ProviderFileInspection>,
48    /// Number of files that would load as active providers.
49    pub providers_loaded: usize,
50    /// Number of files that parsed but are disabled.
51    pub providers_disabled: usize,
52    /// Number of files that failed to parse or validate.
53    pub providers_invalid: usize,
54    /// Number of files skipped because inspecting them requires execution.
55    pub providers_skipped: usize,
56}
57
58/// Inspection result for a single provider file.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct ProviderFileInspection {
61    /// Absolute path to the file.
62    pub path: PathBuf,
63    /// File name component of the path.
64    pub file_name: String,
65    /// Inspection outcome for this file.
66    pub status: ProviderFileInspectionStatus,
67    /// Provider name declared in the manifest, if parsed.
68    pub provider_id: Option<String>,
69    /// Provider kind declared in the manifest, if known.
70    pub provider_kind: Option<String>,
71    /// Tool/action names declared by the provider.
72    pub actions: Vec<String>,
73    /// Error message when the file is invalid or skipped.
74    pub error: Option<String>,
75}
76
77/// Outcome of inspecting a single provider file without executing it.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum ProviderFileInspectionStatus {
80    /// Parsed and validated; would register as an active provider.
81    Loaded,
82    /// Parsed and valid but explicitly disabled.
83    Disabled,
84    /// Failed to parse or validate.
85    Invalid,
86    /// File extension requires executing code to introspect (currently just
87    /// `.py`) — non-executing inspection deliberately does not load it.
88    Skipped,
89}
90
91impl FileProviderSource {
92    /// Creates a source rooted at `root`.
93    pub fn new(root: impl Into<PathBuf>) -> Self {
94        Self { root: root.into() }
95    }
96
97    /// Returns the root provider directory.
98    pub fn root(&self) -> &Path {
99        &self.root
100    }
101
102    /// Non-executing inspection of the provider directory: parses manifests
103    /// (JSON/TS/WASM sidecar/Python/Markdown) but never runs handler code,
104    /// calls MCP, or fetches OpenAPI — safe to run before the runtime loads
105    /// providers.
106    pub fn inspect(&self) -> Result<ProviderDirectoryInspection, FileProviderLoadError> {
107        if !self.root.exists() {
108            return Ok(ProviderDirectoryInspection {
109                root: self.root.clone(),
110                exists: false,
111                files: Vec::new(),
112                providers_loaded: 0,
113                providers_disabled: 0,
114                providers_invalid: 0,
115                providers_skipped: 0,
116            });
117        }
118
119        let mut files = Vec::new();
120        // Parallel to `files`, index-aligned: the parsed catalog for any file
121        // that is (so far) `Loaded`, used by the directory-wide uniqueness
122        // pass below. `Disabled` catalogs are intentionally excluded here —
123        // `load()` never registers disabled providers either, so they can't
124        // collide with anything at runtime.
125        let mut loaded_catalogs: Vec<Option<ProviderCatalog>> = Vec::new();
126        // Parallel to `files`/`loaded_catalogs`: a dynamic `.ts` resource
127        // reader's template, which the live `ResourceIndex::register`
128        // checks for ambiguity but which never appears in `catalog().resources`
129        // (dynamic templates aren't declared data, they're derived from the
130        // filename) — without this, lint can't see two colliding readers
131        // like `service/[name].ts` and `service/[id].ts` at all.
132        let mut dynamic_templates: Vec<Option<DynamicResourceTemplate>> = Vec::new();
133
134        for path in self.provider_paths()? {
135            let file_name = path
136                .file_name()
137                .and_then(|name| name.to_str())
138                .unwrap_or("<unknown>")
139                .to_owned();
140
141            // Python catalogs are extracted by importing (and thus executing) the
142            // module in a sidecar process — there is no metadata-only path. Never
143            // run that from a non-executing inspection; report it as skipped
144            // instead of silently exec'ing arbitrary import-time code.
145            if is_python_provider_source(&path) {
146                files.push(ProviderFileInspection {
147                    path,
148                    file_name,
149                    status: ProviderFileInspectionStatus::Skipped,
150                    provider_id: None,
151                    provider_kind: Some(ProviderKind::Python.as_str().to_owned()),
152                    actions: Vec::new(),
153                    error: Some(
154                        "Python providers can only be introspected by executing the module; \
155                         non-executing inspection does not run them. Use `soma providers \
156                         validate` or `soma providers inspect` to check this file."
157                            .to_owned(),
158                    ),
159                });
160                loaded_catalogs.push(None);
161                continue;
162            }
163
164            match load_catalog(&path) {
165                Ok(catalog) => {
166                    let semantic_check = load_catalog_value(&path)
167                        .map_err(|error| error.to_string())
168                        .and_then(|value| {
169                            validate_manifest_schema(&value).map_err(|error| error.to_string())
170                        })
171                        .and_then(|()| {
172                            validate_provider_manifest(&catalog).map_err(|error| error.to_string())
173                        })
174                        .and_then(|()| compile_tool_schemas(&catalog));
175                    match semantic_check {
176                        Ok(()) => {
177                            let status = if catalog.provider.enabled == Some(false) {
178                                ProviderFileInspectionStatus::Disabled
179                            } else {
180                                ProviderFileInspectionStatus::Loaded
181                            };
182                            let actions = catalog
183                                .tools
184                                .iter()
185                                .map(|tool| tool.name.clone())
186                                .collect::<Vec<_>>();
187                            files.push(ProviderFileInspection {
188                                path,
189                                file_name,
190                                status,
191                                provider_id: Some(catalog.provider.name.clone()),
192                                provider_kind: Some(catalog.provider.kind.as_str().to_owned()),
193                                actions,
194                                error: None,
195                            });
196                            loaded_catalogs.push(
197                                (status == ProviderFileInspectionStatus::Loaded).then_some(catalog),
198                            );
199                        }
200                        Err(message) => {
201                            files.push(ProviderFileInspection {
202                                path,
203                                file_name,
204                                status: ProviderFileInspectionStatus::Invalid,
205                                provider_id: Some(catalog.provider.name),
206                                provider_kind: Some(catalog.provider.kind.as_str().to_owned()),
207                                actions: Vec::new(),
208                                error: Some(message),
209                            });
210                            loaded_catalogs.push(None);
211                        }
212                    }
213                }
214                Err(error) => {
215                    files.push(ProviderFileInspection {
216                        path,
217                        file_name,
218                        status: ProviderFileInspectionStatus::Invalid,
219                        provider_id: None,
220                        provider_kind: None,
221                        actions: Vec::new(),
222                        error: Some(error.to_string()),
223                    });
224                    loaded_catalogs.push(None);
225                }
226            }
227        }
228
229        // None of the entries pushed above are resource files, so pad
230        // `dynamic_templates` up to the same length before extending with
231        // the resource files' own (possibly `Some`) entries below — keeps
232        // all three vectors index-aligned with `files` without touching
233        // every earlier push site.
234        dynamic_templates.resize(files.len(), None);
235        let (resource_files, resource_catalogs, resource_templates) =
236            filesystem_resources::inspect_files(self.resource_pairs_with_canonical_root()?);
237        files.extend(resource_files);
238        loaded_catalogs.extend(resource_catalogs);
239        dynamic_templates.extend(resource_templates);
240
241        filesystem_uniqueness::apply_directory_wide_checks(
242            &mut files,
243            &loaded_catalogs,
244            &dynamic_templates,
245        );
246        files.sort_by(|left, right| left.file_name.cmp(&right.file_name));
247        let providers_loaded = files
248            .iter()
249            .filter(|file| file.status == ProviderFileInspectionStatus::Loaded)
250            .count();
251        let providers_disabled = files
252            .iter()
253            .filter(|file| file.status == ProviderFileInspectionStatus::Disabled)
254            .count();
255        let providers_invalid = files
256            .iter()
257            .filter(|file| file.status == ProviderFileInspectionStatus::Invalid)
258            .count();
259        let providers_skipped = files
260            .iter()
261            .filter(|file| file.status == ProviderFileInspectionStatus::Skipped)
262            .count();
263
264        Ok(ProviderDirectoryInspection {
265            root: self.root.clone(),
266            exists: true,
267            files,
268            providers_loaded,
269            providers_disabled,
270            providers_invalid,
271            providers_skipped,
272        })
273    }
274
275    /// Loads and builds every enabled provider (including resource-file
276    /// providers) from the directory, ready for registration.
277    pub fn load(&self) -> Result<Vec<std::sync::Arc<dyn Provider>>, FileProviderLoadError> {
278        let mut providers = Vec::new();
279        for path in self.provider_paths()? {
280            let catalog = load_catalog(&path)?;
281            if catalog.provider.enabled == Some(false) {
282                continue;
283            }
284            providers.push(provider_for_catalog(path, catalog)?);
285        }
286        for (absolute, relative, canonical_root) in self.resource_pairs_with_canonical_root()? {
287            let provider = ResourceFileProvider::arc(absolute.clone(), &relative, &canonical_root)
288                .map_err(|ResourceFileError(message)| FileProviderLoadError {
289                    path: absolute,
290                    message,
291                })?;
292            providers.push(provider);
293        }
294        Ok(providers)
295    }
296
297    /// Computes a stable SHA-256 fingerprint over all provider inputs, used to
298    /// detect changes to the provider directory.
299    pub fn fingerprint(&self) -> Result<String, FileProviderLoadError> {
300        let mut hasher = Sha256::new();
301        for path in self.fingerprint_paths()? {
302            fingerprint_file(&mut hasher, &self.root, &path)?;
303        }
304        Ok(hasher
305            .finalize()
306            .iter()
307            .map(|byte| format!("{byte:02x}"))
308            .collect::<String>())
309    }
310
311    fn fingerprint_paths(&self) -> Result<Vec<PathBuf>, FileProviderLoadError> {
312        let provider_paths = self.provider_paths()?;
313        let mut paths = BTreeSet::new();
314        let mut has_python_provider = false;
315
316        for path in &provider_paths {
317            match path.extension().and_then(|extension| extension.to_str()) {
318                Some("wasm") => {
319                    let sidecar = filesystem_wasm::wasm_sidecar_manifest_path(path);
320                    if sidecar.is_file() {
321                        paths.insert(sidecar);
322                    } else {
323                        paths.insert(path.clone());
324                    }
325                }
326                Some("py") => {
327                    has_python_provider = true;
328                    paths.insert(path.clone());
329                }
330                _ => {
331                    paths.insert(path.clone());
332                }
333            }
334        }
335
336        if has_python_provider {
337            collect_python_dependency_paths(&self.root, &mut paths)?;
338        }
339
340        for (absolute, _relative) in self.resource_paths()? {
341            paths.insert(absolute);
342        }
343
344        Ok(paths.into_iter().collect())
345    }
346
347    /// Manifest-backed provider files (`.json`/`.ts`/`.wasm`/`.py`/`.md`),
348    /// from the provider root plus the structured `tools/` and `prompts/`
349    /// subdirectories, if present. Root-level files remain supported for
350    /// compatibility per the drop-in provider layout contract; new docs and
351    /// examples should prefer the structured layout. Neither subdirectory is
352    /// scanned recursively — same flat-directory semantics as root.
353    fn provider_paths(&self) -> Result<Vec<PathBuf>, FileProviderLoadError> {
354        if !self.root.exists() {
355            return Ok(Vec::new());
356        }
357        let mut paths = Vec::new();
358        collect_flat_files(&self.root, &mut paths, |path| {
359            is_provider_file(path) && !is_wasm_sidecar_manifest(path)
360        })?;
361        let tools_dir = self.root.join("tools");
362        if tools_dir.is_dir() {
363            collect_flat_files(&tools_dir, &mut paths, |path| {
364                is_tool_file(path) && !is_wasm_sidecar_manifest(path)
365            })?;
366        }
367        let prompts_dir = self.root.join("prompts");
368        if prompts_dir.is_dir() {
369            collect_flat_files(&prompts_dir, &mut paths, is_markdown_prompt_file)?;
370        }
371        paths.sort();
372        Ok(paths)
373    }
374
375    /// Files under the structured `resources/` subdirectory, recursively,
376    /// as `(absolute_path, path_relative_to_resources_dir)` pairs. See
377    /// `filesystem_resources::resource_paths` for the trust-boundary
378    /// enforcement this delegates to.
379    fn resource_paths(&self) -> Result<Vec<(PathBuf, PathBuf)>, FileProviderLoadError> {
380        filesystem_resources::resource_paths(&self.root)
381    }
382
383    /// `resource_paths()` triples with the canonicalized `resources/`
384    /// directory attached to each, so `ResourceFileProvider` can re-verify
385    /// containment at read time against the same root discovery validated,
386    /// closing the TOCTOU window between the two.
387    fn resource_pairs_with_canonical_root(
388        &self,
389    ) -> Result<Vec<(PathBuf, PathBuf, PathBuf)>, FileProviderLoadError> {
390        let pairs = self.resource_paths()?;
391        if pairs.is_empty() {
392            return Ok(Vec::new());
393        }
394        let canonical_root = filesystem_resources::canonical_resources_root(&self.root)?
395            .expect("non-empty resource_paths() implies the resources dir exists");
396        Ok(pairs
397            .into_iter()
398            .map(|(absolute, relative)| (absolute, relative, canonical_root.clone()))
399            .collect())
400    }
401}
402
403fn collect_flat_files(
404    dir: &Path,
405    paths: &mut Vec<PathBuf>,
406    accept: impl Fn(&Path) -> bool,
407) -> Result<(), FileProviderLoadError> {
408    let entries = fs::read_dir(dir).map_err(|source| FileProviderLoadError {
409        path: dir.to_path_buf(),
410        message: format!("failed to read provider directory: {source}"),
411    })?;
412    for entry in entries {
413        let entry = entry.map_err(|source| FileProviderLoadError {
414            path: dir.to_path_buf(),
415            message: format!("failed to read provider directory entry: {source}"),
416        })?;
417        let path = entry.path();
418        if path.is_file() && accept(&path) {
419            paths.push(path);
420        }
421    }
422    Ok(())
423}
424
425fn collect_python_dependency_paths(
426    root: &Path,
427    paths: &mut BTreeSet<PathBuf>,
428) -> Result<(), FileProviderLoadError> {
429    if !root.exists() {
430        return Ok(());
431    }
432    collect_python_dependency_paths_inner(root, paths)
433}
434
435fn collect_python_dependency_paths_inner(
436    dir: &Path,
437    paths: &mut BTreeSet<PathBuf>,
438) -> Result<(), FileProviderLoadError> {
439    let entries = fs::read_dir(dir).map_err(|source| FileProviderLoadError {
440        path: dir.to_path_buf(),
441        message: format!("failed to read provider dependency directory: {source}"),
442    })?;
443    for entry in entries {
444        let entry = entry.map_err(|source| FileProviderLoadError {
445            path: dir.to_path_buf(),
446            message: format!("failed to read provider dependency directory entry: {source}"),
447        })?;
448        let path = entry.path();
449        if path.is_dir() {
450            if should_scan_dependency_dir(&path) {
451                collect_python_dependency_paths_inner(&path, paths)?;
452            }
453            continue;
454        }
455        if path.is_file() && is_python_dependency_file(&path) {
456            paths.insert(path);
457        }
458    }
459    Ok(())
460}
461
462fn should_scan_dependency_dir(path: &Path) -> bool {
463    let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
464        return false;
465    };
466    !matches!(
467        name,
468        "__pycache__"
469            | ".git"
470            | ".mypy_cache"
471            | ".pytest_cache"
472            | ".ruff_cache"
473            | ".venv"
474            | "venv"
475            | "node_modules"
476            | "target"
477            | "dist"
478            | "build"
479    )
480}
481
482fn is_python_dependency_file(path: &Path) -> bool {
483    matches!(
484        path.extension().and_then(|extension| extension.to_str()),
485        Some("py" | "pyi")
486    )
487}
488
489/// Error raised while reading, parsing, or building a file-backed provider.
490#[derive(Debug)]
491pub struct FileProviderLoadError {
492    /// Path to the file or directory that caused the error.
493    pub path: PathBuf,
494    /// Human-readable description of the failure.
495    pub message: String,
496}
497
498impl std::fmt::Display for FileProviderLoadError {
499    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
500        write!(f, "{}: {}", self.path.display(), self.message)
501    }
502}
503
504impl std::error::Error for FileProviderLoadError {}
505
506/// Builds the concrete provider for `catalog`'s declared kind. Every kind's
507/// actual implementation lives in the product-neutral `soma-provider-adapters`
508/// crate (feature-gated per kind); this just dispatches to it and wraps the
509/// result to satisfy this crate's own `Provider` trait — see
510/// `provider_registry::SharedAdapter` and the PR10 deviation notes on why
511/// this dispatch step, and the directory-scanning/Soma-policy orchestration
512/// around it, stayed in this crate rather than moving wholesale.
513///
514/// This crate currently enables every `soma-provider-adapters` kind feature
515/// (see its `Cargo.toml`), so `manifest_file::build_provider` returning
516/// `None` — meaning this binary was built without the feature that owns
517/// `catalog`'s kind — should never happen today. It is nonetheless treated
518/// as an ordinary, per-manifest `FileProviderLoadError` rather than a
519/// process-crashing `unreachable!()`: that invariant depends on two files (a
520/// `Cargo.toml` feature list and this crate's `ProviderKind` coverage)
521/// staying in sync by convention, with nothing enforcing it at compile time.
522/// If they ever drift, one misconfigured/unsupported provider manifest
523/// should fail to load, not take down every other already-working provider.
524fn provider_for_catalog(
525    path: PathBuf,
526    catalog: ProviderCatalog,
527) -> Result<std::sync::Arc<dyn Provider>, FileProviderLoadError> {
528    let kind = catalog.provider.kind;
529    manifest_file::build_provider(path.clone(), catalog, PROVIDER_ENV_PREFIX)
530        .map(SharedAdapter::wrap)
531        .ok_or_else(|| FileProviderLoadError {
532            path,
533            message: format!(
534                "provider kind `{}` is not enabled in this build (soma-provider-adapters feature missing)",
535                kind.as_str()
536            ),
537        })
538}
539
540fn is_provider_file(path: &Path) -> bool {
541    match path.extension().and_then(|extension| extension.to_str()) {
542        Some("json" | "ts" | "wasm" | "py") => true,
543        Some("md") => is_markdown_prompt_file(path),
544        _ => false,
545    }
546}
547
548/// The structured `providers/tools/` directory only owns action-like files —
549/// no `.md`, which belongs to `providers/prompts/` instead.
550fn is_tool_file(path: &Path) -> bool {
551    matches!(
552        path.extension().and_then(|extension| extension.to_str()),
553        Some("json" | "ts" | "wasm" | "py")
554    )
555}
556
557/// A `.md` file is a prompt provider unless it's the directory's own README —
558/// `examples/providers/README.md` documents the directory, it isn't a prompt.
559fn is_markdown_prompt_file(path: &Path) -> bool {
560    path.file_stem()
561        .and_then(|stem| stem.to_str())
562        .map(|stem| !stem.eq_ignore_ascii_case("readme"))
563        .unwrap_or(false)
564}
565
566fn is_wasm_sidecar_manifest(path: &Path) -> bool {
567    path.file_name()
568        .and_then(|name| name.to_str())
569        .is_some_and(|name| name.ends_with(".wasm.json"))
570}
571
572fn is_python_provider_source(path: &Path) -> bool {
573    path.extension().and_then(|extension| extension.to_str()) == Some("py")
574}
575
576/// Mirrors the schema-compilation pass `provider_registry::build_snapshot()`
577/// runs for every tool — a manifest can deserialize and pass
578/// `validate_provider_manifest()` while still carrying an `input_schema` or
579/// `output_schema` that fails to compile as JSON Schema (e.g. `properties`
580/// given as an array instead of an object). Non-executing inspection must
581/// catch that too, or `lint` can bless a provider the live registry rejects.
582fn compile_tool_schemas(catalog: &ProviderCatalog) -> Result<(), String> {
583    for tool in &catalog.tools {
584        jsonschema::validator_for(&tool.input_schema)
585            .map_err(|error| format!("tool `{}` has invalid input_schema: {error}", tool.name))?;
586        if let Some(output_schema) = &tool.output_schema {
587            jsonschema::validator_for(output_schema).map_err(|error| {
588                format!("tool `{}` has invalid output_schema: {error}", tool.name)
589            })?;
590        }
591    }
592    Ok(())
593}
594
595fn fingerprint_file(
596    hasher: &mut Sha256,
597    root: &Path,
598    path: &Path,
599) -> Result<(), FileProviderLoadError> {
600    let bytes = fs::read(path).map_err(|source| FileProviderLoadError {
601        path: path.to_path_buf(),
602        message: format!("failed to read provider file for fingerprint: {source}"),
603    })?;
604    let label = path
605        .strip_prefix(root)
606        .unwrap_or(path)
607        .display()
608        .to_string();
609    hasher.update(label.as_bytes());
610    hasher.update([0]);
611    hasher.update(bytes.len().to_le_bytes());
612    hasher.update([0]);
613    hasher.update(bytes);
614    hasher.update([0xff]);
615    Ok(())
616}
617
618fn load_catalog(path: &Path) -> Result<ProviderCatalog, FileProviderLoadError> {
619    let extension = path.extension().and_then(|extension| extension.to_str());
620    let catalog = match extension {
621        Some("json") | Some("ts") | Some("wasm") | Some("md") => {
622            let value = load_catalog_value(path)?;
623            serde_json::from_value(value).map_err(|source| FileProviderLoadError {
624                path: path.to_path_buf(),
625                message: format!("invalid provider manifest JSON: {source}"),
626            })?
627        }
628        Some("py") => {
629            // Only generic (soma-provider-core) manifest validation runs
630            // here — Soma's own CLI reserved-command / env-prefix policy is
631            // enforced uniformly for every provider kind by
632            // `provider_registry::build_registry`'s
633            // `validate_provider_manifest(&provider.catalog())` call, so
634            // this does not skip that policy, just defers it to the same
635            // place every other kind already goes through.
636            soma_provider_adapters::python::load_python_catalog(path, PROVIDER_ENV_PREFIX).map_err(
637                |source| FileProviderLoadError {
638                    path: path.to_path_buf(),
639                    message: format!("invalid Python provider: {source}"),
640                },
641            )?
642        }
643        _ => {
644            return Err(FileProviderLoadError {
645                path: path.to_path_buf(),
646                message: "unsupported provider file extension".to_owned(),
647            });
648        }
649    };
650    ensure_kind_matches(path, &catalog)?;
651    Ok(catalog)
652}
653
654/// Parses a JSON/TS/WASM-sidecar provider file to a raw `Value`, one step
655/// short of `load_catalog`'s typed `ProviderCatalog`. Used by non-executing
656/// inspection to validate against `provider-manifest.schema.json` (schema-only
657/// constraints like `rest.path`'s pattern) before that information is lost to
658/// `#[serde(default)]` fields round-tripping through `Option::None` as JSON
659/// `null`, which the schema — correctly — does not accept in place of an
660/// absent key.
661fn load_catalog_value(path: &Path) -> Result<Value, FileProviderLoadError> {
662    let extension = path.extension().and_then(|extension| extension.to_str());
663    match extension {
664        Some("json") => {
665            serde_json::from_slice(&fs::read(path).map_err(|source| FileProviderLoadError {
666                path: path.to_path_buf(),
667                message: format!("failed to read provider manifest: {source}"),
668            })?)
669            .map_err(|source| FileProviderLoadError {
670                path: path.to_path_buf(),
671                message: format!("invalid provider manifest JSON: {source}"),
672            })
673        }
674        Some("ts") => load_ts_catalog_value(path),
675        Some("wasm") => filesystem_wasm::load_wasm_catalog_value(path),
676        Some("md") => filesystem_prompts::load_markdown_catalog_value(path),
677        _ => Err(FileProviderLoadError {
678            path: path.to_path_buf(),
679            message: "unsupported provider file extension".to_owned(),
680        }),
681    }
682}
683
684fn load_ts_catalog_value(path: &Path) -> Result<Value, FileProviderLoadError> {
685    let text = fs::read_to_string(path).map_err(|source| FileProviderLoadError {
686        path: path.to_path_buf(),
687        message: format!("failed to read TypeScript provider: {source}"),
688    })?;
689    let json_text = extract_ts_manifest(&text).ok_or_else(|| FileProviderLoadError {
690        path: path.to_path_buf(),
691        message: "TypeScript provider must contain `export default { ... }` manifest JSON"
692            .to_owned(),
693    })?;
694    serde_json::from_str(json_text).map_err(|source| FileProviderLoadError {
695        path: path.to_path_buf(),
696        message: format!("invalid TypeScript provider manifest JSON: {source}"),
697    })
698}
699
700fn extract_ts_manifest(text: &str) -> Option<&str> {
701    let marker = "export default";
702    let start = text.find(marker)? + marker.len();
703    let rest = text[start..].trim_start();
704    let open = rest.find('{')?;
705    let mut depth = 0usize;
706    let mut in_string = false;
707    let mut escaped = false;
708    for (offset, ch) in rest[open..].char_indices() {
709        if in_string {
710            if escaped {
711                escaped = false;
712            } else if ch == '\\' {
713                escaped = true;
714            } else if ch == '"' {
715                in_string = false;
716            }
717            continue;
718        }
719        match ch {
720            '"' => in_string = true,
721            '{' => depth += 1,
722            '}' => {
723                depth = depth.checked_sub(1)?;
724                if depth == 0 {
725                    let end = open + offset + ch.len_utf8();
726                    return Some(rest[..end].trim());
727                }
728            }
729            _ => {}
730        }
731    }
732    None
733}
734
735fn ensure_kind_matches(
736    path: &Path,
737    catalog: &ProviderCatalog,
738) -> Result<(), FileProviderLoadError> {
739    let extension = path.extension().and_then(|extension| extension.to_str());
740    let required_extension = required_extension_for_kind(catalog.provider.kind);
741    if required_extension.is_some_and(|expected| extension != Some(expected)) {
742        return Err(FileProviderLoadError {
743            path: path.to_path_buf(),
744            message: format!(
745                "provider kind `{}` requires a .{} file",
746                catalog.provider.kind.as_str(),
747                required_extension_for_kind(catalog.provider.kind).unwrap()
748            ),
749        });
750    }
751
752    // No `Some("md")` arm: `load_markdown_catalog_value` unconditionally
753    // hardcodes `"kind": "static-rust"`, so a mismatch can't currently occur.
754    // `.md` catalogs fall through to `_ => {}` like any other `static-rust`
755    // manifest (`required_extension_for_kind` returns `None` for it too).
756    match extension {
757        Some("ts") if catalog.provider.kind != ProviderKind::AiSdk => {
758            return Err(FileProviderLoadError {
759                path: path.to_path_buf(),
760                message: format!(
761                    "provider kind `{}` does not match TypeScript provider extension",
762                    catalog.provider.kind.as_str()
763                ),
764            });
765        }
766        Some("wasm") if catalog.provider.kind != ProviderKind::Wasm => {
767            return Err(FileProviderLoadError {
768                path: path.to_path_buf(),
769                message: format!(
770                    "provider kind `{}` does not match WASM provider extension",
771                    catalog.provider.kind.as_str()
772                ),
773            });
774        }
775        Some("py")
776            if !matches!(
777                catalog.provider.kind,
778                ProviderKind::Python | ProviderKind::Langchain | ProviderKind::Llamaindex
779            ) =>
780        {
781            return Err(FileProviderLoadError {
782                path: path.to_path_buf(),
783                message: format!(
784                    "provider kind `{}` does not match Python provider extension",
785                    catalog.provider.kind.as_str()
786                ),
787            });
788        }
789        _ => {}
790    }
791    Ok(())
792}
793
794fn required_extension_for_kind(kind: ProviderKind) -> Option<&'static str> {
795    match kind {
796        ProviderKind::AiSdk => Some("ts"),
797        ProviderKind::Wasm => Some("wasm"),
798        ProviderKind::Python | ProviderKind::Langchain | ProviderKind::Llamaindex => Some("py"),
799        ProviderKind::StaticRust | ProviderKind::Openapi | ProviderKind::Mcp => None,
800    }
801}
802
803#[cfg(test)]
804#[path = "filesystem_tests.rs"]
805mod tests;