Skip to main content

soma_application/providers/
filesystem.rs

1use std::{
2    collections::BTreeSet,
3    fmt, fs,
4    path::{Path, PathBuf},
5    sync::Arc,
6};
7
8use serde_json::Value;
9use sha2::{Digest, Sha256};
10use soma_domain::provider_validation::{validate_manifest_schema, validate_provider_manifest};
11use soma_provider_adapters::{
12    manifest_file,
13    python::{
14        PythonInterpreter, PythonProvider, describe_persistent_catalog,
15        host::PythonExecutionProfile, supervisor::PythonSupervisorConfig,
16    },
17};
18use soma_provider_core::{ProviderCatalog, ProviderKind};
19
20use crate::{
21    provider_registry::{DynamicResourceTemplate, Provider, SharedAdapter},
22    providers::resource_files::{ResourceFileError, ResourceFileProvider},
23};
24
25/// Product env-namespace forwarded to shared adapters that resolve
26/// `EnvRequirement`s (ai-sdk, python) — see `soma_provider_adapters::sidecar
27/// ::collect_provider_env`'s docs for why this crate has no hard-coded
28/// product prefix of its own.
29const PROVIDER_ENV_PREFIX: &str = "SOMA";
30
31#[path = "filesystem_prompts.rs"]
32mod filesystem_prompts;
33#[path = "filesystem_python.rs"]
34mod filesystem_python;
35#[path = "filesystem_resources.rs"]
36mod filesystem_resources;
37#[path = "filesystem_uniqueness.rs"]
38mod filesystem_uniqueness;
39#[path = "filesystem_wasm.rs"]
40mod filesystem_wasm;
41
42pub use filesystem_python::{
43    PythonProviderEnvironmentPreparer, PythonProviderEnvironmentSelections,
44};
45use filesystem_python::{
46    collect_python_dependency_paths, fingerprint_python_environment, immutable_python_generation,
47    is_python_provider_source, python_worker_generation_digest, retain_python_snapshot,
48};
49
50/// File-backed provider source rooted at a provider directory.
51#[derive(Clone)]
52pub struct FileProviderSource {
53    root: PathBuf,
54    python_environment_preparer: Option<Arc<dyn PythonProviderEnvironmentPreparer>>,
55    python_runner: PythonRunnerSelection,
56}
57
58/// Python execution strategy applied to file-backed Python providers.
59#[derive(Debug, Clone, Default)]
60pub enum PythonRunnerSelection {
61    /// Start a fresh bounded bridge process for each catalog or invocation.
62    #[default]
63    OneShot,
64    /// Keep one serial supervised worker alive per Python provider.
65    Persistent(PythonSupervisorConfig),
66}
67
68/// Complete Python-provider runtime selection for a file-backed source.
69///
70/// The environment preparer is optional so existing ambient-interpreter
71/// behavior remains the default until an operator enables immutable
72/// environments.
73#[derive(Clone)]
74pub struct PythonProviderRuntime {
75    runner: PythonRunnerSelection,
76    environment_preparer: Option<Arc<dyn PythonProviderEnvironmentPreparer>>,
77    capability_broker: crate::capabilities::CapabilityBroker,
78}
79
80impl PythonProviderRuntime {
81    /// Creates a runtime using `runner` and the ambient interpreter.
82    pub fn new(runner: PythonRunnerSelection) -> Self {
83        Self {
84            runner,
85            environment_preparer: None,
86            capability_broker: crate::capabilities::CapabilityBroker::default_deny(),
87        }
88    }
89
90    /// Installs deployment policy used to intersect provider declarations.
91    pub fn with_capability_broker(
92        mut self,
93        capability_broker: crate::capabilities::CapabilityBroker,
94    ) -> Self {
95        self.capability_broker = capability_broker;
96        self
97    }
98
99    /// Installs immutable environment preparation before Python candidates load.
100    pub fn with_environment_preparer(
101        mut self,
102        preparer: Arc<dyn PythonProviderEnvironmentPreparer>,
103    ) -> Self {
104        self.environment_preparer = Some(preparer);
105        self
106    }
107
108    pub(crate) fn configure_source(
109        self,
110        mut source: FileProviderSource,
111    ) -> (FileProviderSource, crate::capabilities::CapabilityBroker) {
112        source = source.with_python_runner(self.runner);
113        if let Some(preparer) = self.environment_preparer {
114            source = source.with_python_environment_preparer(preparer);
115        }
116        (source, self.capability_broker)
117    }
118}
119
120impl fmt::Debug for PythonProviderRuntime {
121    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
122        formatter
123            .debug_struct("PythonProviderRuntime")
124            .field("runner", &self.runner)
125            .field("environment_preparer", &self.environment_preparer.is_some())
126            .field("capability_broker", &self.capability_broker)
127            .finish()
128    }
129}
130
131impl fmt::Debug for FileProviderSource {
132    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
133        formatter
134            .debug_struct("FileProviderSource")
135            .field("root", &self.root)
136            .field(
137                "python_environment_preparer",
138                &self.python_environment_preparer.is_some(),
139            )
140            .field("python_runner", &self.python_runner)
141            .finish()
142    }
143}
144
145/// Result of a non-executing inspection of a provider directory.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct ProviderDirectoryInspection {
148    /// Root directory that was inspected.
149    pub root: PathBuf,
150    /// Whether the root directory exists.
151    pub exists: bool,
152    /// Per-file inspection results, sorted by file name.
153    pub files: Vec<ProviderFileInspection>,
154    /// Number of files that would load as active providers.
155    pub providers_loaded: usize,
156    /// Number of files that parsed but are disabled.
157    pub providers_disabled: usize,
158    /// Number of files that failed to parse or validate.
159    pub providers_invalid: usize,
160    /// Number of files skipped because inspecting them requires execution.
161    pub providers_skipped: usize,
162}
163
164/// Inspection result for a single provider file.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct ProviderFileInspection {
167    /// Absolute path to the file.
168    pub path: PathBuf,
169    /// File name component of the path.
170    pub file_name: String,
171    /// Inspection outcome for this file.
172    pub status: ProviderFileInspectionStatus,
173    /// Provider name declared in the manifest, if parsed.
174    pub provider_id: Option<String>,
175    /// Provider kind declared in the manifest, if known.
176    pub provider_kind: Option<String>,
177    /// Tool/action names declared by the provider.
178    pub actions: Vec<String>,
179    /// Error message when the file is invalid or skipped.
180    pub error: Option<String>,
181}
182
183/// Outcome of inspecting a single provider file without executing it.
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum ProviderFileInspectionStatus {
186    /// Parsed and validated; would register as an active provider.
187    Loaded,
188    /// Parsed and valid but explicitly disabled.
189    Disabled,
190    /// Failed to parse or validate.
191    Invalid,
192    /// File extension requires executing code to introspect (currently just
193    /// `.py`) — non-executing inspection deliberately does not load it.
194    Skipped,
195}
196
197impl FileProviderSource {
198    /// Creates a source rooted at `root`.
199    pub fn new(root: impl Into<PathBuf>) -> Self {
200        Self {
201            root: root.into(),
202            python_environment_preparer: None,
203            python_runner: PythonRunnerSelection::OneShot,
204        }
205    }
206
207    /// Configures immutable Python environment preparation for `.py`
208    /// candidates. Existing sources remain ambient unless this is called.
209    pub fn with_python_environment_preparer(
210        mut self,
211        preparer: Arc<dyn PythonProviderEnvironmentPreparer>,
212    ) -> Self {
213        self.python_environment_preparer = Some(preparer);
214        self
215    }
216
217    /// Selects persistent execution for eligible Python providers.
218    pub fn with_python_runner(mut self, runner: PythonRunnerSelection) -> Self {
219        self.python_runner = runner;
220        self
221    }
222
223    /// Returns the root provider directory.
224    pub fn root(&self) -> &Path {
225        &self.root
226    }
227
228    /// Non-executing inspection of the provider directory: parses manifests
229    /// (JSON/TS/WASM sidecar/Python/Markdown) but never runs handler code,
230    /// calls MCP, or fetches OpenAPI — safe to run before the runtime loads
231    /// providers.
232    pub fn inspect(&self) -> Result<ProviderDirectoryInspection, FileProviderLoadError> {
233        if !self.root.exists() {
234            return Ok(ProviderDirectoryInspection {
235                root: self.root.clone(),
236                exists: false,
237                files: Vec::new(),
238                providers_loaded: 0,
239                providers_disabled: 0,
240                providers_invalid: 0,
241                providers_skipped: 0,
242            });
243        }
244
245        let mut files = Vec::new();
246        // Parallel to `files`, index-aligned: the parsed catalog for any file
247        // that is (so far) `Loaded`, used by the directory-wide uniqueness
248        // pass below. `Disabled` catalogs are intentionally excluded here —
249        // `load()` never registers disabled providers either, so they can't
250        // collide with anything at runtime.
251        let mut loaded_catalogs: Vec<Option<ProviderCatalog>> = Vec::new();
252        // Parallel to `files`/`loaded_catalogs`: a dynamic `.ts` resource
253        // reader's template, which the live `ResourceIndex::register`
254        // checks for ambiguity but which never appears in `catalog().resources`
255        // (dynamic templates aren't declared data, they're derived from the
256        // filename) — without this, lint can't see two colliding readers
257        // like `service/[name].ts` and `service/[id].ts` at all.
258        let mut dynamic_templates: Vec<Option<DynamicResourceTemplate>> = Vec::new();
259
260        for path in self.provider_paths()? {
261            let file_name = path
262                .file_name()
263                .and_then(|name| name.to_str())
264                .unwrap_or("<unknown>")
265                .to_owned();
266
267            // Python catalogs are extracted by importing (and thus executing) the
268            // module in a sidecar process — there is no metadata-only path. Never
269            // run that from a non-executing inspection; report it as skipped
270            // instead of silently exec'ing arbitrary import-time code.
271            if is_python_provider_source(&path) {
272                files.push(ProviderFileInspection {
273                    path,
274                    file_name,
275                    status: ProviderFileInspectionStatus::Skipped,
276                    provider_id: None,
277                    provider_kind: Some(ProviderKind::Python.as_str().to_owned()),
278                    actions: Vec::new(),
279                    error: Some(
280                        "Python providers can only be introspected by executing the module; \
281                         non-executing inspection does not run them. Use `soma providers \
282                         validate` or `soma providers inspect` to check this file."
283                            .to_owned(),
284                    ),
285                });
286                loaded_catalogs.push(None);
287                continue;
288            }
289
290            match load_catalog(&path) {
291                Ok(catalog) => {
292                    let semantic_check = load_catalog_value(&path)
293                        .map_err(|error| error.to_string())
294                        .and_then(|value| {
295                            validate_manifest_schema(&value).map_err(|error| error.to_string())
296                        })
297                        .and_then(|()| {
298                            validate_provider_manifest(&catalog).map_err(|error| error.to_string())
299                        })
300                        .and_then(|()| compile_tool_schemas(&catalog));
301                    match semantic_check {
302                        Ok(()) => {
303                            let status = if catalog.provider.enabled == Some(false) {
304                                ProviderFileInspectionStatus::Disabled
305                            } else {
306                                ProviderFileInspectionStatus::Loaded
307                            };
308                            let actions = catalog
309                                .tools
310                                .iter()
311                                .map(|tool| tool.name.clone())
312                                .collect::<Vec<_>>();
313                            files.push(ProviderFileInspection {
314                                path,
315                                file_name,
316                                status,
317                                provider_id: Some(catalog.provider.name.clone()),
318                                provider_kind: Some(catalog.provider.kind.as_str().to_owned()),
319                                actions,
320                                error: None,
321                            });
322                            loaded_catalogs.push(
323                                (status == ProviderFileInspectionStatus::Loaded).then_some(catalog),
324                            );
325                        }
326                        Err(message) => {
327                            files.push(ProviderFileInspection {
328                                path,
329                                file_name,
330                                status: ProviderFileInspectionStatus::Invalid,
331                                provider_id: Some(catalog.provider.name),
332                                provider_kind: Some(catalog.provider.kind.as_str().to_owned()),
333                                actions: Vec::new(),
334                                error: Some(message),
335                            });
336                            loaded_catalogs.push(None);
337                        }
338                    }
339                }
340                Err(error) => {
341                    files.push(ProviderFileInspection {
342                        path,
343                        file_name,
344                        status: ProviderFileInspectionStatus::Invalid,
345                        provider_id: None,
346                        provider_kind: None,
347                        actions: Vec::new(),
348                        error: Some(error.to_string()),
349                    });
350                    loaded_catalogs.push(None);
351                }
352            }
353        }
354
355        // None of the entries pushed above are resource files, so pad
356        // `dynamic_templates` up to the same length before extending with
357        // the resource files' own (possibly `Some`) entries below — keeps
358        // all three vectors index-aligned with `files` without touching
359        // every earlier push site.
360        dynamic_templates.resize(files.len(), None);
361        let (resource_files, resource_catalogs, resource_templates) =
362            filesystem_resources::inspect_files(self.resource_pairs_with_canonical_root()?);
363        files.extend(resource_files);
364        loaded_catalogs.extend(resource_catalogs);
365        dynamic_templates.extend(resource_templates);
366
367        filesystem_uniqueness::apply_directory_wide_checks(
368            &mut files,
369            &loaded_catalogs,
370            &dynamic_templates,
371        );
372        files.sort_by(|left, right| left.file_name.cmp(&right.file_name));
373        let providers_loaded = files
374            .iter()
375            .filter(|file| file.status == ProviderFileInspectionStatus::Loaded)
376            .count();
377        let providers_disabled = files
378            .iter()
379            .filter(|file| file.status == ProviderFileInspectionStatus::Disabled)
380            .count();
381        let providers_invalid = files
382            .iter()
383            .filter(|file| file.status == ProviderFileInspectionStatus::Invalid)
384            .count();
385        let providers_skipped = files
386            .iter()
387            .filter(|file| file.status == ProviderFileInspectionStatus::Skipped)
388            .count();
389
390        Ok(ProviderDirectoryInspection {
391            root: self.root.clone(),
392            exists: true,
393            files,
394            providers_loaded,
395            providers_disabled,
396            providers_invalid,
397            providers_skipped,
398        })
399    }
400
401    /// Loads and builds every enabled provider (including resource-file
402    /// providers) from the directory, ready for registration.
403    pub fn load(&self) -> Result<Vec<std::sync::Arc<dyn Provider>>, FileProviderLoadError> {
404        self.load_with_python_environments(&PythonProviderEnvironmentSelections::new())
405    }
406
407    /// Loads providers using the supplied immutable Python environment selections.
408    pub fn load_with_python_environments(
409        &self,
410        selections: &PythonProviderEnvironmentSelections,
411    ) -> Result<Vec<std::sync::Arc<dyn Provider>>, FileProviderLoadError> {
412        if matches!(self.python_runner, PythonRunnerSelection::Persistent(_)) {
413            return Err(FileProviderLoadError {
414                path: self.root.clone(),
415                message: "persistent Python providers require the async preflight loading path"
416                    .to_owned(),
417            });
418        }
419        self.validate_python_environment_selections(selections)?;
420        let mut providers: Vec<std::sync::Arc<dyn Provider>> = Vec::new();
421        let paths = self.provider_paths()?;
422        let immutable_generation = paths
423            .iter()
424            .any(|path| is_python_provider_source(path))
425            .then(|| immutable_python_generation(&self.root))
426            .transpose()?;
427        for path in paths {
428            if is_python_provider_source(&path)
429                && matches!(
430                    self.python_runner,
431                    PythonRunnerSelection::Persistent(PythonSupervisorConfig {
432                        execution_profile: PythonExecutionProfile::Disabled,
433                        ..
434                    })
435                )
436            {
437                continue;
438            }
439            let interpreter = self.python_interpreter_with_environments(&path, selections)?;
440            let immutable = immutable_generation
441                .as_ref()
442                .filter(|_| is_python_provider_source(&path))
443                .map(|generation| generation.source(&self.root, &path))
444                .transpose()?;
445            let execution_path = immutable
446                .as_ref()
447                .map_or_else(|| path.clone(), |source| source.path.clone());
448            let mut catalog = load_catalog_with_interpreter(&execution_path, &interpreter)?;
449            catalog.provider.source = Some(path.display().to_string());
450            if catalog.provider.enabled == Some(false) {
451                continue;
452            }
453            let provider =
454                provider_for_catalog(execution_path, catalog, interpreter, &self.python_runner)?;
455            providers.push(immutable.map_or(provider.clone(), |source| {
456                retain_python_snapshot(provider, source.lease)
457            }));
458        }
459        for (absolute, relative, canonical_root) in self.resource_pairs_with_canonical_root()? {
460            let provider = ResourceFileProvider::arc(absolute.clone(), &relative, &canonical_root)
461                .map_err(|ResourceFileError(message)| FileProviderLoadError {
462                    path: absolute,
463                    message,
464                })?;
465            providers.push(provider);
466        }
467        Ok(providers)
468    }
469
470    /// Loads providers and preflights every persistent Python worker before
471    /// returning a candidate set to the registry.
472    pub async fn load_with_python_environments_async(
473        &self,
474        selections: &PythonProviderEnvironmentSelections,
475    ) -> Result<Vec<std::sync::Arc<dyn Provider>>, FileProviderLoadError> {
476        if matches!(self.python_runner, PythonRunnerSelection::OneShot) {
477            let source = self.clone();
478            let selections = selections.clone();
479            let root = self.root.clone();
480            return tokio::task::spawn_blocking(move || {
481                source.load_with_python_environments(&selections)
482            })
483            .await
484            .map_err(|error| FileProviderLoadError {
485                path: root,
486                message: format!("one-shot Python provider load task failed: {error}"),
487            })?;
488        }
489        self.validate_python_environment_selections(selections)?;
490        let mut providers: Vec<std::sync::Arc<dyn Provider>> = Vec::new();
491        let paths = self.provider_paths()?;
492        let immutable_generation = if paths.iter().any(|path| is_python_provider_source(path)) {
493            let root = self.root.clone();
494            Some(
495                tokio::task::spawn_blocking(move || immutable_python_generation(&root))
496                    .await
497                    .map_err(|error| FileProviderLoadError {
498                        path: self.root.clone(),
499                        message: format!("Python generation snapshot task failed: {error}"),
500                    })??,
501            )
502        } else {
503            None
504        };
505        let worker_generation = immutable_generation
506            .as_ref()
507            .map(|generation| python_worker_generation_digest(&generation.digest, selections));
508        for path in paths {
509            if is_python_provider_source(&path)
510                && matches!(
511                    self.python_runner,
512                    PythonRunnerSelection::Persistent(PythonSupervisorConfig {
513                        execution_profile: PythonExecutionProfile::Disabled,
514                        ..
515                    })
516                )
517            {
518                continue;
519            }
520            let interpreter_source = self.clone();
521            let interpreter_path = path.clone();
522            let interpreter_selections = selections.clone();
523            let interpreter = tokio::task::spawn_blocking(move || {
524                if interpreter_path.extension().and_then(|value| value.to_str()) == Some("py") {
525                    let metadata = fs::symlink_metadata(&interpreter_path).map_err(|error| {
526                        FileProviderLoadError {
527                            path: interpreter_path.clone(),
528                            message: format!("failed to inspect Python provider source: {error}"),
529                        }
530                    })?;
531                    if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
532                        return Err(FileProviderLoadError {
533                            path: interpreter_path,
534                            message: "persistent Python provider source must be a regular non-symlink file"
535                                .to_owned(),
536                        });
537                    }
538                }
539                interpreter_source.python_interpreter_with_environments(
540                    &interpreter_path,
541                    &interpreter_selections,
542                )
543            })
544            .await
545            .map_err(|error| FileProviderLoadError {
546                path: path.clone(),
547                message: format!("Python environment preparation task failed: {error}"),
548            })??;
549            let immutable = immutable_generation
550                .as_ref()
551                .filter(|_| is_python_provider_source(&path))
552                .map(|generation| generation.source(&self.root, &path))
553                .transpose()?;
554            let execution_path = immutable
555                .as_ref()
556                .map_or_else(|| path.clone(), |source| source.path.clone());
557            let mut catalog = if is_python_provider_source(&execution_path)
558                && matches!(
559                    self.python_runner,
560                    PythonRunnerSelection::Persistent(PythonSupervisorConfig {
561                        execution_profile: PythonExecutionProfile::Brokered,
562                        ..
563                    })
564                ) {
565                let PythonRunnerSelection::Persistent(config) = &self.python_runner else {
566                    unreachable!("brokered execution is persistent")
567                };
568                describe_persistent_catalog(
569                    execution_path.clone(),
570                    interpreter.clone(),
571                    config.clone(),
572                )
573                .await
574                .map_err(|error| FileProviderLoadError {
575                    path: execution_path.clone(),
576                    message: error.to_string(),
577                })?
578            } else {
579                let catalog_path = execution_path.clone();
580                let catalog_interpreter = interpreter.clone();
581                tokio::task::spawn_blocking(move || {
582                    load_catalog_with_interpreter(&catalog_path, &catalog_interpreter)
583                })
584                .await
585                .map_err(|error| FileProviderLoadError {
586                    path: path.clone(),
587                    message: format!("Python catalog task failed: {error}"),
588                })??
589            };
590            catalog.provider.source = Some(path.display().to_string());
591            if catalog.provider.enabled == Some(false) {
592                continue;
593            }
594            if matches!(
595                catalog.provider.kind,
596                ProviderKind::Python | ProviderKind::Langchain | ProviderKind::Llamaindex
597            ) {
598                let PythonRunnerSelection::Persistent(config) = &self.python_runner else {
599                    unreachable!("one-shot returned above")
600                };
601                let provider = PythonProvider::arc_persistent_in_generation(
602                    execution_path.clone(),
603                    catalog,
604                    PROVIDER_ENV_PREFIX,
605                    interpreter,
606                    config.clone(),
607                    worker_generation
608                        .clone()
609                        .expect("persistent Python source has an immutable generation"),
610                )
611                .map_err(|error| FileProviderLoadError {
612                    path: execution_path.clone(),
613                    message: error.to_string(),
614                })?;
615                if let Err(error) = provider.preflight().await {
616                    for started in &providers {
617                        started.retire().await;
618                    }
619                    soma_provider_core::Provider::retire(provider.as_ref()).await;
620                    return Err(FileProviderLoadError {
621                        path: execution_path,
622                        message: error.to_string(),
623                    });
624                }
625                let provider = SharedAdapter::wrap(provider);
626                providers.push(immutable.map_or(provider.clone(), |source| {
627                    retain_python_snapshot(provider, source.lease)
628                }));
629            } else {
630                let provider = provider_for_catalog(
631                    execution_path,
632                    catalog,
633                    interpreter,
634                    &self.python_runner,
635                )?;
636                providers.push(immutable.map_or(provider.clone(), |source| {
637                    retain_python_snapshot(provider, source.lease)
638                }));
639            }
640        }
641        for (absolute, relative, canonical_root) in self.resource_pairs_with_canonical_root()? {
642            let provider = ResourceFileProvider::arc(absolute.clone(), &relative, &canonical_root)
643                .map_err(|ResourceFileError(message)| FileProviderLoadError {
644                    path: absolute,
645                    message,
646                })?;
647            providers.push(provider);
648        }
649        Ok(providers)
650    }
651
652    /// Computes a stable SHA-256 fingerprint over all provider inputs, used to
653    /// detect changes to the provider directory.
654    pub fn fingerprint(&self) -> Result<String, FileProviderLoadError> {
655        self.fingerprint_with_python_environments(&PythonProviderEnvironmentSelections::new())
656    }
657
658    /// Fingerprints provider inputs plus active Python environment generations.
659    pub fn fingerprint_with_python_environments(
660        &self,
661        selections: &PythonProviderEnvironmentSelections,
662    ) -> Result<String, FileProviderLoadError> {
663        self.validate_python_environment_selections(selections)?;
664        let mut hasher = Sha256::new();
665        for path in self.fingerprint_paths()? {
666            fingerprint_file(&mut hasher, &self.root, &path)?;
667        }
668        for (path, candidate) in selections {
669            fingerprint_python_environment(&mut hasher, &self.root, path, candidate);
670        }
671        Ok(hasher
672            .finalize()
673            .iter()
674            .map(|byte| format!("{byte:02x}"))
675            .collect::<String>())
676    }
677
678    fn fingerprint_paths(&self) -> Result<Vec<PathBuf>, FileProviderLoadError> {
679        let provider_paths = self.provider_paths()?;
680        let mut paths = BTreeSet::new();
681        let mut has_python_provider = false;
682
683        for path in &provider_paths {
684            match path.extension().and_then(|extension| extension.to_str()) {
685                Some("wasm") => {
686                    let sidecar = filesystem_wasm::wasm_sidecar_manifest_path(path);
687                    if sidecar.is_file() {
688                        paths.insert(sidecar);
689                    } else {
690                        paths.insert(path.clone());
691                    }
692                }
693                Some("py") => {
694                    has_python_provider = true;
695                    paths.insert(path.clone());
696                }
697                _ => {
698                    paths.insert(path.clone());
699                }
700            }
701        }
702
703        if has_python_provider {
704            collect_python_dependency_paths(&self.root, &mut paths)?;
705        }
706
707        for (absolute, _relative) in self.resource_paths()? {
708            paths.insert(absolute);
709        }
710
711        Ok(paths.into_iter().collect())
712    }
713
714    /// Manifest-backed provider files (`.json`/`.ts`/`.wasm`/`.py`/`.md`),
715    /// from the provider root plus the structured `tools/` and `prompts/`
716    /// subdirectories, if present. Root-level files remain supported for
717    /// compatibility per the drop-in provider layout contract; new docs and
718    /// examples should prefer the structured layout. Neither subdirectory is
719    /// scanned recursively — same flat-directory semantics as root.
720    fn provider_paths(&self) -> Result<Vec<PathBuf>, FileProviderLoadError> {
721        if !self.root.exists() {
722            return Ok(Vec::new());
723        }
724        let mut paths = Vec::new();
725        collect_flat_files(&self.root, &mut paths, |path| {
726            is_provider_file(path) && !is_wasm_sidecar_manifest(path)
727        })?;
728        let tools_dir = self.root.join("tools");
729        if tools_dir.is_dir() {
730            collect_flat_files(&tools_dir, &mut paths, |path| {
731                is_tool_file(path) && !is_wasm_sidecar_manifest(path)
732            })?;
733        }
734        let prompts_dir = self.root.join("prompts");
735        if prompts_dir.is_dir() {
736            collect_flat_files(&prompts_dir, &mut paths, is_markdown_prompt_file)?;
737        }
738        paths.sort();
739        Ok(paths)
740    }
741
742    /// Files under the structured `resources/` subdirectory, recursively,
743    /// as `(absolute_path, path_relative_to_resources_dir)` pairs. See
744    /// `filesystem_resources::resource_paths` for the trust-boundary
745    /// enforcement this delegates to.
746    fn resource_paths(&self) -> Result<Vec<(PathBuf, PathBuf)>, FileProviderLoadError> {
747        filesystem_resources::resource_paths(&self.root)
748    }
749
750    /// `resource_paths()` triples with the canonicalized `resources/`
751    /// directory attached to each, so `ResourceFileProvider` can re-verify
752    /// containment at read time against the same root discovery validated,
753    /// closing the TOCTOU window between the two.
754    fn resource_pairs_with_canonical_root(
755        &self,
756    ) -> Result<Vec<(PathBuf, PathBuf, PathBuf)>, FileProviderLoadError> {
757        let pairs = self.resource_paths()?;
758        if pairs.is_empty() {
759            return Ok(Vec::new());
760        }
761        let canonical_root = filesystem_resources::canonical_resources_root(&self.root)?
762            .expect("non-empty resource_paths() implies the resources dir exists");
763        Ok(pairs
764            .into_iter()
765            .map(|(absolute, relative)| (absolute, relative, canonical_root.clone()))
766            .collect())
767    }
768}
769
770fn collect_flat_files(
771    dir: &Path,
772    paths: &mut Vec<PathBuf>,
773    accept: impl Fn(&Path) -> bool,
774) -> Result<(), FileProviderLoadError> {
775    let entries = fs::read_dir(dir).map_err(|source| FileProviderLoadError {
776        path: dir.to_path_buf(),
777        message: format!("failed to read provider directory: {source}"),
778    })?;
779    for entry in entries {
780        let entry = entry.map_err(|source| FileProviderLoadError {
781            path: dir.to_path_buf(),
782            message: format!("failed to read provider directory entry: {source}"),
783        })?;
784        let path = entry.path();
785        if path.is_file() && accept(&path) {
786            paths.push(path);
787        }
788    }
789    Ok(())
790}
791
792/// Error raised while reading, parsing, or building a file-backed provider.
793#[derive(Debug)]
794pub struct FileProviderLoadError {
795    /// Path to the file or directory that caused the error.
796    pub path: PathBuf,
797    /// Human-readable description of the failure.
798    pub message: String,
799}
800
801impl std::fmt::Display for FileProviderLoadError {
802    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
803        write!(f, "{}: {}", self.path.display(), self.message)
804    }
805}
806
807impl std::error::Error for FileProviderLoadError {}
808
809/// Builds the concrete provider for `catalog`'s declared kind. Every kind's
810/// actual implementation lives in the product-neutral `soma-provider-adapters`
811/// crate (feature-gated per kind); this just dispatches to it and wraps the
812/// result to satisfy this crate's own `Provider` trait — see
813/// `provider_registry::SharedAdapter` and the PR10 deviation notes on why
814/// this dispatch step, and the directory-scanning/Soma-policy orchestration
815/// around it, stayed in this crate rather than moving wholesale.
816///
817/// This crate currently enables every `soma-provider-adapters` kind feature
818/// (see its `Cargo.toml`), so `manifest_file::build_provider` returning
819/// `None` — meaning this binary was built without the feature that owns
820/// `catalog`'s kind — should never happen today. It is nonetheless treated
821/// as an ordinary, per-manifest `FileProviderLoadError` rather than a
822/// process-crashing `unreachable!()`: that invariant depends on two files (a
823/// `Cargo.toml` feature list and this crate's `ProviderKind` coverage)
824/// staying in sync by convention, with nothing enforcing it at compile time.
825/// If they ever drift, one misconfigured/unsupported provider manifest
826/// should fail to load, not take down every other already-working provider.
827fn provider_for_catalog(
828    path: PathBuf,
829    catalog: ProviderCatalog,
830    interpreter: PythonInterpreter,
831    runner: &PythonRunnerSelection,
832) -> Result<std::sync::Arc<dyn Provider>, FileProviderLoadError> {
833    let kind = catalog.provider.kind;
834    if matches!(
835        kind,
836        ProviderKind::Python | ProviderKind::Langchain | ProviderKind::Llamaindex
837    ) {
838        let provider = match runner {
839            PythonRunnerSelection::OneShot => PythonProvider::arc_with_interpreter(
840                path,
841                catalog,
842                PROVIDER_ENV_PREFIX,
843                interpreter,
844            ),
845            PythonRunnerSelection::Persistent(config) => PythonProvider::arc_persistent(
846                path.clone(),
847                catalog,
848                PROVIDER_ENV_PREFIX,
849                interpreter,
850                config.clone(),
851            )
852            .map_err(|error| FileProviderLoadError {
853                path,
854                message: error.to_string(),
855            })?,
856        };
857        return Ok(SharedAdapter::wrap(provider));
858    }
859    manifest_file::build_provider(path.clone(), catalog, PROVIDER_ENV_PREFIX)
860        .map(SharedAdapter::wrap)
861        .ok_or_else(|| FileProviderLoadError {
862            path,
863            message: format!(
864                "provider kind `{}` is not enabled in this build (soma-provider-adapters feature missing)",
865                kind.as_str()
866            ),
867        })
868}
869
870fn is_provider_file(path: &Path) -> bool {
871    match path.extension().and_then(|extension| extension.to_str()) {
872        Some("json" | "ts" | "wasm" | "py") => true,
873        Some("md") => is_markdown_prompt_file(path),
874        _ => false,
875    }
876}
877
878/// The structured `providers/tools/` directory only owns action-like files —
879/// no `.md`, which belongs to `providers/prompts/` instead.
880fn is_tool_file(path: &Path) -> bool {
881    matches!(
882        path.extension().and_then(|extension| extension.to_str()),
883        Some("json" | "ts" | "wasm" | "py")
884    )
885}
886
887/// A `.md` file is a prompt provider unless it's the directory's own README —
888/// `examples/providers/README.md` documents the directory, it isn't a prompt.
889fn is_markdown_prompt_file(path: &Path) -> bool {
890    path.file_stem()
891        .and_then(|stem| stem.to_str())
892        .map(|stem| !stem.eq_ignore_ascii_case("readme"))
893        .unwrap_or(false)
894}
895
896fn is_wasm_sidecar_manifest(path: &Path) -> bool {
897    path.file_name()
898        .and_then(|name| name.to_str())
899        .is_some_and(|name| name.ends_with(".wasm.json"))
900}
901
902/// Mirrors the schema-compilation pass `provider_registry::build_snapshot()`
903/// runs for every tool — a manifest can deserialize and pass
904/// `validate_provider_manifest()` while still carrying an `input_schema` or
905/// `output_schema` that fails to compile as JSON Schema (e.g. `properties`
906/// given as an array instead of an object). Non-executing inspection must
907/// catch that too, or `lint` can bless a provider the live registry rejects.
908fn compile_tool_schemas(catalog: &ProviderCatalog) -> Result<(), String> {
909    for tool in &catalog.tools {
910        jsonschema::validator_for(&tool.input_schema)
911            .map_err(|error| format!("tool `{}` has invalid input_schema: {error}", tool.name))?;
912        if let Some(output_schema) = &tool.output_schema {
913            jsonschema::validator_for(output_schema).map_err(|error| {
914                format!("tool `{}` has invalid output_schema: {error}", tool.name)
915            })?;
916        }
917    }
918    Ok(())
919}
920
921fn fingerprint_file(
922    hasher: &mut Sha256,
923    root: &Path,
924    path: &Path,
925) -> Result<(), FileProviderLoadError> {
926    let bytes = fs::read(path).map_err(|source| FileProviderLoadError {
927        path: path.to_path_buf(),
928        message: format!("failed to read provider file for fingerprint: {source}"),
929    })?;
930    let label = path
931        .strip_prefix(root)
932        .unwrap_or(path)
933        .display()
934        .to_string();
935    hasher.update(label.as_bytes());
936    hasher.update([0]);
937    hasher.update(bytes.len().to_le_bytes());
938    hasher.update([0]);
939    hasher.update(bytes);
940    hasher.update([0xff]);
941    Ok(())
942}
943
944fn load_catalog(path: &Path) -> Result<ProviderCatalog, FileProviderLoadError> {
945    load_catalog_with_interpreter(path, &PythonInterpreter::Ambient)
946}
947
948fn load_catalog_with_interpreter(
949    path: &Path,
950    interpreter: &PythonInterpreter,
951) -> Result<ProviderCatalog, FileProviderLoadError> {
952    let extension = path.extension().and_then(|extension| extension.to_str());
953    let catalog = match extension {
954        Some("json") | Some("ts") | Some("wasm") | Some("md") => {
955            let value = load_catalog_value(path)?;
956            serde_json::from_value(value).map_err(|source| FileProviderLoadError {
957                path: path.to_path_buf(),
958                message: format!("invalid provider manifest JSON: {source}"),
959            })?
960        }
961        Some("py") => {
962            // Only generic (soma-provider-core) manifest validation runs
963            // here — Soma's own CLI reserved-command / env-prefix policy is
964            // enforced uniformly for every provider kind by
965            // `provider_registry::build_registry`'s
966            // `validate_provider_manifest(&provider.catalog())` call, so
967            // this does not skip that policy, just defers it to the same
968            // place every other kind already goes through.
969            soma_provider_adapters::python::load_python_catalog_with_interpreter(
970                path,
971                PROVIDER_ENV_PREFIX,
972                interpreter,
973            )
974            .map_err(|source| FileProviderLoadError {
975                path: path.to_path_buf(),
976                message: format!("invalid Python provider: {source}"),
977            })?
978        }
979        _ => {
980            return Err(FileProviderLoadError {
981                path: path.to_path_buf(),
982                message: "unsupported provider file extension".to_owned(),
983            });
984        }
985    };
986    ensure_kind_matches(path, &catalog)?;
987    Ok(catalog)
988}
989
990/// Parses a JSON/TS/WASM-sidecar provider file to a raw `Value`, one step
991/// short of `load_catalog`'s typed `ProviderCatalog`. Used by non-executing
992/// inspection to validate against `provider-manifest.schema.json` (schema-only
993/// constraints like `rest.path`'s pattern) before that information is lost to
994/// `#[serde(default)]` fields round-tripping through `Option::None` as JSON
995/// `null`, which the schema — correctly — does not accept in place of an
996/// absent key.
997fn load_catalog_value(path: &Path) -> Result<Value, FileProviderLoadError> {
998    let extension = path.extension().and_then(|extension| extension.to_str());
999    match extension {
1000        Some("json") => {
1001            serde_json::from_slice(&fs::read(path).map_err(|source| FileProviderLoadError {
1002                path: path.to_path_buf(),
1003                message: format!("failed to read provider manifest: {source}"),
1004            })?)
1005            .map_err(|source| FileProviderLoadError {
1006                path: path.to_path_buf(),
1007                message: format!("invalid provider manifest JSON: {source}"),
1008            })
1009        }
1010        Some("ts") => load_ts_catalog_value(path),
1011        Some("wasm") => filesystem_wasm::load_wasm_catalog_value(path),
1012        Some("md") => filesystem_prompts::load_markdown_catalog_value(path),
1013        _ => Err(FileProviderLoadError {
1014            path: path.to_path_buf(),
1015            message: "unsupported provider file extension".to_owned(),
1016        }),
1017    }
1018}
1019
1020fn load_ts_catalog_value(path: &Path) -> Result<Value, FileProviderLoadError> {
1021    let text = fs::read_to_string(path).map_err(|source| FileProviderLoadError {
1022        path: path.to_path_buf(),
1023        message: format!("failed to read TypeScript provider: {source}"),
1024    })?;
1025    let json_text = extract_ts_manifest(&text).ok_or_else(|| FileProviderLoadError {
1026        path: path.to_path_buf(),
1027        message: "TypeScript provider must contain `export default { ... }` manifest JSON"
1028            .to_owned(),
1029    })?;
1030    serde_json::from_str(json_text).map_err(|source| FileProviderLoadError {
1031        path: path.to_path_buf(),
1032        message: format!("invalid TypeScript provider manifest JSON: {source}"),
1033    })
1034}
1035
1036fn extract_ts_manifest(text: &str) -> Option<&str> {
1037    let marker = "export default";
1038    let start = text.find(marker)? + marker.len();
1039    let rest = text[start..].trim_start();
1040    let open = rest.find('{')?;
1041    let mut depth = 0usize;
1042    let mut in_string = false;
1043    let mut escaped = false;
1044    for (offset, ch) in rest[open..].char_indices() {
1045        if in_string {
1046            if escaped {
1047                escaped = false;
1048            } else if ch == '\\' {
1049                escaped = true;
1050            } else if ch == '"' {
1051                in_string = false;
1052            }
1053            continue;
1054        }
1055        match ch {
1056            '"' => in_string = true,
1057            '{' => depth += 1,
1058            '}' => {
1059                depth = depth.checked_sub(1)?;
1060                if depth == 0 {
1061                    let end = open + offset + ch.len_utf8();
1062                    return Some(rest[..end].trim());
1063                }
1064            }
1065            _ => {}
1066        }
1067    }
1068    None
1069}
1070
1071fn ensure_kind_matches(
1072    path: &Path,
1073    catalog: &ProviderCatalog,
1074) -> Result<(), FileProviderLoadError> {
1075    let extension = path.extension().and_then(|extension| extension.to_str());
1076    let required_extension = required_extension_for_kind(catalog.provider.kind);
1077    if required_extension.is_some_and(|expected| extension != Some(expected)) {
1078        return Err(FileProviderLoadError {
1079            path: path.to_path_buf(),
1080            message: format!(
1081                "provider kind `{}` requires a .{} file",
1082                catalog.provider.kind.as_str(),
1083                required_extension_for_kind(catalog.provider.kind).unwrap()
1084            ),
1085        });
1086    }
1087
1088    // No `Some("md")` arm: `load_markdown_catalog_value` unconditionally
1089    // hardcodes `"kind": "static-rust"`, so a mismatch can't currently occur.
1090    // `.md` catalogs fall through to `_ => {}` like any other `static-rust`
1091    // manifest (`required_extension_for_kind` returns `None` for it too).
1092    match extension {
1093        Some("ts") if catalog.provider.kind != ProviderKind::AiSdk => {
1094            return Err(FileProviderLoadError {
1095                path: path.to_path_buf(),
1096                message: format!(
1097                    "provider kind `{}` does not match TypeScript provider extension",
1098                    catalog.provider.kind.as_str()
1099                ),
1100            });
1101        }
1102        Some("wasm") if catalog.provider.kind != ProviderKind::Wasm => {
1103            return Err(FileProviderLoadError {
1104                path: path.to_path_buf(),
1105                message: format!(
1106                    "provider kind `{}` does not match WASM provider extension",
1107                    catalog.provider.kind.as_str()
1108                ),
1109            });
1110        }
1111        Some("py")
1112            if !matches!(
1113                catalog.provider.kind,
1114                ProviderKind::Python | ProviderKind::Langchain | ProviderKind::Llamaindex
1115            ) =>
1116        {
1117            return Err(FileProviderLoadError {
1118                path: path.to_path_buf(),
1119                message: format!(
1120                    "provider kind `{}` does not match Python provider extension",
1121                    catalog.provider.kind.as_str()
1122                ),
1123            });
1124        }
1125        _ => {}
1126    }
1127    Ok(())
1128}
1129
1130fn required_extension_for_kind(kind: ProviderKind) -> Option<&'static str> {
1131    match kind {
1132        ProviderKind::AiSdk => Some("ts"),
1133        ProviderKind::Wasm => Some("wasm"),
1134        ProviderKind::Python | ProviderKind::Langchain | ProviderKind::Llamaindex => Some("py"),
1135        ProviderKind::StaticRust | ProviderKind::Openapi | ProviderKind::Mcp => None,
1136    }
1137}
1138
1139#[cfg(test)]
1140#[path = "filesystem_tests.rs"]
1141mod tests;