//! 项目技术栈探测 — 浅读根目录标志文件识别技术栈 //! //! 纯函数、零状态、零外部依赖(仅 std + serde_json + anyhow)。 //! 只读根目录标志文件,不递归遍历(控制性能与安全)。 //! //! 供 commands 层薄封装暴露为 IPC 命令,新建/导入项目时自动填充 `ProjectRecord.stack`; //! 未来 df-ai/df-workflow 亦可复用以感知「项目是什么技术栈」。 use std::path::Path; use anyhow::{Context, Result}; /// 规范化路径用于比较:canonicalize 解析绝对规范路径(失败降级), /// 统一正斜杠 + 小写。防 `C:\a\b` vs `C:/a/b/` 绕过重复检查。 /// 注:仅用于比较,存库保留用户输入的原始可读路径。 pub fn normalize_path(p: &str) -> String { match Path::new(p).canonicalize() { Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(), Err(_) => p .trim_end_matches(['\\', '/']) .replace('\\', "/") .to_lowercase(), } } /// 探测目录的技术栈 /// /// 返回去重后的技术栈标签数组(如 `["rust","vue","tauri","typescript"]`)。 /// 空数组表示未识别出任何已知标志(空目录或非常规项目)。非目录返回 Err。 pub fn detect_stack(root: &Path) -> Result> { if !root.is_dir() { anyhow::bail!("路径不是目录: {}", root.display()); } let mut stack: Vec = Vec::new(); // ── 后端/系统语言 ── if root.join("Cargo.toml").exists() { push_unique(&mut stack, "rust"); } if root.join("go.mod").exists() { push_unique(&mut stack, "go"); } if root.join("pom.xml").exists() || root.join("build.gradle").exists() || root.join("build.gradle.kts").exists() { push_unique(&mut stack, "java"); } if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() { push_unique(&mut stack, "python"); } // C#: 根目录存在 .csproj 文件(仅一层) if has_file_with_ext(root, "csproj") { push_unique(&mut stack, "csharp"); } // ── Tauri 桌面应用(通常含 src-tauri 目录) ── if root.join("src-tauri").is_dir() { push_unique(&mut stack, "tauri"); } // ── 前端/Node:解析 package.json 的依赖推断框架 ── if root.join("package.json").exists() { if let Ok(deps) = read_package_deps(root.join("package.json")) { if deps.iter().any(|d| d == "vue") { push_unique(&mut stack, "vue"); } if deps.iter().any(|d| d == "react" || d == "react-dom") { push_unique(&mut stack, "react"); } if deps.iter().any(|d| d == "@angular/core") { push_unique(&mut stack, "angular"); } if deps.iter().any(|d| d == "svelte") { push_unique(&mut stack, "svelte"); } if deps.iter().any(|d| d == "next") { push_unique(&mut stack, "next"); } if deps.iter().any(|d| d == "vite") { push_unique(&mut stack, "vite"); } if deps.iter().any(|d| d == "typescript") { push_unique(&mut stack, "typescript"); } if deps.iter().any(|d| d == "express" || d == "koa" || d == "fastify") { push_unique(&mut stack, "node"); } } } Ok(stack) } /// 解析 package.json,合并 dependencies + devDependencies 的包名 fn read_package_deps(path: impl AsRef) -> Result> { let content = std::fs::read_to_string(path.as_ref()) .with_context(|| format!("读取 package.json 失败: {}", path.as_ref().display()))?; let pkg: serde_json::Value = serde_json::from_str(&content).context("解析 package.json 失败")?; let mut names = Vec::new(); for key in &["dependencies", "devDependencies"] { if let Some(obj) = pkg.get(key).and_then(|v| v.as_object()) { for k in obj.keys() { names.push(k.clone()); } } } Ok(names) } /// 目录下是否存在指定扩展名的文件(仅一层) fn has_file_with_ext(dir: &Path, ext: &str) -> bool { let Ok(entries) = std::fs::read_dir(dir) else { return false; }; entries.flatten().any(|e| { e.path() .extension() .and_then(|x| x.to_str()) .map(|x| x == ext) .unwrap_or(false) }) } // ============================================================ // 项目采样(供 LLM 分析基础信息) — 纯 IO,控 token 不读源码 // ============================================================ /// 项目采样结果 — README 首段 + 目录树(2层) + 清单文件片段 #[derive(Debug, Clone)] pub struct ProjectSample { pub readme: Option, pub tree: Vec, /// (文件名, 截断内容) pub manifests: Vec<(String, String)>, } const SAMPLE_README_MAX: usize = 2000; const SAMPLE_MANIFEST_MAX: usize = 1500; const SAMPLE_TREE_MAX: usize = 80; /// 目录树过滤的噪音目录(依赖产物/构建/缓存/IDE) const SAMPLE_IGNORED_DIRS: &[&str] = &[ "node_modules", "target", ".git", "dist", "build", ".next", "venv", ".venv", "__pycache__", ".idea", ".vscode", ".cache", "out", "coverage", ".svelte-kit", ".turbo", ".angular", ".gradle", "vendor", ]; /// 采集项目采样(README + 目录树 + 清单),供 LLM 分析填基础信息 pub fn collect_sample(root: &Path) -> Result { if !root.is_dir() { anyhow::bail!("路径不是目录: {}", root.display()); } Ok(ProjectSample { readme: read_readme(root), tree: collect_tree(root), manifests: collect_manifests(root), }) } fn read_readme(root: &Path) -> Option { for name in &["README.md", "README.MD", "README", "README.zh.md", "README_zh.md", "README_EN.md", "readme.md"] { let p = root.join(name); if p.is_file() { if let Ok(content) = std::fs::read_to_string(&p) { return Some(truncate_chars(&content, SAMPLE_README_MAX)); } } } None } /// 目录树(根 + 一层子目录),过滤噪音目录,控条目数 fn collect_tree(root: &Path) -> Vec { let mut lines = Vec::new(); let mut count = 0usize; collect_tree_level(root, "", &mut lines, &mut count, false); lines } fn collect_tree_level(dir: &Path, prefix: &str, lines: &mut Vec, count: &mut usize, is_sub: bool) { if *count >= SAMPLE_TREE_MAX { return; } let Ok(entries) = std::fs::read_dir(dir) else { return }; let mut items: Vec<_> = entries.flatten().collect(); items.sort_by_key(|e| e.file_name()); for e in items { if *count >= SAMPLE_TREE_MAX { return; } let name = e.file_name().to_string_lossy().to_string(); let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false); if is_dir { if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) { continue; } lines.push(format!("{}{}/", prefix, name)); *count += 1; // 仅根目录的子目录展开一层(is_sub=true 不再递归) if !is_sub { collect_tree_level(&e.path(), &format!("{} ", prefix), lines, count, true); } } else { // 跳过隐藏文件(保留 .gitignore 作 git 标识) if name.starts_with('.') && name != ".gitignore" { continue; } lines.push(format!("{}{}", prefix, name)); *count += 1; } } } fn collect_manifests(root: &Path) -> Vec<(String, String)> { let mut out = Vec::new(); for name in &["package.json", "Cargo.toml", "go.mod", "pyproject.toml", "pom.xml", "build.gradle", "build.gradle.kts"] { let p = root.join(name); if let Ok(content) = std::fs::read_to_string(&p) { out.push(((*name).to_string(), truncate_chars(&content, SAMPLE_MANIFEST_MAX))); } } out } /// 按字符数截断(避免截断 UTF-8 多字节边界) fn truncate_chars(s: &str, max: usize) -> String { if s.chars().count() <= max { return s.to_string(); } let truncated: String = s.chars().take(max).collect(); format!("{}…(已截断)", truncated) } /// 去重 push fn push_unique(stack: &mut Vec, s: &str) { if !stack.iter().any(|x| x == s) { stack.push(s.to_string()); } } #[cfg(test)] mod tests { use super::*; use std::fs; use std::path::PathBuf; /// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径 fn scratch(name: &str) -> PathBuf { let mut p = std::env::temp_dir(); p.push(format!("df-project-scan-{}-{}", name, std::process::id())); let _ = fs::remove_dir_all(&p); fs::create_dir_all(&p).unwrap(); p } #[test] fn non_dir_errors() { let r = detect_stack(Path::new("definitely-not-exist-xyz-123")); assert!(r.is_err()); } #[test] fn detects_rust() { let d = scratch("rust"); fs::write(d.join("Cargo.toml"), "").unwrap(); let s = detect_stack(&d).unwrap(); assert!(s.contains(&"rust".to_string())); fs::remove_dir_all(&d).ok(); } #[test] fn detects_full_stack() { // 模拟 DevFlow 自身:rust + tauri + vue + vite + typescript let d = scratch("full"); fs::write(d.join("Cargo.toml"), "").unwrap(); fs::create_dir(d.join("src-tauri")).unwrap(); fs::write( d.join("package.json"), r#"{"dependencies":{"vue":"^3.5.0"},"devDependencies":{"vite":"^6.0.0","typescript":"~5.6.0"}}"#, ) .unwrap(); let s = detect_stack(&d).unwrap(); assert!(s.contains(&"rust".to_string())); assert!(s.contains(&"tauri".to_string())); assert!(s.contains(&"vue".to_string())); assert!(s.contains(&"vite".to_string())); assert!(s.contains(&"typescript".to_string())); fs::remove_dir_all(&d).ok(); } #[test] fn empty_dir_returns_empty() { let d = scratch("empty"); let s = detect_stack(&d).unwrap(); assert!(s.is_empty()); fs::remove_dir_all(&d).ok(); } #[test] fn collects_sample() { let d = scratch("sample"); fs::write(d.join("README.md"), "# Test\nA test project.\nMore.").unwrap(); fs::write(d.join("package.json"), r#"{"name":"x","dependencies":{"vue":"3"}}"#).unwrap(); fs::create_dir(d.join("src")).unwrap(); fs::write(d.join("src/main.ts"), "x").unwrap(); fs::create_dir(d.join("node_modules")).unwrap(); fs::write(d.join("node_modules/junk.json"), "x").unwrap(); let s = collect_sample(&d).unwrap(); assert!(s.readme.as_deref().unwrap_or("").contains("test project")); assert!(s.manifests.iter().any(|(n, _)| n == "package.json")); assert!(s.tree.iter().any(|t| t.contains("src"))); // node_modules 应被过滤 assert!(s.tree.iter().all(|t| !t.contains("node_modules"))); fs::remove_dir_all(&d).ok(); } }