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
18const 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#[derive(Debug, Clone)]
35pub struct FileProviderSource {
36 root: PathBuf,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ProviderDirectoryInspection {
42 pub root: PathBuf,
44 pub exists: bool,
46 pub files: Vec<ProviderFileInspection>,
48 pub providers_loaded: usize,
50 pub providers_disabled: usize,
52 pub providers_invalid: usize,
54 pub providers_skipped: usize,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct ProviderFileInspection {
61 pub path: PathBuf,
63 pub file_name: String,
65 pub status: ProviderFileInspectionStatus,
67 pub provider_id: Option<String>,
69 pub provider_kind: Option<String>,
71 pub actions: Vec<String>,
73 pub error: Option<String>,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum ProviderFileInspectionStatus {
80 Loaded,
82 Disabled,
84 Invalid,
86 Skipped,
89}
90
91impl FileProviderSource {
92 pub fn new(root: impl Into<PathBuf>) -> Self {
94 Self { root: root.into() }
95 }
96
97 pub fn root(&self) -> &Path {
99 &self.root
100 }
101
102 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 let mut loaded_catalogs: Vec<Option<ProviderCatalog>> = Vec::new();
126 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 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 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 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 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 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 fn resource_paths(&self) -> Result<Vec<(PathBuf, PathBuf)>, FileProviderLoadError> {
380 filesystem_resources::resource_paths(&self.root)
381 }
382
383 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#[derive(Debug)]
491pub struct FileProviderLoadError {
492 pub path: PathBuf,
494 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
506fn 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
548fn 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
557fn 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
576fn 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 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
654fn 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 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;