1use serde_json::Value;
2use sha2::{Digest, Sha256};
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use crate::error::{CodeSynapseError, Result};
7use crate::security::{check_file_size, MAX_GRAPH_FILE_BYTES};
8
9#[derive(Debug, Clone)]
12pub struct CfNode {
13 pub id: String,
14 pub label: String,
15 pub source_file: String,
16 pub community: String,
17 pub node_type: String,
18 pub file_type: String,
19}
20
21#[derive(Debug, Clone)]
22pub struct CfEdge {
23 pub id: String,
24 pub source: String,
25 pub target: String,
26 pub relation: String,
27 pub confidence: String,
28 pub confidence_score: f64,
29}
30
31#[derive(Debug, Clone)]
32pub struct CallflowSection {
33 pub id: String,
34 pub name: String,
35 pub communities: Vec<String>,
36}
37
38struct Archetype {
41 id: &'static str,
42 en_name: &'static str,
43 keywords: &'static [&'static str],
44}
45
46static ARCHETYPES: &[Archetype] = &[
47 Archetype {
48 id: "extract-pipeline",
49 en_name: "Extraction Pipeline",
50 keywords: &[
51 "extract",
52 "extractor",
53 "tree",
54 "sitter",
55 "parser",
56 "language",
57 "python",
58 "javascript",
59 "typescript",
60 "rust",
61 "java",
62 "go",
63 "ast",
64 "calls",
65 "imports",
66 "multilang",
67 ],
68 },
69 Archetype {
70 id: "build-graph",
71 en_name: "Graph Build",
72 keywords: &[
73 "build",
74 "graph",
75 "merge",
76 "dedup",
77 "node",
78 "edge",
79 "hyperedge",
80 "json",
81 "schema",
82 "normalize",
83 "confidence",
84 ],
85 },
86 Archetype {
87 id: "analysis-clustering",
88 en_name: "Analysis & Clustering",
89 keywords: &[
90 "cluster",
91 "community",
92 "leiden",
93 "cohesion",
94 "analyze",
95 "god",
96 "surprise",
97 "question",
98 "query",
99 "path",
100 "explain",
101 "benchmark",
102 ],
103 },
104 Archetype {
105 id: "outputs-docs",
106 en_name: "Outputs & Docs",
107 keywords: &[
108 "export",
109 "html",
110 "wiki",
111 "obsidian",
112 "canvas",
113 "svg",
114 "graphml",
115 "report",
116 "callflow",
117 "mermaid",
118 "tree",
119 "documentation",
120 ],
121 },
122 Archetype {
123 id: "cli-skills",
124 en_name: "CLI & Skill Installers",
125 keywords: &[
126 "main",
127 "install",
128 "uninstall",
129 "skill",
130 "agent",
131 "claude",
132 "codex",
133 "opencode",
134 "aider",
135 "copilot",
136 "kiro",
137 "vscode",
138 "hook",
139 "command",
140 ],
141 },
142 Archetype {
143 id: "ingest-cache-update",
144 en_name: "Ingestion & Updates",
145 keywords: &[
146 "ingest",
147 "fetch",
148 "download",
149 "url",
150 "markdown",
151 "cache",
152 "manifest",
153 "watch",
154 "update",
155 "incremental",
156 "transcribe",
157 "video",
158 "audio",
159 "google",
160 ],
161 },
162 Archetype {
163 id: "serve-api",
164 en_name: "Serving API",
165 keywords: &[
166 "serve", "api", "request", "response", "endpoint", "router", "handle", "upload",
167 "search", "delete", "enrich",
168 ],
169 },
170 Archetype {
171 id: "security-global",
172 en_name: "Security & Global Graph",
173 keywords: &[
174 "security",
175 "safe",
176 "ssrf",
177 "xss",
178 "path",
179 "traversal",
180 "global",
181 "prefix",
182 "prune",
183 "repo",
184 "clone",
185 ],
186 },
187 Archetype {
188 id: "tests-fixtures",
189 en_name: "Tests & Fixtures",
190 keywords: &[
191 "test", "tests", "fixture", "fixtures", "sample", "assert", "pytest", "mock",
192 ],
193 },
194];
195
196fn html_escape(text: &str) -> String {
199 text.replace('&', "&")
200 .replace('<', "<")
201 .replace('>', ">")
202 .replace('"', """)
203}
204
205pub fn safe_mermaid_text(text: &str) -> String {
206 let mut s = text.to_string();
207 s = s.replace('"', "'");
208 s = s.replace('`', "");
209 s = s.replace('#', "");
210 s = s.replace('|', " ");
211 s = s.replace(['{', '}'], "");
212 s = s
213 .replace("->>", " to ")
214 .replace("-->", " to ")
215 .replace("->", " to ");
216 let s: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
217 html_escape(&s)
218}
219
220pub fn stable_ascii_id(raw: &str, prefix: &str, limit: usize) -> String {
221 let mut hasher = Sha256::new();
222 hasher.update(raw.as_bytes());
223 let digest = hasher.finalize();
224 let hex = format!("{:x}", digest);
225 let short = &hex[..8];
226
227 let slug: String = raw
228 .chars()
229 .map(|c| {
230 if c.is_ascii_alphanumeric() || c == '_' {
231 c
232 } else {
233 '_'
234 }
235 })
236 .collect();
237 let slug = slug.trim_matches('_');
238 let slug = if slug.is_empty() {
239 prefix.to_string()
240 } else {
241 slug.to_string()
242 };
243 let slug = if slug
244 .chars()
245 .next()
246 .map(|c| c.is_ascii_digit())
247 .unwrap_or(false)
248 {
249 format!("{}_{}", prefix, slug)
250 } else {
251 slug
252 };
253 let slug = &slug[..slug.len().min(limit)];
254 let slug = slug.trim_end_matches('_');
255 format!("{}_{}", slug, short)
256}
257
258pub fn node_mermaid_id(node: &CfNode) -> String {
259 stable_ascii_id(&node.id, "node", 48)
260}
261
262pub fn mermaid_section_id(section_id: &str) -> String {
263 stable_ascii_id(section_id, "section", 48).to_uppercase()
264}
265
266pub fn safe_file_path(path: &str) -> String {
267 let parts: Vec<&str> = path.split('/').collect();
268 if parts.len() > 3 {
269 parts[parts.len() - 3..].join("/")
270 } else {
271 path.to_string()
272 }
273}
274
275pub fn truncate_text(text: &str, limit: usize) -> String {
276 let s: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
277 if s.len() <= limit {
278 s
279 } else {
280 let end = limit.saturating_sub(3);
281 format!("{}...", s[..end].trim_end())
282 }
283}
284
285pub fn humanize_label(label: &str, source_file: &str) -> String {
286 let label = label.trim();
287 if label.is_empty() {
288 return Path::new(source_file)
289 .file_name()
290 .and_then(|n| n.to_str())
291 .unwrap_or("Unknown")
292 .to_string();
293 }
294 if label.starts_with('.') && label.ends_with("()") {
295 return label[1..].to_string();
296 }
297 if label.ends_with(".py")
298 || label.ends_with(".ts")
299 || label.ends_with(".tsx")
300 || label.ends_with(".js")
301 || label.ends_with(".jsx")
302 || label.ends_with(".go")
303 || label.ends_with(".rs")
304 || label.ends_with(".java")
305 || label.ends_with(".rb")
306 {
307 return Path::new(label)
308 .file_name()
309 .and_then(|n| n.to_str())
310 .unwrap_or(label)
311 .to_string();
312 }
313 if label.contains('_') && !label.contains(' ') && label.len() > 28 {
314 let parts: Vec<&str> = label.split('_').filter(|p| !p.is_empty()).collect();
315 if !parts.is_empty() {
316 let tail: Vec<&str> = if parts.len() > 3 {
317 parts[parts.len() - 3..].to_vec()
318 } else {
319 parts.clone()
320 };
321 return truncate_text(&tail.join(" "), 42);
322 }
323 }
324 truncate_text(label, 42)
325}
326
327pub fn node_kind(node: &CfNode) -> &'static str {
328 let label = node.label.to_lowercase();
329 let source_file = node.source_file.to_lowercase();
330 let file_type = node.file_type.to_lowercase();
331 let node_type = node.node_type.to_lowercase();
332
333 if matches!(
334 node_type.as_str(),
335 "class" | "klass" | "struct" | "interface" | "enum" | "trait" | "model"
336 ) {
337 return "klass";
338 }
339 if matches!(
340 node_type.as_str(),
341 "module" | "file" | "package" | "namespace"
342 ) {
343 return "module";
344 }
345 if matches!(
346 node_type.as_str(),
347 "endpoint" | "route" | "api" | "handler" | "controller"
348 ) {
349 return "api";
350 }
351 if matches!(node_type.as_str(), "test" | "spec") {
352 return "test";
353 }
354 if matches!(node_type.as_str(), "component" | "hook" | "view" | "page") {
355 return "ui";
356 }
357 if matches!(file_type.as_str(), "rationale" | "document") {
358 return "concept";
359 }
360 if source_file.contains("test") || label.starts_with("test_") || source_file.contains("spec") {
361 return "test";
362 }
363 if label.contains("endpoint")
364 || label.contains("router")
365 || label.contains("api")
366 || label.contains("route")
367 {
368 return "api";
369 }
370 if label.contains("cli")
371 || label.contains("command")
372 || label.contains("click")
373 || label.contains("typer")
374 {
375 return "entry";
376 }
377 if label.contains("async")
378 || label.contains("await")
379 || label.contains("stream")
380 || label.contains("sse")
381 {
382 return "async";
383 }
384 let raw_label = &node.label;
385 if (raw_label.starts_with("use")
386 && raw_label.len() > 3
387 && (raw_label
388 .chars()
389 .nth(3)
390 .map(|c| c.is_uppercase())
391 .unwrap_or(false)
392 || raw_label
393 .chars()
394 .nth(3)
395 .map(|c| c == '_' || c == '-')
396 .unwrap_or(false)))
397 || label.contains("component")
398 || label.contains("props")
399 || label.contains("hook")
400 || label.contains("store")
401 || source_file.ends_with(".tsx")
402 || source_file.ends_with(".jsx")
403 || source_file.ends_with(".vue")
404 || source_file.ends_with(".svelte")
405 {
406 return "ui";
407 }
408 if raw_label
409 .chars()
410 .next()
411 .map(|c| c.is_uppercase())
412 .unwrap_or(false)
413 && !raw_label.ends_with("()")
414 {
415 return "klass";
416 }
417 if raw_label.ends_with(".py")
418 || raw_label.ends_with(".ts")
419 || raw_label.ends_with(".tsx")
420 || raw_label.ends_with(".js")
421 || raw_label.ends_with(".jsx")
422 || raw_label.ends_with(".go")
423 || raw_label.ends_with(".rs")
424 || raw_label.ends_with(".java")
425 || raw_label.ends_with(".kt")
426 || raw_label.ends_with(".rb")
427 || raw_label.ends_with(".php")
428 || raw_label.ends_with(".cs")
429 || raw_label.ends_with(".swift")
430 || raw_label.ends_with(".vue")
431 || raw_label.ends_with(".svelte")
432 {
433 return "module";
434 }
435 "function"
436}
437
438fn mermaid_class_defs() -> Vec<&'static str> {
439 vec![
440 " classDef entry fill:#422006,stroke:#fbbf24,color:#fde68a,stroke-width:1px;",
441 " classDef api fill:#450a0a,stroke:#f87171,color:#fee2e2,stroke-width:1px;",
442 " classDef async fill:#2e1065,stroke:#a78bfa,color:#ede9fe,stroke-width:1px;",
443 " classDef klass fill:#064e3b,stroke:#34d399,color:#d1fae5,stroke-width:1px;",
444 " classDef ui fill:#831843,stroke:#f472b6,color:#fce7f3,stroke-width:1px;",
445 " classDef module fill:#172554,stroke:#60a5fa,color:#dbeafe,stroke-width:1px;",
446 " classDef test fill:#3f3f46,stroke:#a1a1aa,color:#f4f4f5,stroke-width:1px;",
447 " classDef concept fill:#292524,stroke:#a8a29e,color:#fafaf9,stroke-dasharray:4 3;",
448 " classDef function fill:#0f172a,stroke:#38bdf8,color:#e0f2fe,stroke-width:1px;",
449 ]
450}
451
452fn mermaid_init(scale: f64, direction: &str) -> String {
453 let scale = scale.clamp(0.65, 1.8);
454 let font_size = (15.0 * scale * 10.0).round() / 10.0;
455 let node_spacing = (48.0 * scale).round() as i64;
456 let rank_spacing = (64.0 * scale).round() as i64;
457 let padding = (14.0 * scale).round() as i64;
458 let diagram_padding = (10.0 * scale).round() as i64;
459 format!(
460 "%%{{init: {{\"theme\":\"dark\",\"themeVariables\":{{\"fontSize\":\"{font_size}px\",\"primaryColor\":\"#1e293b\",\"primaryTextColor\":\"#e2e8f0\",\"primaryBorderColor\":\"#38bdf8\",\"secondaryColor\":\"#0f172a\",\"tertiaryColor\":\"#334155\",\"lineColor\":\"#64748b\",\"textColor\":\"#e2e8f0\"}},\"flowchart\":{{\"htmlLabels\":true,\"curve\":\"basis\",\"nodeSpacing\":{node_spacing},\"rankSpacing\":{rank_spacing},\"padding\":{padding},\"diagramPadding\":{diagram_padding},\"useMaxWidth\":true}}}}}}%%\nflowchart {direction}"
461 )
462}
463
464static LABEL_STOPWORDS: &[&str] = &[
467 "the", "and", "for", "with", "from", "this", "that", "class", "function", "method", "file",
468 "src", "lib", "core", "index", "main", "init", "py", "ts", "tsx", "js", "jsx", "go", "rs",
469 "java", "html", "css",
470];
471
472fn section_keywords(nodes: &[&CfNode], limit: usize) -> Vec<String> {
473 let mut counts: HashMap<String, usize> = HashMap::new();
474 for node in nodes {
475 let text = format!("{} {}", node.label, node.source_file)
476 .replace('/', " ")
477 .replace(['_', '-'], " ");
478 for raw in text.split_whitespace() {
479 let word: String = raw.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
480 let word = word.to_lowercase();
481 if word.len() < 3 || LABEL_STOPWORDS.contains(&word.as_str()) {
482 continue;
483 }
484 *counts.entry(word).or_insert(0) += 1;
485 }
486 }
487 let mut pairs: Vec<(String, usize)> = counts.into_iter().collect();
488 pairs.sort_by(|a, b| b.1.cmp(&a.1).then(b.0.cmp(&a.0)));
489 pairs.into_iter().take(limit).map(|(w, _)| w).collect()
490}
491
492fn community_text(nodes: &[&CfNode], label: &str) -> String {
493 let mut parts = vec![label.to_string()];
494 for node in nodes.iter().take(80) {
495 parts.push(node.label.clone());
496 parts.push(node.source_file.clone());
497 parts.push(node.node_type.clone());
498 parts.push(node.file_type.clone());
499 }
500 parts.join(" ").to_lowercase()
501}
502
503fn keyword_score(text: &str, keywords: &[&str]) -> usize {
504 let tokens: Vec<String> = text
505 .split(|c: char| !c.is_ascii_alphanumeric())
506 .filter(|t| !t.is_empty())
507 .map(|t| t.to_lowercase())
508 .collect();
509 let mut score = 0usize;
510 for kw in keywords {
511 score += tokens.iter().filter(|t| t.as_str() == *kw).count();
512 }
513 score
514}
515
516fn label_for_community(cid: &str, labels: &HashMap<String, String>, nodes: &[&CfNode]) -> String {
517 if let Some(lbl) = labels.get(cid) {
518 if !lbl.is_empty() {
519 return lbl.clone();
520 }
521 }
522 let kws = section_keywords(nodes, 3);
523 if !kws.is_empty() {
524 kws.iter()
525 .map(|w| {
526 let mut c = w.chars();
527 match c.next() {
528 None => String::new(),
529 Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
530 }
531 })
532 .collect::<Vec<_>>()
533 .join(" ")
534 } else {
535 format!("Community {cid}")
536 }
537}
538
539pub fn derive_sections_from_communities(
542 nodes: &[CfNode],
543 labels: &HashMap<String, String>,
544 _lang: &str,
545 max_sections: usize,
546) -> Vec<CallflowSection> {
547 let mut comm_idx: HashMap<String, Vec<&CfNode>> = HashMap::new();
548 for node in nodes {
549 comm_idx
550 .entry(node.community.clone())
551 .or_default()
552 .push(node);
553 }
554
555 let mut sections = vec![CallflowSection {
556 id: "overview".to_string(),
557 name: "Architecture Overview".to_string(),
558 communities: vec![],
559 }];
560
561 struct GroupEntry {
562 id: String,
563 name: String,
564 communities: Vec<String>,
565 node_count: usize,
566 priority: usize,
567 }
568
569 let mut grouped: Vec<GroupEntry> = Vec::new();
570 let mut grouped_idx: HashMap<String, usize> = HashMap::new();
571 let mut unassigned: Vec<(String, Vec<String>)> = Vec::new();
572
573 let mut sorted_communities: Vec<(&String, &Vec<&CfNode>)> = comm_idx.iter().collect();
574 sorted_communities.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then(a.0.cmp(b.0)));
575
576 for (cid, community_nodes) in &sorted_communities {
577 let refs: Vec<&CfNode> = community_nodes.to_vec();
578 let lbl = label_for_community(cid, labels, &refs);
579 let text = community_text(&refs, &lbl);
580
581 let mut best_priority = usize::MAX;
582 let mut best_sid = String::new();
583 let mut best_name = String::new();
584 let mut best_score = 0usize;
585
586 for (priority, archetype) in ARCHETYPES.iter().enumerate() {
587 let score = keyword_score(&text, archetype.keywords);
588 if score > best_score {
589 best_score = score;
590 best_priority = priority;
591 best_sid = archetype.id.to_string();
592 best_name = archetype.en_name.to_string();
593 }
594 }
595
596 if best_score >= 2 {
597 if let Some(&idx) = grouped_idx.get(&best_sid) {
598 grouped[idx].communities.push((*cid).clone());
599 grouped[idx].node_count += community_nodes.len();
600 } else {
601 let idx = grouped.len();
602 grouped_idx.insert(best_sid.clone(), idx);
603 grouped.push(GroupEntry {
604 id: best_sid,
605 name: best_name,
606 communities: vec![(*cid).clone()],
607 node_count: community_nodes.len(),
608 priority: best_priority,
609 });
610 }
611 } else {
612 let node_labels: Vec<String> = community_nodes
613 .iter()
614 .take(3)
615 .map(|n| n.label.clone())
616 .collect();
617 unassigned.push(((*cid).clone(), node_labels));
618 }
619 }
620
621 let cap = max_sections.max(1);
622 grouped.sort_by(|a, b| {
623 a.priority
624 .cmp(&b.priority)
625 .then(b.node_count.cmp(&a.node_count))
626 .then(a.id.cmp(&b.id))
627 });
628 let selected_count = grouped.len().min(cap - 1);
629 let overflow: Vec<String> = grouped[selected_count..]
630 .iter()
631 .flat_map(|g| g.communities.clone())
632 .collect();
633
634 for g in &grouped[..selected_count] {
635 sections.push(CallflowSection {
636 id: g.id.clone(),
637 name: g.name.clone(),
638 communities: g.communities.clone(),
639 });
640 }
641
642 let remaining_slots = cap.saturating_sub(sections.len()).saturating_sub(1);
643 for (cid, node_labels) in unassigned.iter().take(remaining_slots) {
644 let name = if node_labels.is_empty() {
645 format!("Community {cid}")
646 } else {
647 node_labels[0].clone()
648 };
649 sections.push(CallflowSection {
650 id: cid.clone(),
651 name,
652 communities: vec![cid.clone()],
653 });
654 }
655
656 let other_communities: Vec<String> = overflow
657 .into_iter()
658 .chain(
659 unassigned
660 .iter()
661 .skip(remaining_slots)
662 .map(|(cid, _)| cid.clone()),
663 )
664 .collect();
665 if !other_communities.is_empty() {
666 sections.push(CallflowSection {
667 id: "other".to_string(),
668 name: "Other".to_string(),
669 communities: other_communities,
670 });
671 }
672
673 sections
674}
675
676fn first_str<'a>(map: &'a serde_json::Map<String, Value>, keys: &[&str]) -> Option<&'a str> {
679 for k in keys {
680 if let Some(Value::String(s)) = map.get(*k) {
681 if !s.is_empty() {
682 return Some(s.as_str());
683 }
684 }
685 }
686 None
687}
688
689fn normalize_node(raw: &Value, index: usize) -> Option<CfNode> {
690 let map = raw.as_object()?;
691 let id = first_str(
692 map,
693 &[
694 "id",
695 "node_id",
696 "key",
697 "uid",
698 "name",
699 "qualified_name",
700 "fqname",
701 "symbol",
702 ],
703 )
704 .map(|s| s.to_string())
705 .unwrap_or_else(|| {
706 if let Some(v) = map.get("id") {
708 return v.to_string().trim_matches('"').to_string();
709 }
710 format!("node_{}", index + 1)
711 });
712 let source_file = first_str(
713 map,
714 &[
715 "source_file",
716 "file",
717 "file_path",
718 "filepath",
719 "path",
720 "module_path",
721 "defined_in",
722 ],
723 )
724 .unwrap_or("")
725 .to_string();
726 let label = first_str(
727 map,
728 &[
729 "label",
730 "display_name",
731 "title",
732 "name",
733 "qualified_name",
734 "fqname",
735 "symbol",
736 ],
737 )
738 .unwrap_or(id.as_str())
739 .to_string();
740 let community = map
741 .get("community")
742 .map(|v| match v {
743 Value::String(s) => s.clone(),
744 Value::Number(n) => n.to_string(),
745 _ => "unknown".to_string(),
746 })
747 .unwrap_or_else(|| "unknown".to_string());
748 let node_type = first_str(map, &["node_type", "kind", "type", "category"])
749 .unwrap_or("")
750 .to_string();
751 let file_type = first_str(map, &["file_type", "content_type", "artifact_type"])
752 .unwrap_or("code")
753 .to_string();
754
755 Some(CfNode {
756 id,
757 label,
758 source_file,
759 community,
760 node_type,
761 file_type,
762 })
763}
764
765fn endpoint_id(v: &Value) -> String {
766 match v {
767 Value::String(s) => s.clone(),
768 Value::Object(m) => first_str(m, &["id", "node_id", "key", "name", "qualified_name"])
769 .unwrap_or("")
770 .to_string(),
771 Value::Number(n) => n.to_string(),
772 _ => String::new(),
773 }
774}
775
776fn normalize_edge(raw: &Value, index: usize) -> Option<CfEdge> {
777 let map = raw.as_object()?;
778 let source = map
779 .get("source")
780 .or_else(|| map.get("src"))
781 .or_else(|| map.get("from"))
782 .map(endpoint_id)
783 .unwrap_or_default();
784 let target = map
785 .get("target")
786 .or_else(|| map.get("dst"))
787 .or_else(|| map.get("to"))
788 .map(endpoint_id)
789 .unwrap_or_default();
790 if source.is_empty() || target.is_empty() {
791 return None;
792 }
793 let relation = first_str(map, &["relation", "type", "kind", "label", "predicate"])
794 .unwrap_or("relates")
795 .to_string();
796 let confidence = first_str(map, &["confidence", "evidence", "provenance"])
797 .unwrap_or("EXTRACTED")
798 .to_uppercase();
799 let confidence_score = match map
800 .get("confidence_score")
801 .or_else(|| map.get("score"))
802 .or_else(|| map.get("weight"))
803 {
804 Some(Value::Number(n)) => n.as_f64().unwrap_or(1.0),
805 Some(Value::String(s)) => s.parse::<f64>().unwrap_or(1.0),
806 _ => 1.0,
807 };
808 let id = first_str(map, &["id", "edge_id"])
809 .map(|s| s.to_string())
810 .unwrap_or_else(|| format!("edge_{}", index + 1));
811 Some(CfEdge {
812 id,
813 source,
814 target,
815 relation,
816 confidence,
817 confidence_score,
818 })
819}
820
821#[allow(clippy::type_complexity)]
822pub fn load_graph(
823 path: &Path,
824 max_bytes: u64,
825) -> Result<(Vec<CfNode>, Vec<CfEdge>, Vec<Value>, HashMap<String, Value>)> {
826 check_file_size(path, max_bytes)?;
827 let text = std::fs::read_to_string(path).map_err(CodeSynapseError::Io)?;
828 let data: Value = serde_json::from_str(&text).map_err(CodeSynapseError::Serialization)?;
829 let obj = data
830 .as_object()
831 .ok_or_else(|| CodeSynapseError::Validation("graph file must be a JSON object".into()))?;
832
833 let raw_nodes = obj
834 .get("nodes")
835 .and_then(|v| v.as_array())
836 .cloned()
837 .unwrap_or_default();
838 let raw_edges = obj
839 .get("links")
840 .and_then(|v| v.as_array())
841 .or_else(|| obj.get("edges").and_then(|v| v.as_array()))
842 .cloned()
843 .unwrap_or_default();
844 let hyperedges = obj
845 .get("hyperedges")
846 .and_then(|v| v.as_array())
847 .cloned()
848 .unwrap_or_default();
849
850 let nodes: Vec<CfNode> = raw_nodes
851 .iter()
852 .enumerate()
853 .filter_map(|(i, v)| normalize_node(v, i))
854 .collect();
855 let edges: Vec<CfEdge> = raw_edges
856 .iter()
857 .enumerate()
858 .filter_map(|(i, v)| normalize_edge(v, i))
859 .collect();
860
861 let mut meta: HashMap<String, Value> = HashMap::new();
862 if let Some(graph_block) = obj.get("graph").and_then(|v| v.as_object()) {
863 for (k, v) in graph_block {
864 meta.insert(k.clone(), v.clone());
865 }
866 }
867 for key in &[
868 "built_at_commit",
869 "commit",
870 "project_name",
871 "repo",
872 "language_breakdown",
873 ] {
874 if let Some(v) = obj.get(*key) {
875 meta.entry(key.to_string()).or_insert_with(|| v.clone());
876 }
877 }
878
879 Ok((nodes, edges, hyperedges, meta))
880}
881
882fn load_labels(path: &Path) -> HashMap<String, String> {
883 let text = match std::fs::read_to_string(path) {
884 Ok(t) => t,
885 Err(_) => return HashMap::new(),
886 };
887 let data: Value = match serde_json::from_str(&text) {
888 Ok(v) => v,
889 Err(_) => return HashMap::new(),
890 };
891 let mut result = HashMap::new();
892 if let Some(obj) = data.as_object() {
893 for (k, v) in obj {
894 match v {
895 Value::String(s) => {
896 result.insert(k.clone(), s.clone());
897 }
898 Value::Number(n) => {
899 result.insert(k.clone(), n.to_string());
900 }
901 _ => {}
902 }
903 }
904 }
905 result
906}
907
908fn load_report(path: &Path) -> String {
909 std::fs::read_to_string(path).unwrap_or_default()
910}
911
912fn infer_project_name(graph_path: &Path, meta: &HashMap<String, Value>) -> String {
913 if let Some(Value::String(s)) = meta.get("project_name") {
914 return s.clone();
915 }
916 if let Some(parent) = graph_path.parent() {
917 if parent.file_name().and_then(|n| n.to_str()) == Some("codesynapse-out") {
918 if let Some(pp) = parent.parent() {
919 if let Some(name) = pp.file_name().and_then(|n| n.to_str()) {
920 return name.to_string();
921 }
922 }
923 }
924 if let Some(name) = parent.file_name().and_then(|n| n.to_str()) {
925 return name.to_string();
926 }
927 }
928 "Project".to_string()
929}
930
931fn edge_score(edge: &CfEdge) -> f64 {
934 let mut score = edge.confidence_score;
935 if edge.confidence == "EXTRACTED" {
936 score += 2.0;
937 }
938 match edge.relation.as_str() {
939 "calls" | "uses" | "method" => score += 1.0,
940 "imports" | "imports_from" => score += 0.6,
941 "contains" => score -= 0.2,
942 "rationale_for" => score -= 0.6,
943 _ => {}
944 }
945 score
946}
947
948fn node_label_mermaid(node: &CfNode) -> String {
949 let label = humanize_label(&node.label, &node.source_file);
950 let sf = safe_file_path(&node.source_file);
951 let label_safe = safe_mermaid_text(&label);
952 if !sf.is_empty()
953 && !label.ends_with(
954 Path::new(&sf)
955 .file_name()
956 .and_then(|n| n.to_str())
957 .unwrap_or(""),
958 )
959 {
960 format!(
961 "{}<br/><small>{}</small>",
962 label_safe,
963 safe_mermaid_text(&sf)
964 )
965 } else {
966 label_safe
967 }
968}
969
970fn generate_section_flowchart(
971 section_id: &str,
972 section_name: &str,
973 nodes: &[&CfNode],
974 edges: &[&CfEdge],
975 diagram_scale: f64,
976 max_nodes: usize,
977 max_edges: usize,
978) -> String {
979 let mut lines = vec![mermaid_init(diagram_scale, "LR")];
980 lines.push(format!(
981 " %% Section: {} ({} nodes, {} edges)",
982 safe_mermaid_text(section_name),
983 nodes.len(),
984 edges.len()
985 ));
986
987 if nodes.is_empty() {
988 lines.push(format!(
989 " empty(\"{} - no nodes\")",
990 safe_mermaid_text(section_name)
991 ));
992 for def in mermaid_class_defs() {
993 lines.push(def.to_string());
994 }
995 return lines.join("\n");
996 }
997
998 let selected_nodes: Vec<&CfNode> = nodes.iter().copied().take(max_nodes).collect();
999 let selected_ids: std::collections::HashSet<&str> =
1000 selected_nodes.iter().map(|n| n.id.as_str()).collect();
1001
1002 let vis_edges: Vec<&CfEdge> = edges
1003 .iter()
1004 .copied()
1005 .filter(|e| {
1006 selected_ids.contains(e.source.as_str()) && selected_ids.contains(e.target.as_str())
1007 })
1008 .collect();
1009
1010 let mut class_lines: Vec<String> = Vec::new();
1011 for node in &selected_nodes {
1012 let mid = node_mermaid_id(node);
1013 let lbl = node_label_mermaid(node);
1014 lines.push(format!(" {}(\"{}\")", mid, lbl));
1015 class_lines.push(format!(" class {} {};", mid, node_kind(node)));
1016 }
1017
1018 let mut sorted_vis: Vec<&CfEdge> = vis_edges.clone();
1019 sorted_vis.sort_by(|a, b| {
1020 edge_score(b)
1021 .partial_cmp(&edge_score(a))
1022 .unwrap_or(std::cmp::Ordering::Equal)
1023 });
1024
1025 for edge in sorted_vis.into_iter().take(max_edges) {
1026 let src_id = stable_ascii_id(&edge.source, "node", 48);
1027 let tgt_id = stable_ascii_id(&edge.target, "node", 48);
1028 let rel = safe_mermaid_text(&edge.relation.replace('_', " "));
1029 lines.push(format!(" {} -->|{}| {}", src_id, rel, tgt_id));
1030 }
1031
1032 let _ = section_id;
1033 lines.extend(class_lines);
1034 for def in mermaid_class_defs() {
1035 lines.push(def.to_string());
1036 }
1037 lines.join("\n")
1038}
1039
1040fn generate_overview_graph(
1041 sections: &[CallflowSection],
1042 section_nodes_map: &HashMap<String, Vec<usize>>,
1043 nodes: &[CfNode],
1044 edges: &[CfEdge],
1045 diagram_scale: f64,
1046) -> String {
1047 let mut lines = vec![mermaid_init(diagram_scale, "LR")];
1048
1049 let node_section: HashMap<&str, &str> = {
1050 let mut m = HashMap::new();
1051 for sec in sections {
1052 if sec.id == "overview" {
1053 continue;
1054 }
1055 for &idx in section_nodes_map.get(&sec.id).unwrap_or(&vec![]) {
1056 m.insert(nodes[idx].id.as_str(), sec.id.as_str());
1057 }
1058 }
1059 m
1060 };
1061
1062 for sec in sections {
1063 if sec.id == "overview" {
1064 continue;
1065 }
1066 let sid = mermaid_section_id(&sec.id);
1067 let node_count = section_nodes_map.get(&sec.id).map(|v| v.len()).unwrap_or(0);
1068 let lbl = format!(
1069 "{}<br/><small>{} nodes</small>",
1070 safe_mermaid_text(&sec.name),
1071 node_count
1072 );
1073 lines.push(format!(" {}(\"{}\")", sid, lbl));
1074 lines.push(format!(" class {} module;", sid));
1075 }
1076
1077 let mut inter_counts: HashMap<(&str, &str), usize> = HashMap::new();
1078 let mut inter_rel: HashMap<(&str, &str), HashMap<String, usize>> = HashMap::new();
1079 for edge in edges {
1080 let src_sec = node_section.get(edge.source.as_str()).copied();
1081 let tgt_sec = node_section.get(edge.target.as_str()).copied();
1082 if let (Some(ss), Some(ts)) = (src_sec, tgt_sec) {
1083 if ss != ts {
1084 *inter_counts.entry((ss, ts)).or_insert(0) += 1;
1085 *inter_rel
1086 .entry((ss, ts))
1087 .or_default()
1088 .entry(edge.relation.clone())
1089 .or_insert(0) += 1;
1090 }
1091 }
1092 }
1093
1094 let mut inter_vec: Vec<((&str, &str), usize)> = inter_counts.into_iter().collect();
1095 inter_vec.sort_by_key(|k| std::cmp::Reverse(k.1));
1096 for ((ss, ts), count) in inter_vec.iter().take(12) {
1097 let src_id = mermaid_section_id(ss);
1098 let tgt_id = mermaid_section_id(ts);
1099 let rels = inter_rel.get(&(*ss, *ts)).cloned().unwrap_or_default();
1100 let top_rel = rels
1101 .iter()
1102 .max_by_key(|(_, c)| *c)
1103 .map(|(r, _)| r.as_str())
1104 .unwrap_or("relates");
1105 let lbl = if *count > 1 {
1106 format!(
1107 "{} x{}",
1108 safe_mermaid_text(&top_rel.replace('_', " ")),
1109 count
1110 )
1111 } else {
1112 safe_mermaid_text(&top_rel.replace('_', " "))
1113 };
1114 lines.push(format!(" {} -->|{}| {}", src_id, lbl, tgt_id));
1115 }
1116
1117 if inter_vec.is_empty() {
1118 let non_overview: Vec<&CallflowSection> =
1119 sections.iter().filter(|s| s.id != "overview").collect();
1120 for w in non_overview.windows(2) {
1121 lines.push(format!(
1122 " {} -.-> {}",
1123 mermaid_section_id(&w[0].id),
1124 mermaid_section_id(&w[1].id)
1125 ));
1126 }
1127 }
1128
1129 for def in mermaid_class_defs() {
1130 lines.push(def.to_string());
1131 }
1132 lines.join("\n")
1133}
1134
1135fn report_highlights(report_text: &str) -> String {
1138 if report_text.trim().is_empty() {
1139 return String::new();
1140 }
1141 let mut keep: Vec<String> = Vec::new();
1142 let mut in_gods = false;
1143 let mut in_summary = false;
1144 for line in report_text.lines() {
1145 let stripped = line.trim();
1146 if stripped.starts_with("## ") {
1147 in_summary = stripped == "## Summary";
1148 in_gods = stripped.starts_with("## God Nodes");
1149 continue;
1150 }
1151 if in_summary && stripped.starts_with("- ") {
1152 keep.push(stripped[2..].to_string());
1153 } else if in_gods
1154 && stripped
1155 .chars()
1156 .next()
1157 .map(|c| c.is_ascii_digit())
1158 .unwrap_or(false)
1159 {
1160 keep.push(stripped.to_string());
1161 }
1162 if keep.len() >= 6 {
1163 break;
1164 }
1165 }
1166 if keep.is_empty() {
1167 return String::new();
1168 }
1169 let items: String = keep
1170 .iter()
1171 .map(|item| format!(" <li>{}</li>", html_escape(item)))
1172 .collect::<Vec<_>>()
1173 .join("\n");
1174 format!(
1175 r#"<div class="card">
1176 <h4>Graph Report Highlights</h4>
1177 <ul>
1178{items}
1179 </ul>
1180 </div>"#
1181 )
1182}
1183
1184fn generate_nav(sections: &[CallflowSection]) -> String {
1185 let links: String = sections
1186 .iter()
1187 .map(|sec| {
1188 format!(
1189 " <a href=\"#{}\">{}</a>",
1190 html_escape(&sec.id),
1191 html_escape(&sec.name)
1192 )
1193 })
1194 .collect::<Vec<_>>()
1195 .join("\n");
1196 format!("<div class=\"nav\">\n{}\n</div>", links)
1197}
1198
1199fn build_section_node_map(
1200 sections: &[CallflowSection],
1201 nodes: &[CfNode],
1202) -> HashMap<String, Vec<usize>> {
1203 let mut comm_idx: HashMap<String, Vec<usize>> = HashMap::new();
1204 for (i, node) in nodes.iter().enumerate() {
1205 comm_idx.entry(node.community.clone()).or_default().push(i);
1206 }
1207 let mut result = HashMap::new();
1208 for sec in sections {
1209 let indices: Vec<usize> = sec
1210 .communities
1211 .iter()
1212 .flat_map(|cid| comm_idx.get(cid).cloned().unwrap_or_default())
1213 .collect();
1214 result.insert(sec.id.clone(), indices);
1215 }
1216 result
1217}
1218
1219fn generate_overview_cards(
1220 sections: &[CallflowSection],
1221 section_nodes_map: &HashMap<String, Vec<usize>>,
1222) -> String {
1223 let rows: String = sections
1224 .iter()
1225 .filter(|s| s.id != "overview")
1226 .map(|sec| {
1227 let node_count = section_nodes_map.get(&sec.id).map(|v| v.len()).unwrap_or(0);
1228 let comms = sec
1229 .communities
1230 .iter()
1231 .map(|c| c.as_str())
1232 .collect::<Vec<_>>()
1233 .join(", ");
1234 format!(
1235 "<tr><td>{}</td><td>{node_count}</td><td><code>{}</code></td></tr>",
1236 html_escape(&sec.name),
1237 html_escape(&comms)
1238 )
1239 })
1240 .collect::<Vec<_>>()
1241 .join("\n");
1242
1243 format!(
1244 r#"<div class="grid">
1245 <div class="card">
1246 <h4>Architecture Layers</h4>
1247 <table style="width:100%;font-size:0.85rem;">
1248 <tr><th>Layer</th><th>Nodes</th><th>Communities</th></tr>
1249 {rows}
1250 </table>
1251 </div>
1252</div>"#
1253 )
1254}
1255
1256fn generate_call_table_rows(nodes: &[&CfNode], _edges: &[&CfEdge]) -> String {
1257 let mut rows = Vec::new();
1258 for (i, n) in nodes.iter().take(30).enumerate() {
1259 let label = html_escape(&n.label);
1260 let sf = html_escape(&safe_file_path(&n.source_file));
1261 let kind = node_kind(n);
1262 let tag = match kind {
1263 "klass" => r#"<span class="tag tag-class">Class</span>"#,
1264 "api" => r#"<span class="tag tag-endpoint">API</span>"#,
1265 "async" => r#"<span class="tag tag-async">Async</span>"#,
1266 "entry" => r#"<span class="tag tag-cmd">Entry</span>"#,
1267 "test" => r#"<span class="tag tag-func">Test</span>"#,
1268 "ui" => r#"<span class="tag tag-hook">UI</span>"#,
1269 "module" => r#"<span class="tag tag-class">Module</span>"#,
1270 _ => r#"<span class="tag tag-func">Function</span>"#,
1271 };
1272 rows.push(format!(
1273 "<tr>\n <td>{}</td>\n <td><code>{label}</code><br><small style=\"color:var(--muted)\">{sf}</small></td>\n <td>{tag}</td>\n <td></td>\n <td></td>\n <td></td>\n</tr>",
1274 i + 1
1275 ));
1276 }
1277 rows.join("\n")
1278}
1279
1280fn section_intro(sec: &CallflowSection, nodes: &[&CfNode], edge_count: usize) -> String {
1281 let kws = section_keywords(nodes, 4);
1282 let kw_text = if kws.is_empty() {
1283 sec.name.clone()
1284 } else {
1285 kws.join(", ")
1286 };
1287 let text = format!(
1288 "{} groups implementation around {kw_text}. This section covers {} nodes and {edge_count} internal edges; the diagram shows only representative relationships to stay readable.",
1289 sec.name, nodes.len()
1290 );
1291 format!("<p>{}</p>", html_escape(&text))
1292}
1293
1294fn section_cards(sec: &CallflowSection, nodes: &[&CfNode], _edges: &[&CfEdge]) -> String {
1295 let mut file_counts: HashMap<&str, usize> = HashMap::new();
1296 for n in nodes {
1297 if !n.source_file.is_empty() {
1298 *file_counts.entry(n.source_file.as_str()).or_insert(0) += 1;
1299 }
1300 }
1301 let mut top_files: Vec<(&str, usize)> = file_counts.into_iter().collect();
1302 top_files.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
1303 let file_rows: String = if top_files.is_empty() {
1304 r#"<tr><td colspan="2">No source file mapping</td></tr>"#.to_string()
1305 } else {
1306 top_files
1307 .iter()
1308 .take(8)
1309 .map(|(path, count)| {
1310 format!(
1311 "<tr><td><code>{}</code></td><td>{count} nodes</td></tr>",
1312 html_escape(&safe_file_path(path))
1313 )
1314 })
1315 .collect::<Vec<_>>()
1316 .join("\n")
1317 };
1318
1319 format!(
1320 r#"<div class="grid">
1321 <div class="card">
1322 <h4>Key Files</h4>
1323 <table style="width:100%;font-size:0.85rem;">
1324 <tr><th>File</th><th>Coverage</th></tr>
1325 {file_rows}
1326 </table>
1327 </div>
1328 <div class="card">
1329 <h4>Design Notes</h4>
1330 <p>This section comes from {} community clustering.</p>
1331 </div>
1332</div>"#,
1333 html_escape(&sec.name)
1334 )
1335}
1336
1337static CSS: &str = r#":root {
1340 --bg: #0f172a; --surface: #1e293b; --border: #334155;
1341 --text: #e2e8f0; --muted: #94a3b8; --accent: #38bdf8;
1342 --warn: #fbbf24; --err: #f87171; --ok: #34d399;
1343}
1344* { box-sizing: border-box; margin: 0; padding: 0; }
1345body { font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); line-height: 1.7; }
1346.container { max-width: 1200px; margin: 0 auto; padding: 40px 24px; }
1347h1 { font-size: 2.4rem; margin-bottom: 8px; }
1348h2 { font-size: 1.7rem; margin: 48px 0 16px; padding-bottom: 8px; border-bottom: 2px solid var(--accent); }
1349h3 { font-size: 1.25rem; margin: 32px 0 12px; color: var(--accent); }
1350h4 { font-size: 1.05rem; margin: 20px 0 8px; color: var(--warn); }
1351p { margin: 8px 0; color: var(--muted); }
1352.subtitle { color: var(--muted); font-size: 1.1rem; margin-bottom: 32px; }
1353.mermaid { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 24px; margin: 20px 0; overflow-x: auto; }
1354.call-table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 0.92rem; }
1355.call-table th { background: #1a2744; color: var(--accent); text-align: left; padding: 10px 14px; border: 1px solid var(--border); }
1356.call-table td { padding: 8px 14px; border: 1px solid var(--border); vertical-align: top; }
1357.tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.8rem; font-weight: 600; }
1358.tag-async { background: #7c3aed33; color: #a78bfa; }
1359.tag-class { background: #05966933; color: var(--ok); }
1360.tag-func { background: #2563eb33; color: var(--accent); }
1361.tag-cmd { background: #d9770633; color: var(--warn); }
1362.tag-endpoint { background: #dc262633; color: var(--err); }
1363.tag-hook { background: #db277733; color: #f472b6; }
1364.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 20px; margin: 16px 0; }
1365.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 16px; margin: 16px 0; }
1366code { font-family: monospace; background: rgba(255,255,255,0.06); padding: 1px 6px; border-radius: 3px; font-size: 0.88em; }
1367ul, ol { margin: 8px 0 8px 24px; color: var(--muted); }
1368li { margin: 4px 0; }
1369a { color: var(--accent); }
1370hr { border: none; border-top: 1px solid var(--border); margin: 40px 0; }
1371.nav { position: sticky; top: 0; background: var(--bg); z-index: 10; padding: 12px 0; border-bottom: 1px solid var(--border); display: flex; gap: 20px; flex-wrap: wrap; font-size: 0.9rem; }
1372.nav a { text-decoration: none; }
1373"#;
1374
1375static MERMAID_JS: &str = r#"<script>
1376(function () {
1377 mermaid.initialize({
1378 startOnLoad: false,
1379 theme: 'dark',
1380 securityLevel: 'loose',
1381 flowchart: { htmlLabels: true, useMaxWidth: true },
1382 themeVariables: {
1383 primaryColor: '#1e293b',
1384 primaryTextColor: '#e2e8f0',
1385 primaryBorderColor: '#38bdf8',
1386 secondaryColor: '#0f172a',
1387 tertiaryColor: '#334155',
1388 lineColor: '#64748b',
1389 textColor: '#e2e8f0',
1390 }
1391 });
1392 if (document.readyState === 'loading') {
1393 document.addEventListener('DOMContentLoaded', () => mermaid.run({ querySelector: '.mermaid' }));
1394 } else {
1395 mermaid.run({ querySelector: '.mermaid' });
1396 }
1397})();
1398</script>"#;
1399
1400#[allow(clippy::too_many_arguments)]
1401pub fn write_callflow_html(
1402 project: Option<&Path>,
1403 codesynapse_out_arg: Option<&Path>,
1404 graph_arg: Option<&Path>,
1405 report_arg: Option<&Path>,
1406 labels_arg: Option<&Path>,
1407 sections_file: Option<&Path>,
1408 output_arg: Option<&Path>,
1409 _lang: &str,
1410 max_sections: usize,
1411 diagram_scale: f64,
1412 max_diagram_nodes: usize,
1413 max_diagram_edges: usize,
1414 verbose: bool,
1415) -> Result<PathBuf> {
1416 let base = project
1417 .map(|p| p.to_path_buf())
1418 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1419
1420 let codesynapse_out = if let Some(p) = codesynapse_out_arg {
1421 p.to_path_buf()
1422 } else if let Some(g) = graph_arg {
1423 g.parent().unwrap_or(Path::new(".")).to_path_buf()
1424 } else if base.join("graph.json").exists() {
1425 base.clone()
1426 } else {
1427 base.join("codesynapse-out")
1428 };
1429
1430 let graph_path = graph_arg
1431 .map(|p| p.to_path_buf())
1432 .unwrap_or_else(|| codesynapse_out.join("graph.json"));
1433 let report_path = report_arg
1434 .map(|p| p.to_path_buf())
1435 .unwrap_or_else(|| codesynapse_out.join("GRAPH_REPORT.md"));
1436 let labels_path = labels_arg
1437 .map(|p| p.to_path_buf())
1438 .unwrap_or_else(|| codesynapse_out.join(".codesynapse_labels.json"));
1439
1440 if !graph_path.exists() {
1441 return Err(CodeSynapseError::Io(std::io::Error::new(
1442 std::io::ErrorKind::NotFound,
1443 format!("graph file not found: {}", graph_path.display()),
1444 )));
1445 }
1446
1447 let (nodes, edges, hyperedges, mut meta) = load_graph(&graph_path, MAX_GRAPH_FILE_BYTES)?;
1448 let labels = load_labels(&labels_path);
1449 let report_text = load_report(&report_path);
1450
1451 if nodes.is_empty() {
1452 return Err(CodeSynapseError::Validation(
1453 "graph.json contains 0 nodes".into(),
1454 ));
1455 }
1456
1457 let sections = derive_sections_from_communities(&nodes, &labels, "en", max_sections);
1458 if sections.len() <= 1 {
1459 return Err(CodeSynapseError::Validation("no sections defined".into()));
1460 }
1461
1462 let _ = sections_file;
1463
1464 let project_name = infer_project_name(&graph_path, &meta);
1465 meta.insert("project_name".into(), Value::String(project_name.clone()));
1466 meta.insert("node_count".into(), Value::Number(nodes.len().into()));
1467 meta.insert("edge_count".into(), Value::Number(edges.len().into()));
1468 meta.insert(
1469 "hyperedge_count".into(),
1470 Value::Number(hyperedges.len().into()),
1471 );
1472
1473 let commit = meta
1474 .get("built_at_commit")
1475 .and_then(|v| v.as_str())
1476 .map(|s| s.chars().take(7).collect::<String>())
1477 .unwrap_or_else(|| "unknown".to_string());
1478
1479 let output_path = if let Some(o) = output_arg {
1480 if o.is_absolute() {
1481 o.to_path_buf()
1482 } else {
1483 base.join(o)
1484 }
1485 } else {
1486 let stem = project_name
1487 .chars()
1488 .map(|c| {
1489 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
1490 c
1491 } else {
1492 '-'
1493 }
1494 })
1495 .collect::<String>();
1496 let stem = stem.trim_matches('-').to_string();
1497 let stem = if stem.is_empty() {
1498 "project".to_string()
1499 } else {
1500 stem
1501 };
1502 codesynapse_out.join(format!("{stem}-callflow.html"))
1503 };
1504
1505 if verbose {
1506 eprintln!(
1507 "Loaded: {} nodes, {} edges, {} sections",
1508 nodes.len(),
1509 edges.len(),
1510 sections.len()
1511 );
1512 }
1513
1514 let section_nodes_map = build_section_node_map(§ions, &nodes);
1515
1516 let mut comm_idx: HashMap<String, usize> = HashMap::new();
1517 for node in &nodes {
1518 comm_idx.entry(node.community.clone()).or_insert(0);
1519 *comm_idx.get_mut(&node.community).unwrap() += 1;
1520 }
1521
1522 let doc_title = format!(
1523 "{} — Complete Call Flow & Architecture Documentation",
1524 project_name
1525 );
1526 let subtitle = format!(
1527 "Generated from codesynapse knowledge graph: {} nodes, {} edges, {} communities. Commit: {}",
1528 nodes.len(), edges.len(), comm_idx.len(), commit
1529 );
1530
1531 let mut html = Vec::new();
1532
1533 html.push(format!(
1534 r#"<!DOCTYPE html>
1535<html lang="en">
1536<head>
1537<meta charset="UTF-8">
1538<meta name="viewport" content="width=device-width, initial-scale=1.0">
1539<title>{}</title>
1540<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
1541<style>
1542{}
1543</style>
1544</head>
1545<body>
1546<div class="container">
1547"#,
1548 html_escape(&doc_title),
1549 CSS
1550 ));
1551
1552 html.push(format!(
1554 "<h1>{}</h1>\n<p class=\"subtitle\">{}</p>\n\n{}",
1555 html_escape(&doc_title),
1556 html_escape(&subtitle),
1557 generate_nav(§ions)
1558 ));
1559
1560 let overview_name = sections
1562 .first()
1563 .map(|s| s.name.as_str())
1564 .unwrap_or("Architecture Overview");
1565 html.push(format!(
1566 "\n<!-- ====== Architecture Overview ====== -->\n<h2 id=\"overview\">1. {}</h2>\n",
1567 html_escape(overview_name)
1568 ));
1569 html.push(format!(
1570 "<div class=\"mermaid\">\n{}\n</div>\n",
1571 generate_overview_graph(§ions, §ion_nodes_map, &nodes, &edges, diagram_scale)
1572 ));
1573 html.push(generate_overview_cards(§ions, §ion_nodes_map));
1574 let rh = report_highlights(&report_text);
1575 if !rh.is_empty() {
1576 html.push(format!("<div class=\"grid\">\n {}\n</div>", rh));
1577 }
1578 html.push("<hr>".to_string());
1579
1580 let mut section_num = 1usize;
1582 for sec in §ions {
1583 if sec.id == "overview" {
1584 continue;
1585 }
1586 section_num += 1;
1587 let sec_node_indices = section_nodes_map.get(&sec.id).cloned().unwrap_or_default();
1588 let sec_nodes: Vec<&CfNode> = sec_node_indices.iter().map(|&i| &nodes[i]).collect();
1589 let sec_node_ids: std::collections::HashSet<&str> =
1590 sec_nodes.iter().map(|n| n.id.as_str()).collect();
1591 let sec_edges: Vec<&CfEdge> = edges
1592 .iter()
1593 .filter(|e| {
1594 sec_node_ids.contains(e.source.as_str()) && sec_node_ids.contains(e.target.as_str())
1595 })
1596 .collect();
1597
1598 html.push(format!(
1599 "<!-- ====== {}. {} ====== -->\n<h2 id=\"{}\">{section_num}. {}</h2>\n",
1600 section_num,
1601 sec.name,
1602 html_escape(&sec.id),
1603 html_escape(&sec.name)
1604 ));
1605 html.push(section_intro(sec, &sec_nodes, sec_edges.len()));
1606 html.push(format!(
1607 "\n<div class=\"mermaid\">\n{}\n</div>\n",
1608 generate_section_flowchart(
1609 &sec.id,
1610 &sec.name,
1611 &sec_nodes,
1612 &sec_edges,
1613 diagram_scale,
1614 max_diagram_nodes,
1615 max_diagram_edges
1616 )
1617 ));
1618 html.push(format!(
1619 "<h3>Call Details</h3>\n<table class=\"call-table\">\n<tr>\n <th style=\"width:5%\">#</th>\n <th style=\"width:28%\">Node</th>\n <th style=\"width:10%\">Type</th>\n <th style=\"width:17%\">Caller</th>\n <th style=\"width:20%\">Callees</th>\n <th style=\"width:20%\">Description</th>\n</tr>\n{}\n</table>\n",
1620 generate_call_table_rows(&sec_nodes, &sec_edges)
1621 ));
1622 html.push(section_cards(sec, &sec_nodes, &sec_edges));
1623 html.push("<hr>".to_string());
1624 }
1625
1626 if !hyperedges.is_empty() {
1628 html.push(
1629 "<h2 id=\"hyperedges\">Group Relationships (Hyperedges)</h2>\n<div class=\"grid\">"
1630 .to_string(),
1631 );
1632 for he in hyperedges.iter().take(9) {
1633 if let Some(obj) = he.as_object() {
1634 let hid = obj.get("id").and_then(|v| v.as_str()).unwrap_or("?");
1635 let hlabel = obj.get("label").and_then(|v| v.as_str()).unwrap_or(hid);
1636 let hnodes = obj
1637 .get("nodes")
1638 .and_then(|v| v.as_array())
1639 .cloned()
1640 .unwrap_or_default();
1641 let hrel = obj.get("relation").and_then(|v| v.as_str()).unwrap_or("");
1642 html.push(format!(
1643 " <div class=\"card\"><h4>{}</h4><p><code>{}</code> — {} participants</p><ul>",
1644 html_escape(hlabel),
1645 html_escape(hrel),
1646 hnodes.len()
1647 ));
1648 for hn in hnodes.iter().take(5) {
1649 html.push(format!(
1650 " <li><code>{}</code></li>",
1651 html_escape(&hn.to_string())
1652 ));
1653 }
1654 if hnodes.len() > 5 {
1655 html.push(format!(" <li>... and {} more</li>", hnodes.len() - 5));
1656 }
1657 html.push(" </ul>\n </div>".to_string());
1658 }
1659 }
1660 html.push("</div>\n<hr>".to_string());
1661 }
1662
1663 html.push(format!(
1665 r#"<h2 id="stats">Project Statistics</h2>
1666<div class="grid">
1667 <div class="card">
1668 <h4>Graph</h4>
1669 <table style="width:100%;font-size:0.85rem;">
1670 <tr><td>Nodes</td><td>{}</td></tr>
1671 <tr><td>Edges</td><td>{}</td></tr>
1672 <tr><td>Hyperedges</td><td>{}</td></tr>
1673 <tr><td>Communities</td><td>{}</td></tr>
1674 </table>
1675 </div>
1676</div>
1677"#,
1678 nodes.len(),
1679 edges.len(),
1680 hyperedges.len(),
1681 comm_idx.len()
1682 ));
1683
1684 html.push(format!(
1686 "<div style=\"text-align:center; padding:40px 0; color: var(--muted); font-size:0.9rem;\">\n <p>{} — Architecture Documentation</p>\n <p>Generated by codesynapse callflow-html</p>\n</div>\n",
1687 html_escape(&project_name)
1688 ));
1689
1690 html.push("</div><!-- .container -->\n".to_string());
1691 html.push(MERMAID_JS.to_string());
1692 html.push("\n</body>\n</html>".to_string());
1693
1694 let content = html.join("\n");
1695
1696 if let Some(parent) = output_path.parent() {
1697 std::fs::create_dir_all(parent).map_err(CodeSynapseError::Io)?;
1698 }
1699 std::fs::write(&output_path, &content).map_err(CodeSynapseError::Io)?;
1700
1701 if verbose {
1702 println!("callflow HTML written: {}", output_path.display());
1703 }
1704
1705 Ok(output_path)
1706}
1707
1708#[cfg(test)]
1711mod tests {
1712 use super::*;
1713 use std::collections::HashMap;
1714 use tempfile::tempdir;
1715
1716 fn make_codesynapse_out(dir: &Path) -> PathBuf {
1717 let out = dir.join("codesynapse-out");
1718 std::fs::create_dir_all(&out).unwrap();
1719
1720 let graph = serde_json::json!({
1721 "directed": false,
1722 "multigraph": false,
1723 "graph": {},
1724 "nodes": [
1725 {"id": "api", "label": "ApiClient", "source_file": "src/api.py", "file_type": "code", "community": 0},
1726 {"id": "run", "label": "run()", "source_file": "src/main.py", "file_type": "code", "community": 0},
1727 {"id": "export", "label": "write_html()", "source_file": "src/export.py", "file_type": "code", "community": 1},
1728 {"id": "evil", "label": "<script>alert(1)</script>", "source_file": "src/evil.py", "file_type": "code", "community": 1}
1729 ],
1730 "links": [
1731 {"source": "run", "target": "api", "relation": "calls", "confidence": "EXTRACTED", "confidence_score": 1.0},
1732 {"source": "api", "target": "export", "relation": "uses", "confidence": "EXTRACTED", "confidence_score": 1.0},
1733 {"source": "export", "target": "evil", "relation": "calls", "confidence": "EXTRACTED", "confidence_score": 1.0}
1734 ],
1735 "hyperedges": [],
1736 "built_at_commit": "abcdef123456"
1737 });
1738 std::fs::write(
1739 out.join("graph.json"),
1740 serde_json::to_string(&graph).unwrap(),
1741 )
1742 .unwrap();
1743
1744 std::fs::write(
1745 out.join(".codesynapse_labels.json"),
1746 r#"{"0": "Runtime", "1": "Export"}"#,
1747 )
1748 .unwrap();
1749
1750 let report = "# Graph Report - sample\n\n## Summary\n- 3 nodes · 2 edges · 1 communities detected\n\n## God Nodes (most connected - your core abstractions)\n1. `Transformer` - 2 edges\n";
1751 std::fs::write(out.join("GRAPH_REPORT.md"), report).unwrap();
1752
1753 out
1754 }
1755
1756 #[test]
1757 fn test_write_callflow_html_creates_file_and_uses_report() {
1758 let dir = tempdir().unwrap();
1759 let _out = make_codesynapse_out(dir.path());
1760
1761 let html_path = write_callflow_html(
1762 Some(dir.path()),
1763 None,
1764 None,
1765 None,
1766 None,
1767 None,
1768 Some(Path::new("codesynapse-out/callflow.html")),
1769 "en",
1770 4,
1771 1.0,
1772 18,
1773 24,
1774 false,
1775 )
1776 .unwrap();
1777
1778 assert_eq!(html_path, dir.path().join("codesynapse-out/callflow.html"));
1779 let content = std::fs::read_to_string(&html_path).unwrap();
1780 assert!(content.contains("mermaid"), "should contain mermaid");
1781 assert!(
1782 content.contains("Graph Report Highlights"),
1783 "should contain report highlights heading"
1784 );
1785 assert!(
1786 content.contains("Transformer"),
1787 "should contain god node from report"
1788 );
1789 assert!(content.contains("ApiClient"), "should contain node label");
1790 assert!(
1791 content.contains("<script>alert(1)</script>"),
1792 "XSS should be escaped"
1793 );
1794 assert!(
1795 !content.contains("<script>alert(1)</script>"),
1796 "raw XSS must not appear"
1797 );
1798 }
1799
1800 #[test]
1801 fn test_load_graph_rejects_oversized_file() {
1802 let dir = tempdir().unwrap();
1803 let path = dir.path().join("graph.json");
1804 std::fs::write(&path, r#"{"nodes": [], "links": []}"#).unwrap();
1805 let result = load_graph(&path, 8);
1806 assert!(result.is_err());
1807 let msg = result.unwrap_err().to_string();
1808 assert!(
1809 msg.contains("exceeds"),
1810 "error should mention 'exceeds': {msg}"
1811 );
1812 }
1813
1814 #[test]
1815 fn test_derive_sections_groups_by_architecture_keywords() {
1816 let nodes = vec![
1817 CfNode {
1818 id: "extract_py".into(),
1819 label: "extract_python".into(),
1820 source_file: "codesynapse/extract.py".into(),
1821 community: "0".into(),
1822 node_type: String::new(),
1823 file_type: "code".into(),
1824 },
1825 CfNode {
1826 id: "extract_js".into(),
1827 label: "extract_js".into(),
1828 source_file: "codesynapse/extract.py".into(),
1829 community: "0".into(),
1830 node_type: String::new(),
1831 file_type: "code".into(),
1832 },
1833 CfNode {
1834 id: "to_html".into(),
1835 label: "to_html".into(),
1836 source_file: "codesynapse/export.py".into(),
1837 community: "1".into(),
1838 node_type: String::new(),
1839 file_type: "code".into(),
1840 },
1841 CfNode {
1842 id: "test_html".into(),
1843 label: "test_export_html".into(),
1844 source_file: "tests/test_export.py".into(),
1845 community: "2".into(),
1846 node_type: String::new(),
1847 file_type: "code".into(),
1848 },
1849 ];
1850
1851 let sections = derive_sections_from_communities(&nodes, &HashMap::new(), "en", 6);
1852 let ids: std::collections::HashSet<&str> = sections.iter().map(|s| s.id.as_str()).collect();
1853
1854 assert!(
1855 ids.contains("extract-pipeline"),
1856 "should contain extract-pipeline, got: {:?}",
1857 ids
1858 );
1859 assert!(
1860 ids.contains("outputs-docs"),
1861 "should contain outputs-docs, got: {:?}",
1862 ids
1863 );
1864 assert!(
1865 ids.contains("tests-fixtures"),
1866 "should contain tests-fixtures, got: {:?}",
1867 ids
1868 );
1869 }
1870
1871 #[test]
1872 fn test_safe_mermaid_text_escapes_html() {
1873 let result = safe_mermaid_text("<script>alert(1)</script>");
1874 assert!(!result.contains('<'));
1875 assert!(!result.contains('>'));
1876 assert!(result.contains("<") || result.contains("script"));
1877 }
1878
1879 #[test]
1880 fn test_stable_ascii_id_no_collision() {
1881 let id1 = stable_ascii_id("foo", "node", 48);
1882 let id2 = stable_ascii_id("bar", "node", 48);
1883 assert_ne!(id1, id2);
1884 assert!(id1.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'));
1885 }
1886
1887 #[test]
1888 fn test_humanize_label_strips_leading_dot() {
1889 assert_eq!(humanize_label(".foo()", ""), "foo()");
1890 }
1891
1892 #[test]
1893 fn test_load_graph_parses_nodes_and_edges() {
1894 let dir = tempdir().unwrap();
1895 let _out = make_codesynapse_out(dir.path());
1896 let graph_path = dir.path().join("codesynapse-out/graph.json");
1897 let (nodes, edges, _, _) = load_graph(&graph_path, MAX_GRAPH_FILE_BYTES).unwrap();
1898 assert_eq!(nodes.len(), 4);
1899 assert_eq!(edges.len(), 3);
1900 }
1901
1902 #[test]
1903 fn test_keyword_score_word_boundary() {
1904 let text = "test export html test_export_html tests/test_export.py code";
1905 let score = keyword_score(text, &["test", "tests"]);
1906 assert!(score >= 4, "expected >= 4, got {score}");
1907 }
1908
1909 #[test]
1910 fn test_section_keywords_excludes_stopwords() {
1911 let nodes = [CfNode {
1912 id: "n1".into(),
1913 label: "to_html".into(),
1914 source_file: "export.py".into(),
1915 community: "0".into(),
1916 node_type: String::new(),
1917 file_type: "code".into(),
1918 }];
1919 let refs: Vec<&CfNode> = nodes.iter().collect();
1920 let kws = section_keywords(&refs, 5);
1921 assert!(!kws.contains(&"html".to_string()), "html is a stopword");
1922 }
1923}