diff --git a/Cargo.lock b/Cargo.lock index 4182a605..8d2d610f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -958,6 +958,21 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "parking_lot", + "pear", + "serde", + "tempfile", + "uncased", + "version_check", +] + [[package]] name = "filedescriptor" version = "0.8.3" @@ -1586,6 +1601,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + [[package]] name = "inotify" version = "0.10.2" @@ -2472,6 +2493,7 @@ dependencies = [ "assert_cmd", "chrono", "clap", + "figment", "git2", "hex", "insta", @@ -2528,6 +2550,29 @@ dependencies = [ "uuid", ] +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.117", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2778,6 +2823,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "version_check", + "yansi", +] + [[package]] name = "progenitor" version = "0.14.0" @@ -4500,6 +4558,15 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-display-width" version = "0.3.0" @@ -5320,6 +5387,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.2" diff --git a/crates/path-cli/Cargo.toml b/crates/path-cli/Cargo.toml index 863df641..2ea6f406 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -40,6 +40,7 @@ hex = "0.4" jaq-core = "3.1.0" jaq-std = "3.0.1" jaq-json = "2.0.1" +figment = { version = "0.10", features = ["env"] } [target.'cfg(not(target_os = "emscripten"))'.dependencies] toolpath-claude = { workspace = true, features = ["watcher"] } @@ -91,3 +92,4 @@ assert_cmd = "2" predicates = "3" insta = { workspace = true } toolpath-convo = { workspace = true } +figment = { version = "0.10", features = ["env", "test"] } diff --git a/crates/path-cli/src/cmd_pathbase.rs b/crates/path-cli/src/cmd_pathbase.rs index 8851c443..bfff3f7b 100644 --- a/crates/path-cli/src/cmd_pathbase.rs +++ b/crates/path-cli/src/cmd_pathbase.rs @@ -13,10 +13,10 @@ use anyhow::{Context, Result, anyhow, bail}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; +pub(crate) use crate::config::PATHBASE_URL_ENV; use crate::config::config_dir; pub(crate) const DEFAULT_URL: &str = "https://pathbase.dev"; -pub(crate) const PATHBASE_URL_ENV: &str = "PATHBASE_URL"; /// JSON blob persisted at `credentials.json`. #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/path-cli/src/config.rs b/crates/path-cli/src/config.rs index 946cd606..3c838e72 100644 --- a/crates/path-cli/src/config.rs +++ b/crates/path-cli/src/config.rs @@ -1,15 +1,27 @@ -//! Shared config-directory and home-directory resolution. +//! Environment-derived configuration. [`Config`] holds every value the +//! CLI reads from the process environment. The module also resolves +//! the config directory and the home directory. //! //! Kept in its own module so it can be used by `cmd_cache` (needed on every //! target, including wasm/emscripten) and `cmd_pathbase` (native-only). //! `cmd_pathbase` is cfg-gated; without this split, anything `cmd_cache` //! imports from it would break wasm builds. -use anyhow::{Result, anyhow}; +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"; +/// Pathbase server override (see `cmd_pathbase`). +/// +/// Declared here because [`CONFIG_ENV_VARS`] needs it on every build +/// target. `cmd_pathbase` depends on reqwest, so lib.rs compiles it +/// out of the wasm/emscripten build; a constant declared there does +/// not exist on that target. +pub(crate) const PATHBASE_URL_ENV: &str = "PATHBASE_URL"; // Every file and directory name under the config dir is declared here, // next to the directory resolution — never inline at a use site. @@ -28,15 +40,106 @@ pub(crate) const CREDENTIALS_FILE_NAME: &str = "credentials.json"; /// The document cache directory (see `cache`). pub(crate) const DOCUMENTS_DIR_NAME: &str = "documents"; +/// Every environment variable [`Config`] reads. The figment env layer +/// reads exactly these; no other variable can influence a `Config`. +/// `Env::raw` matches names case-insensitively, so each [`Config`] +/// field name matches its variable. +const CONFIG_ENV_VARS: &[&str] = &[ + "APPDATA", + "COPILOT_HOME", + "HOME", + PATHBASE_URL_ENV, + CONFIG_DIR_ENV, + "TOOLPATH_QUERY_EXPLAIN", + "USERPROFILE", + "XDG_DATA_HOME", +]; + +/// Environment-derived configuration. [`Config::load`] reads the +/// environment once, at the composition root. Code below the root +/// receives values as parameters and does not read the environment. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub(crate) struct Config { + /// `$APPDATA`: Windows harness data root. + pub(crate) appdata: Option, + /// `$COPILOT_HOME`: Copilot CLI session root override. + pub(crate) copilot_home: Option, + /// `$HOME`: config-root fallback and the harness resolvers' root. + pub(crate) home: Option, + /// `$PATHBASE_URL`: Pathbase server override (see `cmd_pathbase`). + pub(crate) pathbase_url: Option, + /// `$TOOLPATH_CONFIG_DIR`: overrides the `~/.toolpath` root. + pub(crate) toolpath_config_dir: Option, + /// `$TOOLPATH_QUERY_EXPLAIN`: query-planner diagnostics on stderr. + pub(crate) toolpath_query_explain: Option, + /// `$USERPROFILE`: Windows home, the fallback when `$HOME` is unset. + pub(crate) userprofile: Option, + /// `$XDG_DATA_HOME`: opencode's data root (Linux). + pub(crate) xdg_data_home: Option, +} + +/// [`Env`], with values emitted as verbatim strings. +/// +/// `Env`'s own `Provider::data` type-infers every value and has no +/// option to disable this: `TOOLPATH_QUERY_EXPLAIN=1` arrives as an +/// integer, not a string. Environment variables are strings; the +/// [`Config`] field types are the single type authority. +struct VerbatimEnv(Env); + +impl figment::Provider for VerbatimEnv { + fn metadata(&self) -> figment::Metadata { + self.0.metadata() + } + + fn data( + &self, + ) -> Result, 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 { + /// Read the process environment and extract an immutable `Config`. + pub(crate) fn load() -> Result { + Figment::from(Serialized::defaults(Config::default())) + .merge(VerbatimEnv(Env::raw().only(CONFIG_ENV_VARS))) + .extract() + .context("failed to load configuration from the environment") + } + + /// The configured toolpath config directory (default `~/.toolpath`, + /// overridable via `$TOOLPATH_CONFIG_DIR`). + pub(crate) fn config_dir(&self) -> Result { + 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)) + } + + /// The home directory the provider resolvers should use: + /// `$HOME`, falling back to `$USERPROFILE` (Windows). Matches the + /// resolvers' own internal fallback. + #[cfg_attr(not(test), expect(dead_code))] + pub(crate) fn home_dir(&self) -> Option<&PathBuf> { + self.home.as_ref().or(self.userprofile.as_ref()) + } +} + /// The configured toolpath config directory (default `~/.toolpath`, /// overridable via `$TOOLPATH_CONFIG_DIR`). +/// +/// Transitional: loads a [`Config`] per call. New code takes `&Config` +/// as a parameter and calls [`Config::config_dir`]. pub(crate) fn config_dir() -> Result { - if let Some(override_) = std::env::var_os(CONFIG_DIR_ENV) { - return Ok(PathBuf::from(override_)); - } - let home = std::env::var_os("HOME") - .ok_or_else(|| anyhow!("$HOME is not set — cannot locate config directory"))?; - Ok(PathBuf::from(home).join(CONFIG_DIR_NAME)) + Config::load()?.config_dir() } /// Cross-platform `$HOME` lookup matching the providers' internal helpers. @@ -74,6 +177,135 @@ pub(crate) static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(() mod tests { use super::*; + /// `figment::Jail` restores the variables it sets, but it serializes + /// only against other Jail tests. Hold `TEST_ENV_LOCK` too: other + /// test modules mutate `$HOME` / `$TOOLPATH_CONFIG_DIR` under that + /// lock. + // result_large_err: the Jail closure returns figment's own + // 208-byte error type. + #[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(PATHBASE_URL_ENV, "https://pathbase.test"); + jail.set_env("TOOLPATH_QUERY_EXPLAIN", "1"); + jail.set_env("USERPROFILE", "/home/jailed-profile"); + let config = Config::load().unwrap(); + assert_eq!( + config, + Config { + appdata: Some(PathBuf::from("/home/jailed/appdata")), + copilot_home: Some(PathBuf::from("/home/jailed/.copilot")), + home: Some(PathBuf::from("/home/jailed")), + pathbase_url: Some("https://pathbase.test".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(()) + }); + } + + /// Variables outside [`CONFIG_ENV_VARS`] never reach the `Config`. + /// The `only` filter and serde's unknown-field drop both enforce + /// this; the test observes their combined effect and cannot tell + /// them apart. Jail does not clear the ambient environment. Assert + /// only on fields whose variables are set here or never exported + /// ambiently (`TOOLPATH_QUERY_EXPLAIN`, unlike `PATHBASE_URL` or + /// `HOME`). + #[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(()) + }); + } + + /// Values reach the `Config` verbatim. Type inference would turn + /// `01` into the integer `1`. + #[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")); + } + + /// The transitional free function honors the env override + /// end-to-end. #[test] fn config_dir_honors_override() { let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());