1use std::fs;
13use std::path::{Path, PathBuf};
14
15use lex_extension::schema::{HandlerTransport, ParamType, Schema};
16use lex_extension::wire::HostNodeKind;
17
18#[derive(Debug)]
25#[non_exhaustive]
26pub enum SchemaError {
27 Io {
30 path: PathBuf,
31 source: std::io::Error,
32 },
33
34 Parse { path: PathBuf, message: String },
40
41 UnsupportedSchemaVersion {
45 path: PathBuf,
46 label: String,
47 version: u32,
48 },
49
50 UnknownNodeKind {
55 path: PathBuf,
56 label: String,
57 kind: String,
58 },
59
60 EmptyEnumValues {
62 path: PathBuf,
63 label: String,
64 param: String,
65 },
66
67 DuplicateEnumValue {
69 path: PathBuf,
70 label: String,
71 param: String,
72 value: String,
73 },
74
75 EmptyEnumValueName {
79 path: PathBuf,
80 label: String,
81 param: String,
82 },
83
84 InvalidVerbatimLabel {
88 path: PathBuf,
89 label: String,
90 reason: String,
91 },
92
93 WasmTransportDeferred { path: PathBuf, label: String },
97
98 EmptySubprocessCommand { path: PathBuf, label: String },
101
102 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
199pub struct SchemaLoader;
203
204impl SchemaLoader {
205 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 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 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
266const SUPPORTED_SCHEMA_VERSIONS: &[u32] = &[1];
270
271fn validate(schema: &Schema, path: &Path) -> Result<(), SchemaError> {
277 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 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 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 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 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 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
378fn 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 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 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 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 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 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 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 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 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 #[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}