pub mod opencode;
pub mod mcp;
pub mod schema;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::project::ProjectRoot;
pub const PROJECT_CONFIG_FILE: &str = "config.toml";
pub const USER_CONFIG_FILE: &str = "config.toml";
pub fn load_config(path: &Path) -> Result<schema::WikiConfig> {
if !path.exists() {
anyhow::bail!(
"配置文件不存在: {}(可运行 `code-repo-wiki install` 确保用户级默认配置,或使用 --config 显式指定)",
path.display()
);
}
let content = std::fs::read_to_string(path)
.with_context(|| format!("读取配置文件失败: {}", path.display()))?;
let config: schema::WikiConfig = toml::from_str(&content)
.with_context(|| format!("解析配置文件失败: {}", path.display()))?;
validate_config(&config)?;
Ok(config)
}
pub fn create_default_config(path: &Path) -> Result<schema::WikiConfig> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, include_str!("../../config.toml"))?;
load_config(path)
}
pub fn global_config_dir_from(userprofile: Option<&Path>, home: Option<&Path>) -> Result<PathBuf> {
match userprofile {
Some(p) if !p.as_os_str().is_empty() => Ok(p.join(".code-repo-wiki")),
_ => home
.filter(|h| !h.as_os_str().is_empty())
.ok_or_else(|| anyhow::anyhow!("无法确定用户级配置目录(USERPROFILE 与 HOME 均未设置)"))
.map(|h| h.join(".code-repo-wiki")),
}
}
pub fn global_config_dir() -> Result<PathBuf> {
if let Some(dir) = std::env::var("CODE_REPO_WIKI_HOME")
.ok()
.filter(|v| !v.is_empty())
{
return Ok(PathBuf::from(dir));
}
let userprofile = std::env::var("USERPROFILE").ok().map(PathBuf::from);
let home = std::env::var("HOME").ok().map(PathBuf::from);
global_config_dir_from(userprofile.as_deref(), home.as_deref())
}
pub fn ensure_global_config_dir() -> Result<(PathBuf, bool)> {
let dir = global_config_dir()?;
let legacy_dirs: Vec<PathBuf> = if std::env::var("CODE_REPO_WIKI_HOME").is_ok() {
Vec::new()
} else {
let mut legacy = Vec::new();
if let Some(appdata) = std::env::var("APPDATA").ok().filter(|v| !v.is_empty()) {
legacy.push(PathBuf::from(appdata).join("code-repo-wiki"));
}
if let Some(home) = std::env::var("HOME").ok().filter(|v| !v.is_empty()) {
legacy.push(PathBuf::from(home).join("code-repo-wiki"));
}
legacy
}; let migrated = migrate_global_config(&dir, &legacy_dirs)?;
Ok((dir, migrated))
}
pub fn migrate_global_config(new_dir: &Path, legacy_dirs: &[PathBuf]) -> Result<bool> {
if new_dir.join(USER_CONFIG_FILE).exists() {
return Ok(false);
}
for legacy in legacy_dirs {
if !legacy.join(USER_CONFIG_FILE).exists() {
continue;
}
std::fs::create_dir_all(new_dir)
.with_context(|| format!("创建全局配置目录失败: {}", new_dir.display()))?;
copy_dir_contents(legacy, new_dir)?;
return Ok(true);
}
Ok(false)
}
fn copy_dir_contents(from: &Path, to: &Path) -> Result<()> {
std::fs::create_dir_all(to)
.with_context(|| format!("创建目录失败: {}", to.display()))?;
for entry in std::fs::read_dir(from)
.with_context(|| format!("读取目录失败: {}", from.display()))?
{
let entry = entry?;
let src = entry.path();
let dst = to.join(entry.file_name());
if src.is_dir() {
copy_dir_contents(&src, &dst)?;
} else {
std::fs::copy(&src, &dst)
.with_context(|| format!("复制失败: {} → {}", src.display(), dst.display()))?;
}
}
Ok(())
}
fn merge_config(base: &toml::Value, overlay: &toml::Value) -> toml::Value {
match (base, overlay) {
(toml::Value::Table(base_tbl), toml::Value::Table(overlay_tbl)) => {
let mut merged = base_tbl.clone();
for (key, overlay_val) in overlay_tbl {
let recursive = merged
.get(key)
.is_some_and(|bv| bv.is_table() && overlay_val.is_table());
if recursive {
let base_child = merged.get(key).unwrap().clone();
merged.insert(key.clone(), merge_config(&base_child, overlay_val));
} else {
merged.insert(key.clone(), overlay_val.clone());
}
}
toml::Value::Table(merged)
}
_ => overlay.clone(),
}
}
pub fn load_default_config_with(
root: &ProjectRoot,
global_dir: &Path,
) -> Result<(PathBuf, schema::WikiConfig)> {
let project_config = root.path().join(PROJECT_CONFIG_FILE);
let user_config = global_dir.join(USER_CONFIG_FILE);
if project_config.exists() {
let base_text = if user_config.exists() {
std::fs::read_to_string(&user_config)
.with_context(|| format!("读取用户级配置失败: {}", user_config.display()))?
} else {
include_str!("../../config.toml").to_string()
};
let base: toml::Value = toml::from_str(&base_text)
.with_context(|| "解析用户级配置(或模板)失败".to_string())?;
let project_text = std::fs::read_to_string(&project_config)
.with_context(|| format!("读取项目级配置失败: {}", project_config.display()))?;
let overlay: toml::Value = toml::from_str(&project_text)
.with_context(|| format!("解析项目级配置失败: {}", project_config.display()))?;
let merged = merge_config(&base, &overlay);
let text = toml::to_string(&merged).context("合并配置序列化失败")?;
let config: schema::WikiConfig = toml::from_str(&text)
.with_context(|| format!("解析合并后配置失败: {}", project_config.display()))?;
validate_config(&config)?;
Ok((project_config, config))
} else if user_config.exists() {
let config = load_config(&user_config)?;
Ok((user_config, config))
} else {
std::fs::create_dir_all(global_dir)
.with_context(|| format!("创建全局配置目录失败: {}", global_dir.display()))?;
create_default_config(&user_config)?;
let config = load_config(&user_config)?;
Ok((user_config, config))
}
}
pub fn load_default_config(root: &ProjectRoot) -> Result<(PathBuf, schema::WikiConfig)> {
let (global_dir, migrated) = ensure_global_config_dir()?;
if migrated {
println!(
"提示: 用户级配置已迁移到 {}(旧目录保留,未删除)",
global_dir.display()
);
}
load_default_config_with(root, &global_dir)
}
pub fn resolve_default_config_path_with(root: &ProjectRoot, global_dir: &Path) -> Result<PathBuf> {
let project_config = root.path().join(PROJECT_CONFIG_FILE);
if project_config.exists() {
return Ok(project_config);
}
let global_config = global_dir.join(USER_CONFIG_FILE);
if global_config.exists() {
return Ok(global_config);
}
std::fs::create_dir_all(global_dir)
.with_context(|| format!("创建全局配置目录失败: {}", global_dir.display()))?;
create_default_config(&global_config)?;
Ok(global_config)
}
pub fn resolve_default_config_path(root: &ProjectRoot) -> Result<PathBuf> {
let (global_dir, migrated) = ensure_global_config_dir()?;
if migrated {
println!(
"提示: 用户级配置已迁移到 {}(旧目录保留,未删除)",
global_dir.display()
);
}
resolve_default_config_path_with(root, &global_dir)
}
pub fn resolve_config_path(config: Option<&Path>, root: &ProjectRoot) -> Result<PathBuf> {
match config {
Some(p) => Ok(p.to_path_buf()),
None => resolve_default_config_path(root),
}
}
pub fn resolve_mcp_config(config: Option<&Path>, root: &ProjectRoot) -> Result<schema::WikiConfig> {
match config {
Some(p) => load_config(p),
None => load_default_config(root).map(|(_path, cfg)| cfg),
}
}
fn validate_config(_config: &schema::WikiConfig) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config_roundtrip() {
let config = schema::WikiConfig::default();
let toml_str = toml::to_string_pretty(&config).unwrap();
let parsed: schema::WikiConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.llm.model, "deepseek-v4-flash");
assert_eq!(parsed.llm.api_key_env, "OPENCODEGO2_API_KEY");
assert_eq!(parsed.wiki.language, "zh");
assert_eq!(parsed.output_dir(), std::path::Path::new(crate::config::schema::OUTPUT_DIR));
}
}
#[test]
fn test_global_config_dir_from_userprofile() {
let dir = global_config_dir_from(Some(Path::new("C:/Users/wenyu")), Some(Path::new("/home/wenyu")))
.unwrap();
assert_eq!(dir, PathBuf::from("C:/Users/wenyu/.code-repo-wiki"));
}
#[test]
fn test_global_config_dir_from_home_fallback() {
let dir = global_config_dir_from(None, Some(Path::new("/home/wenyu"))).unwrap();
assert_eq!(dir, PathBuf::from("/home/wenyu/.code-repo-wiki"));
}
#[test]
fn test_global_config_dir_from_missing_both_errors() {
assert!(global_config_dir_from(None, None).is_err());
assert!(global_config_dir_from(None, Some(Path::new(""))).is_err());
}
#[test]
fn test_migrate_global_config_migrates_legacy() {
let tmp = test_tmp_dir("migrate-legacy");
let legacy = tmp.join("legacy");
let new = tmp.join("new");
std::fs::create_dir_all(legacy.join("sub")).unwrap();
std::fs::write(legacy.join("config.toml"), "llm_model = 'deepseek'").unwrap();
std::fs::write(legacy.join("sub/notes.txt"), "abc").unwrap();
assert!(migrate_global_config(&new, std::slice::from_ref(&legacy)).unwrap());
assert_eq!(
std::fs::read_to_string(new.join("config.toml")).unwrap(),
"llm_model = 'deepseek'"
);
assert_eq!(std::fs::read_to_string(new.join("sub/notes.txt")).unwrap(), "abc");
assert!(legacy.join("config.toml").exists());
}
#[test]
fn test_migrate_global_config_skips_when_new_exists() {
let tmp = test_tmp_dir("migrate-new-exists");
let legacy = tmp.join("legacy");
let new = tmp.join("new");
std::fs::create_dir_all(&legacy).unwrap();
std::fs::write(legacy.join("config.toml"), "old").unwrap();
std::fs::create_dir_all(&new).unwrap();
std::fs::write(new.join("config.toml"), "new-content").unwrap();
assert!(!migrate_global_config(&new, &[legacy]).unwrap());
assert_eq!(std::fs::read_to_string(new.join("config.toml")).unwrap(), "new-content");
}
#[test]
fn test_migrate_global_config_skips_when_legacy_missing() {
let tmp = test_tmp_dir("migrate-legacy-missing");
let legacy = tmp.join("missing");
let new = tmp.join("new");
assert!(!migrate_global_config(&new, &[legacy]).unwrap());
assert!(!new.exists());
}
#[test]
fn test_migrate_global_config_uses_first_legacy_with_config() {
let tmp = test_tmp_dir("migrate-first-legacy");
let legacy_empty = tmp.join("empty");
let legacy_real = tmp.join("real");
let new = tmp.join("new");
std::fs::create_dir_all(&legacy_empty).unwrap();
std::fs::create_dir_all(&legacy_real).unwrap();
std::fs::write(legacy_real.join("config.toml"), "real-content").unwrap();
assert!(migrate_global_config(&new, &[legacy_empty, legacy_real]).unwrap());
assert_eq!(
std::fs::read_to_string(new.join("config.toml")).unwrap(),
"real-content"
);
}
#[allow(dead_code)]
fn test_tmp_dir(name: &str) -> PathBuf {
use std::sync::atomic::{AtomicUsize, Ordering};
static SEQ: AtomicUsize = AtomicUsize::new(0);
std::env::temp_dir().join(format!(
"code-repo-wiki-config-test-{}-{}-{}",
std::process::id(),
name,
SEQ.fetch_add(1, Ordering::SeqCst)
))
}
#[test]
fn test_resolve_prefers_project_config() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_e_project_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(PROJECT_CONFIG_FILE), "dummy").unwrap();
let global_dir = dir.join("global");
std::fs::create_dir_all(&global_dir).unwrap();
std::fs::write(global_dir.join(USER_CONFIG_FILE), "dummy-global").unwrap();
let resolved = resolve_default_config_path_with(&ProjectRoot::new(dir.clone()), &global_dir).unwrap();
assert_eq!(resolved, dir.join(PROJECT_CONFIG_FILE));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_load_default_config_project_overrides_user() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_merge_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let global_dir = dir.join("global");
std::fs::create_dir_all(&global_dir).unwrap();
let user_text = include_str!("../../config.toml")
.replace("model = \"deepseek-v4-flash\"", "model = \"user-model\"");
std::fs::write(global_dir.join(USER_CONFIG_FILE), &user_text).unwrap();
std::fs::write(
dir.join(PROJECT_CONFIG_FILE),
r#"
[llm]
provider = "anthropic"
api_key_env = "ANTHROPIC_API_KEY"
model = "claude-test"
"#,
)
.unwrap();
let (path, config) = load_default_config_with(&ProjectRoot::new(dir.clone()), &global_dir).unwrap();
assert_eq!(path, dir.join(PROJECT_CONFIG_FILE));
assert_eq!(config.llm.model, "claude-test");
assert_eq!(config.llm.provider, schema::LlmProviderType::Anthropic);
assert_eq!(config.llm.api_key_env, "ANTHROPIC_API_KEY");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_load_default_config_user_only_or_creates() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_useronly_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let global_dir = dir.join("global");
std::fs::create_dir_all(&global_dir).unwrap();
let user_text = include_str!("../../config.toml")
.replace("model = \"deepseek-v4-flash\"", "model = \"user-only-model\"");
std::fs::write(global_dir.join(USER_CONFIG_FILE), &user_text).unwrap();
let (path, config) = load_default_config_with(&ProjectRoot::new(dir.clone()), &global_dir).unwrap();
assert_eq!(path, global_dir.join(USER_CONFIG_FILE));
assert_eq!(config.llm.model, "user-only-model");
assert!(!dir.join(PROJECT_CONFIG_FILE).exists());
let global2 = dir.join("global2");
let (path2, config2) = load_default_config_with(&ProjectRoot::new(dir.clone()), &global2).unwrap();
assert!(path2.ends_with(USER_CONFIG_FILE));
assert!(global2.join(USER_CONFIG_FILE).exists());
assert_eq!(config2.llm.model, "deepseek-v4-flash");
assert!(!dir.join(PROJECT_CONFIG_FILE).exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_load_project_config_keeps_sensitive_keys() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_projcfg_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(PROJECT_CONFIG_FILE);
std::fs::write(
&path,
r#"
[wiki]
language = "en"
[llm]
provider = "anthropic"
model = "claude-opus"
base_url = "https://custom.example.com/v1"
api_key_env = "HACKED_KEY"
"#,
)
.unwrap();
let config = load_config(&path).unwrap();
assert_eq!(config.llm.provider, crate::config::schema::LlmProviderType::Anthropic);
assert_eq!(config.llm.model, "claude-opus");
assert_eq!(config.llm.base_url.as_deref(), Some("https://custom.example.com/v1"));
assert_eq!(config.llm.api_key_env, "HACKED_KEY");
assert_eq!(config.wiki.language, "en");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_load_project_config_defaults_for_missing_keys() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_projcfg_defaults_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(PROJECT_CONFIG_FILE);
std::fs::write(
&path,
r#"
[llm]
provider = "mock"
"#,
)
.unwrap();
let config = load_config(&path).unwrap();
assert_eq!(config.llm.provider, crate::config::schema::LlmProviderType::Mock);
assert_eq!(config.llm.base_url.as_deref(), Some("https://opencode.ai/zen/go/v1"));
assert_eq!(config.llm.api_key_env, "OPENCODEGO2_API_KEY");
assert_eq!(config.embed.model, "qwen3.7-text-embedding");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_load_explicit_config_keeps_sensitive_keys() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_anyname_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("my.toml");
std::fs::write(
&path,
r#"
[llm]
provider = "anthropic"
model = "claude-opus"
api_key_env = "ANTHROPIC_API_KEY"
"#,
)
.unwrap();
let config = load_config(&path).unwrap();
assert_eq!(config.llm.provider, crate::config::schema::LlmProviderType::Anthropic);
assert_eq!(config.llm.model, "claude-opus");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_resolve_falls_back_to_global() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_e_global_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let global_dir = dir.join("global");
std::fs::create_dir_all(&global_dir).unwrap();
std::fs::write(global_dir.join(USER_CONFIG_FILE), "dummy-global").unwrap();
let resolved = resolve_default_config_path_with(&ProjectRoot::new(dir.clone()), &global_dir).unwrap();
assert_eq!(resolved, global_dir.join(USER_CONFIG_FILE));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_resolve_creates_global_config_when_missing() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_e_create_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let global_dir = dir.join("global");
let resolved = resolve_default_config_path_with(&ProjectRoot::new(dir.clone()), &global_dir).unwrap();
assert_eq!(resolved, global_dir.join(USER_CONFIG_FILE));
assert!(global_dir.join(USER_CONFIG_FILE).exists(), "缺失时应创建全局默认配置");
assert!(load_config(&resolved).is_ok());
let resolved2 = resolve_default_config_path_with(&ProjectRoot::new(dir.clone()), &global_dir).unwrap();
assert_eq!(resolved2, resolved);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_resolve_config_path_explicit_wins() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_e_explicit_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let explicit = dir.join("custom.toml");
let resolved = resolve_config_path(Some(&explicit), &ProjectRoot::new(dir.clone())).unwrap();
assert_eq!(resolved, explicit);
assert!(!dir.join("global").exists());
let _ = std::fs::remove_dir_all(&dir);
}