use std::path::{Path, PathBuf};
use anyhow::Context;
use serde::Deserialize;
#[derive(Debug, Clone)]
pub(crate) struct ManagedPaths {
pub(crate) root: PathBuf,
pub(crate) claude_config_dir: PathBuf,
}
impl ManagedPaths {
pub(crate) fn from_root(root: PathBuf) -> Self {
let claude_config_dir = root.join("claude-config");
Self {
root,
claude_config_dir,
}
}
}
#[derive(Debug, Deserialize, Default)]
#[non_exhaustive]
pub(crate) struct StandaloneConfig {
pub(crate) root: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
struct XdgConfigFile {
#[serde(default)]
standalone: StandaloneConfig,
}
pub(crate) fn try_register_alias(cwd: &std::path::Path) {
if let Ok(managed) = resolve_managed_paths(None) {
trusty_mpm::core::project_aliases::try_register_project_alias(cwd, &managed.root);
}
}
pub(crate) fn resolve_managed_paths(cli_root: Option<&str>) -> anyhow::Result<ManagedPaths> {
let root = resolve_root(cli_root)?;
Ok(ManagedPaths::from_root(root))
}
fn resolve_root(cli_root: Option<&str>) -> anyhow::Result<PathBuf> {
if let Some(raw) = cli_root {
let p = expand_tilde(raw)?;
return Ok(p);
}
if let Ok(raw) = std::env::var("TRUSTY_MPM_ROOT")
&& !raw.is_empty()
{
let p = expand_tilde(&raw)?;
return Ok(p);
}
if let Some(cfg_path) = xdg_config_path()
&& let Some(root) = read_standalone_root_from_file(&cfg_path)
{
let p = expand_tilde(&root)?;
return Ok(p);
}
dirs::home_dir()
.map(|h| h.join(".trusty-mpm"))
.context("cannot resolve home directory for default managed root")
}
pub(crate) fn expand_tilde(raw: &str) -> anyhow::Result<PathBuf> {
if raw == "~" || raw.starts_with("~/") {
let home = dirs::home_dir().context("cannot resolve home directory for tilde expansion")?;
let rest = raw.trim_start_matches('~').trim_start_matches('/');
if rest.is_empty() {
Ok(home)
} else {
Ok(home.join(rest))
}
} else {
Ok(PathBuf::from(raw))
}
}
pub(crate) fn xdg_config_path() -> Option<PathBuf> {
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME")
&& !xdg.is_empty()
{
return Some(PathBuf::from(xdg).join("trusty-mpm").join("config.toml"));
}
dirs::home_dir().map(|h| h.join(".config").join("trusty-mpm").join("config.toml"))
}
fn read_standalone_root_from_file(path: &Path) -> Option<String> {
let raw = std::fs::read_to_string(path).ok()?;
let cfg: XdgConfigFile = toml::from_str(&raw).ok()?;
let root = cfg.standalone.root?;
if root.is_empty() { None } else { Some(root) }
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::*;
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn set_env(key: &str, val: &str) {
unsafe { std::env::set_var(key, val) }
}
fn remove_env(key: &str) {
unsafe { std::env::remove_var(key) }
}
struct CleanEnvGuard {
saved_root: Option<String>,
saved_xdg: Option<String>,
_lock: std::sync::MutexGuard<'static, ()>,
}
impl CleanEnvGuard {
fn new() -> Self {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let saved_root = std::env::var("TRUSTY_MPM_ROOT").ok();
let saved_xdg = std::env::var("XDG_CONFIG_HOME").ok();
remove_env("TRUSTY_MPM_ROOT");
remove_env("XDG_CONFIG_HOME");
Self {
saved_root,
saved_xdg,
_lock,
}
}
}
impl Drop for CleanEnvGuard {
fn drop(&mut self) {
match &self.saved_root {
Some(v) => set_env("TRUSTY_MPM_ROOT", v),
None => remove_env("TRUSTY_MPM_ROOT"),
}
match &self.saved_xdg {
Some(v) => set_env("XDG_CONFIG_HOME", v),
None => remove_env("XDG_CONFIG_HOME"),
}
}
}
#[test]
fn test_managed_paths_claude_config_dir_is_subdir() {
let root = PathBuf::from("/tmp/my-root");
let mp = ManagedPaths::from_root(root.clone());
assert_eq!(mp.root, root);
assert_eq!(mp.claude_config_dir, root.join("claude-config"));
}
#[test]
fn test_expand_tilde_expands() {
let home = dirs::home_dir().expect("home dir required for this test");
let result = expand_tilde("~/foo/bar").unwrap();
assert_eq!(result, home.join("foo/bar"));
}
#[test]
fn test_expand_tilde_bare_tilde() {
let home = dirs::home_dir().expect("home dir required for this test");
let result = expand_tilde("~").unwrap();
assert_eq!(result, home);
}
#[test]
fn test_expand_tilde_noop_absolute() {
let result = expand_tilde("/absolute/path").unwrap();
assert_eq!(result, PathBuf::from("/absolute/path"));
}
#[test]
fn test_expand_tilde_noop_relative() {
let result = expand_tilde("relative/path").unwrap();
assert_eq!(result, PathBuf::from("relative/path"));
}
#[test]
fn test_xdg_config_path_uses_xdg_home() {
let _g = CleanEnvGuard::new();
set_env("XDG_CONFIG_HOME", "/custom/config");
let path = xdg_config_path();
assert_eq!(
path,
Some(PathBuf::from("/custom/config/trusty-mpm/config.toml"))
);
}
#[test]
fn test_xdg_config_path_falls_back_to_home_config() {
let _g = CleanEnvGuard::new();
let path = xdg_config_path();
if let Some(home) = dirs::home_dir() {
assert_eq!(
path,
Some(home.join(".config").join("trusty-mpm").join("config.toml"))
);
}
}
#[test]
fn test_resolve_default() {
let _g = CleanEnvGuard::new();
let root = resolve_root(None).expect("resolve_root should succeed");
let home = dirs::home_dir().expect("home dir required");
assert_eq!(root, home.join(".trusty-mpm"));
}
#[test]
fn test_resolve_env_wins_over_default() {
let _g = CleanEnvGuard::new();
set_env("TRUSTY_MPM_ROOT", "/tmp/from-env");
let root = resolve_root(None).expect("resolve_root should succeed");
assert_eq!(root, PathBuf::from("/tmp/from-env"));
}
#[test]
fn test_resolve_flag_wins_over_env() {
let _g = CleanEnvGuard::new();
set_env("TRUSTY_MPM_ROOT", "/tmp/from-env");
let root = resolve_root(Some("/tmp/from-flag")).expect("resolve_root should succeed");
assert_eq!(root, PathBuf::from("/tmp/from-flag"));
}
#[test]
fn test_resolve_config_file_wins_over_default() {
let tmp = tempfile::tempdir().expect("tempdir");
let cfg_dir = tmp.path().join("trusty-mpm");
std::fs::create_dir_all(&cfg_dir).unwrap();
let cfg_path = cfg_dir.join("config.toml");
std::fs::write(&cfg_path, "[standalone]\nroot = \"/tmp/from-config\"\n").unwrap();
let _g = CleanEnvGuard::new();
set_env("XDG_CONFIG_HOME", tmp.path().to_str().unwrap());
let root = resolve_root(None).expect("resolve_root should succeed");
assert_eq!(root, PathBuf::from("/tmp/from-config"));
}
#[test]
fn test_resolve_flag_wins_over_config_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let cfg_dir = tmp.path().join("trusty-mpm");
std::fs::create_dir_all(&cfg_dir).unwrap();
let cfg_path = cfg_dir.join("config.toml");
std::fs::write(&cfg_path, "[standalone]\nroot = \"/tmp/from-config\"\n").unwrap();
let _g = CleanEnvGuard::new();
set_env("XDG_CONFIG_HOME", tmp.path().to_str().unwrap());
let root = resolve_root(Some("/tmp/from-flag")).expect("resolve_root should succeed");
assert_eq!(root, PathBuf::from("/tmp/from-flag"));
}
#[test]
fn test_resolve_env_wins_over_config_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let cfg_dir = tmp.path().join("trusty-mpm");
std::fs::create_dir_all(&cfg_dir).unwrap();
let cfg_path = cfg_dir.join("config.toml");
std::fs::write(&cfg_path, "[standalone]\nroot = \"/tmp/from-config\"\n").unwrap();
let _g = CleanEnvGuard::new();
set_env("XDG_CONFIG_HOME", tmp.path().to_str().unwrap());
set_env("TRUSTY_MPM_ROOT", "/tmp/from-env");
let root = resolve_root(None).expect("resolve_root should succeed");
assert_eq!(root, PathBuf::from("/tmp/from-env"));
}
#[test]
fn test_env_and_config_file_independent_tiers() {
let tmp = tempfile::tempdir().expect("tempdir");
let cfg_dir = tmp.path().join("trusty-mpm");
std::fs::create_dir_all(&cfg_dir).unwrap();
let cfg_path = cfg_dir.join("config.toml");
std::fs::write(&cfg_path, "[standalone]\nroot = \"/tmp/from-config\"\n").unwrap();
{
let _g = CleanEnvGuard::new();
set_env("XDG_CONFIG_HOME", tmp.path().to_str().unwrap());
set_env("TRUSTY_MPM_ROOT", "/tmp/from-env");
let root = resolve_root(None).expect("resolve_root");
assert_eq!(
root,
PathBuf::from("/tmp/from-env"),
"env must win over config.toml when TRUSTY_MPM_ROOT is set"
);
}
{
let _g = CleanEnvGuard::new();
set_env("XDG_CONFIG_HOME", tmp.path().to_str().unwrap());
let root = resolve_root(None).expect("resolve_root");
assert_eq!(
root,
PathBuf::from("/tmp/from-config"),
"config.toml must be consulted when TRUSTY_MPM_ROOT is absent"
);
}
}
#[test]
fn test_resolve_config_file_missing_is_ok() {
let tmp = tempfile::tempdir().expect("tempdir");
let _g = CleanEnvGuard::new();
set_env("XDG_CONFIG_HOME", tmp.path().to_str().unwrap());
let root = resolve_root(None).expect("resolve_root should succeed");
let home = dirs::home_dir().expect("home dir required");
assert_eq!(root, home.join(".trusty-mpm"));
}
#[test]
fn test_resolve_config_file_malformed_is_ignored() {
let tmp = tempfile::tempdir().expect("tempdir");
let cfg_dir = tmp.path().join("trusty-mpm");
std::fs::create_dir_all(&cfg_dir).unwrap();
let cfg_path = cfg_dir.join("config.toml");
std::fs::write(&cfg_path, "this is not valid toml :::").unwrap();
let _g = CleanEnvGuard::new();
set_env("XDG_CONFIG_HOME", tmp.path().to_str().unwrap());
let root = resolve_root(None).expect("resolve_root should succeed");
let home = dirs::home_dir().expect("home dir required");
assert_eq!(root, home.join(".trusty-mpm"));
}
#[test]
fn test_resolve_tilde_in_env() {
let _g = CleanEnvGuard::new();
set_env("TRUSTY_MPM_ROOT", "~/my-tm-root");
let root = resolve_root(None).expect("resolve_root should succeed");
let home = dirs::home_dir().expect("home dir required");
assert_eq!(root, home.join("my-tm-root"));
}
#[test]
fn test_resolve_managed_paths_sets_claude_config_dir() {
let _g = CleanEnvGuard::new();
set_env("TRUSTY_MPM_ROOT", "/tmp/test-root");
let mp = resolve_managed_paths(None).expect("should resolve");
assert_eq!(mp.root, PathBuf::from("/tmp/test-root"));
assert_eq!(
mp.claude_config_dir,
PathBuf::from("/tmp/test-root/claude-config")
);
}
}