use anyhow::{Context, Result, anyhow};
use figment::Figment;
use figment::providers::{Env, Serialized};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub(crate) const CONFIG_DIR_NAME: &str = ".toolpath";
pub(crate) const CONFIG_DIR_ENV: &str = "TOOLPATH_CONFIG_DIR";
pub(crate) const PATHBASE_URL_ENV: &str = "PATHBASE_URL";
pub(crate) const CONFIG_FILE_NAME: &str = "config.toml";
pub(crate) const MANIFEST_FILE_NAME: &str = "manifest.json";
pub(crate) const MANIFEST_LOCK_FILE_NAME: &str = "manifest.json.lock";
pub(crate) const CREDENTIALS_FILE_NAME: &str = "credentials.json";
pub(crate) const DOCUMENTS_DIR_NAME: &str = "documents";
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub(crate) struct Config {
pub(crate) appdata: Option<PathBuf>,
pub(crate) claude_config_dir: Option<PathBuf>,
pub(crate) copilot_home: Option<PathBuf>,
pub(crate) home: Option<PathBuf>,
pub(crate) pathbase_url: Option<String>,
#[cfg(all(unix, feature = "resume-remote"))]
pub(crate) ssh_auth_sock: Option<PathBuf>,
#[cfg(all(unix, feature = "resume-remote"))]
pub(crate) term: Option<String>,
pub(crate) toolpath_config_dir: Option<PathBuf>,
pub(crate) toolpath_query_explain: Option<String>,
pub(crate) userprofile: Option<PathBuf>,
pub(crate) xdg_data_home: Option<PathBuf>,
}
struct VerbatimEnv(Env);
impl figment::Provider for VerbatimEnv {
fn metadata(&self) -> figment::Metadata {
self.0.metadata()
}
fn data(
&self,
) -> Result<figment::value::Map<figment::Profile, figment::value::Dict>, figment::Error> {
let mut dict = figment::value::Dict::new();
for (key, value) in self.0.iter() {
dict.insert(key.as_str().to_string(), value.into());
}
Ok(self.0.profile.collect(dict))
}
}
impl Config {
const ENV_MAP: &'static [(&'static str, &'static str)] = &[
("APPDATA", "appdata"),
("CLAUDE_CONFIG_DIR", "claude_config_dir"),
("COPILOT_HOME", "copilot_home"),
("HOME", "home"),
(PATHBASE_URL_ENV, "pathbase_url"),
("SSH_AUTH_SOCK", "ssh_auth_sock"),
("TERM", "term"),
(CONFIG_DIR_ENV, "toolpath_config_dir"),
("TOOLPATH_QUERY_EXPLAIN", "toolpath_query_explain"),
("USERPROFILE", "userprofile"),
("XDG_DATA_HOME", "xdg_data_home"),
];
pub(crate) fn env_var_names() -> impl Iterator<Item = &'static str> {
Self::ENV_MAP.iter().map(|(var, _)| *var)
}
pub(crate) fn load() -> Result<Self> {
let vars: Vec<&str> = Self::env_var_names().collect();
let env = Env::raw().only(&vars).map(|key| {
Self::ENV_MAP
.iter()
.find(|(var, _)| key.as_str().eq_ignore_ascii_case(var))
.map(|(_, field)| (*field).into())
.expect("only() admits exactly the mapped variables")
});
Figment::from(Serialized::defaults(Config::default()))
.merge(VerbatimEnv(env))
.extract()
.context("failed to load configuration from the environment")
}
pub(crate) fn config_dir(&self) -> Result<PathBuf> {
if let Some(override_) = &self.toolpath_config_dir {
return Ok(override_.clone());
}
let home = self
.home
.as_ref()
.ok_or_else(|| anyhow!("$HOME is not set; cannot locate config directory"))?;
Ok(home.join(CONFIG_DIR_NAME))
}
pub(crate) fn home_dir(&self) -> Option<&PathBuf> {
self.home.as_ref().or(self.userprofile.as_ref())
}
}
pub(crate) fn config_dir() -> Result<PathBuf> {
Config::load()?.config_dir()
}
pub(crate) fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
pub(crate) fn home_relative(path: &std::path::Path, home: Option<&std::path::Path>) -> String {
if let Some(home) = home
&& let Ok(rest) = path.strip_prefix(home)
{
if rest.as_os_str().is_empty() {
return "~".to_string();
}
return format!("~/{}", rest.display());
}
path.display().to_string()
}
#[cfg(test)]
pub(crate) static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[allow(clippy::result_large_err)]
fn load_maps_every_owned_env_var() {
let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
figment::Jail::expect_with(|jail| {
jail.set_env(CONFIG_DIR_ENV, "/tmp/cfg-root");
jail.set_env("HOME", "/home/jailed");
jail.set_env("XDG_DATA_HOME", "/home/jailed/.local/share");
jail.set_env("COPILOT_HOME", "/home/jailed/.copilot");
jail.set_env("APPDATA", "/home/jailed/appdata");
jail.set_env("CLAUDE_CONFIG_DIR", "/home/jailed/.config/claude");
jail.set_env(PATHBASE_URL_ENV, "https://pathbase.test");
jail.set_env("TOOLPATH_QUERY_EXPLAIN", "1");
jail.set_env("USERPROFILE", "/home/jailed-profile");
jail.set_env("SSH_AUTH_SOCK", "/run/jailed/agent.sock");
jail.set_env("TERM", "xterm-jailed");
let config = Config::load().unwrap();
assert_eq!(
config,
Config {
appdata: Some(PathBuf::from("/home/jailed/appdata")),
claude_config_dir: Some(PathBuf::from("/home/jailed/.config/claude")),
copilot_home: Some(PathBuf::from("/home/jailed/.copilot")),
home: Some(PathBuf::from("/home/jailed")),
pathbase_url: Some("https://pathbase.test".to_string()),
#[cfg(all(unix, feature = "resume-remote"))]
ssh_auth_sock: Some(PathBuf::from("/run/jailed/agent.sock")),
#[cfg(all(unix, feature = "resume-remote"))]
term: Some("xterm-jailed".to_string()),
toolpath_config_dir: Some(PathBuf::from("/tmp/cfg-root")),
toolpath_query_explain: Some("1".to_string()),
userprofile: Some(PathBuf::from("/home/jailed-profile")),
xdg_data_home: Some(PathBuf::from("/home/jailed/.local/share")),
}
);
Ok(())
});
}
#[test]
#[allow(clippy::result_large_err)]
fn load_ignores_unowned_env_vars() {
let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
figment::Jail::expect_with(|jail| {
jail.set_env(PATHBASE_URL_ENV, "https://real.example");
jail.set_env("PATHBASE_URL_BACKUP", "https://wrong.example");
jail.set_env("TOOLPATH_QUERY_EXPLAINED", "yes");
let config = Config::load().unwrap();
assert_eq!(
config.pathbase_url,
Some("https://real.example".to_string())
);
assert_eq!(config.toolpath_query_explain, None);
Ok(())
});
}
#[test]
fn named_env_constants_are_mapped() {
let names: Vec<&str> = Config::env_var_names().collect();
assert!(names.contains(&CONFIG_DIR_ENV));
assert!(names.contains(&PATHBASE_URL_ENV));
}
#[test]
#[allow(clippy::result_large_err)]
fn load_keeps_scalar_looking_values_verbatim() {
let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
figment::Jail::expect_with(|jail| {
jail.set_env("TOOLPATH_QUERY_EXPLAIN", "01");
let config = Config::load().unwrap();
assert_eq!(config.toolpath_query_explain, Some("01".to_string()));
Ok(())
});
}
#[test]
fn config_dir_prefers_override() {
let config = Config {
toolpath_config_dir: Some(PathBuf::from("/tmp/test-toolpath")),
home: Some(PathBuf::from("/home/alex")),
..Config::default()
};
assert_eq!(
config.config_dir().unwrap(),
PathBuf::from("/tmp/test-toolpath")
);
}
#[test]
fn config_dir_falls_back_to_home() {
let config = Config {
home: Some(PathBuf::from("/home/alex")),
..Config::default()
};
assert_eq!(
config.config_dir().unwrap(),
PathBuf::from("/home/alex/.toolpath")
);
}
#[test]
fn home_dir_prefers_home_over_userprofile() {
let config = Config {
home: Some(PathBuf::from("/home/alex")),
userprofile: Some(PathBuf::from("C:/Users/alex")),
..Config::default()
};
assert_eq!(config.home_dir(), Some(&PathBuf::from("/home/alex")));
}
#[test]
fn home_dir_falls_back_to_userprofile() {
let config = Config {
userprofile: Some(PathBuf::from("C:/Users/alex")),
..Config::default()
};
assert_eq!(config.home_dir(), Some(&PathBuf::from("C:/Users/alex")));
assert_eq!(Config::default().home_dir(), None);
}
#[test]
fn config_dir_errors_without_override_or_home() {
let err = Config::default().config_dir().unwrap_err();
assert!(err.to_string().contains("$HOME"));
}
#[test]
fn config_dir_honors_override() {
let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var(CONFIG_DIR_ENV, "/tmp/test-toolpath");
}
let dir = config_dir().unwrap();
unsafe {
std::env::remove_var(CONFIG_DIR_ENV);
}
assert_eq!(dir, PathBuf::from("/tmp/test-toolpath"));
}
#[test]
fn home_relative_strips_home_prefix() {
let home = std::path::Path::new("/Users/alex");
assert_eq!(
home_relative(
std::path::Path::new("/Users/alex/.claude/projects"),
Some(home)
),
"~/.claude/projects"
);
}
#[test]
fn home_relative_returns_tilde_for_home_itself() {
let home = std::path::Path::new("/Users/alex");
assert_eq!(home_relative(home, Some(home)), "~");
}
#[test]
fn home_relative_passes_through_paths_outside_home() {
let home = std::path::Path::new("/Users/alex");
assert_eq!(
home_relative(std::path::Path::new("/tmp/elsewhere"), Some(home)),
"/tmp/elsewhere"
);
}
#[test]
fn home_relative_passes_through_when_no_home() {
assert_eq!(
home_relative(std::path::Path::new("/foo/bar"), None),
"/foo/bar"
);
}
}