use crate::db::pool; use rusqlite::params; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; // ── 数据结构 ────────────────────────────────────────────────────────────────── #[derive(Debug, Serialize, Deserialize, Clone)] pub struct EnvToolItem { pub id: String, pub project_id: String, pub layer: u8, pub name: String, pub source: Option, pub version: Option, pub note: Option, } #[derive(Debug, Deserialize)] pub struct SaveEnvToolInput { pub id: String, pub layer: u8, pub name: String, pub source: Option, pub version: Option, pub note: Option, } // ── 内部扫描结构 ────────────────────────────────────────────────────────────── struct RawItem { source: Option, version: Option, note: Option, } // Key = (layer, name),用于去重 type DetectedMap = BTreeMap<(u8, String), RawItem>; /// 插入(已存在则不覆盖) fn insert( map: &mut DetectedMap, layer: u8, name: &str, source: Option<&str>, version: Option, note: Option<&str>, ) { map.entry((layer, name.to_string())).or_insert(RawItem { source: source.map(|s| s.to_string()), version, note: note.map(|s| s.to_string()), }); } /// 插入或补充(已存在时填充空字段) fn upsert( map: &mut DetectedMap, layer: u8, name: &str, source: Option<&str>, version: Option, note: Option<&str>, ) { let entry = map .entry((layer, name.to_string())) .or_insert(RawItem { source: None, version: None, note: None }); if source.is_some() && entry.source.is_none() { entry.source = source.map(|s| s.to_string()); } if version.is_some() && entry.version.is_none() { entry.version = version; } if note.is_some() && entry.note.is_none() { entry.note = note.map(|s| s.to_string()); } } // ── 文件读取工具 ────────────────────────────────────────────────────────────── fn read_first_line(path: &Path) -> Option { std::fs::read_to_string(path) .ok() .and_then(|s| s.lines().next().map(|l| l.trim().to_string())) .filter(|s| !s.is_empty()) } fn read_all(path: &Path) -> Option { std::fs::read_to_string(path).ok() } // ── 目录扫描核心 ────────────────────────────────────────────────────────────── fn scan_dir_files(dir: &Path) -> DetectedMap { let mut map: DetectedMap = BTreeMap::new(); // ── Node.js 生态 ────────────────────────────────────────────────────────── // .nvmrc → nvm(layer0) + Node.js(layer1, source=nvm) let nvmrc = dir.join(".nvmrc"); if nvmrc.exists() { let ver = read_first_line(&nvmrc); insert(&mut map, 0, "nvm", None, None, Some(".nvmrc")); upsert(&mut map, 1, "Node.js", Some("nvm"), ver, None); } // .node-version → fnm(layer0) + Node.js(layer1, source=fnm) let node_version_file = dir.join(".node-version"); if node_version_file.exists() { let ver = read_first_line(&node_version_file); insert(&mut map, 0, "fnm", None, None, Some(".node-version")); upsert(&mut map, 1, "Node.js", Some("fnm"), ver, None); } // package.json → Node.js(layer1),检查 volta 字段 let pkg_json = dir.join("package.json"); if pkg_json.exists() { upsert(&mut map, 1, "Node.js", None, None, None); if let Some(content) = read_all(&pkg_json) { if content.contains("\"volta\"") { insert(&mut map, 0, "Volta", None, None, Some("package.json#volta")); let key = (1u8, "Node.js".to_string()); if let Some(item) = map.get_mut(&key) { if item.source.is_none() { item.source = Some("volta".to_string()); } } } } } // 包管理器 lock 文件 → layer2 if dir.join("pnpm-lock.yaml").exists() { insert(&mut map, 2, "pnpm", Some("direct"), None, None); } if dir.join("package-lock.json").exists() { insert(&mut map, 2, "npm", Some("direct"), None, None); } if dir.join("yarn.lock").exists() { insert(&mut map, 2, "Yarn", Some("direct"), None, None); } if dir.join("bun.lockb").exists() || dir.join("bun.lock").exists() { insert(&mut map, 2, "Bun", Some("direct"), None, None); } // tsconfig.json → TypeScript(layer3, local) if dir.join("tsconfig.json").exists() { insert(&mut map, 3, "TypeScript", Some("local"), None, Some("tsconfig.json")); } // ── Rust 生态 ───────────────────────────────────────────────────────────── let toolchain_toml = dir.join("rust-toolchain.toml"); let toolchain_plain = dir.join("rust-toolchain"); if toolchain_toml.exists() || toolchain_plain.exists() { let file = if toolchain_toml.exists() { toolchain_toml } else { toolchain_plain }; let channel = read_all(&file).and_then(|content| { content .lines() .find(|l| l.trim_start().starts_with("channel")) .and_then(|l| l.splitn(2, '=').nth(1).map(|v| v.trim().trim_matches('"').to_string())) }); insert(&mut map, 0, "rustup", None, channel.clone(), Some("rust-toolchain.toml")); upsert(&mut map, 1, "Rust", Some("rustup"), channel, None); upsert(&mut map, 2, "Cargo", Some("direct"), None, None); } else if dir.join("Cargo.toml").exists() { upsert(&mut map, 1, "Rust", Some("rustup"), None, None); insert(&mut map, 2, "Cargo", Some("direct"), None, None); } // ── Go 生态 ─────────────────────────────────────────────────────────────── let go_mod = dir.join("go.mod"); if go_mod.exists() { let ver = read_all(&go_mod).and_then(|content| { content .lines() .find(|l| l.trim_start().starts_with("go ")) .map(|l| l.trim().to_string()) }); insert(&mut map, 1, "Go", Some("direct"), ver, None); } // ── Python 生态 ─────────────────────────────────────────────────────────── let python_version_file = dir.join(".python-version"); if python_version_file.exists() { let ver = read_first_line(&python_version_file); insert(&mut map, 0, "pyenv", None, None, Some(".python-version")); upsert(&mut map, 1, "Python", Some("pyenv"), ver, None); } if dir.join("poetry.lock").exists() { upsert(&mut map, 1, "Python", None, None, None); insert(&mut map, 2, "poetry", Some("direct"), None, None); } if dir.join("Pipfile").exists() { upsert(&mut map, 1, "Python", None, None, None); insert(&mut map, 2, "pipenv", Some("direct"), None, None); } if dir.join("requirements.txt").exists() || dir.join("setup.py").exists() { upsert(&mut map, 1, "Python", Some("direct"), None, None); } if dir.join("pyproject.toml").exists() { upsert(&mut map, 1, "Python", None, None, None); if let Some(content) = read_all(&dir.join("pyproject.toml")) { if content.contains("[tool.poetry]") { upsert(&mut map, 2, "poetry", Some("direct"), None, None); } } } if dir.join(".venv").exists() || dir.join("venv").exists() { upsert(&mut map, 1, "Python", None, None, Some("venv")); } // ── Java 生态 ───────────────────────────────────────────────────────────── if dir.join("pom.xml").exists() { insert(&mut map, 1, "Java", None, None, Some("pom.xml")); insert(&mut map, 2, "Maven", Some("direct"), None, None); } if dir.join("build.gradle").exists() || dir.join("build.gradle.kts").exists() { upsert(&mut map, 1, "Java", None, None, Some("build.gradle")); insert(&mut map, 2, "Gradle", Some("direct"), None, None); } // ── Ruby 生态 ───────────────────────────────────────────────────────────── let ruby_version_file = dir.join(".ruby-version"); if ruby_version_file.exists() { let ver = read_first_line(&ruby_version_file); insert(&mut map, 0, "rbenv", None, ver.clone(), Some(".ruby-version")); upsert(&mut map, 1, "Ruby", Some("rbenv"), ver, None); } else if dir.join("Gemfile").exists() { insert(&mut map, 1, "Ruby", Some("direct"), None, None); } if dir.join("Gemfile").exists() { insert(&mut map, 2, "Bundler", Some("direct"), None, None); } // ── 基础设施工具 ────────────────────────────────────────────────────────── if dir.join(".gitignore").exists() || dir.join(".git").exists() { insert(&mut map, 2, "Git", Some("direct"), None, None); } if dir.join("Dockerfile").exists() || dir.join("docker-compose.yml").exists() || dir.join("docker-compose.yaml").exists() || dir.join("compose.yml").exists() { insert(&mut map, 2, "Docker", Some("direct"), None, None); } // Claude CLI if dir.join(".claude").exists() { insert(&mut map, 2, "Claude CLI", Some("direct"), None, Some(".claude/")); } map } // ── WSL 路径转换 ────────────────────────────────────────────────────────────── fn get_wsl_distro() -> String { pool() .get() .ok() .and_then(|c| { c.query_row( "SELECT value FROM settings WHERE key = 'wsl_distro'", [], |row| row.get::<_, String>(0), ) .ok() }) .unwrap_or_else(|| "Ubuntu".to_string()) } fn wsl_to_unc(wsl_path: &str, distro: &str) -> PathBuf { // /home/user/project → \\wsl$\Ubuntu\home\user\project let stripped = wsl_path.trim_start_matches('/'); PathBuf::from(format!( "\\\\wsl$\\{}\\{}", distro, stripped.replace('/', "\\") )) } // ── Tauri 命令 ──────────────────────────────────────────────────────────────── /// 扫描项目目录的环境工具(基于配置文件指纹) #[tauri::command] pub fn scan_project_env(project_id: String, env: String) -> Result, String> { let conn = pool().get().map_err(|e| e.to_string())?; let (wsl_path, win_path): (Option, Option) = conn .query_row( "SELECT wsl_path, win_path FROM project_workspaces WHERE id = ?1", params![project_id], |row| Ok((row.get(0)?, row.get(1)?)), ) .map_err(|_| format!("Project not found: {project_id}"))?; let dir: PathBuf = match env.as_str() { "wsl" => { let path = wsl_path.ok_or("该项目没有配置 WSL 路径")?; let distro = get_wsl_distro(); wsl_to_unc(&path, &distro) } _ => PathBuf::from(win_path.ok_or("该项目没有配置 Windows 路径")?), }; if !dir.exists() { return Err(format!("目录不存在: {}", dir.display())); } let detected = scan_dir_files(&dir); let items: Vec = detected .into_iter() .enumerate() .map(|(i, ((layer, name), raw))| EnvToolItem { id: format!("{}-{}-{}-{}", project_id, env, layer, i), project_id: project_id.clone(), layer, name, source: raw.source, version: raw.version, note: raw.note, }) .collect(); Ok(items) } /// 获取已保存的项目环境工具 #[tauri::command] pub fn get_project_env_tools(project_id: String) -> Result, String> { let conn = pool().get().map_err(|e| e.to_string())?; let mut stmt = conn .prepare( "SELECT id, project_id, layer, name, source, version, note FROM project_env_tools WHERE project_id = ?1 ORDER BY layer, name", ) .map_err(|e| e.to_string())?; let rows = stmt .query_map(params![project_id], |row| { Ok(EnvToolItem { id: row.get(0)?, project_id: row.get(1)?, layer: row.get(2)?, name: row.get(3)?, source: row.get(4)?, version: row.get(5)?, note: row.get(6)?, }) }) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; Ok(rows) } /// 保存项目环境工具(先清空再写入) #[tauri::command] pub fn save_project_env_tools( project_id: String, items: Vec, ) -> Result<(), String> { let conn = pool().get().map_err(|e| e.to_string())?; conn.execute( "DELETE FROM project_env_tools WHERE project_id = ?1", params![project_id], ) .map_err(|e| e.to_string())?; for item in items { conn.execute( "INSERT INTO project_env_tools (id, project_id, layer, name, source, version, note, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, datetime('now'))", params![ item.id, project_id, item.layer, item.name, item.source, item.version, item.note, ], ) .map_err(|e| e.to_string())?; } Ok(()) }