use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub const MPM_CRATE_NAME: &str = "trusty-mpm";
pub const DEFAULT_WORKSPACE_DIR: &str = "trusty-mpm-projects";
pub const WORKSPACE_ROOT_ENV: &str = "TRUSTY_MPM_WORKSPACE_ROOT";
pub const DEFAULT_WORKTREES_DIRNAME: &str = ".worktrees";
pub const WORKTREES_DIRNAME_ENV: &str = "TRUSTY_MPM_WORKTREES_DIRNAME";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct WorkspaceLayoutConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_root_template: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worktrees_dirname: Option<String>,
}
#[cfg(feature = "crate-config")]
impl WorkspaceLayoutConfig {
pub fn load() -> Self {
crate::crate_config::load_or_default::<Self>(MPM_CRATE_NAME)
}
}
pub fn expand_tilde(template: &str, home: &Path) -> PathBuf {
if let Some(rest) = template.strip_prefix("~/") {
home.join(rest)
} else if template == "~" {
home.to_path_buf()
} else {
PathBuf::from(template)
}
}
pub fn resolve_workspace_root(template: Option<&str>) -> PathBuf {
let home = dirs::home_dir();
let from_env = std::env::var(WORKSPACE_ROOT_ENV)
.ok()
.filter(|raw| !raw.trim().is_empty());
let chosen = from_env
.as_deref()
.or(template)
.map(str::trim)
.filter(|s| !s.is_empty());
match (chosen, &home) {
(Some(raw), Some(h)) => expand_tilde(raw, h),
(Some(raw), None) => PathBuf::from(raw),
(None, Some(h)) => h.join(DEFAULT_WORKSPACE_DIR),
(None, None) => PathBuf::from("/tmp").join(DEFAULT_WORKSPACE_DIR),
}
}
#[cfg(feature = "crate-config")]
pub fn workspace_root() -> PathBuf {
resolve_workspace_root(
WorkspaceLayoutConfig::load()
.workspace_root_template
.as_deref(),
)
}
pub fn resolve_worktrees_dirname(configured: Option<&str>) -> String {
let from_env = std::env::var(WORKTREES_DIRNAME_ENV)
.ok()
.filter(|raw| !raw.trim().is_empty());
let Some(candidate) = from_env
.as_deref()
.or(configured)
.map(str::trim)
.filter(|s| !s.is_empty())
else {
return DEFAULT_WORKTREES_DIRNAME.to_string();
};
if !is_single_component(candidate) {
tracing::warn!(
candidate,
default = DEFAULT_WORKTREES_DIRNAME,
"worktrees_dirname must be a single path component — using the default"
);
return DEFAULT_WORKTREES_DIRNAME.to_string();
}
if RESERVED_WORKTREES_DIRNAMES.contains(&candidate) {
tracing::warn!(
candidate,
default = DEFAULT_WORKTREES_DIRNAME,
"worktrees_dirname collides with a reserved directory name — using the default"
);
return DEFAULT_WORKTREES_DIRNAME.to_string();
}
candidate.to_string()
}
const RESERVED_WORKTREES_DIRNAMES: &[&str] = &["worktrees", ".git", ".claude", ".base"];
fn is_single_component(name: &str) -> bool {
!matches!(name, "." | "..")
&& !name.contains(['/', '\\', '\0'])
&& Path::new(name).components().count() == 1
}
#[cfg(feature = "crate-config")]
pub fn worktrees_dirname() -> String {
resolve_worktrees_dirname(WorkspaceLayoutConfig::load().worktrees_dirname.as_deref())
}
#[cfg(feature = "crate-config")]
pub fn is_worktrees_dirname(name: &str) -> bool {
WorktreeDirNames::resolve().matches(name)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeDirNames {
configured: String,
}
impl WorktreeDirNames {
#[cfg(feature = "crate-config")]
pub fn resolve() -> Self {
Self {
configured: worktrees_dirname(),
}
}
pub fn from_configured(configured: Option<&str>) -> Self {
Self {
configured: resolve_worktrees_dirname(configured),
}
}
pub fn creation_name(&self) -> &str {
&self.configured
}
pub fn matches(&self, name: &str) -> bool {
name == self.configured || name == DEFAULT_WORKTREES_DIRNAME
}
}
impl Default for WorktreeDirNames {
fn default() -> Self {
Self {
configured: DEFAULT_WORKTREES_DIRNAME.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard, OnceLock};
fn env_lock() -> MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|e| e.into_inner())
}
fn clear_env() {
unsafe {
std::env::remove_var(WORKSPACE_ROOT_ENV);
std::env::remove_var(WORKTREES_DIRNAME_ENV);
}
}
#[test]
fn default_root_is_trusty_mpm_projects() {
let _guard = env_lock();
clear_env();
let root = resolve_workspace_root(None);
assert!(
root.ends_with(DEFAULT_WORKSPACE_DIR),
"expected default root to end with {DEFAULT_WORKSPACE_DIR}, got {}",
root.display()
);
}
#[test]
fn env_overrides_config_and_default() {
let _guard = env_lock();
clear_env();
unsafe { std::env::set_var(WORKSPACE_ROOT_ENV, "/tmp/from-env") };
assert_eq!(
resolve_workspace_root(Some("/tmp/from-config")),
PathBuf::from("/tmp/from-env")
);
clear_env();
}
#[test]
fn config_template_used_when_no_env() {
let _guard = env_lock();
clear_env();
assert_eq!(
resolve_workspace_root(Some("/tmp/from-config")),
PathBuf::from("/tmp/from-config")
);
}
#[test]
fn tilde_expansion() {
let home = Path::new("/home/bob");
assert_eq!(
expand_tilde("~/code", home),
PathBuf::from("/home/bob/code")
);
assert_eq!(expand_tilde("~", home), PathBuf::from("/home/bob"));
assert_eq!(expand_tilde("/abs/path", home), PathBuf::from("/abs/path"));
}
#[test]
fn default_worktrees_dirname() {
let _guard = env_lock();
clear_env();
assert_eq!(resolve_worktrees_dirname(None), DEFAULT_WORKTREES_DIRNAME);
}
#[test]
fn configured_worktrees_dirname_is_honoured() {
let _guard = env_lock();
clear_env();
assert_eq!(resolve_worktrees_dirname(Some(".sessions")), ".sessions");
unsafe { std::env::set_var(WORKTREES_DIRNAME_ENV, ".from-env") };
assert_eq!(resolve_worktrees_dirname(Some(".sessions")), ".from-env");
clear_env();
}
#[test]
fn invalid_worktrees_dirname_falls_back_to_default() {
let _guard = env_lock();
clear_env();
for bad in ["..", ".", "a/b", "../escape", "a\\b", "/abs"] {
assert_eq!(
resolve_worktrees_dirname(Some(bad)),
DEFAULT_WORKTREES_DIRNAME,
"{bad:?} must be rejected back to the built-in default"
);
}
}
#[cfg(feature = "crate-config")]
#[test]
fn config_file_drives_both_zero_argument_resolvers() {
let _guard = env_lock();
clear_env();
let fake_home = tempfile::tempdir().expect("tempdir");
let cfg_path = crate::crate_config::crate_config_path_at(fake_home.path(), MPM_CRATE_NAME);
std::fs::create_dir_all(cfg_path.parent().expect("config parent")).expect("mkdir");
std::fs::write(
&cfg_path,
"workspace_root_template: /tmp/from-yaml-root\nworktrees_dirname: .from-yaml-wt\n",
)
.expect("write config.yaml");
let real_home = std::env::var_os("HOME");
unsafe { std::env::set_var("HOME", fake_home.path()) };
let root = workspace_root();
let wt = worktrees_dirname();
unsafe {
match &real_home {
Some(h) => std::env::set_var("HOME", h),
None => std::env::remove_var("HOME"),
}
}
assert_eq!(
root,
PathBuf::from("/tmp/from-yaml-root"),
"workspace_root() must read workspace_root_template from {}",
cfg_path.display()
);
assert_eq!(
wt,
".from-yaml-wt",
"worktrees_dirname() must read worktrees_dirname from {}",
cfg_path.display()
);
}
#[test]
fn reserved_worktrees_dirname_falls_back_to_default() {
let _guard = env_lock();
clear_env();
for reserved in ["worktrees", ".git", ".claude", ".base"] {
assert_eq!(
resolve_worktrees_dirname(Some(reserved)),
DEFAULT_WORKTREES_DIRNAME,
"{reserved:?} must never become the worktree base"
);
}
assert_eq!(
resolve_worktrees_dirname(Some(DEFAULT_WORKTREES_DIRNAME)),
DEFAULT_WORKTREES_DIRNAME
);
}
#[test]
fn reserved_name_via_env_cannot_claim_the_claude_agent_store() {
let _guard = env_lock();
clear_env();
unsafe { std::env::set_var(WORKTREES_DIRNAME_ENV, "worktrees") };
let names = WorktreeDirNames::from_configured(None);
clear_env();
assert_eq!(names.creation_name(), DEFAULT_WORKTREES_DIRNAME);
assert!(
!names.matches("worktrees"),
"`.claude/worktrees/<agent>` must never be classified as a tm session worktree"
);
}
#[test]
fn detection_matches_configured_and_builtin() {
let _guard = env_lock();
clear_env();
let names = WorktreeDirNames::from_configured(Some(".sessions"));
assert_eq!(names.creation_name(), ".sessions");
assert!(names.matches(".sessions"));
assert!(names.matches(DEFAULT_WORKTREES_DIRNAME));
assert!(!names.matches("worktrees"));
assert!(!names.matches("src"));
assert_eq!(
WorktreeDirNames::default().creation_name(),
DEFAULT_WORKTREES_DIRNAME
);
}
}