Skip to main content

jsonschema_explain/
lib.rs

1#![doc = include_str!("../README.md")]
2
3mod fmt;
4mod man;
5mod render;
6mod schema;
7mod sections;
8
9use core::fmt::Write;
10
11use jsonschema_schema::{Schema, SchemaValue};
12
13use fmt::{Fmt, format_header, format_type};
14use man::{write_description, write_section};
15use render::{
16    render_additional_properties, render_pattern_properties, render_properties, render_subschema,
17};
18use schema::{get_description, required_set, schema_type_str};
19use sections::{
20    render_definitions_section, render_examples_section, render_schema_section,
21    render_variants_section,
22};
23
24pub use schema::{navigate_pointer, resolve_ref as resolve_schema_ref};
25
26/// A validation error to display in the VALIDATION ERRORS section.
27pub struct ExplainError {
28    /// JSON Pointer to the failing instance (e.g. `/badges/appveyor`).
29    pub instance_path: String,
30    /// Human-readable error message.
31    pub message: String,
32}
33
34/// Display options for rendering schema documentation.
35pub struct ExplainOptions {
36    /// Use ANSI color codes in output.
37    pub color: bool,
38    /// Syntax-highlight fenced code blocks in descriptions.
39    pub syntax_highlight: bool,
40    /// Terminal width in columns for layout.
41    pub width: usize,
42    /// Validation errors to show before the schema documentation.
43    pub validation_errors: Vec<ExplainError>,
44    /// Show extended details like `$comment` annotations.
45    pub extended: bool,
46}
47
48/// Render a JSON Schema as human-readable terminal documentation.
49///
50/// `schema` is a parsed `SchemaValue`. `name` is a display name
51/// (e.g. from a catalog entry). `opts` controls color, syntax highlighting,
52/// and terminal width.
53pub fn explain(schema: &SchemaValue, name: &str, opts: &ExplainOptions) -> String {
54    let Some(s) = schema.as_schema() else {
55        // Bool schema — just show header
56        let mut out = String::new();
57        let f = Fmt::from_opts(opts);
58        let upper = name.to_uppercase();
59        let header = format_header(&upper, name, opts.width);
60        let _ = writeln!(out, "{}{header}{}\n", f.bold, f.reset);
61        return out;
62    };
63    explain_schema(s, schema, name, opts)
64}
65
66/// Render a `Schema` as human-readable terminal documentation.
67fn explain_schema(s: &Schema, root: &SchemaValue, name: &str, opts: &ExplainOptions) -> String {
68    let mut out = String::new();
69    let f = Fmt::from_opts(opts);
70
71    // In extended mode, show raw schema structure; otherwise flatten allOf.
72    // absolute() rewrites local $refs to absolute URLs using the schema's $id.
73    let s = if f.extended {
74        s.clone()
75    } else {
76        s.absolute().flatten(root)
77    };
78    let render_root = SchemaValue::Schema(Box::new(s.clone()));
79
80    let title = s.title.as_deref();
81    let description = get_description(&s);
82
83    let label = std::path::Path::new(name)
84        .file_name()
85        .and_then(|f| f.to_str())
86        .unwrap_or(name);
87    let center = title.unwrap_or(label);
88    let header = format_header(label, center, opts.width);
89    let _ = writeln!(out, "{}{header}{}\n", f.bold, f.reset);
90
91    if !opts.validation_errors.is_empty() {
92        write_section(&mut out, "VALIDATION ERRORS", &f);
93        for err in &opts.validation_errors {
94            let path = if err.instance_path.is_empty() {
95                "(root)"
96            } else {
97                &err.instance_path
98            };
99            let _ = writeln!(out, "    {}{path}{}: {}", f.red, f.reset, err.message);
100        }
101        out.push('\n');
102    }
103
104    if let Some(t) = title {
105        write_section(&mut out, "TITLE", &f);
106        let _ = writeln!(out, "    {}{t}{}", f.bold, f.reset);
107        out.push('\n');
108    }
109
110    if let Some(desc) = description {
111        write_section(&mut out, "DESCRIPTION", &f);
112        write_description(&mut out, desc, &f, "    ");
113        out.push('\n');
114    }
115
116    if f.extended
117        && let Some(ref comment) = s.comment
118    {
119        write_section(&mut out, "COMMENT", &f);
120        write_description(&mut out, comment, &f, "    ");
121        out.push('\n');
122    }
123
124    render_schema_section(&mut out, &s, &f);
125
126    let type_str = schema_type_str(&s);
127    if let Some(ref ty) = type_str {
128        write_section(&mut out, "TYPE", &f);
129        let _ = writeln!(out, "    {}", format_type(ty, &f));
130        out.push('\n');
131    }
132
133    let required = required_set(&s);
134    if !s.properties.is_empty() {
135        write_section(&mut out, "PROPERTIES", &f);
136        render_properties(&mut out, &s.properties, &required, &render_root, &f, 1);
137        out.push('\n');
138    }
139
140    render_pattern_properties(&mut out, &s, root, &f, 0, "    ");
141    render_additional_properties(&mut out, &s, root, &f, 0, "    ");
142
143    // Root-level if/then/else
144    if s.if_.is_some() {
145        use crate::schema::variant_summary;
146        write_section(&mut out, "CONDITIONAL", &f);
147        if let Some(ref if_sv) = s.if_ {
148            let summary = variant_summary(if_sv, root, &f);
149            let _ = writeln!(out, "    If: {summary}");
150        }
151        if let Some(ref then_sv) = s.then_ {
152            let summary = variant_summary(then_sv, root, &f);
153            let _ = writeln!(out, "    Then: {summary}");
154        }
155        if let Some(ref else_sv) = s.else_ {
156            let summary = variant_summary(else_sv, root, &f);
157            let _ = writeln!(out, "    Else: {summary}");
158        }
159        out.push('\n');
160    }
161
162    if type_str.as_deref() == Some("array")
163        && let Some(ref items) = s.items
164    {
165        write_section(&mut out, "ITEMS", &f);
166        render_subschema(&mut out, items, &render_root, &f, 1);
167        out.push('\n');
168    }
169
170    render_examples_section(&mut out, &s, &f);
171    // Resolve allOf/oneOf/anyOf $refs against the original root — the flattened
172    // schema may have pruned merged $defs entries.
173    render_variants_section(&mut out, &s, root, &f);
174    render_definitions_section(&mut out, &s, &render_root, &f);
175
176    out
177}
178
179/// Render a sub-schema at a given JSON Pointer path.
180///
181/// Navigates `pointer` within `schema`, then renders the sub-schema the same
182/// way [`explain`] renders the root. `name` is used in the header.
183///
184/// # Errors
185///
186/// Returns an error if the pointer cannot be resolved within the schema.
187pub fn explain_at_path(
188    schema: &SchemaValue,
189    pointer: &str,
190    name: &str,
191    opts: &ExplainOptions,
192) -> Result<String, String> {
193    let sub = navigate_pointer(schema, schema, pointer)?;
194    Ok(explain(sub, name, opts))
195}
196
197#[cfg(test)]
198#[allow(clippy::unwrap_used)]
199mod tests {
200    use super::*;
201    use crate::fmt::{BLUE, BOLD, CYAN, GREEN, RESET, format_header, format_type};
202    use serde_json::json;
203
204    /// Parse a JSON value into a `SchemaValue`, running migration first
205    /// to ensure compatibility with older JSON Schema drafts.
206    fn sv(val: serde_json::Value) -> SchemaValue {
207        SchemaValue::Schema(Box::new(jsonschema_migrate::migrate(val).unwrap()))
208    }
209
210    fn plain() -> ExplainOptions {
211        ExplainOptions {
212            color: false,
213            syntax_highlight: false,
214            width: 80,
215            validation_errors: vec![],
216            extended: false,
217        }
218    }
219
220    fn colored() -> ExplainOptions {
221        ExplainOptions {
222            color: true,
223            syntax_highlight: true,
224            width: 80,
225            validation_errors: vec![],
226            extended: false,
227        }
228    }
229
230    #[test]
231    fn simple_object_schema() {
232        let schema = sv(json!({
233            "title": "Test",
234            "description": "A test schema",
235            "type": "object",
236            "properties": {
237                "name": {
238                    "type": "string",
239                    "description": "The name field"
240                },
241                "age": {
242                    "type": "integer",
243                    "description": "The age field"
244                }
245            }
246        }));
247
248        let output = explain(&schema, "test", &plain());
249        assert!(output.contains("TITLE"));
250        assert!(output.contains("Test"));
251        assert!(!output.contains("Test - A test schema"));
252        assert!(output.contains("DESCRIPTION"));
253        assert!(output.contains("A test schema"));
254        assert!(output.contains("PROPERTIES"));
255        assert!(output.contains("name (string)"));
256        assert!(output.contains("The name field"));
257        assert!(output.contains("age (integer)"));
258    }
259
260    #[test]
261    fn nested_object_renders_with_indentation() {
262        let schema = sv(json!({
263            "type": "object",
264            "properties": {
265                "config": {
266                    "type": "object",
267                    "description": "Configuration block",
268                    "properties": {
269                        "debug": {
270                            "type": "boolean",
271                            "description": "Enable debug mode"
272                        }
273                    }
274                }
275            }
276        }));
277
278        let output = explain(&schema, "nested", &plain());
279        assert!(output.contains("config (object)"));
280        assert!(output.contains("debug (boolean)"));
281        assert!(output.contains("Enable debug mode"));
282    }
283
284    #[test]
285    fn enum_values_listed() {
286        let schema = sv(json!({
287            "type": "object",
288            "properties": {
289                "level": {
290                    "type": "string",
291                    "enum": ["low", "medium", "high"]
292                }
293            }
294        }));
295
296        let output = explain(&schema, "enum-test", &plain());
297        assert!(output.contains("Values: low, medium, high"));
298    }
299
300    #[test]
301    fn required_properties_marked() {
302        let schema = sv(json!({
303            "type": "object",
304            "required": ["name"],
305            "properties": {
306                "name": {
307                    "type": "string"
308                },
309                "optional": {
310                    "type": "string"
311                }
312            }
313        }));
314
315        let output = explain(&schema, "required-test", &plain());
316        assert!(output.contains("name (string, *required)"));
317        assert!(output.contains("optional (string)"));
318        assert!(!output.contains("optional (string, *required)"));
319
320        // Required fields should appear before optional fields
321        let name_pos = output
322            .find("name (string")
323            .expect("name field should be present");
324        let optional_pos = output
325            .find("optional (string")
326            .expect("optional field should be present");
327        assert!(
328            name_pos < optional_pos,
329            "required field 'name' should appear before optional field"
330        );
331    }
332
333    #[test]
334    fn schema_with_no_properties_handled() {
335        let schema = sv(json!({
336            "type": "string",
337            "description": "A plain string type"
338        }));
339
340        let output = explain(&schema, "simple", &plain());
341        assert!(!output.contains("TITLE"));
342        assert!(output.contains("DESCRIPTION"));
343        assert!(output.contains("A plain string type"));
344        assert!(!output.contains("PROPERTIES"));
345    }
346
347    #[test]
348    fn color_output_contains_ansi() {
349        let schema = sv(json!({
350            "title": "Colored",
351            "type": "object",
352            "properties": {
353                "x": { "type": "string" }
354            }
355        }));
356
357        let colored_out = explain(&schema, "colored", &colored());
358        let plain_out = explain(&schema, "colored", &plain());
359
360        assert!(colored_out.contains(BOLD));
361        assert!(colored_out.contains(RESET));
362        assert!(colored_out.contains(CYAN));
363        assert!(colored_out.contains(GREEN));
364        assert!(!plain_out.contains(BOLD));
365        assert!(!plain_out.contains(RESET));
366    }
367
368    #[test]
369    fn default_value_shown() {
370        let schema = sv(json!({
371            "type": "object",
372            "properties": {
373                "port": {
374                    "type": "integer",
375                    "default": 8080
376                }
377            }
378        }));
379
380        let output = explain(&schema, "defaults", &plain());
381        assert!(output.contains("Default: 8080"));
382    }
383
384    #[test]
385    fn long_default_wraps() {
386        let long_val = "First of: `tsconfig.json` rootDir if specified, directory containing `tsconfig.json`, or cwd if no `tsconfig.json` is loaded.";
387        let schema = sv(json!({
388            "type": "object",
389            "properties": {
390                "declarationDir": {
391                    "type": "string",
392                    "default": long_val
393                }
394            }
395        }));
396
397        let output = explain(&schema, "wrap-test", &plain());
398        // Short defaults stay on one line, but this is too long — the label
399        // should appear on its own line with the value wrapped below.
400        assert!(
401            output.contains("Default:\n"),
402            "long default should wrap onto next line\n{output}"
403        );
404        assert!(
405            output.contains(long_val),
406            "full default value should appear in output\n{output}"
407        );
408    }
409
410    #[test]
411    fn ref_resolution() {
412        let schema = sv(json!({
413            "type": "object",
414            "properties": {
415                "item": { "$ref": "#/$defs/Item" }
416            },
417            "$defs": {
418                "Item": {
419                    "type": "object",
420                    "description": "An item definition"
421                }
422            }
423        }));
424
425        let output = explain(&schema, "ref-test", &plain());
426        assert!(output.contains("item (object)"));
427        assert!(output.contains("An item definition"));
428    }
429
430    #[test]
431    fn format_header_centers() {
432        let h = format_header("TEST", "JSON Schema", 76);
433        assert!(h.starts_with("TEST"));
434        assert!(h.ends_with("TEST"));
435        assert!(h.contains("JSON Schema"));
436        assert_eq!(h.len(), 76);
437    }
438
439    #[test]
440    fn format_header_uses_full_width() {
441        let h = format_header("CARGO MANIFEST", "JSON Schema", 120);
442        assert_eq!(h.len(), 120);
443        assert!(h.starts_with("CARGO MANIFEST"));
444        assert!(h.ends_with("CARGO MANIFEST"));
445    }
446
447    #[test]
448    fn explain_output_uses_width() {
449        let schema = sv(json!({"type": "object", "title": "Test"}));
450        let opts_120 = ExplainOptions {
451            width: 120,
452            ..plain()
453        };
454        let output_80 = explain(&schema, "test", &plain());
455        let output_120 = explain(&schema, "test", &opts_120);
456        let header_80 = output_80.lines().next().unwrap();
457        let header_120 = output_120.lines().next().unwrap();
458        assert_eq!(header_80.len(), 80);
459        assert_eq!(header_120.len(), 120);
460    }
461
462    #[test]
463    fn inline_backtick_colorization() {
464        let f = Fmt::color(80);
465        let result = markdown_to_ansi::render_inline("Use `foo` and `bar`", &f.md_opts(None));
466        assert!(result.contains(BLUE));
467        assert!(result.contains("foo"));
468        assert!(result.contains("bar"));
469        assert!(!result.contains('`'));
470    }
471
472    #[test]
473    fn inline_bold_rendering() {
474        let f = Fmt::color(80);
475        let result =
476            markdown_to_ansi::render_inline("This is **important** text", &f.md_opts(None));
477        assert!(result.contains(BOLD));
478        assert!(result.contains("important"));
479        assert!(!result.contains("**"));
480    }
481
482    #[test]
483    fn inline_markdown_link() {
484        let f = Fmt::color(80);
485        let result = markdown_to_ansi::render_inline(
486            "See [docs](https://example.com) here",
487            &f.md_opts(None),
488        );
489        assert!(result.contains("docs"));
490        assert!(result.contains("https://example.com"));
491        assert!(result.contains("\x1b]8;;"));
492    }
493
494    #[test]
495    fn inline_raw_url() {
496        let f = Fmt::color(80);
497        let result =
498            markdown_to_ansi::render_inline("See more: https://example.com/foo", &f.md_opts(None));
499        assert!(result.contains("https://example.com/foo"));
500    }
501
502    #[test]
503    fn type_formatting_union() {
504        let f = Fmt::plain(80);
505        let result = format_type("object | null", &f);
506        assert!(result.contains("object"));
507        assert!(result.contains("null"));
508        assert!(result.contains('|'));
509    }
510
511    #[test]
512    fn prefers_markdown_description() {
513        let schema = sv(json!({
514            "type": "object",
515            "properties": {
516                "target": {
517                    "type": "string",
518                    "description": "Plain description",
519                    "markdownDescription": "Rich **markdown** description"
520                }
521            }
522        }));
523
524        let output = explain(&schema, "test", &plain());
525        assert!(output.contains("Rich **markdown** description"));
526        assert!(!output.contains("Plain description"));
527    }
528
529    #[test]
530    fn no_premature_wrapping() {
531        let schema = sv(json!({
532            "type": "object",
533            "properties": {
534                "x": {
535                    "type": "string",
536                    "description": "This is a very long description that should not be wrapped at 72 characters because we want the pager to handle wrapping at the terminal width instead"
537                }
538            }
539        }));
540
541        let output = explain(&schema, "test", &plain());
542        let desc_line = output
543            .lines()
544            .find(|l| l.contains("This is a very long"))
545            .expect("description line should be present");
546        assert!(desc_line.contains("terminal width instead"));
547    }
548
549    // --- explain_at_path ---
550
551    #[test]
552    fn explain_at_path_shows_sub_schema() {
553        let schema = sv(json!({
554            "type": "object",
555            "properties": {
556                "name": {
557                    "type": "string",
558                    "description": "The name field"
559                },
560                "config": {
561                    "type": "object",
562                    "title": "Config",
563                    "description": "Configuration settings",
564                    "properties": {
565                        "debug": { "type": "boolean" }
566                    }
567                }
568            }
569        }));
570
571        let output = explain_at_path(&schema, "/properties/config", "test", &plain()).unwrap();
572        assert!(output.contains("Config"));
573        assert!(output.contains("Configuration settings"));
574        assert!(output.contains("debug (boolean)"));
575        // Should NOT contain the sibling "name" property
576        assert!(!output.contains("The name field"));
577    }
578
579    #[test]
580    fn explain_at_path_root_pointer_shows_full_schema() {
581        let schema = sv(json!({
582            "type": "object",
583            "title": "Root",
584            "properties": {
585                "a": { "type": "string" }
586            }
587        }));
588
589        let output = explain_at_path(&schema, "", "test", &plain()).unwrap();
590        assert!(output.contains("Root"));
591        assert!(output.contains("a (string)"));
592    }
593
594    #[test]
595    fn explain_at_path_resolves_ref() {
596        let schema = sv(json!({
597            "type": "object",
598            "properties": {
599                "item": { "$ref": "#/$defs/Item" }
600            },
601            "$defs": {
602                "Item": {
603                    "type": "object",
604                    "title": "Item",
605                    "description": "An item",
606                    "properties": {
607                        "id": { "type": "integer" }
608                    }
609                }
610            }
611        }));
612
613        let output = explain_at_path(&schema, "/properties/item", "test", &plain()).unwrap();
614        assert!(output.contains("Item"));
615        assert!(output.contains("An item"));
616        assert!(output.contains("id (integer)"));
617    }
618
619    #[test]
620    fn explain_at_path_bad_pointer_errors() {
621        let schema = sv(json!({"type": "object"}));
622        let err = explain_at_path(&schema, "/nonexistent/path", "test", &plain());
623        assert!(err.is_err());
624        assert!(err.unwrap_err().contains("nonexistent"));
625    }
626
627    #[test]
628    fn property_examples_shown() {
629        let schema = sv(json!({
630            "type": "object",
631            "properties": {
632                "name": {
633                    "type": "string",
634                    "examples": ["TAG-ID", "DUNS"]
635                }
636            }
637        }));
638
639        let output = explain(&schema, "examples-test", &plain());
640        assert!(output.contains("Examples: \"TAG-ID\", \"DUNS\""));
641    }
642
643    #[test]
644    fn explain_at_path_deep_nesting() {
645        let schema = sv(json!({
646            "type": "object",
647            "properties": {
648                "a": {
649                    "type": "object",
650                    "properties": {
651                        "b": {
652                            "type": "object",
653                            "title": "Deep",
654                            "properties": {
655                                "c": { "type": "string", "description": "Deeply nested" }
656                            }
657                        }
658                    }
659                }
660            }
661        }));
662
663        let output =
664            explain_at_path(&schema, "/properties/a/properties/b", "test", &plain()).unwrap();
665        assert!(output.contains("Deep"));
666        assert!(output.contains("c (string)"));
667        assert!(output.contains("Deeply nested"));
668    }
669
670    #[test]
671    fn numeric_constraints_shown() {
672        let schema = sv(json!({
673            "type": "object",
674            "properties": {
675                "port": {
676                    "type": "integer",
677                    "minimum": 1,
678                    "maximum": 65535
679                }
680            }
681        }));
682
683        let output = explain(&schema, "constraints", &plain());
684        assert!(output.contains("Constraints:"));
685        assert!(output.contains("min=1"));
686        assert!(output.contains("max=65535"));
687    }
688
689    #[test]
690    fn string_constraints_shown() {
691        let schema = sv(json!({
692            "type": "object",
693            "properties": {
694                "email": {
695                    "type": "string",
696                    "format": "email",
697                    "minLength": 5,
698                    "maxLength": 255,
699                    "pattern": "^[^@]+@[^@]+$"
700                }
701            }
702        }));
703
704        let output = explain(&schema, "constraints", &plain());
705        assert!(output.contains("format=email"));
706        assert!(output.contains("minLength=5"));
707        assert!(output.contains("maxLength=255"));
708        assert!(output.contains("pattern=^[^@]+@[^@]+$"));
709    }
710
711    #[test]
712    fn array_constraints_shown() {
713        let schema = sv(json!({
714            "type": "object",
715            "properties": {
716                "tags": {
717                    "type": "array",
718                    "items": { "type": "string" },
719                    "minItems": 1,
720                    "maxItems": 10,
721                    "uniqueItems": true
722                }
723            }
724        }));
725
726        let output = explain(&schema, "constraints", &plain());
727        assert!(output.contains("minItems=1"));
728        assert!(output.contains("maxItems=10"));
729        assert!(output.contains("unique"));
730    }
731
732    #[test]
733    fn exclusive_bounds_and_multiple_of_shown() {
734        let schema = sv(json!({
735            "type": "object",
736            "properties": {
737                "score": {
738                    "type": "number",
739                    "exclusiveMinimum": 0,
740                    "exclusiveMaximum": 100,
741                    "multipleOf": 0.5
742                }
743            }
744        }));
745
746        let output = explain(&schema, "constraints", &plain());
747        assert!(output.contains("exclusiveMin=0"));
748        assert!(output.contains("exclusiveMax=100"));
749        assert!(output.contains("multipleOf=0.5"));
750    }
751
752    #[test]
753    fn no_constraints_line_when_none() {
754        let schema = sv(json!({
755            "type": "object",
756            "properties": {
757                "name": {
758                    "type": "string",
759                    "description": "Just a name"
760                }
761            }
762        }));
763
764        let output = explain(&schema, "no-constraints", &plain());
765        assert!(!output.contains("Constraints:"));
766    }
767
768    // --- TITLE section ---
769
770    #[test]
771    fn title_section_shows_schema_title() {
772        let schema = sv(json!({
773            "title": "My Schema",
774            "type": "object"
775        }));
776
777        let output = explain(&schema, "display-name", &plain());
778        assert!(output.contains("TITLE"));
779        assert!(output.contains("My Schema"));
780    }
781
782    #[test]
783    fn title_section_hidden_without_schema_title() {
784        let schema = sv(json!({ "type": "object" }));
785
786        let output = explain(&schema, "fallback-name", &plain());
787        assert!(!output.contains("TITLE"));
788        // display name still appears in the header banner (not uppercased)
789        assert!(output.contains("fallback-name"));
790    }
791
792    #[test]
793    fn schema_section_appears_after_description() {
794        let schema = sv(json!({
795            "$id": "https://json.schemastore.org/cargo.json",
796            "type": "object",
797            "title": "Cargo",
798            "description": "Cargo manifest schema"
799        }));
800
801        let output = explain(&schema, "cargo", &plain());
802        let desc_pos = output.find("DESCRIPTION").unwrap();
803        let schema_pos = output.find("SCHEMA").unwrap();
804        let type_pos = output.find("TYPE").unwrap();
805        assert!(
806            desc_pos < schema_pos,
807            "SCHEMA should appear after DESCRIPTION"
808        );
809        assert!(schema_pos < type_pos, "SCHEMA should appear before TYPE");
810    }
811
812    // --- New keyword rendering ---
813
814    #[test]
815    fn comment_shown() {
816        let schema = sv(json!({
817            "type": "object",
818            "properties": {
819                "x": {
820                    "type": "string",
821                    "$comment": "See https://example.com for details"
822                }
823            }
824        }));
825
826        let extended = ExplainOptions {
827            extended: true,
828            ..plain()
829        };
830        let output = explain(&schema, "comment-test", &extended);
831        assert!(output.contains("Comment:"));
832        assert!(output.contains("See https://example.com for details"));
833    }
834
835    #[test]
836    fn comment_hidden_by_default() {
837        let schema = sv(json!({
838            "type": "object",
839            "$comment": "Hidden comment",
840            "properties": {
841                "x": {
842                    "type": "string",
843                    "$comment": "Also hidden"
844                }
845            }
846        }));
847
848        let output = explain(&schema, "comment-test", &plain());
849        assert!(!output.contains("Comment"));
850        assert!(!output.contains("Hidden comment"));
851        assert!(!output.contains("Also hidden"));
852    }
853
854    #[test]
855    fn root_comment_shown() {
856        let schema = sv(json!({
857            "$comment": "Root level comment",
858            "type": "object"
859        }));
860
861        let extended = ExplainOptions {
862            extended: true,
863            ..plain()
864        };
865        let output = explain(&schema, "comment-test", &extended);
866        assert!(output.contains("COMMENT"));
867        assert!(output.contains("Root level comment"));
868        // No double blank lines — only one blank line between sections
869        assert!(
870            !output.contains("\n\n\n"),
871            "should not have triple newlines (double blank lines)\n{output}"
872        );
873    }
874
875    #[test]
876    fn additional_properties_false() {
877        let schema = sv(json!({
878            "type": "object",
879            "properties": {
880                "name": { "type": "string" }
881            },
882            "additionalProperties": false
883        }));
884
885        let output = explain(&schema, "ap-test", &plain());
886        assert!(output.contains("Additional properties: not allowed"));
887    }
888
889    #[test]
890    fn additional_properties_schema() {
891        let schema = sv(json!({
892            "type": "object",
893            "properties": {
894                "name": { "type": "string" }
895            },
896            "additionalProperties": { "type": "string" }
897        }));
898
899        let output = explain(&schema, "ap-test", &plain());
900        assert!(output.contains("Additional properties: string"));
901    }
902
903    #[test]
904    fn additional_properties_true_not_shown() {
905        let schema = sv(json!({
906            "type": "object",
907            "additionalProperties": true
908        }));
909
910        let output = explain(&schema, "ap-test", &plain());
911        assert!(!output.contains("Additional properties"));
912    }
913
914    #[test]
915    fn pattern_properties_shown() {
916        let schema = sv(json!({
917            "type": "object",
918            "patternProperties": {
919                "^x-": { "type": "object", "description": "Extension properties" }
920            }
921        }));
922
923        let output = explain(&schema, "pp-test", &plain());
924        assert!(output.contains("Pattern properties:"));
925        assert!(output.contains("^x-"));
926        assert!(output.contains("Extension properties"));
927    }
928
929    #[test]
930    fn if_then_else_shown() {
931        let schema = sv(json!({
932            "type": "object",
933            "if": { "properties": { "type": { "const": "a" } } },
934            "then": { "properties": { "value": { "type": "string" } } },
935            "else": { "properties": { "value": { "type": "integer" } } }
936        }));
937
938        let output = explain(&schema, "cond-test", &plain());
939        assert!(output.contains("CONDITIONAL"));
940        assert!(output.contains("If:"));
941        assert!(output.contains("Then:"));
942        assert!(output.contains("Else:"));
943    }
944
945    #[test]
946    fn not_shown() {
947        let schema = sv(json!({
948            "type": "object",
949            "properties": {
950                "x": {
951                    "not": { "type": "string" }
952                }
953            }
954        }));
955
956        let output = explain(&schema, "not-test", &plain());
957        assert!(output.contains("Not: string"));
958    }
959
960    #[test]
961    fn dependent_required_shown() {
962        let schema = sv(json!({
963            "type": "object",
964            "properties": {
965                "config": {
966                    "type": "object",
967                    "dependentRequired": {
968                        "bar": ["foo"]
969                    }
970                }
971            }
972        }));
973
974        let output = explain(&schema, "dr-test", &plain());
975        assert!(output.contains("Dependent required:"));
976        assert!(output.contains("\"bar\""));
977        assert!(output.contains("\"foo\""));
978    }
979
980    #[test]
981    fn property_names_shown() {
982        let schema = sv(json!({
983            "type": "object",
984            "properties": {
985                "config": {
986                    "type": "object",
987                    "propertyNames": { "pattern": "^[a-z]+$" }
988                }
989            }
990        }));
991
992        let output = explain(&schema, "pn-test", &plain());
993        assert!(output.contains("Property names: pattern=^[a-z]+$"));
994    }
995
996    #[test]
997    fn prefix_items_shown() {
998        let schema = sv(json!({
999            "type": "object",
1000            "properties": {
1001                "tuple": {
1002                    "type": "array",
1003                    "prefixItems": [
1004                        { "type": "string" },
1005                        { "type": "integer" }
1006                    ]
1007                }
1008            }
1009        }));
1010
1011        let output = explain(&schema, "prefix-test", &plain());
1012        assert!(output.contains("Tuple items:"));
1013        assert!(output.contains("[0]: string"));
1014        assert!(output.contains("[1]: integer"));
1015    }
1016
1017    #[test]
1018    fn contains_shown() {
1019        let schema = sv(json!({
1020            "type": "object",
1021            "properties": {
1022                "arr": {
1023                    "type": "array",
1024                    "contains": { "type": "string" },
1025                    "minContains": 1
1026                }
1027            }
1028        }));
1029
1030        let output = explain(&schema, "contains-test", &plain());
1031        assert!(output.contains("Contains: string"));
1032        assert!(output.contains("minContains=1"));
1033    }
1034
1035    #[test]
1036    fn read_only_tag_shown() {
1037        let schema = sv(json!({
1038            "type": "object",
1039            "properties": {
1040                "id": {
1041                    "type": "string",
1042                    "readOnly": true
1043                }
1044            }
1045        }));
1046
1047        let output = explain(&schema, "ro-test", &plain());
1048        assert!(output.contains("[READ-ONLY]"));
1049    }
1050
1051    #[test]
1052    fn write_only_tag_shown() {
1053        let schema = sv(json!({
1054            "type": "object",
1055            "properties": {
1056                "password": {
1057                    "type": "string",
1058                    "writeOnly": true
1059                }
1060            }
1061        }));
1062
1063        let output = explain(&schema, "wo-test", &plain());
1064        assert!(output.contains("[WRITE-ONLY]"));
1065    }
1066
1067    #[test]
1068    fn content_media_type_shown() {
1069        let schema = sv(json!({
1070            "type": "object",
1071            "properties": {
1072                "data": {
1073                    "type": "string",
1074                    "contentMediaType": "application/json",
1075                    "contentEncoding": "base64"
1076                }
1077            }
1078        }));
1079
1080        let output = explain(&schema, "content-test", &plain());
1081        assert!(output.contains("Content: application/json (base64)"));
1082    }
1083
1084    #[test]
1085    fn markdown_enum_descriptions_shown() {
1086        let schema = sv(json!({
1087            "type": "object",
1088            "properties": {
1089                "mode": {
1090                    "type": "string",
1091                    "enum": ["fast", "safe", "auto"],
1092                    "markdownEnumDescriptions": [
1093                        "Optimizes for speed",
1094                        "Optimizes for safety",
1095                        "Automatically chooses"
1096                    ]
1097                }
1098            }
1099        }));
1100
1101        let output = explain(&schema, "enum-desc-test", &plain());
1102        assert!(output.contains("Values:"));
1103        assert!(output.contains("fast"));
1104        assert!(output.contains("Optimizes for speed"));
1105        assert!(output.contains("—"));
1106    }
1107
1108    #[test]
1109    fn min_max_contains_in_constraints() {
1110        let schema = sv(json!({
1111            "type": "object",
1112            "properties": {
1113                "arr": {
1114                    "type": "array",
1115                    "minContains": 2,
1116                    "maxContains": 5
1117                }
1118            }
1119        }));
1120
1121        let output = explain(&schema, "contains-constraints", &plain());
1122        assert!(output.contains("minContains=2"));
1123        assert!(output.contains("maxContains=5"));
1124    }
1125
1126    #[test]
1127    fn dependent_schemas_shown() {
1128        let schema = sv(json!({
1129            "type": "object",
1130            "properties": {
1131                "config": {
1132                    "type": "object",
1133                    "dependentSchemas": {
1134                        "credit_card": {
1135                            "properties": {
1136                                "billing_address": { "type": "string" }
1137                            }
1138                        }
1139                    }
1140                }
1141            }
1142        }));
1143
1144        let output = explain(&schema, "ds-test", &plain());
1145        assert!(output.contains("Dependent schemas:"));
1146        assert!(output.contains("\"credit_card\""));
1147    }
1148}