Skip to main content

webspec_index/
format.rs

1//! Markdown output formatters for CLI commands
2
3use crate::model::{
4    AnchorsResult, ExistsResult, GraphResult, IdlResult, ListEntry, PrDiffResult, QueryResult,
5    RefsResult, SearchResult,
6};
7
8#[cfg(test)]
9use crate::model::{AnchorEntry, SearchEntry};
10
11/// Format a QueryResult as markdown
12pub fn query(result: &QueryResult) -> String {
13    let mut md = String::new();
14
15    md.push_str(&format!("# {}#{}\n\n", result.spec, result.anchor));
16
17    if let Some(title) = &result.title {
18        md.push_str(&format!("**{}** ({})\n\n", title, result.section_type));
19    } else {
20        md.push_str(&format!("**Type**: {}\n\n", result.section_type));
21    }
22
23    md.push_str(&format!("**SHA**: {}\n\n", result.sha));
24
25    if let Some(content) = &result.content {
26        md.push_str("## Content\n\n");
27        md.push_str(content);
28        md.push_str("\n\n");
29    }
30
31    // Navigation
32    md.push_str("## Navigation\n\n");
33    if let Some(parent) = &result.navigation.parent {
34        md.push_str(&format!(
35            "- Parent: `{}`{}\n",
36            parent.anchor,
37            parent
38                .title
39                .as_deref()
40                .map_or(String::new(), |t| format!(" — {}", t))
41        ));
42    }
43    if let Some(prev) = &result.navigation.prev {
44        md.push_str(&format!(
45            "- Prev: `{}`{}\n",
46            prev.anchor,
47            prev.title
48                .as_deref()
49                .map_or(String::new(), |t| format!(" — {}", t))
50        ));
51    }
52    if let Some(next) = &result.navigation.next {
53        md.push_str(&format!(
54            "- Next: `{}`{}\n",
55            next.anchor,
56            next.title
57                .as_deref()
58                .map_or(String::new(), |t| format!(" — {}", t))
59        ));
60    }
61    if !result.navigation.children.is_empty() {
62        md.push_str(&format!(
63            "- Children: {}\n",
64            result.navigation.children.len()
65        ));
66        for child in &result.navigation.children {
67            md.push_str(&format!(
68                "  - `{}`{}\n",
69                child.anchor,
70                child
71                    .title
72                    .as_deref()
73                    .map_or(String::new(), |t| format!(" — {}", t))
74            ));
75        }
76    }
77
78    if !result.outgoing_refs.is_empty() {
79        md.push_str(&format!(
80            "\n## Outgoing refs ({})\n\n",
81            result.outgoing_refs.len()
82        ));
83        for ref_entry in &result.outgoing_refs {
84            md.push_str(&format!("- {}#{}\n", ref_entry.spec, ref_entry.anchor));
85        }
86    }
87
88    if !result.incoming_refs.is_empty() {
89        md.push_str(&format!(
90            "\n## Incoming refs ({})\n\n",
91            result.incoming_refs.len()
92        ));
93        for ref_entry in &result.incoming_refs {
94            md.push_str(&format!("- {}#{}\n", ref_entry.spec, ref_entry.anchor));
95        }
96    }
97
98    md
99}
100
101/// Format an ExistsResult as markdown
102pub fn exists(result: &ExistsResult) -> String {
103    if result.exists {
104        format!(
105            "{}#{} exists ({})\n",
106            result.spec,
107            result.anchor,
108            result.section_type.as_deref().unwrap_or("unknown")
109        )
110    } else {
111        format!("{}#{} not found\n", result.spec, result.anchor)
112    }
113}
114
115/// Format an AnchorsResult as markdown
116pub fn anchors(result: &AnchorsResult) -> String {
117    let mut md = String::new();
118
119    md.push_str(&format!("# Anchors matching `{}`\n\n", result.pattern));
120
121    if result.results.is_empty() {
122        md.push_str("No results.\n");
123    } else {
124        for entry in &result.results {
125            md.push_str(&format!(
126                "- **{}#{}**{} ({})\n",
127                entry.spec,
128                entry.anchor,
129                entry
130                    .title
131                    .as_deref()
132                    .map_or(String::new(), |t| format!(" — {}", t)),
133                entry.section_type,
134            ));
135        }
136    }
137
138    md
139}
140
141/// Format a SearchResult as markdown
142pub fn search(result: &SearchResult) -> String {
143    let mut md = String::new();
144
145    md.push_str(&format!("# Search: \"{}\"\n\n", result.query));
146
147    if result.results.is_empty() {
148        md.push_str("No results.\n");
149    } else {
150        for entry in &result.results {
151            md.push_str(&format!(
152                "### {}#{}{}\n\n",
153                entry.spec,
154                entry.anchor,
155                entry
156                    .title
157                    .as_deref()
158                    .map_or(String::new(), |t| format!(" — {}", t)),
159            ));
160            if !entry.snippet.is_empty() {
161                md.push_str(&format!("{}\n\n", entry.snippet));
162            }
163        }
164    }
165
166    md
167}
168
169/// Format a list of headings as markdown (tree structure)
170pub fn list(entries: &[ListEntry]) -> String {
171    let mut md = String::new();
172
173    for entry in entries {
174        let indent = if entry.depth > 2 {
175            "  ".repeat((entry.depth - 2) as usize)
176        } else {
177            String::new()
178        };
179
180        md.push_str(&format!(
181            "{}- `{}`{}\n",
182            indent,
183            entry.anchor,
184            entry
185                .title
186                .as_deref()
187                .map_or(String::new(), |t| format!(" — {}", t)),
188        ));
189    }
190
191    md
192}
193
194/// Format a RefsResult as markdown
195pub fn refs(result: &RefsResult) -> String {
196    let mut md = String::new();
197    md.push_str(&format!(
198        "# refs: `{}` ({})\n\n",
199        result.query, result.direction
200    ));
201
202    if result.matches.is_empty() {
203        md.push_str("No matches found in indexed specs.\n");
204        return md;
205    }
206
207    for m in &result.matches {
208        md.push_str(&format!(
209            "## {}#{} ({}, {})\n\n",
210            m.spec, m.anchor, m.section_type, m.resolution
211        ));
212        if let Some(title) = &m.title {
213            md.push_str(&format!("Title: **{}**\n\n", title));
214        }
215
216        if let Some(incoming) = &m.incoming {
217            md.push_str(&format!("Incoming: {}\n", incoming.len()));
218            for r in incoming {
219                md.push_str(&format!("- {}#{}\n", r.spec, r.anchor));
220            }
221            md.push('\n');
222        }
223
224        if let Some(outgoing) = &m.outgoing {
225            md.push_str(&format!("Outgoing: {}\n", outgoing.len()));
226            for r in outgoing {
227                md.push_str(&format!("- {}#{}\n", r.spec, r.anchor));
228            }
229            md.push('\n');
230        }
231    }
232
233    md
234}
235
236/// Format a GraphResult as markdown
237pub fn graph(result: &GraphResult) -> String {
238    let mut md = String::new();
239    md.push_str(&format!(
240        "# graph {}#{} ({})\n\n",
241        result.root.spec, result.root.anchor, result.direction
242    ));
243    md.push_str(&format!(
244        "Nodes: {} | Edges: {} | Max depth: {} | Truncated: {}\n\n",
245        result.nodes.len(),
246        result.edges.len(),
247        result.max_depth,
248        result.truncated
249    ));
250
251    md.push_str("## Nodes\n\n");
252    for node in &result.nodes {
253        md.push_str(&format!(
254            "- `{}`{}\n",
255            node.id,
256            node.title
257                .as_deref()
258                .map_or(String::new(), |t| format!(" — {}", t))
259        ));
260    }
261
262    md.push_str("\n## Edges\n\n");
263    for edge in &result.edges {
264        md.push_str(&format!(
265            "- `{}` -> `{}` ({})\n",
266            edge.from, edge.to, edge.kind
267        ));
268    }
269
270    md
271}
272
273fn escape_mermaid_label(s: &str) -> String {
274    s.replace('\\', "\\\\").replace('"', "\\\"")
275}
276
277fn escape_dot_label(s: &str) -> String {
278    s.replace('\\', "\\\\").replace('"', "\\\"")
279}
280
281/// Render a GraphResult as Mermaid flowchart.
282pub fn graph_mermaid(result: &GraphResult) -> String {
283    let mut out = String::from("graph TD\n");
284    let mut ids = std::collections::HashMap::new();
285    let mut bridge_nodes = Vec::new();
286    let mut root_nodes = Vec::new();
287
288    for (idx, node) in result.nodes.iter().enumerate() {
289        let local_id = format!("n{}", idx);
290        ids.insert(node.id.clone(), local_id.clone());
291        let label = if let Some(title) = &node.title {
292            format!("{}<br>{}", node.id, title.replace('\n', "<br>"))
293        } else {
294            node.id.clone()
295        };
296        out.push_str(&format!(
297            "  {}[\"{}\"]\n",
298            local_id,
299            escape_mermaid_label(&label)
300        ));
301
302        if let Some(role) = &node.filter_role {
303            match role.as_str() {
304                "bridge" => bridge_nodes.push(local_id.clone()),
305                "root" => root_nodes.push(local_id.clone()),
306                _ => {}
307            }
308        }
309    }
310
311    for edge in &result.edges {
312        if let (Some(from), Some(to)) = (ids.get(&edge.from), ids.get(&edge.to)) {
313            out.push_str(&format!("  {} --> {}\n", from, to));
314        }
315    }
316
317    if !bridge_nodes.is_empty() {
318        out.push_str("  classDef bridge stroke-dasharray: 5 5\n");
319        out.push_str(&format!("  class {} bridge\n", bridge_nodes.join(",")));
320    }
321    if !root_nodes.is_empty() {
322        out.push_str("  classDef root stroke-width: 3px\n");
323        out.push_str(&format!("  class {} root\n", root_nodes.join(",")));
324    }
325
326    out
327}
328
329/// Render a GraphResult as Graphviz DOT.
330pub fn graph_dot(result: &GraphResult) -> String {
331    let mut out = String::from("digraph webspec {\n  rankdir=LR;\n");
332
333    for node in &result.nodes {
334        let escaped_id = escape_dot_label(&node.id);
335        let label = if let Some(title) = &node.title {
336            format!("{}\\n{}", escaped_id, escape_dot_label(title))
337        } else {
338            escaped_id.clone()
339        };
340        out.push_str(&format!("  \"{}\" [label=\"{}\"];\n", escaped_id, label));
341    }
342
343    for edge in &result.edges {
344        out.push_str(&format!(
345            "  \"{}\" -> \"{}\";\n",
346            escape_dot_label(&edge.from),
347            escape_dot_label(&edge.to)
348        ));
349    }
350
351    out.push_str("}\n");
352    out
353}
354
355/// Format an IdlResult as markdown
356pub fn idl(result: &IdlResult) -> String {
357    let mut md = String::new();
358    md.push_str(&format!("# IDL: `{}`\n\n", result.query));
359
360    if result.matches.is_empty() {
361        md.push_str("No IDL matches found.\n");
362        return md;
363    }
364
365    for entry in &result.matches {
366        md.push_str(&format!("## {} ({})\n\n", entry.canonical_name, entry.kind));
367        md.push_str(&format!("- Anchor: `{}#{}`\n", entry.spec, entry.anchor));
368        if let Some(owner) = &entry.owner {
369            md.push_str(&format!("- Owner: `{}`\n", owner));
370        }
371        md.push_str(&format!("- Name: `{}`\n", entry.name));
372        if let Some(title) = &entry.title {
373            md.push_str(&format!("- Title: {}\n", title));
374        }
375        if let Some(idl_text) = &entry.idl_text {
376            md.push_str("\n```webidl\n");
377            md.push_str(idl_text);
378            md.push_str("\n```\n");
379        }
380        md.push('\n');
381    }
382
383    md
384}
385
386/// Format a PrDiffResult as markdown with unified diffs per section
387pub fn pr_diff(result: &PrDiffResult) -> String {
388    use similar::TextDiff;
389
390    let mut out = String::new();
391    out.push_str(&format!(
392        "# {} PR #{} diff\n\nHead: `{}` | Base: `{}`\n\n",
393        result.spec, result.pr_number, result.head_sha, result.merge_base_sha
394    ));
395    out.push_str(&format!(
396        "**Summary:** {} added, {} removed, {} modified\n\n",
397        result.summary.added, result.summary.removed, result.summary.modified
398    ));
399
400    for change in &result.changes {
401        let title = change.title.as_deref().unwrap_or("(untitled)");
402        let icon = match change.change_type.as_str() {
403            "added" => "+",
404            "removed" => "-",
405            "modified" => "~",
406            _ => "?",
407        };
408        out.push_str(&format!(
409            "## {}{} `#{}` — {}\n\n",
410            icon, change.change_type, change.anchor, title
411        ));
412
413        match change.change_type.as_str() {
414            "added" => {
415                if let Some(content) = &change.new_content {
416                    out.push_str("```\n");
417                    out.push_str(content);
418                    if !content.ends_with('\n') {
419                        out.push('\n');
420                    }
421                    out.push_str("```\n\n");
422                }
423            }
424            "removed" => {
425                if let Some(content) = &change.old_content {
426                    out.push_str("```\n");
427                    out.push_str(content);
428                    if !content.ends_with('\n') {
429                        out.push('\n');
430                    }
431                    out.push_str("```\n\n");
432                }
433            }
434            "modified" => {
435                let old = change.old_content.as_deref().unwrap_or("");
436                let new = change.new_content.as_deref().unwrap_or("");
437                let diff = TextDiff::from_lines(old, new);
438                out.push_str("```diff\n");
439                for hunk in diff.unified_diff().context_radius(2).iter_hunks() {
440                    out.push_str(&format!("{hunk}"));
441                }
442                out.push_str("```\n\n");
443            }
444            _ => {}
445        }
446    }
447
448    out
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use crate::model::{
455        NavEntry, Navigation, PrDiffEntry, PrDiffResult, PrDiffSummary, RefEntry, RefsMatch,
456    };
457
458    #[test]
459    fn test_query_format_minimal() {
460        let result = QueryResult {
461            spec: "TEST".to_string(),
462            sha: "abc123".to_string(),
463            anchor: "test-section".to_string(),
464            title: None,
465            content: None,
466            section_type: "Heading".to_string(),
467            navigation: Navigation {
468                parent: None,
469                prev: None,
470                next: None,
471                children: vec![],
472            },
473            outgoing_refs: vec![],
474            incoming_refs: vec![],
475        };
476
477        let md = query(&result);
478        assert!(md.contains("# TEST#test-section"));
479        assert!(md.contains("**Type**: Heading"));
480        assert!(md.contains("**SHA**: abc123"));
481        assert!(md.contains("## Navigation"));
482    }
483
484    #[test]
485    fn test_query_format_with_content() {
486        let result = QueryResult {
487            spec: "TEST".to_string(),
488            sha: "abc123".to_string(),
489            anchor: "navigate".to_string(),
490            title: Some("navigate".to_string()),
491            content: Some("To **navigate** a [navigable](#foo)".to_string()),
492            section_type: "Algorithm".to_string(),
493            navigation: Navigation {
494                parent: Some(NavEntry {
495                    anchor: "section-7".to_string(),
496                    title: None,
497                }),
498                prev: None,
499                next: None,
500                children: vec![],
501            },
502            outgoing_refs: vec![],
503            incoming_refs: vec![],
504        };
505
506        let md = query(&result);
507        assert!(md.contains("**navigate** (Algorithm)"));
508        assert!(md.contains("## Content"));
509        assert!(md.contains("To **navigate** a [navigable](#foo)"));
510        assert!(md.contains("- Parent: `section-7`"));
511    }
512
513    #[test]
514    fn test_query_format_with_refs() {
515        let result = QueryResult {
516            spec: "TEST".to_string(),
517            sha: "abc123".to_string(),
518            anchor: "foo".to_string(),
519            title: None,
520            content: None,
521            section_type: "Definition".to_string(),
522            navigation: Navigation {
523                parent: None,
524                prev: None,
525                next: None,
526                children: vec![
527                    NavEntry {
528                        anchor: "child1".to_string(),
529                        title: Some("First Child".to_string()),
530                    },
531                    NavEntry {
532                        anchor: "child2".to_string(),
533                        title: None,
534                    },
535                ],
536            },
537            outgoing_refs: vec![RefEntry {
538                spec: "OTHER".to_string(),
539                anchor: "bar".to_string(),
540            }],
541            incoming_refs: vec![RefEntry {
542                spec: "ANOTHER".to_string(),
543                anchor: "baz".to_string(),
544            }],
545        };
546
547        let md = query(&result);
548        assert!(md.contains("- Children: 2"));
549        assert!(md.contains("  - `child1` — First Child"));
550        assert!(md.contains("  - `child2`"));
551        assert!(md.contains("## Outgoing refs (1)"));
552        assert!(md.contains("- OTHER#bar"));
553        assert!(md.contains("## Incoming refs (1)"));
554        assert!(md.contains("- ANOTHER#baz"));
555    }
556
557    #[test]
558    fn test_exists_true() {
559        let result = ExistsResult {
560            exists: true,
561            spec: "HTML".to_string(),
562            anchor: "navigate".to_string(),
563            section_type: Some("Algorithm".to_string()),
564        };
565        let md = exists(&result);
566        assert_eq!(md, "HTML#navigate exists (Algorithm)\n");
567    }
568
569    #[test]
570    fn test_exists_false() {
571        let result = ExistsResult {
572            exists: false,
573            spec: "DOM".to_string(),
574            anchor: "missing".to_string(),
575            section_type: None,
576        };
577        let md = exists(&result);
578        assert_eq!(md, "DOM#missing not found\n");
579    }
580
581    #[test]
582    fn test_anchors_format() {
583        let result = AnchorsResult {
584            pattern: "*-tree".to_string(),
585            results: vec![
586                AnchorEntry {
587                    spec: "DOM".to_string(),
588                    anchor: "concept-tree".to_string(),
589                    title: Some("tree".to_string()),
590                    section_type: "Definition".to_string(),
591                },
592                AnchorEntry {
593                    spec: "HTML".to_string(),
594                    anchor: "document-tree".to_string(),
595                    title: None,
596                    section_type: "Definition".to_string(),
597                },
598            ],
599        };
600
601        let md = anchors(&result);
602        assert!(md.contains("# Anchors matching `*-tree`"));
603        assert!(md.contains("- **DOM#concept-tree** — tree (Definition)"));
604        assert!(md.contains("- **HTML#document-tree** (Definition)"));
605    }
606
607    #[test]
608    fn test_search_format() {
609        let result = SearchResult {
610            query: "tree order".to_string(),
611            results: vec![SearchEntry {
612                spec: "DOM".to_string(),
613                anchor: "concept-tree-order".to_string(),
614                title: Some("tree order".to_string()),
615                section_type: "Definition".to_string(),
616                snippet: "An object A is before an object B in <mark>tree order</mark>..."
617                    .to_string(),
618            }],
619        };
620
621        let md = search(&result);
622        assert!(md.contains("# Search: \"tree order\""));
623        assert!(md.contains("### DOM#concept-tree-order — tree order"));
624        assert!(md.contains("An object A is before"));
625    }
626
627    #[test]
628    fn test_list_format() {
629        let entries = vec![
630            ListEntry {
631                anchor: "intro".to_string(),
632                title: Some("Introduction".to_string()),
633                depth: 2,
634                parent: None,
635            },
636            ListEntry {
637                anchor: "algorithms".to_string(),
638                title: Some("Algorithms".to_string()),
639                depth: 3,
640                parent: Some("intro".to_string()),
641            },
642        ];
643
644        let md = list(&entries);
645        assert!(md.contains("- `intro` — Introduction"));
646        assert!(md.contains("  - `algorithms` — Algorithms")); // depth 3 gets 1 level indent
647    }
648
649    #[test]
650    fn test_list_format_empty() {
651        let md = list(&[]);
652        assert_eq!(md, "");
653    }
654
655    #[test]
656    fn test_refs_format_both_directions() {
657        let result = RefsResult {
658            query: "HTML#navigate".to_string(),
659            direction: "both".to_string(),
660            matches: vec![RefsMatch {
661                spec: "HTML".to_string(),
662                anchor: "navigate".to_string(),
663                title: None,
664                section_type: "algorithm".to_string(),
665                resolution: "exact".to_string(),
666                outgoing: Some(vec![
667                    RefEntry {
668                        spec: "URL".to_string(),
669                        anchor: "concept-url".to_string(),
670                    },
671                    RefEntry {
672                        spec: "INFRA".to_string(),
673                        anchor: "assert".to_string(),
674                    },
675                ]),
676                incoming: Some(vec![RefEntry {
677                    spec: "HTML".to_string(),
678                    anchor: "navigate-fragid".to_string(),
679                }]),
680            }],
681        };
682
683        let md = refs(&result);
684        assert!(md.contains("# refs: `HTML#navigate`"));
685        assert!(md.contains("## HTML#navigate"));
686        assert!(md.contains("Outgoing: 2"));
687        assert!(md.contains("- URL#concept-url"));
688        assert!(md.contains("- INFRA#assert"));
689        assert!(md.contains("Incoming: 1"));
690        assert!(md.contains("- HTML#navigate-fragid"));
691    }
692
693    #[test]
694    fn test_refs_format_no_matches() {
695        let result = RefsResult {
696            query: "HTML#orphan".to_string(),
697            direction: "both".to_string(),
698            matches: vec![],
699        };
700
701        let md = refs(&result);
702        assert!(md.contains("# refs: `HTML#orphan`"));
703        assert!(md.contains("No matches found"));
704    }
705
706    #[test]
707    fn test_pr_diff_markdown_shows_unified_diff() {
708        let result = PrDiffResult {
709            spec: "HTML".to_string(),
710            pr_number: 123,
711            head_sha: "pr:123:abc".to_string(),
712            merge_base_sha: "def456".to_string(),
713            summary: PrDiffSummary {
714                added: 1,
715                removed: 0,
716                modified: 1,
717            },
718            changes: vec![
719                PrDiffEntry {
720                    anchor: "sec-a".to_string(),
721                    title: Some("Section A".to_string()),
722                    change_type: "modified".to_string(),
723                    old_content: Some("Line one\nLine two\nLine three".to_string()),
724                    new_content: Some("Line one\nLine TWO modified\nLine three".to_string()),
725                },
726                PrDiffEntry {
727                    anchor: "sec-b".to_string(),
728                    title: Some("Section B".to_string()),
729                    change_type: "added".to_string(),
730                    old_content: None,
731                    new_content: Some("Brand new content".to_string()),
732                },
733            ],
734        };
735
736        let md = pr_diff(&result);
737        assert!(md.contains("## ~modified `#sec-a` — Section A"));
738        assert!(md.contains("-Line two"));
739        assert!(md.contains("+Line TWO modified"));
740        assert!(md.contains("## +added `#sec-b` — Section B"));
741        assert!(md.contains("Brand new content"));
742    }
743}