Skip to main content

cgx_engine/docs/
layered.rs

1//! Diátaxis-ish layered vault writer.
2//!
3//! Top-level indices (00-Overview .. 50-Ownership) are always re-written.
4//! Module notes (30-Modules/...) honour the incremental slice-hash gate.
5//!
6//! Layout:
7//! ```text
8//! cgx-docs/
9//!   .obsidian/
10//!   README.md                     ← entry point with "Start here"
11//!   00-Overview/
12//!     Architecture.md             ← project, stack, languages, top modules
13//!     HowToNavigate.md            ← reading path for new readers
14//!     Glossary.md                 ← node kinds, communities
15//!   10-PublicAPI/<community>.md
16//!   20-Architecture/
17//!     Communities.md
18//!     CrossClusterDeps.md
19//!     EntryPoints.md
20//!   30-Modules/<community>/<basename>.md  ← per-file notes (flattened)
21//!   40-Risk/
22//!     Hotspots.md
23//!     ComplexityHigh.md
24//!     DeadCode.md
25//!     Duplicates.md
26//!   50-Ownership/
27//!     Owners.md
28//!     BlameGraph.md
29//! ```
30
31use std::collections::HashMap;
32use std::fmt::Write as _;
33use std::path::{Path, PathBuf};
34
35use crate::graph::{
36    ApiScope, CrossClusterEdge, EntryPoint, FileSummary, GraphDb, Node, PublicSymbol,
37};
38
39use super::incremental::{self, DocsState};
40use super::label::build_community_labels;
41use super::project::{detect as detect_project, DepKind, Dependency, ProjectInfo};
42use super::prompt_packet;
43use super::role::{classify as classify_role, FileRole};
44use super::wiki_link::{file_group, format_link, public_api_target, safe_filename};
45use super::{DocsMode, DocsOptions, DocsReport, WikiLinkStyle};
46
47pub fn write_vault(
48    repo_path: &Path,
49    db: &GraphDb,
50    output_dir: &Path,
51    mode: DocsMode,
52    opts: &DocsOptions,
53) -> anyhow::Result<DocsReport> {
54    std::fs::create_dir_all(output_dir)?;
55    std::fs::create_dir_all(output_dir.join(".obsidian"))?;
56
57    // ---- Gather once, reuse everywhere ----
58    let all_nodes = db.get_all_nodes()?;
59    let communities = db.get_communities()?;
60    let cgx_labels: HashMap<i64, String> = communities
61        .iter()
62        .map(|(id, label, _, _)| (*id, label.clone()))
63        .collect();
64    // Override with our better path-prefix-derived labels.
65    let community_labels = build_community_labels(&all_nodes, &cgx_labels);
66    let project = detect_project(repo_path);
67
68    let mut file_paths: Vec<String> = all_nodes
69        .iter()
70        .filter(|n| n.kind == "File")
71        .map(|n| n.path.clone())
72        .collect();
73    file_paths.sort();
74    file_paths.dedup();
75
76    // Precompute summaries + roles so indices can reference them too.
77    let mut summaries: Vec<(String, FileSummary, FileRole)> = Vec::with_capacity(file_paths.len());
78    for path in &file_paths {
79        let Ok(summary) = db.get_file_summary(path) else {
80            continue;
81        };
82        let role = classify_role(&summary);
83        summaries.push((path.clone(), summary, role));
84    }
85
86    // ---- Incremental state ----
87    let mut state = if mode == DocsMode::Incremental {
88        incremental::load_state(&db.repo_id)
89    } else {
90        DocsState::default()
91    };
92
93    let mut module_written = 0usize;
94    let mut module_skipped = 0usize;
95    let mut index_written = 0usize;
96
97    // ---- 30-Modules: one note per source file, grouped by file_group ----
98    for (path, summary, role) in &summaries {
99        let new_hash = incremental::slice_hash(summary);
100        if mode == DocsMode::Incremental
101            && !incremental::needs_regen(&state, Path::new(path), &new_hash)
102        {
103            module_skipped += 1;
104            continue;
105        }
106
107        let group = file_group(path);
108        let group_dir = group.replace('/', std::path::MAIN_SEPARATOR_STR);
109        let basename = path.rsplit('/').next().unwrap_or(path);
110        // Disambiguate basename collisions within the same group.
111        let mut filename = safe_filename(basename);
112        if collision_risk_in_group(basename, &group, &summaries) {
113            let hash = short_hash(path);
114            filename = format!("{}.{}", filename, hash);
115        }
116        let abs = output_dir
117            .join("30-Modules")
118            .join(&group_dir)
119            .join(format!("{}.md", filename));
120        if let Some(parent) = abs.parent() {
121            std::fs::create_dir_all(parent)?;
122        }
123        let content = render_module_note(db, summary, *role, &group, opts, &community_labels);
124        std::fs::write(&abs, content)?;
125        module_written += 1;
126
127        state
128            .files
129            .insert(path.clone(), incremental::entry_now(new_hash));
130    }
131
132    // ---- Top-level indices: always rewritten ----
133    index_written += write_overview(
134        output_dir,
135        db,
136        &all_nodes,
137        &project,
138        &summaries,
139        &community_labels,
140        opts,
141    )?;
142    index_written += write_how_to_navigate(output_dir, &project, opts)?;
143    index_written += write_glossary(output_dir, &all_nodes, &community_labels, opts)?;
144    index_written += write_public_api(output_dir, &summaries, opts)?;
145    index_written += write_architecture(output_dir, db, &summaries, &community_labels, opts)?;
146    index_written += write_risk(output_dir, db, &all_nodes, opts)?;
147    index_written += write_ownership(output_dir, db, &all_nodes, opts)?;
148    index_written += write_readme(output_dir, db, &file_paths, &project, &summaries, opts)?;
149
150    state.generated_at = chrono::Utc::now().to_rfc3339();
151    incremental::save_state(&db.repo_id, &state)?;
152
153    let _ = repo_path;
154    Ok(DocsReport {
155        output_dir: PathBuf::from(output_dir),
156        module_notes_written: module_written,
157        module_notes_skipped: module_skipped,
158        index_notes_written: index_written,
159        mode: match mode {
160            DocsMode::Full => "full",
161            DocsMode::Incremental => "incremental",
162        },
163    })
164}
165
166fn short_hash(s: &str) -> String {
167    use sha2::{Digest, Sha256};
168    let mut h = Sha256::new();
169    h.update(s.as_bytes());
170    let digest = format!("{:x}", h.finalize());
171    digest[..6].to_string()
172}
173
174fn collision_risk_in_group(
175    basename: &str,
176    group: &str,
177    summaries: &[(String, FileSummary, FileRole)],
178) -> bool {
179    summaries
180        .iter()
181        .filter(|(p, _, _)| file_group(p) == group && p.rsplit('/').next().unwrap_or(p) == basename)
182        .count()
183        > 1
184}
185
186// =====================================================================
187// Module note (30-Modules/<community>/<basename>.md)
188// =====================================================================
189
190fn render_module_note(
191    db: &GraphDb,
192    summary: &FileSummary,
193    role: FileRole,
194    file_group_str: &str,
195    opts: &DocsOptions,
196    community_labels: &HashMap<i64, String>,
197) -> String {
198    let mut out = String::new();
199    let community_label = community_labels
200        .get(&summary.community)
201        .cloned()
202        .unwrap_or_else(|| format!("community-{}", summary.community));
203
204    if opts.frontmatter {
205        let unique_owners = dedup_keep_order(summary.owners.iter().map(|(n, _)| n.clone()));
206        let _ = writeln!(out, "---");
207        let _ = writeln!(out, "cgx_kind: module");
208        let _ = writeln!(out, "role: {}", role.label());
209        let _ = writeln!(out, "path: {}", yaml_quote(&summary.path));
210        let _ = writeln!(
211            out,
212            "language: {}",
213            if summary.language.is_empty() {
214                "unknown"
215            } else {
216                &summary.language
217            }
218        );
219        let _ = writeln!(out, "community: {}", yaml_quote(&community_label));
220        if !unique_owners.is_empty() {
221            let top: Vec<String> = unique_owners
222                .iter()
223                .take(3)
224                .map(|n| yaml_quote(n))
225                .collect();
226            let _ = writeln!(out, "owners: [{}]", top.join(", "));
227        }
228        let _ = writeln!(out, "churn: {:.2}", summary.churn);
229        let _ = writeln!(out, "complexity: {:.1}", summary.complexity);
230        let _ = writeln!(out, "exported_count: {}", summary.exported_count);
231        let _ = writeln!(out, "tags: [module, cgx, {}]", role.label());
232        let _ = writeln!(out, "---");
233        out.push('\n');
234    }
235
236    let basename = summary.path.rsplit('/').next().unwrap_or(&summary.path);
237    let _ = writeln!(out, "# `{}`\n", basename);
238    let _ = writeln!(out, "**Path:** `{}`  ", summary.path);
239    let _ = writeln!(out, "**Group:** `{}`  ", file_group_str);
240    let _ = writeln!(out, "**Community:** {}\n", community_label);
241
242    // --- TL;DR ----------------------------------------------------------
243    out.push_str(&tldr_line(summary, role));
244    out.push_str("\n\n");
245
246    // --- Existing prose docstrings — top of note, before tables ----------
247    let existing_docs: Vec<(String, String)> = summary
248        .symbols
249        .iter()
250        .filter_map(|sym| match db.get_doc_comment(&sym.id).ok().flatten() {
251            Some(doc) if !doc.trim().is_empty() => Some((sym.name.clone(), doc)),
252            _ => None,
253        })
254        .collect();
255
256    if !existing_docs.is_empty() {
257        out.push_str("## What's documented in source\n\n");
258        for (name, doc) in &existing_docs {
259            let _ = writeln!(out, "**`{}`** — {}\n", name, first_line(doc));
260        }
261        out.push('\n');
262    } else {
263        out.push_str("> _No symbol-level docstrings in source yet. The AI prose stub at the bottom of this note is the recommended next step._\n\n");
264    }
265
266    // --- Structure: symbols table with inline descriptions ---------------
267    out.push_str("## Structure\n\n");
268    if summary.symbols.is_empty() {
269        out.push_str("_(no symbols extracted for this file)_\n\n");
270    } else {
271        let doc_map: HashMap<&str, &str> = existing_docs
272            .iter()
273            .map(|(n, d)| (n.as_str(), d.as_str()))
274            .collect();
275        // The Description column is only worth its width when at least one
276        // symbol actually carries a docstring; otherwise it's an empty column.
277        let has_descriptions = summary
278            .symbols
279            .iter()
280            .any(|n| doc_map.contains_key(n.name.as_str()));
281
282        if has_descriptions {
283            out.push_str("| Kind | Name | Lines | Exported | Description |\n");
284            out.push_str("|------|------|-------|----------|-------------|\n");
285        } else {
286            out.push_str("| Kind | Name | Lines | Exported |\n");
287            out.push_str("|------|------|-------|----------|\n");
288        }
289        for n in &summary.symbols {
290            let exported = if n.exported { "✓" } else { "" };
291            if has_descriptions {
292                let desc = doc_map
293                    .get(n.name.as_str())
294                    .map(|d| first_line(d))
295                    .unwrap_or_default();
296                let _ = writeln!(
297                    out,
298                    "| {} | `{}` | {}–{} | {} | {} |",
299                    n.kind,
300                    n.name,
301                    n.line_start,
302                    n.line_end,
303                    exported,
304                    escape_table_cell(&desc),
305                );
306            } else {
307                let _ = writeln!(
308                    out,
309                    "| {} | `{}` | {}–{} | {} |",
310                    n.kind, n.name, n.line_start, n.line_end, exported,
311                );
312            }
313        }
314        out.push('\n');
315    }
316
317    // --- Callers / Callees ---
318    if !summary.callers.is_empty() {
319        out.push_str("## Called by\n\n");
320        for n in &summary.callers {
321            let target = module_target_for(&n.path, &n.community, community_labels);
322            let _ = writeln!(
323                out,
324                "- {} — `{}`",
325                format_link(&target, &n.name, opts.wiki_links_style),
326                n.path,
327            );
328        }
329        out.push('\n');
330    }
331    if !summary.callees.is_empty() {
332        out.push_str("## Calls into\n\n");
333        for n in &summary.callees {
334            let target = module_target_for(&n.path, &n.community, community_labels);
335            let _ = writeln!(
336                out,
337                "- {} — `{}`",
338                format_link(&target, &n.name, opts.wiki_links_style),
339                n.path,
340            );
341        }
342        out.push('\n');
343    }
344
345    // --- Tests ---
346    if !summary.tests.is_empty() {
347        out.push_str("## Tests covering this file\n\n");
348        let paths: std::collections::BTreeSet<&str> =
349            summary.tests.iter().map(|t| t.path.as_str()).collect();
350        let count = summary.tests.len();
351        let _ = writeln!(out, "{} test fn(s) across:", count);
352        for p in paths.iter().copied().take(10) {
353            let _ = writeln!(out, "- `{}`", p);
354        }
355        if paths.len() > 10 {
356            let _ = writeln!(out, "- … {} more", paths.len() - 10);
357        }
358        out.push('\n');
359    }
360
361    // --- Ownership ---
362    if !summary.owners.is_empty() {
363        out.push_str("## Ownership\n\n");
364        let mut seen = std::collections::HashSet::new();
365        for (name, weight) in &summary.owners {
366            if !seen.insert(name.clone()) {
367                continue;
368            }
369            let _ = writeln!(out, "- {} (weight: {:.2})", name, weight);
370        }
371        out.push('\n');
372    }
373
374    // --- Prompt packet ---
375    if opts.prompt_packets {
376        out.push_str("## AI prose stub\n\n");
377        out.push_str(
378            "_Replace the block below with prose by feeding it to Claude/Cursor. Run `cgx docs prompts --next` to step through unfilled packets._\n\n",
379        );
380        out.push_str(&prompt_packet::build(db, summary));
381        out.push('\n');
382    }
383
384    out
385}
386
387/// Build the canonical wiki-link target for a given file's module note.
388fn module_target_for(path: &str, _community: &i64, _labels: &HashMap<i64, String>) -> String {
389    let group = file_group(path);
390    let basename = path.rsplit('/').next().unwrap_or(path);
391    let filename = safe_filename(basename);
392    format!("30-Modules/{}/{}", group, filename)
393}
394
395fn tldr_line(summary: &FileSummary, role: FileRole) -> String {
396    let lang = if summary.language.is_empty() {
397        "Source"
398    } else {
399        match summary.language.as_str() {
400            "rust" => "Rust",
401            "typescript" => "TypeScript",
402            "javascript" => "JavaScript",
403            "python" => "Python",
404            "go" => "Go",
405            "java" => "Java",
406            "php" => "PHP",
407            other => other,
408        }
409    };
410    let role_desc = role.description();
411    let exported = if summary.exported_count > 0 {
412        format!(
413            "{} exported symbol{}",
414            summary.exported_count,
415            if summary.exported_count == 1 { "" } else { "s" }
416        )
417    } else {
418        "internal-only".to_string()
419    };
420    let edges = match (summary.callers.len(), summary.callees.len()) {
421        (0, 0) => String::from("no cross-file edges"),
422        (c, 0) => format!("called by {} other file(s)", c),
423        (0, d) => format!("calls into {} other file(s)", d),
424        (c, d) => format!("called by {} · calls into {}", c, d),
425    };
426    let risk = if summary.churn >= 0.5 || summary.complexity >= 10.0 {
427        " · ⚠ hotspot".to_string()
428    } else {
429        String::new()
430    };
431    format!(
432        "> **{} · {}** — {}, {}.{}",
433        lang, role_desc, exported, edges, risk
434    )
435}
436
437// =====================================================================
438// 00-Overview/
439// =====================================================================
440
441#[allow(clippy::too_many_arguments)]
442fn write_overview(
443    output_dir: &Path,
444    db: &GraphDb,
445    all_nodes: &[Node],
446    project: &ProjectInfo,
447    summaries: &[(String, FileSummary, FileRole)],
448    community_labels: &HashMap<i64, String>,
449    opts: &DocsOptions,
450) -> anyhow::Result<usize> {
451    let dir = output_dir.join("00-Overview");
452    std::fs::create_dir_all(&dir)?;
453
454    let lang = db.get_language_breakdown().unwrap_or_default();
455    let mut lang_entries: Vec<_> = lang.iter().collect();
456    lang_entries.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
457
458    let mut arch = String::new();
459    if opts.frontmatter {
460        arch.push_str("---\ncgx_kind: overview\ntags: [overview, architecture, cgx]\n---\n\n");
461    }
462
463    let _ = writeln!(arch, "# Architecture overview\n");
464
465    // ---- Project header ----
466    if let Some(name) = &project.name {
467        if let Some(version) = &project.version {
468            let _ = writeln!(arch, "**Project:** `{}` v{}", name, version);
469        } else {
470            let _ = writeln!(arch, "**Project:** `{}`", name);
471        }
472    }
473    if let Some(desc) = &project.description {
474        let _ = writeln!(arch, "\n> {}\n", desc);
475    }
476    if let Some(rd) = &project.readme_excerpt {
477        let _ = writeln!(arch, "{}\n", rd);
478    }
479
480    // ---- Stack ----
481    if !project.stack.is_empty() {
482        let _ = writeln!(arch, "**Stack:** {}", project.stack.join(", "));
483    }
484    if !lang_entries.is_empty() {
485        let langs: Vec<String> = lang_entries
486            .iter()
487            .map(|(l, p)| format!("{} {:.0}%", l, **p * 100.0))
488            .collect();
489        let _ = writeln!(arch, "**Languages by node count:** {}", langs.join(", "));
490    }
491    let _ = writeln!(
492        arch,
493        "**Manifests detected:** {}",
494        project.manifests.join(", ")
495    );
496    let _ = writeln!(
497        arch,
498        "**Graph size:** {} nodes · {} edges · {} files · {} communities\n",
499        db.node_count().unwrap_or(0),
500        db.edge_count().unwrap_or(0),
501        summaries.len(),
502        community_labels.len()
503    );
504
505    // ---- Dependencies (with purpose + usage check) ----
506    if !project.deps.is_empty() {
507        let runtime: Vec<&Dependency> = project
508            .deps
509            .iter()
510            .filter(|d| matches!(d.kind, DepKind::Runtime))
511            .collect();
512        let dev: Vec<&Dependency> = project
513            .deps
514            .iter()
515            .filter(|d| matches!(d.kind, DepKind::Dev | DepKind::Build | DepKind::Peer))
516            .collect();
517        let unused: Vec<&Dependency> = project
518            .deps
519            .iter()
520            .filter(|d| !d.used && matches!(d.kind, DepKind::Runtime))
521            .collect();
522
523        arch.push_str("## Dependencies and what they're used for\n\n");
524        let _ = writeln!(
525            arch,
526            "{} declared in total ({} runtime · {} dev/build/peer). {} runtime dep(s) have no detected import in source.\n",
527            project.deps.len(),
528            runtime.len(),
529            dev.len(),
530            unused.len()
531        );
532
533        if !runtime.is_empty() {
534            arch.push_str("### Runtime\n\n");
535            arch.push_str("| Package | Used for | Files importing | Status |\n|---|---|---|---|\n");
536            for d in &runtime {
537                let status = if d.used {
538                    format!("✓ {}", d.use_count)
539                } else {
540                    "⚠ unused?".to_string()
541                };
542                let _ = writeln!(
543                    arch,
544                    "| `{}` | {} | {} | {} |",
545                    d.name,
546                    escape_table_cell(&d.purpose),
547                    d.use_count,
548                    status
549                );
550            }
551            arch.push('\n');
552        }
553
554        if !dev.is_empty() {
555            arch.push_str("### Dev / build / peer\n\n");
556            arch.push_str("| Package | Used for | Kind |\n|---|---|---|\n");
557            for d in &dev {
558                let _ = writeln!(
559                    arch,
560                    "| `{}` | {} | {} |",
561                    d.name,
562                    escape_table_cell(&d.purpose),
563                    d.kind.label(),
564                );
565            }
566            arch.push('\n');
567        }
568
569        if !unused.is_empty() {
570            arch.push_str("> ⚠ **Possibly unused runtime dependencies:** ");
571            let names: Vec<String> = unused.iter().map(|d| format!("`{}`", d.name)).collect();
572            arch.push_str(&names.join(", "));
573            arch.push_str(". cgx scanned every source file for `use`/`import` statements but found none referencing these. Confirm before removing — they may be loaded via dynamic dispatch, build scripts, or feature flags.\n\n");
574        }
575    }
576
577    // ---- Role breakdown ----
578    let role_counts = role_distribution(summaries);
579    if !role_counts.is_empty() {
580        arch.push_str("## Files by role\n\n");
581        arch.push_str("| Role | Count |\n|---|---|\n");
582        let mut ordered: Vec<_> = role_counts.iter().collect();
583        ordered.sort_by_key(|(_, c)| std::cmp::Reverse(**c));
584        for (role, count) in ordered {
585            let _ = writeln!(arch, "| {} | {} |", role, count);
586        }
587        arch.push('\n');
588    }
589
590    // ---- Top groups (largest source directories) ----
591    arch.push_str("## Largest groups\n\n");
592    let group_sizes: Vec<(String, usize, usize)> = {
593        let mut counts: HashMap<String, (usize, usize)> = HashMap::new();
594        for (_, s, _) in summaries {
595            let entry = counts.entry(file_group(&s.path)).or_insert((0, 0));
596            entry.0 += 1;
597            entry.1 += s.exported_count;
598        }
599        let mut v: Vec<(String, usize, usize)> =
600            counts.into_iter().map(|(g, (f, e))| (g, f, e)).collect();
601        v.sort_by_key(|(_, files, _)| std::cmp::Reverse(*files));
602        v
603    };
604    for (group, files, exported) in group_sizes.iter().take(10) {
605        let slug = safe_filename(group).replace('/', "-");
606        let link = format_link(&public_api_target(&slug), group, opts.wiki_links_style);
607        let _ = writeln!(
608            arch,
609            "- {} — {} file(s), {} exported symbol(s)",
610            link, files, exported
611        );
612    }
613    arch.push('\n');
614    let _ = all_nodes; // retained for signature compat; not used anymore here.
615
616    // ---- Entry points (program roots) ----
617    let entry_files: Vec<&(String, FileSummary, FileRole)> = summaries
618        .iter()
619        .filter(|(_, _, r)| matches!(r, FileRole::Entry))
620        .collect();
621    if !entry_files.is_empty() {
622        arch.push_str("## Entry points (program roots)\n\n");
623        for (path, summary, _) in &entry_files {
624            let target = module_target_for(path, &summary.community, community_labels);
625            let _ = writeln!(
626                arch,
627                "- {} — `{}`",
628                format_link(
629                    &target,
630                    path.rsplit('/').next().unwrap_or(path),
631                    opts.wiki_links_style
632                ),
633                path
634            );
635        }
636        arch.push('\n');
637    }
638
639    // ---- See also ----
640    arch.push_str("## See also\n\n");
641    let _ = writeln!(
642        arch,
643        "- {}",
644        format_link(
645            "00-Overview/HowToNavigate",
646            "How to navigate",
647            opts.wiki_links_style
648        )
649    );
650    let _ = writeln!(
651        arch,
652        "- {}",
653        format_link("00-Overview/Glossary", "Glossary", opts.wiki_links_style)
654    );
655    let _ = writeln!(
656        arch,
657        "- {}",
658        format_link(
659            "20-Architecture/Groups",
660            "Source groups",
661            opts.wiki_links_style
662        )
663    );
664    let _ = writeln!(
665        arch,
666        "- {}",
667        format_link(
668            "20-Architecture/EntryPoints",
669            "Entry points (graph roots)",
670            opts.wiki_links_style
671        )
672    );
673    let _ = writeln!(
674        arch,
675        "- {}",
676        format_link("40-Risk/Hotspots", "Hotspots", opts.wiki_links_style)
677    );
678
679    std::fs::write(dir.join("Architecture.md"), arch)?;
680    Ok(1)
681}
682
683fn write_how_to_navigate(
684    output_dir: &Path,
685    project: &ProjectInfo,
686    opts: &DocsOptions,
687) -> anyhow::Result<usize> {
688    let dir = output_dir.join("00-Overview");
689    std::fs::create_dir_all(&dir)?;
690
691    let mut out = String::new();
692    if opts.frontmatter {
693        out.push_str("---\ncgx_kind: how_to_navigate\ntags: [overview, cgx]\n---\n\n");
694    }
695    out.push_str("# How to navigate this vault\n\n");
696
697    if let Some(name) = &project.name {
698        let _ = writeln!(
699            out,
700            "This vault documents **`{}`**. Read these in order if you're new.\n",
701            name
702        );
703    } else {
704        out.push_str("Read these in order if you're new to this codebase.\n\n");
705    }
706
707    let _ = writeln!(
708        out,
709        "1. {} — what the project is, stack, top-level shape.",
710        format_link(
711            "00-Overview/Architecture",
712            "Architecture overview",
713            opts.wiki_links_style
714        )
715    );
716    let _ = writeln!(
717        out,
718        "2. {} — directory-based modules, with file counts and exported-symbol counts.",
719        format_link(
720            "20-Architecture/Groups",
721            "Source groups",
722            opts.wiki_links_style
723        )
724    );
725    let _ = writeln!(
726        out,
727        "3. {} — exported API of each group.",
728        format_link("10-PublicAPI", "Public APIs", opts.wiki_links_style)
729    );
730    let _ = writeln!(out, "4. `30-Modules/<group>/<file>.md` — per-file notes with structure tables, callers/callees, tests, and AI prose stubs.");
731    let _ = writeln!(
732        out,
733        "5. {} — files most likely to break if you touch them.",
734        format_link("40-Risk/Hotspots", "Hotspots", opts.wiki_links_style)
735    );
736    out.push('\n');
737
738    out.push_str("## How module notes work\n\n");
739    out.push_str("Each module note has:\n\n");
740    out.push_str("- **TL;DR** — one line: language · role · symbol count · cross-file edges.\n");
741    out.push_str("- **What's documented in source** — any docstrings the parser found.\n");
742    out.push_str("- **Structure** — every symbol with its line range, export status, and inline description.\n");
743    out.push_str("- **Called by / Calls into** — wiki-linked neighbours.\n");
744    out.push_str("- **Tests / Ownership** — coverage and authorship.\n");
745    out.push_str("- **AI prose stub** — a `<!-- cgx-prompt -->` block. Feed it to Claude / Cursor to generate prose without re-reading the source.\n");
746
747    out.push_str("\n## Regenerating these docs\n\n");
748    out.push_str("```bash\n");
749    out.push_str("cgx analyze                                    # refresh the graph\n");
750    out.push_str(
751        "cgx docs generate --vault                      # full rebuild into Obsidian vault\n",
752    );
753    out.push_str(
754        "cgx docs generate --vault --incremental        # only files whose graph slice changed\n",
755    );
756    out.push_str("cgx docs generate --vault --force              # alias for full rebuild\n");
757    out.push_str("cgx docs prompts                               # list unfilled AI prose stubs\n");
758    out.push_str("cgx docs prompts --next                        # print the next packet to send to your AI\n");
759    out.push_str("```\n");
760
761    std::fs::write(dir.join("HowToNavigate.md"), out)?;
762    Ok(1)
763}
764
765/// `"42 nodes"`, `"1 node"`, `"3 communities"`. Handles the `-y → -ies`
766/// case (community → communities); everything else just gets a trailing `s`.
767fn pluralize(count: usize, noun: &str) -> String {
768    if count == 1 {
769        return format!("1 {}", noun);
770    }
771    let plural = if let Some(stem) = noun.strip_suffix('y') {
772        format!("{}ies", stem)
773    } else {
774        format!("{}s", noun)
775    };
776    format!("{} {}", count, plural)
777}
778
779fn write_glossary(
780    output_dir: &Path,
781    all_nodes: &[Node],
782    community_labels: &HashMap<i64, String>,
783    opts: &DocsOptions,
784) -> anyhow::Result<usize> {
785    let dir = output_dir.join("00-Overview");
786    std::fs::create_dir_all(&dir)?;
787
788    let mut out = String::new();
789    if opts.frontmatter {
790        out.push_str("---\ncgx_kind: glossary\ntags: [glossary, cgx]\n---\n\n");
791    }
792    out.push_str("# Glossary\n\n");
793    out.push_str("## Node kinds\n\n");
794    for kind in [
795        "File", "Function", "Class", "Variable", "Type", "Module", "Author",
796    ] {
797        let count = all_nodes.iter().filter(|n| n.kind == kind).count();
798        // Skip kinds this language/repo never produces — an empty "Type — 0"
799        // row is pure noise.
800        if count == 0 {
801            continue;
802        }
803        let _ = writeln!(out, "- **{}** — {}", kind, pluralize(count, "node"));
804    }
805
806    out.push_str("\n## Communities (Louvain clusters)\n\n");
807    let mut sizes: HashMap<i64, usize> = HashMap::new();
808    for n in all_nodes {
809        *sizes.entry(n.community).or_insert(0) += 1;
810    }
811    let mut ordered: Vec<(&i64, &usize)> = sizes.iter().collect();
812    ordered.sort_by_key(|(_, c)| std::cmp::Reverse(**c));
813
814    // Louvain leaves a long tail of singleton communities (one isolated node
815    // each). Listing all of them buries the few that matter, so we show the
816    // multi-node communities and collapse the singletons into one summary line.
817    let total = ordered.len();
818    let singletons = ordered.iter().filter(|(_, c)| **c <= 1).count();
819    let shown: Vec<_> = ordered.iter().filter(|(_, c)| **c > 1).collect();
820
821    let _ = writeln!(
822        out,
823        "{} total. Showing the {} with 2+ nodes; {} omitted.\n",
824        pluralize(total, "community"),
825        shown.len(),
826        pluralize(singletons, "singleton")
827    );
828    for (id, count) in shown {
829        let label = community_labels
830            .get(id)
831            .cloned()
832            .unwrap_or_else(|| format!("community-{}", id));
833        let _ = writeln!(
834            out,
835            "- **{}** (id={}) — {}",
836            label,
837            id,
838            pluralize(**count, "node")
839        );
840    }
841    std::fs::write(dir.join("Glossary.md"), out)?;
842    Ok(1)
843}
844
845// =====================================================================
846// 10-PublicAPI/
847// =====================================================================
848
849fn write_public_api(
850    output_dir: &Path,
851    summaries: &[(String, FileSummary, FileRole)],
852    opts: &DocsOptions,
853) -> anyhow::Result<usize> {
854    let dir = output_dir.join("10-PublicAPI");
855    std::fs::create_dir_all(&dir)?;
856
857    // Bucket symbols by file group, not by Louvain community.
858    let mut by_group: HashMap<String, Vec<&FileSummary>> = HashMap::new();
859    for (_, summary, _) in summaries {
860        by_group
861            .entry(file_group(&summary.path))
862            .or_default()
863            .push(summary);
864    }
865
866    let mut written = 0usize;
867    for (group, files) in &by_group {
868        let exported_count: usize = files.iter().map(|s| s.exported_count).sum();
869        if exported_count == 0 {
870            continue;
871        }
872        let mut content = String::new();
873        if opts.frontmatter {
874            content.push_str("---\ncgx_kind: public_api\ntags: [public-api, cgx]\n---\n\n");
875        }
876        let _ = writeln!(content, "# Public API — `{}`\n", group);
877        let _ = writeln!(
878            content,
879            "{} file(s) · {} exported symbol(s).\n",
880            files.len(),
881            exported_count
882        );
883
884        let mut sorted_files: Vec<&&FileSummary> = files.iter().collect();
885        sorted_files.sort_by(|a, b| a.path.cmp(&b.path));
886        for file in sorted_files {
887            let basename = file.path.rsplit('/').next().unwrap_or(&file.path);
888            let target = module_target_for(&file.path, &file.community, &HashMap::new());
889            let _ = writeln!(
890                content,
891                "## {} ({} exported)\n",
892                format_link(&target, basename, opts.wiki_links_style),
893                file.exported_count
894            );
895            let _ = writeln!(content, "`{}`\n", file.path);
896            for sym in &file.symbols {
897                if !sym.exported {
898                    continue;
899                }
900                let _ = writeln!(
901                    content,
902                    "- **`{}`** ({}) — line {}",
903                    sym.name, sym.kind, sym.line_start
904                );
905            }
906            content.push('\n');
907        }
908        let slug = safe_filename(group).replace('/', "-");
909        std::fs::write(dir.join(format!("{}.md", slug)), content)?;
910        written += 1;
911    }
912    Ok(written)
913}
914
915#[allow(dead_code)]
916fn render_symbol_row_unused(out: &mut String, s: &PublicSymbol) {
917    let _ = writeln!(out, "### `{}` ({})", s.name, s.kind);
918    let _ = writeln!(out, "- File: `{}` (line {})", s.path, s.line_start);
919    if let Some(doc) = &s.doc_comment {
920        if !doc.trim().is_empty() {
921            out.push_str("\n```\n");
922            out.push_str(doc.trim());
923            out.push_str("\n```\n\n");
924        }
925    } else {
926        out.push('\n');
927    }
928}
929
930// =====================================================================
931// 20-Architecture/
932// =====================================================================
933
934fn write_architecture(
935    output_dir: &Path,
936    db: &GraphDb,
937    summaries: &[(String, FileSummary, FileRole)],
938    community_labels: &HashMap<i64, String>,
939    opts: &DocsOptions,
940) -> anyhow::Result<usize> {
941    let dir = output_dir.join("20-Architecture");
942    std::fs::create_dir_all(&dir)?;
943
944    // Groups.md (replaces Communities.md as the primary navigation index)
945    let mut by_group: HashMap<String, Vec<&FileSummary>> = HashMap::new();
946    for (_, summary, _) in summaries {
947        by_group
948            .entry(file_group(&summary.path))
949            .or_default()
950            .push(summary);
951    }
952    let mut sorted_groups: Vec<(&String, &Vec<&FileSummary>)> = by_group.iter().collect();
953    sorted_groups.sort_by_key(|(g, _)| (*g).clone());
954
955    let mut groups = String::new();
956    if opts.frontmatter {
957        groups.push_str("---\ncgx_kind: groups\ntags: [architecture, cgx]\n---\n\n");
958    }
959    groups.push_str("# Source groups\n\n");
960    groups.push_str(
961        "Each group is a directory of related files. Click into one to see its public API.\n\n",
962    );
963    groups.push_str("| Group | Files | Exported symbols |\n|---|---|---|\n");
964    for (group, files) in &sorted_groups {
965        let exported: usize = files.iter().map(|s| s.exported_count).sum();
966        let slug = safe_filename(group).replace('/', "-");
967        let _ = writeln!(
968            groups,
969            "| {} | {} | {} |",
970            format_link(&public_api_target(&slug), group, opts.wiki_links_style),
971            files.len(),
972            exported,
973        );
974    }
975    std::fs::write(dir.join("Groups.md"), groups)?;
976
977    // Communities.md kept as raw Louvain output for graph nerds.
978    let mut comm = String::new();
979    if opts.frontmatter {
980        comm.push_str("---\ncgx_kind: communities\ntags: [architecture, cgx]\n---\n\n");
981    }
982    comm.push_str("# Louvain communities (raw graph clusters)\n\n");
983    comm.push_str(
984        "These are the raw clusters detected by Louvain modularity optimisation over the call graph. \
985         For navigation, prefer [[20-Architecture/Groups|Source groups]] — it organises by directory.\n\n",
986    );
987    comm.push_str("| ID | Label | Public symbols |\n|---|---|---|\n");
988    let mut entries: Vec<(&i64, &String)> = community_labels.iter().collect();
989    entries.sort_by(|a, b| a.1.cmp(b.1));
990    for (id, label) in entries {
991        let public_count = db
992            .get_public_api(ApiScope::Community(*id))
993            .map(|v| v.len())
994            .unwrap_or(0);
995        let _ = writeln!(comm, "| {} | {} | {} |", id, label, public_count);
996    }
997    std::fs::write(dir.join("Communities.md"), comm)?;
998
999    // CrossClusterDeps.md
1000    let edges = db.get_cross_cluster_deps().unwrap_or_default();
1001    let mut cross = String::new();
1002    if opts.frontmatter {
1003        cross.push_str("---\ncgx_kind: cross_cluster\ntags: [architecture, cgx]\n---\n\n");
1004    }
1005    cross.push_str("# Cross-cluster dependencies\n\n");
1006    if edges.is_empty() {
1007        cross.push_str("_(no cross-cluster edges detected)_\n");
1008    } else {
1009        cross.push_str("| From | → | To | Edges | Total weight |\n|---|---|---|---|---|\n");
1010        for e in edges.iter().take(50) {
1011            render_cross_edge(&mut cross, e, community_labels, opts.wiki_links_style);
1012        }
1013    }
1014    std::fs::write(dir.join("CrossClusterDeps.md"), cross)?;
1015
1016    // EntryPoints.md
1017    let entries = db.list_entry_points().unwrap_or_default();
1018    let mut ep = String::new();
1019    if opts.frontmatter {
1020        ep.push_str("---\ncgx_kind: entry_points\ntags: [architecture, cgx]\n---\n\n");
1021    }
1022    ep.push_str("# Entry points\n\n");
1023    ep.push_str("Nodes with no inbound graph edges — likely public entry points, event handlers, or dead candidates.\n\n");
1024    if entries.is_empty() {
1025        ep.push_str("_(none detected)_\n");
1026    } else {
1027        render_entry_points(&mut ep, &entries);
1028    }
1029    std::fs::write(dir.join("EntryPoints.md"), ep)?;
1030
1031    Ok(4)
1032}
1033
1034fn render_cross_edge(
1035    out: &mut String,
1036    e: &CrossClusterEdge,
1037    labels: &HashMap<i64, String>,
1038    style: WikiLinkStyle,
1039) {
1040    let src_label = labels
1041        .get(&e.src_community)
1042        .cloned()
1043        .unwrap_or_else(|| format!("community-{}", e.src_community));
1044    let dst_label = labels
1045        .get(&e.dst_community)
1046        .cloned()
1047        .unwrap_or_else(|| format!("community-{}", e.dst_community));
1048    // Communities are not navigable as their own notes (Public API notes are
1049    // bucketed by directory group, not Louvain community), so link the labels
1050    // to the Communities index, which lists every cluster. Linking them to a
1051    // per-community Public API note would dangle.
1052    let src_link = format_link("20-Architecture/Communities", &src_label, style);
1053    let dst_link = format_link("20-Architecture/Communities", &dst_label, style);
1054    let _ = writeln!(
1055        out,
1056        "| {} | → | {} | {} | {:.2} |",
1057        src_link, dst_link, e.edge_count, e.total_weight
1058    );
1059}
1060
1061fn render_entry_points(out: &mut String, entries: &[EntryPoint]) {
1062    let mut by_file: HashMap<String, Vec<&EntryPoint>> = HashMap::new();
1063    for ep in entries {
1064        by_file.entry(ep.node.path.clone()).or_default().push(ep);
1065    }
1066    let mut files: Vec<&String> = by_file.keys().collect();
1067    files.sort();
1068    for f in files {
1069        let _ = writeln!(out, "### `{}`\n", f);
1070        for ep in &by_file[f] {
1071            let _ = writeln!(
1072                out,
1073                "- `{}` ({}) — {}",
1074                ep.node.name, ep.node.kind, ep.reason
1075            );
1076        }
1077        out.push('\n');
1078    }
1079}
1080
1081// =====================================================================
1082// 40-Risk/
1083// =====================================================================
1084
1085fn write_risk(
1086    output_dir: &Path,
1087    db: &GraphDb,
1088    all_nodes: &[Node],
1089    opts: &DocsOptions,
1090) -> anyhow::Result<usize> {
1091    let dir = output_dir.join("40-Risk");
1092    std::fs::create_dir_all(&dir)?;
1093
1094    let mut hs: Vec<&Node> = all_nodes
1095        .iter()
1096        .filter(|n| n.kind == "File" && n.churn > 0.0)
1097        .collect();
1098    hs.sort_by(|a, b| {
1099        let sa = a.churn * a.coupling + a.in_degree as f64 * 0.01;
1100        let sb = b.churn * b.coupling + b.in_degree as f64 * 0.01;
1101        sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal)
1102    });
1103    let mut content = String::new();
1104    if opts.frontmatter {
1105        content.push_str("---\ncgx_kind: hotspots\ntags: [risk, cgx]\n---\n\n");
1106    }
1107    content.push_str("# Hotspots\n\n");
1108    content.push_str("Files ranked by churn × coupling. Edit these carefully.\n\n");
1109    content.push_str("| File | Churn | Coupling | In-deg |\n|---|---|---|---|\n");
1110    for n in hs.iter().take(30) {
1111        let _ = writeln!(
1112            content,
1113            "| `{}` | {:.2} | {:.2} | {} |",
1114            n.path, n.churn, n.coupling, n.in_degree
1115        );
1116    }
1117    std::fs::write(dir.join("Hotspots.md"), content)?;
1118
1119    let complex: Vec<&Node> = {
1120        let mut v: Vec<&Node> = all_nodes
1121            .iter()
1122            .filter(|n| n.kind == "Function" && n.complexity > 0.0)
1123            .collect();
1124        v.sort_by(|a, b| {
1125            b.complexity
1126                .partial_cmp(&a.complexity)
1127                .unwrap_or(std::cmp::Ordering::Equal)
1128        });
1129        v
1130    };
1131    let mut content = String::new();
1132    if opts.frontmatter {
1133        content.push_str("---\ncgx_kind: complexity\ntags: [risk, cgx]\n---\n\n");
1134    }
1135    content.push_str("# High-complexity functions\n\n");
1136    content.push_str("Top cyclomatic-complexity hot spots — good refactor candidates.\n\n");
1137    if complex.is_empty() {
1138        content.push_str("_(no complexity scores stored — cgx only computes complexity for TypeScript right now.)_\n");
1139    } else {
1140        content.push_str("| Function | File | Complexity |\n|---|---|---|\n");
1141        for n in complex.iter().take(40) {
1142            let _ = writeln!(
1143                content,
1144                "| `{}` | `{}` | {:.1} |",
1145                n.name, n.path, n.complexity
1146            );
1147        }
1148    }
1149    std::fs::write(dir.join("ComplexityHigh.md"), content)?;
1150
1151    if opts.include_dead_code {
1152        let dead: Vec<&Node> = all_nodes.iter().filter(|n| n.is_dead_candidate).collect();
1153        let mut content = String::new();
1154        if opts.frontmatter {
1155            content.push_str("---\ncgx_kind: dead_code\ntags: [risk, cleanup, cgx]\n---\n\n");
1156        }
1157        content.push_str("# Dead code candidates\n\n");
1158        if dead.is_empty() {
1159            content.push_str("_(no dead-code candidates flagged)_\n");
1160        } else {
1161            content.push_str("| Symbol | Kind | File | Reason |\n|---|---|---|---|\n");
1162            for n in &dead {
1163                let _ = writeln!(
1164                    content,
1165                    "| `{}` | {} | `{}` | {} |",
1166                    n.name,
1167                    n.kind,
1168                    n.path,
1169                    n.dead_reason.clone().unwrap_or_default()
1170                );
1171            }
1172        }
1173        std::fs::write(dir.join("DeadCode.md"), content)?;
1174    }
1175
1176    if opts.include_duplicates {
1177        let clones = db.get_clones(0.85, None).unwrap_or_default();
1178        let mut content = String::new();
1179        if opts.frontmatter {
1180            content.push_str("---\ncgx_kind: duplicates\ntags: [risk, cgx]\n---\n\n");
1181        }
1182        content.push_str("# Duplicate / near-duplicate code\n\n");
1183        if clones.is_empty() {
1184            content.push_str("_(no clone pairs above similarity threshold)_\n");
1185        } else {
1186            content.push_str("| A | B | Similarity | Kind |\n|---|---|---|---|\n");
1187            for c in clones.iter().take(100) {
1188                let _ = writeln!(
1189                    content,
1190                    "| `{}` | `{}` | {:.2} | {} |",
1191                    c.node_a, c.node_b, c.similarity, c.kind
1192                );
1193            }
1194        }
1195        std::fs::write(dir.join("Duplicates.md"), content)?;
1196    }
1197
1198    let total = 2
1199        + if opts.include_dead_code { 1 } else { 0 }
1200        + if opts.include_duplicates { 1 } else { 0 };
1201    Ok(total)
1202}
1203
1204// =====================================================================
1205// 50-Ownership/
1206// =====================================================================
1207
1208fn write_ownership(
1209    output_dir: &Path,
1210    db: &GraphDb,
1211    _all_nodes: &[Node],
1212    opts: &DocsOptions,
1213) -> anyhow::Result<usize> {
1214    let dir = output_dir.join("50-Ownership");
1215    std::fs::create_dir_all(&dir)?;
1216
1217    // `(author, files_owned)` sorted by file count, descending. Count the rows
1218    // here (contributors who actually own a file) so the headline matches the
1219    // table — Author nodes with no surviving OWNS edge are excluded.
1220    let ownership = db.get_ownership().unwrap_or_default();
1221    let mut content = String::new();
1222    if opts.frontmatter {
1223        content.push_str("---\ncgx_kind: owners\ntags: [ownership, cgx]\n---\n\n");
1224    }
1225    content.push_str("# File owners\n\n");
1226    if ownership.is_empty() {
1227        content.push_str("_(no author data — git layer may have been disabled during analyze)_\n");
1228    } else {
1229        let _ = writeln!(
1230            content,
1231            "{}, ranked by number of files they're a top contributor to.\n",
1232            pluralize(ownership.len(), "distinct contributor")
1233        );
1234        content.push_str("| Contributor | Files owned |\n|---|---|\n");
1235        for (author, files) in &ownership {
1236            let _ = writeln!(content, "| {} | {} |", author, files);
1237        }
1238    }
1239    std::fs::write(dir.join("Owners.md"), content)?;
1240
1241    let mut content = String::new();
1242    if opts.frontmatter {
1243        content.push_str("---\ncgx_kind: blame_graph\ntags: [ownership, cgx]\n---\n\n");
1244    }
1245    content.push_str("# Blame graph\n\n");
1246    content.push_str("Run `cgx query blame-graph` for the live per-contributor breakdown.\n");
1247    std::fs::write(dir.join("BlameGraph.md"), content)?;
1248
1249    Ok(2)
1250}
1251
1252// =====================================================================
1253// README.md (root index)
1254// =====================================================================
1255
1256fn write_readme(
1257    output_dir: &Path,
1258    db: &GraphDb,
1259    file_paths: &[String],
1260    project: &ProjectInfo,
1261    summaries: &[(String, FileSummary, FileRole)],
1262    opts: &DocsOptions,
1263) -> anyhow::Result<usize> {
1264    let mut readme = String::new();
1265    if opts.frontmatter {
1266        readme.push_str("---\ncgx_kind: index\ntags: [cgx]\n---\n\n");
1267    }
1268    readme.push_str("# cgx docs\n\n");
1269
1270    if let Some(name) = &project.name {
1271        let _ = writeln!(readme, "Auto-generated documentation for **`{}`**.\n", name);
1272    }
1273    if let Some(desc) = &project.description {
1274        let _ = writeln!(readme, "> {}\n", desc);
1275    } else if let Some(rd) = &project.readme_excerpt {
1276        let _ = writeln!(readme, "> {}\n", rd);
1277    }
1278
1279    if !project.stack.is_empty() {
1280        let _ = writeln!(readme, "**Stack:** {}", project.stack.join(", "));
1281    }
1282    let _ = writeln!(
1283        readme,
1284        "**Size:** {} files · {} nodes · {} edges · {} declared deps · regenerated {}\n",
1285        file_paths.len(),
1286        db.node_count().unwrap_or(0),
1287        db.edge_count().unwrap_or(0),
1288        project.deps.len(),
1289        chrono::Utc::now().format("%Y-%m-%d %H:%M UTC"),
1290    );
1291
1292    // Quick dep highlight: top runtime deps and any flagged-unused ones.
1293    let project_prefix = project.name.as_deref().unwrap_or("").to_string();
1294    let workspace_prefix = project_prefix.split('-').next().unwrap_or("").to_string();
1295    let is_intra_workspace = |name: &str| -> bool {
1296        !workspace_prefix.is_empty() && name.starts_with(&format!("{}-", workspace_prefix))
1297    };
1298    let runtime: Vec<&Dependency> = project
1299        .deps
1300        .iter()
1301        .filter(|d| matches!(d.kind, DepKind::Runtime))
1302        .collect();
1303    let curated: Vec<&Dependency> = runtime
1304        .iter()
1305        .copied()
1306        .filter(|d| !is_intra_workspace(&d.name) && !d.purpose.starts_with("Uncategorised"))
1307        .collect();
1308    let intra: Vec<&Dependency> = runtime
1309        .iter()
1310        .copied()
1311        .filter(|d| is_intra_workspace(&d.name))
1312        .collect();
1313
1314    if !runtime.is_empty() {
1315        readme.push_str("## What this project pulls in\n\n");
1316        readme.push_str(
1317            "Highlights — full breakdown in [[00-Overview/Architecture|Architecture overview]].\n\n",
1318        );
1319        for d in curated.iter().take(12) {
1320            let _ = writeln!(readme, "- `{}` — {}", d.name, d.purpose);
1321        }
1322        if !intra.is_empty() {
1323            readme.push_str("\n**Workspace-internal:** ");
1324            let names: Vec<String> = intra.iter().map(|d| format!("`{}`", d.name)).collect();
1325            readme.push_str(&names.join(", "));
1326            readme.push('\n');
1327        }
1328        let unused: Vec<&Dependency> = runtime
1329            .iter()
1330            .copied()
1331            .filter(|d| !d.used && !is_intra_workspace(&d.name))
1332            .collect();
1333        if !unused.is_empty() {
1334            readme.push('\n');
1335            let names: Vec<String> = unused.iter().map(|d| format!("`{}`", d.name)).collect();
1336            let _ = writeln!(
1337                readme,
1338                "> ⚠ **Possibly unused:** {} — declared in a manifest but no `use`/`import` found. Verify before removing.\n",
1339                names.join(", ")
1340            );
1341        }
1342    }
1343
1344    readme.push_str("## Start here\n\n");
1345    let _ = writeln!(
1346        readme,
1347        "- {} — what this project is, in 30 seconds.",
1348        format_link(
1349            "00-Overview/Architecture",
1350            "Architecture overview",
1351            opts.wiki_links_style
1352        )
1353    );
1354    let _ = writeln!(
1355        readme,
1356        "- {} — recommended reading path for new readers.",
1357        format_link(
1358            "00-Overview/HowToNavigate",
1359            "How to navigate",
1360            opts.wiki_links_style
1361        )
1362    );
1363    readme.push('\n');
1364
1365    readme.push_str("## Everything else\n\n");
1366    for (target, alias) in [
1367        ("00-Overview/Glossary", "Glossary"),
1368        (
1369            "20-Architecture/Groups",
1370            "Source groups (best place to browse)",
1371        ),
1372        ("10-PublicAPI", "Public APIs (per group)"),
1373        (
1374            "20-Architecture/Communities",
1375            "Louvain communities (graph-clustered)",
1376        ),
1377        (
1378            "20-Architecture/CrossClusterDeps",
1379            "Cross-cluster dependencies",
1380        ),
1381        ("20-Architecture/EntryPoints", "Entry points"),
1382        ("40-Risk/Hotspots", "Hotspots"),
1383        ("40-Risk/ComplexityHigh", "High-complexity functions"),
1384        ("40-Risk/DeadCode", "Dead code"),
1385        ("40-Risk/Duplicates", "Duplicates"),
1386        ("50-Ownership/Owners", "File owners"),
1387    ] {
1388        let _ = writeln!(
1389            readme,
1390            "- {}",
1391            format_link(target, alias, opts.wiki_links_style)
1392        );
1393    }
1394
1395    // Files by role — quick orientation.
1396    let role_counts = role_distribution(summaries);
1397    if !role_counts.is_empty() {
1398        readme.push_str("\n## Files by role\n\n");
1399        let mut ordered: Vec<_> = role_counts.iter().collect();
1400        ordered.sort_by_key(|(_, c)| std::cmp::Reverse(**c));
1401        for (role, count) in ordered {
1402            let _ = writeln!(readme, "- **{}** — {}", role, count);
1403        }
1404    }
1405
1406    readme.push_str("\n## Filling in the prose\n\n");
1407    readme.push_str(
1408        "Each module note under `30-Modules/` ends with a `<!-- cgx-prompt -->` block. \
1409         Paste it into Claude / Cursor and replace the block with the response.\n\n\
1410         Or run `cgx docs prompts` to list unfilled stubs and `cgx docs prompts --next` to print one packet at a time.\n",
1411    );
1412
1413    std::fs::write(output_dir.join("README.md"), readme)?;
1414    Ok(1)
1415}
1416
1417// =====================================================================
1418// Helpers
1419// =====================================================================
1420
1421fn role_distribution(
1422    summaries: &[(String, FileSummary, FileRole)],
1423) -> HashMap<&'static str, usize> {
1424    let mut counts: HashMap<&'static str, usize> = HashMap::new();
1425    for (_, _, role) in summaries {
1426        *counts.entry(role.label()).or_insert(0) += 1;
1427    }
1428    counts
1429}
1430
1431fn first_line(s: &str) -> String {
1432    s.lines()
1433        .next()
1434        .map(|l| l.trim().to_string())
1435        .unwrap_or_default()
1436}
1437
1438fn escape_table_cell(s: &str) -> String {
1439    s.replace('|', "\\|").replace('\n', " ")
1440}
1441
1442fn dedup_keep_order<I, T>(it: I) -> Vec<T>
1443where
1444    I: IntoIterator<Item = T>,
1445    T: Eq + std::hash::Hash + Clone,
1446{
1447    let mut seen = std::collections::HashSet::new();
1448    let mut out = Vec::new();
1449    for v in it {
1450        if seen.insert(v.clone()) {
1451            out.push(v);
1452        }
1453    }
1454    out
1455}
1456
1457fn yaml_quote(raw: &str) -> String {
1458    let needs_quote = raw.is_empty()
1459        || raw.starts_with('-')
1460        || raw.contains(": ")
1461        || raw.contains(['[', ']', '{', '}', ',', '#', '"', '\n', '\r', '\t']);
1462    if !needs_quote {
1463        return raw.to_string();
1464    }
1465    let mut out = String::with_capacity(raw.len() + 2);
1466    out.push('"');
1467    for c in raw.chars() {
1468        match c {
1469            '"' => out.push_str("\\\""),
1470            '\\' => out.push_str("\\\\"),
1471            '\n' => out.push_str("\\n"),
1472            '\r' => out.push_str("\\r"),
1473            '\t' => out.push_str("\\t"),
1474            _ => out.push(c),
1475        }
1476    }
1477    out.push('"');
1478    out
1479}