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 74bdf0a045
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
7 changed files with 676 additions and 17 deletions

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);
}
}