config: add per-command and global impure knobs; bump deps

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Icadc94f1e1ca1c007feee7766c60847c6a6a6964
This commit is contained in:
raf 2026-04-02 23:46:03 +03:00
commit e385c74b57
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
6 changed files with 321 additions and 25 deletions

99
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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

236
eh/src/config.rs Normal file
View file

@ -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<bool>,
#[serde(default)]
pub commands: HashMap<String, CommandConfig>,
}
/// 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<bool>,
/// Additional environment variables to set for the Nix command.
#[serde(default)]
pub env: HashMap<String, String>,
}
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<PathBuf> {
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<PathBuf> {
let home = env::var("HOME").ok()?;
Some(
PathBuf::from(home)
.join(".config")
.join("eh")
.join("config.toml"),
)
}
fn load_from_file(path: &Path) -> Option<Config> {
let content = fs::read_to_string(path).ok()?;
match toml::de::from_str::<Config>(&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::<Config>("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::<Config>(
r#"
[commands.build]
typo_key = true
"#,
);
assert!(
result.is_err(),
"unknown per-command keys should be rejected"
);
}
}

View file

@ -1,4 +1,5 @@
pub mod commands;
pub mod config;
pub mod error;
pub mod util;

View file

@ -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<i32> {
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<i32> {
&hash_extractor,
&fixer,
&classifier,
&cmd_cfg,
)
},
_ => unreachable!(),