use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use trusty_common::github_path::GithubPath;
pub const CRATE_NAME: &str = "trusty-mpm";
pub const DEFAULT_WORKSPACE_DIR: &str = "trusty-mpm-projects";
pub const WORKSPACE_ROOT_ENV: &str = "TRUSTY_MPM_WORKSPACE_ROOT";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrustyToolsConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_root_template: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_resume: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub watch: Option<WatchConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github: Option<GithubConfig>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct GithubConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_dir: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_env: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub account: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
}
pub const DEFAULT_GITHUB_HOST: &str = "github.com";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct WatchConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interval_secs: Option<u64>,
}
impl TrustyToolsConfig {
pub fn load() -> Self {
trusty_common::crate_config::load_or_default::<Self>(CRATE_NAME)
}
}
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 workspace_root(config: &TrustyToolsConfig) -> PathBuf {
let home = dirs::home_dir();
if let Ok(raw) = std::env::var(WORKSPACE_ROOT_ENV) {
let raw = raw.trim();
if !raw.is_empty() {
return match &home {
Some(h) => expand_tilde(raw, h),
None => PathBuf::from(raw),
};
}
}
if let Some(template) = config
.workspace_root_template
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
return match &home {
Some(h) => expand_tilde(template, h),
None => PathBuf::from(template),
};
}
match home {
Some(h) => h.join(DEFAULT_WORKSPACE_DIR),
None => PathBuf::from("/tmp").join(DEFAULT_WORKSPACE_DIR),
}
}
pub fn workspace_subpath(config: &TrustyToolsConfig, gh: &GithubPath) -> PathBuf {
workspace_root(config).join(&gh.owner).join(&gh.repo)
}
#[cfg(test)]
mod tests {
use super::*;
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
#[test]
fn default_template_is_trusty_mpm_projects() {
let _g = env_lock();
unsafe { std::env::remove_var(WORKSPACE_ROOT_ENV) };
let root = workspace_root(&TrustyToolsConfig::default());
assert!(
root.ends_with(DEFAULT_WORKSPACE_DIR),
"expected …/{DEFAULT_WORKSPACE_DIR}, got {}",
root.display()
);
}
#[test]
fn env_overrides_config_and_default() {
let _g = env_lock();
let cfg = TrustyToolsConfig {
workspace_root_template: Some("~/from-config".into()),
..Default::default()
};
unsafe { std::env::set_var(WORKSPACE_ROOT_ENV, "/explicit/env/root") };
let root = workspace_root(&cfg);
unsafe { std::env::remove_var(WORKSPACE_ROOT_ENV) };
assert_eq!(root, PathBuf::from("/explicit/env/root"));
}
#[test]
fn config_template_used_when_no_env() {
let _g = env_lock();
unsafe { std::env::remove_var(WORKSPACE_ROOT_ENV) };
let cfg = TrustyToolsConfig {
workspace_root_template: Some("/custom/projects".into()),
..Default::default()
};
let root = workspace_root(&cfg);
assert_eq!(root, PathBuf::from("/custom/projects"));
}
#[test]
fn tilde_expansion() {
let home = PathBuf::from("/home/bob");
assert_eq!(
expand_tilde("~/trusty-mpm-projects", &home),
PathBuf::from("/home/bob/trusty-mpm-projects")
);
assert_eq!(expand_tilde("~", &home), home);
assert_eq!(expand_tilde("/abs/path", &home), PathBuf::from("/abs/path"));
}
#[test]
fn github_config_yaml_round_trip() {
let cfg = TrustyToolsConfig {
github: Some(GithubConfig {
config_dir: Some(PathBuf::from("/home/bob/.config/gh-work")),
token_env: Some("WORK_GH_TOKEN".into()),
account: Some("bob-work".into()),
host: Some("github.example.com".into()),
}),
..Default::default()
};
let yaml = serde_yaml::to_string(&cfg).expect("serialise");
let back: TrustyToolsConfig = serde_yaml::from_str(&yaml).expect("deserialise");
assert_eq!(cfg, back);
assert!(!yaml.contains("workspace_root_template"), "yaml: {yaml}");
assert!(yaml.contains("github:"), "yaml: {yaml}");
}
#[test]
fn github_config_stores_only_env_name() {
let cfg = GithubConfig {
token_env: Some("MY_GH_TOKEN".into()),
..Default::default()
};
let yaml = serde_yaml::to_string(&cfg).expect("serialise");
assert!(yaml.contains("token_env"), "yaml: {yaml}");
assert!(yaml.contains("MY_GH_TOKEN"), "yaml: {yaml}");
assert!(
!yaml.contains("token:"),
"must not have a bare token field: {yaml}"
);
}
#[test]
fn subpath_nests_owner_repo() {
let _g = env_lock();
unsafe { std::env::remove_var(WORKSPACE_ROOT_ENV) };
let cfg = TrustyToolsConfig {
workspace_root_template: Some("/projects".into()),
..Default::default()
};
let gh = GithubPath {
owner: "bobmatnyc".into(),
repo: "trusty-tools".into(),
};
assert_eq!(
workspace_subpath(&cfg, &gh),
PathBuf::from("/projects/bobmatnyc/trusty-tools")
);
}
}