use std::fs;
use std::path::{Path, PathBuf};
use super::xdg::{self, ENV_CONFIG_DIR};
pub const SYSTEM_CONFIG_DIR: &str = "/etc/crabmate";
pub const ENV_SKIP_CONFIG_SEED: &str = "CM_CRABMATE_SKIP_CONFIG_SEED";
pub use super::xdg::user_config_dir;
#[must_use]
pub fn user_config_toml_path() -> PathBuf {
xdg::user_config_dir().join("config.toml")
}
#[must_use]
pub fn system_config_toml_path() -> PathBuf {
PathBuf::from(SYSTEM_CONFIG_DIR).join("config.toml")
}
fn env_flag_truthy(key: &str) -> bool {
std::env::var(key)
.ok()
.map(|s| {
let t = s.trim();
t == "1"
|| t.eq_ignore_ascii_case("true")
|| t.eq_ignore_ascii_case("yes")
|| t.eq_ignore_ascii_case("on")
})
.unwrap_or(false)
}
#[must_use]
pub fn cwd_in_crabmate_source_tree() -> bool {
let Ok(cwd) = std::env::current_dir() else {
return false;
};
cwd.ancestors().take(8).any(|dir| {
dir.join("src/cm_config/mod.rs").is_file()
&& dir.join("config/default_config.toml").is_file()
})
}
#[must_use]
pub fn cwd_has_local_user_config() -> bool {
Path::new("config.toml").is_file() || Path::new(".agent_demo.toml").is_file()
}
#[must_use]
pub fn should_resolve_xdg_user_config() -> bool {
if std::env::var(ENV_CONFIG_DIR)
.ok()
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
{
return true;
}
!cwd_in_crabmate_source_tree()
}
const SEED_ROOT_FILES: &[&str] = &["config.toml", "agent_roles.toml"];
const SEED_ROOT_DIRS: &[&str] = &["prompts", "config", "skills"];
pub fn ensure_user_config_seeded_from(system_dir: &Path, user_dir: &Path) -> Result<bool, String> {
let user_toml = user_dir.join("config.toml");
if user_toml.is_file() {
return Ok(false);
}
let system_toml = system_dir.join("config.toml");
if !system_toml.is_file() {
return Ok(false);
}
fs::create_dir_all(user_dir)
.map_err(|e| format!("无法创建用户配置目录 \"{}\": {e}", user_dir.display()))?;
copy_seed_subset_no_overwrite(system_dir, user_dir)?;
if !user_toml.is_file() {
return Err(format!("种子拷贝后仍缺少 \"{}\"", user_toml.display()));
}
Ok(true)
}
pub fn ensure_user_config_seeded_from_system() -> Result<bool, String> {
if env_flag_truthy(ENV_SKIP_CONFIG_SEED) {
return Ok(false);
}
ensure_user_config_seeded_from(Path::new(SYSTEM_CONFIG_DIR), &user_config_dir())
}
#[must_use]
pub fn resolve_default_user_config_toml_after_seed() -> Option<PathBuf> {
if !should_resolve_xdg_user_config() {
return None;
}
if let Err(e) = ensure_user_config_seeded_from_system() {
log::warn!("seed user config from {SYSTEM_CONFIG_DIR}: {e}");
}
let p = user_config_toml_path();
p.is_file().then_some(p)
}
#[must_use]
pub fn resolve_interactive_cli_config_path(explicit: Option<&str>) -> Option<PathBuf> {
if explicit.map(str::trim).is_some_and(|s| !s.is_empty()) {
return None;
}
if cwd_has_local_user_config() {
return None;
}
let seed = ensure_user_config_seeded_from_system();
let user = user_config_toml_path();
if user.is_file() {
return Some(user);
}
if let Err(e) = seed {
log::warn!("seed user config from {SYSTEM_CONFIG_DIR}: {e}");
}
let system = system_config_toml_path();
system.is_file().then_some(system)
}
fn copy_seed_subset_no_overwrite(src: &Path, dst: &Path) -> Result<(), String> {
for name in SEED_ROOT_FILES {
let from = src.join(name);
if !from.is_file() {
continue;
}
let to = dst.join(name);
copy_file_no_overwrite(&from, &to)?;
}
for name in SEED_ROOT_DIRS {
let from = src.join(name);
if !from.is_dir() {
continue;
}
let to = dst.join(name);
copy_dir_contents_no_overwrite(&from, &to)?;
}
Ok(())
}
fn copy_file_no_overwrite(from: &Path, to: &Path) -> Result<(), String> {
if to.exists() {
return Ok(());
}
if let Some(parent) = to.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("无法创建 \"{}\": {e}", parent.display()))?;
}
fs::copy(from, to).map_err(|e| {
format!(
"无法复制 \"{}\" → \"{}\": {e}",
from.display(),
to.display()
)
})?;
Ok(())
}
fn copy_dir_entry_no_overwrite(dst: &Path, entry: fs::DirEntry) -> Result<(), String> {
let file_type = entry
.file_type()
.map_err(|e| format!("无法识别 \"{}\": {e}", entry.path().display()))?;
let from = entry.path();
let to = dst.join(entry.file_name());
if file_type.is_dir() {
copy_dir_contents_no_overwrite(&from, &to)?;
} else if file_type.is_file() {
copy_file_no_overwrite(&from, &to)?;
} else if file_type.is_symlink() && from.is_file() {
copy_file_no_overwrite(&from, &to)?;
}
Ok(())
}
fn copy_dir_contents_no_overwrite(src: &Path, dst: &Path) -> Result<(), String> {
fs::create_dir_all(dst).map_err(|e| format!("无法创建 \"{}\": {e}", dst.display()))?;
let entries = fs::read_dir(src)
.map_err(|e| format!("无法读取系统配置目录 \"{}\": {e}", src.display()))?;
for entry in entries {
let entry =
entry.map_err(|e| format!("无法读取系统配置目录 \"{}\": {e}", src.display()))?;
copy_dir_entry_no_overwrite(dst, entry)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cm_config::xdg::ENV_CONFIG_DIR;
use crate::cm_config::xdg::test_env_lock;
fn recover_cwd_if_needed() {
if std::env::current_dir().is_err() {
let fallback = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let _ = std::env::set_current_dir(&fallback);
}
}
#[test]
fn seed_copies_once_and_never_overwrites() {
let _guard = test_env_lock();
recover_cwd_if_needed();
let tmp = tempfile::tempdir().expect("tempdir");
let system = tmp.path().join("etc");
let user = tmp.path().join("xdg");
fs::create_dir_all(system.join("prompts")).unwrap();
fs::create_dir_all(system.join("config/prompts")).unwrap();
fs::write(system.join("config.toml"), b"system-v1\n").unwrap();
fs::write(system.join("agent_roles.toml"), b"roles-v1\n").unwrap();
fs::write(system.join("prompts/a.md"), b"prompt\n").unwrap();
fs::write(system.join("config/prompts/role.md"), b"role\n").unwrap();
fs::create_dir_all(system.join("skills/packaged")).unwrap();
fs::write(
system.join("skills/packaged/SKILL.md"),
b"---\nname: packaged\n---\nbody\n",
)
.unwrap();
fs::write(system.join("tools.toml"), b"should-not-copy\n").unwrap();
fs::write(system.join("default_config.toml"), b"should-not-copy\n").unwrap();
assert!(ensure_user_config_seeded_from(&system, &user).unwrap());
assert_eq!(
fs::read_to_string(user.join("config.toml")).unwrap(),
"system-v1\n"
);
assert_eq!(
fs::read_to_string(user.join("agent_roles.toml")).unwrap(),
"roles-v1\n"
);
assert_eq!(
fs::read_to_string(user.join("prompts/a.md")).unwrap(),
"prompt\n"
);
assert_eq!(
fs::read_to_string(user.join("config/prompts/role.md")).unwrap(),
"role\n"
);
assert_eq!(
fs::read_to_string(user.join("skills/packaged/SKILL.md")).unwrap(),
"---\nname: packaged\n---\nbody\n"
);
assert!(!user.join("tools.toml").exists());
assert!(!user.join("default_config.toml").exists());
fs::write(system.join("config.toml"), b"system-v2\n").unwrap();
fs::write(user.join("config.toml"), b"user-edited\n").unwrap();
assert!(!ensure_user_config_seeded_from(&system, &user).unwrap());
assert_eq!(
fs::read_to_string(user.join("config.toml")).unwrap(),
"user-edited\n"
);
}
#[test]
fn seed_skips_when_system_config_missing() {
let _guard = test_env_lock();
recover_cwd_if_needed();
let tmp = tempfile::tempdir().expect("tempdir");
let system = tmp.path().join("etc");
let user = tmp.path().join("xdg");
fs::create_dir_all(&system).unwrap();
assert!(!ensure_user_config_seeded_from(&system, &user).unwrap());
assert!(!user.join("config.toml").exists());
}
#[test]
fn skip_seed_env_disables_system_seed() {
let _guard = test_env_lock();
recover_cwd_if_needed();
unsafe {
std::env::set_var(ENV_SKIP_CONFIG_SEED, "1");
}
let r = ensure_user_config_seeded_from_system();
unsafe {
std::env::remove_var(ENV_SKIP_CONFIG_SEED);
}
assert!(!r.unwrap());
}
#[test]
fn source_tree_skips_xdg_unless_config_dir_override() {
let _guard = test_env_lock();
recover_cwd_if_needed();
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.canonicalize()
.expect("repo root");
let prev = std::env::current_dir().unwrap_or_else(|_| root.clone());
unsafe {
std::env::remove_var(ENV_CONFIG_DIR);
std::env::remove_var(ENV_SKIP_CONFIG_SEED);
}
std::env::set_current_dir(&root).unwrap();
assert!(
cwd_in_crabmate_source_tree(),
"expected repo root markers under {}",
root.display()
);
assert!(
!should_resolve_xdg_user_config(),
"source tree must skip XDG without CM_CRABMATE_CONFIG_DIR"
);
assert!(
resolve_default_user_config_toml_after_seed().is_none(),
"must not resolve/seed XDG inside source tree"
);
let fake = tempfile::tempdir().expect("fake xdg");
unsafe {
std::env::set_var(ENV_CONFIG_DIR, fake.path());
std::env::set_var(ENV_SKIP_CONFIG_SEED, "1");
}
assert!(should_resolve_xdg_user_config());
assert!(
resolve_default_user_config_toml_after_seed().is_none(),
"empty override dir + skip seed => no config.toml"
);
std::env::set_current_dir(prev).unwrap();
unsafe {
std::env::remove_var(ENV_CONFIG_DIR);
std::env::remove_var(ENV_SKIP_CONFIG_SEED);
}
}
#[test]
fn interactive_cli_resolves_xdg_even_in_source_tree() {
let _guard = test_env_lock();
recover_cwd_if_needed();
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.canonicalize()
.expect("repo root");
let tmp = tempfile::tempdir().expect("tempdir");
let xdg = tmp.path().join("xdg");
std::fs::create_dir_all(&xdg).unwrap();
let user_toml = xdg.join("config.toml");
std::fs::write(&user_toml, b"[agent]\nmodel = \"xdg\"\n").unwrap();
let prev = std::env::current_dir().unwrap_or_else(|_| root.clone());
unsafe {
std::env::set_var(ENV_CONFIG_DIR, &xdg);
std::env::set_var(ENV_SKIP_CONFIG_SEED, "1");
}
std::env::set_current_dir(&root).unwrap();
assert!(cwd_in_crabmate_source_tree());
let got = resolve_interactive_cli_config_path(None);
assert_eq!(got.as_deref(), Some(user_toml.as_path()));
assert!(resolve_interactive_cli_config_path(Some("/tmp/explicit.toml")).is_none());
std::env::set_current_dir(prev).unwrap();
unsafe {
std::env::remove_var(ENV_CONFIG_DIR);
std::env::remove_var(ENV_SKIP_CONFIG_SEED);
}
}
}