Compare commits
5 commits
7b3452ef18
...
43fdbe5ae6
| Author | SHA1 | Date | |
|---|---|---|---|
|
43fdbe5ae6 |
|||
|
3f1d906bb2 |
|||
|
74bdf0a045 |
|||
|
d744510ab2 |
|||
|
4707a7e49f |
24 changed files with 1598 additions and 2078 deletions
|
|
@ -24,4 +24,3 @@ unstable_features = true
|
|||
use_field_init_shorthand = true
|
||||
use_try_shorthand = true
|
||||
wrap_comments = true
|
||||
|
||||
|
|
|
|||
86
Cargo.lock
generated
86
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "eh-log"
|
||||
description = "Styled logging for eh"
|
||||
description = "Tiny, styled logging crate for eh"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,26 +1,64 @@
|
|||
use std::fmt;
|
||||
use std::{
|
||||
fmt,
|
||||
sync::atomic::{AtomicI8, Ordering},
|
||||
};
|
||||
|
||||
use yansi::Paint;
|
||||
|
||||
static VERBOSITY: AtomicI8 = AtomicI8::new(0);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum Level {
|
||||
Error = -2,
|
||||
Warn = -1,
|
||||
Info = 0,
|
||||
Debug = 1,
|
||||
}
|
||||
|
||||
pub fn set_verbosity(verbosity: i8) {
|
||||
VERBOSITY.store(verbosity, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn enabled(level: Level) -> bool {
|
||||
VERBOSITY.load(Ordering::Relaxed) >= level as i8
|
||||
}
|
||||
|
||||
pub fn info(args: fmt::Arguments) {
|
||||
eprintln!(" {} {args}", "->".green().bold());
|
||||
if enabled(Level::Info) {
|
||||
eprintln!(" {} {args}", "->".green().bold());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn debug(args: fmt::Arguments) {
|
||||
if enabled(Level::Debug) {
|
||||
eprintln!(" {} {args}", "*".blue().dim());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn warn(args: fmt::Arguments) {
|
||||
eprintln!(" {} {args}", "->".yellow().bold());
|
||||
if enabled(Level::Warn) {
|
||||
eprintln!(" {} {args}", "->".yellow().bold());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(args: fmt::Arguments) {
|
||||
eprintln!(" {} {args}", "!".red().bold());
|
||||
if enabled(Level::Error) {
|
||||
eprintln!(" {} {args}", "!".red().bold());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hint(args: fmt::Arguments) {
|
||||
eprintln!(" {} {args}", "~".yellow().dim());
|
||||
if enabled(Level::Info) {
|
||||
eprintln!(" {} {args}", "~".yellow().dim());
|
||||
}
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! log_info { ($($t:tt)*) => { $crate::info(format_args!($($t)*)) } }
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! log_debug { ($($t:tt)*) => { $crate::debug(format_args!($($t)*)) } }
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! log_warn { ($($t:tt)*) => { $crate::warn(format_args!($($t)*)) } }
|
||||
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use eh_log::{log_error, log_info};
|
||||
use nix_command::{CommandKind, NixCommand};
|
||||
use serde::Deserialize;
|
||||
use yansi::Paint;
|
||||
|
||||
use crate::{
|
||||
commands::NixCommand,
|
||||
error::{EhError, Result},
|
||||
util::{make_eval_expr, print_error_suggestions},
|
||||
eval::make_eval_expr,
|
||||
nix_config::ApplyCommandConfig,
|
||||
suggestions::print_error_suggestions,
|
||||
};
|
||||
|
||||
const UNKNOWN_LICENSE: &str = "Unknown";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PackageMeta {
|
||||
name: String,
|
||||
|
|
@ -33,41 +37,24 @@ struct PackageOutputs {
|
|||
|
||||
pub fn handle_info(
|
||||
args: &[String],
|
||||
cfg: &crate::config::CommandConfig,
|
||||
cfg: &eh_config::CommandConfig,
|
||||
) -> Result<i32> {
|
||||
// Get the package argument (skip flags)
|
||||
let pkg = args
|
||||
.iter()
|
||||
.find(|arg| !arg.starts_with('-'))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ".".to_string());
|
||||
|
||||
let eval_arg = make_eval_expr(&pkg);
|
||||
let pkg_name: String = if eval_arg.contains("#") {
|
||||
eval_arg
|
||||
.split("#")
|
||||
.last()
|
||||
.unwrap_or(&eval_arg)
|
||||
.trim_end_matches(".meta")
|
||||
.to_string()
|
||||
} else {
|
||||
eval_arg.trim_end_matches(".meta").to_string()
|
||||
};
|
||||
// Handle .# case - show "default" as the package name
|
||||
let pkg_name = if pkg_name.is_empty() {
|
||||
"default".to_string()
|
||||
} else {
|
||||
pkg_name
|
||||
};
|
||||
let eval_arg = make_eval_expr(&pkg)?;
|
||||
let pkg_name = package_name_from_eval_expr(&eval_arg);
|
||||
|
||||
log_info!("Fetching info for {}", pkg_name.bold());
|
||||
|
||||
// Fetch metadata
|
||||
let meta_cmd = NixCommand::new("eval")
|
||||
let meta_cmd = NixCommand::new(CommandKind::Eval)
|
||||
.arg("--json")
|
||||
.arg(&eval_arg)
|
||||
.print_build_logs(false)
|
||||
.with_config(cfg);
|
||||
.apply_config(cfg);
|
||||
|
||||
let meta_output = meta_cmd.output()?;
|
||||
|
||||
|
|
@ -87,16 +74,15 @@ pub fn handle_info(
|
|||
)))
|
||||
})?;
|
||||
|
||||
// Fetch outputs
|
||||
let outputs_expr = eval_arg
|
||||
.strip_suffix(".meta")
|
||||
.unwrap_or(&eval_arg)
|
||||
.to_string();
|
||||
let outputs_cmd = NixCommand::new("eval")
|
||||
let outputs_cmd = NixCommand::new(CommandKind::Eval)
|
||||
.arg("--json")
|
||||
.arg(format!("{}.outputs", outputs_expr))
|
||||
.print_build_logs(false)
|
||||
.with_config(cfg);
|
||||
.apply_config(cfg);
|
||||
|
||||
let outputs_output = outputs_cmd.output()?;
|
||||
let outputs: Option<PackageOutputs> = if outputs_output.status.success() {
|
||||
|
|
@ -105,12 +91,49 @@ pub fn handle_info(
|
|||
None
|
||||
};
|
||||
|
||||
// Print formatted info
|
||||
print_package_info(&meta, outputs.as_ref(), &pkg);
|
||||
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn package_name_from_eval_expr(eval_arg: &str) -> String {
|
||||
let name = eval_arg
|
||||
.rsplit_once('#')
|
||||
.map_or(eval_arg, |(_, name)| name)
|
||||
.trim_end_matches(".meta");
|
||||
if name.is_empty() { "default" } else { name }.to_string()
|
||||
}
|
||||
|
||||
fn license_name(license: &serde_json::Value) -> Option<String> {
|
||||
match license {
|
||||
serde_json::Value::String(s) => Some(s.clone()),
|
||||
serde_json::Value::Object(obj) => {
|
||||
obj
|
||||
.get("spdxId")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| obj.get("shortName").and_then(|v| v.as_str()))
|
||||
.map(str::to_string)
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn format_license(license: &serde_json::Value) -> String {
|
||||
match license {
|
||||
serde_json::Value::Array(licenses) => {
|
||||
let names = licenses.iter().filter_map(license_name).collect::<Vec<_>>();
|
||||
if names.is_empty() {
|
||||
UNKNOWN_LICENSE.to_string()
|
||||
} else {
|
||||
names.join(", ")
|
||||
}
|
||||
},
|
||||
license => {
|
||||
license_name(license).unwrap_or_else(|| UNKNOWN_LICENSE.to_string())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn print_package_info(
|
||||
meta: &PackageMeta,
|
||||
outputs: Option<&PackageOutputs>,
|
||||
|
|
@ -118,7 +141,6 @@ fn print_package_info(
|
|||
) {
|
||||
println!();
|
||||
|
||||
// Header
|
||||
println!(" {} {}", "Package:".bold(), meta.name);
|
||||
|
||||
if let Some(ref version) = meta.version {
|
||||
|
|
@ -129,7 +151,6 @@ fn print_package_info(
|
|||
println!(" {} {}", "Description:".bold(), desc);
|
||||
}
|
||||
|
||||
// Show long description if available and different from short description
|
||||
if let Some(ref long_desc) = meta.long_description {
|
||||
let should_show = meta
|
||||
.description
|
||||
|
|
@ -138,7 +159,6 @@ fn print_package_info(
|
|||
.unwrap_or(true);
|
||||
if should_show {
|
||||
println!();
|
||||
// Wrap long description to 70 chars for readability
|
||||
let wrapped = textwrap::fill(long_desc, 70);
|
||||
for line in wrapped.lines() {
|
||||
println!(" {}", line);
|
||||
|
|
@ -146,58 +166,17 @@ fn print_package_info(
|
|||
}
|
||||
}
|
||||
|
||||
// License
|
||||
if let Some(ref license) = meta.license {
|
||||
let license_str = match license {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Object(obj) => {
|
||||
obj
|
||||
.get("spdxId")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| obj.get("shortName").and_then(|v| v.as_str()))
|
||||
.unwrap_or("Unknown")
|
||||
.to_string()
|
||||
},
|
||||
serde_json::Value::Array(licenses) => {
|
||||
// Handle multiple licenses (e.g., neovim has Apache-2.0 AND Vim)
|
||||
let license_names: Vec<String> = licenses
|
||||
.iter()
|
||||
.filter_map(|lic| {
|
||||
match lic {
|
||||
serde_json::Value::Object(obj) => {
|
||||
obj
|
||||
.get("spdxId")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| obj.get("shortName").and_then(|v| v.as_str()))
|
||||
.map(|s| s.to_string())
|
||||
},
|
||||
serde_json::Value::String(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if license_names.is_empty() {
|
||||
"Unknown".to_string()
|
||||
} else {
|
||||
license_names.join(", ")
|
||||
}
|
||||
},
|
||||
_ => "Unknown".to_string(),
|
||||
};
|
||||
println!(" {} {}", "License:".bold(), license_str);
|
||||
println!(" {} {}", "License:".bold(), format_license(license));
|
||||
}
|
||||
|
||||
// Homepage
|
||||
if let Some(ref homepage) = meta.homepage {
|
||||
println!(" {} {}", "Homepage:".bold(), homepage);
|
||||
}
|
||||
|
||||
// Meta section
|
||||
println!();
|
||||
println!(" {}", "Meta:".bold());
|
||||
|
||||
// Status indicators
|
||||
let mut status_parts = Vec::new();
|
||||
if meta.broken == Some(true) {
|
||||
status_parts.push("Broken".red().to_string());
|
||||
|
|
@ -215,7 +194,6 @@ fn print_package_info(
|
|||
println!(" {} {}", "Status:".bold(), status_parts.join(", "));
|
||||
}
|
||||
|
||||
// Platforms
|
||||
if let Some(ref platforms) = meta.platforms {
|
||||
let platform_list: Vec<_> = platforms.iter().take(4).cloned().collect();
|
||||
let platform_str = if platforms.len() > 4 {
|
||||
|
|
@ -230,7 +208,6 @@ fn print_package_info(
|
|||
println!(" {} {}", "Platforms:".bold(), platform_str);
|
||||
}
|
||||
|
||||
// Outputs section
|
||||
if let Some(outputs) = outputs {
|
||||
println!();
|
||||
println!(" {}", "Outputs:".bold());
|
||||
|
|
@ -241,7 +218,6 @@ fn print_package_info(
|
|||
}
|
||||
}
|
||||
|
||||
// Usage section
|
||||
println!();
|
||||
println!(" {}", "Usage:".bold());
|
||||
println!(
|
||||
|
|
|
|||
|
|
@ -1,486 +1,27 @@
|
|||
use std::{
|
||||
io::{self, Read, Write},
|
||||
process::{Command, ExitStatus, Output, Stdio},
|
||||
sync::mpsc,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
error::{EhError, Result},
|
||||
util::{
|
||||
HashExtractor,
|
||||
NixErrorClassifier,
|
||||
NixFileFixer,
|
||||
handle_nix_with_retry,
|
||||
},
|
||||
error::Result,
|
||||
hash::{HashExtractor, NixFileFixer},
|
||||
retry::{NixErrorClassifier, handle_nix_with_retry},
|
||||
};
|
||||
|
||||
pub mod info;
|
||||
pub mod update;
|
||||
|
||||
const DEFAULT_BUFFER_SIZE: usize = 4096;
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
pub trait LogInterceptor: Send {
|
||||
fn on_stderr(&mut self, chunk: &[u8]);
|
||||
fn on_stdout(&mut self, chunk: &[u8]);
|
||||
}
|
||||
|
||||
pub struct StdIoInterceptor;
|
||||
|
||||
impl LogInterceptor for StdIoInterceptor {
|
||||
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; DEFAULT_BUFFER_SIZE];
|
||||
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 {
|
||||
subcommand: String,
|
||||
args: Vec<String>,
|
||||
env: Vec<(String, String)>,
|
||||
impure: bool,
|
||||
print_build_logs: bool,
|
||||
interactive: bool,
|
||||
}
|
||||
|
||||
impl NixCommand {
|
||||
pub fn new<S: Into<String>>(subcommand: S) -> Self {
|
||||
Self {
|
||||
subcommand: subcommand.into(),
|
||||
args: Vec::new(),
|
||||
env: Vec::new(),
|
||||
impure: false,
|
||||
print_build_logs: true,
|
||||
interactive: false,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
/// Apply per-command configuration: sets `--impure` (when explicitly enabled)
|
||||
/// and any extra environment variables declared in the config file. Call
|
||||
/// this before any retry-specific overrides so that retry logic can still
|
||||
/// force `impure(true)` afterwards.
|
||||
#[must_use]
|
||||
pub fn with_config(mut self, cfg: &crate::config::CommandConfig) -> Self {
|
||||
if cfg.impure == Some(true) {
|
||||
self = self.impure(true);
|
||||
}
|
||||
for (k, v) in &cfg.env {
|
||||
self = self.env(k, v);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
fn build_command(&self) -> Command {
|
||||
let mut cmd = Command::new("nix");
|
||||
cmd.arg(&self.subcommand);
|
||||
|
||||
if self.print_build_logs
|
||||
&& !self.args.iter().any(|a| a == "--no-build-output")
|
||||
{
|
||||
cmd.arg("--print-build-logs");
|
||||
}
|
||||
if self.impure {
|
||||
cmd.arg("--impure");
|
||||
}
|
||||
for (k, v) in &self.env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
cmd.args(&self.args);
|
||||
cmd
|
||||
}
|
||||
|
||||
pub fn run_with_logs<I: LogInterceptor + 'static>(
|
||||
&self,
|
||||
mut interceptor: I,
|
||||
) -> Result<ExitStatus> {
|
||||
let mut cmd = self.build_command();
|
||||
|
||||
if self.interactive {
|
||||
cmd.stdout(Stdio::inherit());
|
||||
cmd.stderr(Stdio::inherit());
|
||||
cmd.stdin(Stdio::inherit());
|
||||
return Ok(cmd.status()?);
|
||||
}
|
||||
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn()?;
|
||||
let stdout = child.stdout.take().ok_or_else(|| {
|
||||
EhError::CommandFailed {
|
||||
command: format!("nix {}", self.subcommand),
|
||||
}
|
||||
})?;
|
||||
let stderr = child.stderr.take().ok_or_else(|| {
|
||||
EhError::CommandFailed {
|
||||
command: format!("nix {}", self.subcommand),
|
||||
}
|
||||
})?;
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
let tx_out = tx.clone();
|
||||
let stdout_thread = thread::spawn(move || read_pipe(stdout, tx_out, false));
|
||||
|
||||
let tx_err = tx;
|
||||
let stderr_thread = thread::spawn(move || read_pipe(stderr, tx_err, true));
|
||||
|
||||
let start_time = Instant::now();
|
||||
|
||||
loop {
|
||||
if start_time.elapsed() > DEFAULT_TIMEOUT {
|
||||
let _ = child.kill();
|
||||
let _ = stdout_thread.join();
|
||||
let _ = stderr_thread.join();
|
||||
let _ = child.wait();
|
||||
return Err(EhError::Timeout {
|
||||
command: format!("nix {}", self.subcommand),
|
||||
duration: DEFAULT_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)) => {
|
||||
let _ = child.kill();
|
||||
let _ = stdout_thread.join();
|
||||
let _ = stderr_thread.join();
|
||||
let _ = child.wait();
|
||||
return Err(EhError::Io(e));
|
||||
},
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {},
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
|
||||
let _ = stdout_thread.join();
|
||||
let _ = stderr_thread.join();
|
||||
|
||||
let status = child.wait()?;
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
pub fn output(&self) -> Result<Output> {
|
||||
let mut cmd = self.build_command();
|
||||
|
||||
if self.interactive {
|
||||
cmd.stdout(Stdio::inherit());
|
||||
cmd.stderr(Stdio::inherit());
|
||||
cmd.stdin(Stdio::inherit());
|
||||
return Ok(cmd.output()?);
|
||||
}
|
||||
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn()?;
|
||||
let stdout = child.stdout.take();
|
||||
let stderr = child.stderr.take();
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
let tx_out = tx.clone();
|
||||
let stdout_thread = thread::spawn(move || {
|
||||
let mut buf = Vec::new();
|
||||
if let Some(mut r) = stdout {
|
||||
let _ = r.read_to_end(&mut buf);
|
||||
}
|
||||
let _ = tx_out.send((false, buf));
|
||||
});
|
||||
|
||||
let tx_err = tx;
|
||||
let stderr_thread = thread::spawn(move || {
|
||||
let mut buf = Vec::new();
|
||||
if let Some(mut r) = stderr {
|
||||
let _ = r.read_to_end(&mut buf);
|
||||
}
|
||||
let _ = tx_err.send((true, buf));
|
||||
});
|
||||
|
||||
let start_time = Instant::now();
|
||||
let mut stdout_buf = Vec::new();
|
||||
let mut stderr_buf = Vec::new();
|
||||
let mut received = 0;
|
||||
|
||||
while received < 2 {
|
||||
let remaining = DEFAULT_TIMEOUT
|
||||
.checked_sub(start_time.elapsed())
|
||||
.unwrap_or(Duration::ZERO);
|
||||
|
||||
if remaining.is_zero() {
|
||||
let _ = child.kill();
|
||||
let _ = stdout_thread.join();
|
||||
let _ = stderr_thread.join();
|
||||
let _ = child.wait();
|
||||
return Err(EhError::Timeout {
|
||||
command: format!("nix {}", self.subcommand),
|
||||
duration: DEFAULT_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
match rx.recv_timeout(remaining) {
|
||||
Ok((true, buf)) => {
|
||||
stderr_buf = buf;
|
||||
received += 1;
|
||||
},
|
||||
Ok((false, buf)) => {
|
||||
stdout_buf = buf;
|
||||
received += 1;
|
||||
},
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||
let _ = child.kill();
|
||||
let _ = stdout_thread.join();
|
||||
let _ = stderr_thread.join();
|
||||
let _ = child.wait();
|
||||
return Err(EhError::Timeout {
|
||||
command: format!("nix {}", self.subcommand),
|
||||
duration: DEFAULT_TIMEOUT,
|
||||
});
|
||||
},
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
|
||||
let _ = stdout_thread.join();
|
||||
let _ = stderr_thread.join();
|
||||
|
||||
let status = child.wait()?;
|
||||
Ok(Output {
|
||||
status,
|
||||
stdout: stdout_buf,
|
||||
stderr: stderr_buf,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_nix_command(
|
||||
command: &str,
|
||||
args: &[String],
|
||||
hash_extractor: &dyn HashExtractor,
|
||||
fixer: &dyn NixFileFixer,
|
||||
classifier: &dyn NixErrorClassifier,
|
||||
cfg: &crate::config::CommandConfig,
|
||||
cfg: &eh_config::CommandConfig,
|
||||
) -> Result<i32> {
|
||||
let intercept_env = matches!(command, "run" | "shell");
|
||||
handle_nix_with_retry(
|
||||
command,
|
||||
args,
|
||||
hash_extractor,
|
||||
fixer,
|
||||
classifier,
|
||||
intercept_env,
|
||||
matches!(command, "run" | "shell" | "develop"),
|
||||
cfg,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::{Cursor, Error};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_read_pipe_stdout() {
|
||||
let data = b"hello world";
|
||||
let cursor = Cursor::new(data);
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
let tx_clone = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
read_pipe(cursor, tx_clone, false);
|
||||
});
|
||||
|
||||
drop(tx);
|
||||
|
||||
let events: Vec<PipeEvent> = rx.iter().take(10).collect();
|
||||
assert!(!events.is_empty());
|
||||
|
||||
let stdout_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| matches!(e, PipeEvent::Stdout(_)))
|
||||
.collect();
|
||||
assert!(!stdout_events.is_empty());
|
||||
|
||||
let combined: Vec<u8> = events
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
match e {
|
||||
PipeEvent::Stdout(b) => Some(b.clone()),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.flatten()
|
||||
.collect();
|
||||
assert_eq!(combined, data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_pipe_stderr() {
|
||||
let data = b"error output";
|
||||
let cursor = Cursor::new(data);
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
let tx_clone = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
read_pipe(cursor, tx_clone, true);
|
||||
});
|
||||
|
||||
drop(tx);
|
||||
|
||||
let events: Vec<PipeEvent> = rx.iter().take(10).collect();
|
||||
|
||||
let stderr_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| matches!(e, PipeEvent::Stderr(_)))
|
||||
.collect();
|
||||
assert!(!stderr_events.is_empty());
|
||||
|
||||
let combined: Vec<u8> = events
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
match e {
|
||||
PipeEvent::Stderr(b) => Some(b.clone()),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.flatten()
|
||||
.collect();
|
||||
assert_eq!(combined, data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_pipe_empty() {
|
||||
let cursor = Cursor::new(b"");
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
let tx_clone = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
read_pipe(cursor, tx_clone, false);
|
||||
});
|
||||
|
||||
drop(tx);
|
||||
|
||||
let events: Vec<PipeEvent> = rx.iter().take(10).collect();
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_pipe_error() {
|
||||
struct ErrorReader;
|
||||
impl Read for ErrorReader {
|
||||
fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
Err(std::io::Error::other("test error"))
|
||||
}
|
||||
}
|
||||
|
||||
let reader = ErrorReader;
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
let tx_clone = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
read_pipe(reader, tx_clone, false);
|
||||
});
|
||||
|
||||
drop(tx);
|
||||
|
||||
let events: Vec<PipeEvent> = rx.iter().take(10).collect();
|
||||
|
||||
let error_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| matches!(e, PipeEvent::Error(_)))
|
||||
.collect();
|
||||
assert!(!error_events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pipe_event_debug() {
|
||||
let stdout_event = PipeEvent::Stdout(b"test".to_vec());
|
||||
let stderr_event = PipeEvent::Stderr(b"error".to_vec());
|
||||
let error_event = PipeEvent::Error(Error::other("test"));
|
||||
|
||||
let debug_stdout = format!("{:?}", stdout_event);
|
||||
let debug_stderr = format!("{:?}", stderr_event);
|
||||
let debug_error = format!("{:?}", error_event);
|
||||
|
||||
assert!(debug_stdout.contains("Stdout"));
|
||||
assert!(debug_stderr.contains("Stderr"));
|
||||
assert!(debug_error.contains("Error"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
use nix_command::{CommandKind, NixCommand, StdIo};
|
||||
|
||||
use crate::{
|
||||
commands::{NixCommand, StdIoInterceptor},
|
||||
error::{EhError, Result},
|
||||
nix_config::ApplyCommandConfig,
|
||||
};
|
||||
|
||||
/// Parse flake input names from `nix flake metadata --json` output.
|
||||
|
|
@ -26,7 +28,7 @@ pub fn parse_flake_inputs(stdout: &str) -> Result<Vec<String>> {
|
|||
|
||||
/// Fetch flake input names by running `nix flake metadata --json`.
|
||||
fn fetch_flake_inputs() -> Result<Vec<String>> {
|
||||
let output = NixCommand::new("flake")
|
||||
let output = NixCommand::new(CommandKind::Flake)
|
||||
.arg("metadata")
|
||||
.arg("--json")
|
||||
.print_build_logs(false)
|
||||
|
|
@ -57,9 +59,10 @@ fn prompt_input_selection(inputs: &[String]) -> Result<Vec<String>> {
|
|||
/// Otherwise, fetch inputs interactively and prompt for selection.
|
||||
pub fn handle_update(
|
||||
args: &[String],
|
||||
cfg: &crate::config::CommandConfig,
|
||||
cfg: &eh_config::CommandConfig,
|
||||
) -> Result<i32> {
|
||||
let selected = if args.is_empty() {
|
||||
eh_log::log_debug!("checking flake inputs");
|
||||
let inputs = fetch_flake_inputs()?;
|
||||
if inputs.is_empty() {
|
||||
return Err(EhError::NoFlakeInputs);
|
||||
|
|
@ -69,14 +72,16 @@ pub fn handle_update(
|
|||
args.to_vec()
|
||||
};
|
||||
|
||||
let mut cmd = NixCommand::new("flake").arg("lock").with_config(cfg);
|
||||
let mut cmd = NixCommand::new(CommandKind::Flake)
|
||||
.arg("lock")
|
||||
.apply_config(cfg);
|
||||
for name in &selected {
|
||||
cmd = cmd.arg("--update-input").arg(name);
|
||||
}
|
||||
|
||||
eh_log::log_info!("updating inputs: {}", selected.join(", "));
|
||||
|
||||
let status = cmd.run_with_logs(StdIoInterceptor)?;
|
||||
let status = cmd.run_with_logs(StdIo)?;
|
||||
Ok(status.code().unwrap_or(1))
|
||||
}
|
||||
|
||||
|
|
|
|||
236
eh/src/config.rs
236
eh/src/config.rs
|
|
@ -1,236 +0,0 @@
|
|||
use std::{
|
||||
collections::HashMap,
|
||||
env,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
/// When `Some(true)`, pass `--impure` to every Nix command.
|
||||
/// When `Some(false)`, block automatic impure retries for every command.
|
||||
/// When absent (`None`), retry behaviour is automatic (default).
|
||||
#[serde(default)]
|
||||
pub impure: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub commands: HashMap<String, CommandConfig>,
|
||||
}
|
||||
|
||||
/// Per-command configuration.
|
||||
#[derive(Debug, Deserialize, Default, Clone)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CommandConfig {
|
||||
/// When `Some(true)`, pass `--impure` to the underlying Nix command.
|
||||
/// When `Some(false)`, block automatic impure retries for this command.
|
||||
/// When absent (`None`), the global setting is used; if that is also absent,
|
||||
/// retry behaviour is automatic (default).
|
||||
#[serde(default)]
|
||||
pub impure: Option<bool>,
|
||||
/// Additional environment variables to set for the Nix command.
|
||||
#[serde(default)]
|
||||
pub env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Return the [`CommandConfig`] for `command`.
|
||||
///
|
||||
/// Resolution order: per-command `impure` takes precedence over the global
|
||||
/// `impure`. Neither being set means automatic retry behaviour.
|
||||
pub fn for_command(&self, command: &str) -> CommandConfig {
|
||||
let mut cmd = self.commands.get(command).cloned().unwrap_or_default();
|
||||
// Per-command setting wins; fall back to global.
|
||||
if cmd.impure.is_none() {
|
||||
cmd.impure = self.impure;
|
||||
}
|
||||
cmd
|
||||
}
|
||||
}
|
||||
|
||||
/// Load configuration from the first `.eh.toml` found by walking up from the
|
||||
/// current directory, or from `~/.config/eh/config.toml` as a global
|
||||
/// fallback. Returns a default (empty) config if no file is found or if
|
||||
/// parsing fails.
|
||||
pub fn load() -> Config {
|
||||
if let Some(path) = find_project_config()
|
||||
&& let Some(cfg) = load_from_file(&path)
|
||||
{
|
||||
return cfg;
|
||||
}
|
||||
|
||||
if let Some(path) = global_config_path()
|
||||
&& let Some(cfg) = load_from_file(&path)
|
||||
{
|
||||
return cfg;
|
||||
}
|
||||
|
||||
Config::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> {
|
||||
let home = env::var("HOME").ok()?;
|
||||
Some(
|
||||
PathBuf::from(home)
|
||||
.join(".config")
|
||||
.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) => {
|
||||
eprintln!(
|
||||
"eh: warning: failed to parse config file {}: {}",
|
||||
path.display(),
|
||||
e
|
||||
);
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_empty_config_defaults() {
|
||||
let cfg: Config = toml::from_str("").unwrap();
|
||||
assert!(cfg.impure.is_none());
|
||||
assert!(cfg.commands.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_config_impure_true() {
|
||||
let cfg: Config = toml::from_str(
|
||||
r#"
|
||||
[commands.build]
|
||||
impure = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cfg.for_command("build").impure, Some(true));
|
||||
assert_eq!(cfg.for_command("run").impure, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_config_impure_false() {
|
||||
let cfg: Config = toml::from_str(
|
||||
r#"
|
||||
[commands.build]
|
||||
impure = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cfg.for_command("build").impure, Some(false));
|
||||
assert_eq!(cfg.for_command("run").impure, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_impure_propagates_to_unconfigured_commands() {
|
||||
let cfg: Config = toml::from_str("impure = true").unwrap();
|
||||
// Commands with no per-command entry inherit global.
|
||||
assert_eq!(cfg.for_command("build").impure, Some(true));
|
||||
assert_eq!(cfg.for_command("nonexistent").impure, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_impure_false_propagates_to_unconfigured_commands() {
|
||||
let cfg: Config = toml::from_str("impure = false").unwrap();
|
||||
assert_eq!(cfg.for_command("build").impure, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_per_command_impure_overrides_global() {
|
||||
// Per-command setting wins over global.
|
||||
let cfg: Config = toml::from_str(
|
||||
r#"
|
||||
impure = false
|
||||
|
||||
[commands.build]
|
||||
impure = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cfg.for_command("build").impure, Some(true));
|
||||
// Command without per-command entry falls back to global false.
|
||||
assert_eq!(cfg.for_command("run").impure, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_config_env() {
|
||||
let cfg: Config = toml::from_str(
|
||||
r#"
|
||||
[commands.develop]
|
||||
env = { FOO = "bar", BAZ = "1" }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let dev = cfg.for_command("develop");
|
||||
assert_eq!(dev.env.get("FOO").map(String::as_str), Some("bar"));
|
||||
assert_eq!(dev.env.get("BAZ").map(String::as_str), Some("1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_config_env_table_syntax() {
|
||||
let cfg: Config = toml::from_str(
|
||||
r#"
|
||||
[commands.shell]
|
||||
impure = true
|
||||
|
||||
[commands.shell.env]
|
||||
MY_VAR = "hello"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let shell = cfg.for_command("shell");
|
||||
assert_eq!(shell.impure, Some(true));
|
||||
assert_eq!(shell.env.get("MY_VAR").map(String::as_str), Some("hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_for_command_missing_returns_default() {
|
||||
let cfg = Config::default();
|
||||
let cc = cfg.for_command("nonexistent");
|
||||
assert_eq!(cc.impure, None);
|
||||
assert!(cc.env.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_top_level_key_is_rejected() {
|
||||
let result = toml::de::from_str::<Config>("unknown_key = true");
|
||||
assert!(result.is_err(), "unknown top-level keys should be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_command_key_is_rejected() {
|
||||
let result = toml::de::from_str::<Config>(
|
||||
r#"
|
||||
[commands.build]
|
||||
typo_key = true
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"unknown per-command keys should be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
|
|
@ -10,6 +8,9 @@ pub enum EhError {
|
|||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
NixCommand(#[from] nix_command::Error),
|
||||
|
||||
#[error("regex: {0}")]
|
||||
Regex(#[from] regex::Error),
|
||||
|
||||
|
|
@ -28,24 +29,12 @@ pub enum EhError {
|
|||
#[error("process exited with code {code}")]
|
||||
ProcessExit { code: i32 },
|
||||
|
||||
#[error("command '{command}' failed")]
|
||||
CommandFailed { command: String },
|
||||
|
||||
#[error("nix {command} timed out after {} seconds", duration.as_secs())]
|
||||
Timeout {
|
||||
command: String,
|
||||
duration: Duration,
|
||||
},
|
||||
|
||||
#[error("'{expression}' failed to evaluate: {stderr}")]
|
||||
PreEvalFailed {
|
||||
expression: String,
|
||||
stderr: String,
|
||||
},
|
||||
|
||||
#[error("invalid input '{input}': {reason}")]
|
||||
InvalidInput { input: String, reason: String },
|
||||
|
||||
#[error("failed to parse JSON from nix output: {detail}")]
|
||||
JsonParse { detail: String },
|
||||
|
||||
|
|
@ -55,6 +44,9 @@ pub enum EhError {
|
|||
#[error("no inputs selected")]
|
||||
UpdateCancelled,
|
||||
|
||||
#[error("empty nix expression")]
|
||||
InvalidEvalInput,
|
||||
|
||||
#[error(
|
||||
"package {reason} but `--impure` is disabled for `{command}` in config"
|
||||
)]
|
||||
|
|
@ -69,20 +61,18 @@ impl EhError {
|
|||
match self {
|
||||
Self::ProcessExit { code } => *code,
|
||||
Self::NixCommandFailed { .. } => 2,
|
||||
Self::CommandFailed { .. } => 3,
|
||||
Self::HashExtractionFailed { .. } => 4,
|
||||
Self::NoNixFilesFound => 5,
|
||||
Self::HashFixFailed { .. } => 6,
|
||||
Self::InvalidInput { .. } => 7,
|
||||
Self::Io(_) => 8,
|
||||
Self::Io(_) | Self::NixCommand(_) => 8,
|
||||
Self::Regex(_) => 9,
|
||||
Self::Utf8(_) => 10,
|
||||
Self::Timeout { .. } => 11,
|
||||
Self::PreEvalFailed { .. } => 12,
|
||||
Self::JsonParse { .. } => 13,
|
||||
Self::NoFlakeInputs => 14,
|
||||
Self::UpdateCancelled => 0,
|
||||
Self::ImpureRequired { .. } => 15,
|
||||
Self::InvalidEvalInput => 16,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -101,15 +91,6 @@ impl EhError {
|
|||
Self::NoNixFilesFound => {
|
||||
Some("run this command from a directory containing .nix files")
|
||||
},
|
||||
Self::Timeout { .. } => {
|
||||
Some(
|
||||
"the command took too long; try a faster network or a smaller \
|
||||
derivation",
|
||||
)
|
||||
},
|
||||
Self::InvalidInput { .. } => {
|
||||
Some("avoid shell metacharacters in nix arguments")
|
||||
},
|
||||
Self::JsonParse { .. } => {
|
||||
Some("ensure 'nix flake metadata --json' produces valid output")
|
||||
},
|
||||
|
|
@ -122,12 +103,15 @@ impl EhError {
|
|||
~/.config/eh/config.toml, or pass `--impure` manually",
|
||||
)
|
||||
},
|
||||
Self::InvalidEvalInput => {
|
||||
Some("pass a package name, flake reference, or path")
|
||||
},
|
||||
Self::Io(_)
|
||||
| Self::NixCommand(_)
|
||||
| Self::Regex(_)
|
||||
| Self::Utf8(_)
|
||||
| Self::HashFixFailed { .. }
|
||||
| Self::ProcessExit { .. }
|
||||
| Self::CommandFailed { .. }
|
||||
| Self::UpdateCancelled => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -146,13 +130,6 @@ mod tests {
|
|||
.exit_code(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
EhError::CommandFailed {
|
||||
command: "x".into(),
|
||||
}
|
||||
.exit_code(),
|
||||
3
|
||||
);
|
||||
assert_eq!(
|
||||
EhError::HashExtractionFailed {
|
||||
stderr: String::new(),
|
||||
|
|
@ -162,22 +139,6 @@ mod tests {
|
|||
);
|
||||
assert_eq!(EhError::NoNixFilesFound.exit_code(), 5);
|
||||
assert_eq!(EhError::HashFixFailed { path: "x".into() }.exit_code(), 6);
|
||||
assert_eq!(
|
||||
EhError::InvalidInput {
|
||||
input: "x".into(),
|
||||
reason: "y".into(),
|
||||
}
|
||||
.exit_code(),
|
||||
7
|
||||
);
|
||||
assert_eq!(
|
||||
EhError::Timeout {
|
||||
command: "build".into(),
|
||||
duration: Duration::from_secs(300),
|
||||
}
|
||||
.exit_code(),
|
||||
11
|
||||
);
|
||||
assert_eq!(
|
||||
EhError::PreEvalFailed {
|
||||
expression: "x".into(),
|
||||
|
|
@ -194,12 +155,6 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_display_messages() {
|
||||
let err = EhError::Timeout {
|
||||
command: "build".into(),
|
||||
duration: Duration::from_secs(300),
|
||||
};
|
||||
assert_eq!(err.to_string(), "nix build timed out after 300 seconds");
|
||||
|
||||
let err = EhError::PreEvalFailed {
|
||||
expression: "nixpkgs#hello".into(),
|
||||
stderr: "attribute not found".into(),
|
||||
|
|
@ -231,22 +186,6 @@ mod tests {
|
|||
.is_some()
|
||||
);
|
||||
assert!(EhError::NoNixFilesFound.hint().is_some());
|
||||
assert!(
|
||||
EhError::Timeout {
|
||||
command: "x".into(),
|
||||
duration: Duration::from_secs(1),
|
||||
}
|
||||
.hint()
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
EhError::InvalidInput {
|
||||
input: "x".into(),
|
||||
reason: "y".into(),
|
||||
}
|
||||
.hint()
|
||||
.is_some()
|
||||
);
|
||||
// Variants with hints
|
||||
assert!(
|
||||
EhError::NixCommandFailed {
|
||||
|
|
@ -258,13 +197,6 @@ mod tests {
|
|||
assert!(EhError::JsonParse { detail: "x".into() }.hint().is_some());
|
||||
assert!(EhError::NoFlakeInputs.hint().is_some());
|
||||
// Variants without hints
|
||||
assert!(
|
||||
EhError::CommandFailed {
|
||||
command: "x".into(),
|
||||
}
|
||||
.hint()
|
||||
.is_none()
|
||||
);
|
||||
assert!(EhError::ProcessExit { code: 1 }.hint().is_none());
|
||||
assert!(EhError::UpdateCancelled.hint().is_none());
|
||||
}
|
||||
|
|
|
|||
48
eh/src/eval.rs
Normal file
48
eh/src/eval.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
use crate::error::{EhError, Result};
|
||||
|
||||
pub fn package_arg(args: &[String]) -> Option<&str> {
|
||||
args
|
||||
.iter()
|
||||
.find(|arg| !arg.starts_with('-'))
|
||||
.map(String::as_str)
|
||||
}
|
||||
|
||||
pub fn make_eval_expr(eval_arg: &str) -> Result<String> {
|
||||
let eval_arg = eval_arg.trim();
|
||||
if eval_arg.is_empty() {
|
||||
return Err(EhError::InvalidEvalInput);
|
||||
}
|
||||
let eval_arg = if eval_arg == "." { ".#" } else { eval_arg };
|
||||
if eval_arg.ends_with('#') {
|
||||
Ok(format!("{eval_arg}default.meta"))
|
||||
} else if eval_arg.contains('#') {
|
||||
Ok(format!("{eval_arg}.meta"))
|
||||
} else {
|
||||
Ok(format!("nixpkgs#{eval_arg}.meta"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builds_metadata_eval_expressions() {
|
||||
assert_eq!(make_eval_expr("hello").unwrap(), "nixpkgs#hello.meta");
|
||||
assert_eq!(make_eval_expr(".").unwrap(), ".#default.meta");
|
||||
assert_eq!(make_eval_expr(".#").unwrap(), ".#default.meta");
|
||||
assert_eq!(
|
||||
make_eval_expr("github:nixos/nixpkgs#hello").unwrap(),
|
||||
"github:nixos/nixpkgs#hello.meta"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_eval_expression() {
|
||||
assert!(matches!(make_eval_expr(""), Err(EhError::InvalidEvalInput)));
|
||||
assert!(matches!(
|
||||
make_eval_expr(" "),
|
||||
Err(EhError::InvalidEvalInput)
|
||||
));
|
||||
}
|
||||
}
|
||||
275
eh/src/hash.rs
Normal file
275
eh/src/hash.rs
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
use std::{
|
||||
io::{BufWriter, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::LazyLock,
|
||||
};
|
||||
|
||||
use eh_log::log_info;
|
||||
use regex::Regex;
|
||||
use tempfile::NamedTempFile;
|
||||
use walkdir::WalkDir;
|
||||
use yansi::Paint;
|
||||
|
||||
use crate::error::{EhError, Result};
|
||||
|
||||
const MAX_DIR_DEPTH: usize = 3;
|
||||
|
||||
static HASH_EXTRACT_PATTERNS: LazyLock<[Regex; 3]> = LazyLock::new(|| {
|
||||
[
|
||||
Regex::new(r"got:\s+(sha256-[a-zA-Z0-9+/=]+)").unwrap(),
|
||||
Regex::new(r"actual:\s+(sha256-[a-zA-Z0-9+/=]+)").unwrap(),
|
||||
Regex::new(r"have:\s+(sha256-[a-zA-Z0-9+/=]+)").unwrap(),
|
||||
]
|
||||
});
|
||||
|
||||
static HASH_OLD_EXTRACT_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"specified:\s+(sha256-[a-zA-Z0-9+/=]+)").unwrap()
|
||||
});
|
||||
|
||||
static HASH_FIX_PATTERNS: LazyLock<[Regex; 3]> = LazyLock::new(|| {
|
||||
[
|
||||
Regex::new(r#"hash\s*=\s*"[^"]*""#).unwrap(),
|
||||
Regex::new(r#"sha256\s*=\s*"[^"]*""#).unwrap(),
|
||||
Regex::new(r#"outputHash\s*=\s*"[^"]*""#).unwrap(),
|
||||
]
|
||||
});
|
||||
|
||||
pub trait HashExtractor {
|
||||
fn extract_hash(&self, stderr: &str) -> Option<String>;
|
||||
fn extract_old_hash(&self, stderr: &str) -> Option<String>;
|
||||
}
|
||||
|
||||
pub struct RegexHashExtractor;
|
||||
|
||||
impl HashExtractor for RegexHashExtractor {
|
||||
fn extract_hash(&self, stderr: &str) -> Option<String> {
|
||||
HASH_EXTRACT_PATTERNS.iter().find_map(|re| {
|
||||
re.captures(stderr)
|
||||
.and_then(|captures| captures.get(1))
|
||||
.map(|hash| hash.as_str().to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_old_hash(&self, stderr: &str) -> Option<String> {
|
||||
HASH_OLD_EXTRACT_PATTERN
|
||||
.captures(stderr)
|
||||
.and_then(|c| c.get(1))
|
||||
.map(|m| m.as_str().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait NixFileFixer {
|
||||
fn fix_hash_in_files(
|
||||
&self,
|
||||
old_hash: Option<&str>,
|
||||
new_hash: &str,
|
||||
) -> Result<bool>;
|
||||
fn find_nix_files(&self) -> Result<Vec<PathBuf>>;
|
||||
fn fix_hash_in_file(
|
||||
&self,
|
||||
file_path: &Path,
|
||||
old_hash: Option<&str>,
|
||||
new_hash: &str,
|
||||
) -> Result<bool>;
|
||||
}
|
||||
|
||||
pub struct DefaultNixFileFixer;
|
||||
|
||||
impl NixFileFixer for DefaultNixFileFixer {
|
||||
fn fix_hash_in_files(
|
||||
&self,
|
||||
old_hash: Option<&str>,
|
||||
new_hash: &str,
|
||||
) -> Result<bool> {
|
||||
let mut fixed = false;
|
||||
for file_path in self.find_nix_files()? {
|
||||
if self.fix_hash_in_file(&file_path, old_hash, new_hash)? {
|
||||
log_info!("updated hash in {}", file_path.display().bold());
|
||||
fixed = true;
|
||||
}
|
||||
}
|
||||
Ok(fixed)
|
||||
}
|
||||
|
||||
fn find_nix_files(&self) -> Result<Vec<PathBuf>> {
|
||||
let files = WalkDir::new(".")
|
||||
.max_depth(MAX_DIR_DEPTH)
|
||||
.into_iter()
|
||||
.filter_entry(|entry| !should_skip(entry))
|
||||
.filter_map(std::result::Result::ok)
|
||||
.filter(|entry| {
|
||||
entry.file_type().is_file()
|
||||
&& entry.path().extension().is_some_and(|ext| ext == "nix")
|
||||
})
|
||||
.map(|entry| entry.path().to_path_buf())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if files.is_empty() {
|
||||
return Err(EhError::NoNixFilesFound);
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn fix_hash_in_file(
|
||||
&self,
|
||||
file_path: &Path,
|
||||
old_hash: Option<&str>,
|
||||
new_hash: &str,
|
||||
) -> Result<bool> {
|
||||
let content = std::fs::read_to_string(file_path)?;
|
||||
let result_content = if let Some(old) = old_hash {
|
||||
replace_target_hash(&content, old, new_hash)?
|
||||
} else {
|
||||
replace_any_hash(&content, new_hash)
|
||||
};
|
||||
|
||||
if result_content == content {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let temp_file =
|
||||
NamedTempFile::new_in(file_path.parent().unwrap_or(Path::new(".")))?;
|
||||
{
|
||||
let mut writer = BufWriter::new(temp_file.as_file());
|
||||
writer.write_all(result_content.as_bytes())?;
|
||||
writer.flush()?;
|
||||
}
|
||||
temp_file.persist(file_path).map_err(|_| {
|
||||
EhError::HashFixFailed {
|
||||
path: file_path.to_string_lossy().to_string(),
|
||||
}
|
||||
})?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
fn should_skip(entry: &walkdir::DirEntry) -> bool {
|
||||
if entry.depth() == 0 || !entry.file_type().is_dir() {
|
||||
return false;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy();
|
||||
name.starts_with('.')
|
||||
|| matches!(name.as_ref(), "node_modules" | "target" | "result")
|
||||
}
|
||||
|
||||
fn replace_target_hash(
|
||||
content: &str,
|
||||
old_hash: &str,
|
||||
new_hash: &str,
|
||||
) -> Result<String> {
|
||||
let old = regex::escape(old_hash);
|
||||
let replacements = [
|
||||
(
|
||||
Regex::new(&format!(r#"hash\s*=\s*"{old}""#))?,
|
||||
format!(r#"hash = "{new_hash}""#),
|
||||
),
|
||||
(
|
||||
Regex::new(&format!(r#"sha256\s*=\s*"{old}""#))?,
|
||||
format!(r#"sha256 = "{new_hash}""#),
|
||||
),
|
||||
(
|
||||
Regex::new(&format!(r#"outputHash\s*=\s*"{old}""#))?,
|
||||
format!(r#"outputHash = "{new_hash}""#),
|
||||
),
|
||||
];
|
||||
Ok(replace_with_patterns(
|
||||
content,
|
||||
replacements.iter().map(|(re, value)| (re, value.as_str())),
|
||||
))
|
||||
}
|
||||
|
||||
fn replace_any_hash(content: &str, new_hash: &str) -> String {
|
||||
let replacements = [
|
||||
format!(r#"hash = "{new_hash}""#),
|
||||
format!(r#"sha256 = "{new_hash}""#),
|
||||
format!(r#"outputHash = "{new_hash}""#),
|
||||
];
|
||||
replace_with_patterns(
|
||||
content,
|
||||
HASH_FIX_PATTERNS
|
||||
.iter()
|
||||
.zip(replacements.iter().map(String::as_str)),
|
||||
)
|
||||
}
|
||||
|
||||
fn replace_with_patterns<'a>(
|
||||
content: &str,
|
||||
patterns: impl Iterator<Item = (&'a Regex, &'a str)>,
|
||||
) -> String {
|
||||
patterns.fold(content.to_string(), |acc, (re, replacement)| {
|
||||
re.replace_all(&acc, replacement).into_owned()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_hash_mismatch_error(stderr: &str) -> bool {
|
||||
stderr.contains("hash mismatch")
|
||||
|| (stderr.contains("specified:") && stderr.contains("got:"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Write;
|
||||
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extracts_new_and_old_hashes() {
|
||||
let stderr = "specified: sha256-OLD\n got: sha256-NEW=";
|
||||
let extractor = RegexHashExtractor;
|
||||
assert_eq!(
|
||||
extractor.extract_hash(stderr),
|
||||
Some("sha256-NEW=".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
extractor.extract_old_hash(stderr),
|
||||
Some("sha256-OLD".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_only_matching_old_hash() {
|
||||
let file = NamedTempFile::new().unwrap();
|
||||
let path = file.path();
|
||||
std::fs::write(
|
||||
path,
|
||||
r#"hash = "sha256-old";
|
||||
sha256 = "sha256-other";
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
DefaultNixFileFixer
|
||||
.fix_hash_in_file(path, Some("sha256-old"), "sha256-new")
|
||||
.unwrap()
|
||||
);
|
||||
let updated = std::fs::read_to_string(path).unwrap();
|
||||
assert!(updated.contains(r#"hash = "sha256-new""#));
|
||||
assert!(updated.contains(r#"sha256 = "sha256-other""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_all_hash_attributes_without_old_hash() {
|
||||
let file = NamedTempFile::new().unwrap();
|
||||
let path = file.path();
|
||||
let mut writer = std::fs::File::create(path).unwrap();
|
||||
writer
|
||||
.write_all(
|
||||
br#"hash = "a";
|
||||
sha256 = "b";
|
||||
outputHash = "c";
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
DefaultNixFileFixer
|
||||
.fix_hash_in_file(path, None, "sha256-new")
|
||||
.unwrap()
|
||||
);
|
||||
let updated = std::fs::read_to_string(path).unwrap();
|
||||
assert_eq!(updated.matches("sha256-new").count(), 3);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,4 @@
|
|||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod util;
|
||||
|
||||
pub use clap::{CommandFactory, Parser, Subcommand};
|
||||
pub use error::{EhError, Result};
|
||||
|
|
@ -11,6 +8,14 @@ pub use error::{EhError, Result};
|
|||
#[command(about = "Ergonomic Nix helper", long_about = None)]
|
||||
#[command(version)]
|
||||
pub struct Cli {
|
||||
/// Increase logging verbosity (-v, -vv, -vvv)
|
||||
#[arg(short, long, action = clap::ArgAction::Count, global = true)]
|
||||
pub verbose: u8,
|
||||
|
||||
/// Decrease logging verbosity (-q, -qq)
|
||||
#[arg(short, long, action = clap::ArgAction::Count, global = true)]
|
||||
pub quiet: u8,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Command>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,12 @@ use eh::{Cli, Command, CommandFactory, Parser};
|
|||
use yansi::Paint;
|
||||
|
||||
mod commands;
|
||||
mod config;
|
||||
mod error;
|
||||
mod util;
|
||||
mod eval;
|
||||
mod hash;
|
||||
mod nix_config;
|
||||
mod retry;
|
||||
mod suggestions;
|
||||
|
||||
fn main() {
|
||||
let result = run_app();
|
||||
|
|
@ -27,10 +30,10 @@ fn main() {
|
|||
}
|
||||
|
||||
fn handle_command(command: &str, args: &[String]) -> error::Result<i32> {
|
||||
let hash_extractor = util::RegexHashExtractor;
|
||||
let fixer = util::DefaultNixFileFixer;
|
||||
let classifier = util::DefaultNixErrorClassifier;
|
||||
let cfg = config::load();
|
||||
let hash_extractor = hash::RegexHashExtractor;
|
||||
let fixer = hash::DefaultNixFileFixer;
|
||||
let classifier = retry::DefaultNixErrorClassifier;
|
||||
let cfg = eh_config::load();
|
||||
let cmd_cfg = cfg.for_command(command);
|
||||
|
||||
match command {
|
||||
|
|
@ -53,9 +56,21 @@ fn handle_command(command: &str, args: &[String]) -> error::Result<i32> {
|
|||
|
||||
fn dispatch_multicall(
|
||||
app_name: &str,
|
||||
args: std::env::Args,
|
||||
args: impl IntoIterator<Item = String>,
|
||||
) -> Option<error::Result<i32>> {
|
||||
let rest: Vec<String> = args.collect();
|
||||
let mut verbosity = 0i8;
|
||||
let mut rest = Vec::new();
|
||||
for arg in args {
|
||||
match arg.as_str() {
|
||||
"-v" | "--verbose" => verbosity += 1,
|
||||
"-vv" => verbosity += 2,
|
||||
"-vvv" => verbosity += 3,
|
||||
"-q" | "--quiet" => verbosity -= 1,
|
||||
"-qq" => verbosity -= 2,
|
||||
_ => rest.push(arg),
|
||||
}
|
||||
}
|
||||
eh_log::set_verbosity(verbosity);
|
||||
|
||||
let subcommand = match app_name {
|
||||
"nr" => "run",
|
||||
|
|
@ -104,6 +119,7 @@ fn run_app() -> error::Result<i32> {
|
|||
}
|
||||
|
||||
let cli = Cli::parse();
|
||||
eh_log::set_verbosity(cli.verbose as i8 - cli.quiet as i8);
|
||||
|
||||
match cli.command {
|
||||
Some(Command::Run { args }) => handle_command("run", &args),
|
||||
|
|
|
|||
14
eh/src/nix_config.rs
Normal file
14
eh/src/nix_config.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
use nix_command::NixCommand;
|
||||
|
||||
pub trait ApplyCommandConfig {
|
||||
fn apply_config(self, cfg: &eh_config::CommandConfig) -> Self;
|
||||
}
|
||||
|
||||
impl ApplyCommandConfig for NixCommand {
|
||||
fn apply_config(mut self, cfg: &eh_config::CommandConfig) -> Self {
|
||||
if cfg.impure == Some(true) {
|
||||
self = self.impure(true);
|
||||
}
|
||||
self.envs(cfg.env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
|
||||
}
|
||||
}
|
||||
365
eh/src/retry.rs
Normal file
365
eh/src/retry.rs
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
use std::io::{IsTerminal, Write};
|
||||
|
||||
use eh_log::{log_debug, log_info, log_warn};
|
||||
use nix_command::{CommandKind, NixCommand, StdIo};
|
||||
use yansi::Paint;
|
||||
|
||||
use crate::{
|
||||
error::{EhError, Result},
|
||||
eval::{make_eval_expr, package_arg},
|
||||
hash::{HashExtractor, NixFileFixer, is_hash_mismatch_error},
|
||||
nix_config::ApplyCommandConfig,
|
||||
suggestions::print_error_suggestions,
|
||||
};
|
||||
|
||||
pub trait NixErrorClassifier {
|
||||
fn should_retry(&self, stderr: &str) -> bool;
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum RetryAction {
|
||||
AllowUnfree,
|
||||
AllowInsecure,
|
||||
AllowBroken,
|
||||
None,
|
||||
}
|
||||
|
||||
impl RetryAction {
|
||||
const fn env_override(&self) -> Option<(&str, &str)> {
|
||||
match self {
|
||||
Self::AllowUnfree => {
|
||||
Some(("NIXPKGS_ALLOW_UNFREE", "has an unfree license"))
|
||||
},
|
||||
Self::AllowInsecure => {
|
||||
Some(("NIXPKGS_ALLOW_INSECURE", "has been marked as insecure"))
|
||||
},
|
||||
Self::AllowBroken => {
|
||||
Some(("NIXPKGS_ALLOW_BROKEN", "has been marked as broken"))
|
||||
},
|
||||
Self::None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn classify_retry_action(stderr: &str) -> RetryAction {
|
||||
if stderr.contains("refusing") && stderr.contains("has an unfree license") {
|
||||
RetryAction::AllowUnfree
|
||||
} else if stderr.contains("refusing")
|
||||
&& stderr.contains("has been marked as insecure")
|
||||
{
|
||||
RetryAction::AllowInsecure
|
||||
} else if stderr.contains("refusing")
|
||||
&& stderr.contains("has been marked as broken")
|
||||
{
|
||||
RetryAction::AllowBroken
|
||||
} else {
|
||||
RetryAction::None
|
||||
}
|
||||
}
|
||||
|
||||
fn command_kind(subcommand: &str) -> Result<CommandKind> {
|
||||
CommandKind::try_from(subcommand).map_err(|_| {
|
||||
EhError::NixCommandFailed {
|
||||
command: subcommand.to_string(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn nix_command(
|
||||
subcommand: &str,
|
||||
args: &[String],
|
||||
cfg: &eh_config::CommandConfig,
|
||||
interactive: bool,
|
||||
) -> Result<NixCommand> {
|
||||
Ok(
|
||||
NixCommand::new(command_kind(subcommand)?)
|
||||
.args_ref(args)
|
||||
.apply_config(cfg)
|
||||
.interactive(interactive),
|
||||
)
|
||||
}
|
||||
|
||||
fn run_nix_command(
|
||||
subcommand: &str,
|
||||
args: &[String],
|
||||
cfg: &eh_config::CommandConfig,
|
||||
interactive: bool,
|
||||
env_override: Option<&str>,
|
||||
) -> Result<i32> {
|
||||
let mut cmd = nix_command(subcommand, args, cfg, interactive)?;
|
||||
if let Some(env_var) = env_override {
|
||||
cmd = cmd.env(env_var, "1").impure(true);
|
||||
}
|
||||
if interactive {
|
||||
log_debug!("entering {}", command_display(subcommand, args));
|
||||
}
|
||||
Ok(cmd.run_with_logs(StdIo)?.code().unwrap_or(1))
|
||||
}
|
||||
|
||||
fn command_display(subcommand: &str, args: &[String]) -> String {
|
||||
if args.is_empty() {
|
||||
format!("nix {subcommand}")
|
||||
} else {
|
||||
format!("nix {} {}", subcommand, args.join(" "))
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_impure_allowed(
|
||||
cfg: &eh_config::CommandConfig,
|
||||
subcommand: &str,
|
||||
reason: &str,
|
||||
) -> Result<()> {
|
||||
if cfg.impure == Some(false) {
|
||||
return Err(EhError::ImpureRequired {
|
||||
command: subcommand.to_string(),
|
||||
reason: reason.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_package_flags(args: &[String]) -> Result<RetryAction> {
|
||||
let eval_arg = package_arg(args).unwrap_or(".");
|
||||
let eval_expr = make_eval_expr(eval_arg)?;
|
||||
let output = match NixCommand::new(CommandKind::Eval)
|
||||
.arg("--json")
|
||||
.arg(eval_expr)
|
||||
.output()
|
||||
{
|
||||
Ok(output) if output.status.success() => output,
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if !stderr.contains("does not provide attribute") {
|
||||
log_warn!(
|
||||
"failed to check package flags for '{}': {}",
|
||||
eval_arg,
|
||||
stderr.trim()
|
||||
);
|
||||
}
|
||||
return Ok(RetryAction::None);
|
||||
},
|
||||
Err(e) => {
|
||||
log_warn!("failed to check package flags for '{}': {}", eval_arg, e);
|
||||
return Ok(RetryAction::None);
|
||||
},
|
||||
};
|
||||
|
||||
let meta = match serde_json::from_slice::<serde_json::Value>(&output.stdout) {
|
||||
Ok(meta) => meta,
|
||||
Err(e) => {
|
||||
log_warn!("failed to parse package metadata for '{}': {}", eval_arg, e);
|
||||
return Ok(RetryAction::None);
|
||||
},
|
||||
};
|
||||
|
||||
[
|
||||
("unfree", RetryAction::AllowUnfree),
|
||||
("insecure", RetryAction::AllowInsecure),
|
||||
("broken", RetryAction::AllowBroken),
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|(key, action)| {
|
||||
meta
|
||||
.get(key)
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
.then_some(action)
|
||||
})
|
||||
.ok_or(EhError::ProcessExit { code: 0 })
|
||||
.or(Ok(RetryAction::None))
|
||||
}
|
||||
|
||||
fn pre_evaluate(args: &[String]) -> Result<RetryAction> {
|
||||
let action = check_package_flags(args)?;
|
||||
if action != RetryAction::None {
|
||||
return Ok(action);
|
||||
}
|
||||
|
||||
let eval_arg = package_arg(args).unwrap_or(".");
|
||||
let output = NixCommand::new(CommandKind::Eval).arg(eval_arg).output()?;
|
||||
if output.status.success() {
|
||||
return Ok(RetryAction::None);
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let action = classify_retry_action(&stderr);
|
||||
if action != RetryAction::None {
|
||||
return Ok(action);
|
||||
}
|
||||
|
||||
let stderr = stderr
|
||||
.trim()
|
||||
.strip_prefix("error:")
|
||||
.unwrap_or(stderr.trim())
|
||||
.trim();
|
||||
Err(EhError::PreEvalFailed {
|
||||
expression: eval_arg.to_string(),
|
||||
stderr: stderr.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn handle_nix_with_retry(
|
||||
subcommand: &str,
|
||||
args: &[String],
|
||||
hash_extractor: &dyn HashExtractor,
|
||||
fixer: &dyn NixFileFixer,
|
||||
classifier: &dyn NixErrorClassifier,
|
||||
interactive: bool,
|
||||
cfg: &eh_config::CommandConfig,
|
||||
) -> Result<i32> {
|
||||
let pkg = package_arg(args).unwrap_or("<unknown>");
|
||||
log_debug!("checking {}", command_display(subcommand, args));
|
||||
if let Some((env_var, reason)) = pre_evaluate(args)?.env_override() {
|
||||
ensure_impure_allowed(cfg, subcommand, reason)?;
|
||||
print_retry_msg(pkg, reason, env_var);
|
||||
return run_nix_command(subcommand, args, cfg, interactive, Some(env_var));
|
||||
}
|
||||
|
||||
if interactive {
|
||||
let code = run_nix_command(subcommand, args, cfg, true, None)?;
|
||||
if code == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
}
|
||||
|
||||
log_debug!("running {}", command_display(subcommand, args));
|
||||
let output = nix_command(subcommand, args, cfg, false)?.output()?;
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
if let Some(new_hash) = hash_extractor.extract_hash(&stderr) {
|
||||
let ctx = HashFixContext {
|
||||
subcommand,
|
||||
args,
|
||||
cfg,
|
||||
interactive,
|
||||
pkg,
|
||||
fixer,
|
||||
};
|
||||
if let Some(code) = handle_hash_mismatch(
|
||||
ctx,
|
||||
hash_extractor.extract_old_hash(&stderr),
|
||||
&new_hash,
|
||||
)? {
|
||||
return Ok(code);
|
||||
}
|
||||
} else if is_hash_mismatch_error(&stderr) {
|
||||
return Err(EhError::HashExtractionFailed {
|
||||
stderr: stderr.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if classifier.should_retry(&stderr)
|
||||
&& let Some((env_var, reason)) =
|
||||
classify_retry_action(&stderr).env_override()
|
||||
{
|
||||
ensure_impure_allowed(cfg, subcommand, reason)?;
|
||||
print_retry_msg(pkg, reason, env_var);
|
||||
return run_nix_command(subcommand, args, cfg, interactive, Some(env_var));
|
||||
}
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
std::io::stderr()
|
||||
.write_all(&output.stderr)
|
||||
.map_err(EhError::Io)?;
|
||||
print_error_suggestions(&output.stderr);
|
||||
output.status.code().map_or_else(
|
||||
|| {
|
||||
Err(EhError::NixCommandFailed {
|
||||
command: subcommand.to_string(),
|
||||
})
|
||||
},
|
||||
|code| Err(EhError::ProcessExit { code }),
|
||||
)
|
||||
}
|
||||
|
||||
struct HashFixContext<'a> {
|
||||
subcommand: &'a str,
|
||||
args: &'a [String],
|
||||
cfg: &'a eh_config::CommandConfig,
|
||||
interactive: bool,
|
||||
pkg: &'a str,
|
||||
fixer: &'a dyn NixFileFixer,
|
||||
}
|
||||
|
||||
fn handle_hash_mismatch(
|
||||
ctx: HashFixContext<'_>,
|
||||
old_hash: Option<String>,
|
||||
new_hash: &str,
|
||||
) -> Result<Option<i32>> {
|
||||
if !std::io::stdin().is_terminal() {
|
||||
log_info!(
|
||||
"{}: skipping hash fix in non-interactive mode",
|
||||
ctx.pkg.bold()
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let should_fix = dialoguer::Confirm::new()
|
||||
.with_prompt(format!(
|
||||
"Hash mismatch detected for {}. Update hash in local .nix files?",
|
||||
ctx.pkg.bold()
|
||||
))
|
||||
.default(true)
|
||||
.interact()
|
||||
.map_err(|e| EhError::Io(std::io::Error::other(e)))?;
|
||||
|
||||
if !should_fix {
|
||||
log_warn!("{}: hash fix cancelled", ctx.pkg.bold());
|
||||
return Err(EhError::ProcessExit { code: 1 });
|
||||
}
|
||||
|
||||
match ctx.fixer.fix_hash_in_files(old_hash.as_deref(), new_hash) {
|
||||
Ok(true) => {
|
||||
log_info!(
|
||||
"{}: hash mismatch corrected in local files, rebuilding",
|
||||
ctx.pkg.bold()
|
||||
);
|
||||
run_nix_command(ctx.subcommand, ctx.args, ctx.cfg, ctx.interactive, None)
|
||||
.map(Some)
|
||||
},
|
||||
Ok(false) | Err(EhError::NoNixFilesFound) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DefaultNixErrorClassifier;
|
||||
|
||||
impl NixErrorClassifier for DefaultNixErrorClassifier {
|
||||
fn should_retry(&self, stderr: &str) -> bool {
|
||||
classify_retry_action(stderr) != RetryAction::None
|
||||
}
|
||||
}
|
||||
|
||||
fn print_retry_msg(pkg: &str, reason: &str, env_var: &str) {
|
||||
log_warn!(
|
||||
"{}: {}, setting {}",
|
||||
pkg.bold(),
|
||||
reason,
|
||||
format!("{env_var}=1").bold()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classifies_retryable_errors() {
|
||||
assert_eq!(
|
||||
classify_retry_action("refusing because it has an unfree license"),
|
||||
RetryAction::AllowUnfree
|
||||
);
|
||||
assert_eq!(
|
||||
classify_retry_action("refusing because it has been marked as insecure"),
|
||||
RetryAction::AllowInsecure
|
||||
);
|
||||
assert_eq!(
|
||||
classify_retry_action("refusing because it has been marked as broken"),
|
||||
RetryAction::AllowBroken
|
||||
);
|
||||
assert_eq!(classify_retry_action("ordinary error"), RetryAction::None);
|
||||
}
|
||||
}
|
||||
58
eh/src/suggestions.rs
Normal file
58
eh/src/suggestions.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
use std::sync::LazyLock;
|
||||
|
||||
use eh_log::log_info;
|
||||
use regex::Regex;
|
||||
use yansi::Paint;
|
||||
|
||||
static DID_YOU_MEAN_PATTERN: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r#"Did you mean (?:one of )?(.+?)\?"#).unwrap());
|
||||
|
||||
fn parse_nix_suggestions(did_you_mean_line: &str) -> Vec<String> {
|
||||
DID_YOU_MEAN_PATTERN
|
||||
.captures(did_you_mean_line)
|
||||
.and_then(|caps| caps.get(1))
|
||||
.map(|m| m.as_str())
|
||||
.map(|suggestions| {
|
||||
suggestions
|
||||
.split(", ")
|
||||
.flat_map(|part| part.split(" or "))
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn print_error_suggestions(stderr: &[u8]) {
|
||||
let stderr = String::from_utf8_lossy(stderr);
|
||||
let Some(line) = stderr.lines().find(|line| line.contains("Did you mean"))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let suggestions = parse_nix_suggestions(line);
|
||||
if suggestions.is_empty() {
|
||||
return;
|
||||
}
|
||||
let formatted = suggestions
|
||||
.iter()
|
||||
.map(|s| s.bold().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
log_info!("Did you mean: {}?", formatted);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_nix_suggestions() {
|
||||
assert_eq!(
|
||||
parse_nix_suggestions(
|
||||
"Did you mean one of neovim, hevi, navi, neo or neo4j?"
|
||||
),
|
||||
["neovim", "hevi", "navi", "neo", "neo4j"]
|
||||
);
|
||||
}
|
||||
}
|
||||
1176
eh/src/util.rs
1176
eh/src/util.rs
File diff suppressed because it is too large
Load diff
|
|
@ -5,13 +5,14 @@
|
|||
rustfmt,
|
||||
clippy,
|
||||
taplo,
|
||||
rust-analyzer-unwrapped,
|
||||
rustPlatform,
|
||||
rust-analyzer,
|
||||
cargo-nextest,
|
||||
}:
|
||||
mkShell {
|
||||
name = "rust";
|
||||
|
||||
packages = [
|
||||
strictDeps = true;
|
||||
nativeBuildInputs = [
|
||||
rustc
|
||||
cargo
|
||||
|
||||
|
|
@ -19,8 +20,8 @@ mkShell {
|
|||
clippy
|
||||
cargo
|
||||
taplo
|
||||
rust-analyzer-unwrapped
|
||||
];
|
||||
rust-analyzer
|
||||
|
||||
env.RUST_SRC_PATH = "${rustPlatform.rustLibSrc}";
|
||||
cargo-nextest
|
||||
];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue