Skip to main content

lex_extension_host/schema/
loader.rs

1//! YAML loader for `lex_extension::Schema`.
2//!
3//! Reads one schema per `.yaml`/`.yml` file. Deserialisation is strict:
4//! unknown fields are rejected (see `Schema`'s `deny_unknown_fields`
5//! attribute). After deserialisation, the loader runs a validator pass for
6//! the invariants serde can't enforce on its own — see the `validate`
7//! function below for the canonical list.
8//!
9//! All errors carry the offending `path` so that a directory-load failure
10//! points the user at the right file, not just the directory.
11
12use std::fs;
13use std::path::{Path, PathBuf};
14
15use lex_extension::schema::{HandlerTransport, ParamType, Schema};
16use lex_extension::wire::HostNodeKind;
17
18/// One schema file failed to load. Variants distinguish *post-deserialise*
19/// validation failures by class so callers can pattern-match on the cause.
20/// The deserialise step is one variant — `Parse` — because serde_yaml
21/// reports missing required fields, wrong-typed fields, and unknown fields
22/// (rejected by `deny_unknown_fields`) all through the same error path
23/// with line/column attribution baked into the message.
24#[derive(Debug)]
25#[non_exhaustive]
26pub enum SchemaError {
27    /// Reading the file (or, for `load_dir`, listing the directory or one
28    /// of its entries) failed at the OS level.
29    Io {
30        path: PathBuf,
31        source: std::io::Error,
32    },
33
34    /// The YAML body did not deserialise into a [`Schema`]. The message
35    /// is whatever serde_yaml produced — which carries line/column
36    /// information when attribution is possible. Reasons covered by
37    /// this single variant: missing required field, wrong-typed field,
38    /// unknown field rejected by `deny_unknown_fields`, malformed YAML.
39    Parse { path: PathBuf, message: String },
40
41    /// `schema_version` is set to a value the loader doesn't support.
42    /// Currently only `1` is recognised; future versions land with
43    /// dedicated migration paths, not a permissive accept.
44    UnsupportedSchemaVersion {
45        path: PathBuf,
46        label: String,
47        version: u32,
48    },
49
50    /// `attaches_to` referenced a node kind outside the closed set
51    /// defined by [`HostNodeKind::ALL`]. The Display impl emits the
52    /// current allowed list verbatim so the error stays in sync if
53    /// new kinds are added.
54    UnknownNodeKind {
55        path: PathBuf,
56        label: String,
57        kind: String,
58    },
59
60    /// A param declared `type: enum` but its `values` list is empty.
61    EmptyEnumValues {
62        path: PathBuf,
63        label: String,
64        param: String,
65    },
66
67    /// Two `EnumValue` entries on the same param share a name.
68    DuplicateEnumValue {
69        path: PathBuf,
70        label: String,
71        param: String,
72        value: String,
73    },
74
75    /// An `EnumValue` was declared with an empty `name`. The empty
76    /// string isn't a useful identifier and almost always indicates a
77    /// schema typo (`- name:` with no value).
78    EmptyEnumValueName {
79        path: PathBuf,
80        label: String,
81        param: String,
82    },
83
84    /// `verbatim_label: true` was set on a label that can't legally appear
85    /// as a verbatim block closing — typically because it contains
86    /// whitespace or the verbatim-marker sequence `::`.
87    InvalidVerbatimLabel {
88        path: PathBuf,
89        label: String,
90        reason: String,
91    },
92
93    /// `handler.transport: wasm` is reserved for a future release. The
94    /// loader rejects it with a clear deferral message rather than the
95    /// generic "unknown variant" error so users get an actionable hint.
96    WasmTransportDeferred { path: PathBuf, label: String },
97
98    /// `handler.transport: subprocess` declared without a non-empty
99    /// `command` array — the subprocess transport has nothing to spawn.
100    EmptySubprocessCommand { path: PathBuf, label: String },
101
102    /// `handler.transport` is a value the loader doesn't understand.
103    /// `HandlerTransport` is `#[non_exhaustive]` upstream; reaching this
104    /// branch means a future variant slipped past serde without being
105    /// taught to the validator. Surfacing it as an error rather than a
106    /// silent accept keeps lockstep with `lex-extension`.
107    UnsupportedTransport {
108        path: PathBuf,
109        label: String,
110        transport: String,
111    },
112}
113
114impl std::fmt::Display for SchemaError {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        match self {
117            SchemaError::Io { path, source } => {
118                write!(f, "{}: io error: {source}", path.display())
119            }
120            SchemaError::Parse { path, message } => {
121                write!(f, "{}: schema parse error: {message}", path.display())
122            }
123            SchemaError::UnsupportedSchemaVersion {
124                path,
125                label,
126                version,
127            } => write!(
128                f,
129                "{}: schema for `{label}` declares schema_version: {version} (this loader supports only version 1)",
130                path.display()
131            ),
132            SchemaError::UnknownNodeKind { path, label, kind } => write!(
133                f,
134                "{}: schema for `{label}` lists unknown node kind `{kind}` in attaches_to (allowed: {})",
135                path.display(),
136                HostNodeKind::allowed_list()
137            ),
138            SchemaError::EmptyEnumValues { path, label, param } => write!(
139                f,
140                "{}: schema for `{label}` declares param `{param}` as enum but provides no values",
141                path.display()
142            ),
143            SchemaError::DuplicateEnumValue {
144                path,
145                label,
146                param,
147                value,
148            } => write!(
149                f,
150                "{}: schema for `{label}` lists duplicate enum value `{value}` on param `{param}`",
151                path.display()
152            ),
153            SchemaError::EmptyEnumValueName { path, label, param } => write!(
154                f,
155                "{}: schema for `{label}` has an empty enum value name on param `{param}`",
156                path.display()
157            ),
158            SchemaError::InvalidVerbatimLabel {
159                path,
160                label,
161                reason,
162            } => write!(
163                f,
164                "{}: schema for `{label}` sets verbatim_label: true but the label is not legal as a verbatim closing ({reason})",
165                path.display()
166            ),
167            SchemaError::WasmTransportDeferred { path, label } => write!(
168                f,
169                "{}: schema for `{label}` declares transport: wasm — the WASM transport is deferred for v1; use subprocess or native",
170                path.display()
171            ),
172            SchemaError::EmptySubprocessCommand { path, label } => write!(
173                f,
174                "{}: schema for `{label}` declares transport: subprocess but provides an empty command array",
175                path.display()
176            ),
177            SchemaError::UnsupportedTransport {
178                path,
179                label,
180                transport,
181            } => write!(
182                f,
183                "{}: schema for `{label}` declares unsupported transport `{transport}`",
184                path.display()
185            ),
186        }
187    }
188}
189
190impl std::error::Error for SchemaError {
191    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
192        match self {
193            SchemaError::Io { source, .. } => Some(source),
194            _ => None,
195        }
196    }
197}
198
199/// Loader for schema YAML files. Stateless — the type exists for
200/// namespacing and future-proofing (caching, schema-version negotiation
201/// could grow into instance state).
202pub struct SchemaLoader;
203
204impl SchemaLoader {
205    /// Read and validate one schema file.
206    pub fn load_file(path: impl AsRef<Path>) -> Result<Schema, SchemaError> {
207        let path = path.as_ref();
208        let body = fs::read_to_string(path).map_err(|source| SchemaError::Io {
209            path: path.to_path_buf(),
210            source,
211        })?;
212        let schema: Schema = serde_yaml::from_str(&body).map_err(|err| SchemaError::Parse {
213            path: path.to_path_buf(),
214            message: err.to_string(),
215        })?;
216        validate(&schema, path)?;
217        Ok(schema)
218    }
219
220    /// Read and validate every `.yaml`/`.yml` file in a directory
221    /// (non-recursive). Files are visited in sorted order so the caller
222    /// gets a deterministic vector. One bad file fails the whole load
223    /// with the offending path in the error.
224    pub fn load_dir(path: impl AsRef<Path>) -> Result<Vec<Schema>, SchemaError> {
225        let path = path.as_ref();
226        let entries = fs::read_dir(path).map_err(|source| SchemaError::Io {
227            path: path.to_path_buf(),
228            source,
229        })?;
230        // Per-entry errors (permission denied, transient FS hiccup) are
231        // propagated rather than silently filtered: an incomplete schema
232        // set is worse than a hard failure with a precise message.
233        // We use `entry.file_type()?` rather than `path.is_file()` —
234        // the latter swallows metadata errors as `false`, which would
235        // silently skip e.g. EACCES files instead of failing the load
236        // with a precise path.
237        let mut yaml_paths: Vec<PathBuf> = Vec::new();
238        for entry in entries {
239            let entry = entry.map_err(|source| SchemaError::Io {
240                path: path.to_path_buf(),
241                source,
242            })?;
243            let p = entry.path();
244            let file_type = entry.file_type().map_err(|source| SchemaError::Io {
245                path: p.clone(),
246                source,
247            })?;
248            if file_type.is_file()
249                && p.extension().and_then(|s| s.to_str()).is_some_and(|ext| {
250                    ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml")
251                })
252            {
253                yaml_paths.push(p);
254            }
255        }
256        yaml_paths.sort();
257
258        let mut schemas = Vec::with_capacity(yaml_paths.len());
259        for p in yaml_paths {
260            schemas.push(Self::load_file(&p)?);
261        }
262        Ok(schemas)
263    }
264}
265
266/// Schema-format versions this loader recognises. Currently only `1`.
267/// New versions land with explicit migration paths, not a permissive
268/// accept.
269const SUPPORTED_SCHEMA_VERSIONS: &[u32] = &[1];
270
271// Allowed values of `attaches_to` come from `HostNodeKind` in
272// `lex-extension`. Centralising the list there keeps the loader and
273// the analysis/render walkers from drifting — see the
274// `host_node_kind` module for the rationale.
275
276fn validate(schema: &Schema, path: &Path) -> Result<(), SchemaError> {
277    // schema_version: only the recognised versions are accepted.
278    if !SUPPORTED_SCHEMA_VERSIONS.contains(&schema.schema_version) {
279        return Err(SchemaError::UnsupportedSchemaVersion {
280            path: path.to_path_buf(),
281            label: schema.label.clone(),
282            version: schema.schema_version,
283        });
284    }
285
286    // Params: enum-typed values are non-empty, individual names are
287    // non-empty, and all names are unique within the param.
288    for (name, spec) in &schema.params {
289        if spec.ty == ParamType::Enum {
290            if spec.values.is_empty() {
291                return Err(SchemaError::EmptyEnumValues {
292                    path: path.to_path_buf(),
293                    label: schema.label.clone(),
294                    param: name.clone(),
295                });
296            }
297            let mut seen = std::collections::HashSet::with_capacity(spec.values.len());
298            for v in &spec.values {
299                if v.name.is_empty() {
300                    return Err(SchemaError::EmptyEnumValueName {
301                        path: path.to_path_buf(),
302                        label: schema.label.clone(),
303                        param: name.clone(),
304                    });
305                }
306                if !seen.insert(v.name.as_str()) {
307                    return Err(SchemaError::DuplicateEnumValue {
308                        path: path.to_path_buf(),
309                        label: schema.label.clone(),
310                        param: name.clone(),
311                        value: v.name.clone(),
312                    });
313                }
314            }
315        }
316    }
317
318    // attaches_to: every entry must be a known node kind.
319    for kind in &schema.attaches_to {
320        if HostNodeKind::parse(kind).is_none() {
321            return Err(SchemaError::UnknownNodeKind {
322                path: path.to_path_buf(),
323                label: schema.label.clone(),
324                kind: kind.clone(),
325            });
326        }
327    }
328
329    // verbatim_label: label must be syntactically legal as a verbatim
330    // closing token.
331    if schema.verbatim_label {
332        if let Err(reason) = check_verbatim_label(&schema.label) {
333            return Err(SchemaError::InvalidVerbatimLabel {
334                path: path.to_path_buf(),
335                label: schema.label.clone(),
336                reason: reason.into(),
337            });
338        }
339    }
340
341    // handler: transport-specific shape rules.
342    if let Some(handler) = &schema.handler {
343        match handler.transport {
344            HandlerTransport::Wasm => {
345                return Err(SchemaError::WasmTransportDeferred {
346                    path: path.to_path_buf(),
347                    label: schema.label.clone(),
348                });
349            }
350            HandlerTransport::Subprocess => {
351                if handler.command.is_empty() {
352                    return Err(SchemaError::EmptySubprocessCommand {
353                        path: path.to_path_buf(),
354                        label: schema.label.clone(),
355                    });
356                }
357            }
358            HandlerTransport::Native => {}
359            // HandlerTransport is #[non_exhaustive] for forward-compat
360            // across lex-extension major versions. Reject unknown
361            // variants explicitly: a future variant slipping through
362            // serde without being taught to the validator would
363            // otherwise be silently accepted, which contradicts the
364            // strict-by-default loader contract.
365            other => {
366                return Err(SchemaError::UnsupportedTransport {
367                    path: path.to_path_buf(),
368                    label: schema.label.clone(),
369                    transport: format!("{other:?}").to_lowercase(),
370                });
371            }
372        }
373    }
374
375    Ok(())
376}
377
378/// A label is legal as a verbatim closing if it forms a single token in
379/// the verbatim header line `:: label ... ::`. We enforce the minimum
380/// invariants the lexer relies on:
381///
382/// - non-empty,
383/// - no whitespace (would split it into multiple tokens),
384/// - no `::` substring (collides with the closing marker).
385fn check_verbatim_label(label: &str) -> Result<(), &'static str> {
386    if label.is_empty() {
387        return Err("label is empty");
388    }
389    if label.chars().any(char::is_whitespace) {
390        return Err("label contains whitespace");
391    }
392    if label.contains("::") {
393        return Err("label contains the verbatim-marker sequence `::`");
394    }
395    Ok(())
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use lex_extension::schema::{
402        BodyKind, BodyShape, EnumValue, HandlerSpec, HookSet, ParamSpec, RenderHook,
403    };
404    use std::collections::BTreeMap;
405    use tempfile::TempDir;
406
407    fn write_yaml(dir: &TempDir, name: &str, body: &str) -> PathBuf {
408        let path = dir.path().join(name);
409        fs::write(&path, body).expect("write fixture");
410        path
411    }
412
413    const COMMENT_SCHEMA_YAML: &str = r#"
414schema_version: 1
415label: acme.commenting
416description: A comment thread.
417params:
418  role:
419    type: enum
420    required: true
421    values:
422      - name: author
423      - name: editor
424attaches_to: [paragraph, session]
425body:
426  kind: lex
427  presence: required
428verbatim_label: false
429hooks:
430  validate: true
431  hover: true
432  render: [html, markdown]
433handler:
434  transport: subprocess
435  command: [acme-comment-handler]
436  timeout_ms: 2000
437"#;
438
439    #[test]
440    fn loads_valid_schema_with_all_features() {
441        let dir = TempDir::new().unwrap();
442        let path = write_yaml(&dir, "comment.yaml", COMMENT_SCHEMA_YAML);
443
444        let schema = SchemaLoader::load_file(&path).expect("loads cleanly");
445        assert_eq!(schema.label, "acme.commenting");
446        assert_eq!(schema.attaches_to, vec!["paragraph", "session"]);
447        assert_eq!(schema.body.kind, BodyKind::Lex);
448        assert!(schema.hooks.validate);
449        assert_eq!(
450            schema.hooks.render,
451            vec![RenderHook::new("html"), RenderHook::new("markdown")]
452        );
453        let handler = schema.handler.as_ref().expect("handler present");
454        assert_eq!(handler.transport, HandlerTransport::Subprocess);
455        assert_eq!(handler.command, vec!["acme-comment-handler".to_string()]);
456        assert_eq!(handler.timeout_ms, Some(2000));
457    }
458
459    #[test]
460    fn loads_minimal_schema_with_defaults() {
461        // Only the required fields. Body defaults to {kind: none,
462        // presence: optional}; hooks default to all-off; capabilities
463        // default to {fs: false, net: false}; no handler.
464        let dir = TempDir::new().unwrap();
465        let path = write_yaml(
466            &dir,
467            "min.yaml",
468            r#"
469schema_version: 1
470label: ns.bare
471"#,
472        );
473        let schema = SchemaLoader::load_file(&path).expect("loads cleanly");
474        assert_eq!(schema.label, "ns.bare");
475        assert!(schema.params.is_empty());
476        assert!(schema.attaches_to.is_empty());
477        assert_eq!(schema.body.kind, BodyKind::None);
478        assert!(!schema.verbatim_label);
479        assert!(schema.handler.is_none());
480    }
481
482    #[test]
483    fn unknown_top_level_field_is_rejected() {
484        let dir = TempDir::new().unwrap();
485        let path = write_yaml(
486            &dir,
487            "unknown.yaml",
488            r#"
489schema_version: 1
490label: ns.x
491mystery_field: 42
492"#,
493        );
494        let err = SchemaLoader::load_file(&path).unwrap_err();
495        assert!(matches!(err, SchemaError::Parse { .. }));
496        assert!(
497            err.to_string().contains("mystery_field"),
498            "error must name the offending field, got: {err}"
499        );
500    }
501
502    #[test]
503    fn unknown_field_inside_handler_is_rejected() {
504        let dir = TempDir::new().unwrap();
505        let path = write_yaml(
506            &dir,
507            "unknown_nested.yaml",
508            r#"
509schema_version: 1
510label: ns.x
511handler:
512  transport: subprocess
513  command: [run]
514  bogus: true
515"#,
516        );
517        let err = SchemaLoader::load_file(&path).unwrap_err();
518        assert!(matches!(err, SchemaError::Parse { .. }));
519        assert!(err.to_string().contains("bogus"));
520    }
521
522    #[test]
523    fn missing_required_field_is_a_parse_error() {
524        let dir = TempDir::new().unwrap();
525        let path = write_yaml(&dir, "no_label.yaml", "schema_version: 1\n");
526        let err = SchemaLoader::load_file(&path).unwrap_err();
527        assert!(matches!(err, SchemaError::Parse { .. }));
528        assert!(err.to_string().contains("label"));
529    }
530
531    #[test]
532    fn unknown_param_type_is_a_parse_error() {
533        // ParamType is a closed enum; serde rejects unknown variants.
534        let dir = TempDir::new().unwrap();
535        let path = write_yaml(
536            &dir,
537            "weird_type.yaml",
538            r#"
539schema_version: 1
540label: ns.x
541params:
542  count:
543    type: integer
544"#,
545        );
546        let err = SchemaLoader::load_file(&path).unwrap_err();
547        assert!(matches!(err, SchemaError::Parse { .. }));
548    }
549
550    #[test]
551    fn document_list_and_table_are_valid_attachment_kinds() {
552        // Regression for the duplicated-allowed-list bug: the loader
553        // and the analysis/render walkers both encoded their own
554        // copies of the kind set, and the loader's was missing
555        // `document`, `list`, and `table` even though the walkers
556        // emitted those names. Centralising the list on
557        // `HostNodeKind` should make all three pass.
558        for kind in ["document", "list", "table"] {
559            let dir = TempDir::new().unwrap();
560            let body = format!("schema_version: 1\nlabel: ns.x\nattaches_to: [{kind}]\n");
561            let path = write_yaml(&dir, &format!("{kind}.yaml"), &body);
562            SchemaLoader::load_file(&path)
563                .unwrap_or_else(|e| panic!("kind `{kind}` should be accepted, got: {e}"));
564        }
565    }
566
567    #[test]
568    fn unknown_node_kind_in_attaches_to() {
569        let dir = TempDir::new().unwrap();
570        let path = write_yaml(
571            &dir,
572            "bad_attach.yaml",
573            r#"
574schema_version: 1
575label: ns.x
576attaches_to: [paragraph, fragment]
577"#,
578        );
579        let err = SchemaLoader::load_file(&path).unwrap_err();
580        match err {
581            SchemaError::UnknownNodeKind { kind, label, .. } => {
582                assert_eq!(kind, "fragment");
583                assert_eq!(label, "ns.x");
584            }
585            other => panic!("expected UnknownNodeKind, got: {other}"),
586        }
587    }
588
589    #[test]
590    fn enum_values_must_be_non_empty() {
591        let dir = TempDir::new().unwrap();
592        let path = write_yaml(
593            &dir,
594            "empty_enum.yaml",
595            r#"
596schema_version: 1
597label: ns.x
598params:
599  role:
600    type: enum
601"#,
602        );
603        let err = SchemaLoader::load_file(&path).unwrap_err();
604        match err {
605            SchemaError::EmptyEnumValues { param, label, .. } => {
606                assert_eq!(param, "role");
607                assert_eq!(label, "ns.x");
608            }
609            other => panic!("expected EmptyEnumValues, got: {other}"),
610        }
611    }
612
613    #[test]
614    fn empty_enum_value_name_rejected() {
615        let dir = TempDir::new().unwrap();
616        let path = write_yaml(
617            &dir,
618            "empty_name.yaml",
619            r#"
620schema_version: 1
621label: ns.x
622params:
623  role:
624    type: enum
625    values:
626      - name: ""
627"#,
628        );
629        let err = SchemaLoader::load_file(&path).unwrap_err();
630        match err {
631            SchemaError::EmptyEnumValueName { param, label, .. } => {
632                assert_eq!(param, "role");
633                assert_eq!(label, "ns.x");
634            }
635            other => panic!("expected EmptyEnumValueName, got: {other}"),
636        }
637    }
638
639    #[test]
640    fn unsupported_schema_version_rejected() {
641        // schema_version: 2 deserialises fine but the validator
642        // refuses it because this loader only recognises version 1.
643        let dir = TempDir::new().unwrap();
644        let path = write_yaml(
645            &dir,
646            "v2.yaml",
647            r#"
648schema_version: 2
649label: ns.x
650"#,
651        );
652        let err = SchemaLoader::load_file(&path).unwrap_err();
653        match err {
654            SchemaError::UnsupportedSchemaVersion { version, label, .. } => {
655                assert_eq!(version, 2);
656                assert_eq!(label, "ns.x");
657            }
658            other => panic!("expected UnsupportedSchemaVersion, got: {other}"),
659        }
660    }
661
662    #[test]
663    fn duplicate_enum_values_rejected() {
664        let dir = TempDir::new().unwrap();
665        let path = write_yaml(
666            &dir,
667            "dup_enum.yaml",
668            r#"
669schema_version: 1
670label: ns.x
671params:
672  role:
673    type: enum
674    values:
675      - name: author
676      - name: author
677"#,
678        );
679        let err = SchemaLoader::load_file(&path).unwrap_err();
680        match err {
681            SchemaError::DuplicateEnumValue { value, .. } => {
682                assert_eq!(value, "author");
683            }
684            other => panic!("expected DuplicateEnumValue, got: {other}"),
685        }
686    }
687
688    #[test]
689    fn verbatim_label_with_whitespace_is_invalid() {
690        let dir = TempDir::new().unwrap();
691        let path = write_yaml(
692            &dir,
693            "ws.yaml",
694            r#"
695schema_version: 1
696label: "ns.has space"
697verbatim_label: true
698"#,
699        );
700        let err = SchemaLoader::load_file(&path).unwrap_err();
701        match err {
702            SchemaError::InvalidVerbatimLabel { reason, .. } => {
703                assert!(reason.contains("whitespace"), "got: {reason}");
704            }
705            other => panic!("expected InvalidVerbatimLabel, got: {other}"),
706        }
707    }
708
709    #[test]
710    fn verbatim_label_with_double_colon_is_invalid() {
711        let dir = TempDir::new().unwrap();
712        let path = write_yaml(
713            &dir,
714            "colon.yaml",
715            r#"
716schema_version: 1
717label: "ns::bad"
718verbatim_label: true
719"#,
720        );
721        let err = SchemaLoader::load_file(&path).unwrap_err();
722        assert!(matches!(err, SchemaError::InvalidVerbatimLabel { .. }));
723    }
724
725    #[test]
726    fn verbatim_label_false_does_not_validate_label_shape() {
727        // Whitespace in the label is silly but only fatal when
728        // verbatim_label: true claims to use the label as a verbatim
729        // closing.
730        let dir = TempDir::new().unwrap();
731        let path = write_yaml(
732            &dir,
733            "ws_off.yaml",
734            r#"
735schema_version: 1
736label: "ns.has space"
737verbatim_label: false
738"#,
739        );
740        SchemaLoader::load_file(&path).expect("verbatim_label: false skips the legality check");
741    }
742
743    #[test]
744    fn wasm_transport_is_deferred_with_clear_error() {
745        let dir = TempDir::new().unwrap();
746        let path = write_yaml(
747            &dir,
748            "wasm.yaml",
749            r#"
750schema_version: 1
751label: ns.x
752handler:
753  transport: wasm
754"#,
755        );
756        let err = SchemaLoader::load_file(&path).unwrap_err();
757        assert!(matches!(err, SchemaError::WasmTransportDeferred { .. }));
758        assert!(err.to_string().contains("deferred"));
759    }
760
761    #[test]
762    fn subprocess_transport_requires_non_empty_command() {
763        let dir = TempDir::new().unwrap();
764        let path = write_yaml(
765            &dir,
766            "sub.yaml",
767            r#"
768schema_version: 1
769label: ns.x
770handler:
771  transport: subprocess
772  command: []
773"#,
774        );
775        let err = SchemaLoader::load_file(&path).unwrap_err();
776        assert!(matches!(err, SchemaError::EmptySubprocessCommand { .. }));
777    }
778
779    #[test]
780    fn missing_file_yields_io_error() {
781        let dir = TempDir::new().unwrap();
782        let path = dir.path().join("does-not-exist.yaml");
783        let err = SchemaLoader::load_file(&path).unwrap_err();
784        assert!(matches!(err, SchemaError::Io { .. }));
785    }
786
787    #[test]
788    fn load_dir_collects_all_yaml_files() {
789        let dir = TempDir::new().unwrap();
790        write_yaml(&dir, "a.yaml", COMMENT_SCHEMA_YAML);
791        write_yaml(
792            &dir,
793            "b.yml",
794            r#"
795schema_version: 1
796label: ns.y
797"#,
798        );
799        // Non-yaml file is ignored.
800        write_yaml(&dir, "readme.md", "ignore me");
801
802        let schemas = SchemaLoader::load_dir(dir.path()).expect("loads dir");
803        assert_eq!(schemas.len(), 2);
804        // Sorted by file name → a.yaml before b.yml.
805        assert_eq!(schemas[0].label, "acme.commenting");
806        assert_eq!(schemas[1].label, "ns.y");
807    }
808
809    #[test]
810    fn load_dir_fails_with_offending_path_on_one_bad_file() {
811        let dir = TempDir::new().unwrap();
812        write_yaml(&dir, "ok.yaml", COMMENT_SCHEMA_YAML);
813        let bad_path = write_yaml(
814            &dir,
815            "broken.yaml",
816            "schema_version: 1\nlabel: ns.x\nattaches_to: [bogus]\n",
817        );
818
819        let err = SchemaLoader::load_dir(dir.path()).unwrap_err();
820        match &err {
821            SchemaError::UnknownNodeKind { path, .. } => {
822                assert_eq!(path, &bad_path, "error must name the offending file");
823            }
824            other => panic!("expected UnknownNodeKind from the bad file, got: {other}"),
825        }
826        // And the error formats with that file path.
827        assert!(err.to_string().contains("broken.yaml"));
828    }
829
830    #[test]
831    fn load_dir_on_missing_directory_yields_io_error() {
832        let dir = TempDir::new().unwrap();
833        let missing = dir.path().join("does-not-exist");
834        let err = SchemaLoader::load_dir(&missing).unwrap_err();
835        assert!(matches!(err, SchemaError::Io { .. }));
836    }
837
838    /// Round-trip: a hand-built `Schema` serialises to YAML and reloads
839    /// through `load_file` to an equal value. This is the property test
840    /// the issue asks for, in concrete form.
841    #[test]
842    fn round_trip_schema_through_yaml_is_identity() {
843        let mut params = BTreeMap::new();
844        params.insert(
845            "limit".into(),
846            ParamSpec {
847                ty: ParamType::Int,
848                required: false,
849                default: Some(serde_json::json!(10)),
850                description: Some("Max items".into()),
851                pattern: None,
852                values: Vec::new(),
853            },
854        );
855        params.insert(
856            "kind".into(),
857            ParamSpec {
858                ty: ParamType::Enum,
859                required: true,
860                default: None,
861                description: None,
862                pattern: None,
863                values: vec![
864                    EnumValue {
865                        name: "small".into(),
866                        description: None,
867                    },
868                    EnumValue {
869                        name: "large".into(),
870                        description: Some("the big one".into()),
871                    },
872                ],
873            },
874        );
875        let original = Schema {
876            schema_version: 1,
877            label: "demo.thing".into(),
878            description: Some("Demo schema".into()),
879            params,
880            attaches_to: vec!["paragraph".into(), "annotation".into()],
881            body: BodyShape {
882                kind: BodyKind::Text,
883                presence: lex_extension::schema::BodyPresence::Required,
884                description: None,
885            },
886            verbatim_label: false,
887            capabilities: lex_extension::schema::Capabilities {
888                fs: false,
889                net: false,
890            },
891            hooks: HookSet {
892                validate: true,
893                render: vec![RenderHook::new("html")],
894                ..HookSet::default()
895            },
896            handler: Some(HandlerSpec {
897                transport: HandlerTransport::Native,
898                command: Vec::new(),
899                timeout_ms: None,
900            }),
901            diagnostics: vec![lex_extension::schema::DiagnosticDecl {
902                code: "thing-incomplete".into(),
903                description: Some("The thing is incomplete.".into()),
904                default_severity: lex_extension::DiagnosticSeverity::Warning,
905            }],
906        };
907
908        let yaml = serde_yaml::to_string(&original).expect("serialises");
909        let dir = TempDir::new().unwrap();
910        let path = write_yaml(&dir, "rt.yaml", &yaml);
911        let reloaded = SchemaLoader::load_file(&path).expect("reloads");
912        assert_eq!(reloaded, original);
913    }
914}