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:
parent
d744510ab2
commit
74bdf0a045
7 changed files with 676 additions and 17 deletions
10
crates/nix-command/Cargo.toml
Normal file
10
crates/nix-command/Cargo.toml
Normal 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
|
||||
416
crates/nix-command/src/lib.rs
Normal file
416
crates/nix-command/src/lib.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue