1use std::path::{Path, PathBuf};
9
10use serde::Serialize;
11use serde_json::Value;
12
13use crate::loader::{load_schema, navigate_fragment};
14use crate::types::{
15 is_valid_schema_transition, is_valid_version, json_type_name, VersionConstraint, Visibility,
16 UCP_ANNOTATIONS, VALID_OPERATIONS,
17};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "lowercase")]
22pub enum Severity {
23 Error,
24 Warning,
25}
26
27#[derive(Debug, Clone, Serialize)]
29pub struct Diagnostic {
30 pub severity: Severity,
31 pub code: String,
32 pub file: PathBuf,
33 pub path: String,
35 pub message: String,
36}
37
38#[derive(Debug, Clone, Serialize)]
40pub struct FileResult {
41 pub file: PathBuf,
42 pub status: FileStatus,
43 #[serde(skip_serializing_if = "Vec::is_empty")]
44 pub diagnostics: Vec<Diagnostic>,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
49#[serde(rename_all = "lowercase")]
50pub enum FileStatus {
51 Ok,
52 Error,
53 Warning,
54}
55
56#[derive(Debug, Clone, Serialize)]
58pub struct LintResult {
59 pub path: PathBuf,
60 pub files_checked: usize,
61 pub passed: usize,
62 pub failed: usize,
63 pub errors: usize,
64 pub warnings: usize,
65 pub results: Vec<FileResult>,
66}
67
68impl LintResult {
69 pub fn is_ok(&self) -> bool {
71 self.errors == 0
72 }
73}
74
75pub fn lint(path: &Path, strict: bool) -> LintResult {
81 let files = collect_schema_files(path);
82 let mut results = Vec::new();
83 let mut total_errors = 0;
84 let mut total_warnings = 0;
85
86 for file in &files {
87 let file_result = lint_file(file, path);
88 let file_errors = file_result
89 .diagnostics
90 .iter()
91 .filter(|d| d.severity == Severity::Error)
92 .count();
93 let file_warnings = file_result
94 .diagnostics
95 .iter()
96 .filter(|d| d.severity == Severity::Warning)
97 .count();
98
99 total_errors += file_errors;
100 total_warnings += file_warnings;
101 results.push(file_result);
102 }
103
104 let failed = results
105 .iter()
106 .filter(|r| {
107 if strict {
108 r.status != FileStatus::Ok
109 } else {
110 r.status == FileStatus::Error
111 }
112 })
113 .count();
114
115 LintResult {
116 path: path.to_path_buf(),
117 files_checked: files.len(),
118 passed: files.len() - failed,
119 failed,
120 errors: total_errors,
121 warnings: total_warnings,
122 results,
123 }
124}
125
126pub fn lint_file(file: &Path, base_path: &Path) -> FileResult {
128 let mut diagnostics = Vec::new();
129
130 let schema = match load_schema(file) {
132 Ok(s) => s,
133 Err(e) => {
134 diagnostics.push(Diagnostic {
135 severity: Severity::Error,
136 code: "E001".to_string(),
137 file: file.to_path_buf(),
138 path: "/".to_string(),
139 message: format!("syntax error: {}", e),
140 });
141 return FileResult {
142 file: file.strip_prefix(base_path).unwrap_or(file).to_path_buf(),
143 status: FileStatus::Error,
144 diagnostics,
145 };
146 }
147 };
148
149 let file_dir = file.parent().unwrap_or(Path::new("."));
151 check_refs(&schema, file, file_dir, "", &schema, &mut diagnostics);
152
153 check_annotations(&schema, file, "", &mut diagnostics);
155
156 check_requires(&schema, file, &mut diagnostics);
158
159 if schema.get("$id").is_none() {
161 diagnostics.push(Diagnostic {
162 severity: Severity::Warning,
163 code: "W002".to_string(),
164 file: file.to_path_buf(),
165 path: "/".to_string(),
166 message: "schema missing $id field".to_string(),
167 });
168 }
169
170 let has_errors = diagnostics.iter().any(|d| d.severity == Severity::Error);
171 let has_warnings = diagnostics.iter().any(|d| d.severity == Severity::Warning);
172
173 let status = if has_errors {
174 FileStatus::Error
175 } else if has_warnings {
176 FileStatus::Warning
177 } else {
178 FileStatus::Ok
179 };
180
181 FileResult {
182 file: file.strip_prefix(base_path).unwrap_or(file).to_path_buf(),
183 status,
184 diagnostics,
185 }
186}
187
188fn check_refs(
190 value: &Value,
191 file: &Path,
192 file_dir: &Path,
193 path: &str,
194 root: &Value,
195 diagnostics: &mut Vec<Diagnostic>,
196) {
197 match value {
198 Value::Object(map) => {
199 if let Some(Value::String(ref_val)) = map.get("$ref") {
200 check_single_ref(ref_val, file, file_dir, path, root, diagnostics);
201 }
202
203 for (key, val) in map {
204 let child_path = format!("{}/{}", path, key);
205 check_refs(val, file, file_dir, &child_path, root, diagnostics);
206 }
207 }
208 Value::Array(arr) => {
209 for (i, item) in arr.iter().enumerate() {
210 let child_path = format!("{}/{}", path, i);
211 check_refs(item, file, file_dir, &child_path, root, diagnostics);
212 }
213 }
214 _ => {}
215 }
216}
217
218fn check_single_ref(
220 ref_val: &str,
221 file: &Path,
222 file_dir: &Path,
223 path: &str,
224 root: &Value,
225 diagnostics: &mut Vec<Diagnostic>,
226) {
227 if ref_val.starts_with("http://") || ref_val.starts_with("https://") {
229 return;
230 }
231
232 if ref_val.starts_with('#') {
233 if ref_val != "#" && navigate_fragment(root, ref_val).is_err() {
235 diagnostics.push(Diagnostic {
236 severity: Severity::Error,
237 code: "E003".to_string(),
238 file: file.to_path_buf(),
239 path: path.to_string(),
240 message: format!("anchor not found: {}", ref_val),
241 });
242 }
243 return;
244 }
245
246 let (file_part, fragment) = match ref_val.find('#') {
248 Some(idx) => (&ref_val[..idx], Some(&ref_val[idx..])),
249 None => (ref_val, None),
250 };
251
252 let ref_path = file_dir.join(file_part);
253 if !ref_path.exists() {
254 diagnostics.push(Diagnostic {
255 severity: Severity::Error,
256 code: "E002".to_string(),
257 file: file.to_path_buf(),
258 path: path.to_string(),
259 message: format!("file not found: {}", file_part),
260 });
261 return;
262 }
263
264 if let Some(frag) = fragment {
266 if frag != "#" {
267 match load_schema(&ref_path) {
268 Ok(ref_schema) => {
269 if navigate_fragment(&ref_schema, frag).is_err() {
270 diagnostics.push(Diagnostic {
271 severity: Severity::Error,
272 code: "E003".to_string(),
273 file: file.to_path_buf(),
274 path: path.to_string(),
275 message: format!("anchor not found in {}: {}", file_part, frag),
276 });
277 }
278 }
279 Err(_) => {
280 }
283 }
284 }
285 }
286}
287
288fn check_annotations(value: &Value, file: &Path, path: &str, diagnostics: &mut Vec<Diagnostic>) {
290 if let Value::Object(map) = value {
291 for &annotation_key in UCP_ANNOTATIONS {
293 if let Some(annotation) = map.get(annotation_key) {
294 check_annotation_value(annotation, annotation_key, file, path, diagnostics);
295 }
296 }
297
298 for (key, val) in map {
300 let child_path = format!("{}/{}", path, key);
301 check_annotations(val, file, &child_path, diagnostics);
302 }
303 } else if let Value::Array(arr) = value {
304 for (i, item) in arr.iter().enumerate() {
305 let child_path = format!("{}/{}", path, i);
306 check_annotations(item, file, &child_path, diagnostics);
307 }
308 }
309}
310
311fn check_annotation_value(
313 annotation: &Value,
314 key: &str,
315 file: &Path,
316 path: &str,
317 diagnostics: &mut Vec<Diagnostic>,
318) {
319 let annotation_path = format!("{}/{}", path, key);
320
321 match annotation {
322 Value::String(s) => {
323 if Visibility::parse(s).is_none() {
324 diagnostics.push(Diagnostic {
325 severity: Severity::Error,
326 code: "E004".to_string(),
327 file: file.to_path_buf(),
328 path: annotation_path,
329 message: format!(
330 "invalid {} value \"{}\": expected omit, required, or optional",
331 key, s
332 ),
333 });
334 }
335 }
336 Value::Object(map) => {
337 for (op, val) in map {
339 let op_path = format!("{}/{}", annotation_path, op);
340
341 if op == "transition" {
343 check_transition_object(val, key, file, &op_path, diagnostics);
344 continue;
345 }
346
347 if !VALID_OPERATIONS.contains(&op.as_str()) {
349 diagnostics.push(Diagnostic {
350 severity: Severity::Warning,
351 code: "W003".to_string(),
352 file: file.to_path_buf(),
353 path: op_path.clone(),
354 message: format!(
355 "unknown operation \"{}\": expected {}",
356 op,
357 VALID_OPERATIONS.join(", ")
358 ),
359 });
360 }
361
362 match val {
364 Value::String(s) => {
365 if Visibility::parse(s).is_none() {
366 diagnostics.push(Diagnostic {
367 severity: Severity::Error,
368 code: "E004".to_string(),
369 file: file.to_path_buf(),
370 path: op_path,
371 message: format!(
372 "invalid {} value \"{}\": expected omit, required, or optional",
373 key, s
374 ),
375 });
376 }
377 }
378 Value::Object(obj) => {
379 if let Some(t) = obj.get("transition") {
381 check_transition_object(t, key, file, &op_path, diagnostics);
382 } else {
383 diagnostics.push(Diagnostic {
384 severity: Severity::Error,
385 code: "E005".to_string(),
386 file: file.to_path_buf(),
387 path: op_path,
388 message: format!(
389 "invalid {} value type: expected string or transition object, got {}",
390 key,
391 json_type_name(val)
392 ),
393 });
394 }
395 }
396 _ => {
397 diagnostics.push(Diagnostic {
398 severity: Severity::Error,
399 code: "E005".to_string(),
400 file: file.to_path_buf(),
401 path: op_path,
402 message: format!(
403 "invalid {} value type: expected string or transition object, got {}",
404 key,
405 json_type_name(val)
406 ),
407 });
408 }
409 }
410 }
411 }
412 other => {
413 diagnostics.push(Diagnostic {
414 severity: Severity::Error,
415 code: "E005".to_string(),
416 file: file.to_path_buf(),
417 path: annotation_path,
418 message: format!(
419 "invalid {} type: expected string or object, got {}",
420 key,
421 json_type_name(other)
422 ),
423 });
424 }
425 }
426}
427
428fn check_transition_object(
430 value: &Value,
431 key: &str,
432 file: &Path,
433 path: &str,
434 diagnostics: &mut Vec<Diagnostic>,
435) {
436 let Some(obj) = value.as_object() else {
437 diagnostics.push(Diagnostic {
438 severity: Severity::Error,
439 code: "E005".to_string(),
440 file: file.to_path_buf(),
441 path: path.to_string(),
442 message: format!(
443 "invalid {} transition: expected object, got {}",
444 key,
445 json_type_name(value)
446 ),
447 });
448 return;
449 };
450
451 let from = obj.get("from").and_then(|v| v.as_str()).unwrap_or("");
452 let to = obj.get("to").and_then(|v| v.as_str()).unwrap_or("");
453 let description = obj
454 .get("description")
455 .and_then(|v| v.as_str())
456 .unwrap_or("");
457
458 if description.is_empty() {
459 diagnostics.push(Diagnostic {
460 severity: Severity::Error,
461 code: "E004".to_string(),
462 file: file.to_path_buf(),
463 path: path.to_string(),
464 message: format!(
465 "invalid {} transition: missing required field \"description\"",
466 key
467 ),
468 });
469 }
470
471 if !is_valid_schema_transition(from, to) {
472 diagnostics.push(Diagnostic {
473 severity: Severity::Error,
474 code: "E004".to_string(),
475 file: file.to_path_buf(),
476 path: path.to_string(),
477 message: format!(
478 "invalid {} schema transition: \"from\" ({}) and \"to\" ({}) must be distinct visibility values (omit, required, optional)",
479 key, from, to
480 ),
481 });
482 }
483}
484
485fn check_version_constraint(
488 value: &Value,
489 file: &Path,
490 path: &str,
491 diagnostics: &mut Vec<Diagnostic>,
492) -> Option<VersionConstraint> {
493 let obj = match value.as_object() {
494 Some(o) => o,
495 None => {
496 diagnostics.push(Diagnostic {
497 severity: Severity::Error,
498 code: "E006".to_string(),
499 file: file.to_path_buf(),
500 path: path.to_string(),
501 message: format!(
502 "invalid version constraint: expected object, got {}",
503 json_type_name(value)
504 ),
505 });
506 return None;
507 }
508 };
509
510 const KNOWN_CONSTRAINT_KEYS: &[&str] = &["min", "max"];
512 for key in obj.keys() {
513 if !KNOWN_CONSTRAINT_KEYS.contains(&key.as_str()) {
514 diagnostics.push(Diagnostic {
515 severity: Severity::Warning,
516 code: "W005".to_string(),
517 file: file.to_path_buf(),
518 path: format!("{}/{}", path, key),
519 message: format!(
520 "unknown key \"{}\" in version constraint: expected min, max",
521 key
522 ),
523 });
524 }
525 }
526
527 let min = match obj.get("min").and_then(|v| v.as_str()) {
528 Some(s) => s,
529 None => {
530 diagnostics.push(Diagnostic {
531 severity: Severity::Error,
532 code: "E006".to_string(),
533 file: file.to_path_buf(),
534 path: path.to_string(),
535 message: "version constraint missing required field \"min\"".to_string(),
536 });
537 return None;
538 }
539 };
540
541 if !is_valid_version(min) {
542 diagnostics.push(Diagnostic {
543 severity: Severity::Error,
544 code: "E006".to_string(),
545 file: file.to_path_buf(),
546 path: format!("{}/min", path),
547 message: format!("invalid version format \"{}\": expected YYYY-MM-DD", min),
548 });
549 return None;
550 }
551
552 let mut max_str = None;
553 if let Some(max_val) = obj.get("max") {
554 match max_val.as_str() {
555 Some(s) => {
556 if !is_valid_version(s) {
557 diagnostics.push(Diagnostic {
558 severity: Severity::Error,
559 code: "E006".to_string(),
560 file: file.to_path_buf(),
561 path: format!("{}/max", path),
562 message: format!("invalid version format \"{}\": expected YYYY-MM-DD", s),
563 });
564 return None;
565 }
566 max_str = Some(s.to_string());
567 }
568 None => {
569 diagnostics.push(Diagnostic {
570 severity: Severity::Error,
571 code: "E006".to_string(),
572 file: file.to_path_buf(),
573 path: format!("{}/max", path),
574 message: "\"max\" must be a string".to_string(),
575 });
576 return None;
577 }
578 }
579 }
580
581 let vc = VersionConstraint {
582 min: min.to_string(),
583 max: max_str,
584 };
585
586 if let Some(ref max) = vc.max {
588 if vc.min.as_str() > max.as_str() {
589 diagnostics.push(Diagnostic {
590 severity: Severity::Warning,
591 code: "W004".to_string(),
592 file: file.to_path_buf(),
593 path: path.to_string(),
594 message: format!("version constraint has min ({}) > max ({})", vc.min, max),
595 });
596 }
597 }
598
599 Some(vc)
600}
601
602fn check_requires(schema: &Value, file: &Path, diagnostics: &mut Vec<Diagnostic>) {
609 let Some(requires) = schema.get("requires") else {
610 return;
611 };
612
613 let requires_path = "/requires";
614
615 let obj = match requires.as_object() {
616 Some(o) => o,
617 None => {
618 diagnostics.push(Diagnostic {
619 severity: Severity::Error,
620 code: "E006".to_string(),
621 file: file.to_path_buf(),
622 path: requires_path.to_string(),
623 message: format!(
624 "\"requires\" must be an object, got {}",
625 json_type_name(requires)
626 ),
627 });
628 return;
629 }
630 };
631
632 const KNOWN_REQUIRES_KEYS: &[&str] = &["protocol", "capabilities"];
634 for key in obj.keys() {
635 if !KNOWN_REQUIRES_KEYS.contains(&key.as_str()) {
636 diagnostics.push(Diagnostic {
637 severity: Severity::Warning,
638 code: "W005".to_string(),
639 file: file.to_path_buf(),
640 path: format!("{}/{}", requires_path, key),
641 message: format!(
642 "unknown key \"{}\" in requires: expected protocol, capabilities",
643 key
644 ),
645 });
646 }
647 }
648
649 if let Some(protocol) = obj.get("protocol") {
651 check_version_constraint(
652 protocol,
653 file,
654 &format!("{}/protocol", requires_path),
655 diagnostics,
656 );
657 }
658
659 if let Some(caps) = obj.get("capabilities") {
661 let caps_path = format!("{}/capabilities", requires_path);
662 let caps_obj = match caps.as_object() {
663 Some(o) => o,
664 None => {
665 diagnostics.push(Diagnostic {
666 severity: Severity::Error,
667 code: "E006".to_string(),
668 file: file.to_path_buf(),
669 path: caps_path,
670 message: format!(
671 "\"requires.capabilities\" must be an object, got {}",
672 json_type_name(caps)
673 ),
674 });
675 return;
676 }
677 };
678
679 let defs_keys: std::collections::HashSet<&str> = schema
681 .get("$defs")
682 .and_then(|d| d.as_object())
683 .map(|d| d.keys().map(|k| k.as_str()).collect())
684 .unwrap_or_default();
685
686 for (cap_name, constraint) in caps_obj {
687 let cap_path = format!("{}/{}", caps_path, cap_name);
688
689 check_version_constraint(constraint, file, &cap_path, diagnostics);
690
691 if !defs_keys.contains(cap_name.as_str()) {
693 diagnostics.push(Diagnostic {
694 severity: Severity::Error,
695 code: "E007".to_string(),
696 file: file.to_path_buf(),
697 path: cap_path,
698 message: format!(
699 "requires.capabilities key \"{}\" not found in $defs",
700 cap_name
701 ),
702 });
703 }
704 }
705 }
706}
707
708fn collect_schema_files(path: &Path) -> Vec<PathBuf> {
710 if path.is_file() {
711 if path.extension().map(|e| e == "json").unwrap_or(false) {
712 return vec![path.to_path_buf()];
713 }
714 return vec![];
715 }
716
717 let mut files = Vec::new();
718 collect_files_recursive(path, &mut files);
719 files.sort();
720 files
721}
722
723fn collect_files_recursive(dir: &Path, files: &mut Vec<PathBuf>) {
724 let Ok(entries) = std::fs::read_dir(dir) else {
725 return;
726 };
727
728 for entry in entries.flatten() {
729 let path = entry.path();
730 if path.is_dir() {
731 collect_files_recursive(&path, files);
732 } else if path.extension().map(|e| e == "json").unwrap_or(false) {
733 files.push(path);
734 }
735 }
736}
737
738#[cfg(test)]
739mod tests {
740 use super::*;
741 use std::io::Write;
742 use tempfile::{tempdir, NamedTempFile};
743
744 #[test]
745 fn lint_valid_schema() {
746 let mut file = NamedTempFile::new().unwrap();
747 writeln!(
748 file,
749 r#"{{
750 "$id": "https://example.com/test.json",
751 "type": "object",
752 "properties": {{
753 "id": {{ "type": "string" }}
754 }}
755 }}"#
756 )
757 .unwrap();
758
759 let result = lint_file(file.path(), file.path().parent().unwrap());
760 assert_eq!(result.status, FileStatus::Ok);
761 assert!(result.diagnostics.is_empty());
762 }
763
764 #[test]
765 fn lint_invalid_json_syntax() {
766 let mut file = NamedTempFile::new().unwrap();
767 writeln!(file, "{{ not valid json }}").unwrap();
768
769 let result = lint_file(file.path(), file.path().parent().unwrap());
770 assert_eq!(result.status, FileStatus::Error);
771 assert_eq!(result.diagnostics.len(), 1);
772 assert_eq!(result.diagnostics[0].code, "E001");
773 }
774
775 #[test]
776 fn lint_broken_internal_ref() {
777 let mut file = NamedTempFile::new().unwrap();
778 writeln!(
779 file,
780 r##"{{
781 "$id": "https://example.com/test.json",
782 "type": "object",
783 "properties": {{
784 "data": {{ "$ref": "#/$defs/missing" }}
785 }}
786 }}"##
787 )
788 .unwrap();
789
790 let result = lint_file(file.path(), file.path().parent().unwrap());
791 assert_eq!(result.status, FileStatus::Error);
792 assert!(result.diagnostics.iter().any(|d| d.code == "E003"));
793 }
794
795 #[test]
796 fn lint_broken_file_ref() {
797 let mut file = NamedTempFile::new().unwrap();
798 writeln!(
799 file,
800 r#"{{
801 "$id": "https://example.com/test.json",
802 "properties": {{
803 "data": {{ "$ref": "nonexistent.json" }}
804 }}
805 }}"#
806 )
807 .unwrap();
808
809 let result = lint_file(file.path(), file.path().parent().unwrap());
810 assert_eq!(result.status, FileStatus::Error);
811 assert!(result.diagnostics.iter().any(|d| d.code == "E002"));
812 }
813
814 #[test]
815 fn lint_invalid_ucp_request_value() {
816 let mut file = NamedTempFile::new().unwrap();
817 writeln!(
818 file,
819 r#"{{
820 "$id": "https://example.com/test.json",
821 "properties": {{
822 "id": {{
823 "type": "string",
824 "ucp_request": "invalid_value"
825 }}
826 }}
827 }}"#
828 )
829 .unwrap();
830
831 let result = lint_file(file.path(), file.path().parent().unwrap());
832 assert_eq!(result.status, FileStatus::Error);
833 assert!(result.diagnostics.iter().any(|d| d.code == "E004"));
834 }
835
836 #[test]
837 fn lint_valid_ucp_annotations() {
838 let mut file = NamedTempFile::new().unwrap();
839 writeln!(
840 file,
841 r#"{{
842 "$id": "https://example.com/test.json",
843 "properties": {{
844 "id": {{
845 "type": "string",
846 "ucp_request": {{
847 "create": "omit",
848 "update": "required"
849 }},
850 "ucp_response": "omit"
851 }}
852 }}
853 }}"#
854 )
855 .unwrap();
856
857 let result = lint_file(file.path(), file.path().parent().unwrap());
858 assert_eq!(result.status, FileStatus::Ok);
859 assert!(result.diagnostics.is_empty());
860 }
861
862 #[test]
863 fn lint_valid_schema_transition_object() {
864 let mut file = NamedTempFile::new().unwrap();
865 writeln!(
866 file,
867 r#"{{
868 "$id": "https://example.com/test.json",
869 "properties": {{
870 "legacy_id": {{
871 "type": "string",
872 "ucp_request": {{
873 "update": {{
874 "transition": {{
875 "from": "required",
876 "to": "omit",
877 "description": "Will be removed in v2."
878 }}
879 }}
880 }}
881 }}
882 }}
883 }}"#
884 )
885 .unwrap();
886
887 let result = lint_file(file.path(), file.path().parent().unwrap());
888 assert_eq!(result.status, FileStatus::Ok);
889 assert!(result.diagnostics.is_empty());
890 }
891
892 #[test]
893 fn lint_invalid_schema_transition() {
894 let mut file = NamedTempFile::new().unwrap();
895 writeln!(
896 file,
897 r#"{{
898 "$id": "https://example.com/test.json",
899 "properties": {{
900 "x": {{
901 "type": "string",
902 "ucp_request": {{
903 "transition": {{
904 "from": "required",
905 "to": "required",
906 "description": "from and to must be distinct"
907 }}
908 }}
909 }}
910 }}
911 }}"#
912 )
913 .unwrap();
914
915 let result = lint_file(file.path(), file.path().parent().unwrap());
916 assert_eq!(result.status, FileStatus::Error);
917 assert!(result.diagnostics.iter().any(|d| d.code == "E004"));
918 }
919
920 #[test]
921 fn lint_schema_transition_missing_description() {
922 let mut file = NamedTempFile::new().unwrap();
923 writeln!(
924 file,
925 r#"{{
926 "$id": "https://example.com/test.json",
927 "properties": {{
928 "x": {{
929 "type": "string",
930 "ucp_request": {{
931 "transition": {{
932 "from": "required",
933 "to": "omit"
934 }}
935 }}
936 }}
937 }}
938 }}"#
939 )
940 .unwrap();
941
942 let result = lint_file(file.path(), file.path().parent().unwrap());
943 assert_eq!(result.status, FileStatus::Error);
944 assert!(result
945 .diagnostics
946 .iter()
947 .any(|d| d.code == "E004" && d.message.contains("description")));
948 }
949
950 #[test]
951 fn lint_invalid_ucp_type() {
952 let mut file = NamedTempFile::new().unwrap();
953 writeln!(
954 file,
955 r#"{{
956 "$id": "https://example.com/test.json",
957 "properties": {{
958 "id": {{
959 "type": "string",
960 "ucp_request": 123
961 }}
962 }}
963 }}"#
964 )
965 .unwrap();
966
967 let result = lint_file(file.path(), file.path().parent().unwrap());
968 assert_eq!(result.status, FileStatus::Error);
969 assert!(result.diagnostics.iter().any(|d| d.code == "E005"));
970 }
971
972 #[test]
973 fn lint_missing_id_warning() {
974 let mut file = NamedTempFile::new().unwrap();
975 writeln!(
976 file,
977 r#"{{
978 "type": "object",
979 "properties": {{}}
980 }}"#
981 )
982 .unwrap();
983
984 let result = lint_file(file.path(), file.path().parent().unwrap());
985 assert_eq!(result.status, FileStatus::Warning);
986 assert!(result.diagnostics.iter().any(|d| d.code == "W002"));
987 }
988
989 #[test]
990 fn lint_directory() {
991 let dir = tempdir().unwrap();
992
993 let valid_path = dir.path().join("valid.json");
995 std::fs::write(
996 &valid_path,
997 r#"{"$id": "https://example.com/valid.json", "type": "object"}"#,
998 )
999 .unwrap();
1000
1001 let invalid_path = dir.path().join("invalid.json");
1003 std::fs::write(&invalid_path, "{ not json }").unwrap();
1004
1005 let result = lint(dir.path(), false);
1006 assert_eq!(result.files_checked, 2);
1007 assert_eq!(result.passed, 1);
1008 assert_eq!(result.failed, 1);
1009 assert!(!result.is_ok());
1010 }
1011
1012 #[test]
1013 fn lint_strict_mode() {
1014 let dir = tempdir().unwrap();
1015 let file_path = dir.path().join("test.json");
1016 std::fs::write(&file_path, r#"{"type": "object"}"#).unwrap();
1018
1019 let result = lint(&file_path, false);
1021 assert_eq!(result.files_checked, 1);
1022 assert_eq!(result.passed, 1);
1023 assert_eq!(result.failed, 0);
1024
1025 let result = lint(&file_path, true);
1027 assert_eq!(result.files_checked, 1);
1028 assert_eq!(result.passed, 0);
1029 assert_eq!(result.failed, 1);
1030 }
1031
1032 #[test]
1033 fn lint_valid_ref_with_anchor() {
1034 let dir = tempdir().unwrap();
1035
1036 let ref_path = dir.path().join("types.json");
1038 std::fs::write(
1039 &ref_path,
1040 r#"{"$id": "https://example.com/types.json", "$defs": {"thing": {"type": "string"}}}"#,
1041 )
1042 .unwrap();
1043
1044 let main_path = dir.path().join("main.json");
1046 std::fs::write(
1047 &main_path,
1048 r#"{"$id": "https://example.com/main.json", "properties": {"x": {"$ref": "types.json#/$defs/thing"}}}"#,
1049 )
1050 .unwrap();
1051
1052 let result = lint_file(&main_path, dir.path());
1053 assert_eq!(result.status, FileStatus::Ok);
1054 }
1055
1056 #[test]
1057 fn lint_valid_requires() {
1058 let mut file = NamedTempFile::new().unwrap();
1059 writeln!(
1060 file,
1061 r#"{{
1062 "$id": "https://example.com/loyalty.json",
1063 "requires": {{
1064 "protocol": {{ "min": "2026-01-23" }},
1065 "capabilities": {{
1066 "dev.ucp.shopping.checkout": {{ "min": "2026-06-01" }}
1067 }}
1068 }},
1069 "$defs": {{
1070 "dev.ucp.shopping.checkout": {{ "type": "object" }}
1071 }}
1072 }}"#
1073 )
1074 .unwrap();
1075
1076 let result = lint_file(file.path(), file.path().parent().unwrap());
1077 assert_eq!(result.status, FileStatus::Ok);
1078 assert!(result.diagnostics.is_empty());
1079 }
1080
1081 #[test]
1082 fn lint_requires_with_range() {
1083 let mut file = NamedTempFile::new().unwrap();
1084 writeln!(
1085 file,
1086 r#"{{
1087 "$id": "https://example.com/loyalty.json",
1088 "requires": {{
1089 "protocol": {{ "min": "2026-01-23", "max": "2026-09-01" }}
1090 }}
1091 }}"#
1092 )
1093 .unwrap();
1094
1095 let result = lint_file(file.path(), file.path().parent().unwrap());
1096 assert_eq!(result.status, FileStatus::Ok);
1097 assert!(result.diagnostics.is_empty());
1098 }
1099
1100 #[test]
1101 fn lint_requires_not_object() {
1102 let mut file = NamedTempFile::new().unwrap();
1103 writeln!(
1104 file,
1105 r#"{{
1106 "$id": "https://example.com/test.json",
1107 "requires": "bad"
1108 }}"#
1109 )
1110 .unwrap();
1111
1112 let result = lint_file(file.path(), file.path().parent().unwrap());
1113 assert_eq!(result.status, FileStatus::Error);
1114 assert!(result.diagnostics.iter().any(|d| d.code == "E006"));
1115 }
1116
1117 #[test]
1118 fn lint_requires_bad_version_format() {
1119 let mut file = NamedTempFile::new().unwrap();
1120 writeln!(
1121 file,
1122 r#"{{
1123 "$id": "https://example.com/test.json",
1124 "requires": {{
1125 "protocol": {{ "min": "not-a-date" }}
1126 }}
1127 }}"#
1128 )
1129 .unwrap();
1130
1131 let result = lint_file(file.path(), file.path().parent().unwrap());
1132 assert_eq!(result.status, FileStatus::Error);
1133 assert!(result.diagnostics.iter().any(|d| d.code == "E006"));
1134 }
1135
1136 #[test]
1137 fn lint_requires_missing_min() {
1138 let mut file = NamedTempFile::new().unwrap();
1139 writeln!(
1140 file,
1141 r#"{{
1142 "$id": "https://example.com/test.json",
1143 "requires": {{
1144 "protocol": {{ "max": "2026-09-01" }}
1145 }}
1146 }}"#
1147 )
1148 .unwrap();
1149
1150 let result = lint_file(file.path(), file.path().parent().unwrap());
1151 assert_eq!(result.status, FileStatus::Error);
1152 assert!(result
1153 .diagnostics
1154 .iter()
1155 .any(|d| d.code == "E006" && d.message.contains("min")));
1156 }
1157
1158 #[test]
1159 fn lint_requires_min_greater_than_max() {
1160 let mut file = NamedTempFile::new().unwrap();
1161 writeln!(
1162 file,
1163 r#"{{
1164 "$id": "https://example.com/test.json",
1165 "requires": {{
1166 "protocol": {{ "min": "2026-09-01", "max": "2026-01-23" }}
1167 }}
1168 }}"#
1169 )
1170 .unwrap();
1171
1172 let result = lint_file(file.path(), file.path().parent().unwrap());
1173 assert!(result.diagnostics.iter().any(|d| d.code == "W004"));
1174 }
1175
1176 #[test]
1177 fn lint_requires_capability_not_in_defs() {
1178 let mut file = NamedTempFile::new().unwrap();
1179 writeln!(
1180 file,
1181 r#"{{
1182 "$id": "https://example.com/loyalty.json",
1183 "requires": {{
1184 "capabilities": {{
1185 "dev.ucp.shopping.checkout": {{ "min": "2026-06-01" }}
1186 }}
1187 }},
1188 "$defs": {{
1189 "dev.ucp.shopping.order": {{ "type": "object" }}
1190 }}
1191 }}"#
1192 )
1193 .unwrap();
1194
1195 let result = lint_file(file.path(), file.path().parent().unwrap());
1196 assert_eq!(result.status, FileStatus::Error);
1197 assert!(result.diagnostics.iter().any(|d| d.code == "E007"));
1198 }
1199
1200 #[test]
1201 fn lint_requires_capability_no_defs() {
1202 let mut file = NamedTempFile::new().unwrap();
1203 writeln!(
1204 file,
1205 r#"{{
1206 "$id": "https://example.com/test.json",
1207 "requires": {{
1208 "capabilities": {{
1209 "dev.ucp.shopping.checkout": {{ "min": "2026-06-01" }}
1210 }}
1211 }}
1212 }}"#
1213 )
1214 .unwrap();
1215
1216 let result = lint_file(file.path(), file.path().parent().unwrap());
1217 assert_eq!(result.status, FileStatus::Error);
1218 assert!(result.diagnostics.iter().any(|d| d.code == "E007"));
1219 }
1220
1221 #[test]
1222 fn lint_requires_unknown_key_in_requires() {
1223 let mut file = NamedTempFile::new().unwrap();
1224 writeln!(
1225 file,
1226 r#"{{
1227 "$id": "https://example.com/test.json",
1228 "requires": {{
1229 "proto_version": {{ "min": "2026-01-23" }}
1230 }}
1231 }}"#
1232 )
1233 .unwrap();
1234
1235 let result = lint_file(file.path(), file.path().parent().unwrap());
1236 assert!(result
1237 .diagnostics
1238 .iter()
1239 .any(|d| d.code == "W005" && d.message.contains("proto_version")));
1240 }
1241
1242 #[test]
1243 fn lint_requires_unknown_key_in_constraint() {
1244 let mut file = NamedTempFile::new().unwrap();
1245 writeln!(
1246 file,
1247 r#"{{
1248 "$id": "https://example.com/test.json",
1249 "requires": {{
1250 "protocol": {{ "min": "2026-01-23", "maxx": "2026-09-01" }}
1251 }}
1252 }}"#
1253 )
1254 .unwrap();
1255
1256 let result = lint_file(file.path(), file.path().parent().unwrap());
1257 assert!(result
1258 .diagnostics
1259 .iter()
1260 .any(|d| d.code == "W005" && d.message.contains("maxx")));
1261 }
1262
1263 #[test]
1264 fn lint_requires_empty_capabilities_ok() {
1265 let mut file = NamedTempFile::new().unwrap();
1266 writeln!(
1267 file,
1268 r#"{{
1269 "$id": "https://example.com/test.json",
1270 "requires": {{
1271 "capabilities": {{}}
1272 }}
1273 }}"#
1274 )
1275 .unwrap();
1276
1277 let result = lint_file(file.path(), file.path().parent().unwrap());
1278 assert_eq!(result.status, FileStatus::Ok);
1279 }
1280
1281 #[test]
1282 fn lint_schema_without_requires_unchanged() {
1283 let mut file = NamedTempFile::new().unwrap();
1285 writeln!(
1286 file,
1287 r#"{{
1288 "$id": "https://example.com/test.json",
1289 "type": "object",
1290 "properties": {{
1291 "id": {{ "type": "string" }}
1292 }}
1293 }}"#
1294 )
1295 .unwrap();
1296
1297 let result = lint_file(file.path(), file.path().parent().unwrap());
1298 assert_eq!(result.status, FileStatus::Ok);
1299 }
1300
1301 #[test]
1302 fn lint_broken_ref_anchor() {
1303 let dir = tempdir().unwrap();
1304
1305 let ref_path = dir.path().join("types.json");
1307 std::fs::write(
1308 &ref_path,
1309 r#"{"$id": "https://example.com/types.json", "$defs": {}}"#,
1310 )
1311 .unwrap();
1312
1313 let main_path = dir.path().join("main.json");
1315 std::fs::write(
1316 &main_path,
1317 r#"{"$id": "https://example.com/main.json", "properties": {"x": {"$ref": "types.json#/$defs/missing"}}}"#,
1318 )
1319 .unwrap();
1320
1321 let result = lint_file(&main_path, dir.path());
1322 assert_eq!(result.status, FileStatus::Error);
1323 assert!(result.diagnostics.iter().any(|d| d.code == "E003"));
1324 }
1325}