meta: extract configuration loading and command execution into workspace crates

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I2da9bbddc01186af23e12c0dbbf3b23e6a6a6964
This commit is contained in:
raf 2026-05-12 17:21:50 +03:00
commit 89ac0dd84e
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
7 changed files with 674 additions and 15 deletions

86
Cargo.lock generated
View file

@ -104,6 +104,27 @@ dependencies = [
"shell-words",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys",
]
[[package]]
name = "eh"
version = "0.2.0"
@ -111,18 +132,29 @@ dependencies = [
"clap",
"clap_complete",
"dialoguer",
"eh-config",
"eh-log",
"nix-command",
"regex",
"serde",
"serde_json",
"tempfile",
"textwrap",
"thiserror",
"toml",
"walkdir",
"yansi",
]
[[package]]
name = "eh-config"
version = "0.2.0"
dependencies = [
"dirs",
"eh-log",
"serde",
"toml",
]
[[package]]
name = "eh-log"
version = "0.2.0"
@ -164,6 +196,17 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "getrandom"
version = "0.4.2"
@ -234,6 +277,15 @@ version = "0.2.185"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
[[package]]
name = "libredox"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
dependencies = [
"libc",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@ -252,12 +304,25 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "nix-command"
version = "0.2.0"
dependencies = [
"thiserror",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "prettyplease"
version = "0.2.37"
@ -292,6 +357,17 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "redox_users"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror",
]
[[package]]
name = "regex"
version = "1.12.3"
@ -431,7 +507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys",
@ -533,6 +609,12 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"

View file

@ -4,6 +4,7 @@ members = [ "eh", "crates/*" ]
resolver = "3"
[workspace.package]
authors = [ "NotAShelf" ]
description = "Ergonomic Nix CLI helper"
edition = "2024"
license = "MPL-2.0"
@ -14,7 +15,12 @@ version = "0.2.0"
[workspace.dependencies]
clap = { default-features = false, features = [ "std", "help", "derive" ], version = "4.6.1" }
clap_complete = "4.6.3"
eh = { path = "./eh", version = "0.2.0" }
eh-config = { path = "./crates/eh-config", version = "0.2.0" }
eh-log = { path = "./crates/eh-log", version = "0.2.0" }
nix-command = { path = "./crates/nix-command", version = "0.2.0" }
dialoguer = { default-features = false, version = "0.12.0" }
dirs = "6.0.0"
regex = "1.12.3"
serde = { features = [ "derive" ], version = "1.0.228" }
serde_json = "1.0.149"
@ -25,9 +31,6 @@ toml = { default-features = false, features = [ "parse", "serde" ], ver
walkdir = "2.5.0"
yansi = "1.0.1"
eh = { path = "./eh", version = "0.2.0" }
eh-log = { path = "./crates/eh-log", version = "0.2.0" }
[profile.release]
codegen-units = 1
lto = true

View file

@ -0,0 +1,13 @@
[package]
name = "eh-config"
description = "Configuration loading for eh"
version.workspace = true
edition.workspace = true
authors.workspace = true
rust-version.workspace = true
[dependencies]
dirs.workspace = true
eh-log.workspace = true
serde.workspace = true
toml.workspace = true

134
crates/eh-config/src/lib.rs Normal file
View file

@ -0,0 +1,134 @@
use std::{
collections::HashMap,
env,
fs,
path::{Path, PathBuf},
};
use eh_log::log_warn;
use serde::Deserialize;
#[derive(Debug, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub impure: Option<bool>,
#[serde(default)]
pub commands: HashMap<String, CommandConfig>,
}
#[derive(Debug, Deserialize, Default, Clone)]
#[serde(deny_unknown_fields)]
pub struct CommandConfig {
#[serde(default)]
pub impure: Option<bool>,
#[serde(default)]
pub env: HashMap<String, String>,
}
impl Config {
#[must_use]
pub fn for_command(&self, command: &str) -> CommandConfig {
let mut cmd = self.commands.get(command).cloned().unwrap_or_default();
cmd.impure = cmd.impure.or(self.impure);
cmd
}
}
#[must_use]
pub fn load() -> Config {
find_project_config()
.and_then(|path| load_from_file(&path))
.or_else(|| global_config_path().and_then(|path| load_from_file(&path)))
.unwrap_or_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> {
dirs::config_dir().map(|dir| dir.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) => {
log_warn!("failed to parse config file {}: {}", path.display(), e);
None
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_config_defaults() {
let cfg: Config = toml::from_str("").unwrap();
assert!(cfg.impure.is_none());
assert!(cfg.commands.is_empty());
}
#[test]
fn command_impure_overrides_global() {
let cfg: Config = toml::from_str(
r#"
impure = false
[commands.build]
impure = true
"#,
)
.unwrap();
assert_eq!(cfg.for_command("build").impure, Some(true));
assert_eq!(cfg.for_command("run").impure, Some(false));
}
#[test]
fn command_env_supports_inline_and_table_syntax() {
let inline: Config = toml::from_str(
r#"
[commands.develop]
env = { FOO = "bar" }
"#,
)
.unwrap();
assert_eq!(
inline.for_command("develop").env.get("FOO"),
Some(&"bar".into())
);
let table: Config = toml::from_str(
r#"
[commands.shell.env]
MY_VAR = "hello"
"#,
)
.unwrap();
assert_eq!(
table.for_command("shell").env.get("MY_VAR"),
Some(&"hello".into())
);
}
#[test]
fn rejects_unknown_fields() {
assert!(toml::de::from_str::<Config>("unknown_key = true").is_err());
assert!(
toml::de::from_str::<Config>("[commands.build]\ntypo = true").is_err()
);
}
}

View file

@ -0,0 +1,10 @@
[package]
name = "nix-command"
description = "Typed Nix command construction and execution"
version.workspace = true
edition.workspace = true
authors.workspace = true
rust-version.workspace = true
[dependencies]
thiserror.workspace = true

View file

@ -0,0 +1,416 @@
use std::{
io::{self, Read, Write},
process::{Command, ExitStatus, Output, Stdio},
sync::mpsc,
thread,
time::{Duration, Instant},
};
use thiserror::Error;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
#[derive(Debug, Error)]
pub enum Error {
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("command '{command}' failed")]
CommandFailed { command: String },
#[error("nix {command} timed out after {} seconds", duration.as_secs())]
Timeout {
command: String,
duration: Duration,
},
}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CommandKind {
Build,
Develop,
Eval,
Flake,
Run,
Shell,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct CommandSpec {
pub name: &'static str,
pub print_build_logs: bool,
pub interactive: bool,
}
pub const COMMAND_SPECS: &[CommandSpec] = &[
CommandSpec {
name: "build",
print_build_logs: true,
interactive: false,
},
CommandSpec {
name: "develop",
print_build_logs: true,
interactive: true,
},
CommandSpec {
name: "eval",
print_build_logs: false,
interactive: false,
},
CommandSpec {
name: "flake",
print_build_logs: false,
interactive: false,
},
CommandSpec {
name: "run",
print_build_logs: true,
interactive: true,
},
CommandSpec {
name: "shell",
print_build_logs: true,
interactive: true,
},
];
impl CommandKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
self.spec().name
}
#[must_use]
pub const fn spec(self) -> CommandSpec {
match self {
Self::Build => COMMAND_SPECS[0],
Self::Develop => COMMAND_SPECS[1],
Self::Eval => COMMAND_SPECS[2],
Self::Flake => COMMAND_SPECS[3],
Self::Run => COMMAND_SPECS[4],
Self::Shell => COMMAND_SPECS[5],
}
}
}
impl TryFrom<&str> for CommandKind {
type Error = UnknownCommand;
fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
match value {
"build" => Ok(Self::Build),
"develop" => Ok(Self::Develop),
"eval" => Ok(Self::Eval),
"flake" => Ok(Self::Flake),
"run" => Ok(Self::Run),
"shell" => Ok(Self::Shell),
command => {
Err(UnknownCommand {
command: command.to_string(),
})
},
}
}
}
#[derive(Debug, Error, Eq, PartialEq)]
#[error("unknown nix command '{command}'")]
pub struct UnknownCommand {
command: String,
}
pub struct StdIo;
impl StdIo {
fn on_stderr(&mut self, chunk: &[u8]) {
let _ = io::stderr().write_all(chunk);
}
fn on_stdout(&mut self, chunk: &[u8]) {
let _ = io::stdout().write_all(chunk);
}
}
#[derive(Debug)]
enum PipeEvent {
Stdout(Vec<u8>),
Stderr(Vec<u8>),
Error(io::Error),
}
fn read_pipe<R: Read>(
mut reader: R,
tx: mpsc::Sender<PipeEvent>,
is_stderr: bool,
) {
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let event = if is_stderr {
PipeEvent::Stderr(buf[..n].to_vec())
} else {
PipeEvent::Stdout(buf[..n].to_vec())
};
if tx.send(event).is_err() {
break;
}
},
Err(e) => {
let _ = tx.send(PipeEvent::Error(e));
break;
},
}
}
}
pub struct NixCommand {
kind: CommandKind,
args: Vec<String>,
env: Vec<(String, String)>,
impure: bool,
print_build_logs: bool,
interactive: bool,
}
impl NixCommand {
#[must_use]
pub fn new(kind: CommandKind) -> Self {
let spec = kind.spec();
Self {
kind,
args: Vec::new(),
env: Vec::new(),
impure: false,
print_build_logs: spec.print_build_logs,
interactive: spec.interactive,
}
}
#[must_use]
pub fn arg<S: Into<String>>(mut self, arg: S) -> Self {
self.args.push(arg.into());
self
}
#[must_use]
pub fn args_ref(mut self, args: &[String]) -> Self {
self.args.extend(args.iter().cloned());
self
}
#[must_use]
pub fn env<K: Into<String>, V: Into<String>>(
mut self,
key: K,
value: V,
) -> Self {
self.env.push((key.into(), value.into()));
self
}
#[must_use]
pub fn envs<I, K, V>(mut self, env: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
self
.env
.extend(env.into_iter().map(|(k, v)| (k.into(), v.into())));
self
}
#[must_use]
pub const fn impure(mut self, yes: bool) -> Self {
self.impure = yes;
self
}
#[must_use]
pub const fn interactive(mut self, yes: bool) -> Self {
self.interactive = yes;
self
}
#[must_use]
pub const fn print_build_logs(mut self, yes: bool) -> Self {
self.print_build_logs = yes;
self
}
#[must_use]
pub fn argv(&self) -> Vec<String> {
let mut argv = vec!["nix".to_string(), self.kind.as_str().to_string()];
if self.print_build_logs
&& !self.args.iter().any(|a| a == "--no-build-output")
{
argv.push("--print-build-logs".to_string());
}
if self.impure {
argv.push("--impure".to_string());
}
argv.extend(self.args.iter().cloned());
argv
}
fn build_command(&self) -> Command {
let argv = self.argv();
let mut cmd = Command::new(&argv[0]);
cmd.args(&argv[1..]);
for (k, v) in &self.env {
cmd.env(k, v);
}
cmd
}
pub fn run_with_logs(&self, mut interceptor: StdIo) -> Result<ExitStatus> {
let mut cmd = self.build_command();
if self.interactive {
return Ok(
cmd
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.stdin(Stdio::inherit())
.status()?,
);
}
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn()?;
let stdout = child.stdout.take().ok_or_else(|| self.command_failed())?;
let stderr = child.stderr.take().ok_or_else(|| self.command_failed())?;
let (tx, rx) = mpsc::channel();
let stdout_thread = thread::spawn({
let tx = tx.clone();
move || read_pipe(stdout, tx, false)
});
let stderr_thread = thread::spawn(move || read_pipe(stderr, tx, true));
let start = Instant::now();
loop {
if start.elapsed() > DEFAULT_TIMEOUT {
self.kill_wait_join(&mut child, stdout_thread, stderr_thread)?;
return Err(self.timeout());
}
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(PipeEvent::Stdout(data)) => interceptor.on_stdout(&data),
Ok(PipeEvent::Stderr(data)) => interceptor.on_stderr(&data),
Ok(PipeEvent::Error(e)) => {
self.kill_wait_join(&mut child, stdout_thread, stderr_thread)?;
return Err(Error::Io(e));
},
Err(mpsc::RecvTimeoutError::Timeout) => {},
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
let _ = stdout_thread.join();
let _ = stderr_thread.join();
Ok(child.wait()?)
}
pub fn output(&self) -> Result<Output> {
let mut cmd = self.build_command();
if self.interactive {
return Ok(
cmd
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.stdin(Stdio::inherit())
.output()?,
);
}
Ok(cmd.output()?)
}
fn kill_wait_join(
&self,
child: &mut std::process::Child,
stdout_thread: thread::JoinHandle<()>,
stderr_thread: thread::JoinHandle<()>,
) -> Result<()> {
let _ = child.kill();
let _ = stdout_thread.join();
let _ = stderr_thread.join();
let _ = child.wait()?;
Ok(())
}
fn command_failed(&self) -> Error {
Error::CommandFailed {
command: self.kind.as_str().to_string(),
}
}
fn timeout(&self) -> Error {
Error::Timeout {
command: self.kind.as_str().to_string(),
duration: DEFAULT_TIMEOUT,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_parses_supported_commands() {
for spec in COMMAND_SPECS {
let kind = CommandKind::try_from(spec.name).unwrap();
assert_eq!(kind.as_str(), spec.name);
}
}
#[test]
fn schema_rejects_unknown_commands() {
assert_eq!(
CommandKind::try_from("repl"),
Err(UnknownCommand {
command: "repl".to_string(),
})
);
}
#[test]
fn argv_is_deterministic_and_schema_driven() {
let argv = NixCommand::new(CommandKind::Build)
.arg("nixpkgs#hello")
.impure(true)
.argv();
assert_eq!(argv, [
"nix",
"build",
"--print-build-logs",
"--impure",
"nixpkgs#hello"
]);
}
#[test]
fn no_build_output_suppresses_print_build_logs() {
let argv = NixCommand::new(CommandKind::Build)
.arg("--no-build-output")
.argv();
assert_eq!(argv, ["nix", "build", "--no-build-output"]);
}
#[test]
fn eval_defaults_to_quiet_schema() {
assert_eq!(NixCommand::new(CommandKind::Eval).argv(), ["nix", "eval"]);
}
#[test]
fn interactive_defaults_come_from_schema() {
assert!(NixCommand::new(CommandKind::Run).interactive);
assert!(NixCommand::new(CommandKind::Shell).interactive);
assert!(NixCommand::new(CommandKind::Develop).interactive);
assert!(!NixCommand::new(CommandKind::Build).interactive);
}
}

View file

@ -13,13 +13,14 @@ name = "eh"
clap.workspace = true
clap_complete.workspace = true
dialoguer.workspace = true
eh-log.workspace = true
regex.workspace = true
serde.workspace = true
serde_json.workspace = true
tempfile.workspace = true
textwrap.workspace = true
thiserror.workspace = true
toml.workspace = true
walkdir.workspace = true
yansi.workspace = true
eh-config.workspace = true
eh-log.workspace = true
nix-command.workspace = true
regex.workspace = true
serde.workspace = true
serde_json.workspace = true
tempfile.workspace = true
textwrap.workspace = true
thiserror.workspace = true
walkdir.workspace = true
yansi.workspace = true