1use std::collections::HashMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use walkdir::WalkDir;
13
14use apcore::module::{ModuleAnnotations, ModuleExample};
15use serde_json::Value;
16use thiserror::Error;
17use tracing::warn;
18
19use crate::types::ScannedModule;
20
21const SUPPORTED_SPEC_VERSIONS: &[&str] = &["1.0"];
22
23const MAX_BINDING_FILE_SIZE: u64 = 16 * 1024 * 1024;
30
31const MAX_BINDING_FILES_PER_DIR: usize = 10_000;
36
37#[derive(Debug, Error)]
43pub enum BindingLoadError {
44 #[error("path does not exist: {path}")]
46 PathNotFound { path: String },
47
48 #[error("binding file {path} is too large ({size} bytes > {max} byte limit)")]
50 FileTooLarge { path: String, size: u64, max: u64 },
51
52 #[error("directory {path} contains more than {max} binding files")]
54 TooManyFiles { path: String, max: usize },
55
56 #[error("failed to read {path}: {source}")]
58 FileRead {
59 path: String,
60 #[source]
61 source: std::io::Error,
62 },
63
64 #[error("failed to parse YAML in {path}: {source}")]
66 YamlParse {
67 path: String,
68 #[source]
69 source: serde_yaml_ng::Error,
70 },
71
72 #[error("missing or invalid required fields {missing_fields:?} (file={}, module_id={})",
78 .path.as_deref().unwrap_or("<inline>"),
79 .module_id.as_deref().unwrap_or("<unknown>"))]
80 MissingFields {
81 path: Option<String>,
82 module_id: Option<String>,
83 missing_fields: Vec<String>,
84 },
85
86 #[error("invalid binding structure in {}: {reason}", .path.as_deref().unwrap_or("<inline>"))]
89 InvalidStructure {
90 path: Option<String>,
91 reason: String,
92 },
93}
94
95#[derive(Debug, Default)]
111pub struct BindingLoader;
112
113impl BindingLoader {
114 pub fn new() -> Self {
116 Self
117 }
118
119 pub fn load(
124 &self,
125 path: &Path,
126 strict: bool,
127 recursive: bool,
128 ) -> Result<Vec<ScannedModule>, BindingLoadError> {
129 let files: Vec<PathBuf> = if path.is_file() {
130 vec![path.to_path_buf()]
131 } else if path.is_dir() {
132 let mut entries: Vec<PathBuf> = if recursive {
133 let mut flat: Vec<PathBuf> = Vec::new();
139 for entry_result in WalkDir::new(path) {
140 let entry = entry_result.map_err(|e| {
141 let io_err = e
142 .into_io_error()
143 .unwrap_or_else(|| std::io::Error::other("walkdir traversal error"));
144 BindingLoadError::FileRead {
145 path: path.display().to_string(),
146 source: io_err,
147 }
148 })?;
149 if entry.file_type().is_file()
150 && entry
151 .file_name()
152 .to_string_lossy()
153 .ends_with(".binding.yaml")
154 {
155 flat.push(entry.into_path());
156 }
157 }
158 flat
159 } else {
160 let read_dir = fs::read_dir(path).map_err(|e| BindingLoadError::FileRead {
161 path: path.display().to_string(),
162 source: e,
163 })?;
164 let mut flat: Vec<PathBuf> = Vec::new();
165 for entry_result in read_dir {
166 match entry_result {
167 Ok(entry) => {
168 let p = entry.path();
169 let is_binding = p
170 .file_name()
171 .and_then(|n| n.to_str())
172 .is_some_and(|n| n.ends_with(".binding.yaml"));
173 if is_binding {
174 flat.push(p);
175 }
176 }
177 Err(e) => {
178 return Err(BindingLoadError::FileRead {
182 path: path.display().to_string(),
183 source: e,
184 });
185 }
186 }
187 }
188 flat
189 };
190 entries.sort();
191 entries
192 } else {
193 return Err(BindingLoadError::PathNotFound {
194 path: path.display().to_string(),
195 });
196 };
197
198 if files.len() > MAX_BINDING_FILES_PER_DIR {
199 return Err(BindingLoadError::TooManyFiles {
200 path: path.display().to_string(),
201 max: MAX_BINDING_FILES_PER_DIR,
202 });
203 }
204
205 let mut modules: Vec<ScannedModule> = Vec::new();
206 for f in files {
207 let file_size = fs::metadata(&f)
208 .map_err(|e| BindingLoadError::FileRead {
209 path: f.display().to_string(),
210 source: e,
211 })?
212 .len();
213 if file_size > MAX_BINDING_FILE_SIZE {
214 return Err(BindingLoadError::FileTooLarge {
215 path: f.display().to_string(),
216 size: file_size,
217 max: MAX_BINDING_FILE_SIZE,
218 });
219 }
220 let content = fs::read_to_string(&f).map_err(|e| BindingLoadError::FileRead {
221 path: f.display().to_string(),
222 source: e,
223 })?;
224 let raw: serde_yaml_ng::Value =
225 serde_yaml_ng::from_str(&content).map_err(|e| BindingLoadError::YamlParse {
226 path: f.display().to_string(),
227 source: e,
228 })?;
229 if raw.is_null() {
230 warn!("BindingLoader: {} is empty, skipping", f.display());
231 continue;
232 }
233 let json_value =
234 serde_json::to_value(raw).map_err(|e| BindingLoadError::InvalidStructure {
235 path: Some(f.display().to_string()),
236 reason: format!("YAML → JSON conversion failed: {e}"),
237 })?;
238 modules.extend(self.parse_document(
239 &json_value,
240 Some(&f.display().to_string()),
241 strict,
242 )?);
243 }
244 Ok(modules)
245 }
246
247 pub fn load_data(
249 &self,
250 data: &Value,
251 strict: bool,
252 ) -> Result<Vec<ScannedModule>, BindingLoadError> {
253 self.parse_document(data, None, strict)
254 }
255
256 fn parse_document(
261 &self,
262 raw: &Value,
263 file_path: Option<&str>,
264 strict: bool,
265 ) -> Result<Vec<ScannedModule>, BindingLoadError> {
266 let obj = raw
267 .as_object()
268 .ok_or_else(|| BindingLoadError::InvalidStructure {
269 path: file_path.map(String::from),
270 reason: "top-level binding document must be a mapping".into(),
271 })?;
272
273 Self::check_spec_version(obj.get("spec_version"), file_path);
274
275 let bindings = obj
276 .get("bindings")
277 .and_then(|v| v.as_array())
278 .ok_or_else(|| BindingLoadError::InvalidStructure {
279 path: file_path.map(String::from),
280 reason: "'bindings' key missing or not a list".into(),
281 })?;
282
283 let mut modules: Vec<ScannedModule> = Vec::with_capacity(bindings.len());
284 for entry in bindings {
285 let entry_obj =
286 entry
287 .as_object()
288 .ok_or_else(|| BindingLoadError::InvalidStructure {
289 path: file_path.map(String::from),
290 reason: "binding entry must be a mapping".into(),
291 })?;
292 modules.push(Self::parse_entry(entry_obj, file_path, strict)?);
293 }
294 Ok(modules)
295 }
296
297 fn check_spec_version(spec_version: Option<&Value>, file_path: Option<&str>) {
298 let where_str = file_path.unwrap_or("<inline>");
299 match spec_version {
300 None | Some(Value::Null) => {
301 warn!(
302 "BindingLoader: {} missing 'spec_version'; defaulting to '1.0'.",
303 where_str
304 );
305 }
306 Some(v) => {
307 let as_str = v.as_str();
308 if !as_str.is_some_and(|s| SUPPORTED_SPEC_VERSIONS.contains(&s)) {
309 warn!(
310 "BindingLoader: {} has spec_version={} newer than supported {:?}; proceeding best-effort.",
311 where_str, v, SUPPORTED_SPEC_VERSIONS
312 );
313 }
314 }
315 }
316 }
317
318 fn parse_entry(
319 entry: &serde_json::Map<String, Value>,
320 file_path: Option<&str>,
321 strict: bool,
322 ) -> Result<ScannedModule, BindingLoadError> {
323 let required: &[&str] = if strict {
324 &["module_id", "target", "input_schema", "output_schema"]
325 } else {
326 &["module_id", "target"]
327 };
328
329 let missing: Vec<String> = required
334 .iter()
335 .filter(|f| match entry.get(**f) {
336 None | Some(Value::Null) => true,
337 Some(v) => match **f {
338 "input_schema" | "output_schema" => !v.is_object(),
340 _ => v.as_str().is_none_or(|s| s.is_empty()),
342 },
343 })
344 .map(|f| (*f).to_string())
345 .collect();
346 if !missing.is_empty() {
347 return Err(BindingLoadError::MissingFields {
348 path: file_path.map(String::from),
349 module_id: entry
350 .get("module_id")
351 .and_then(|v| v.as_str())
352 .map(String::from),
353 missing_fields: missing,
354 });
355 }
356
357 let module_id = entry
358 .get("module_id")
359 .and_then(|v| v.as_str())
360 .unwrap_or_default()
361 .to_string();
362
363 let target = entry
364 .get("target")
365 .and_then(|v| v.as_str())
366 .unwrap_or_default()
367 .to_string();
368
369 let description = entry
370 .get("description")
371 .and_then(|v| v.as_str())
372 .unwrap_or("")
373 .to_string();
374
375 let version = entry
376 .get("version")
377 .and_then(|v| v.as_str())
378 .unwrap_or("1.0.0")
379 .to_string();
380
381 let documentation = entry
382 .get("documentation")
383 .and_then(|v| v.as_str())
384 .map(String::from);
385
386 let suggested_alias = entry
387 .get("suggested_alias")
388 .and_then(|v| v.as_str())
389 .map(String::from);
390
391 let input_schema = entry
392 .get("input_schema")
393 .filter(|v| !v.is_null())
394 .cloned()
395 .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
396
397 let output_schema = entry
398 .get("output_schema")
399 .filter(|v| !v.is_null())
400 .cloned()
401 .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
402
403 let tags: Vec<String> = entry
404 .get("tags")
405 .and_then(|v| v.as_array())
406 .map(|arr| {
407 arr.iter()
408 .filter_map(|v| v.as_str().map(String::from))
409 .collect()
410 })
411 .unwrap_or_default();
412
413 let warnings: Vec<String> = entry
414 .get("warnings")
415 .and_then(|v| v.as_array())
416 .map(|arr| {
417 arr.iter()
418 .filter_map(|v| v.as_str().map(String::from))
419 .collect()
420 })
421 .unwrap_or_default();
422
423 let metadata: HashMap<String, Value> = entry
424 .get("metadata")
425 .and_then(|v| v.as_object())
426 .map(|o| o.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
427 .unwrap_or_default();
428
429 let display = Self::parse_display(entry.get("display"), &module_id);
430
431 let annotations = Self::parse_annotations(entry.get("annotations"), &module_id);
432 let examples = Self::parse_examples(entry.get("examples"), &module_id);
433
434 Ok(ScannedModule {
435 module_id,
436 description,
437 input_schema,
438 output_schema,
439 tags,
440 target,
441 version,
442 annotations,
443 documentation,
444 suggested_alias,
445 examples,
446 metadata,
447 display,
448 warnings,
449 })
450 }
451
452 fn parse_display(value: Option<&Value>, module_id: &str) -> Option<Value> {
453 let v = value?;
454 if v.is_null() {
455 return None;
456 }
457 if !v.is_object() {
458 warn!(
459 "BindingLoader: display for module {} is not an object; ignoring",
460 module_id
461 );
462 return None;
463 }
464 Some(v.clone())
465 }
466
467 fn parse_annotations(value: Option<&Value>, module_id: &str) -> Option<ModuleAnnotations> {
468 let v = value?;
469 if v.is_null() {
470 return None;
471 }
472 if !v.is_object() {
473 warn!(
474 "BindingLoader: annotations for module {} is not a dict; treating as None",
475 module_id
476 );
477 return None;
478 }
479 match serde_json::from_value::<ModuleAnnotations>(v.clone()) {
480 Ok(ann) => Some(ann),
481 Err(e) => {
482 warn!(
483 "BindingLoader: failed to parse annotations for module {}: {}; treating as None",
484 module_id, e
485 );
486 None
487 }
488 }
489 }
490
491 fn parse_examples(value: Option<&Value>, module_id: &str) -> Vec<ModuleExample> {
492 let Some(v) = value else {
493 return Vec::new();
494 };
495 if v.is_null() {
496 return Vec::new();
497 }
498 let Some(arr) = v.as_array() else {
499 warn!(
500 "BindingLoader: examples for module {} is not a list; ignoring",
501 module_id
502 );
503 return Vec::new();
504 };
505 let mut result = Vec::with_capacity(arr.len());
506 for (i, ex) in arr.iter().enumerate() {
507 if !ex.is_object() {
508 warn!(
509 "BindingLoader: examples[{}] of module {} is not a dict; ignoring",
510 i, module_id
511 );
512 continue;
513 }
514 match serde_json::from_value::<ModuleExample>(ex.clone()) {
515 Ok(parsed) => result.push(parsed),
516 Err(e) => warn!(
517 "BindingLoader: examples[{}] of module {} malformed: {}; ignoring",
518 i, module_id, e
519 ),
520 }
521 }
522 result
523 }
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529 use serde_json::json;
530 use std::fs;
531 use tempfile::TempDir;
532
533 fn minimal_entry() -> Value {
534 json!({"module_id": "x.y", "target": "pkg:func"})
535 }
536
537 fn full_entry() -> Value {
538 json!({
539 "module_id": "users.get_user",
540 "target": "myapp.views:get_user",
541 "description": "Get a user",
542 "documentation": "Returns a user by ID.",
543 "tags": ["users", "get"],
544 "version": "2.0.0",
545 "annotations": {"readonly": true, "cacheable": true, "cache_ttl": 60},
546 "examples": [
547 {"title": "happy", "inputs": {"id": 1}, "output": {"name": "alice"}}
548 ],
549 "metadata": {"http_method": "GET"},
550 "input_schema": {"type": "object"},
551 "output_schema": {"type": "object"},
552 "display": {"mcp": {"alias": "users_get"}, "alias": "users.get"},
553 "suggested_alias": "users.get.alt",
554 "warnings": ["stale"]
555 })
556 }
557
558 #[test]
559 fn test_loose_minimum_entry() {
560 let loader = BindingLoader::new();
561 let modules = loader
562 .load_data(&json!({"bindings": [minimal_entry()]}), false)
563 .unwrap();
564 assert_eq!(modules.len(), 1);
565 let m = &modules[0];
566 assert_eq!(m.module_id, "x.y");
567 assert_eq!(m.target, "pkg:func");
568 assert_eq!(m.description, "");
569 assert_eq!(m.version, "1.0.0");
570 assert!(m.annotations.is_none());
571 assert!(m.display.is_none());
572 assert!(m.tags.is_empty());
573 assert_eq!(m.input_schema, json!({}));
574 assert_eq!(m.output_schema, json!({}));
575 }
576
577 #[test]
578 fn test_strict_requires_input_schema() {
579 let loader = BindingLoader::new();
580 let err = loader
581 .load_data(&json!({"bindings": [minimal_entry()]}), true)
582 .unwrap_err();
583 match err {
584 BindingLoadError::MissingFields {
585 missing_fields,
586 module_id,
587 ..
588 } => {
589 assert!(missing_fields.contains(&"input_schema".to_string()));
590 assert!(missing_fields.contains(&"output_schema".to_string()));
591 assert_eq!(module_id.as_deref(), Some("x.y"));
592 }
593 _ => panic!("expected MissingFields, got {err:?}"),
594 }
595 }
596
597 #[test]
598 fn test_strict_accepts_when_schemas_present() {
599 let loader = BindingLoader::new();
600 let entry = json!({
601 "module_id": "x.y",
602 "target": "pkg:func",
603 "input_schema": {"type": "object"},
604 "output_schema": {"type": "object"}
605 });
606 let modules = loader
607 .load_data(&json!({"bindings": [entry]}), true)
608 .unwrap();
609 assert_eq!(modules.len(), 1);
610 }
611
612 #[test]
613 fn test_missing_module_id_always_fails() {
614 let loader = BindingLoader::new();
615 let err = loader
616 .load_data(&json!({"bindings": [{"target": "p:f"}]}), false)
617 .unwrap_err();
618 assert!(matches!(
619 err,
620 BindingLoadError::MissingFields { ref missing_fields, .. }
621 if missing_fields.contains(&"module_id".to_string())
622 ));
623 }
624
625 #[test]
626 fn test_missing_target_always_fails() {
627 let loader = BindingLoader::new();
628 let err = loader
629 .load_data(&json!({"bindings": [{"module_id": "x"}]}), false)
630 .unwrap_err();
631 assert!(matches!(
632 err,
633 BindingLoadError::MissingFields { ref missing_fields, .. }
634 if missing_fields.contains(&"target".to_string())
635 ));
636 }
637
638 #[test]
639 fn test_missing_bindings_key() {
640 let loader = BindingLoader::new();
641 let err = loader
642 .load_data(&json!({"spec_version": "1.0"}), false)
643 .unwrap_err();
644 assert!(matches!(
645 err,
646 BindingLoadError::InvalidStructure { ref reason, .. } if reason.contains("bindings")
647 ));
648 }
649
650 #[test]
651 fn test_top_level_not_mapping() {
652 let loader = BindingLoader::new();
653 let err = loader.load_data(&json!(["a", "b"]), false).unwrap_err();
654 assert!(matches!(
655 err,
656 BindingLoadError::InvalidStructure { ref reason, .. } if reason.contains("mapping")
657 ));
658 }
659
660 #[test]
661 fn test_entry_not_a_mapping() {
662 let loader = BindingLoader::new();
663 let err = loader
664 .load_data(&json!({"bindings": ["scalar"]}), false)
665 .unwrap_err();
666 assert!(matches!(
667 err,
668 BindingLoadError::InvalidStructure { ref reason, .. } if reason.contains("mapping")
669 ));
670 }
671
672 #[test]
673 fn test_annotations_parsed() {
674 let loader = BindingLoader::new();
675 let m = &loader
676 .load_data(&json!({"bindings": [full_entry()]}), false)
677 .unwrap()[0];
678 let ann = m.annotations.as_ref().expect("annotations should parse");
679 assert!(ann.readonly);
680 assert!(ann.cacheable);
681 assert_eq!(ann.cache_ttl, 60);
682 }
683
684 #[test]
685 fn test_annotations_wrong_type_treated_as_none() {
686 let loader = BindingLoader::new();
687 let m = &loader
688 .load_data(
689 &json!({"bindings": [{"module_id": "x", "target": "p:f", "annotations": "readonly"}]}),
690 false,
691 )
692 .unwrap()[0];
693 assert!(m.annotations.is_none());
694 }
695
696 #[test]
697 fn test_missing_fields_error_message_is_readable() {
698 let loader = BindingLoader::new();
699 let err = loader
700 .load_data(&json!({"bindings": [{"module_id": "x"}]}), false)
701 .unwrap_err();
702 let msg = err.to_string();
703 assert!(!msg.contains("Some("), "got: {msg}");
705 assert!(!msg.contains("None"), "got: {msg}");
706 assert!(msg.contains("x"), "module_id missing from message: {msg}");
707 assert!(msg.contains("target"), "missing field not listed: {msg}");
708 }
709
710 #[test]
711 fn test_display_wrong_type_dropped() {
712 let loader = BindingLoader::new();
715 let m = &loader
716 .load_data(
717 &json!({"bindings": [{"module_id": "x", "target": "p:f", "display": "not-a-dict"}]}),
718 false,
719 )
720 .unwrap()[0];
721 assert!(m.display.is_none());
722 }
723
724 #[test]
725 fn test_display_null_dropped() {
726 let loader = BindingLoader::new();
727 let m = &loader
728 .load_data(
729 &json!({"bindings": [{"module_id": "x", "target": "p:f", "display": null}]}),
730 false,
731 )
732 .unwrap()[0];
733 assert!(m.display.is_none());
734 }
735
736 #[test]
737 fn test_display_preserved() {
738 let loader = BindingLoader::new();
739 let m = &loader
740 .load_data(&json!({"bindings": [full_entry()]}), false)
741 .unwrap()[0];
742 assert_eq!(
743 m.display.as_ref().unwrap(),
744 &json!({"mcp": {"alias": "users_get"}, "alias": "users.get"})
745 );
746 }
747
748 #[test]
749 fn test_examples_parsed() {
750 let loader = BindingLoader::new();
751 let m = &loader
752 .load_data(&json!({"bindings": [full_entry()]}), false)
753 .unwrap()[0];
754 assert_eq!(m.examples.len(), 1);
755 assert_eq!(m.examples[0].title, "happy");
756 }
757
758 #[test]
759 fn test_file_too_large_error_variant() {
760 let err = BindingLoadError::FileTooLarge {
765 path: "/bindings/huge.binding.yaml".to_string(),
766 size: MAX_BINDING_FILE_SIZE + 1,
767 max: MAX_BINDING_FILE_SIZE,
768 };
769 let msg = err.to_string();
770 assert!(
771 msg.contains("too large"),
772 "message should mention size: {msg}"
773 );
774 assert!(
775 msg.contains("huge.binding.yaml"),
776 "message should mention path: {msg}"
777 );
778 }
779
780 #[test]
781 fn test_load_single_file() {
782 let dir = TempDir::new().unwrap();
783 let file = dir.path().join("one.binding.yaml");
784 let doc = json!({"spec_version": "1.0", "bindings": [full_entry()]});
785 fs::write(&file, serde_yaml_ng::to_string(&doc).unwrap()).unwrap();
786 let modules = BindingLoader::new().load(&file, false, false).unwrap();
787 assert_eq!(modules.len(), 1);
788 assert_eq!(modules[0].module_id, "users.get_user");
789 }
790
791 #[test]
792 fn test_load_directory_sorted() {
793 let dir = TempDir::new().unwrap();
794 for (i, name) in ["a", "b", "c"].iter().enumerate() {
795 let f = dir.path().join(format!("{name}.binding.yaml"));
796 let doc = json!({
797 "spec_version": "1.0",
798 "bindings": [{"module_id": name, "target": format!("pkg:f{i}")}]
799 });
800 fs::write(&f, serde_yaml_ng::to_string(&doc).unwrap()).unwrap();
801 }
802 fs::write(dir.path().join("unrelated.yaml"), "irrelevant: true").unwrap();
803
804 let modules = BindingLoader::new().load(dir.path(), false, false).unwrap();
805 let ids: Vec<&str> = modules.iter().map(|m| m.module_id.as_str()).collect();
806 assert_eq!(ids, vec!["a", "b", "c"]);
807 }
808
809 #[test]
810 fn test_nonexistent_path() {
811 let dir = TempDir::new().unwrap();
812 let err = BindingLoader::new()
813 .load(&dir.path().join("nope"), false, false)
814 .unwrap_err();
815 assert!(matches!(err, BindingLoadError::PathNotFound { .. }));
816 }
817
818 #[test]
819 fn test_malformed_yaml() {
820 let dir = TempDir::new().unwrap();
821 let f = dir.path().join("bad.binding.yaml");
822 fs::write(&f, "::: not yaml :::\n - [").unwrap();
823 let err = BindingLoader::new().load(&f, false, false).unwrap_err();
824 assert!(matches!(err, BindingLoadError::YamlParse { .. }));
825 }
826
827 #[test]
828 fn test_empty_file_skipped() {
829 let dir = TempDir::new().unwrap();
830 let f = dir.path().join("empty.binding.yaml");
831 fs::write(&f, "").unwrap();
832 let modules = BindingLoader::new().load(&f, false, false).unwrap();
833 assert!(modules.is_empty());
834 }
835
836 #[test]
837 fn test_round_trip_with_yaml_writer() {
838 use crate::output::yaml_writer::YAMLWriter;
839
840 let mut original = ScannedModule::new(
841 "round.trip".into(),
842 "Round-trip test".into(),
843 json!({"type": "object", "properties": {"q": {"type": "string"}}}),
844 json!({"type": "object"}),
845 vec!["demo".into()],
846 "demo.app:handler".into(),
847 );
848 original.version = "1.2.3".into();
849 original.annotations = Some(ModuleAnnotations {
850 readonly: true,
851 streaming: true,
852 cache_ttl: 30,
853 ..Default::default()
854 });
855 original.documentation = Some("Docs here".into());
856 original.metadata.insert("http_method".into(), json!("GET"));
857 original.display = Some(json!({"mcp": {"alias": "rt"}, "alias": "round-trip"}));
858
859 let dir = TempDir::new().unwrap();
860 YAMLWriter
861 .write(
862 &[original.clone()],
863 dir.path().to_str().unwrap(),
864 false,
865 false,
866 None,
867 )
868 .unwrap();
869
870 let loaded = BindingLoader::new().load(dir.path(), false, false).unwrap();
871 assert_eq!(loaded.len(), 1);
872 let m = &loaded[0];
873 assert_eq!(m.module_id, original.module_id);
874 assert_eq!(m.target, original.target);
875 assert_eq!(m.description, original.description);
876 assert_eq!(m.documentation, original.documentation);
877 assert_eq!(m.tags, original.tags);
878 assert_eq!(m.version, original.version);
879 assert_eq!(m.input_schema, original.input_schema);
880 assert_eq!(m.output_schema, original.output_schema);
881 assert_eq!(m.metadata, original.metadata);
882 assert_eq!(m.display, original.display);
883 let ann = m.annotations.as_ref().unwrap();
884 assert!(ann.readonly);
885 assert!(ann.streaming);
886 assert_eq!(ann.cache_ttl, 30);
887 }
888
889 #[test]
892 fn test_wrong_type_module_id_integer_rejected() {
893 let loader = BindingLoader::new();
894 let err = loader
895 .load_data(
896 &json!({"bindings": [{"module_id": 42, "target": "p:f"}]}),
897 false,
898 )
899 .unwrap_err();
900 assert!(
901 matches!(
902 &err,
903 BindingLoadError::MissingFields { missing_fields, .. }
904 if missing_fields.iter().any(|f| f == "module_id")
905 ),
906 "got: {err:?}"
907 );
908 }
909
910 #[test]
911 fn test_wrong_type_target_bool_rejected() {
912 let loader = BindingLoader::new();
913 let err = loader
914 .load_data(
915 &json!({"bindings": [{"module_id": "x", "target": true}]}),
916 false,
917 )
918 .unwrap_err();
919 assert!(
920 matches!(
921 &err,
922 BindingLoadError::MissingFields { missing_fields, .. }
923 if missing_fields.iter().any(|f| f == "target")
924 ),
925 "got: {err:?}"
926 );
927 }
928
929 #[test]
930 fn test_empty_string_module_id_rejected() {
931 let loader = BindingLoader::new();
932 let err = loader
933 .load_data(
934 &json!({"bindings": [{"module_id": "", "target": "p:f"}]}),
935 false,
936 )
937 .unwrap_err();
938 assert!(
939 matches!(
940 &err,
941 BindingLoadError::MissingFields { missing_fields, .. }
942 if missing_fields.iter().any(|f| f == "module_id")
943 ),
944 "got: {err:?}"
945 );
946 }
947
948 #[test]
949 fn test_strict_wrong_type_input_schema_rejected() {
950 let loader = BindingLoader::new();
951 let err = loader
952 .load_data(
953 &json!({"bindings": [{
954 "module_id": "x",
955 "target": "p:f",
956 "input_schema": 42,
957 "output_schema": {"type": "object"}
958 }]}),
959 true,
960 )
961 .unwrap_err();
962 assert!(
963 matches!(
964 &err,
965 BindingLoadError::MissingFields { missing_fields, .. }
966 if missing_fields.iter().any(|f| f == "input_schema")
967 ),
968 "got: {err:?}"
969 );
970 }
971
972 #[test]
975 #[cfg(unix)]
976 fn test_recursive_load_surfaces_walkdir_errors() {
977 use std::os::unix::fs::PermissionsExt;
978
979 let is_root = libc_geteuid() == 0;
982 if is_root {
983 return;
984 }
985
986 let dir = TempDir::new().unwrap();
987 let unreadable = dir.path().join("unreadable");
988 fs::create_dir(&unreadable).unwrap();
989 fs::set_permissions(&unreadable, fs::Permissions::from_mode(0o000)).unwrap();
990
991 let result = BindingLoader::new().load(dir.path(), false, true);
992
993 fs::set_permissions(&unreadable, fs::Permissions::from_mode(0o755)).ok();
995
996 assert!(
997 matches!(result, Err(BindingLoadError::FileRead { .. })),
998 "recursive load should propagate per-entry I/O errors, got: {result:?}",
999 );
1000 }
1001
1002 #[cfg(unix)]
1003 fn libc_geteuid() -> u32 {
1004 extern "C" {
1006 fn geteuid() -> u32;
1007 }
1008 unsafe { geteuid() }
1011 }
1012
1013 #[test]
1014 fn test_load_recursive_finds_nested_files() {
1015 let dir = TempDir::new().unwrap();
1016 let subdir = dir.path().join("sub");
1017 fs::create_dir(&subdir).unwrap();
1018
1019 let doc_root = json!({"spec_version": "1.0", "bindings": [{"module_id": "root.mod", "target": "pkg:f0"}]});
1021 fs::write(
1022 dir.path().join("root.binding.yaml"),
1023 serde_yaml_ng::to_string(&doc_root).unwrap(),
1024 )
1025 .unwrap();
1026
1027 let doc_sub = json!({"spec_version": "1.0", "bindings": [{"module_id": "sub.mod", "target": "pkg:f1"}]});
1029 fs::write(
1030 subdir.join("sub.binding.yaml"),
1031 serde_yaml_ng::to_string(&doc_sub).unwrap(),
1032 )
1033 .unwrap();
1034
1035 let flat = BindingLoader::new().load(dir.path(), false, false).unwrap();
1037 let flat_ids: Vec<&str> = flat.iter().map(|m| m.module_id.as_str()).collect();
1038 assert_eq!(flat_ids, vec!["root.mod"]);
1039
1040 let recursive = BindingLoader::new().load(dir.path(), false, true).unwrap();
1042 let mut rec_ids: Vec<&str> = recursive.iter().map(|m| m.module_id.as_str()).collect();
1043 rec_ids.sort();
1044 assert_eq!(rec_ids, vec!["root.mod", "sub.mod"]);
1045 }
1046}