diff --git a/Cargo.lock b/Cargo.lock index dd5c19e..9758723 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,24 +104,56 @@ 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" dependencies = [ "clap", "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" @@ -163,6 +195,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" @@ -233,6 +276,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" @@ -251,12 +303,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" @@ -291,6 +356,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" @@ -430,7 +506,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", @@ -532,6 +608,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" diff --git a/Cargo.toml b/Cargo.toml index 931f336..cb7de60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,9 +13,15 @@ rust-version = "1.94.0" version = "0.2.0" [workspace.dependencies] +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" } + clap = { default-features = false, features = [ "std", "help", "derive" ], version = "4.6.0" } clap_complete = "4.6.0" dialoguer = { default-features = false, version = "0.12.0" } +dirs = "6.0.0" regex = "1.12.3" serde = { features = [ "derive" ], version = "1.0.149" } serde_json = "1.0.149" @@ -26,9 +32,6 @@ toml = { default-features = false, features = [ "parse", "serde" ], ver walkdir = "2.5.0" yansi = "1.0.1" -eh = { path = "./eh" } -eh-log = { path = "./crates/eh-log" } - [profile.release] codegen-units = 1 lto = true diff --git a/crates/eh-config/Cargo.toml b/crates/eh-config/Cargo.toml new file mode 100644 index 0000000..28ec0e1 --- /dev/null +++ b/crates/eh-config/Cargo.toml @@ -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 diff --git a/crates/eh-config/src/lib.rs b/crates/eh-config/src/lib.rs new file mode 100644 index 0000000..5b15dde --- /dev/null +++ b/crates/eh-config/src/lib.rs @@ -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, + #[serde(default)] + pub commands: HashMap, +} + +#[derive(Debug, Deserialize, Default, Clone)] +#[serde(deny_unknown_fields)] +pub struct CommandConfig { + #[serde(default)] + pub impure: Option, + #[serde(default)] + pub env: HashMap, +} + +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 { + 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 { + dirs::config_dir().map(|dir| dir.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) => { + 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::("unknown_key = true").is_err()); + assert!( + toml::de::from_str::("[commands.build]\ntypo = true").is_err() + ); + } +} diff --git a/crates/nix-command/Cargo.toml b/crates/nix-command/Cargo.toml new file mode 100644 index 0000000..b18b531 --- /dev/null +++ b/crates/nix-command/Cargo.toml @@ -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 diff --git a/crates/nix-command/src/lib.rs b/crates/nix-command/src/lib.rs new file mode 100644 index 0000000..c3ef054 --- /dev/null +++ b/crates/nix-command/src/lib.rs @@ -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 = std::result::Result; + +#[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 { + 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), + Stderr(Vec), + Error(io::Error), +} + +fn read_pipe( + mut reader: R, + tx: mpsc::Sender, + 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, + 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>(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, V: Into>( + mut self, + key: K, + value: V, + ) -> Self { + self.env.push((key.into(), value.into())); + self + } + + #[must_use] + pub fn envs(mut self, env: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/eh/Cargo.toml b/eh/Cargo.toml index f3f4e51..0fba45b 100644 --- a/eh/Cargo.toml +++ b/eh/Cargo.toml @@ -11,15 +11,16 @@ crate-type = [ "lib" ] name = "eh" [dependencies] -clap.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 +clap.workspace = true +dialoguer.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