1use 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 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 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 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 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 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 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 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
186fn 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 out.push_str(&tldr_line(summary, role));
244 out.push_str("\n\n");
245
246 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 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 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 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 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 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 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
387fn 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#[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 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 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 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 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 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; 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 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
765fn 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 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 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
845fn 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 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
930fn 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 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 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 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 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 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
1081fn 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
1204fn 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 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
1252fn 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 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 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
1417fn 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}