use std::path::{Path, PathBuf}; use regex::Regex; use crate::paths::{copy_tree, dsh_home, BundledPaths}; pub const PRESET_ID: &str = "anchored-standard"; pub const ZERO_PRESET_ID: &str = "zero-anchored-standard"; pub const SOURCE_MARKER: &str = ".dsh-desktop-source"; pub const DEFAULT_BLOCK: &str = "agent-presets:\n default: anchored-standard\n"; pub const PRESET_NAME_ZH: &str = "锚定式标准(实验)"; pub const PRESET_DESCRIPTION_ZH: &str = "首轮使用 Minimal 的真实工具对(持久 bash + str_replace_editor),不自动注入工作区或技能上下文;首次工具调用或回复后开放完整 Standard 工具。"; pub const ZERO_PRESET_NAME_ZH: &str = "零工具锚定式标准(实验)"; pub const ZERO_PRESET_DESCRIPTION_ZH: &str = "先插入一轮无工具的锚定对话(固定提示),从下一轮起开放完整 Standard 工具。"; #[derive(Debug, Clone)] pub struct BundledPreset { pub preset_id: &'static str, pub name_zh: &'static str, pub description_zh: &'static str, } pub const BUNDLED_PRESETS: &[BundledPreset] = &[ BundledPreset { preset_id: PRESET_ID, name_zh: PRESET_NAME_ZH, description_zh: PRESET_DESCRIPTION_ZH, }, BundledPreset { preset_id: ZERO_PRESET_ID, name_zh: ZERO_PRESET_NAME_ZH, description_zh: ZERO_PRESET_DESCRIPTION_ZH, }, ]; #[derive(Debug, Clone)] pub struct PresetEnsureResult { pub status: &'static str, pub version: Option, pub message: String, } pub fn preset_install_dir(preset_id: &str) -> PathBuf { dsh_home().join(".agent-presets").join(preset_id) } fn is_preset_dir(path: &Path) -> bool { path.is_dir() && path.join("preset.yml").is_file() } pub fn read_source_version(path: &Path) -> Option { let version = std::fs::read_to_string(path.join(SOURCE_MARKER)).ok()?; let version = version.trim(); if version.is_empty() { None } else { Some(version.to_string()) } } pub fn bundled_preset_dir(paths: &BundledPaths, preset_id: &str) -> Option { paths .find_dir(preset_id, "preset.yml") .or_else(|| paths.find_dir(&format!("vendor/{preset_id}"), "preset.yml")) .filter(|p| is_preset_dir(p)) } pub fn ensure_default_preset(text: &str) -> String { let mut body = text.replace("\r\n", "\n"); if !body.ends_with('\n') { body.push('\n'); } if body.trim().is_empty() { return DEFAULT_BLOCK.to_string(); } let re = Regex::new(r"(?m)^agent-presets:\n((?:[ \t]+.*\n)*)").unwrap(); let Some(caps) = re.captures(&body) else { body.push('\n'); body.push_str(DEFAULT_BLOCK); return body; }; let inner = caps.get(1).unwrap().as_str(); if Regex::new(r"(?m)^[ \t]+default:\s*\S+") .unwrap() .is_match(inner) { return body; } let insert = format!(" default: {PRESET_ID}\n"); let start = caps.get(1).unwrap().start(); let end = caps.get(1).unwrap().end(); format!("{}{}{}{}", &body[..start], insert, inner, &body[end..]) } pub fn localize_preset_yml(text: &str, name: &str, description: &str) -> String { let mut body = text.replace("\r\n", "\n"); if !body.ends_with('\n') { body.push('\n'); } let name_line = format!("name: {}\n", serde_json::to_string(name).unwrap()); let desc_line = format!( "description: {}\n", serde_json::to_string(description).unwrap() ); let name_re = Regex::new(r"(?m)^name:.*\n").unwrap(); if let Some(m) = name_re.find(&body) { body = format!("{}{}{}", &body[..m.start()], name_line, &body[m.end()..]); } else { body = format!("{name_line}{body}"); } let desc_re = Regex::new(r"(?m)^description:(?:[ \t].*)?\n(?:[ \t].+\n)*").unwrap(); if let Some(m) = desc_re.find(&body) { return format!("{}{}{}", &body[..m.start()], desc_line, &body[m.end()..]); } if let Some(m) = name_re.find(&body) { return format!("{}{}{}", &body[..m.end()], desc_line, &body[m.end()..]); } format!("{desc_line}{body}") } fn write_localized_preset(dest: &Path, spec: &BundledPreset) -> std::io::Result<()> { let path = dest.join("preset.yml"); let previous = std::fs::read_to_string(&path)?; let updated = localize_preset_yml(&previous, spec.name_zh, spec.description_zh); if updated != previous { std::fs::write(path, updated)?; } Ok(()) } fn short_version(version: Option<&str>) -> String { match version { None => "bundled".into(), Some(v) if v.len() > 12 => v[..12].into(), Some(v) => v.into(), } } fn ensure_one(paths: &BundledPaths, spec: &BundledPreset) -> PresetEnsureResult { let src = bundled_preset_dir(paths, spec.preset_id); let dest = preset_install_dir(spec.preset_id); let bundled = src.as_ref().and_then(|p| read_source_version(p)); let installed = if dest.is_dir() { read_source_version(&dest) } else { None }; let version = bundled.clone().or_else(|| installed.clone()); let label = spec.name_zh; if src.is_none() { if dest.is_dir() && dest.join("preset.yml").is_file() { let _ = write_localized_preset(&dest, spec); return PresetEnsureResult { status: "current", version: version.clone(), message: format!( "已配置已安装的{label}({})", short_version(version.as_deref()) ), }; } return PresetEnsureResult { status: "skipped", version: None, message: format!("未找到内置{label},跳过安装"), }; } let src = src.unwrap(); if let Err(exc) = (|| -> std::io::Result<()> { if installed != bundled || !dest.join("preset.yml").is_file() { copy_tree(&src, &dest, false)?; } write_localized_preset(&dest, spec)?; Ok(()) })() { return PresetEnsureResult { status: "failed", version, message: format!("内置{label}安装失败:{exc}"), }; } let sha = short_version(bundled.as_deref()); if installed == bundled && dest.join("preset.yml").is_file() { return PresetEnsureResult { status: "current", version: bundled, message: format!("内置{label} {sha} 已就绪"), }; } if installed.is_some() { return PresetEnsureResult { status: "updated", version: bundled, message: format!("已将内置{label}更新到 {sha}"), }; } PresetEnsureResult { status: "installed", version: bundled, message: format!("已启用内置{label} {sha}"), } } fn combine(results: &[PresetEnsureResult]) -> PresetEnsureResult { if results.iter().any(|item| item.status == "failed") { let failed: Vec<_> = results .iter() .filter(|item| item.status == "failed") .map(|item| item.message.clone()) .collect(); return PresetEnsureResult { status: "failed", version: None, message: failed.join(";"), }; } let active: Vec<_> = results .iter() .filter(|item| item.status != "skipped") .collect(); if active.is_empty() { return PresetEnsureResult { status: "skipped", version: None, message: "未找到内置锚定预设,跳过安装".into(), }; } let version = active.iter().find_map(|item| item.version.clone()); let (status, lead) = if active.iter().any(|item| item.status == "updated") { ("updated", "已更新内置锚定预设") } else if active.iter().any(|item| item.status == "installed") { ("installed", "已启用内置锚定预设") } else { ("current", "内置锚定预设已就绪") }; let detail = active .iter() .map(|item| item.message.as_str()) .collect::>() .join(";"); PresetEnsureResult { status, version, message: format!("{lead}。{detail}"), } } pub fn ensure_anchored_standard(paths: &BundledPaths) -> PresetEnsureResult { let results: Vec<_> = BUNDLED_PRESETS .iter() .map(|spec| ensure_one(paths, spec)) .collect(); let combined = combine(&results); if combined.status == "failed" { return combined; } let settings = dsh_home().join("settings.yaml"); match (|| -> std::io::Result<()> { let previous = if settings.is_file() { std::fs::read_to_string(&settings)? } else { String::new() }; let updated = ensure_default_preset(&previous); if updated != previous { if let Some(parent) = settings.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(&settings, updated)?; } Ok(()) })() { Ok(()) => combined, Err(exc) => PresetEnsureResult { status: "failed", version: combined.version, message: format!("写入默认 preset 失败:{exc}"), }, } } #[cfg(test)] mod tests { use super::*; use crate::paths::BundledPaths; #[test] fn empty_file_gets_default_block() { assert_eq!( ensure_default_preset(""), "agent-presets:\n default: anchored-standard\n" ); } #[test] fn appends_when_section_missing() { let updated = ensure_default_preset("ui-theme:\n preference: dark\n"); assert!(updated.contains("preference: dark")); assert!(updated.contains("agent-presets:\n default: anchored-standard\n")); } #[test] fn inserts_default_into_empty_section() { let updated = ensure_default_preset("agent-presets:\nui-theme:\n preference: dark\n"); assert!(updated.contains("agent-presets:\n default: anchored-standard\n")); assert!(updated.contains("preference: dark")); } #[test] fn keeps_existing_default() { let original = "agent-presets:\n default: standard\n"; assert_eq!(ensure_default_preset(original), original); } #[test] fn does_not_match_permission_default_preset() { let original = "permission:\n defaultPreset: danger-full-access\n"; let updated = ensure_default_preset(original); assert!(updated.contains("defaultPreset: danger-full-access")); assert!(updated.contains("agent-presets:\n default: anchored-standard\n")); } #[test] fn replaces_english_name_and_description() { let original = "\ name: Anchored Standard (experimental) description: Bootstrap with the Minimal preset's real tool pair. order: 5 "; let updated = localize_preset_yml(original, PRESET_NAME_ZH, PRESET_DESCRIPTION_ZH); assert!(updated.contains(PRESET_NAME_ZH)); assert!(updated.contains(PRESET_DESCRIPTION_ZH)); assert!(!updated.contains("Anchored Standard (experimental)")); assert!(!updated.contains("Bootstrap")); assert!(updated.contains("order: 5")); } #[test] fn inserts_description_after_name() { let updated = localize_preset_yml( "name: Anchored Standard\norder: 5\n", PRESET_NAME_ZH, PRESET_DESCRIPTION_ZH, ); assert!(updated.contains(PRESET_NAME_ZH)); assert!(!updated.contains("name: Anchored Standard\n")); assert!(updated.contains(PRESET_DESCRIPTION_ZH)); assert!(updated.contains("order: 5")); } #[test] fn installs_from_bundle_and_sets_default() { let tmp = tempfile::TempDir::new().unwrap(); let src = tmp.path().join("anchored-standard"); std::fs::create_dir_all(&src).unwrap(); std::fs::write(src.join("preset.yml"), "name: Anchored Standard\n").unwrap(); std::fs::write(src.join(SOURCE_MARKER), "abc123def456\n").unwrap(); let home = tmp.path().join(".dsh"); std::env::set_var("DSH_HOME", &home); let paths = BundledPaths::discover().with_resource_dir(tmp.path().to_path_buf()); let result = ensure_anchored_standard(&paths); std::env::remove_var("DSH_HOME"); assert_eq!(result.status, "installed"); let dest = home.join(".agent-presets").join(PRESET_ID); assert!(dest.join("preset.yml").is_file()); let yml = std::fs::read_to_string(dest.join("preset.yml")).unwrap(); assert!(yml.contains(PRESET_DESCRIPTION_ZH)); let settings = std::fs::read_to_string(home.join("settings.yaml")).unwrap(); assert!(settings.contains("default: anchored-standard")); } #[test] fn localizes_zero_preset() { let original = "\ name: Zero-Anchored Standard (experimental) description: Inject one zero-tool anchor turn. order: 6 "; let updated = localize_preset_yml(original, ZERO_PRESET_NAME_ZH, ZERO_PRESET_DESCRIPTION_ZH); assert!(updated.contains(ZERO_PRESET_NAME_ZH)); assert!(updated.contains(ZERO_PRESET_DESCRIPTION_ZH)); assert!(!updated.contains("Zero-Anchored")); } }