Skip to main content

cgx_engine/docs/
project.rs

1//! Detect "what is this project?" by walking every manifest in the repo and
2//! cross-referencing declared dependencies with actual imports in source code.
3//!
4//! Output drives both `00-Overview/Architecture.md` and the root `README.md`.
5
6use std::collections::{BTreeMap, HashSet};
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, Default)]
10pub struct ProjectInfo {
11    pub name: Option<String>,
12    pub version: Option<String>,
13    pub description: Option<String>,
14    /// Free-form labels: "Rust workspace", "Node package", "Python package", "Go module".
15    pub stack: Vec<String>,
16    /// First non-empty paragraph from README.md.
17    pub readme_excerpt: Option<String>,
18    /// One entry per `(manifest, dep)` pair, deduped by name.
19    pub deps: Vec<Dependency>,
20    /// Manifest files we successfully read, relative to repo root.
21    pub manifests: Vec<String>,
22}
23
24#[derive(Debug, Clone)]
25pub struct Dependency {
26    pub name: String,
27    pub version: Option<String>,
28    pub manifest: String,
29    pub kind: DepKind,
30    /// Human-readable one-liner ("JSON serialisation"). Curated lookup with a fallback.
31    pub purpose: String,
32    /// Whether we found any imports of this dep in source files.
33    pub used: bool,
34    /// Number of files importing this dep (0 ⇒ unused candidate).
35    pub use_count: usize,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum DepKind {
40    Runtime,
41    Dev,
42    Build,
43    Peer,
44}
45
46impl DepKind {
47    pub fn label(self) -> &'static str {
48        match self {
49            DepKind::Runtime => "runtime",
50            DepKind::Dev => "dev",
51            DepKind::Build => "build",
52            DepKind::Peer => "peer",
53        }
54    }
55}
56
57pub fn detect(repo_path: &Path) -> ProjectInfo {
58    let mut info = ProjectInfo::default();
59
60    // ---- Walk every relevant manifest ----
61    let manifests = discover_manifests(repo_path);
62    let mut raw_deps: Vec<Dependency> = Vec::new();
63
64    for manifest in &manifests {
65        let abs = repo_path.join(manifest);
66        let rel = manifest.clone();
67        if manifest.ends_with("Cargo.toml") {
68            if let Some((header, deps)) = read_cargo(&abs, &rel) {
69                merge_header(&mut info, header, "Rust workspace");
70                raw_deps.extend(deps);
71            }
72        } else if manifest.ends_with("package.json") {
73            if let Some((header, deps)) = read_package_json(&abs, &rel) {
74                merge_header(&mut info, header, "Node project");
75                raw_deps.extend(deps);
76            }
77        } else if manifest.ends_with("pyproject.toml") {
78            if let Some((header, deps)) = read_pyproject(&abs, &rel) {
79                merge_header(&mut info, header, "Python package");
80                raw_deps.extend(deps);
81            }
82        } else if manifest.ends_with("requirements.txt") {
83            let deps = read_requirements_txt(&abs, &rel);
84            if !deps.is_empty() {
85                if !info.stack.contains(&"Python package".to_string()) {
86                    info.stack.push("Python package".to_string());
87                }
88                raw_deps.extend(deps);
89            }
90        } else if manifest.ends_with("go.mod")
91            && read_go_mod(&abs).is_some()
92            && !info.stack.contains(&"Go module".to_string())
93        {
94            info.stack.push("Go module".to_string());
95        }
96        info.manifests.push(rel);
97    }
98
99    // ---- Scan source for imports to flag unused deps ----
100    let index = scan_imports(repo_path);
101
102    // ---- Dedupe by name (workspace + sub-crate often declare the same dep) ----
103    let mut by_name: BTreeMap<String, Dependency> = BTreeMap::new();
104    for mut d in raw_deps {
105        d.use_count = lookup_use_count(&index, &d.name);
106        d.used = d.use_count > 0;
107        d.purpose = purpose_for(&d.name);
108        by_name
109            .entry(d.name.clone())
110            .and_modify(|existing| {
111                // Prefer the entry with the more specific manifest path (longer one is sub-crate).
112                if d.manifest.len() > existing.manifest.len() {
113                    existing.manifest = d.manifest.clone();
114                }
115                if existing.version.is_none() && d.version.is_some() {
116                    existing.version = d.version.clone();
117                }
118                if d.used && !existing.used {
119                    existing.used = true;
120                    existing.use_count = d.use_count;
121                }
122            })
123            .or_insert(d);
124    }
125    info.deps = by_name.into_values().collect();
126    info.deps.sort_by(|a, b| a.name.cmp(&b.name));
127
128    info.readme_excerpt = read_readme_excerpt(repo_path);
129
130    // Stable dedup of stack labels.
131    let mut seen = HashSet::new();
132    info.stack.retain(|s| seen.insert(s.clone()));
133
134    info
135}
136
137fn discover_manifests(repo_path: &Path) -> Vec<String> {
138    let candidates = [
139        "Cargo.toml",
140        "package.json",
141        "pyproject.toml",
142        "requirements.txt",
143        "go.mod",
144        "Pipfile",
145    ];
146    let mut out: Vec<String> = Vec::new();
147    for c in &candidates {
148        if repo_path.join(c).exists() {
149            out.push((*c).to_string());
150        }
151    }
152    // Walk one level deep into common workspace folders.
153    for prefix in ["crates", "packages", "apps", "services"] {
154        let dir = repo_path.join(prefix);
155        if !dir.is_dir() {
156            continue;
157        }
158        let Ok(entries) = std::fs::read_dir(&dir) else {
159            continue;
160        };
161        for entry in entries.flatten() {
162            if !entry.path().is_dir() {
163                continue;
164            }
165            for c in &candidates {
166                let p = entry.path().join(c);
167                if p.exists() {
168                    if let Ok(rel) = p.strip_prefix(repo_path) {
169                        out.push(rel.to_string_lossy().to_string());
170                    }
171                }
172            }
173        }
174    }
175    out.sort();
176    out.dedup();
177    out
178}
179
180#[derive(Default)]
181struct ManifestHeader {
182    name: Option<String>,
183    version: Option<String>,
184    description: Option<String>,
185}
186
187fn merge_header(info: &mut ProjectInfo, header: ManifestHeader, stack_label: &str) {
188    if info.name.is_none() {
189        info.name = header.name;
190    }
191    if info.version.is_none() {
192        info.version = header.version;
193    }
194    if info.description.is_none() {
195        info.description = header.description;
196    }
197    if !info.stack.iter().any(|s| s == stack_label) {
198        info.stack.push(stack_label.to_string());
199    }
200}
201
202// ---------------------------------------------------------------------
203// Per-format readers
204// ---------------------------------------------------------------------
205
206fn read_cargo(abs: &Path, rel: &str) -> Option<(ManifestHeader, Vec<Dependency>)> {
207    let content = std::fs::read_to_string(abs).ok()?;
208    let parsed: toml::Value = toml::from_str(&content).ok()?;
209
210    let pkg = parsed.get("package");
211    let workspace_pkg = parsed.get("workspace").and_then(|w| w.get("package"));
212    let source = pkg.or(workspace_pkg);
213    let header = ManifestHeader {
214        name: source
215            .and_then(|t| t.get("name"))
216            .and_then(|v| v.as_str())
217            .map(String::from),
218        version: source
219            .and_then(|t| t.get("version"))
220            .and_then(|v| v.as_str())
221            .map(String::from),
222        description: source
223            .and_then(|t| t.get("description"))
224            .and_then(|v| v.as_str())
225            .map(String::from),
226    };
227
228    let mut deps: Vec<Dependency> = Vec::new();
229    for (table_path, kind) in [
230        (vec!["dependencies"], DepKind::Runtime),
231        (vec!["dev-dependencies"], DepKind::Dev),
232        (vec!["build-dependencies"], DepKind::Build),
233        (vec!["workspace", "dependencies"], DepKind::Runtime),
234    ] {
235        let mut node: &toml::Value = &parsed;
236        let mut ok = true;
237        for seg in &table_path {
238            match node.get(*seg) {
239                Some(n) => node = n,
240                None => {
241                    ok = false;
242                    break;
243                }
244            }
245        }
246        if !ok {
247            continue;
248        }
249        if let Some(map) = node.as_table() {
250            for (name, value) in map {
251                let version = match value {
252                    toml::Value::String(s) => Some(s.clone()),
253                    toml::Value::Table(t) => {
254                        t.get("version").and_then(|v| v.as_str()).map(String::from)
255                    }
256                    _ => None,
257                };
258                deps.push(Dependency {
259                    name: name.clone(),
260                    version,
261                    manifest: rel.to_string(),
262                    kind,
263                    purpose: String::new(),
264                    used: false,
265                    use_count: 0,
266                });
267            }
268        }
269    }
270
271    Some((header, deps))
272}
273
274fn read_package_json(abs: &Path, rel: &str) -> Option<(ManifestHeader, Vec<Dependency>)> {
275    let content = std::fs::read_to_string(abs).ok()?;
276    let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
277    let header = ManifestHeader {
278        name: parsed
279            .get("name")
280            .and_then(|v| v.as_str())
281            .map(String::from),
282        version: parsed
283            .get("version")
284            .and_then(|v| v.as_str())
285            .map(String::from),
286        description: parsed
287            .get("description")
288            .and_then(|v| v.as_str())
289            .map(String::from),
290    };
291
292    let mut deps: Vec<Dependency> = Vec::new();
293    for (key, kind) in [
294        ("dependencies", DepKind::Runtime),
295        ("devDependencies", DepKind::Dev),
296        ("peerDependencies", DepKind::Peer),
297    ] {
298        if let Some(map) = parsed.get(key).and_then(|v| v.as_object()) {
299            for (name, ver) in map {
300                deps.push(Dependency {
301                    name: name.clone(),
302                    version: ver.as_str().map(String::from),
303                    manifest: rel.to_string(),
304                    kind,
305                    purpose: String::new(),
306                    used: false,
307                    use_count: 0,
308                });
309            }
310        }
311    }
312    Some((header, deps))
313}
314
315fn read_pyproject(abs: &Path, rel: &str) -> Option<(ManifestHeader, Vec<Dependency>)> {
316    let content = std::fs::read_to_string(abs).ok()?;
317    let parsed: toml::Value = toml::from_str(&content).ok()?;
318    let project = parsed.get("project")?;
319    let header = ManifestHeader {
320        name: project
321            .get("name")
322            .and_then(|v| v.as_str())
323            .map(String::from),
324        version: project
325            .get("version")
326            .and_then(|v| v.as_str())
327            .map(String::from),
328        description: project
329            .get("description")
330            .and_then(|v| v.as_str())
331            .map(String::from),
332    };
333
334    let mut deps: Vec<Dependency> = Vec::new();
335    if let Some(list) = project.get("dependencies").and_then(|v| v.as_array()) {
336        for d in list {
337            if let Some(s) = d.as_str() {
338                let name = s
339                    .split(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
340                    .next()
341                    .unwrap_or("");
342                if !name.is_empty() {
343                    deps.push(Dependency {
344                        name: name.to_string(),
345                        version: None,
346                        manifest: rel.to_string(),
347                        kind: DepKind::Runtime,
348                        purpose: String::new(),
349                        used: false,
350                        use_count: 0,
351                    });
352                }
353            }
354        }
355    }
356    Some((header, deps))
357}
358
359fn read_requirements_txt(abs: &Path, rel: &str) -> Vec<Dependency> {
360    let Ok(content) = std::fs::read_to_string(abs) else {
361        return Vec::new();
362    };
363    let mut out = Vec::new();
364    for line in content.lines() {
365        let line = line.trim();
366        if line.is_empty() || line.starts_with('#') {
367            continue;
368        }
369        let name = line
370            .split(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
371            .next()
372            .unwrap_or("");
373        if name.is_empty() {
374            continue;
375        }
376        out.push(Dependency {
377            name: name.to_string(),
378            version: None,
379            manifest: rel.to_string(),
380            kind: DepKind::Runtime,
381            purpose: String::new(),
382            used: false,
383            use_count: 0,
384        });
385    }
386    out
387}
388
389fn read_go_mod(abs: &Path) -> Option<()> {
390    if abs.exists() {
391        Some(())
392    } else {
393        None
394    }
395}
396
397// ---------------------------------------------------------------------
398// README excerpt
399// ---------------------------------------------------------------------
400
401fn read_readme_excerpt(repo_path: &Path) -> Option<String> {
402    for name in ["README.md", "Readme.md", "readme.md", "README.markdown"] {
403        let p = repo_path.join(name);
404        if !p.exists() {
405            continue;
406        }
407        let content = std::fs::read_to_string(&p).ok()?;
408        let body = if content.starts_with("---") {
409            content.splitn(3, "---").nth(2).unwrap_or(&content)
410        } else {
411            &content
412        };
413        let mut paragraph: Vec<&str> = Vec::new();
414        for line in body.lines() {
415            let trimmed = line.trim();
416            if trimmed.is_empty() {
417                if !paragraph.is_empty() {
418                    let joined = paragraph.join(" ").trim().to_string();
419                    if !joined.is_empty() && joined.len() > 20 {
420                        return Some(truncate_clean(&joined, 600));
421                    }
422                    paragraph.clear();
423                }
424                continue;
425            }
426            if trimmed.starts_with('#')
427                || trimmed.starts_with('!')
428                || trimmed.starts_with('<')
429                || trimmed.starts_with("> ")
430                || trimmed.starts_with('|')
431                || trimmed.starts_with("```")
432            {
433                paragraph.clear();
434                continue;
435            }
436            paragraph.push(trimmed);
437        }
438        if !paragraph.is_empty() {
439            let joined = paragraph.join(" ").trim().to_string();
440            if !joined.is_empty() && joined.len() > 20 {
441                return Some(truncate_clean(&joined, 600));
442            }
443        }
444    }
445    None
446}
447
448fn truncate_clean(s: &str, max: usize) -> String {
449    if s.len() <= max {
450        return s.to_string();
451    }
452    let slice = &s[..max];
453    if let Some(idx) = slice.rfind(['.', '!', '?']) {
454        return s[..=idx].to_string();
455    }
456    format!("{}…", slice)
457}
458
459// ---------------------------------------------------------------------
460// Import scanning
461// ---------------------------------------------------------------------
462
463/// Lightweight per-language index of source files for usage detection.
464struct SourceIndex {
465    /// `(ext, content)` for each readable source file.
466    files: Vec<(String, String)>,
467}
468
469fn scan_imports(repo_path: &Path) -> SourceIndex {
470    let mut paths: Vec<PathBuf> = Vec::new();
471    walk_for_source(repo_path, &mut paths, 0);
472    let mut files: Vec<(String, String)> = Vec::with_capacity(paths.len());
473    for p in paths {
474        let Some(ext) = p.extension().and_then(|e| e.to_str()) else {
475            continue;
476        };
477        let Ok(content) = std::fs::read_to_string(&p) else {
478            continue;
479        };
480        files.push((ext.to_string(), content));
481    }
482    SourceIndex { files }
483}
484
485fn walk_for_source(dir: &Path, out: &mut Vec<PathBuf>, depth: usize) {
486    if depth > 10 {
487        return;
488    }
489    let name = dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
490    if matches!(
491        name,
492        "node_modules" | "target" | ".git" | "dist" | "build" | "web-ui-dist" | "__pycache__"
493    ) {
494        return;
495    }
496    let Ok(entries) = std::fs::read_dir(dir) else {
497        return;
498    };
499    for entry in entries.flatten() {
500        let path = entry.path();
501        if path.is_dir() {
502            walk_for_source(&path, out, depth + 1);
503        } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
504            if matches!(
505                ext,
506                "rs" | "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" | "py" | "go"
507            ) {
508                out.push(path);
509            }
510        }
511    }
512}
513
514/// Count files in the index that "use" `dep_name`.
515///
516/// For Rust, we look for the identifier (with `-` → `_`) appearing as a
517/// word boundary anywhere in the source — this catches both `use foo::...`
518/// and bare path expressions like `foo::bar()` that `use`-only scanning
519/// misses (e.g. `tree_sitter_rust::language()`, `serde_json::json!(...)`).
520///
521/// For JS/TS we still look for `import` / `require` strings since identifier
522/// names in JS don't reliably match the package name.
523fn lookup_use_count(index: &SourceIndex, dep_name: &str) -> usize {
524    let rust_ident = dep_name.replace('-', "_");
525    let mut count = 0usize;
526    for (ext, content) in &index.files {
527        let matched = match ext.as_str() {
528            "rs" => rust_file_uses(content, &rust_ident),
529            "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" => js_file_imports(content, dep_name),
530            "py" => python_file_imports(content, dep_name),
531            "go" => go_file_imports(content, dep_name),
532            _ => false,
533        };
534        if matched {
535            count += 1;
536        }
537    }
538    count
539}
540
541/// True if `content` contains `ident` as an identifier (word-bounded).
542/// Skips matches inside line/block comments so a stray "// uses tokio" doesn't
543/// count as real usage.
544fn rust_file_uses(content: &str, ident: &str) -> bool {
545    if ident.is_empty() {
546        return false;
547    }
548    // Strip line and block comments before scanning.
549    let stripped = strip_rust_comments(content);
550    contains_identifier(&stripped, ident)
551}
552
553fn js_file_imports(content: &str, pkg: &str) -> bool {
554    for line in content.lines() {
555        let t = line.trim_start();
556        for marker in ["from ", "import ", "require("] {
557            if let Some(idx) = t.find(marker) {
558                let after = &t[idx + marker.len()..];
559                let after = after.trim_start();
560                if let Some(stripped) = after.strip_prefix('\'').or_else(|| after.strip_prefix('"'))
561                {
562                    if let Some(end) = stripped.find(['\'', '"']) {
563                        let name = &stripped[..end];
564                        if name == pkg || name.starts_with(&format!("{}/", pkg)) {
565                            return true;
566                        }
567                    }
568                }
569            }
570        }
571    }
572    false
573}
574
575fn python_file_imports(content: &str, pkg: &str) -> bool {
576    for line in content.lines() {
577        let t = line.trim_start();
578        if let Some(rest) = t.strip_prefix("from ") {
579            let head = rest.split([' ', '.']).next().unwrap_or("");
580            if head == pkg {
581                return true;
582            }
583        } else if let Some(rest) = t.strip_prefix("import ") {
584            for part in rest.split(',') {
585                let head = part.trim().split([' ', '.', ';']).next().unwrap_or("");
586                if head == pkg {
587                    return true;
588                }
589            }
590        }
591    }
592    false
593}
594
595fn go_file_imports(content: &str, pkg: &str) -> bool {
596    // Go deps are written as full module paths; do a contains check.
597    for line in content.lines() {
598        let t = line.trim();
599        if let Some(idx) = t.find('"') {
600            if let Some(end) = t[idx + 1..].find('"') {
601                let path = &t[idx + 1..idx + 1 + end];
602                if path == pkg || path.starts_with(&format!("{}/", pkg)) {
603                    return true;
604                }
605            }
606        }
607    }
608    false
609}
610
611/// Word-boundary identifier check. `ident` is treated as a Rust-style identifier.
612fn contains_identifier(haystack: &str, ident: &str) -> bool {
613    let mut start = 0;
614    while let Some(pos) = haystack[start..].find(ident) {
615        let abs = start + pos;
616        let before = if abs == 0 {
617            None
618        } else {
619            haystack[..abs].chars().last()
620        };
621        let after = haystack[abs + ident.len()..].chars().next();
622        let ok_before = before.is_none_or(|c| !is_ident_char(c));
623        let ok_after = after.is_none_or(|c| !is_ident_char(c));
624        if ok_before && ok_after {
625            return true;
626        }
627        start = abs + ident.len();
628    }
629    false
630}
631
632fn is_ident_char(c: char) -> bool {
633    c.is_ascii_alphanumeric() || c == '_'
634}
635
636/// Strip `//`-line and `/* */`-block comments from Rust source. Naive but
637/// good enough for usage detection — string literals containing `//` are
638/// rare in practice.
639fn strip_rust_comments(src: &str) -> String {
640    let mut out = String::with_capacity(src.len());
641    let bytes = src.as_bytes();
642    let mut i = 0;
643    while i < bytes.len() {
644        if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'/' {
645            // line comment
646            while i < bytes.len() && bytes[i] != b'\n' {
647                i += 1;
648            }
649        } else if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {
650            // block comment (no nesting for simplicity)
651            i += 2;
652            while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
653                i += 1;
654            }
655            i = i.saturating_add(2).min(bytes.len());
656        } else {
657            out.push(bytes[i] as char);
658            i += 1;
659        }
660    }
661    out
662}
663
664// ---------------------------------------------------------------------
665// Curated purpose lookup
666// ---------------------------------------------------------------------
667
668const PURPOSES: &[(&str, &str)] = &[
669    // --- Rust ---
670    ("anyhow", "Error handling with context chains"),
671    ("thiserror", "Custom error types"),
672    ("serde", "Serialise/deserialise framework"),
673    ("serde_json", "JSON serialisation"),
674    ("toml", "TOML parsing"),
675    ("tokio", "Async runtime"),
676    ("async-trait", "Async methods in traits"),
677    ("futures", "Async combinators"),
678    ("tracing", "Structured logging"),
679    ("tracing-subscriber", "Logging output formatting"),
680    ("log", "Logging facade"),
681    ("env_logger", "Environment-driven logger"),
682    ("chrono", "Date and time"),
683    ("clap", "CLI argument parsing"),
684    ("dirs", "Standard user directories"),
685    ("sha2", "SHA-256/512 hashing"),
686    ("blake3", "Fast cryptographic hashing"),
687    ("uuid", "UUID generation"),
688    ("regex", "Regular expressions"),
689    ("rayon", "Data parallelism"),
690    ("crossbeam", "Concurrent data structures"),
691    ("notify", "Filesystem watching"),
692    ("walkdir", "Recursive directory walking"),
693    ("ignore", "Gitignore-aware file walking"),
694    ("duckdb", "Embedded analytics database"),
695    ("rusqlite", "SQLite bindings"),
696    ("reqwest", "HTTP client"),
697    ("axum", "HTTP server"),
698    ("actix-web", "HTTP server"),
699    ("hyper", "Low-level HTTP"),
700    ("tower", "Service middleware"),
701    ("ratatui", "Terminal UI framework"),
702    ("crossterm", "Terminal manipulation"),
703    ("tree-sitter", "Incremental parsing framework"),
704    ("tree-sitter-rust", "Tree-sitter Rust grammar"),
705    ("tree-sitter-typescript", "Tree-sitter TypeScript grammar"),
706    ("tree-sitter-javascript", "Tree-sitter JavaScript grammar"),
707    ("tree-sitter-python", "Tree-sitter Python grammar"),
708    ("tree-sitter-go", "Tree-sitter Go grammar"),
709    ("tree-sitter-java", "Tree-sitter Java grammar"),
710    ("tree-sitter-php", "Tree-sitter PHP grammar"),
711    ("tree-sitter-c-sharp", "Tree-sitter C# grammar"),
712    ("git2", "Git bindings"),
713    ("gix", "Pure-Rust git"),
714    ("indicatif", "Progress bars"),
715    ("dialoguer", "Interactive prompts"),
716    ("console", "Terminal styling"),
717    ("colored", "Terminal colours"),
718    ("once_cell", "Lazy statics"),
719    ("lazy_static", "Lazy statics"),
720    ("itertools", "Iterator helpers"),
721    ("rand", "Random number generation"),
722    ("fastrand", "Fast random number generation"),
723    ("base64", "Base64 encoding"),
724    ("flate2", "DEFLATE/gzip compression"),
725    ("mime_guess", "MIME-type guessing from path"),
726    ("open", "Open a path in the user's default app"),
727    ("rust-embed", "Embed files into the binary at build time"),
728    ("tokio-stream", "Async stream combinators (Tokio)"),
729    ("tokio-util", "Tokio helpers (codecs, frames)"),
730    ("tower-http", "HTTP middleware (tower)"),
731    ("async-stream", "Async stream macros"),
732    ("fdg-sim", "Force-directed graph simulation"),
733    ("graphology", "Graph data structures (JS)"),
734    ("graphology-communities-louvain", "Louvain clustering (JS)"),
735    ("graphology-layout-forceatlas2", "ForceAtlas2 layout (JS)"),
736    ("sigma", "Graph rendering (JS)"),
737    ("prism-react-renderer", "Syntax highlighting"),
738    // --- Node / JS / TS ---
739    ("react", "UI rendering library"),
740    ("react-dom", "React DOM renderer"),
741    ("react-router", "Client-side routing"),
742    ("react-router-dom", "React routing for browsers"),
743    ("next", "Next.js framework"),
744    ("vue", "Vue.js framework"),
745    ("svelte", "Svelte framework"),
746    ("vite", "Frontend dev server / bundler"),
747    ("@vitejs/plugin-react", "Vite React plugin"),
748    ("typescript", "TypeScript compiler"),
749    ("eslint", "Linter"),
750    ("prettier", "Code formatter"),
751    ("tailwindcss", "CSS utility framework"),
752    ("postcss", "CSS post-processor"),
753    ("autoprefixer", "CSS vendor prefixing"),
754    ("d3", "Data-driven SVG"),
755    ("d3-force", "Force-directed layout"),
756    ("d3-zoom", "SVG pan/zoom"),
757    ("fuse.js", "Client-side fuzzy search"),
758    ("zustand", "State management"),
759    ("@tanstack/react-query", "Async data fetching cache"),
760    ("axios", "HTTP client"),
761    ("lodash", "Utility helpers"),
762    ("zod", "Schema validation"),
763    ("dayjs", "Date manipulation"),
764    ("framer-motion", "Animation library"),
765    ("clsx", "Conditional className helper"),
766    ("vitest", "Test runner"),
767    ("jest", "Test runner"),
768    ("@testing-library/react", "React testing utilities"),
769    ("@types/node", "Node.js TypeScript types"),
770    ("@types/react", "React TypeScript types"),
771    ("@types/react-dom", "React DOM TypeScript types"),
772    // --- Python ---
773    ("requests", "HTTP client"),
774    ("flask", "Web framework"),
775    ("django", "Web framework"),
776    ("fastapi", "ASGI web framework"),
777    ("pydantic", "Data validation"),
778    ("sqlalchemy", "ORM"),
779    ("numpy", "Numerical arrays"),
780    ("pandas", "Data analysis"),
781    ("pytest", "Test runner"),
782    // --- Go ---
783    ("github.com/gin-gonic/gin", "Web framework"),
784    ("github.com/spf13/cobra", "CLI framework"),
785    ("github.com/stretchr/testify", "Testing assertions"),
786];
787
788fn purpose_for(name: &str) -> String {
789    for (n, p) in PURPOSES {
790        if *n == name {
791            return (*p).to_string();
792        }
793    }
794    // Heuristic fallback for unknown deps — extract a few tokens from the name.
795    if let Some(grammar) = name.strip_prefix("tree-sitter-") {
796        return format!("Tree-sitter grammar for {}", grammar);
797    }
798    if let Some(typename) = name.strip_prefix("@types/") {
799        return format!("TypeScript types for `{}`", typename);
800    }
801    if name.starts_with("eslint-") {
802        return "ESLint plugin".to_string();
803    }
804    if name.contains("logger") || name.contains("logging") {
805        return "Logging".to_string();
806    }
807    "Uncategorised — see crate/package docs".to_string()
808}