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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 用 USERPROFILE,Linux 用 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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user