diff --git a/Cargo.lock b/Cargo.lock index 30e8551..244e843 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -25,9 +25,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "cfg-if" @@ -37,9 +37,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "clap" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -57,18 +57,18 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.6.0" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb" +checksum = "3ff7a1dccbdd8b078c2bdebff47e404615151534d5043da397ec50286816f9cb" dependencies = [ "clap", ] [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -117,6 +117,7 @@ dependencies = [ "tempfile", "textwrap", "thiserror", + "toml", "walkdir", "yansi", ] @@ -152,9 +153,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "foldhash" @@ -186,9 +187,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "heck" @@ -204,12 +205,12 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -228,9 +229,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.184" +version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] name = "linux-raw-sys" @@ -343,9 +344,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -390,6 +391,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "shell-words" version = "1.1.1" @@ -457,6 +467,37 @@ dependencies = [ "syn", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -493,11 +534,11 @@ dependencies = [ [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -506,7 +547,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] @@ -567,6 +608,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -576,6 +623,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 816d198..c4a72db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ serde_json = "1.0.149" tempfile = "3.27.0" textwrap = "0.16.2" thiserror = "2.0.18" +toml = { default-features = false, features = [ "parse", "serde" ], version = "1.1.2" } walkdir = "2.5.0" yansi = "1.0.1" diff --git a/eh/Cargo.toml b/eh/Cargo.toml index 98c219b..f3f4e51 100644 --- a/eh/Cargo.toml +++ b/eh/Cargo.toml @@ -20,5 +20,6 @@ serde_json.workspace = true tempfile.workspace = true textwrap.workspace = true thiserror.workspace = true +toml.workspace = true walkdir.workspace = true yansi.workspace = true diff --git a/eh/src/config.rs b/eh/src/config.rs new file mode 100644 index 0000000..d30fb2e --- /dev/null +++ b/eh/src/config.rs @@ -0,0 +1,236 @@ +use std::{ + collections::HashMap, + env, + fs, + path::{Path, PathBuf}, +}; + +use serde::Deserialize; + +#[derive(Debug, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct Config { + /// When `Some(true)`, pass `--impure` to every Nix command. + /// When `Some(false)`, block automatic impure retries for every command. + /// When absent (`None`), retry behaviour is automatic (default). + #[serde(default)] + pub impure: Option, + #[serde(default)] + pub commands: HashMap, +} + +/// Per-command configuration. +#[derive(Debug, Deserialize, Default, Clone)] +#[serde(deny_unknown_fields)] +pub struct CommandConfig { + /// When `Some(true)`, pass `--impure` to the underlying Nix command. + /// When `Some(false)`, block automatic impure retries for this command. + /// When absent (`None`), the global setting is used; if that is also absent, + /// retry behaviour is automatic (default). + #[serde(default)] + pub impure: Option, + /// Additional environment variables to set for the Nix command. + #[serde(default)] + pub env: HashMap, +} + +impl Config { + /// Return the [`CommandConfig`] for `command`. + /// + /// Resolution order: per-command `impure` takes precedence over the global + /// `impure`. Neither being set means automatic retry behaviour. + pub fn for_command(&self, command: &str) -> CommandConfig { + let mut cmd = self.commands.get(command).cloned().unwrap_or_default(); + // Per-command setting wins; fall back to global. + if cmd.impure.is_none() { + cmd.impure = self.impure; + } + cmd + } +} + +/// Load configuration from the first `.eh.toml` found by walking up from the +/// current directory, or from `~/.config/eh/config.toml` as a global +/// fallback. Returns a default (empty) config if no file is found or if +/// parsing fails. +pub fn load() -> Config { + if let Some(path) = find_project_config() + && let Some(cfg) = load_from_file(&path) + { + return cfg; + } + + if let Some(path) = global_config_path() + && let Some(cfg) = load_from_file(&path) + { + return cfg; + } + + Config::default() +} + +fn find_project_config() -> Option { + let mut dir = env::current_dir().ok()?; + loop { + let candidate = dir.join(".eh.toml"); + if candidate.exists() { + return Some(candidate); + } + if !dir.pop() { + return None; + } + } +} + +fn global_config_path() -> Option { + let home = env::var("HOME").ok()?; + Some( + PathBuf::from(home) + .join(".config") + .join("eh") + .join("config.toml"), + ) +} + +fn load_from_file(path: &Path) -> Option { + let content = fs::read_to_string(path).ok()?; + match toml::de::from_str::(&content) { + Ok(cfg) => Some(cfg), + Err(e) => { + eprintln!( + "eh: warning: failed to parse config file {}: {}", + path.display(), + e + ); + None + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_config_defaults() { + let cfg: Config = toml::from_str("").unwrap(); + assert!(cfg.impure.is_none()); + assert!(cfg.commands.is_empty()); + } + + #[test] + fn test_command_config_impure_true() { + let cfg: Config = toml::from_str( + r#" + [commands.build] + impure = true + "#, + ) + .unwrap(); + assert_eq!(cfg.for_command("build").impure, Some(true)); + assert_eq!(cfg.for_command("run").impure, None); + } + + #[test] + fn test_command_config_impure_false() { + let cfg: Config = toml::from_str( + r#" + [commands.build] + impure = false + "#, + ) + .unwrap(); + assert_eq!(cfg.for_command("build").impure, Some(false)); + assert_eq!(cfg.for_command("run").impure, None); + } + + #[test] + fn test_global_impure_propagates_to_unconfigured_commands() { + let cfg: Config = toml::from_str("impure = true").unwrap(); + // Commands with no per-command entry inherit global. + assert_eq!(cfg.for_command("build").impure, Some(true)); + assert_eq!(cfg.for_command("nonexistent").impure, Some(true)); + } + + #[test] + fn test_global_impure_false_propagates_to_unconfigured_commands() { + let cfg: Config = toml::from_str("impure = false").unwrap(); + assert_eq!(cfg.for_command("build").impure, Some(false)); + } + + #[test] + fn test_per_command_impure_overrides_global() { + // Per-command setting wins over global. + let cfg: Config = toml::from_str( + r#" + impure = false + + [commands.build] + impure = true + "#, + ) + .unwrap(); + assert_eq!(cfg.for_command("build").impure, Some(true)); + // Command without per-command entry falls back to global false. + assert_eq!(cfg.for_command("run").impure, Some(false)); + } + + #[test] + fn test_command_config_env() { + let cfg: Config = toml::from_str( + r#" + [commands.develop] + env = { FOO = "bar", BAZ = "1" } + "#, + ) + .unwrap(); + let dev = cfg.for_command("develop"); + assert_eq!(dev.env.get("FOO").map(String::as_str), Some("bar")); + assert_eq!(dev.env.get("BAZ").map(String::as_str), Some("1")); + } + + #[test] + fn test_command_config_env_table_syntax() { + let cfg: Config = toml::from_str( + r#" + [commands.shell] + impure = true + + [commands.shell.env] + MY_VAR = "hello" + "#, + ) + .unwrap(); + let shell = cfg.for_command("shell"); + assert_eq!(shell.impure, Some(true)); + assert_eq!(shell.env.get("MY_VAR").map(String::as_str), Some("hello")); + } + + #[test] + fn test_for_command_missing_returns_default() { + let cfg = Config::default(); + let cc = cfg.for_command("nonexistent"); + assert_eq!(cc.impure, None); + assert!(cc.env.is_empty()); + } + + #[test] + fn test_unknown_top_level_key_is_rejected() { + let result = toml::de::from_str::("unknown_key = true"); + assert!(result.is_err(), "unknown top-level keys should be rejected"); + } + + #[test] + fn test_unknown_command_key_is_rejected() { + let result = toml::de::from_str::( + r#" + [commands.build] + typo_key = true + "#, + ); + assert!( + result.is_err(), + "unknown per-command keys should be rejected" + ); + } +} diff --git a/eh/src/lib.rs b/eh/src/lib.rs index d0ddfe6..532625a 100644 --- a/eh/src/lib.rs +++ b/eh/src/lib.rs @@ -1,4 +1,5 @@ pub mod commands; +pub mod config; pub mod error; pub mod util; diff --git a/eh/src/main.rs b/eh/src/main.rs index a9133ac..38cf7c7 100644 --- a/eh/src/main.rs +++ b/eh/src/main.rs @@ -4,6 +4,7 @@ use eh::{Cli, Command, CommandFactory, Parser}; use yansi::Paint; mod commands; +mod config; mod error; mod util; @@ -29,11 +30,13 @@ fn handle_command(command: &str, args: &[String]) -> error::Result { let hash_extractor = util::RegexHashExtractor; let fixer = util::DefaultNixFileFixer; let classifier = util::DefaultNixErrorClassifier; + let cfg = config::load(); + let cmd_cfg = cfg.for_command(command); match command { - "info" => commands::info::handle_info(args), + "info" => commands::info::handle_info(args, &cmd_cfg), - "update" => commands::update::handle_update(args), + "update" => commands::update::handle_update(args, &cmd_cfg), "run" | "shell" | "build" | "develop" => { commands::handle_nix_command( command, @@ -41,6 +44,7 @@ fn handle_command(command: &str, args: &[String]) -> error::Result { &hash_extractor, &fixer, &classifier, + &cmd_cfg, ) }, _ => unreachable!(),