diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index 2d1e2be..1c5c1a0 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -240,6 +240,11 @@ pub fn tools_list_result() -> Value { }, "required": ["project_id"] } + }, + { + "name": "scan_unregistered_projects", + "description": "扫描已登记项目的父目录,找出存在于文件系统但尚未在炼境注册的项目目录。返回每个未注册目录的路径、是否有 .git、技术栈特征文件(package.json / Cargo.toml / go.mod)、是否有 AGENTS.md。用于日常发现漏网项目并决定是否接入炼境。", + "inputSchema": { "type": "object", "properties": {}, "required": [] } } ] }) @@ -456,6 +461,7 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value { crate::commands::cicd::distribute_workflow_for_project(pid, wt) } "list_all_projects" => list_all_projects(), + "scan_unregistered_projects" => scan_unregistered_projects(), "apply_onboarding_pack" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); resolve_project_root(&gid, pid).and_then(|root| { @@ -897,6 +903,117 @@ pub fn list_all_projects() -> Result { Ok(out) } +pub fn scan_unregistered_projects() -> Result { + let conn = db::pool().get().map_err(|e| e.to_string())?; + + // 取所有已登记的 win_path + let mut stmt = conn + .prepare("SELECT win_path FROM project_workspaces WHERE win_path IS NOT NULL") + .map_err(|e| e.to_string())?; + let registered_paths: std::collections::HashSet = stmt + .query_map([], |r| r.get::<_, String>(0)) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .map(|p| p.trim_end_matches(['/', '\\']).to_string()) + .collect(); + + // 推导父目录(去重) + let mut parent_dirs: std::collections::HashSet = + std::collections::HashSet::new(); + for p in ®istered_paths { + if let Some(parent) = std::path::Path::new(p).parent() { + parent_dirs.insert(parent.to_path_buf()); + } + } + + if parent_dirs.is_empty() { + return Ok("炼境暂无登记项目,无法推导扫描目录。".to_string()); + } + + let mut unregistered: Vec<(String, bool, Vec, bool)> = Vec::new(); + + for parent in &parent_dirs { + let entries = std::fs::read_dir(parent).map_err(|e| e.to_string())?; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let path_str = path.to_string_lossy().trim_end_matches(['/', '\\']).to_string(); + if registered_paths.contains(&path_str) { + continue; + } + // 隐藏目录跳过 + if path + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with('.')) + .unwrap_or(false) + { + continue; + } + + let has_git = path.join(".git").exists(); + let mut stack: Vec = Vec::new(); + if path.join("package.json").exists() { + stack.push("JS/TS".into()); + } + if path.join("Cargo.toml").exists() { + stack.push("Rust".into()); + } + if path.join("go.mod").exists() { + stack.push("Go".into()); + } + if path.join("pom.xml").exists() { + stack.push("Java".into()); + } + let has_agents = path.join("AGENTS.md").exists(); + + unregistered.push((path_str, has_git, stack, has_agents)); + } + } + + if unregistered.is_empty() { + return Ok(format!( + "扫描了 {} 个父目录,所有子目录均已在炼境注册。", + parent_dirs.len() + )); + } + + unregistered.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut out = format!( + "## 未注册目录(共 {} 个)\n\n扫描范围:{}\n\n", + unregistered.len(), + parent_dirs + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect::>() + .join("、") + ); + + for (path, has_git, stack, has_agents) in &unregistered { + let git_icon = if *has_git { "🗂 git" } else { "❌ 无git" }; + let agents_icon = if *has_agents { "📄 AGENTS" } else { "—" }; + let stack_str = if stack.is_empty() { + "未知".to_string() + } else { + stack.join("/") + }; + let name = std::path::Path::new(path) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| path.clone()); + out.push_str(&format!( + "- **{}** | {} | {} | {}\n `{}`\n\n", + name, git_icon, stack_str, agents_icon, path + )); + } + + out.push_str("---\n在炼境 UI 添加项目后,调用 `apply_onboarding_pack(project_id)` 分发接入包。"); + Ok(out) +} + // ── 测试 ───────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -915,6 +1032,10 @@ mod tests { assert!(names.contains(&"apply_onboarding_pack"), "缺少 apply_onboarding_pack"); assert!(names.contains(&"verify_onboarding"), "缺少 verify_onboarding"); assert!(names.contains(&"list_all_projects"), "缺少 list_all_projects"); + assert!( + names.contains(&"scan_unregistered_projects"), + "缺少 scan_unregistered_projects" + ); assert!(names.contains(&"inject_mcp"), "缺少 inject_mcp"); assert!(names.contains(&"create_pull_request"), "缺少 create_pull_request"); assert!(names.contains(&"poll_now"), "缺少 poll_now");