Skip to main content

codesynapse_core/
callflow_html.rs

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// ─── Data types ───────────────────────────────────────────────────────────────
10
11#[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
38// ─── Section archetypes ───────────────────────────────────────────────────────
39
40struct 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
196// ─── Helpers ──────────────────────────────────────────────────────────────────
197
198fn html_escape(text: &str) -> String {
199    text.replace('&', "&amp;")
200        .replace('<', "&lt;")
201        .replace('>', "&gt;")
202        .replace('"', "&quot;")
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
464// ─── Community text helpers ────────────────────────────────────────────────────
465
466static 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
539// ─── Section derivation ───────────────────────────────────────────────────────
540
541pub 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
676// ─── Graph loading ────────────────────────────────────────────────────────────
677
678fn 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        // try integer id
707        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
931// ─── Mermaid diagram generators ───────────────────────────────────────────────
932
933fn 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
1135// ─── HTML component generators ────────────────────────────────────────────────
1136
1137fn 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
1337// ─── Main entry point ─────────────────────────────────────────────────────────
1338
1339static 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(&sections, &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    // Header
1553    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(&sections)
1558    ));
1559
1560    // Overview section
1561    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(&sections, &section_nodes_map, &nodes, &edges, diagram_scale)
1572    ));
1573    html.push(generate_overview_cards(&sections, &section_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    // Per-section content
1581    let mut section_num = 1usize;
1582    for sec in &sections {
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    // Hyperedges
1627    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    // Stats
1664    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    // Footer
1685    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// ─── Tests ────────────────────────────────────────────────────────────────────
1709
1710#[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("&lt;script&gt;alert(1)&lt;/script&gt;"),
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("&lt;") || 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}