diff --git a/crates/df-project/src/scan.rs b/crates/df-project/src/scan.rs deleted file mode 100644 index 0a9417e..0000000 --- a/crates/df-project/src/scan.rs +++ /dev/null @@ -1,741 +0,0 @@ -//! 项目技术栈探测 — 浅读根目录标志文件识别技术栈 -//! -//! 纯函数、零状态、零外部依赖(仅 std + serde_json + anyhow)。 -//! 只读根目录标志文件,不递归遍历(控制性能与安全)。 -//! -//! 供 commands 层薄封装暴露为 IPC 命令,新建/导入项目时自动填充 `ProjectRecord.stack`; -//! 未来 df-ai/df-workflow 亦可复用以感知「项目是什么技术栈」。 -//! -//! README 噪音处理与图片收集已抽离至 `scan_helpers`(2026-06-19 自底向上拆分), -//! 此处通过 `pub use` 重导出 `ImageRef` 等保持外部路径 `df_project::scan::*` 不变。 - -use std::path::Path; - -use anyhow::{Context, Result}; - -#[path = "scan_helpers.rs"] -mod scan_helpers; -pub use scan_helpers::ImageRef; -use scan_helpers::{collect_images, strip_readme_noise}; - -/// 规范化路径用于比较: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) -} - -// ============================================================ -// 历史项目发现 — monorepo 识别 + 子项目展开(纯规则,不跑 LLM) -// ============================================================ - -/// monorepo 工作区配置文件名(JS 生态主流:pnpm/lerna/turbo/nx) -const MONOREPO_MARKERS: &[&str] = &[ - "pnpm-workspace.yaml", - "lerna.json", - "turbo.json", - "nx.json", -]; - -/// 判定目录是否为 monorepo 根(JS 生态主流工作区管理器)。 -/// -/// 命中任一即视为 monorepo: -/// - pnpm-workspace.yaml / lerna.json / turbo.json / nx.json 存在 -/// - package.json 含 `workspaces` 字段(npm/yarn workspaces) -pub fn is_monorepo(root: &Path) -> bool { - if !root.is_dir() { - return false; - } - for marker in MONOREPO_MARKERS { - if root.join(marker).is_file() { - return true; - } - } - // package.json workspaces 字段(npm/yarn)。数组(`["packages/*"]`)或对象 - // (`{"packages":[...]}`,Yarn)均为真正的工作区;JSON 显式 null 表示「无」, - // 不应误判为 monorepo(`.is_some()` 对 key 存在但值为 null 仍返回 true → 误报)。 - let pkg_path = root.join("package.json"); - if pkg_path.is_file() { - if let Ok(content) = std::fs::read_to_string(&pkg_path) { - if let Ok(pkg) = serde_json::from_str::(&content) { - if pkg.get("workspaces").is_some_and(|v| !v.is_null()) { - return true; - } - } - } - } - false -} - -/// 单个发现的候选项目(monorepo 子项目或独立项目) -#[derive(Debug, Clone)] -pub struct DiscoveredProject { - /// 项目根目录绝对路径 - pub path: String, - /// 推断的项目名(目录名) - pub name: String, - /// 规则探测的技术栈(空=未识别) - pub stack: Vec, - /// 是否为 monorepo 根(便于前端标记) - pub is_monorepo: bool, -} - -/// 在指定根目录下发现候选项目。 -/// -/// 策略(只展开一层,不做深递归): -/// 1. 根目录本身有项目标志(Cargo.toml/package.json/go.mod 等)→ 根为独立项目 -/// 2. 根目录是 monorepo → 展开 packages/\*/apps/\* 直接子目录(各子目录跑 detect_stack 过滤空) -/// 3. 否则:扫根的直接子目录,凡 detect_stack 非空的视为候选项目 -/// -/// 不跑 LLM(快),不读源码。空 stack 的目录在 monorepo 展开/子目录扫描时被过滤。 -pub fn discover_projects(root: &Path) -> Result> { - if !root.is_dir() { - anyhow::bail!("路径不是目录: {}", root.display()); - } - - let mut out: Vec = Vec::new(); - let mono = is_monorepo(root); - - // 1. 根目录自身是项目(有 manifest 标志) - if has_project_manifest(root) { - let stack = detect_stack(root).unwrap_or_default(); - out.push(DiscoveredProject { - path: root.to_string_lossy().to_string(), - name: root - .file_name() - .and_then(|n| n.to_str()) - .map(|s| s.to_string()) - .unwrap_or_else(|| root.to_string_lossy().to_string()), - stack, - is_monorepo: mono, - }); - } - - // 2. monorepo → 展开 packages/* apps/* 直接子目录 - // 3. 普通目录 → 扫直接子目录,凡 detect_stack 非空的入选 - let scan_globs: &[&str] = if mono { - &["packages", "apps"] - } else { - &[""] - }; - - for glob in scan_globs { - let target = if glob.is_empty() { - root.to_path_buf() - } else { - root.join(glob) - }; - if !target.is_dir() { - continue; - } - let Ok(entries) = std::fs::read_dir(&target) else { - continue; - }; - for e in entries.flatten() { - let p = e.path(); - if !p.is_dir() { - continue; - } - let name = e.file_name().to_string_lossy().to_string(); - if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) || name.starts_with('.') { - continue; - } - // 必须有项目标志 + detect_stack 非空 - if !has_project_manifest(&p) { - continue; - } - let stack = match detect_stack(&p) { - Ok(s) => s, - Err(_) => continue, - }; - if stack.is_empty() { - continue; - } - out.push(DiscoveredProject { - path: p.to_string_lossy().to_string(), - name, - stack, - is_monorepo: false, - }); - } - } - - Ok(out) -} - -/// 目录是否含任一项目清单标志文件 -fn has_project_manifest(dir: &Path) -> bool { - const MARKS: &[&str] = &[ - "Cargo.toml", - "package.json", - "go.mod", - "pyproject.toml", - "requirements.txt", - "pom.xml", - "build.gradle", - "build.gradle.kts", - ]; - MARKS.iter().any(|m| dir.join(m).is_file()) || has_file_with_ext(dir, "csproj") -} - -/// 解析 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 首段(首个非标题非空段落,截断 200 字)。 -/// -/// 用于「导入历史项目」时自动填充 description。无 README 或解析失败返回 None。 -/// 首段定义:跳过开头标题行(# / ## …)、空行、HTML 注释与 badge 图片/HTML 行等噪声, -/// 取首个含实质文本的段落(连续多行直到空行);按字符截断至 200 字避免超长。 -pub fn extract_description(root: &Path) -> Option { - // 复用 read_readme_raw 的查找逻辑(支持 README.md / README.zh.md 等变体) - let content = read_readme_raw(root)?; - let mut text = String::new(); - let mut started = false; - for raw_line in content.lines() { - let line = raw_line.trim(); - if line.is_empty() { - if started { - break; // 段落结束 - } - continue; // 首段尚未开始,跳过开头空行 - } - // 段落开始后不再跳行,直接累加 - if !started { - // 跳过标题 / HTML 注释 / badge 图片 / HTML 标签等噪声前导行 - if line.starts_with('#') - || line.starts_with("\n\n[Install](#install)\n\n- [Usage](#usage)\n\n![build](https://img.shields.io/badge/build-passing)\n\nThis is the real intro.\n"; - fs::write(d.join("README.md"), readme).unwrap(); - let s = collect_sample(&d).unwrap(); - let r = s.readme.as_deref().unwrap_or(""); - assert!(r.contains("# Foo"), "标题应保留: {r}"); - assert!(r.contains("real intro"), "正文应保留: {r}"); - assert!(!r.contains("hidden comment"), "HTML 注释未剥: {r}"); - assert!(!r.contains("shields.io"), "徽章未剥: {r}"); - assert!(!r.contains("[Install](#install)"), "TOC 锚点未剥: {r}"); - assert!(!r.contains("[Usage](#usage)"), "TOC 列表项未剥: {r}"); - assert!(!r.contains("title: Foo"), "frontmatter 未剥: {r}"); - fs::remove_dir_all(&d).ok(); - } - - #[test] - fn detects_monorepo_pnpm() { - let d = scratch("mono-pnpm"); - fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap(); - assert!(is_monorepo(&d)); - fs::remove_dir_all(&d).ok(); - } - - #[test] - fn detects_monorepo_npm_workspaces() { - let d = scratch("mono-npm"); - fs::write( - d.join("package.json"), - r#"{"name":"root","workspaces":["packages/*"]}"#, - ) - .unwrap(); - assert!(is_monorepo(&d)); - fs::remove_dir_all(&d).ok(); - } - - #[test] - fn detects_non_monorepo() { - let d = scratch("nonmono"); - fs::write(d.join("package.json"), r#"{"name":"x"}"#).unwrap(); - assert!(!is_monorepo(&d)); - fs::remove_dir_all(&d).ok(); - } - - #[test] - fn discover_monorepo_children() { - let d = scratch("discover-mono"); - fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap(); - // 子项目:packages/web(有 package.json + vue)、packages/cli(有 Cargo.toml) - fs::create_dir_all(d.join("packages/web")).unwrap(); - fs::write( - d.join("packages/web/package.json"), - r#"{"name":"web","dependencies":{"vue":"3"}}"#, - ) - .unwrap(); - fs::create_dir_all(d.join("packages/cli")).unwrap(); - fs::write(d.join("packages/cli/Cargo.toml"), "[package]\nname=\"cli\"\n").unwrap(); - // 空 stack 子目录应过滤 - fs::create_dir_all(d.join("packages/empty")).unwrap(); - fs::write(d.join("packages/empty/x.txt"), "x").unwrap(); - let found = discover_projects(&d).unwrap(); - // 根自身无 manifest 不入选;packages/web + packages/cli 入选;empty 过滤 - let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect(); - assert!(names.contains(&"web"), "names={names:?}"); - assert!(names.contains(&"cli"), "names={names:?}"); - assert!(!names.contains(&"empty"), "空 stack 未过滤: {names:?}"); - fs::remove_dir_all(&d).ok(); - } - - #[test] - fn discover_flat_children() { - // 非 monorepo:扫根直接子目录中 detect_stack 非空的 - let d = scratch("discover-flat"); - fs::create_dir_all(d.join("proj-a")).unwrap(); - fs::write(d.join("proj-a/Cargo.toml"), "").unwrap(); - fs::create_dir_all(d.join("not-a-project")).unwrap(); - fs::write(d.join("not-a-project/readme.txt"), "x").unwrap(); - let found = discover_projects(&d).unwrap(); - let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect(); - assert!(names.contains(&"proj-a"), "names={names:?}"); - assert!(!names.contains(&"not-a-project"), "空 stack 未过滤: {names:?}"); - fs::remove_dir_all(&d).ok(); - } - - #[test] - fn extract_desc_skips_title_badge() { - let d = scratch("desc"); - // 标题 + badge 图片行应跳过,首段为正文 - fs::write( - d.join("README.md"), - "# My Project\n\n![badge](https://x.io/badge.svg)\n\n这是一个示例项目,用于演示。\n第二行正文。\n\n## 安装", - ) - .unwrap(); - let desc = extract_description(&d).unwrap(); - assert!(desc.contains("示例项目")); - assert!(desc.contains("第二行正文")); - assert!(!desc.contains("My Project")); - assert!(!desc.contains("badge")); - assert!(!desc.contains("安装")); - fs::remove_dir_all(&d).ok(); - } - - #[test] - fn extract_desc_no_readme_returns_none() { - let d = scratch("nodesc"); - assert!(extract_description(&d).is_none()); - fs::remove_dir_all(&d).ok(); - } - - #[test] - fn extract_desc_truncates_long() { - let d = scratch("longdesc"); - let long = "啊".repeat(500); - fs::write(d.join("README.md"), format!("# T\n\n{long}")).unwrap(); - let desc = extract_description(&d).unwrap(); - // 截断到 200 字 + 末尾「…(已截断)」标记 - assert!(desc.chars().count() <= 210); - assert!(desc.contains("已截断")); - fs::remove_dir_all(&d).ok(); - } - - // ── normalize_path 跨平台归一化 ── - // 锁定:① 反斜杠→正斜杠 ② 小写 ③ 尾部分隔符裁剪 ④ 相同逻辑路径相等(防重复录入绕过)。 - // 注:canonicalize 成功路径在不同 OS 返回不同形态(Windows 带前缀),故只断言跨平台不变性, - // 不断言精确串,避免与平台耦合。 - #[test] - fn normalize_path_uses_forward_slash_and_lowercase() { - // 不存在的路径走降级分支(trim + replace + lowercase) - let n = normalize_path(r"C:\Foo\Bar\"); - assert!( - !n.contains('\\'), - "反斜杠未归一: {n}" - ); - assert_eq!(n, n.to_lowercase(), "未小写: {n}"); - assert!( - !n.ends_with('/') && !n.ends_with('\\'), - "尾部分隔符未裁剪: {n}" - ); - } - - #[test] - fn normalize_path_equivalent_inputs_equal() { - // 两种写法应归一为同一串(去重场景) - let a = normalize_path(r"C:\Foo\Bar"); - let b = normalize_path("C:/Foo/Bar/"); - assert_eq!(a, b, "等价路径归一不等: {a} vs {b}"); - } - - #[test] - fn normalize_path_relative_is_lowercase_and_forward_slash() { - let n = normalize_path(r"..\Some\Path"); - assert!(!n.contains('\\'), "反斜杠未归一: {n}"); - assert_eq!(n, n.to_lowercase(), "未小写: {n}"); - } - - // ── is_monorepo workspaces:null 防回归(wd2fnjh3s) ── - // .is_some_and(!is_null) 修复点:key 存在但值为 JSON null 时不得判为 monorepo。 - #[test] - fn is_monorepo_workspaces_null_not_misclassified() { - let d = scratch("ws-null"); - fs::write( - d.join("package.json"), - r#"{"name":"x","workspaces":null}"#, - ) - .unwrap(); - assert!( - !is_monorepo(&d), - "workspaces: null 不应判为 monorepo(回归 wd2fnjh3s)" - ); - fs::remove_dir_all(&d).ok(); - } - - #[test] - fn is_monorepo_workspaces_object_treated_as_real() { - // Yarn 形式 {"packages":[...]} —— 非 null,应判为 monorepo - let d = scratch("ws-obj"); - fs::write( - d.join("package.json"), - r#"{"name":"y","workspaces":{"packages":["packages/*"]}}"#, - ) - .unwrap(); - assert!(is_monorepo(&d), "Yarn workspaces 对象形式应判为 monorepo"); - fs::remove_dir_all(&d).ok(); - } - - #[test] - fn is_monorepo_non_dir_returns_false() { - assert!(!is_monorepo(Path::new("definitely-not-exist-xyz-456"))); - } -} diff --git a/crates/df-project/src/scan/discover.rs b/crates/df-project/src/scan/discover.rs new file mode 100644 index 0000000..2a566b2 --- /dev/null +++ b/crates/df-project/src/scan/discover.rs @@ -0,0 +1,276 @@ +//! 历史项目发现 — monorepo 识别 + 子项目展开(纯规则,不跑 LLM)。 + +use std::path::Path; + +use anyhow::Result; + +use super::stack::{detect_stack, has_file_with_ext}; +use super::SAMPLE_IGNORED_DIRS; + +/// monorepo 工作区配置文件名(JS 生态主流:pnpm/lerna/turbo/nx) +const MONOREPO_MARKERS: &[&str] = &[ + "pnpm-workspace.yaml", + "lerna.json", + "turbo.json", + "nx.json", +]; + +/// 判定目录是否为 monorepo 根(JS 生态主流工作区管理器)。 +/// +/// 命中任一即视为 monorepo: +/// - pnpm-workspace.yaml / lerna.json / turbo.json / nx.json 存在 +/// - package.json 含 `workspaces` 字段(npm/yarn workspaces) +pub fn is_monorepo(root: &Path) -> bool { + if !root.is_dir() { + return false; + } + for marker in MONOREPO_MARKERS { + if root.join(marker).is_file() { + return true; + } + } + // package.json workspaces 字段(npm/yarn)。数组(`["packages/*"]`)或对象 + // (`{"packages":[...]}`,Yarn)均为真正的工作区;JSON 显式 null 表示「无」, + // 不应误判为 monorepo(`.is_some()` 对 key 存在但值为 null 仍返回 true → 误报)。 + let pkg_path = root.join("package.json"); + if pkg_path.is_file() { + if let Ok(content) = std::fs::read_to_string(&pkg_path) { + if let Ok(pkg) = serde_json::from_str::(&content) { + if pkg.get("workspaces").is_some_and(|v| !v.is_null()) { + return true; + } + } + } + } + false +} + +/// 单个发现的候选项目(monorepo 子项目或独立项目) +#[derive(Debug, Clone)] +pub struct DiscoveredProject { + /// 项目根目录绝对路径 + pub path: String, + /// 推断的项目名(目录名) + pub name: String, + /// 规则探测的技术栈(空=未识别) + pub stack: Vec, + /// 是否为 monorepo 根(便于前端标记) + pub is_monorepo: bool, +} + +/// 在指定根目录下发现候选项目。 +/// +/// 策略(只展开一层,不做深递归): +/// 1. 根目录本身有项目标志(Cargo.toml/package.json/go.mod 等)→ 根为独立项目 +/// 2. 根目录是 monorepo → 展开 packages/\*/apps/\* 直接子目录(各子目录跑 detect_stack 过滤空) +/// 3. 否则:扫根的直接子目录,凡 detect_stack 非空的视为候选项目 +/// +/// 不跑 LLM(快),不读源码。空 stack 的目录在 monorepo 展开/子目录扫描时被过滤。 +pub fn discover_projects(root: &Path) -> Result> { + if !root.is_dir() { + anyhow::bail!("路径不是目录: {}", root.display()); + } + + let mut out: Vec = Vec::new(); + let mono = is_monorepo(root); + + // 1. 根目录自身是项目(有 manifest 标志) + if has_project_manifest(root) { + let stack = detect_stack(root).unwrap_or_default(); + out.push(DiscoveredProject { + path: root.to_string_lossy().to_string(), + name: root + .file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| root.to_string_lossy().to_string()), + stack, + is_monorepo: mono, + }); + } + + // 2. monorepo → 展开 packages/* apps/* 直接子目录 + // 3. 普通目录 → 扫直接子目录,凡 detect_stack 非空的入选 + let scan_globs: &[&str] = if mono { + &["packages", "apps"] + } else { + &[""] + }; + + for glob in scan_globs { + let target = if glob.is_empty() { + root.to_path_buf() + } else { + root.join(glob) + }; + if !target.is_dir() { + continue; + } + let Ok(entries) = std::fs::read_dir(&target) else { + continue; + }; + for e in entries.flatten() { + let p = e.path(); + if !p.is_dir() { + continue; + } + let name = e.file_name().to_string_lossy().to_string(); + if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) || name.starts_with('.') { + continue; + } + // 必须有项目标志 + detect_stack 非空 + if !has_project_manifest(&p) { + continue; + } + let stack = match detect_stack(&p) { + Ok(s) => s, + Err(_) => continue, + }; + if stack.is_empty() { + continue; + } + out.push(DiscoveredProject { + path: p.to_string_lossy().to_string(), + name, + stack, + is_monorepo: false, + }); + } + } + + Ok(out) +} + +/// 目录是否含任一项目清单标志文件 +fn has_project_manifest(dir: &Path) -> bool { + const MARKS: &[&str] = &[ + "Cargo.toml", + "package.json", + "go.mod", + "pyproject.toml", + "requirements.txt", + "pom.xml", + "build.gradle", + "build.gradle.kts", + ]; + MARKS.iter().any(|m| dir.join(m).is_file()) || has_file_with_ext(dir, "csproj") +} + +#[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 detects_monorepo_pnpm() { + let d = scratch("mono-pnpm"); + fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap(); + assert!(is_monorepo(&d)); + fs::remove_dir_all(&d).ok(); + } + + #[test] + fn detects_monorepo_npm_workspaces() { + let d = scratch("mono-npm"); + fs::write( + d.join("package.json"), + r#"{"name":"root","workspaces":["packages/*"]}"#, + ) + .unwrap(); + assert!(is_monorepo(&d)); + fs::remove_dir_all(&d).ok(); + } + + #[test] + fn detects_non_monorepo() { + let d = scratch("nonmono"); + fs::write(d.join("package.json"), r#"{"name":"x"}"#).unwrap(); + assert!(!is_monorepo(&d)); + fs::remove_dir_all(&d).ok(); + } + + #[test] + fn discover_monorepo_children() { + let d = scratch("discover-mono"); + fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap(); + // 子项目:packages/web(有 package.json + vue)、packages/cli(有 Cargo.toml) + fs::create_dir_all(d.join("packages/web")).unwrap(); + fs::write( + d.join("packages/web/package.json"), + r#"{"name":"web","dependencies":{"vue":"3"}}"#, + ) + .unwrap(); + fs::create_dir_all(d.join("packages/cli")).unwrap(); + fs::write(d.join("packages/cli/Cargo.toml"), "[package]\nname=\"cli\"\n").unwrap(); + // 空 stack 子目录应过滤 + fs::create_dir_all(d.join("packages/empty")).unwrap(); + fs::write(d.join("packages/empty/x.txt"), "x").unwrap(); + let found = discover_projects(&d).unwrap(); + // 根自身无 manifest 不入选;packages/web + packages/cli 入选;empty 过滤 + let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect(); + assert!(names.contains(&"web"), "names={names:?}"); + assert!(names.contains(&"cli"), "names={names:?}"); + assert!(!names.contains(&"empty"), "空 stack 未过滤: {names:?}"); + fs::remove_dir_all(&d).ok(); + } + + #[test] + fn discover_flat_children() { + // 非 monorepo:扫根直接子目录中 detect_stack 非空的 + let d = scratch("discover-flat"); + fs::create_dir_all(d.join("proj-a")).unwrap(); + fs::write(d.join("proj-a/Cargo.toml"), "").unwrap(); + fs::create_dir_all(d.join("not-a-project")).unwrap(); + fs::write(d.join("not-a-project/readme.txt"), "x").unwrap(); + let found = discover_projects(&d).unwrap(); + let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect(); + assert!(names.contains(&"proj-a"), "names={names:?}"); + assert!(!names.contains(&"not-a-project"), "空 stack 未过滤: {names:?}"); + fs::remove_dir_all(&d).ok(); + } + + // ── is_monorepo workspaces:null 防回归(wd2fnjh3s) ── + // .is_some_and(!is_null) 修复点:key 存在但值为 JSON null 时不得判为 monorepo。 + #[test] + fn is_monorepo_workspaces_null_not_misclassified() { + let d = scratch("ws-null"); + fs::write( + d.join("package.json"), + r#"{"name":"x","workspaces":null}"#, + ) + .unwrap(); + assert!( + !is_monorepo(&d), + "workspaces: null 不应判为 monorepo(回归 wd2fnjh3s)" + ); + fs::remove_dir_all(&d).ok(); + } + + #[test] + fn is_monorepo_workspaces_object_treated_as_real() { + // Yarn 形式 {"packages":[...]} —— 非 null,应判为 monorepo + let d = scratch("ws-obj"); + fs::write( + d.join("package.json"), + r#"{"name":"y","workspaces":{"packages":["packages/*"]}}"#, + ) + .unwrap(); + assert!(is_monorepo(&d), "Yarn workspaces 对象形式应判为 monorepo"); + fs::remove_dir_all(&d).ok(); + } + + #[test] + fn is_monorepo_non_dir_returns_false() { + assert!(!is_monorepo(Path::new("definitely-not-exist-xyz-456"))); + } +} diff --git a/crates/df-project/src/scan/mod.rs b/crates/df-project/src/scan/mod.rs new file mode 100644 index 0000000..2b365fe --- /dev/null +++ b/crates/df-project/src/scan/mod.rs @@ -0,0 +1,114 @@ +//! 项目技术栈探测 — 浅读根目录标志文件识别技术栈 +//! +//! 纯函数、零状态、零外部依赖(仅 std + serde_json + anyhow)。 +//! 只读根目录标志文件,不递归遍历(控制性能与安全)。 +//! +//! 供 commands 层薄封装暴露为 IPC 命令,新建/导入项目时自动填充 `ProjectRecord.stack`; +//! 未来 df-ai/df-workflow 亦可复用以感知「项目是什么技术栈」。 +//! +//! ## 模块拆分(2026-06-22,REFACTOR-260619-10) +//! +//! 原 `scan.rs`(741 行)按职责拆为 4 子模块,纯文件重组、零行为变化: +//! - [`stack`]:技术栈探测(`detect_stack` + package.json 解析) +//! - [`discover`]:历史项目发现/monorepo 展开(`discover_projects`/`is_monorepo`) +//! - [`readme`]:README 读取与首段抽取(`read_readme_raw`/`extract_description`) +//! - [`sample`]:项目采样(`collect_sample` + 目录树/清单/噪音目录) +//! +//! 跨模块共享的常量/helper 留在本 `mod.rs`(`SAMPLE_IGNORED_DIRS`/`truncate_chars`), +//! 子模块经 `use super::*` 引用。`pub use` 重导出保持外部 `df_project::scan::*` 路径不变。 +//! +//! README 噪音处理与图片收集已抽离至 `scan_helpers`(2026-06-19 自底向上拆分), +//! 此处通过 `pub use` 重导出 `ImageRef` 等保持外部路径 `df_project::scan::*` 不变。 + +use std::path::Path; + +#[path = "../scan_helpers.rs"] +mod scan_helpers; +pub use scan_helpers::ImageRef; + +pub mod discover; +pub mod readme; +pub mod sample; +pub mod stack; + +// ── 对外稳定 API 重导出(外部 `df_project::scan::*` 路径不变)── +pub use discover::{discover_projects, is_monorepo, DiscoveredProject}; +pub use readme::extract_description; +pub use sample::{collect_sample, ProjectSample}; +pub use stack::detect_stack; + +// ============================================================ +// 跨模块共享常量/helper +// ============================================================ + +/// 目录树/项目发现过滤的噪音目录(依赖产物/构建/缓存/IDE)。 +/// +/// 同时被 [`discover`] 与 [`sample`] 引用,故置于本 `mod.rs`,`pub(super)` 暴露给子模块。 +pub(super) 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", +]; + +/// 按字符数截断(避免截断 UTF-8 多字节边界)。 +/// +/// 被 [`readme`](crate::scan::readme) 与 [`sample`](crate::scan::sample) 引用,故置于本 `mod.rs`。 +pub(super) 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) +} + +/// 规范化路径用于比较: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(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── normalize_path 跨平台归一化 ── + // 锁定:① 反斜杠→正斜杠 ② 小写 ③ 尾部分隔符裁剪 ④ 相同逻辑路径相等(防重复录入绕过)。 + // 注:canonicalize 成功路径在不同 OS 返回不同形态(Windows 带前缀),故只断言跨平台不变性, + // 不断言精确串,避免与平台耦合。 + #[test] + fn normalize_path_uses_forward_slash_and_lowercase() { + // 不存在的路径走降级分支(trim + replace + lowercase) + let n = normalize_path(r"C:\Foo\Bar\"); + assert!( + !n.contains('\\'), + "反斜杠未归一: {n}" + ); + assert_eq!(n, n.to_lowercase(), "未小写: {n}"); + assert!( + !n.ends_with('/') && !n.ends_with('\\'), + "尾部分隔符未裁剪: {n}" + ); + } + + #[test] + fn normalize_path_equivalent_inputs_equal() { + // 两种写法应归一为同一串(去重场景) + let a = normalize_path(r"C:\Foo\Bar"); + let b = normalize_path("C:/Foo/Bar/"); + assert_eq!(a, b, "等价路径归一不等: {a} vs {b}"); + } + + #[test] + fn normalize_path_relative_is_lowercase_and_forward_slash() { + let n = normalize_path(r"..\Some\Path"); + assert!(!n.contains('\\'), "反斜杠未归一: {n}"); + assert_eq!(n, n.to_lowercase(), "未小写: {n}"); + } +} diff --git a/crates/df-project/src/scan/readme.rs b/crates/df-project/src/scan/readme.rs new file mode 100644 index 0000000..fd5ae3d --- /dev/null +++ b/crates/df-project/src/scan/readme.rs @@ -0,0 +1,122 @@ +//! README 读取与首段抽取 — 纯 IO + 文本规则。 +//! +//! `read_readme_raw` 被本模块(`extract_description`)与 [`super::sample`](`crate::scan::sample`) +//! (`collect_sample`)共用,故 `pub(super)` 暴露。 + +use std::path::Path; + +use super::truncate_chars; + +/// extract_description 最大字符数 +const EXTRACT_DESC_MAX: usize = 200; + +/// 读 README 原始内容(不做处理)。 +/// +/// 支持多种 README 变体(`README.md` / `README.zh.md` / `README_zh.md` / `README_EN.md` 等), +/// 返回首个存在的文件内容。 +pub(super) fn read_readme_raw(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(content); + } + } + } + None +} + +/// 提取项目描述 — 读 README 首段(首个非标题非空段落,截断 200 字)。 +/// +/// 用于「导入历史项目」时自动填充 description。无 README 或解析失败返回 None。 +/// 首段定义:跳过开头标题行(# / ## …)、空行、HTML 注释与 badge 图片/HTML 行等噪声, +/// 取首个含实质文本的段落(连续多行直到空行);按字符截断至 200 字避免超长。 +pub fn extract_description(root: &Path) -> Option { + // 复用 read_readme_raw 的查找逻辑(支持 README.md / README.zh.md 等变体) + let content = read_readme_raw(root)?; + let mut text = String::new(); + let mut started = false; + for raw_line in content.lines() { + let line = raw_line.trim(); + if line.is_empty() { + if started { + break; // 段落结束 + } + continue; // 首段尚未开始,跳过开头空行 + } + // 段落开始后不再跳行,直接累加 + if !started { + // 跳过标题 / HTML 注释 / badge 图片 / HTML 标签等噪声前导行 + if line.starts_with('#') + || line.starts_with("\n\n[Install](#install)\n\n- [Usage](#usage)\n\n![build](https://img.shields.io/badge/build-passing)\n\nThis is the real intro.\n"; + fs::write(d.join("README.md"), readme).unwrap(); + let s = collect_sample(&d).unwrap(); + let r = s.readme.as_deref().unwrap_or(""); + assert!(r.contains("# Foo"), "标题应保留: {r}"); + assert!(r.contains("real intro"), "正文应保留: {r}"); + assert!(!r.contains("hidden comment"), "HTML 注释未剥: {r}"); + assert!(!r.contains("shields.io"), "徽章未剥: {r}"); + assert!(!r.contains("[Install](#install)"), "TOC 锚点未剥: {r}"); + assert!(!r.contains("[Usage](#usage)"), "TOC 列表项未剥: {r}"); + assert!(!r.contains("title: Foo"), "frontmatter 未剥: {r}"); + fs::remove_dir_all(&d).ok(); + } +} diff --git a/crates/df-project/src/scan/stack.rs b/crates/df-project/src/scan/stack.rs new file mode 100644 index 0000000..d20e79d --- /dev/null +++ b/crates/df-project/src/scan/stack.rs @@ -0,0 +1,176 @@ +//! 技术栈探测 — 浅读根目录标志文件识别技术栈。 +//! +//! 纯函数、零状态。读 `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(); + } +}