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
01dfbd69e5
commit
89ac0dd84e
7 changed files with 674 additions and 15 deletions
86
Cargo.lock
generated
86
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
13
crates/eh-config/Cargo.toml
Normal file
13
crates/eh-config/Cargo.toml
Normal 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
134
crates/eh-config/src/lib.rs
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue