//! 项目技术栈探测 — 浅读根目录标志文件识别技术栈 //! //! 纯函数、零状态、零外部依赖(仅 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/` 绕过重复检查。 /// 注:仅用于比较,存库请用 [`canonicalize_for_store`](保留大小写)。 pub fn normalize_for_compare(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(), } } /// 规范化路径用于存库:canonicalize 解析绝对规范路径(失败降级为 trim + 正斜杠), /// **保留大小写**(大小写敏感系统如 Linux/容器文件系统路径解析需原样大小写, /// 小写化会导致路径解析失败)。比较/查重请用 [`normalize_for_compare`]。 pub fn canonicalize_for_store(p: &str) -> String { match Path::new(p).canonicalize() { Ok(abs) => abs.to_string_lossy().replace('\\', "/"), Err(_) => p.trim_end_matches(['\\', '/']).replace('\\', "/"), } } /// 兼容别名:小写比较版(等价 [`normalize_for_compare`])。 /// 旧调用方沿用;新代码按用途选 `normalize_for_compare`(比较)/ `canonicalize_for_store`(存库)。 pub fn normalize_path(p: &str) -> String { normalize_for_compare(p) } #[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}"); } #[test] fn canonicalize_for_store_preserves_case() { // 存库版保留大小写(降级分支:不存在的路径走 trim + replace,大小写原样保留) let n = canonicalize_for_store(r"C:\Foo\Bar\"); assert!(!n.contains('\\'), "反斜杠未归一: {n}"); assert!( !n.ends_with('/') && !n.ends_with('\\'), "尾部分隔符未裁剪: {n}" ); assert!( n.contains("Foo") && n.contains("Bar"), "存库版不应小写化(大小写敏感系统路径解析依赖): {n}" ); } #[test] fn compare_lowercases_but_store_keeps_case() { // compare 版与 store 版语义分离:比较小写、存库保留大小写 let p = r"C:\Foo\Bar"; let comp = normalize_for_compare(p); let store = canonicalize_for_store(p); assert_eq!(comp, comp.to_lowercase(), "比较版应小写: {comp}"); assert_ne!(comp, store, "小写化仅限比较版,存库版应保留大小写: {store}"); assert!(!store.contains('\\'), "存库版应正斜杠: {store}"); } }