Files
DevFlow/src-tauri/src/commands/module.rs
T

1457 lines
56 KiB
Rust

//! 工程系统命令(V34,project_modules 表,项目多工程,每个工程独立代码仓库)
//!
//! 对标 [`services`](super::services) 的 IPC 模式:CRUD 返回完整记录,前端拿回 id 直接用。
//!
//! 关键设计(对标设计 §五工程系统 + V34 迁移注释):
//! - **工程元数据 CRUD**:路径/Git 地址/技术栈存表,create/update 返回完整记录。
//! - **Git 状态实时派生**:`get_module_git_status` 在工程目录跑 git 命令(分支/改动/最近提交),
//! 10s 超时,无 .git 返回空状态。**前端 IPC 用 std::process::Command**(非 df-execute,
//! 因为这是用户读操作,不是 AI 工具调用,不走审批/沙箱)。
//! - **文件浏览**(Batch 10):`get_module_file_tree` 列目录并合并 git status --porcelain
//! 到每个条目的 git_status 字段;`read_module_file` 读单文件(1MB 上限,二进制检测)。
//! 同样走 std::process::Command 读 git,目录扫描用 std::fs(read_dir)。
//! - **路径校验**:trim + 非空校验在前置 IPC 层(给清晰错误)。
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use tauri::State;
use df_storage::models::{ModuleDependencyRecord, ProjectModuleRecord};
use df_types::types::new_id;
// 工程扫描技术栈探测(scan_project_modules 自动填 stack 字段)
use df_project::scan::detect_stack;
use crate::state::AppState;
use super::{err_str, now_millis};
// ============================================================
// 入参 / 出参结构
// ============================================================
/// 新增工程入参(对标设计 §五 add_project_module)。
#[derive(Debug, Deserialize)]
pub struct AddProjectModuleInput {
pub project_id: String,
pub name: String,
/// 工程目录绝对路径(本机)
pub path: String,
/// 远程仓库地址,可选(单仓库本地工程可为空)
#[serde(default)]
pub git_url: Option<String>,
/// 技术栈 JSON 字符串(前端检测后传入),可选
#[serde(default)]
pub stack: Option<String>,
/// 工程职责描述(如"前端 web 工程"),可选。V40 加。
#[serde(default)]
pub description: Option<String>,
/// 工程状态 active/archived,可选(默认 active)。V40 加。
#[serde(default)]
pub status: Option<String>,
}
/// 更新工程入参(部分更新语义,仅传入字段被覆盖;对标设计 §五 update_project_module)。
#[derive(Debug, Deserialize)]
pub struct UpdateProjectModuleInput {
pub id: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub git_url: Option<String>,
#[serde(default)]
pub stack: Option<String>,
/// 工程职责描述,V40 加。空串归一为 None(trim_opt)。
#[serde(default)]
pub description: Option<String>,
/// 工程状态 active/archived,V40 加。
#[serde(default)]
pub status: Option<String>,
}
// ============================================================
// 辅助:Optional 字段 trim + 空串归一为 None
// ============================================================
fn trim_opt(s: Option<String>) -> Option<String> {
s.map(|v| v.trim().to_string()).filter(|v| !v.is_empty())
}
/// 路径穿越防御:分段匹配 `..` 段(split '/' 或 '\' 后 any 段 == "..")。
///
/// 与裸 `contains("..")` 的区别:不误伤合法文件名/目录名(如 `a..b.rs`),
/// 且能识别路径中真正独立的 `..` 段(如 `src/../lib` 的 `..`)。sub_path / file_path
/// 约定 POSIX 风格('/' 分隔),反斜杠一并分段以兼容 Windows 传入路径。
fn has_path_traversal(p: &str) -> bool {
p.split(|c| c == '/' || c == '\\').any(|seg| seg == "..")
}
/// 工程状态归一:仅接受 active/archived(大小写不敏感),空/未传 → None(应用层视 None 为 active),
/// 非法值退化 None(应用层归一 active)。返回 None 表示"使用默认 active 语义"。
fn normalize_status(s: Option<String>) -> Option<String> {
match trim_opt(s) {
Some(v) => {
let lower = v.to_lowercase();
if lower == "active" || lower == "archived" {
Some(lower)
} else {
// 非法值 → 退化为 None(应用层/前端视 None 为 active)
None
}
}
None => None,
}
}
// ============================================================
// IPC 命令 — 工程 CRUD
// ============================================================
/// 新增工程(对标设计 §五 add_project_module)。返回完整记录。
#[tauri::command]
pub async fn add_project_module(
state: State<'_, AppState>,
input: AddProjectModuleInput,
) -> Result<ProjectModuleRecord, String> {
let project_id = input.project_id.trim().to_string();
let name = input.name.trim().to_string();
let path = input.path.trim().to_string();
if project_id.is_empty() {
return Err("project_id 不能为空".to_string());
}
if name.is_empty() {
return Err("name 不能为空".to_string());
}
if path.is_empty() {
return Err("path 不能为空".to_string());
}
// sort_order 取当前项目下工程数(末尾追加,新工程排末位)
let existing = state
.project_modules
.list_by_project(&project_id)
.await
.map_err(err_str)?;
let sort_order = existing.len() as i32;
let record = ProjectModuleRecord {
id: new_id(),
project_id,
name,
path,
git_url: trim_opt(input.git_url),
stack: trim_opt(input.stack),
auto_detected: false,
sort_order,
// created_at/updated_at 由 Repo 内部覆盖,此处占位
created_at: now_millis(),
updated_at: now_millis(),
description: trim_opt(input.description),
// status 默认 active(未传/空串归一),仅接受 active/archived 两值,其余退化 active。
status: normalize_status(input.status),
};
let record_id = record.id.clone();
let ok = state
.project_modules
.insert(record)
.await
.map_err(err_str)?;
if !ok {
return Err("插入工程记录失败".to_string());
}
state
.project_modules
.get_by_id(&record_id)
.await
.map_err(err_str)?
.ok_or_else(|| "插入后回读失败".to_string())
}
/// 更新工程(部分更新语义:仅传入字段被覆盖,其余保留)。返回完整记录。
#[tauri::command]
pub async fn update_project_module(
state: State<'_, AppState>,
input: UpdateProjectModuleInput,
) -> Result<ProjectModuleRecord, String> {
let id = input.id.trim().to_string();
if id.is_empty() {
return Err("id 不能为空".to_string());
}
// 至少一个可更新字段
if input.name.is_none()
&& input.path.is_none()
&& input.git_url.is_none()
&& input.stack.is_none()
&& input.description.is_none()
&& input.status.is_none()
{
return Err("至少提供一个待更新字段 (name/path/git_url/stack/description/status)".to_string());
}
// 先校验存在(404 友好错误),再整体更新(保留不可变字段)
let mut existing = state
.project_modules
.get_by_id(&id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("工程 {id} 不存在"))?;
if let Some(name) = input.name.map(|s| s.trim().to_string()) {
if name.is_empty() {
return Err("name 不能为空".to_string());
}
existing.name = name;
}
if let Some(path) = input.path.map(|s| s.trim().to_string()) {
if path.is_empty() {
return Err("path 不能为空".to_string());
}
// path 变更 → 清旧路径的 git status 缓存(避免后续同路径复用脏状态)
if existing.path != path {
invalidate_git_status_cache(&existing.path);
}
existing.path = path;
}
existing.git_url = trim_opt(input.git_url);
existing.stack = trim_opt(input.stack);
// description 仅在传入时覆盖(部分更新语义);None 表示"未提供字段"。
if let Some(d) = input.description {
existing.description = trim_opt(Some(d));
}
// status 仅在传入时覆盖,非 active/archived 退化 active。
if let Some(s) = input.status {
existing.status = normalize_status(Some(s));
}
let hit = state
.project_modules
.update_full(&existing)
.await
.map_err(err_str)?;
if !hit {
return Err(format!("工程 {id} 不存在(更新未命中)"));
}
state
.project_modules
.get_by_id(&id)
.await
.map_err(err_str)?
.ok_or_else(|| "更新后回读失败".to_string())
}
/// 删除工程(按 id 硬删,工程不需要软删审计追溯)。
#[tauri::command]
pub async fn remove_project_module(state: State<'_, AppState>, id: String) -> Result<(), String> {
let id = id.trim().to_string();
if id.is_empty() {
return Err("id 不能为空".to_string());
}
// 删除前取 path,删除后清 git status 缓存(避免同路径复用脏状态)
if let Ok(Some(module)) = state.project_modules.get_by_id(&id).await {
invalidate_git_status_cache(&module.path);
}
state.project_modules.delete(&id).await.map_err(err_str)?;
Ok(())
}
/// 查询项目全部工程(按 sort_order ASC 返回,前端工程列表稳定顺序)。
#[tauri::command]
pub async fn list_project_modules(
state: State<'_, AppState>,
project_id: String,
) -> Result<Vec<ProjectModuleRecord>, String> {
let project_id = project_id.trim().to_string();
if project_id.is_empty() {
return Err("project_id 不能为空".to_string());
}
let modules = state
.project_modules
.list_by_project(&project_id)
.await
.map_err(err_str)?;
// 老项目兼容:V34 迁移前创建的项目无工程记录(project_modules 表为空)。
// 自动从 projects.path 补建一个工程,使文件浏览器直接可用(单仓库退化场景)。
if modules.is_empty() {
if let Ok(Some(project)) = state.projects.get_by_id(&project_id).await {
if let Some(ref path) = project.path {
if !path.trim().is_empty() {
let now_str = df_types::now_millis().to_string();
let module = ProjectModuleRecord {
id: df_types::types::new_id(),
project_id: project_id.clone(),
name: project.name.clone(),
path: path.clone(),
git_url: None,
stack: project.stack.clone(),
auto_detected: true,
sort_order: 0,
created_at: now_str.clone(),
updated_at: now_str,
description: None,
status: Some("active".to_string()),
};
if let Err(e) = state.project_modules.insert(module.clone()).await {
tracing::warn!("老项目自动补建工程失败(非阻断): {}", e);
}
// 补建后重查返回(确保前端拿到刚建的工程)
return state
.project_modules
.list_by_project(&project_id)
.await
.map_err(err_str);
}
}
}
}
Ok(modules)
}
// ============================================================
// 仓库级 git status 缓存(P1-f:cmd 闪烁 N² 修复)
// ============================================================
/// git status 缓存 TTL(5 秒)。
///
/// 背景:`get_module_file_tree` 展开新目录 + `get_module_git_status` 切换变更视图都会在仓库根
/// 跑整仓 `git status --porcelain`;前端 `loadedChildren` 只对「同目录展开」去重,跨目录展开
/// N 次 = N 次整仓扫描(cmd 闪烁 N²)。git status 天然幂等(输出反映当前工作区),TTL 秒级失效
/// 可接受(用户改文件后刷新触发重扫兜底),不做实时 watcher(过度设计)。
const GIT_STATUS_CACHE_TTL: Duration = Duration::from_secs(5);
/// 仓库根路径 → (采集时间戳, git status --porcelain 输出 map)。
/// 键 = module.path(仓库根);值 = (Instant, {posix_rel: status})。
/// 进程级共享(spawn_blocking 内 std Mutex 访问),跨 IPC 请求复用。
static GIT_STATUS_CACHE: LazyLock<Mutex<HashMap<String, (Instant, HashMap<String, String>)>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// 判断缓存条目是否新鲜(TTL 内)。抽出纯函数便于单测。
fn is_git_status_cache_fresh(collected_at: Instant, now: Instant) -> bool {
now.duration_since(collected_at) < GIT_STATUS_CACHE_TTL
}
/// 取仓库 git status map:TTL 内命中直接复用缓存,未命中/过期才跑 git status 并更新缓存。
/// 非 git 仓库 → 空 map 且不入缓存(.git 判断廉价,且避免「git init 后 TTL 内误返空」)。
fn git_status_map_cached(dir: &str) -> HashMap<String, String> {
// 非 git 仓库快速返回(不缓存:git init 后 TTL 内重扫才拿得到真实状态)
if !Path::new(dir).join(".git").exists() {
return HashMap::new();
}
let now = Instant::now();
// 命中 TTL 内缓存 → 直接复用(锁内只查表,不跑 git,快速返回)
{
let cache = GIT_STATUS_CACHE.lock().unwrap_or_else(|e| e.into_inner());
if let Some((ts, map)) = cache.get(dir) {
if is_git_status_cache_fresh(*ts, now) {
return map.clone();
}
}
}
// 未命中/过期 → 跑 git(锁外执行,避免持锁阻塞其他目录的缓存命中;完成后更新缓存)
let map = collect_git_status_map(dir);
let mut cache = GIT_STATUS_CACHE.lock().unwrap_or_else(|e| e.into_inner());
cache.insert(dir.to_string(), (now, map.clone()));
map
}
/// 清空指定仓库根的 git status 缓存条目(工程删除 / path 变更时防脏数据复用)。
fn invalidate_git_status_cache(dir: &str) {
GIT_STATUS_CACHE
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(dir);
}
// ============================================================
// IPC 命令 — Git 状态查询(实时派生,不进表)
// ============================================================
/// Git 改动文件项(状态码 + 相对路径)。
#[derive(Debug, Serialize)]
struct GitChangedFile {
/// `git status --porcelain` 的状态码(xy 两字符,如 " M"、"M "、"??")
status: String,
/// 相对仓库根的路径
path: String,
}
/// 最近提交项(简短哈希 + subject)。
#[derive(Debug, Serialize)]
struct GitRecentCommit {
hash: String,
subject: String,
/// Unix 时间戳(秒),供前端格式化显示。
timestamp: i64,
/// 作者名(git an)
author: String,
}
/// Git 状态返回结构(无 .git 时各字段空,前端据此显示"非 Git 仓库")。
#[derive(Debug, Serialize)]
struct GitStatus {
/// 当前分支名(HEAD 处于分离状态时为空)
branch: String,
/// 改动文件列表(未提交)
changed_files: Vec<GitChangedFile>,
/// 最近 10 条提交
recent_commits: Vec<GitRecentCommit>,
/// 当前 HEAD 的全量提交数(`git rev-list --count HEAD`)。
/// 前端历史 Tab 徽标 / 分支栏计数用它,而非 recent_commits.len()(后者受分页限制)。
total_commits: i64,
/// 该目录是否为 Git 仓库(无 .git 时 false,其余字段空)
is_git_repo: bool,
}
/// 默认空状态(无 .git / git 不可用时返回此)。
fn empty_status() -> GitStatus {
GitStatus {
branch: String::new(),
changed_files: Vec::new(),
recent_commits: Vec::new(),
total_commits: 0,
is_git_repo: false,
}
}
/// 查询工程目录的 Git 状态(分支/改动文件/最近提交)。
///
/// 实现选择:**前端 IPC 用 `std::process::Command`**(非 df-execute,因为这是用户读操作,
/// 不是 AI 工具调用,不走审批/沙箱)。10s 超时,无 .git 返回空状态(`is_git_repo: false`)。
///
/// 返回 `serde_json::Value` 以便前端灵活取字段(分支/改动/提交三段)。
#[tauri::command]
pub async fn get_module_git_status(
state: State<'_, AppState>,
module_id: String,
) -> Result<serde_json::Value, String> {
let module_id = module_id.trim().to_string();
if module_id.is_empty() {
return Err("module_id 不能为空".to_string());
}
// 取工程记录拿 path
let module = state
.project_modules
.get_by_id(&module_id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
let path = std::path::Path::new(&module.path);
// 无 .git → 返回空状态
if !path.join(".git").exists() {
return Ok(serde_json::to_value(empty_status()).map_err(err_str)?);
}
// git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时
let dir = module.path.clone();
let status = tokio::task::spawn_blocking(move || run_git_status(&dir))
.await
.map_err(|e| format!("git 状态查询任务失败: {e}"))?;
Ok(serde_json::to_value(status).map_err(err_str)?)
}
/// 列出工程本地分支(只读)。返回 { current, branches: [{ name, is_current }] }。
#[tauri::command]
pub async fn list_branches(
state: State<'_, AppState>,
module_id: String,
) -> Result<serde_json::Value, String> {
let module_id = module_id.trim().to_string();
if module_id.is_empty() {
return Err("module_id 不能为空".to_string());
}
let module = state
.project_modules
.get_by_id(&module_id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
let path = std::path::Path::new(&module.path);
if !path.join(".git").exists() {
return Ok(serde_json::json!({ "current": "", "branches": [] }));
}
let dir = module.path.clone();
// git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时
let result = tokio::task::spawn_blocking(move || -> (String, Vec<serde_json::Value>) {
// git branch --format="%(HEAD)%00%(refname:short)"
let out = run_git_cmd(
std::path::Path::new(&dir),
&["branch", "--format=%(HEAD)%00%(refname:short)"],
std::time::Duration::from_secs(10),
)
.unwrap_or_default();
let mut current = String::new();
let mut branches = Vec::new();
for line in out.lines() {
let line = line.trim();
if line.is_empty() { continue; }
// 格式: "*\0branch_name" 或 "\0branch_name"
let (head, name) = line.split_once('\u{0}').unwrap_or(("", line));
let is_current = head.contains('*');
let name = name.trim().to_string();
if name.is_empty() { continue; }
if is_current { current = name.clone(); }
branches.push(serde_json::json!({ "name": name, "is_current": is_current }));
}
(current, branches)
})
.await
.map_err(|e| format!("分支列表查询任务失败: {e}"))?;
Ok(serde_json::json!({
"current": result.0,
"branches": result.1,
}))
}
/// 在指定目录跑 git 命令采集状态(当前分支 / 改动文件 / 最近提交)。
///
/// 超时:每个 git 子进程 10s(用户读操作,卡死不应阻塞 UI)。任一命令失败时返回已采集部分。
fn run_git_status(dir: &str) -> GitStatus {
let path = std::path::Path::new(dir);
if !path.join(".git").exists() {
return empty_status();
}
let timeout = Duration::from_secs(10);
// 1) 当前分支:`git rev-parse --abbrev-ref HEAD`(分离 HEAD 时返回 "HEAD")
let branch = run_git_cmd(path, &["rev-parse", "--abbrev-ref", "HEAD"], timeout)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty() && s != "HEAD")
.unwrap_or_default();
// 2) 改动文件:`git status --porcelain`(每行 "XY path",XY 为两字符状态码)。
// 走仓库级缓存(与文件树共用同一份扫描,TTL 内展开/切换不再整仓重扫)。
let mut changed_files: Vec<GitChangedFile> = git_status_map_cached(dir)
.into_iter()
.map(|(path, status)| GitChangedFile { status, path })
.collect();
// map 无序,按路径排序保证前端展示顺序稳定(git status 输出本身即按路径有序)。
changed_files.sort_by(|a, b| a.path.cmp(&b.path));
// 3) 最近提交:`git log -50 --format="%h %ct %an %s"`(hash + 时间戳 + 作者 + subject)
let mut recent_commits = Vec::new();
if let Some(out) = run_git_cmd(path, &["log", "-50", "--format=%h %ct %an %s"], timeout) {
for line in out.lines() {
// format:"<hash> <unix_ts> <author> <subject>"(作者不含空格)
let line = line.trim();
if line.is_empty() { continue; }
let mut parts = line.splitn(4, ' ');
let hash = parts.next().unwrap_or("").to_string();
let ts_str = parts.next().unwrap_or("0");
let timestamp: i64 = ts_str.parse().unwrap_or(0);
let author = parts.next().unwrap_or("").to_string();
let subject = parts.next().unwrap_or("").to_string();
if !hash.is_empty() {
recent_commits.push(GitRecentCommit {
hash,
subject,
timestamp,
author,
});
}
}
}
// 4) 全量提交计数:`git rev-list --count HEAD`(供前端历史 Tab 徽标真实总数,
// 非 recent_commits.len() 后者上限 50)。命令失败 → 退化为 recent_commits 长度。
let total_commits = run_git_cmd(path, &["rev-list", "--count", "HEAD"], timeout)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(recent_commits.len() as i64);
GitStatus {
branch,
changed_files,
recent_commits,
total_commits,
is_git_repo: true,
}
}
/// 跑单个 git 子命令,捕获 stdout(成功)或 None(失败/超时)。
///
/// 用 `wait_timeout` 实现 10s 超时(标准库 `Command::output` 无超时,需手动 wait)。
fn run_git_cmd(cwd: &std::path::Path, args: &[&str], timeout: std::time::Duration) -> Option<String> {
use std::sync::mpsc;
// 在独立线程跑子进程,主线程 select 超时,避免标准库无超时 API 的痛点。
// 编码:设置环境变量强制 git 输出 UTF-8(Windows 系统编码可能是 GBK,直接读会乱码)。
let (tx, rx) = mpsc::channel();
let cwd = cwd.to_path_buf();
let args = args.iter().map(|s| s.to_string()).collect::<Vec<_>>();
std::thread::spawn(move || {
let mut cmd = Command::new("git");
cmd.args(&args)
.current_dir(&cwd)
.env("LANG", "en_US.UTF-8")
.env("LC_ALL", "en_US.UTF-8")
.env("GIT_PAGER", "cat");
// Windows 下 spawn 子进程默认会弹 cmd 黑窗,加 CREATE_NO_WINDOW 抑制闪烁
// (GitChanges 挂载并发跑 rev-parse/status/log,不抑制会疯狂闪)。
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
let out = cmd.output();
let _ = tx.send(out);
});
match rx.recv_timeout(timeout) {
Ok(Ok(output)) if output.status.success() => {
Some(String::from_utf8_lossy(&output.stdout).to_string())
}
_ => None,
}
}
// ============================================================
// IPC 命令 — 文件浏览(Batch 10)
// ============================================================
//
// 设计要点(对标设计 §五 + Batch 10):
// - **路径安全**:sub_path / file_path 均拒含 `..`(防目录穿越)。先 join 再 canonicalize
// 校验结果仍位于 module.path 子树内(双重防御,即便 `..` 漏网也无法逃出工程根)。
// - **噪音过滤**:`get_module_file_tree` 跳过 node_modules/target/.git/__pycache__/dist/.vite
// (对齐需求清单,前端文件树不展示构建产物/VCS 元数据)。
// - **Git 状态合并**:跑 `git status --porcelain` 得 {rel_path: status} 映射,按条目相对路径
// 查表填充 git_status。文件夹一律 git_status = None(只标文件,符合需求)。
// - **二进制检测**:read_module_file 读 1MB 上限,前 8KB 含 \0 视作二进制(is_binary=true,content 留空)。
/// 噪音目录名(列表级过滤,read_dir 看到这些名字跳过)。
const NOISE_DIRS: &[&str] = &[
"node_modules",
"target",
".git",
"__pycache__",
"dist",
".vite",
];
/// 文件树条目(单层;前端点击文件夹再懒加载下一层)。
#[derive(Debug, Serialize)]
struct FileTreeEntry {
name: String,
/// 相对工程根的路径(POSIX 风格,前端用作后续 sub_path / file_path)。
path: String,
is_dir: bool,
/// 字节数(文件夹恒为 0)。
size: u64,
/// Git 状态码(`git status --porcelain` 的 XY 两字符,如 " M"/"M "/"??")。
/// 文件夹恒为 None(只标文件)。
#[serde(skip_serializing_if = "Option::is_none")]
git_status: Option<String>,
}
/// 列出工程目录(可钻入子目录)的文件树(单层 + git 状态合并)。
/// 返回 { path, entries: [{ name, path, is_dir, size, git_status? }] }。
#[tauri::command]
pub async fn get_module_file_tree(
state: State<'_, AppState>,
module_id: String,
sub_path: Option<String>,
) -> Result<serde_json::Value, String> {
let module_id = module_id.trim().to_string();
if module_id.is_empty() {
return Err("module_id 不能为空".to_string());
}
let sub = sub_path
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
// 路径穿越防御:`..` 段一律拒(规范化后 canonicalize 再兜底校验仍在工程根子树)。
if let Some(ref s) = sub {
if has_path_traversal(s) {
return Err("sub_path 不允许包含 ..".to_string());
}
}
let module = state
.project_modules
.get_by_id(&module_id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
let root = PathBuf::from(&module.path);
// 工程根本身必须存在(否则连列根都无意义,直接报错给前端清晰提示)。
if !root.exists() {
return Err(format!("工程目录不存在: {}", module.path));
}
// 拼接 sub_path 得目标目录;规范化路径显示用。
let target_dir = match &sub {
Some(rel) => root.join(rel),
None => root.clone(),
};
if !target_dir.is_dir() {
return Err(format!("目标路径不是目录: {}", target_dir.display()));
}
// 计算相对工程根的显示路径(POSIX 风格),前端面包屑/二次请求复用。
let rel_display = match &sub {
Some(rel) => rel.replace('\\', "/"),
None => String::new(),
};
// 采集 git 改动文件映射(相对仓库根):{posix_rel: status}。
// 10s 超时;非 git 仓库/失败 → 空映射(条目 git_status 全 None,不阻断列目录)。
let dir_for_git = module.path.clone();
let git_map = tokio::task::spawn_blocking(move || git_status_map_cached(&dir_for_git))
.await
.map_err(|e| format!("git status 采集任务失败: {e}"))?;
// 路径前缀(工程根本身)的相对基准;target_dir 下条目相对路径要以此拼成 posix 形式查 git_map。
// git status 输出是相对仓库根(module.path 当作根),sub 非空时条目前缀 = sub + "/"。
let prefix = if rel_display.is_empty() {
String::new()
} else {
format!("{rel_display}/")
};
// 读目录(spawn_blocking 避免阻塞 runtime);排序:目录优先 + 字母序。
let target_dir_clone = target_dir.clone();
let prefix_clone = prefix.clone();
let git_map_clone = git_map.clone();
let entries = tokio::task::spawn_blocking(move || -> Result<Vec<FileTreeEntry>, String> {
let read = std::fs::read_dir(&target_dir_clone)
.map_err(|e| format!("读取目录失败: {e}"))?;
let mut items: Vec<FileTreeEntry> = Vec::new();
for entry in read.flatten() {
let file_name = entry.file_name();
let name = file_name.to_string_lossy().to_string();
// 噪音过滤(无论文件/目录,凡名命中即跳)。
if NOISE_DIRS.iter().any(|n| *n == name.as_str()) {
continue;
}
let ft = match entry.file_type() {
Ok(t) => t,
Err(_) => continue, // 无法判定类型跳过(符号链接断链等)
};
// 跳过隐藏文件(以 . 开头),与常见编辑器行为一致,减少噪音。
if name.starts_with('.') {
continue;
}
let is_dir = ft.is_dir();
let size = if is_dir {
0
} else {
entry.metadata().map(|m| m.len()).unwrap_or(0)
};
let rel_path = format!("{prefix_clone}{name}");
let posix_rel = rel_path.replace('\\', "/");
let git_status = if is_dir {
None
} else {
git_map_clone.get(&posix_rel).cloned()
};
items.push(FileTreeEntry {
name,
path: posix_rel,
is_dir,
size,
git_status,
});
}
// 目录优先,各自字母序(稳定可预期,前端无需再排)。
items.sort_by(|a, b| match (a.is_dir, b.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
});
Ok(items)
})
.await
.map_err(|e| format!("列目录任务失败: {e}"))??;
Ok(serde_json::json!({
"path": rel_display,
"entries": entries,
}))
}
/// 读工程内单文件(文本/图片);1MB 上限,二进制检测(前 8KB 含 \0)。
/// 返回 { path, content, size, is_binary }。二进制/超限时 is_binary=true 或 content 截断,
/// 前端据此降级展示("二进制文件不支持预览")。
#[tauri::command]
pub async fn read_module_file(
state: State<'_, AppState>,
module_id: String,
file_path: String,
) -> Result<serde_json::Value, String> {
let module_id = module_id.trim().to_string();
let file_path = file_path.trim().to_string();
if module_id.is_empty() {
return Err("module_id 不能为空".to_string());
}
if file_path.is_empty() {
return Err("file_path 不能为空".to_string());
}
// 路径穿越防御:`..` 段一律拒。
if has_path_traversal(&file_path) {
return Err("file_path 不允许包含 ..".to_string());
}
let module = state
.project_modules
.get_by_id(&module_id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
let root = PathBuf::from(&module.path);
let abs = root.join(&file_path);
// 必须是文件(目录/不存在均拒)。
if !abs.is_file() {
return Err(format!("文件不存在或不是普通文件: {}", file_path));
}
const MAX_BYTES: u64 = 1024 * 1024; // 1MB 上限
let meta = std::fs::metadata(&abs).map_err(|e| format!("读取文件元信息失败: {e}"))?;
let total_size = meta.len();
// 读取(超 1MB 截断到 1MB;spawn_blocking 避免大文件 IO 阻塞 runtime)。
let abs_clone = abs.clone();
let read_result = tokio::task::spawn_blocking(move || -> Result<(Vec<u8>, bool), String> {
use std::io::Read;
let mut f = std::fs::File::open(&abs_clone).map_err(|e| format!("打开文件失败: {e}"))?;
let mut buf = vec![0u8; MAX_BYTES as usize];
let n = f.read(&mut buf).map_err(|e| format!("读取文件失败: {e}"))?;
buf.truncate(n);
// 二进制检测:前 8KB 含 \0 → 二进制。8KB 内全为文本字节 → 当文本。
let check_len = buf.len().min(8192);
let is_binary = buf[..check_len].iter().any(|&b| b == 0);
Ok((buf, is_binary))
})
.await
.map_err(|e| format!("读文件任务失败: {e}"))??;
let (bytes, is_binary) = read_result;
let content = if is_binary {
// 二进制不返内容(前端走"二进制不支持预览"分支,避免给前端塞不可打印字节)。
String::new()
} else {
// 非 UTF-8 字节用 lossy(替换非法字节,文本类基本不受影响)。
String::from_utf8_lossy(&bytes).to_string()
};
Ok(serde_json::json!({
"path": file_path.replace('\\', "/"),
"content": content,
"size": total_size,
"is_binary": is_binary,
// 标记是否被截断(超 1MB),前端据此提示"文件过大,仅显示前 1MB"。
"truncated": total_size > MAX_BYTES,
}))
}
/// 查询工程内文件元信息(不读内容,轻量检测变化用)。
/// 返回 { path, size, modified_at }。modified_at 是毫秒级时间戳。
#[tauri::command]
pub async fn get_module_file_meta(
state: State<'_, AppState>,
module_id: String,
file_path: String,
) -> Result<serde_json::Value, String> {
let module_id = module_id.trim().to_string();
let file_path = file_path.trim().to_string();
if module_id.is_empty() {
return Err("module_id 不能为空".to_string());
}
if file_path.is_empty() {
return Err("file_path 不能为空".to_string());
}
if has_path_traversal(&file_path) {
return Err("file_path 不允许包含 ..".to_string());
}
let module = state
.project_modules
.get_by_id(&module_id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
let root = std::path::PathBuf::from(&module.path);
let abs = root.join(&file_path);
if !abs.is_file() {
return Err(format!("文件不存在或不是普通文件: {}", file_path));
}
let meta = std::fs::metadata(&abs).map_err(|e| format!("读取文件元信息失败: {e}"))?;
// 系统级 modified_at 需跨平台转换;用 std::time::UNIX_EPOCH 算毫秒
let modified_ms = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
Ok(serde_json::json!({
"path": file_path.replace('\\', "/"),
"size": meta.len(),
"modified_at": modified_ms,
}))
}
/// 查询工程内文件的 git diff(相对 staged 或 working tree 的变更)。
/// 返回 { path, diff }。diff 为空串表示无变更。
#[tauri::command]
pub async fn get_module_file_diff(
state: State<'_, AppState>,
module_id: String,
file_path: String,
) -> Result<serde_json::Value, String> {
let module_id = module_id.trim().to_string();
let file_path = file_path.trim().to_string();
if module_id.is_empty() {
return Err("module_id 不能为空".to_string());
}
if file_path.is_empty() {
return Err("file_path 不能为空".to_string());
}
if has_path_traversal(&file_path) {
return Err("file_path 不允许包含 ..".to_string());
}
let module = state
.project_modules
.get_by_id(&module_id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
let root = std::path::PathBuf::from(&module.path);
if !root.join(".git").exists() {
return Ok(serde_json::json!({"path": file_path, "diff": ""}));
}
// 跑 git diff <path>(工作树 vs 索引的 unstaged 变更+staged 变更)
// spawn_blocking + run_git_cmd(10s 超时,防 git 卡死阻塞 runtime)。
let cmd_dir = root.clone();
let path_arg = file_path.replace('\\', "/");
let diff = tokio::task::spawn_blocking(move || -> Option<String> {
run_git_cmd(
&cmd_dir,
&["diff", "--", &path_arg],
std::time::Duration::from_secs(10),
)
})
.await
.map_err(|e| format!("git diff 任务失败: {e}"))?
.unwrap_or_default();
Ok(serde_json::json!({
"path": file_path.replace('\\', "/"),
"diff": diff,
}))
}
/// 扫描项目绑定目录下的一级子目录,自动创建工程记录。
///
/// 判定为工程的信号:① 含 .git(独立仓库)② detect_stack 命中(有 package.json / go.mod /
/// Cargo.toml 等工程标志文件)。两者满足其一即识别;两者都无(纯普通文件夹)忽略。
/// 无 .git 的工程 git_url 留空。返回新创建的工程数量;幂等(已存在路径不重复创建)。
#[tauri::command]
pub async fn scan_project_modules(
state: State<'_, AppState>,
project_id: String,
) -> Result<i64, String> {
let project_id = project_id.trim().to_string();
if project_id.is_empty() {
return Err("project_id 不能为空".to_string());
}
// 取项目记录拿 path
let project = state
.projects
.get_by_id(&project_id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("项目 {project_id} 不存在"))?;
let root_path = project
.path
.as_ref()
.filter(|p| !p.trim().is_empty())
.ok_or_else(|| "项目未绑定目录".to_string())?;
let root = PathBuf::from(root_path);
// 取已有工程路径集(避免重复创建)
let existing = state
.project_modules
.list_by_project(&project_id)
.await
.map_err(err_str)?;
let existing_paths: std::collections::HashSet<String> = existing
.iter()
.map(|m| m.path.to_lowercase().replace("\\", "/"))
.collect();
// 扫描一级子目录(含 .git)
let mut new_count = 0i64;
let entries = std::fs::read_dir(&root)
.map_err(|e| format!("读取项目目录失败: {e}"))?;
for entry in entries.flatten() {
let ft = match entry.file_type() { Ok(t) => t, Err(_) => continue };
if !ft.is_dir() { continue; }
let name = entry.file_name().to_string_lossy().to_string();
// 跳过隐藏目录和噪音目录
if name.starts_with('.') || name == "node_modules" || name == "target" || name == "__pycache__" || name == "dist" {
continue;
}
let child_path = entry.path();
let child_path_str = child_path.to_string_lossy().replace("\\", "/");
if existing_paths.contains(&child_path_str.to_lowercase()) { continue; }
let has_git = child_path.join(".git").exists();
// 探测技术栈:既是「是否工程」的判定信号,也填 stack 字段
let stack_dir = child_path.clone();
let stack_json = tokio::task::spawn_blocking(move || {
detect_stack(&stack_dir)
.ok()
.filter(|v| !v.is_empty())
.and_then(|v| serde_json::to_string(&v).ok())
})
.await
.unwrap_or(None);
// 合理判定:有 .git(独立仓库)或 detect_stack 命中(工程标志,如 package.json /
// go.mod / Cargo.toml 等)→ 识别为工程;两者都无 → 纯普通文件夹,忽略。
// 相比原「仅 .git」,覆盖无独立 git 但有工程标志的子包(monorepo npm workspace 等)。
if !has_git && stack_json.is_none() {
continue;
}
// 获取远程地址(仅独立 git 仓库有 remote;无 .git 的工程 git_url 留空)
let url_dir = child_path.clone();
let git_url = if has_git {
tokio::task::spawn_blocking(move || {
run_git_cmd(
&url_dir,
&["remote", "get-url", "origin"],
std::time::Duration::from_secs(10),
)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
})
.await
.unwrap_or(None)
} else {
None
};
let now_str = now_millis();
let record = ProjectModuleRecord {
id: new_id(),
project_id: project_id.clone(),
name: name.clone(),
path: child_path.to_string_lossy().to_string(),
git_url,
stack: stack_json,
auto_detected: true,
sort_order: (existing.len() + new_count as usize) as i32,
created_at: now_str.clone(),
updated_at: now_str,
description: None,
status: Some("active".to_string()),
};
if let Err(e) = state.project_modules.insert(record).await {
tracing::warn!("自动探测工程 {} 失败: {}", child_path_str, e);
continue;
}
new_count += 1;
}
Ok(new_count)
}
/// 采集工程目录 git status --porcelain 的 {posix_rel_path: status} 映射。
/// 非 git 仓库/命令失败 → 空映射(不阻断文件树展示,仅 git_status 字段全 None)。
fn collect_git_status_map(dir: &str) -> HashMap<String, String> {
let path = Path::new(dir);
let mut map = HashMap::new();
if !path.join(".git").exists() {
return map;
}
let timeout = std::time::Duration::from_secs(10);
let Some(out) = run_git_cmd(path, &["status", "--porcelain"], timeout) else {
return map;
};
for line in out.lines() {
if line.len() < 4 {
continue;
}
let status = line[..2].to_string();
let raw_path = line[3..].trim().to_string();
if raw_path.is_empty() {
continue;
}
// porcelain 在路径含空格/特殊字符时可能带引号;git 默认不引普通路径,剥引号兜底。
let cleaned = raw_path
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(&raw_path)
.replace('\\', "/");
// 重命名条目形如 "R old -> new":取 -> 后的新路径(更贴合文件树展示位置)。
let final_path = cleaned
.split_once(" -> ")
.map(|(_, new)| new.to_string())
.unwrap_or(cleaned);
map.insert(final_path, status);
}
map
}
/// 查询工程 Git 提交历史(分页,按时间倒序)。
/// 返回 { commits: [{ hash, subject, timestamp, author }], has_more: bool, total: i64 }。
/// total = `git rev-list --count HEAD` 的全量提交数,供前端徽标真实总数(commits.len() 受分页限制)。
#[tauri::command]
pub async fn get_module_commits(
state: State<'_, AppState>,
module_id: String,
skip: Option<u32>,
limit: Option<u32>,
) -> Result<serde_json::Value, String> {
let module_id = module_id.trim().to_string();
if module_id.is_empty() {
return Err("module_id 不能为空".to_string());
}
let module = state
.project_modules
.get_by_id(&module_id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
let path = std::path::Path::new(&module.path);
if !path.join(".git").exists() {
return Ok(serde_json::json!({ "commits": [], "has_more": false, "total": 0 }));
}
let skip = skip.unwrap_or(0);
let fetch = limit.unwrap_or(50);
// 多取一条以判断 has_more
let fetch_plus = fetch + 1;
let dir = module.path.clone();
let dir_for_git = dir.clone();
// git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时
let (commits, total): (Vec<serde_json::Value>, i64) = tokio::task::spawn_blocking(move || -> (Vec<serde_json::Value>, i64) {
let path = std::path::Path::new(&dir_for_git);
let timeout = std::time::Duration::from_secs(10);
let out = run_git_cmd(
path,
&[
"log",
&format!("--skip={}", skip),
&format!("-{}", fetch_plus),
"--format=%h %ct %an %s",
],
timeout,
)
.unwrap_or_default();
let mut commits: Vec<serde_json::Value> = Vec::new();
for line in out.lines() {
let line = line.trim();
if line.is_empty() { continue; }
if commits.len() >= fetch as usize { break; }
let mut parts = line.splitn(4, ' ');
let hash = parts.next().unwrap_or("").to_string();
let ts_str = parts.next().unwrap_or("0");
let timestamp: i64 = ts_str.parse().unwrap_or(0);
let author = parts.next().unwrap_or("").to_string();
let subject = parts.next().unwrap_or("").to_string();
if !hash.is_empty() {
commits.push(serde_json::json!({
"hash": hash,
"subject": subject,
"timestamp": timestamp,
"author": author,
}));
}
}
// 全量提交计数(`git rev-list --count HEAD`):前端历史 Tab 徽标真实总数。
// 命令失败 → 退化为 0(前端会显示 0,但 has_more 仍可驱动分页)。
let total = run_git_cmd(path, &["rev-list", "--count", "HEAD"], timeout)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0);
(commits, total)
})
.await
.map_err(|e| format!("提交历史查询任务失败: {e}"))?;
// 判断 has_more:取了 N+1 条但只返回 N 条,说明有更多
let total_fetched = commits.len();
let has_more = total_fetched == fetch_plus as usize;
let returned: Vec<_> = commits.into_iter().take(fetch as usize).collect();
Ok(serde_json::json!({
"commits": returned,
"has_more": has_more,
"total": total,
}))
}
/// 查询某次提交的变更文件列表及全量 diff。
/// 返回 { files: [{ status, path }], diff: string }。
#[tauri::command]
pub async fn get_commit_detail(
state: State<'_, AppState>,
module_id: String,
commit_hash: String,
) -> Result<serde_json::Value, String> {
let module_id = module_id.trim().to_string();
let commit_hash = commit_hash.trim().to_string();
if module_id.is_empty() {
return Err("module_id 不能为空".to_string());
}
if commit_hash.is_empty() {
return Err("commit_hash 不能为空".to_string());
}
let module = state
.project_modules
.get_by_id(&module_id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
let path = std::path::Path::new(&module.path);
if !path.join(".git").exists() {
return Ok(serde_json::json!({ "files": [], "diff": "" }));
}
let dir = module.path.clone();
// git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时
let (files, diff, parents, author, date, full_message) = tokio::task::spawn_blocking(move || -> (Vec<serde_json::Value>, String, Vec<String>, String, String, String) {
let dir_path = std::path::Path::new(&dir);
let timeout = std::time::Duration::from_secs(10);
// 1) 获取变更文件列表:`git diff-tree --no-commit-id -r --name-status <hash>`
let files_out = run_git_cmd(
dir_path,
&["diff-tree", "--no-commit-id", "-r", "--name-status", &commit_hash],
timeout,
)
.unwrap_or_default();
let mut files: Vec<serde_json::Value> = Vec::new();
for line in files_out.lines() {
let line = line.trim();
if line.is_empty() { continue; }
// format:"<status>\t<path>"
if let Some((status, fpath)) = line.split_once('\t') {
files.push(serde_json::json!({
"status": status.trim(),
"path": fpath.trim(),
}));
}
}
// 2) 获取全量 diff:`git show <hash>`(仅 diff 部分)
let diff = run_git_cmd(
dir_path,
&["show", "--format=", &commit_hash],
timeout,
)
.unwrap_or_default();
// 3) 获取提交元信息(父提交/作者/日期/完整消息):
// 用 printf 自定义格式,%P=父哈希(空格分隔多个)\t%an=作者\t%ad=日期\t%B=完整消息
let meta = run_git_cmd(
dir_path,
&["show", "-s", "--format=%P\t%an\t%ad\t%B", &commit_hash],
timeout,
)
.unwrap_or_default();
let mut parents = Vec::new();
let mut author = String::new();
let mut date = String::new();
let mut full_message = String::new();
if let Some(first_line) = meta.lines().next() {
let parts: Vec<&str> = first_line.splitn(4, '\t').collect();
if parts.len() >= 1 {
parents = parts[0].split_whitespace().map(|s| s.to_string()).collect();
}
if parts.len() >= 2 { author = parts[1].to_string(); }
if parts.len() >= 3 { date = parts[2].to_string(); }
if parts.len() >= 4 { full_message = parts[3].to_string(); }
}
// multi-line message: 补尾部行(meta.lines().next() 只取首行,需拼回)
if !full_message.is_empty() {
let rest = meta.lines().skip(1).collect::<Vec<_>>().join("\n");
if !rest.is_empty() {
full_message = format!("{}\n{}", full_message, rest);
}
}
(files, diff, parents, author, date, full_message)
})
.await
.map_err(|e| format!("提交详情查询任务失败: {e}"))?;
Ok(serde_json::json!({
"files": files,
"diff": diff,
"parents": parents,
"author": author,
"date": date,
"full_message": full_message,
}))
}
// ============================================================
// IPC 命令 — 工程依赖关系(module_dependencies 表)
// ============================================================
/// 新增工程依赖。返回完整记录。
#[tauri::command]
pub async fn add_module_dependency(
state: State<'_, AppState>,
project_id: String,
from_module_id: String,
to_module_id: String,
dep_type: Option<String>,
label: Option<String>,
) -> Result<ModuleDependencyRecord, String> {
let project_id = project_id.trim().to_string();
let from_module_id = from_module_id.trim().to_string();
let to_module_id = to_module_id.trim().to_string();
if project_id.is_empty() || from_module_id.is_empty() || to_module_id.is_empty() {
return Err("project_id / from_module_id / to_module_id 不能为空".to_string());
}
let dep_type = dep_type.unwrap_or_else(|| "library".to_string());
let record = ModuleDependencyRecord {
id: new_id(),
project_id,
from_module_id,
to_module_id,
dep_type,
label: trim_opt(label),
created_at: now_millis(),
};
state.module_dependencies.insert(record.clone()).await.map_err(err_str)?;
Ok(record)
}
/// 删除工程依赖。
#[tauri::command]
pub async fn remove_module_dependency(
state: State<'_, AppState>,
id: String,
) -> Result<(), String> {
let id = id.trim().to_string();
if id.is_empty() {
return Err("id 不能为空".to_string());
}
state.module_dependencies.delete(&id).await.map_err(err_str)?;
Ok(())
}
/// 列出项目的全部工程依赖。
#[tauri::command]
pub async fn list_module_dependencies(
state: State<'_, AppState>,
project_id: String,
) -> Result<Vec<ModuleDependencyRecord>, String> {
let project_id = project_id.trim().to_string();
if project_id.is_empty() {
return Err("project_id 不能为空".to_string());
}
state
.module_dependencies
.list_by_field("project_id", &project_id)
.await
.map_err(err_str)
}
/// 检测工程依赖图中的环形依赖(DFS 三色标记法)。
/// 返回参与环的 module_id 列表(空 = 无环)。
#[tauri::command]
pub async fn detect_module_cycles(
state: State<'_, AppState>,
project_id: String,
) -> Result<Vec<String>, String> {
let project_id = project_id.trim().to_string();
if project_id.is_empty() {
return Err("project_id 不能为空".to_string());
}
let deps = state
.module_dependencies
.list_by_field("project_id", &project_id)
.await
.map_err(err_str)?;
// 构建邻接表
let mut adj: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
for d in &deps {
adj.entry(d.from_module_id.clone())
.or_default()
.push(d.to_module_id.clone());
adj.entry(d.to_module_id.clone()).or_default();
}
// DFS 三色:0=白(未访问) 1=灰(栈中) 2=黑(完成)
let mut color: std::collections::HashMap<String, u8> = std::collections::HashMap::new();
let mut cycle_nodes: std::collections::HashSet<String> = std::collections::HashSet::new();
fn dfs(
node: &str,
adj: &std::collections::HashMap<String, Vec<String>>,
color: &mut std::collections::HashMap<String, u8>,
cycle_nodes: &mut std::collections::HashSet<String>,
path: &mut Vec<String>,
) {
let c = color.get(node).copied().unwrap_or(0);
if c == 1 {
// 发现环:path 中从当前节点开始的都参与环
let in_cycle = path.iter().position(|n| n == node);
if let Some(start) = in_cycle {
for n in &path[start..] {
cycle_nodes.insert(n.clone());
}
}
cycle_nodes.insert(node.to_string());
return;
}
if c == 2 {
return;
}
color.insert(node.to_string(), 1);
path.push(node.to_string());
if let Some(neighbors) = adj.get(node) {
for next in neighbors {
dfs(next, adj, color, cycle_nodes, path);
}
}
path.pop();
color.insert(node.to_string(), 2);
}
let nodes: Vec<String> = adj.keys().cloned().collect();
let mut path: Vec<String> = Vec::new();
for n in &nodes {
if color.get(n).copied().unwrap_or(0) == 0 {
dfs(n, &adj, &mut color, &mut cycle_nodes, &mut path);
}
}
Ok(cycle_nodes.into_iter().collect())
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
/// P1-f:git status 缓存 TTL 判定 — 5s 内新鲜,超时失效。
#[test]
fn test_is_git_status_cache_fresh() {
let now = Instant::now();
// TTL 内 → 新鲜
assert!(is_git_status_cache_fresh(now - Duration::from_millis(4000), now));
// 恰好 TTL → 不新鲜(duration_since < TTL 严格小于)
assert!(!is_git_status_cache_fresh(now - Duration::from_secs(5), now));
// 超过 TTL → 失效
assert!(!is_git_status_cache_fresh(now - Duration::from_secs(6), now));
}
/// P1-f:git status 缓存同一仓库路径复用同一 map 条目、不同路径独立(纯逻辑,不经 git)。
#[test]
fn test_git_status_cache_shared_per_repo() {
let mut cache = GIT_STATUS_CACHE.lock().unwrap_or_else(|e| e.into_inner());
let now = Instant::now();
let mut map_a = HashMap::new();
map_a.insert("src/main.rs".to_string(), " M".to_string());
let mut map_b = HashMap::new();
map_b.insert("README.md".to_string(), "??".to_string());
cache.insert("repoA".to_string(), (now, map_a.clone()));
cache.insert("repoB".to_string(), (now, map_b.clone()));
// 同路径命中同一份缓存,不同路径互不串扰
let (_, cached_a) = cache.get("repoA").unwrap();
assert_eq!(cached_a, &map_a);
assert_ne!(cached_a, &map_b);
// 清理后不再命中
cache.remove("repoA");
assert!(cache.get("repoA").is_none());
}
}