157 lines
6.5 KiB
Rust
157 lines
6.5 KiB
Rust
//! 项目技术栈探测 — 浅读根目录标志文件识别技术栈
|
|
//!
|
|
//! 纯函数、零状态、零外部依赖(仅 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}");
|
|
}
|
|
}
|