1use crate::model::{
4 AnchorsResult, ExistsResult, GraphResult, IdlResult, ListEntry, PrDiffResult, QueryResult,
5 RefsResult, SearchResult,
6};
7
8#[cfg(test)]
9use crate::model::{AnchorEntry, SearchEntry};
10
11pub 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 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
101pub 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
115pub 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
141pub 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
169pub 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
194pub 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
236pub 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
281pub 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
329pub 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
355pub 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
386pub 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")); }
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}