Skip to main content

apcore_toolkit/
binding_loader.rs

1// BindingLoader — parse `.binding.yaml` files back into `ScannedModule`.
2//
3// Inverse of `output::yaml_writer::YAMLWriter`. Unlike apcore's own
4// `BindingLoader` (which imports the target and registers a runtime module),
5// this loader is pure data: it parses YAML into `ScannedModule` objects for
6// validation, merging, diffing, or round-trip workflows. No code is loaded.
7
8use 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
23/// Maximum size of a single `.binding.yaml` file (16 MiB).
24///
25/// A binding file is a structured YAML document, not a data store.
26/// Files larger than this are almost certainly pathological and would
27/// cause the full content to be loaded into memory twice (raw bytes +
28/// serde_yaml_ng::Value + serde_json::Value).
29const MAX_BINDING_FILE_SIZE: u64 = 16 * 1024 * 1024;
30
31/// Maximum number of `.binding.yaml` files loaded from a single directory.
32///
33/// Prevents a maliciously large directory from causing unbounded memory
34/// consumption in `load_data` callers that accumulate results.
35const MAX_BINDING_FILES_PER_DIR: usize = 10_000;
36
37// TODO(release-gate): deep-chain parity with Python/TypeScript BindingLoader — manual
38// cross-SDK review required before tagging 0.5.0. D11 audit was inconclusive due to
39// sub-agent file access limits. BindingLoader is the flagship 0.5.0 cross-SDK feature.
40
41/// Errors produced by [`BindingLoader`].
42#[derive(Debug, Error)]
43pub enum BindingLoadError {
44    /// The path does not exist or cannot be stat'd.
45    #[error("path does not exist: {path}")]
46    PathNotFound { path: String },
47
48    /// A binding file exceeds the maximum allowed size.
49    #[error("binding file {path} is too large ({size} bytes > {max} byte limit)")]
50    FileTooLarge { path: String, size: u64, max: u64 },
51
52    /// The directory contains more binding files than the per-directory limit.
53    #[error("directory {path} contains more than {max} binding files")]
54    TooManyFiles { path: String, max: usize },
55
56    /// Failure reading a binding file from disk.
57    #[error("failed to read {path}: {source}")]
58    FileRead {
59        path: String,
60        #[source]
61        source: std::io::Error,
62    },
63
64    /// The file content is not valid YAML.
65    #[error("failed to parse YAML in {path}: {source}")]
66    YamlParse {
67        path: String,
68        #[source]
69        source: serde_yaml_ng::Error,
70    },
71
72    /// A binding entry is missing or has an invalid value for one or more
73    /// required fields. Covers three cases: absent key, explicit `null`, and
74    /// wrong-type scalar (e.g. `module_id: 42`, `target: true`). All three
75    /// are treated as "required field not supplied" rather than silently
76    /// coerced to empty strings or zero values downstream.
77    #[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    /// The document structure is invalid (e.g. top-level is not a mapping,
87    /// or `bindings` is not a list).
88    #[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/// Loads `.binding.yaml` files into [`ScannedModule`] objects.
96///
97/// # Usage
98///
99/// ```ignore
100/// let loader = BindingLoader;
101/// let modules = loader.load(Path::new("bindings/"), false, false)?;
102/// let strict = loader.load(Path::new("foo.binding.yaml"), true, false)?;
103/// ```
104///
105/// In loose mode (`strict=false`, default), only `module_id` and `target`
106/// are required; missing optional fields fall back to defaults.
107///
108/// In strict mode (`strict=true`), `input_schema` and `output_schema` are
109/// additionally required.
110#[derive(Debug, Default)]
111pub struct BindingLoader;
112
113impl BindingLoader {
114    /// Create a new BindingLoader.
115    pub fn new() -> Self {
116        Self
117    }
118
119    /// Load one file or every `*.binding.yaml` in a directory.
120    ///
121    /// When `recursive` is `true`, subdirectories are traversed depth-first using
122    /// `walkdir`. When `false` (default), only the immediate directory is scanned.
123    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                // Surface per-entry traversal failures (permission denied,
134                // broken symlink, I/O errors) rather than silently dropping
135                // them — matches the non-recursive branch's policy so a
136                // caller switching `recursive=false` → `true` gets a
137                // consistent error contract.
138                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                            // Surface per-entry failures rather than silently
179                            // discarding them; a permission error on a single
180                            // file should not make the directory load partial.
181                            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    /// Parse a pre-loaded binding JSON value (`{"bindings": [...]}`).
248    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    // ------------------------------------------------------------------
257    // Internal helpers
258    // ------------------------------------------------------------------
259
260    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        // A required field is "missing or invalid" when absent, null, or of
330        // the wrong type. Previously only None/Null was rejected, so
331        // `module_id: 42` or `target: true` would silently coerce to an
332        // empty string downstream and corrupt the registered module.
333        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                    // Schemas must be objects.
339                    "input_schema" | "output_schema" => !v.is_object(),
340                    // Identifiers must be non-empty strings.
341                    _ => 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        // No raw debug-format wrappers leak into the user-facing message.
704        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        // Malformed display (non-object) is dropped. We can't easily capture
713        // tracing warnings without a subscriber, but we assert the drop occurs.
714        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        // Verify the FileTooLarge variant can be constructed and displays correctly.
761        // The actual 16 MiB threshold is impractical to trigger in a unit test
762        // (we'd need to write a 16 MiB file), but this test confirms the error
763        // type is wired up and the display message is sensible.
764        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    // ---- Wrong-type scalar rejection (D1-1 regression guard) ----
890
891    #[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    // ---- Recursive WalkDir error propagation (D1-2 regression guard) ----
973
974    #[test]
975    #[cfg(unix)]
976    fn test_recursive_load_surfaces_walkdir_errors() {
977        use std::os::unix::fs::PermissionsExt;
978
979        // Running as root bypasses UNIX permissions and makes this test
980        // a no-op. Skip in that case rather than produce a misleading pass.
981        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        // Restore permissions so TempDir::drop can clean up.
994        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        // Avoid a libc dev-dep solely for this test — inline the syscall.
1005        extern "C" {
1006            fn geteuid() -> u32;
1007        }
1008        // SAFETY: `geteuid` is a stateless C function that takes no args
1009        // and returns the effective UID.
1010        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        // File in root dir
1020        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        // File in subdir
1028        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        // Non-recursive: only root
1036        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        // Recursive: both
1041        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}