chore: wave-0 baseline (Tauri2+React shell, platform detect, fake catalog)

This commit is contained in:
AgentDock 施工员
2026-08-25 00:21:13 +08:00
commit 296843215f
137 changed files with 6696 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "agentdock-adapter"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "适配器 schema 加载、版本校验、dry-run、执行器接口(架构 §3"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
+110
View File
@@ -0,0 +1,110 @@
//! 目录索引与占位条目加载(Wave 0)
//!
//! 从 `adapters/catalog.yaml` 读取索引,再逐个读取 `tools/*.yaml`
//! 返回目录条目。仅消费 `id / name / name_zh / vendor / status` 五个字段。
use std::fs;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::error::AdapterError;
/// 目录索引(adapters/catalog.yaml
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct Catalog {
#[serde(rename = "catalog_version")]
pub version: u32,
pub tools: Vec<CatalogRef>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct CatalogRef {
pub id: String,
pub file: String,
}
/// 目录条目(占位 schema,Wave 0 仅五字段;完整字段见架构 §3.1)
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct CatalogEntry {
pub id: String,
pub name: String,
#[serde(rename = "name_zh")]
pub name_zh: String,
pub vendor: String,
/// available | watch
pub status: String,
}
/// 加载目录索引与全部工具占位 YAML
pub fn load_catalog<P: AsRef<Path>>(adapters_dir: P) -> Result<Vec<CatalogEntry>, AdapterError> {
let dir = adapters_dir.as_ref();
let catalog_text = fs::read_to_string(dir.join("catalog.yaml"))
.map_err(|e| AdapterError::Io(format!("读取 catalog.yaml 失败: {e}")))?;
let catalog: Catalog = serde_yaml::from_str(&catalog_text)
.map_err(|e| AdapterError::Parse(format!("解析 catalog.yaml 失败: {e}")))?;
let mut entries = Vec::with_capacity(catalog.tools.len());
for r in &catalog.tools {
let text = fs::read_to_string(dir.join(&r.file))
.map_err(|e| AdapterError::Io(format!("读取 {} 失败: {e}", r.file)))?;
let entry: CatalogEntry = serde_yaml::from_str(&text)
.map_err(|e| AdapterError::Parse(format!("解析 {} 失败: {e}", r.file)))?;
if entry.id != r.id {
return Err(AdapterError::Parse(format!(
"索引 id 与文件内 id 不一致: 索引={} 文件={}",
r.id, entry.id
)));
}
if entry.status != "available" && entry.status != "watch" {
return Err(AdapterError::Parse(format!(
"{} 的 status 非法: {}(应为 available | watch",
entry.id, entry.status
)));
}
entries.push(entry);
}
Ok(entries)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_minimal_tool_yaml() {
let yaml = "id: codex\nname: Codex CLI\nname_zh: Codex CLI\nvendor: OpenAI\nstatus: available\n";
let entry: CatalogEntry = serde_yaml::from_str(yaml).expect("应能解析");
assert_eq!(entry.id, "codex");
assert_eq!(entry.name_zh, "Codex CLI");
assert_eq!(entry.vendor, "OpenAI");
assert_eq!(entry.status, "available");
}
#[test]
fn rejects_invalid_status() {
let yaml = "id: x\nname: X\nname_zh: X\nvendor: V\nstatus: unknown\n";
let entry: Result<CatalogEntry, _> = serde_yaml::from_str(yaml);
// 解析本身成功,非法 status 由 load_catalog 校验;此处确认 schema 字段可读
assert!(entry.is_ok());
}
#[test]
fn loads_real_catalog_from_repo() {
// 以真实 adapters/ 目录做集成测试(相对本 crate 位于 ../../adapters
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters");
let entries = load_catalog(&dir).expect("真实目录应可加载");
assert_eq!(entries.len(), 14, "第一批应为 14 个工具");
for e in &entries {
assert!(!e.id.is_empty());
assert!(!e.name.is_empty());
assert!(!e.name_zh.is_empty());
assert!(!e.vendor.is_empty());
}
let ids: Vec<&str> = entries.iter().map(|e| e.id.as_str()).collect();
for want in ["codex", "claude-code", "gemini", "copilot", "kimi", "qwen", "codebuddy",
"opencode", "crush", "goose", "aider", "cursor", "cline", "warp"] {
assert!(ids.contains(&want), "目录应包含 {want}");
}
}
}
+20
View File
@@ -0,0 +1,20 @@
//! 适配器层错误类型
use std::fmt;
#[derive(Debug)]
pub enum AdapterError {
Io(String),
Parse(String),
}
impl fmt::Display for AdapterError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AdapterError::Io(m) => write!(f, "IO: {m}"),
AdapterError::Parse(m) => write!(f, "Parse: {m}"),
}
}
}
impl std::error::Error for AdapterError {}
+11
View File
@@ -0,0 +1,11 @@
//! agentdock-adapter —— 适配器层
//!
//! 负责适配器 schema 加载、版本校验、dry-run 与执行器接口(架构 §3)。
//! Wave 0:仅落地目录索引与占位条目加载(`catalog` 模块);
//! 完整 schema 校验、执行器接口随 Wave 1 实现。
pub mod catalog;
pub mod error;
pub use catalog::{Catalog, CatalogEntry, CatalogRef, load_catalog};
pub use error::AdapterError;
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "agentdock-config"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
+18
View File
@@ -0,0 +1,18 @@
//! agentdock-config —— 配置读写层(架构 §6)
//!
//! 职责:原子写入 + 自动备份、多格式统一编解码(ConfigCodec trait
//! toml / json / jsonc / yaml / env / crushrc)。
//! Wave 0:空骨架,随 Wave 1/2 落地。
/// 配置层能力标记(占位)
pub const LAYER: &str = "agentdock-config";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layer_identity() {
assert_eq!(LAYER, "agentdock-config");
}
}
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "agentdock-core"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
+17
View File
@@ -0,0 +1,17 @@
//! agentdock-core —— 编排层(架构 §2 模块关系:UI --invoke--> commands --→ core orchestrator
//!
//! 职责:发现 / 安装 / 配置 / 授权 / 诊断 流水线编排。
//! Wave 0:空骨架,流水线自 Wave 1 起逐波落地。
/// 编排层能力标记(占位)
pub const LAYER: &str = "agentdock-core";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layer_identity() {
assert_eq!(LAYER, "agentdock-core");
}
}
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "agentdock-diag"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
+17
View File
@@ -0,0 +1,17 @@
//! agentdock-diag —— 诊断规则引擎(架构 §10)
//!
//! 职责:PATH / 依赖缺失 / 版本冲突 / 配置损坏 四类检查的规则引擎。
//! Wave 0:空骨架,随 Wave 2 落地。
/// 诊断层能力标记(占位)
pub const LAYER: &str = "agentdock-diag";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layer_identity() {
assert_eq!(LAYER, "agentdock-diag");
}
}
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "agentdock-exec"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
+17
View File
@@ -0,0 +1,17 @@
//! agentdock-exec —— 安全进程执行层(架构 §3.2 / §4.2)
//!
//! 职责:仅执行适配器声明的 argv,禁止 shell 拼接、管道、重定向;
//! 参数白名单校验。Wave 0:空骨架,随 Wave 1 落地。
/// 执行层能力标记(占位)
pub const LAYER: &str = "agentdock-exec";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layer_identity() {
assert_eq!(LAYER, "agentdock-exec");
}
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "agentdock-platform"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Win/Linux 环境检测、PATH、发行版识别(架构 §5)"
[dependencies]
serde = { version = "1", features = ["derive"] }
[target.'cfg(windows)'.dependencies]
winreg = "0.52"
+315
View File
@@ -0,0 +1,315 @@
//! 环境检测实现(架构 §5
//!
//! 只做「探测」不执行用户命令:调用系统 `--version` 类只读命令并解析输出;
//! 进程执行统一走 `std::process::Command`,不做 shell 拼接。
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::model::{Capabilities, PlatformEnv, RuntimeInfo, Runtimes, Shells};
/// 全量环境检测(Windows / Linux 双平台)
pub fn detect_env() -> PlatformEnv {
#[cfg(windows)]
let (os, os_version) = (String::from("windows"), windows_os_version());
#[cfg(not(windows))]
let (os, os_version) = (String::from("linux"), linux_os_version());
PlatformEnv {
os,
os_version,
arch: std::env::consts::ARCH.to_string(),
shells: detect_shells(),
runtimes: detect_runtimes(),
path_entries: path_entries(),
capabilities: detect_capabilities(),
}
}
/// 从命令输出中提取首个版本号(如 "v24.18.0" -> "24.18.0")。
/// 规则:取第一段以数字开头、由数字与点组成的子串,尾部点剔除。
pub fn extract_version(output: &str) -> Option<String> {
let bytes = output.as_bytes();
let n = bytes.len();
let mut i = 0;
while i < n {
if bytes[i].is_ascii_digit() {
let mut j = i;
while j < n && (bytes[j].is_ascii_digit() || bytes[j] == b'.') {
j += 1;
}
let s = output[i..j].trim_end_matches('.');
if !s.is_empty() {
return Some(s.to_string());
}
i = j;
} else {
i += 1;
}
}
None
}
/// 按平台分隔符拆分 PATH
fn path_entries() -> Vec<String> {
std::env::var("PATH")
.unwrap_or_default()
.split(path_list_sep())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
}
#[cfg(windows)]
fn path_list_sep() -> char {
';'
}
#[cfg(not(windows))]
fn path_list_sep() -> char {
':'
}
/// 在 PATH 中解析可执行文件。
/// Windows 优先 .exe/.cmd/.batCreateProcess 无法直接执行无扩展名文件,
/// 例如 nodejs 目录下的 npm bash 脚本),最后才回退无扩展名。
fn resolve_program(program: &str) -> Option<PathBuf> {
let direct = Path::new(program);
if direct.is_absolute() && direct.is_file() {
return Some(direct.to_path_buf());
}
let exts: &[&str] = if cfg!(windows) {
&[".exe", ".cmd", ".bat", ""]
} else {
&[""]
};
for dir in path_entries() {
for ext in exts {
let cand = Path::new(&dir).join(format!("{program}{ext}"));
if cand.is_file() {
return Some(cand);
}
}
}
None
}
/// 探测一个可执行文件:返回状态 / 版本 / 路径。
/// 结果分类:installed / not_in_path / exec_failed / version_unparseable。
fn probe(program: &str, args: &[&str]) -> Option<RuntimeInfo> {
let path = resolve_program(program);
let path_str = path.as_ref().map(|p| p.to_string_lossy().to_string());
let Some(exe) = path else {
return Some(RuntimeInfo {
status: "not_in_path".into(),
version: None,
path: None,
});
};
let mut cmd = Command::new(&exe);
cmd.args(args);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
// CREATE_NO_WINDOW:探测命令不闪黑框
cmd.creation_flags(0x0800_0000);
}
let output = match cmd.output() {
Ok(o) => o,
Err(_) => {
return Some(RuntimeInfo {
status: "exec_failed".into(),
version: None,
path: path_str,
});
}
};
if !output.status.success() {
return Some(RuntimeInfo {
status: "exec_failed".into(),
version: None,
path: path_str,
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
let text = if stdout.trim().is_empty() {
String::from_utf8_lossy(&output.stderr).to_string()
} else {
stdout.to_string()
};
match extract_version(&text) {
Some(version) => Some(RuntimeInfo {
status: "installed".into(),
version: Some(version),
path: path_str,
}),
None => Some(RuntimeInfo {
status: "version_unparseable".into(),
version: None,
path: path_str,
}),
}
}
fn detect_runtimes() -> Runtimes {
Runtimes {
node: probe("node", &["--version"]),
npm: probe("npm", &["--version"]),
python: probe_python(),
uv: probe("uv", &["--version"]),
git: probe("git", &["--version"]),
winget: probe("winget", &["--version"]),
apt: probe("apt", &["--version"]),
}
}
/// Python:优先 `python --version`,失败退回 `py --version`Windows 启动器)
fn probe_python() -> Option<RuntimeInfo> {
let direct = probe("python", &["--version"]);
if matches!(direct, Some(ref i) if i.status == "installed") {
return direct;
}
probe("py", &["--version"])
}
fn detect_shells() -> Shells {
Shells {
powershell_version: probe_powershell(),
pwsh_version: probe("pwsh", &["-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"])
.and_then(|i| i.version),
bash_available: resolve_program("bash").is_some(),
}
}
#[cfg(windows)]
fn probe_powershell() -> Option<String> {
probe(
"powershell",
&["-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"],
)
.and_then(|i| i.version)
}
#[cfg(not(windows))]
fn probe_powershell() -> Option<String> {
None
}
fn detect_capabilities() -> Capabilities {
#[cfg(windows)]
{
Capabilities {
// Windows Credential Manager 常驻可用(架构 §4.1
keyring: "ok".into(),
// `net session` 仅在管理员上下文返回成功(提权探测,仅检测不执行用户命令)
can_elevate: run_success("net", &["session"]),
}
}
#[cfg(not(windows))]
{
Capabilities {
keyring: if resolve_program("secret-tool").is_some() {
"ok".into()
} else {
"missing".into()
},
can_elevate: std::env::var("USER").map(|u| u == "root").unwrap_or(false),
}
}
}
fn run_success(program: &str, args: &[&str]) -> bool {
let mut cmd = Command::new(program);
cmd.args(args);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000);
}
cmd.output().map(|o| o.status.success()).unwrap_or(false)
}
#[cfg(windows)]
fn windows_os_version() -> String {
use winreg::enums::HKEY_LOCAL_MACHINE;
use winreg::RegKey;
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
match hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion") {
Ok(key) => {
let product: String = key.get_value("ProductName").unwrap_or_else(|_| "Windows".into());
let display: String = key.get_value("DisplayVersion").unwrap_or_default();
let build: String = key.get_value("CurrentBuildNumber").unwrap_or_default();
let major: u32 = key.get_value("CurrentMajorVersionNumber").unwrap_or(0);
let minor: u32 = key.get_value("CurrentMinorVersionNumber").unwrap_or(0);
let ver = if !display.is_empty() {
display
} else {
format!("{major}.{minor}")
};
format!("{product} {ver} (Build {build})")
}
Err(_) => "Windows (unknown version)".into(),
}
}
#[cfg(not(windows))]
fn linux_os_version() -> String {
let content = std::fs::read_to_string("/etc/os-release").unwrap_or_default();
for line in content.lines() {
if let Some(v) = line.strip_prefix("PRETTY_NAME=") {
return v.trim().trim_matches('"').to_string();
}
}
"Linux (unknown distro)".into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_version_from_common_outputs() {
assert_eq!(extract_version("v24.18.0"), Some("24.18.0".into()));
assert_eq!(extract_version("11.16.0\n"), Some("11.16.0".into()));
assert_eq!(extract_version("Python 3.14.6"), Some("3.14.6".into()));
assert_eq!(extract_version("git version 2.47.1.windows.1"), Some("2.47.1".into()));
assert_eq!(extract_version("GNU bash, version 5.2.26(1)-release"), Some("5.2.26".into()));
assert_eq!(extract_version("v1.8.1911"), Some("1.8.1911".into()));
assert_eq!(extract_version("5.1.26100.1"), Some("5.1.26100.1".into()));
}
#[test]
fn extracts_none_when_no_digits() {
assert_eq!(extract_version("command not found"), None);
assert_eq!(extract_version(""), None);
}
#[test]
fn trims_trailing_dots() {
assert_eq!(extract_version("v1.2.3."), Some("1.2.3".into()));
}
#[test]
fn env_has_path_on_windows() {
// PATH 变量在任何真实 Windows/Linux 上都存在
assert!(!path_entries().is_empty(), "PATH 不应为空");
}
/// 实机自测:打印本机环境检测快照(cargo test -p agentdock-platform -- --ignored --nocapture
#[test]
#[ignore]
fn print_detect_env_snapshot() {
let env = detect_env();
println!("{:#?}", env);
assert_eq!(env.os, "windows");
assert!(!env.os_version.is_empty());
assert!(!env.arch.is_empty());
}
}
+10
View File
@@ -0,0 +1,10 @@
//! agentdock-platform —— 平台检测层
//!
//! 负责 Windows / Linux 的本机环境检测(架构 §5):
//! OS 版本、架构、PATH、shell、运行时(Node/npm/Python/uv/Git/winget/apt)、
//! 能力(密钥库、提权探测)。
pub mod detect;
pub mod model;
pub use model::{Capabilities, PlatformEnv, RuntimeInfo, Runtimes, Shells};
+56
View File
@@ -0,0 +1,56 @@
//! 平台环境数据结构(对齐架构 §5 `PlatformEnv`
use serde::{Deserialize, Serialize};
/// 平台环境快照
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct PlatformEnv {
/// os: windows | linux
pub os: String,
/// 人类可读的系统版本(如 "Windows 11 24H2 (Build 26100)" / "Ubuntu 24.04 LTS"
pub os_version: String,
/// 架构(std::env::consts::ARCH,如 x86_64
pub arch: String,
pub shells: Shells,
pub runtimes: Runtimes,
/// PATH 条目(Windows 用 ';' 分割,Linux 用 ':'
pub path_entries: Vec<String>,
pub capabilities: Capabilities,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct Shells {
/// Windows PowerShell5.x)版本
pub powershell_version: Option<String>,
/// PowerShell 7+pwsh)版本
pub pwsh_version: Option<String>,
pub bash_available: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct Runtimes {
pub node: Option<RuntimeInfo>,
pub npm: Option<RuntimeInfo>,
pub python: Option<RuntimeInfo>,
pub uv: Option<RuntimeInfo>,
pub git: Option<RuntimeInfo>,
pub winget: Option<RuntimeInfo>,
pub apt: Option<RuntimeInfo>,
}
/// 单个运行时探测结果
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct RuntimeInfo {
/// installed | not_installed | not_in_path | exec_failed | version_unparseable
pub status: String,
pub version: Option<String>,
/// 解析到的可执行文件绝对路径(PATH 搜索)
pub path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct Capabilities {
/// ok | missingLinux 依赖 secret-tool / DBus Secret Service
pub keyring: String,
pub can_elevate: bool,
}
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "agentdock-secrets"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
+17
View File
@@ -0,0 +1,17 @@
//! agentdock-secrets —— 系统密钥库封装与日志脱敏(架构 §4.1 / §4.3)
//!
//! 职责:keyring 封装(Windows Credential Manager / Linux Secret Service)、
//! 敏感值脱敏。Wave 0:空骨架,随 Wave 1 落地。
/// 密钥层能力标记(占位)
pub const LAYER: &str = "agentdock-secrets";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layer_identity() {
assert_eq!(LAYER, "agentdock-secrets");
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "agentdock-store"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "SQLite 本地状态存储:schema 迁移与仓库(架构 §2 状态存储层)"
[dependencies]
rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+32
View File
@@ -0,0 +1,32 @@
//! 存储层错误类型
use std::fmt;
#[derive(Debug)]
pub enum StoreError {
Io(std::io::Error),
Sqlite(rusqlite::Error),
}
impl fmt::Display for StoreError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
StoreError::Io(e) => write!(f, "IO: {e}"),
StoreError::Sqlite(e) => write!(f, "SQLite: {e}"),
}
}
}
impl std::error::Error for StoreError {}
impl From<std::io::Error> for StoreError {
fn from(e: std::io::Error) -> Self {
StoreError::Io(e)
}
}
impl From<rusqlite::Error> for StoreError {
fn from(e: rusqlite::Error) -> Self {
StoreError::Sqlite(e)
}
}
+11
View File
@@ -0,0 +1,11 @@
//! agentdock-store —— 本地状态存储(SQLite)
//!
//! Wave 0SQLite 空库 + schema 迁移骨架。仓库查询接口随后续波次实现。
pub mod error;
pub mod migrations;
pub mod schema;
pub mod store;
pub use error::StoreError;
pub use store::{Store, default_data_dir};
+26
View File
@@ -0,0 +1,26 @@
//! 迁移执行器(Wave 0 骨架)
use rusqlite::Connection;
use crate::schema::{MIGRATIONS, SCHEMA_VERSION};
/// 应用全部迁移。所有语句均为幂等(IF NOT EXISTS),可安全重复执行。
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
let tx = conn.unchecked_transaction()?;
for sql in MIGRATIONS {
tx.execute_batch(sql)?;
}
tx.execute(
"INSERT OR REPLACE INTO schema_version (version) VALUES (?1)",
[SCHEMA_VERSION],
)?;
tx.commit()
}
/// 读取当前 schema 版本(未初始化返回 0)
pub fn current_version(conn: &Connection) -> rusqlite::Result<i64> {
conn.query_row("SELECT version FROM schema_version ORDER BY version DESC LIMIT 1", [], |r| {
r.get(0)
})
.or(Ok(0))
}
+33
View File
@@ -0,0 +1,33 @@
//! schema 定义与迁移骨架(Wave 0
//!
//! 迁移采用顺序数组:每个元素是一段幂等 SQLCREATE TABLE IF NOT EXISTS)。
//! 后续波次新增表时在 `MIGRATIONS` 尾部追加并提升 `SCHEMA_VERSION`。
/// 当前 schema 版本
pub const SCHEMA_VERSION: i64 = 1;
/// 迁移列表(按顺序执行,均需幂等)
pub const MIGRATIONS: [&str; 3] = [
// 迁移 1schema 版本表
"CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);",
// 迁移 2:已安装 CLI 记录
"CREATE TABLE IF NOT EXISTS installed_clis (
cli_id TEXT PRIMARY KEY,
name_zh TEXT NOT NULL,
version TEXT,
install_channel TEXT,
detected_at TEXT
);",
// 迁移 3:诊断历史
"CREATE TABLE IF NOT EXISTS diagnostic_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cli_id TEXT NOT NULL,
rule_id TEXT NOT NULL,
severity TEXT NOT NULL,
message_zh TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);",
];
+105
View File
@@ -0,0 +1,105 @@
//! Store:打开/创建 SQLite 空库并应用迁移(Wave 0
use std::path::{Path, PathBuf};
use rusqlite::Connection;
use crate::error::StoreError;
use crate::migrations;
/// 本地状态存储句柄
pub struct Store {
conn: Connection,
path: PathBuf,
}
impl Store {
/// 打开(不存在则创建)指定路径的 SQLite 库并应用迁移
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, StoreError> {
let path = path.as_ref().to_path_buf();
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let conn = Connection::open(&path)?;
conn.pragma_update(None, "foreign_keys", true)?;
migrations::migrate(&conn)?;
Ok(Self { conn, path })
}
/// 打开默认数据目录下的 agentdock.dbWindows: %APPDATA%\agentdock
pub fn open_default() -> Result<Self, StoreError> {
Self::open(default_data_dir().join("agentdock.db"))
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn connection(&self) -> &Connection {
&self.conn
}
}
/// 默认数据目录
pub fn default_data_dir() -> PathBuf {
#[cfg(windows)]
{
if let Some(appdata) = std::env::var_os("APPDATA") {
return PathBuf::from(appdata).join("agentdock");
}
PathBuf::from(".").join("agentdock")
}
#[cfg(not(windows))]
{
if let Some(xdg) = std::env::var_os("XDG_DATA_HOME") {
return PathBuf::from(xdg).join("agentdock");
}
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(home).join(".local/share").join("agentdock");
}
PathBuf::from(".").join("agentdock")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::migrations::current_version;
use crate::schema::SCHEMA_VERSION;
#[test]
fn in_memory_migration_applies_and_is_idempotent() {
let conn = Connection::open_in_memory().expect("内存库应可打开");
migrations::migrate(&conn).expect("首次迁移应成功");
assert_eq!(current_version(&conn).unwrap(), SCHEMA_VERSION);
// 幂等:重复迁移不报错、版本不变
migrations::migrate(&conn).expect("重复迁移应成功");
assert_eq!(current_version(&conn).unwrap(), SCHEMA_VERSION);
// 关键表存在
let tables: Vec<String> = conn
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('schema_version','installed_clis','diagnostic_history')")
.unwrap()
.query_map([], |r| r.get(0))
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
assert_eq!(tables.len(), 3);
}
#[test]
fn open_creates_empty_db_file() {
let dir = std::env::temp_dir().join(format!("agentdock-test-{}", std::process::id()));
let db = dir.join("agentdock.db");
let _ = std::fs::remove_dir_all(&dir);
let store = Store::open(&db).expect("应能创建库文件");
assert!(db.exists(), "SQLite 空库文件应已创建");
assert_eq!(current_version(store.connection()).unwrap(), SCHEMA_VERSION);
let _ = std::fs::remove_dir_all(&dir);
}
}