Wave 2: 五件套打通全链路(安装/检测/配置/授权/诊断)

- 五个适配器 YAML 全字段补齐(codex/claude-code/gemini/kimi/opencode)
- agentdock-config:TOML/JSON 编解码 + 原子写 + 自动备份
- agentdock-diag:PATH/依赖版本/版本冲突/配置损坏四类规则
- agentdock-core:detectCli/previewAction/runAction 流式/readConfig/writeConfig/authStatus/diagnose
- Tauri IPC 命令 + CLI 详情页/安装确认弹窗/配置表单/诊断页
- cargo test 75 项通过(含 4 项真机 ignored 自测),前端 build 通过
This commit is contained in:
leefer
2026-08-25 08:58:38 +08:00
parent c47494180b
commit 01996a80fe
33 changed files with 9404 additions and 152 deletions
+103
View File
@@ -0,0 +1,103 @@
//! 原子写入 + 自动备份(架构 §6.1)
//!
//! 对目标文件 F
//! 1. F 存在 → 复制为 `F.bak.<时间戳>` 并返回备份路径;
//! 2. 写临时文件 `F.tmp.<pid>`
//! 3. `rename` 覆盖(Windows 用 `MoveFileEx` 替换语义由 std 的 rename 处理,覆盖已存在文件);
//! 4. 失败保留原文件与临时文件,错误上抛。
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::error::ConfigError;
/// 生成唯一后缀(时间戳 + 进程 id)。
fn suffix() -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
format!("{}.{}", millis, std::process::id())
}
/// 计算备份路径:`F.bak.<时间戳>.<pid>`。
pub fn backup_path(path: &Path) -> PathBuf {
let mut name = path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
name.push_str(".bak.");
name.push_str(&suffix());
path.with_file_name(name)
}
/// 原子写文件:先备份已存在文件(返回备份路径),再 tmp + rename。
pub fn atomic_write(path: &Path, content: &str) -> Result<Option<PathBuf>, ConfigError> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)
.map_err(|e| ConfigError::Io(format!("创建目录 {} 失败: {e}", parent.display())))?;
}
}
let backup = if path.exists() {
let bak = backup_path(path);
std::fs::copy(path, &bak)
.map_err(|e| ConfigError::Io(format!("备份 {} 失败: {e}", path.display())))?;
Some(bak)
} else {
None
};
let tmp = path.with_file_name(format!(
"{}.tmp.{}",
path.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default(),
std::process::id()
));
std::fs::write(&tmp, content)
.map_err(|e| ConfigError::Io(format!("写临时文件 {} 失败: {e}", tmp.display())))?;
// rename 覆盖已存在文件(Windows 上 std::fs::rename 对已存在目标会失败,
// 先移除目标再 rename 以保证替换语义)。
if path.exists() {
std::fs::remove_file(path)
.map_err(|e| ConfigError::Io(format!("移除旧文件 {} 失败: {e}", path.display())))?;
}
std::fs::rename(&tmp, path)
.map_err(|e| ConfigError::Io(format!("替换文件 {} 失败: {e}", path.display())))?;
Ok(backup)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn atomic_write_creates_file_without_backup() {
let dir = std::env::temp_dir().join(format!("agentdock-config-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let f = dir.join("new.toml");
let backup = atomic_write(&f, "model = \"x\"\n").unwrap();
assert!(backup.is_none());
assert_eq!(std::fs::read_to_string(&f).unwrap(), "model = \"x\"\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn atomic_write_backs_up_existing_file() {
let dir = std::env::temp_dir().join(format!("agentdock-config-bak-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("config.toml");
std::fs::write(&f, "old").unwrap();
let backup = atomic_write(&f, "new").unwrap().expect("已存在文件应产生备份");
assert!(backup.exists());
assert_eq!(std::fs::read_to_string(&f).unwrap(), "new");
assert_eq!(std::fs::read_to_string(&backup).unwrap(), "old");
let _ = std::fs::remove_dir_all(&dir);
}
}
+303
View File
@@ -0,0 +1,303 @@
//! 配置格式与统一编解码(架构 §6.2 `ConfigCodec`
//!
//! 统一以 `serde_json::Value` 作为内部表示(运行时单一真相)。TOML 通过
//! `toml::Value` 桥接(双向转换),JSON/JSONC 直接用 serde_json。yaml / crushrc
//! 属 Wave 3 工具,本波返回明确的「未实现」错误,不静默吞数据。
use serde_json::{Map, Value};
use crate::error::ConfigError;
/// 支持的配置格式。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigFormat {
Toml,
Json,
Jsonc,
Yaml,
Env,
Crushrc,
}
impl ConfigFormat {
pub fn from_str(s: &str) -> ConfigFormat {
match s {
"toml" => ConfigFormat::Toml,
"json" => ConfigFormat::Json,
"jsonc" => ConfigFormat::Jsonc,
"yaml" => ConfigFormat::Yaml,
"env" => ConfigFormat::Env,
"crushrc" => ConfigFormat::Crushrc,
_ => ConfigFormat::Json,
}
}
}
/// 解析配置文本为内部表示。
pub fn parse(format: ConfigFormat, text: &str) -> Result<Value, ConfigError> {
match format {
ConfigFormat::Json | ConfigFormat::Jsonc => {
// jsonc:剥离 // 与 /* */ 注释后按 JSON 解析(不追求极致,覆盖常见注释)
let text = strip_jsonc_comments(text);
serde_json::from_str(&text).map_err(|e| ConfigError::Parse(e.to_string()))
}
ConfigFormat::Toml => {
let tv: toml::Value = toml::from_str(text).map_err(|e| ConfigError::Parse(e.to_string()))?;
Ok(toml_to_json(&tv))
}
ConfigFormat::Env => parse_env(text),
ConfigFormat::Yaml | ConfigFormat::Crushrc => Err(ConfigError::Unsupported(format!(
"{:?} 格式本波(Wave 2)未实现,将在后续波次接入",
format
))),
}
}
/// 序列化内部表示为配置文本。
pub fn serialize(format: ConfigFormat, value: &Value) -> Result<String, ConfigError> {
match format {
ConfigFormat::Json | ConfigFormat::Jsonc => serde_json::to_string_pretty(value)
.map_err(|e| ConfigError::Parse(e.to_string())),
ConfigFormat::Toml => {
let tv = json_to_toml(value);
toml::to_string(&tv).map_err(|e| ConfigError::Parse(e.to_string()))
}
ConfigFormat::Env => serialize_env(value),
ConfigFormat::Yaml | ConfigFormat::Crushrc => Err(ConfigError::Unsupported(format!(
"{:?} 格式本波(Wave 2)未实现,将在后续波次接入",
format
))),
}
}
/// 按点分路径(如 `model` / `model.name` / `env.ANTHROPIC_API_KEY`)读取值。
pub fn get_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
if path.is_empty() {
return Some(value);
}
let mut cur = value;
for seg in path.split('.') {
match cur {
Value::Object(map) => cur = map.get(seg)?,
_ => return None,
}
}
Some(cur)
}
/// 按点分路径写入值,缺失的中间对象自动创建。
pub fn set_path(value: &mut Value, path: &str, new: Value) -> Result<(), ConfigError> {
let segs: Vec<&str> = path.split('.').filter(|s| !s.is_empty()).collect();
if segs.is_empty() {
return Err(ConfigError::InvalidPath("字段路径不能为空".into()));
}
set_path_impl(value, &segs, new)
}
fn set_path_impl(value: &mut Value, segs: &[&str], new: Value) -> Result<(), ConfigError> {
if !value.is_object() {
*value = Value::Object(Map::new());
}
let map = value
.as_object_mut()
.ok_or_else(|| ConfigError::InvalidPath("路径中间节点不是对象".into()))?;
let seg = segs[0];
if segs.len() == 1 {
map.insert(seg.to_string(), new);
return Ok(());
}
if !map.contains_key(seg) {
map.insert(seg.to_string(), Value::Object(Map::new()));
}
let next = map.get_mut(seg).unwrap();
set_path_impl(next, &segs[1..], new)
}
// ---- TOML <-> JSON 双向桥接 ----
fn toml_to_json(v: &toml::Value) -> Value {
match v {
toml::Value::String(s) => Value::String(s.clone()),
toml::Value::Integer(i) => Value::Number((*i).into()),
toml::Value::Float(f) => serde_json::Number::from_f64(*f)
.map(Value::Number)
.unwrap_or(Value::Null),
toml::Value::Boolean(b) => Value::Bool(*b),
toml::Value::Datetime(d) => Value::String(d.to_string()),
toml::Value::Array(a) => Value::Array(a.iter().map(toml_to_json).collect()),
toml::Value::Table(t) => {
let mut m = Map::new();
for (k, v) in t {
m.insert(k.clone(), toml_to_json(v));
}
Value::Object(m)
}
}
}
fn json_to_toml(v: &Value) -> toml::Value {
match v {
Value::String(s) => toml::Value::String(s.clone()),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
toml::Value::Integer(i)
} else if let Some(f) = n.as_f64() {
toml::Value::Float(f)
} else {
toml::Value::String(n.to_string())
}
}
Value::Bool(b) => toml::Value::Boolean(*b),
Value::Array(a) => toml::Value::Array(a.iter().map(json_to_toml).collect()),
Value::Object(m) => {
let mut t = toml::map::Map::new();
for (k, v) in m {
t.insert(k.clone(), json_to_toml(v));
}
toml::Value::Table(t)
}
Value::Null => toml::Value::String(String::new()),
}
}
// ---- env 格式(KEY=VALUE----
fn parse_env(text: &str) -> Result<Value, ConfigError> {
let mut m = Map::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((k, v)) = line.split_once('=') {
let v = v.trim();
let v = v.trim_matches('"').trim_matches('\'');
m.insert(k.trim().to_string(), Value::String(v.to_string()));
}
}
Ok(Value::Object(m))
}
fn serialize_env(value: &Value) -> Result<String, ConfigError> {
let obj = value.as_object().ok_or_else(|| {
ConfigError::Parse("env 格式要求顶层为对象".into())
})?;
let mut out = String::new();
for (k, v) in obj {
if let Some(s) = v.as_str() {
out.push_str(&format!("{k}={s}\n"));
}
}
Ok(out)
}
/// 剥离 JSONC 的 // 与 /* */ 注释(保守处理,不处理字符串内的注释序列)。
fn strip_jsonc_comments(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let chars: Vec<char> = text.chars().collect();
let mut i = 0;
let n = chars.len();
let mut in_string = false;
let mut escaped = false;
while i < n {
let c = chars[i];
if in_string {
out.push(c);
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == '"' {
in_string = false;
}
i += 1;
continue;
}
if c == '"' {
in_string = true;
out.push(c);
i += 1;
continue;
}
if c == '/' && i + 1 < n && chars[i + 1] == '/' {
while i < n && chars[i] != '\n' {
i += 1;
}
continue;
}
if c == '/' && i + 1 < n && chars[i + 1] == '*' {
i += 2;
while i + 1 < n && !(chars[i] == '*' && chars[i + 1] == '/') {
i += 1;
}
i += 2;
continue;
}
out.push(c);
i += 1;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn toml_roundtrip() {
let text = "model = \"gpt-5\"\n[model_providers.proxy]\nbase_url = \"http://proxy\"\n";
let v = parse(ConfigFormat::Toml, text).unwrap();
assert_eq!(get_path(&v, "model").and_then(|x| x.as_str()), Some("gpt-5"));
assert_eq!(
get_path(&v, "model_providers.proxy.base_url").and_then(|x| x.as_str()),
Some("http://proxy")
);
let out = serialize(ConfigFormat::Toml, &v).unwrap();
assert!(out.contains("gpt-5"));
}
#[test]
fn json_roundtrip() {
let text = r#"{ "model": "claude-sonnet", "env": { "ANTHROPIC_API_KEY": "x" } }"#;
let v = parse(ConfigFormat::Json, text).unwrap();
assert_eq!(get_path(&v, "model").and_then(|x| x.as_str()), Some("claude-sonnet"));
let out = serialize(ConfigFormat::Json, &v).unwrap();
assert!(out.contains("claude-sonnet"));
}
#[test]
fn jsonc_strips_comments() {
let text = "{\n // 注释\n \"model\": \"opencode\" /* 行尾 */\n}";
let v = parse(ConfigFormat::Jsonc, text).unwrap();
assert_eq!(get_path(&v, "model").and_then(|x| x.as_str()), Some("opencode"));
}
#[test]
fn set_path_creates_nested() {
let mut v = json!({});
set_path(&mut v, "env.ANTHROPIC_BASE_URL", json!("https://proxy")).unwrap();
set_path(&mut v, "model", json!("claude")).unwrap();
assert_eq!(
get_path(&v, "env.ANTHROPIC_BASE_URL").and_then(|x| x.as_str()),
Some("https://proxy")
);
assert_eq!(get_path(&v, "model").and_then(|x| x.as_str()), Some("claude"));
}
#[test]
fn unsupported_format_is_explicit() {
assert!(matches!(
parse(ConfigFormat::Crushrc, "foo=bar"),
Err(ConfigError::Unsupported(_))
));
}
#[test]
fn env_roundtrip() {
let v = parse(ConfigFormat::Env, "# c\nGEMINI_API_KEY=\"sk-x\"\n").unwrap();
assert_eq!(get_path(&v, "GEMINI_API_KEY").and_then(|x| x.as_str()), Some("sk-x"));
let out = serialize(ConfigFormat::Env, &v).unwrap();
assert!(out.contains("GEMINI_API_KEY=sk-x"));
}
}
+26
View File
@@ -0,0 +1,26 @@
//! 配置层错误类型(中文,不含密钥明文)
use std::fmt;
#[derive(Debug)]
pub enum ConfigError {
Io(String),
Parse(String),
/// 不支持的配置格式(本波未实现)
Unsupported(String),
/// 字段路径非法
InvalidPath(String),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::Io(m) => write!(f, "IO: {m}"),
ConfigError::Parse(m) => write!(f, "解析失败: {m}"),
ConfigError::Unsupported(m) => write!(f, "不支持: {m}"),
ConfigError::InvalidPath(m) => write!(f, "路径非法: {m}"),
}
}
}
impl std::error::Error for ConfigError {}
+55 -7
View File
@@ -1,18 +1,66 @@
//! agentdock-config —— 配置读写层(架构 §6)
//!
//! 职责:原子写入 + 自动备份、多格式统一编解码(ConfigCodec trait
//! toml / json / jsonc / yaml / env / crushrc)。
//! Wave 0:空骨架,随 Wave 1/2 落地。
//! 职责:多格式编解码(`codec`)、原子写入 + 自动备份(`atomic`)、
//! 配置文件路径解析(`~` 与平台变量展开)。
/// 配置层能力标记(占位)
pub const LAYER: &str = "agentdock-config";
pub mod atomic;
pub mod codec;
pub mod error;
pub use atomic::{atomic_write, backup_path};
pub use codec::{ConfigFormat, get_path, parse, serialize, set_path};
pub use error::ConfigError;
use std::path::PathBuf;
/// 解析适配器声明的配置文件路径:展开 `~`(用户主目录),
/// 并把 Windows 路径分隔符统一处理(YAML 里写的是 `~/.codex/...`)。
pub fn resolve_path(raw: &str) -> PathBuf {
let expanded = expand_home(raw);
PathBuf::from(expanded)
}
/// 展开前导 `~` 为用户主目录(Windows 用 USERPROFILELinux 用 HOME)。
pub fn expand_home(raw: &str) -> String {
if raw == "~" {
return home_dir();
}
if let Some(rest) = raw.strip_prefix("~/") {
return format!("{}{}{}", home_dir(), std::path::MAIN_SEPARATOR, rest);
}
if let Some(rest) = raw.strip_prefix("~\\") {
return format!("{}{}{}", home_dir(), std::path::MAIN_SEPARATOR, rest);
}
raw.to_string()
}
/// 用户主目录(含路径分隔符兜底)。
pub fn home_dir() -> String {
#[cfg(windows)]
{
std::env::var("USERPROFILE").unwrap_or_else(|_| ".".to_string())
}
#[cfg(not(windows))]
{
std::env::var("HOME").unwrap_or_else(|_| ".".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layer_identity() {
assert_eq!(LAYER, "agentdock-config");
fn expand_home_prefix() {
let out = expand_home("~/.codex/config.toml");
assert!(!out.starts_with("~/"));
assert!(out.ends_with("config.toml"));
let bare = expand_home("~");
assert!(!bare.starts_with("~/"));
}
#[test]
fn plain_path_unchanged() {
assert_eq!(expand_home("/etc/codex/config.toml"), "/etc/codex/config.toml");
}
}