//! 技术栈探测 — 浅读根目录标志文件识别技术栈。 //! //! 纯函数、零状态。读 `Cargo.toml`/`go.mod`/`package.json` 等标志, //! 不递归遍历(控制性能与安全)。 use std::path::Path; use anyhow::{Context, Result}; /// 探测目录的技术栈 /// /// 返回去重后的技术栈标签数组(如 `["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) } /// 目录下是否存在指定扩展名的文件(仅一层)。 /// /// 被 `detect_stack`(C# csproj)与 `super::discover`(项目清单判定)共用, /// 故 `pub(super)`。 pub(super) 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) }) } /// 去重 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(); } }