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:
@@ -3,5 +3,9 @@ name = "agentdock-config"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "配置读写:多格式编解码(TOML/JSON)+ 原子写入 + 自动备份(架构 §6)"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,15 @@ name = "agentdock-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "编排层:发现/安装/配置/授权/诊断流水线(架构 §2 核心编排)"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
regex = "1"
|
||||
agentdock-adapter = { path = "../agentdock-adapter" }
|
||||
agentdock-config = { path = "../agentdock-config" }
|
||||
agentdock-secrets = { path = "../agentdock-secrets" }
|
||||
agentdock-exec = { path = "../agentdock-exec" }
|
||||
agentdock-diag = { path = "../agentdock-diag" }
|
||||
agentdock-platform = { path = "../agentdock-platform" }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
//! 编排层错误类型(中文)
|
||||
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum EngineError {
|
||||
/// 未找到指定 CLI
|
||||
NotFound(String),
|
||||
/// 适配器层错误
|
||||
Adapter(String),
|
||||
/// 配置层错误
|
||||
Config(String),
|
||||
/// 进程执行错误
|
||||
Exec(String),
|
||||
/// 密钥库错误
|
||||
Secrets(String),
|
||||
/// 能力本波未实现
|
||||
NotSupported(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for EngineError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
EngineError::NotFound(m) => write!(f, "未找到: {m}"),
|
||||
EngineError::Adapter(m) => write!(f, "适配器: {m}"),
|
||||
EngineError::Config(m) => write!(f, "配置: {m}"),
|
||||
EngineError::Exec(m) => write!(f, "执行: {m}"),
|
||||
EngineError::Secrets(m) => write!(f, "密钥库: {m}"),
|
||||
EngineError::NotSupported(m) => write!(f, "未支持: {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for EngineError {}
|
||||
|
||||
impl From<agentdock_config::ConfigError> for EngineError {
|
||||
fn from(e: agentdock_config::ConfigError) -> Self {
|
||||
EngineError::Config(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<agentdock_exec::ExecError> for EngineError {
|
||||
fn from(e: agentdock_exec::ExecError) -> Self {
|
||||
EngineError::Exec(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for EngineError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
EngineError::Exec(e.to_string())
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
//! agentdock-core —— 编排层(架构 §2 模块关系:UI --invoke--> commands --→ core orchestrator)
|
||||
//!
|
||||
//! 职责:发现 / 安装 / 配置 / 授权 / 诊断 流水线编排。
|
||||
//! Wave 0:空骨架,流水线自 Wave 1 起逐波落地。
|
||||
//! 把 adapter/config/secrets/exec/diag/platform 串成完整流水线:
|
||||
//! detectCli / previewAction / runAction / readConfig / writeConfig /
|
||||
//! authStatus / authorize / diagnose。
|
||||
|
||||
/// 编排层能力标记(占位)
|
||||
pub const LAYER: &str = "agentdock-core";
|
||||
pub mod engine;
|
||||
pub mod error;
|
||||
pub mod process;
|
||||
pub mod types;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layer_identity() {
|
||||
assert_eq!(LAYER, "agentdock-core");
|
||||
}
|
||||
}
|
||||
pub use engine::{ActionOpts, Engine, auth_status, authorize, detect_one, diagnose, parse_version, read_config, run_action, write_config};
|
||||
pub use error::EngineError;
|
||||
pub use types::{
|
||||
ActionEvent, AuthStatus, ConfigFieldState, ConfigFileState, ConfigFormState, DetectResult,
|
||||
EnvFieldState, WriteResult, now_secs,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! 进程探测与流式执行辅助(内部)
|
||||
//!
|
||||
//! 所有命令经 `agentdock-exec::validate_argv` 校验后执行,禁止 shell 拼接。
|
||||
//! 探测类命令(`--version`)隐藏控制台窗口(Windows CREATE_NO_WINDOW)。
|
||||
|
||||
use std::io::BufRead;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// 按平台分隔符拆分 PATH。
|
||||
fn path_entries() -> Vec<String> {
|
||||
std::env::var("PATH")
|
||||
.unwrap_or_default()
|
||||
.split(if cfg!(windows) { ';' } else { ':' })
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 在 PATH 中查找可执行文件(返回全部命中,用于版本冲突检测)。
|
||||
/// Windows 优先 .exe/.cmd/.bat,最后回退无扩展名。
|
||||
pub fn which_all(program: &str) -> Vec<PathBuf> {
|
||||
let direct = Path::new(program);
|
||||
if direct.is_absolute() && direct.is_file() {
|
||||
return vec![direct.to_path_buf()];
|
||||
}
|
||||
let exts: &[&str] = if cfg!(windows) { &[".exe", ".cmd", ".bat", ""] } else { &[""] };
|
||||
let mut found = Vec::new();
|
||||
for dir in path_entries() {
|
||||
for ext in exts {
|
||||
let cand = Path::new(&dir).join(format!("{program}{ext}"));
|
||||
if cand.is_file() && !found.contains(&cand) {
|
||||
found.push(cand);
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// 运行只读探测命令并返回 (exit_ok, stdout+stderr 合并文本)。
|
||||
pub fn run_capture(exe: &Path, args: &[String]) -> std::io::Result<(bool, String)> {
|
||||
let mut cmd = Command::new(exe);
|
||||
cmd.args(args);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
}
|
||||
let out = cmd.output()?;
|
||||
let mut text = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
if text.trim().is_empty() {
|
||||
text = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
}
|
||||
Ok((out.status.success(), text))
|
||||
}
|
||||
|
||||
/// 流式执行命令,逐行回调(stdout 与 stderr 均按行输出)。返回是否成功。
|
||||
/// 顺序读:先 stdout 后 stderr;对长任务足够,且实现简单可靠、无闭包 Send 负担。
|
||||
pub fn run_streaming<F>(exe: &str, args: &[String], mut on_line: F) -> std::io::Result<bool>
|
||||
where
|
||||
F: FnMut(bool, &str),
|
||||
{
|
||||
let mut cmd = Command::new(exe);
|
||||
cmd.args(args);
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
}
|
||||
let mut child = cmd.spawn()?;
|
||||
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
on_line(false, &line);
|
||||
}
|
||||
}
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let reader = std::io::BufReader::new(stderr);
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
on_line(true, &line);
|
||||
}
|
||||
}
|
||||
|
||||
let status = child.wait()?;
|
||||
Ok(status.success())
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! IPC 契约数据类型(对齐架构 §2「关键 IPC 契约」)
|
||||
//!
|
||||
//! 字段命名与前端 TS 类型一一对应(serde 输出 snake_case)。
|
||||
//! 所有时间戳统一用 Unix 秒(u64),由前端做相对时间展示,后端不做日期数学。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 检测结果(detectCli)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DetectResult {
|
||||
pub cli_id: String,
|
||||
/// installed | not_installed | not_in_path | permission_denied | exec_failed | version_unparseable
|
||||
pub status: String,
|
||||
pub version: Option<String>,
|
||||
/// 解析到的可执行文件绝对路径
|
||||
pub executable: Option<String>,
|
||||
/// 适配器声明「版本检测文档未确认」(UI 显示「实测兜底」)
|
||||
pub version_unconfirmed: bool,
|
||||
/// Unix 秒
|
||||
pub checked_at: u64,
|
||||
}
|
||||
|
||||
/// 授权状态(authStatus)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthStatus {
|
||||
pub cli_id: String,
|
||||
/// authorized | unauthorized | unknown | possibly_expired
|
||||
pub status: String,
|
||||
/// 结论来源:status_command | keyring | unknown
|
||||
pub via: String,
|
||||
pub detail_zh: String,
|
||||
pub checked_at: u64,
|
||||
}
|
||||
|
||||
/// 单个配置字段的读取状态(readConfig)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConfigFieldState {
|
||||
pub id: String,
|
||||
pub label_zh: String,
|
||||
pub help_zh: Option<String>,
|
||||
pub required: bool,
|
||||
pub sensitive: bool,
|
||||
/// string | url | enum | bool
|
||||
pub field_type: String,
|
||||
/// file | env | keyring
|
||||
pub storage: String,
|
||||
/// 敏感字段永不明文回显;值为 None 时结合 has_value 展示「已保存 / 未保存」
|
||||
pub value: Option<String>,
|
||||
/// 密钥库类字段:是否已有值
|
||||
pub has_value: bool,
|
||||
}
|
||||
|
||||
/// 配置文件解析状态。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConfigFileState {
|
||||
/// 解析后的绝对路径
|
||||
pub path: String,
|
||||
pub format: String,
|
||||
pub scope: Option<String>,
|
||||
pub exists: bool,
|
||||
pub parse_ok: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 环境变量映射(仅供说明与脱敏,本波不注入执行环境)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EnvFieldState {
|
||||
pub key: String,
|
||||
pub sensitive: bool,
|
||||
pub maps_to_field: Option<String>,
|
||||
}
|
||||
|
||||
/// 配置表单状态(readConfig 返回)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConfigFormState {
|
||||
pub cli_id: String,
|
||||
pub files: Vec<ConfigFileState>,
|
||||
pub fields: Vec<ConfigFieldState>,
|
||||
pub environment: Vec<EnvFieldState>,
|
||||
}
|
||||
|
||||
/// 写配置结果(writeConfig 返回)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WriteResult {
|
||||
/// 自动备份路径(无备份时为 None)
|
||||
pub backup_path: Option<String>,
|
||||
/// 实际写入的配置文件路径
|
||||
pub written_file: Option<String>,
|
||||
/// 成功写入的字段 id
|
||||
pub written_fields: Vec<String>,
|
||||
/// 逐字段错误(key=field id, value=中文错误)
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// 动作流事件(runAction 流式输出)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActionEvent {
|
||||
/// step | stdout | stderr | done | error
|
||||
pub kind: String,
|
||||
/// 已脱敏的消息文本
|
||||
pub message: String,
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl ActionEvent {
|
||||
pub fn step(msg: impl Into<String>) -> Self {
|
||||
ActionEvent { kind: "step".into(), message: msg.into(), data: None }
|
||||
}
|
||||
pub fn stdout(line: impl Into<String>) -> Self {
|
||||
ActionEvent { kind: "stdout".into(), message: line.into(), data: None }
|
||||
}
|
||||
pub fn stderr(line: impl Into<String>) -> Self {
|
||||
ActionEvent { kind: "stderr".into(), message: line.into(), data: None }
|
||||
}
|
||||
pub fn done(data: Option<serde_json::Value>) -> Self {
|
||||
ActionEvent { kind: "done".into(), message: String::new(), data }
|
||||
}
|
||||
pub fn error(msg: impl Into<String>) -> Self {
|
||||
ActionEvent { kind: "error".into(), message: msg.into(), data: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前 Unix 秒。
|
||||
pub fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -3,5 +3,7 @@ name = "agentdock-diag"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "诊断规则引擎:PATH/依赖/版本冲突/配置损坏 四类检查(架构 §10)"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -1,17 +1,224 @@
|
||||
//! agentdock-diag —— 诊断规则引擎(架构 §10)
|
||||
//!
|
||||
//! 职责:PATH / 依赖缺失 / 版本冲突 / 配置损坏 四类检查的规则引擎。
|
||||
//! Wave 0:空骨架,随 Wave 2 落地。
|
||||
//! 四类必检:PATH / 依赖版本 / 版本冲突 / 配置损坏。每个检查器都是纯函数:
|
||||
//! 输入真实探测结果与声明阈值,输出 `Option<Finding>`。规则本身不依赖适配器
|
||||
//! 或进程执行(由 core 编排层传入数据),便于单元测试与「构造缺失场景」验证。
|
||||
|
||||
/// 诊断层能力标记(占位)
|
||||
pub const LAYER: &str = "agentdock-diag";
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 诊断级别(对齐视觉规范 §3.5 四级 + 六色语义)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Severity {
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl Severity {
|
||||
pub fn label_zh(&self) -> &'static str {
|
||||
match self {
|
||||
Severity::Info => "提示",
|
||||
Severity::Warn => "警告",
|
||||
Severity::Error => "错误",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条诊断结论(架构 §10.1:级别 + 证据 + 中文解释)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Finding {
|
||||
/// 规则 id(如 path.not_installed / dependency.node_below_min)
|
||||
pub rule_id: String,
|
||||
pub severity: Severity,
|
||||
pub message_zh: String,
|
||||
/// 原始证据(命令输出 / 路径 / 版本),脱敏后落盘
|
||||
pub evidence: Option<String>,
|
||||
}
|
||||
|
||||
/// 一份诊断报告(面向单个 CLI)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiagnosticReport {
|
||||
pub cli_id: String,
|
||||
pub findings: Vec<Finding>,
|
||||
}
|
||||
|
||||
impl DiagnosticReport {
|
||||
pub fn new(cli_id: &str) -> Self {
|
||||
DiagnosticReport { cli_id: cli_id.to_string(), findings: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn push(&mut self, f: Finding) {
|
||||
self.findings.push(f);
|
||||
}
|
||||
}
|
||||
|
||||
/// PATH 类:可执行文件状态。
|
||||
/// `status` 取 detect 结果:installed / not_installed / not_in_path / version_unparseable / exec_failed / permission_denied。
|
||||
pub fn check_path(cli_name_zh: &str, executable: &str, status: &str) -> Option<Finding> {
|
||||
match status {
|
||||
"not_installed" => Some(Finding {
|
||||
rule_id: "path.not_installed".into(),
|
||||
severity: Severity::Warn,
|
||||
message_zh: format!("未检测到 {cli_name_zh} 的可执行文件({executable})"),
|
||||
evidence: Some(format!("在 PATH 中未找到 {executable}")),
|
||||
}),
|
||||
"not_in_path" => Some(Finding {
|
||||
rule_id: "path.not_in_path".into(),
|
||||
severity: Severity::Warn,
|
||||
message_zh: format!("{cli_name_zh} 已安装但不在 PATH 中({executable})"),
|
||||
evidence: Some(format!("找到 {executable},但所在目录未加入 PATH")),
|
||||
}),
|
||||
"version_unparseable" => Some(Finding {
|
||||
rule_id: "detect.version_unparseable".into(),
|
||||
severity: Severity::Info,
|
||||
message_zh: format!("{cli_name_zh} 可执行但版本号解析失败"),
|
||||
evidence: Some(format!("{executable} 的版本输出无法解析")),
|
||||
}),
|
||||
"exec_failed" | "permission_denied" => Some(Finding {
|
||||
rule_id: "detect.exec_failed".into(),
|
||||
severity: Severity::Error,
|
||||
message_zh: format!("{cli_name_zh} 执行探测失败({executable})"),
|
||||
evidence: Some(format!("执行 {executable} 时失败(status={status})")),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 依赖版本类:实际版本是否满足声明的 semver 范围(如 >=20)。
|
||||
pub fn check_dependency_version(dep_name: &str, range: &str, actual: Option<&str>) -> Option<Finding> {
|
||||
let Some(actual) = actual else {
|
||||
return Some(Finding {
|
||||
rule_id: format!("dependency.{dep_name}_missing"),
|
||||
severity: Severity::Error,
|
||||
message_zh: format!("缺少依赖 {dep_name}(要求 {range})"),
|
||||
evidence: Some(format!("未检测到 {dep_name}")),
|
||||
});
|
||||
};
|
||||
if version_satisfies(actual, range) {
|
||||
return None;
|
||||
}
|
||||
Some(Finding {
|
||||
rule_id: format!("dependency.{dep_name}_below_min"),
|
||||
severity: Severity::Error,
|
||||
message_zh: format!("{dep_name} 版本 {actual} 不满足要求 {range}"),
|
||||
evidence: Some(format!("{dep_name} {actual} 需要 {range}")),
|
||||
})
|
||||
}
|
||||
|
||||
/// 版本冲突类:同名可执行文件在 PATH 中出现多份。
|
||||
pub fn check_version_conflict(executable: &str, resolved_paths: &[String]) -> Option<Finding> {
|
||||
if resolved_paths.len() <= 1 {
|
||||
return None;
|
||||
}
|
||||
Some(Finding {
|
||||
rule_id: "version_conflict.multiple_copies".into(),
|
||||
severity: Severity::Warn,
|
||||
message_zh: format!("检测到 {executable} 在 PATH 中存在多份副本"),
|
||||
evidence: Some(format!("共 {} 份:{}", resolved_paths.len(), resolved_paths.join(" ; "))),
|
||||
})
|
||||
}
|
||||
|
||||
/// 配置损坏类:配置文件解析失败。
|
||||
pub fn check_config_parse(_format: &str, path: &str, parse_ok: bool, err: Option<&str>) -> Option<Finding> {
|
||||
if parse_ok {
|
||||
return None;
|
||||
}
|
||||
Some(Finding {
|
||||
rule_id: "config.corrupt".into(),
|
||||
severity: Severity::Error,
|
||||
message_zh: format!("配置文件解析失败({path})"),
|
||||
evidence: Some(err.unwrap_or("未知解析错误").to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
/// 极简 semver 范围判断:仅支持 `>=X` / `>=X.Y`(本波工具依赖均为这种形态)。
|
||||
pub fn version_satisfies(version: &str, range: &str) -> bool {
|
||||
let Some(range) = range.trim().strip_prefix(">=") else {
|
||||
// 无法识别的范围按「无限制」处理,避免误报
|
||||
return true;
|
||||
};
|
||||
let range = range.trim();
|
||||
let req: Vec<u64> = range.split('.').filter_map(|s| s.parse::<u64>().ok()).collect();
|
||||
let got: Vec<u64> = version
|
||||
.split(|c: char| !c.is_ascii_digit())
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter_map(|s| s.parse::<u64>().ok())
|
||||
.collect();
|
||||
if req.is_empty() || got.is_empty() {
|
||||
return true;
|
||||
}
|
||||
for i in 0..req.len() {
|
||||
let g = got.get(i).copied().unwrap_or(0);
|
||||
if g > req[i] {
|
||||
return true;
|
||||
}
|
||||
if g < req[i] {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layer_identity() {
|
||||
assert_eq!(LAYER, "agentdock-diag");
|
||||
fn path_not_installed_rule() {
|
||||
let f = check_path("Codex CLI", "codex", "not_installed").unwrap();
|
||||
assert_eq!(f.rule_id, "path.not_installed");
|
||||
assert_eq!(f.severity, Severity::Warn);
|
||||
assert!(f.message_zh.contains("codex"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_installed_is_clean() {
|
||||
assert!(check_path("Codex CLI", "codex", "installed").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_below_min_hits() {
|
||||
// Node 18 < 20
|
||||
let f = check_dependency_version("node", ">=20", Some("18.20.0")).unwrap();
|
||||
assert_eq!(f.rule_id, "dependency.node_below_min");
|
||||
assert_eq!(f.severity, Severity::Error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_ok_passes() {
|
||||
assert!(check_dependency_version("node", ">=20", Some("24.18.0")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_missing_hits() {
|
||||
let f = check_dependency_version("node", ">=20", None).unwrap();
|
||||
assert!(f.rule_id.contains("missing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_conflict_hits_when_multiple() {
|
||||
let paths = vec!["C:\\a\\codex.exe".into(), "C:\\b\\codex.exe".into()];
|
||||
let f = check_version_conflict("codex", &paths).unwrap();
|
||||
assert_eq!(f.rule_id, "version_conflict.multiple_copies");
|
||||
assert!(check_version_conflict("codex", &["C:\\a\\codex.exe".into()]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_corrupt_hits() {
|
||||
let f = check_config_parse("json", "~/.gemini/settings.json", false, Some("expected value")).unwrap();
|
||||
assert_eq!(f.rule_id, "config.corrupt");
|
||||
assert_eq!(f.severity, Severity::Error);
|
||||
assert!(check_config_parse("json", "p", true, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semver_range_logic() {
|
||||
assert!(version_satisfies("24.18.0", ">=20"));
|
||||
assert!(version_satisfies("20.0.0", ">=20"));
|
||||
assert!(version_satisfies("22.1.0", ">=22"));
|
||||
assert!(!version_satisfies("18.20.0", ">=20"));
|
||||
assert!(!version_satisfies("20.10.0", ">=22"));
|
||||
assert!(version_satisfies("v24.18.0", ">=20"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,4 +15,4 @@ pub use error::SecretStoreError;
|
||||
pub use keyring::KeyringSecretStore;
|
||||
pub use mock::MockSecretStore;
|
||||
pub use redact::{REDACTED, redact, redact_value};
|
||||
pub use store::SecretStore;
|
||||
pub use store::{SecretStore, service_name};
|
||||
|
||||
Reference in New Issue
Block a user