1use 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 pub stack: Vec<String>,
16 pub readme_excerpt: Option<String>,
18 pub deps: Vec<Dependency>,
20 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 pub purpose: String,
32 pub used: bool,
34 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 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 let index = scan_imports(repo_path);
101
102 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 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 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 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
202fn 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
397fn 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
459struct SourceIndex {
465 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
514fn 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
541fn rust_file_uses(content: &str, ident: &str) -> bool {
545 if ident.is_empty() {
546 return false;
547 }
548 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 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
611fn 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
636fn 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 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 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
664const PURPOSES: &[(&str, &str)] = &[
669 ("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 ("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 ("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 ("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 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}