Compare commits
8 commits
main
...
notashelf/
| Author | SHA1 | Date | |
|---|---|---|---|
|
b8d01730f5 |
|||
|
ef699f6d20 |
|||
|
6a7941fc74 |
|||
|
548bc81923 |
|||
|
1846137bc6 |
|||
|
89ac0dd84e |
|||
|
01dfbd69e5 |
|||
|
cf13c42cd5 |
25 changed files with 1667 additions and 2144 deletions
|
|
@ -1,27 +1,26 @@
|
||||||
condense_wildcard_suffixes = true
|
condense_wildcard_suffixes = true
|
||||||
doc_comment_code_block_width = 80
|
doc_comment_code_block_width = 80
|
||||||
edition = "2024" # Keep in sync with Cargo.toml.
|
edition = "2024" # Keep in sync with Cargo.toml.
|
||||||
enum_discrim_align_threshold = 60
|
enum_discrim_align_threshold = 60
|
||||||
force_explicit_abi = false
|
force_explicit_abi = false
|
||||||
force_multiline_blocks = true
|
force_multiline_blocks = true
|
||||||
format_code_in_doc_comments = true
|
format_code_in_doc_comments = true
|
||||||
format_macro_matchers = true
|
format_macro_matchers = true
|
||||||
format_strings = true
|
format_strings = true
|
||||||
group_imports = "StdExternalCrate"
|
group_imports = "StdExternalCrate"
|
||||||
hex_literal_case = "Upper"
|
hex_literal_case = "Upper"
|
||||||
imports_granularity = "Crate"
|
imports_granularity = "Crate"
|
||||||
imports_layout = "HorizontalVertical"
|
imports_layout = "HorizontalVertical"
|
||||||
inline_attribute_width = 60
|
inline_attribute_width = 60
|
||||||
match_block_trailing_comma = true
|
match_block_trailing_comma = true
|
||||||
max_width = 80
|
max_width = 80
|
||||||
newline_style = "Unix"
|
newline_style = "Unix"
|
||||||
normalize_comments = true
|
normalize_comments = true
|
||||||
normalize_doc_attributes = true
|
normalize_doc_attributes = true
|
||||||
overflow_delimited_expr = true
|
overflow_delimited_expr = true
|
||||||
struct_field_align_threshold = 60
|
struct_field_align_threshold = 60
|
||||||
tab_spaces = 2
|
tab_spaces = 2
|
||||||
unstable_features = true
|
unstable_features = true
|
||||||
use_field_init_shorthand = true
|
use_field_init_shorthand = true
|
||||||
use_try_shorthand = true
|
use_try_shorthand = true
|
||||||
wrap_comments = true
|
wrap_comments = true
|
||||||
|
|
||||||
|
|
|
||||||
86
Cargo.lock
generated
86
Cargo.lock
generated
|
|
@ -104,6 +104,27 @@ dependencies = [
|
||||||
"shell-words",
|
"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]]
|
[[package]]
|
||||||
name = "eh"
|
name = "eh"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|
@ -111,18 +132,29 @@ dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"clap_complete",
|
"clap_complete",
|
||||||
"dialoguer",
|
"dialoguer",
|
||||||
|
"eh-config",
|
||||||
"eh-log",
|
"eh-log",
|
||||||
|
"nix-command",
|
||||||
"regex",
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"textwrap",
|
"textwrap",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"toml",
|
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"yansi",
|
"yansi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "eh-config"
|
||||||
|
version = "0.2.0"
|
||||||
|
dependencies = [
|
||||||
|
"dirs",
|
||||||
|
"eh-log",
|
||||||
|
"serde",
|
||||||
|
"toml",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "eh-log"
|
name = "eh-log"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|
@ -164,6 +196,17 @@ version = "0.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
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]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
|
|
@ -234,6 +277,15 @@ version = "0.2.185"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libredox"
|
||||||
|
version = "0.1.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
|
|
@ -252,12 +304,25 @@ version = "2.8.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix-command"
|
||||||
|
version = "0.2.0"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "option-ext"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prettyplease"
|
name = "prettyplease"
|
||||||
version = "0.2.37"
|
version = "0.2.37"
|
||||||
|
|
@ -292,6 +357,17 @@ version = "6.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
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]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "1.12.3"
|
version = "1.12.3"
|
||||||
|
|
@ -431,7 +507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom",
|
"getrandom 0.4.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys",
|
"windows-sys",
|
||||||
|
|
@ -533,6 +609,12 @@ dependencies = [
|
||||||
"winapi-util",
|
"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]]
|
[[package]]
|
||||||
name = "wasip2"
|
name = "wasip2"
|
||||||
version = "1.0.3+wasi-0.2.9"
|
version = "1.0.3+wasi-0.2.9"
|
||||||
|
|
|
||||||
10
Cargo.toml
10
Cargo.toml
|
|
@ -12,9 +12,16 @@ rust-version = "1.94.0"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
# Workspace members
|
||||||
|
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.1" }
|
clap = { default-features = false, features = [ "std", "help", "derive" ], version = "4.6.1" }
|
||||||
clap_complete = "4.6.3"
|
clap_complete = "4.6.3"
|
||||||
dialoguer = { default-features = false, version = "0.12.0" }
|
dialoguer = { default-features = false, version = "0.12.0" }
|
||||||
|
dirs = "6.0.0"
|
||||||
regex = "1.12.3"
|
regex = "1.12.3"
|
||||||
serde = { features = [ "derive" ], version = "1.0.228" }
|
serde = { features = [ "derive" ], version = "1.0.228" }
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
|
|
@ -25,9 +32,6 @@ toml = { default-features = false, features = [ "parse", "serde" ], ver
|
||||||
walkdir = "2.5.0"
|
walkdir = "2.5.0"
|
||||||
yansi = "1.0.1"
|
yansi = "1.0.1"
|
||||||
|
|
||||||
eh = { path = "./eh", version = "0.2.0" }
|
|
||||||
eh-log = { path = "./crates/eh-log", version = "0.2.0" }
|
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
lto = true
|
lto = true
|
||||||
|
|
|
||||||
12
crates/eh-config/Cargo.toml
Normal file
12
crates/eh-config/Cargo.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
[package]
|
||||||
|
name = "eh-config"
|
||||||
|
description = "Configuration loading for eh"
|
||||||
|
version.workspace = true
|
||||||
|
edition.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]
|
[package]
|
||||||
name = "eh-log"
|
name = "eh-log"
|
||||||
description = "Styled logging for eh"
|
description = "Tiny, styled logging crate for eh"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,64 @@
|
||||||
use std::fmt;
|
use std::{
|
||||||
|
fmt,
|
||||||
|
sync::atomic::{AtomicI8, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
use yansi::Paint;
|
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) {
|
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) {
|
pub fn warn(args: fmt::Arguments) {
|
||||||
eprintln!(" {} {args}", "->".yellow().bold());
|
if enabled(Level::Warn) {
|
||||||
|
eprintln!(" {} {args}", "->".yellow().bold());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn error(args: fmt::Arguments) {
|
pub fn error(args: fmt::Arguments) {
|
||||||
eprintln!(" {} {args}", "!".red().bold());
|
if enabled(Level::Error) {
|
||||||
|
eprintln!(" {} {args}", "!".red().bold());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn hint(args: fmt::Arguments) {
|
pub fn hint(args: fmt::Arguments) {
|
||||||
eprintln!(" {} {args}", "~".yellow().dim());
|
if enabled(Level::Info) {
|
||||||
|
eprintln!(" {} {args}", "~".yellow().dim());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! log_info { ($($t:tt)*) => { $crate::info(format_args!($($t)*)) } }
|
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_export]
|
||||||
macro_rules! log_warn { ($($t:tt)*) => { $crate::warn(format_args!($($t)*)) } }
|
macro_rules! log_warn { ($($t:tt)*) => { $crate::warn(format_args!($($t)*)) } }
|
||||||
|
|
||||||
|
|
|
||||||
11
crates/nix-command/Cargo.toml
Normal file
11
crates/nix-command/Cargo.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
[package]
|
||||||
|
name = "nix-command"
|
||||||
|
description = "Typed Nix command construction and execution"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
publish = true
|
||||||
|
license.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.workspace = true
|
||||||
clap_complete.workspace = true
|
clap_complete.workspace = true
|
||||||
dialoguer.workspace = true
|
dialoguer.workspace = true
|
||||||
|
eh-config.workspace = true
|
||||||
eh-log.workspace = true
|
eh-log.workspace = true
|
||||||
|
nix-command.workspace = true
|
||||||
regex.workspace = true
|
regex.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
textwrap.workspace = true
|
textwrap.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
toml.workspace = true
|
|
||||||
walkdir.workspace = true
|
walkdir.workspace = true
|
||||||
yansi.workspace = true
|
yansi.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use eh_log::{log_error, log_info};
|
use eh_log::{log_error, log_info};
|
||||||
|
use nix_command::{CommandKind, NixCommand};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use yansi::Paint;
|
use yansi::Paint;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
commands::NixCommand,
|
|
||||||
error::{EhError, Result},
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct PackageMeta {
|
struct PackageMeta {
|
||||||
name: String,
|
name: String,
|
||||||
|
|
@ -33,41 +37,24 @@ struct PackageOutputs {
|
||||||
|
|
||||||
pub fn handle_info(
|
pub fn handle_info(
|
||||||
args: &[String],
|
args: &[String],
|
||||||
cfg: &crate::config::CommandConfig,
|
cfg: &eh_config::CommandConfig,
|
||||||
) -> Result<i32> {
|
) -> Result<i32> {
|
||||||
// Get the package argument (skip flags)
|
|
||||||
let pkg = args
|
let pkg = args
|
||||||
.iter()
|
.iter()
|
||||||
.find(|arg| !arg.starts_with('-'))
|
.find(|arg| !arg.starts_with('-'))
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| ".".to_string());
|
.unwrap_or_else(|| ".".to_string());
|
||||||
|
|
||||||
let eval_arg = make_eval_expr(&pkg);
|
let eval_arg = make_eval_expr(&pkg)?;
|
||||||
let pkg_name: String = if eval_arg.contains("#") {
|
let pkg_name = package_name_from_eval_expr(&eval_arg);
|
||||||
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
|
|
||||||
};
|
|
||||||
|
|
||||||
log_info!("Fetching info for {}", pkg_name.bold());
|
log_info!("Fetching info for {}", pkg_name.bold());
|
||||||
|
|
||||||
// Fetch metadata
|
let meta_cmd = NixCommand::new(CommandKind::Eval)
|
||||||
let meta_cmd = NixCommand::new("eval")
|
|
||||||
.arg("--json")
|
.arg("--json")
|
||||||
.arg(&eval_arg)
|
.arg(&eval_arg)
|
||||||
.print_build_logs(false)
|
.print_build_logs(false)
|
||||||
.with_config(cfg);
|
.apply_config(cfg);
|
||||||
|
|
||||||
let meta_output = meta_cmd.output()?;
|
let meta_output = meta_cmd.output()?;
|
||||||
|
|
||||||
|
|
@ -87,16 +74,15 @@ pub fn handle_info(
|
||||||
)))
|
)))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Fetch outputs
|
|
||||||
let outputs_expr = eval_arg
|
let outputs_expr = eval_arg
|
||||||
.strip_suffix(".meta")
|
.strip_suffix(".meta")
|
||||||
.unwrap_or(&eval_arg)
|
.unwrap_or(&eval_arg)
|
||||||
.to_string();
|
.to_string();
|
||||||
let outputs_cmd = NixCommand::new("eval")
|
let outputs_cmd = NixCommand::new(CommandKind::Eval)
|
||||||
.arg("--json")
|
.arg("--json")
|
||||||
.arg(format!("{}.outputs", outputs_expr))
|
.arg(format!("{}.outputs", outputs_expr))
|
||||||
.print_build_logs(false)
|
.print_build_logs(false)
|
||||||
.with_config(cfg);
|
.apply_config(cfg);
|
||||||
|
|
||||||
let outputs_output = outputs_cmd.output()?;
|
let outputs_output = outputs_cmd.output()?;
|
||||||
let outputs: Option<PackageOutputs> = if outputs_output.status.success() {
|
let outputs: Option<PackageOutputs> = if outputs_output.status.success() {
|
||||||
|
|
@ -105,12 +91,49 @@ pub fn handle_info(
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// Print formatted info
|
|
||||||
print_package_info(&meta, outputs.as_ref(), &pkg);
|
print_package_info(&meta, outputs.as_ref(), &pkg);
|
||||||
|
|
||||||
Ok(0)
|
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(
|
fn print_package_info(
|
||||||
meta: &PackageMeta,
|
meta: &PackageMeta,
|
||||||
outputs: Option<&PackageOutputs>,
|
outputs: Option<&PackageOutputs>,
|
||||||
|
|
@ -118,7 +141,6 @@ fn print_package_info(
|
||||||
) {
|
) {
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
// Header
|
|
||||||
println!(" {} {}", "Package:".bold(), meta.name);
|
println!(" {} {}", "Package:".bold(), meta.name);
|
||||||
|
|
||||||
if let Some(ref version) = meta.version {
|
if let Some(ref version) = meta.version {
|
||||||
|
|
@ -129,7 +151,6 @@ fn print_package_info(
|
||||||
println!(" {} {}", "Description:".bold(), desc);
|
println!(" {} {}", "Description:".bold(), desc);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show long description if available and different from short description
|
|
||||||
if let Some(ref long_desc) = meta.long_description {
|
if let Some(ref long_desc) = meta.long_description {
|
||||||
let should_show = meta
|
let should_show = meta
|
||||||
.description
|
.description
|
||||||
|
|
@ -138,7 +159,6 @@ fn print_package_info(
|
||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
if should_show {
|
if should_show {
|
||||||
println!();
|
println!();
|
||||||
// Wrap long description to 70 chars for readability
|
|
||||||
let wrapped = textwrap::fill(long_desc, 70);
|
let wrapped = textwrap::fill(long_desc, 70);
|
||||||
for line in wrapped.lines() {
|
for line in wrapped.lines() {
|
||||||
println!(" {}", line);
|
println!(" {}", line);
|
||||||
|
|
@ -146,58 +166,17 @@ fn print_package_info(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// License
|
|
||||||
if let Some(ref license) = meta.license {
|
if let Some(ref license) = meta.license {
|
||||||
let license_str = match license {
|
println!(" {} {}", "License:".bold(), format_license(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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Homepage
|
|
||||||
if let Some(ref homepage) = meta.homepage {
|
if let Some(ref homepage) = meta.homepage {
|
||||||
println!(" {} {}", "Homepage:".bold(), homepage);
|
println!(" {} {}", "Homepage:".bold(), homepage);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Meta section
|
|
||||||
println!();
|
println!();
|
||||||
println!(" {}", "Meta:".bold());
|
println!(" {}", "Meta:".bold());
|
||||||
|
|
||||||
// Status indicators
|
|
||||||
let mut status_parts = Vec::new();
|
let mut status_parts = Vec::new();
|
||||||
if meta.broken == Some(true) {
|
if meta.broken == Some(true) {
|
||||||
status_parts.push("Broken".red().to_string());
|
status_parts.push("Broken".red().to_string());
|
||||||
|
|
@ -215,7 +194,6 @@ fn print_package_info(
|
||||||
println!(" {} {}", "Status:".bold(), status_parts.join(", "));
|
println!(" {} {}", "Status:".bold(), status_parts.join(", "));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Platforms
|
|
||||||
if let Some(ref platforms) = meta.platforms {
|
if let Some(ref platforms) = meta.platforms {
|
||||||
let platform_list: Vec<_> = platforms.iter().take(4).cloned().collect();
|
let platform_list: Vec<_> = platforms.iter().take(4).cloned().collect();
|
||||||
let platform_str = if platforms.len() > 4 {
|
let platform_str = if platforms.len() > 4 {
|
||||||
|
|
@ -230,7 +208,6 @@ fn print_package_info(
|
||||||
println!(" {} {}", "Platforms:".bold(), platform_str);
|
println!(" {} {}", "Platforms:".bold(), platform_str);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Outputs section
|
|
||||||
if let Some(outputs) = outputs {
|
if let Some(outputs) = outputs {
|
||||||
println!();
|
println!();
|
||||||
println!(" {}", "Outputs:".bold());
|
println!(" {}", "Outputs:".bold());
|
||||||
|
|
@ -241,7 +218,6 @@ fn print_package_info(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usage section
|
|
||||||
println!();
|
println!();
|
||||||
println!(" {}", "Usage:".bold());
|
println!(" {}", "Usage:".bold());
|
||||||
println!(
|
println!(
|
||||||
|
|
|
||||||
|
|
@ -1,488 +1,29 @@
|
||||||
use std::{
|
|
||||||
io::{self, Read, Write},
|
|
||||||
process::{Command, ExitStatus, Output, Stdio},
|
|
||||||
sync::mpsc,
|
|
||||||
thread,
|
|
||||||
time::{Duration, Instant},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::{EhError, Result},
|
error::Result,
|
||||||
util::{
|
hash::{HashExtractor, NixFileFixer},
|
||||||
HashExtractor,
|
retry::{NixErrorClassifier, handle_nix_with_retry},
|
||||||
NixErrorClassifier,
|
|
||||||
NixFileFixer,
|
|
||||||
handle_nix_with_retry,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod info;
|
pub mod info;
|
||||||
pub mod update;
|
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(
|
pub fn handle_nix_command(
|
||||||
command: &str,
|
command: &str,
|
||||||
args: &[String],
|
args: &[String],
|
||||||
hash_extractor: &dyn HashExtractor,
|
hash_extractor: &dyn HashExtractor,
|
||||||
fixer: &dyn NixFileFixer,
|
fixer: &dyn NixFileFixer,
|
||||||
classifier: &dyn NixErrorClassifier,
|
classifier: &dyn NixErrorClassifier,
|
||||||
cfg: &crate::config::CommandConfig,
|
cfg: &eh_config::CommandConfig,
|
||||||
ask: bool,
|
ask: bool,
|
||||||
) -> Result<i32> {
|
) -> Result<i32> {
|
||||||
let intercept_env = matches!(command, "run" | "shell");
|
|
||||||
handle_nix_with_retry(
|
handle_nix_with_retry(
|
||||||
command,
|
command,
|
||||||
args,
|
args,
|
||||||
hash_extractor,
|
hash_extractor,
|
||||||
fixer,
|
fixer,
|
||||||
classifier,
|
classifier,
|
||||||
intercept_env,
|
matches!(command, "run" | "shell" | "develop"),
|
||||||
cfg,
|
cfg,
|
||||||
ask,
|
ask,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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::{
|
use crate::{
|
||||||
commands::{NixCommand, StdIoInterceptor},
|
|
||||||
error::{EhError, Result},
|
error::{EhError, Result},
|
||||||
|
nix_config::ApplyCommandConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Parse flake input names from `nix flake metadata --json` output.
|
/// 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`.
|
/// Fetch flake input names by running `nix flake metadata --json`.
|
||||||
fn fetch_flake_inputs() -> Result<Vec<String>> {
|
fn fetch_flake_inputs() -> Result<Vec<String>> {
|
||||||
let output = NixCommand::new("flake")
|
let output = NixCommand::new(CommandKind::Flake)
|
||||||
.arg("metadata")
|
.arg("metadata")
|
||||||
.arg("--json")
|
.arg("--json")
|
||||||
.print_build_logs(false)
|
.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.
|
/// Otherwise, fetch inputs interactively and prompt for selection.
|
||||||
pub fn handle_update(
|
pub fn handle_update(
|
||||||
args: &[String],
|
args: &[String],
|
||||||
cfg: &crate::config::CommandConfig,
|
cfg: &eh_config::CommandConfig,
|
||||||
) -> Result<i32> {
|
) -> Result<i32> {
|
||||||
let selected = if args.is_empty() {
|
let selected = if args.is_empty() {
|
||||||
|
eh_log::log_debug!("checking flake inputs");
|
||||||
let inputs = fetch_flake_inputs()?;
|
let inputs = fetch_flake_inputs()?;
|
||||||
if inputs.is_empty() {
|
if inputs.is_empty() {
|
||||||
return Err(EhError::NoFlakeInputs);
|
return Err(EhError::NoFlakeInputs);
|
||||||
|
|
@ -69,14 +72,16 @@ pub fn handle_update(
|
||||||
args.to_vec()
|
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 {
|
for name in &selected {
|
||||||
cmd = cmd.arg("--update-input").arg(name);
|
cmd = cmd.arg("--update-input").arg(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
eh_log::log_info!("updating inputs: {}", selected.join(", "));
|
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))
|
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;
|
use thiserror::Error;
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
|
|
@ -10,6 +8,9 @@ pub enum EhError {
|
||||||
#[error("io: {0}")]
|
#[error("io: {0}")]
|
||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
NixCommand(#[from] nix_command::Error),
|
||||||
|
|
||||||
#[error("regex: {0}")]
|
#[error("regex: {0}")]
|
||||||
Regex(#[from] regex::Error),
|
Regex(#[from] regex::Error),
|
||||||
|
|
||||||
|
|
@ -28,24 +29,12 @@ pub enum EhError {
|
||||||
#[error("process exited with code {code}")]
|
#[error("process exited with code {code}")]
|
||||||
ProcessExit { code: i32 },
|
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}")]
|
#[error("'{expression}' failed to evaluate: {stderr}")]
|
||||||
PreEvalFailed {
|
PreEvalFailed {
|
||||||
expression: String,
|
expression: String,
|
||||||
stderr: String,
|
stderr: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
#[error("invalid input '{input}': {reason}")]
|
|
||||||
InvalidInput { input: String, reason: String },
|
|
||||||
|
|
||||||
#[error("failed to parse JSON from nix output: {detail}")]
|
#[error("failed to parse JSON from nix output: {detail}")]
|
||||||
JsonParse { detail: String },
|
JsonParse { detail: String },
|
||||||
|
|
||||||
|
|
@ -55,6 +44,9 @@ pub enum EhError {
|
||||||
#[error("no inputs selected")]
|
#[error("no inputs selected")]
|
||||||
UpdateCancelled,
|
UpdateCancelled,
|
||||||
|
|
||||||
|
#[error("empty nix expression")]
|
||||||
|
InvalidEvalInput,
|
||||||
|
|
||||||
#[error(
|
#[error(
|
||||||
"package {reason} but `--impure` is disabled for `{command}` in config"
|
"package {reason} but `--impure` is disabled for `{command}` in config"
|
||||||
)]
|
)]
|
||||||
|
|
@ -69,20 +61,18 @@ impl EhError {
|
||||||
match self {
|
match self {
|
||||||
Self::ProcessExit { code } => *code,
|
Self::ProcessExit { code } => *code,
|
||||||
Self::NixCommandFailed { .. } => 2,
|
Self::NixCommandFailed { .. } => 2,
|
||||||
Self::CommandFailed { .. } => 3,
|
|
||||||
Self::HashExtractionFailed { .. } => 4,
|
Self::HashExtractionFailed { .. } => 4,
|
||||||
Self::NoNixFilesFound => 5,
|
Self::NoNixFilesFound => 5,
|
||||||
Self::HashFixFailed { .. } => 6,
|
Self::HashFixFailed { .. } => 6,
|
||||||
Self::InvalidInput { .. } => 7,
|
Self::Io(_) | Self::NixCommand(_) => 8,
|
||||||
Self::Io(_) => 8,
|
|
||||||
Self::Regex(_) => 9,
|
Self::Regex(_) => 9,
|
||||||
Self::Utf8(_) => 10,
|
Self::Utf8(_) => 10,
|
||||||
Self::Timeout { .. } => 11,
|
|
||||||
Self::PreEvalFailed { .. } => 12,
|
Self::PreEvalFailed { .. } => 12,
|
||||||
Self::JsonParse { .. } => 13,
|
Self::JsonParse { .. } => 13,
|
||||||
Self::NoFlakeInputs => 14,
|
Self::NoFlakeInputs => 14,
|
||||||
Self::UpdateCancelled => 0,
|
Self::UpdateCancelled => 0,
|
||||||
Self::ImpureRequired { .. } => 15,
|
Self::ImpureRequired { .. } => 15,
|
||||||
|
Self::InvalidEvalInput => 16,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,15 +91,6 @@ impl EhError {
|
||||||
Self::NoNixFilesFound => {
|
Self::NoNixFilesFound => {
|
||||||
Some("run this command from a directory containing .nix files")
|
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 { .. } => {
|
Self::JsonParse { .. } => {
|
||||||
Some("ensure 'nix flake metadata --json' produces valid output")
|
Some("ensure 'nix flake metadata --json' produces valid output")
|
||||||
},
|
},
|
||||||
|
|
@ -122,12 +103,15 @@ impl EhError {
|
||||||
~/.config/eh/config.toml, or pass `--impure` manually",
|
~/.config/eh/config.toml, or pass `--impure` manually",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
Self::InvalidEvalInput => {
|
||||||
|
Some("pass a package name, flake reference, or path")
|
||||||
|
},
|
||||||
Self::Io(_)
|
Self::Io(_)
|
||||||
|
| Self::NixCommand(_)
|
||||||
| Self::Regex(_)
|
| Self::Regex(_)
|
||||||
| Self::Utf8(_)
|
| Self::Utf8(_)
|
||||||
| Self::HashFixFailed { .. }
|
| Self::HashFixFailed { .. }
|
||||||
| Self::ProcessExit { .. }
|
| Self::ProcessExit { .. }
|
||||||
| Self::CommandFailed { .. }
|
|
||||||
| Self::UpdateCancelled => None,
|
| Self::UpdateCancelled => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -146,13 +130,6 @@ mod tests {
|
||||||
.exit_code(),
|
.exit_code(),
|
||||||
2
|
2
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
EhError::CommandFailed {
|
|
||||||
command: "x".into(),
|
|
||||||
}
|
|
||||||
.exit_code(),
|
|
||||||
3
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
EhError::HashExtractionFailed {
|
EhError::HashExtractionFailed {
|
||||||
stderr: String::new(),
|
stderr: String::new(),
|
||||||
|
|
@ -162,22 +139,6 @@ mod tests {
|
||||||
);
|
);
|
||||||
assert_eq!(EhError::NoNixFilesFound.exit_code(), 5);
|
assert_eq!(EhError::NoNixFilesFound.exit_code(), 5);
|
||||||
assert_eq!(EhError::HashFixFailed { path: "x".into() }.exit_code(), 6);
|
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!(
|
assert_eq!(
|
||||||
EhError::PreEvalFailed {
|
EhError::PreEvalFailed {
|
||||||
expression: "x".into(),
|
expression: "x".into(),
|
||||||
|
|
@ -194,12 +155,6 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_display_messages() {
|
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 {
|
let err = EhError::PreEvalFailed {
|
||||||
expression: "nixpkgs#hello".into(),
|
expression: "nixpkgs#hello".into(),
|
||||||
stderr: "attribute not found".into(),
|
stderr: "attribute not found".into(),
|
||||||
|
|
@ -231,22 +186,6 @@ mod tests {
|
||||||
.is_some()
|
.is_some()
|
||||||
);
|
);
|
||||||
assert!(EhError::NoNixFilesFound.hint().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
|
// Variants with hints
|
||||||
assert!(
|
assert!(
|
||||||
EhError::NixCommandFailed {
|
EhError::NixCommandFailed {
|
||||||
|
|
@ -258,13 +197,6 @@ mod tests {
|
||||||
assert!(EhError::JsonParse { detail: "x".into() }.hint().is_some());
|
assert!(EhError::JsonParse { detail: "x".into() }.hint().is_some());
|
||||||
assert!(EhError::NoFlakeInputs.hint().is_some());
|
assert!(EhError::NoFlakeInputs.hint().is_some());
|
||||||
// Variants without hints
|
// Variants without hints
|
||||||
assert!(
|
|
||||||
EhError::CommandFailed {
|
|
||||||
command: "x".into(),
|
|
||||||
}
|
|
||||||
.hint()
|
|
||||||
.is_none()
|
|
||||||
);
|
|
||||||
assert!(EhError::ProcessExit { code: 1 }.hint().is_none());
|
assert!(EhError::ProcessExit { code: 1 }.hint().is_none());
|
||||||
assert!(EhError::UpdateCancelled.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 error;
|
||||||
pub mod util;
|
|
||||||
|
|
||||||
pub use clap::{CommandFactory, Parser, Subcommand};
|
pub use clap::{CommandFactory, Parser, Subcommand};
|
||||||
pub use error::{EhError, Result};
|
pub use error::{EhError, Result};
|
||||||
|
|
@ -22,6 +19,14 @@ pub enum Shell {
|
||||||
#[command(about = "Ergonomic Nix helper", long_about = None)]
|
#[command(about = "Ergonomic Nix helper", long_about = None)]
|
||||||
#[command(version)]
|
#[command(version)]
|
||||||
pub struct Cli {
|
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)]
|
#[command(subcommand)]
|
||||||
pub command: Option<Command>,
|
pub command: Option<Command>,
|
||||||
}
|
}
|
||||||
|
|
@ -31,28 +36,28 @@ pub enum Command {
|
||||||
/// Run a Nix derivation
|
/// Run a Nix derivation
|
||||||
Run {
|
Run {
|
||||||
#[arg(short, long, default_value = "false")]
|
#[arg(short, long, default_value = "false")]
|
||||||
ask: bool,
|
ask: bool,
|
||||||
#[arg(trailing_var_arg = true)]
|
#[arg(trailing_var_arg = true)]
|
||||||
args: Vec<String>,
|
args: Vec<String>,
|
||||||
},
|
},
|
||||||
/// Enter a Nix shell
|
/// Enter a Nix shell
|
||||||
Shell {
|
Shell {
|
||||||
#[arg(short, long, default_value = "false")]
|
#[arg(short, long, default_value = "false")]
|
||||||
ask: bool,
|
ask: bool,
|
||||||
#[arg(trailing_var_arg = true)]
|
#[arg(trailing_var_arg = true)]
|
||||||
args: Vec<String>,
|
args: Vec<String>,
|
||||||
},
|
},
|
||||||
/// Build a Nix derivation
|
/// Build a Nix derivation
|
||||||
Build {
|
Build {
|
||||||
#[arg(short, long, default_value = "false")]
|
#[arg(short, long, default_value = "false")]
|
||||||
ask: bool,
|
ask: bool,
|
||||||
#[arg(trailing_var_arg = true)]
|
#[arg(trailing_var_arg = true)]
|
||||||
args: Vec<String>,
|
args: Vec<String>,
|
||||||
},
|
},
|
||||||
/// Enter a Nix development shell
|
/// Enter a Nix development shell
|
||||||
Develop {
|
Develop {
|
||||||
#[arg(short, long, default_value = "false")]
|
#[arg(short, long, default_value = "false")]
|
||||||
ask: bool,
|
ask: bool,
|
||||||
#[arg(trailing_var_arg = true)]
|
#[arg(trailing_var_arg = true)]
|
||||||
args: Vec<String>,
|
args: Vec<String>,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
use std::{env, path::Path};
|
use std::{env, path::Path};
|
||||||
|
|
||||||
use clap_complete::{generate, Shell};
|
use clap_complete::{Shell, generate};
|
||||||
use eh::{Cli, Command, CommandFactory, Parser, Shell as EhShell};
|
use eh::{Cli, Command, CommandFactory, Parser, Shell as EhShell};
|
||||||
use yansi::Paint;
|
use yansi::Paint;
|
||||||
|
|
||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
|
||||||
mod error;
|
mod error;
|
||||||
mod util;
|
mod eval;
|
||||||
|
mod hash;
|
||||||
|
mod nix_config;
|
||||||
|
mod retry;
|
||||||
|
mod suggestions;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let result = run_app();
|
let result = run_app();
|
||||||
|
|
@ -27,11 +30,15 @@ fn main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_command(command: &str, args: &[String], ask: bool) -> error::Result<i32> {
|
fn handle_command(
|
||||||
let hash_extractor = util::RegexHashExtractor;
|
command: &str,
|
||||||
let fixer = util::DefaultNixFileFixer;
|
args: &[String],
|
||||||
let classifier = util::DefaultNixErrorClassifier;
|
ask: bool,
|
||||||
let cfg = config::load();
|
) -> error::Result<i32> {
|
||||||
|
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);
|
let cmd_cfg = cfg.for_command(command);
|
||||||
|
|
||||||
match command {
|
match command {
|
||||||
|
|
@ -55,9 +62,21 @@ fn handle_command(command: &str, args: &[String], ask: bool) -> error::Result<i3
|
||||||
|
|
||||||
fn dispatch_multicall(
|
fn dispatch_multicall(
|
||||||
app_name: &str,
|
app_name: &str,
|
||||||
args: std::env::Args,
|
args: impl IntoIterator<Item = String>,
|
||||||
) -> Option<error::Result<i32>> {
|
) -> 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 {
|
let subcommand = match app_name {
|
||||||
"nr" => "run",
|
"nr" => "run",
|
||||||
|
|
@ -106,6 +125,7 @@ fn run_app() -> error::Result<i32> {
|
||||||
}
|
}
|
||||||
|
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
eh_log::set_verbosity(cli.verbose as i8 - cli.quiet as i8);
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Some(Command::Run { ask, args }) => handle_command("run", &args, ask),
|
Some(Command::Run { ask, args }) => handle_command("run", &args, ask),
|
||||||
|
|
@ -114,7 +134,9 @@ fn run_app() -> error::Result<i32> {
|
||||||
|
|
||||||
Some(Command::Build { ask, args }) => handle_command("build", &args, ask),
|
Some(Command::Build { ask, args }) => handle_command("build", &args, ask),
|
||||||
|
|
||||||
Some(Command::Develop { ask, args }) => handle_command("develop", &args, ask),
|
Some(Command::Develop { ask, args }) => {
|
||||||
|
handle_command("develop", &args, ask)
|
||||||
|
},
|
||||||
|
|
||||||
Some(Command::Info { args }) => handle_command("info", &args, false),
|
Some(Command::Info { args }) => handle_command("info", &args, false),
|
||||||
|
|
||||||
|
|
|
||||||
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())))
|
||||||
|
}
|
||||||
|
}
|
||||||
407
eh/src/retry.rs
Normal file
407
eh/src/retry.rs
Normal file
|
|
@ -0,0 +1,407 @@
|
||||||
|
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,
|
||||||
|
ask: bool,
|
||||||
|
) -> 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)?;
|
||||||
|
confirm_impure_retry(pkg, reason, ask)?;
|
||||||
|
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,
|
||||||
|
ask,
|
||||||
|
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)?;
|
||||||
|
confirm_impure_retry(pkg, reason, ask)?;
|
||||||
|
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 }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn confirm_impure_retry(pkg: &str, reason: &str, ask: bool) -> Result<()> {
|
||||||
|
if !ask {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !std::io::stdin().is_terminal() {
|
||||||
|
return Err(EhError::Io(std::io::Error::other(
|
||||||
|
"cannot prompt for retry confirmation in non-interactive mode (no TTY)",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let choices = ["Yes, retry with --impure", "No, cancel"];
|
||||||
|
let idx = dialoguer::Select::new()
|
||||||
|
.with_prompt(format!(
|
||||||
|
"Package {} requires `--impure` ({}). Retry?",
|
||||||
|
pkg.bold(),
|
||||||
|
reason.bold()
|
||||||
|
))
|
||||||
|
.items(choices)
|
||||||
|
.default(0)
|
||||||
|
.interact()
|
||||||
|
.map_err(|e| EhError::Io(std::io::Error::other(e)))?;
|
||||||
|
|
||||||
|
if idx == 0 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(EhError::ProcessExit { code: 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HashFixContext<'a> {
|
||||||
|
subcommand: &'a str,
|
||||||
|
args: &'a [String],
|
||||||
|
cfg: &'a eh_config::CommandConfig,
|
||||||
|
interactive: bool,
|
||||||
|
ask: 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() {
|
||||||
|
if ctx.ask {
|
||||||
|
return Err(EhError::Io(std::io::Error::other(
|
||||||
|
"cannot prompt for hash fix confirmation in non-interactive mode (no \
|
||||||
|
TTY)",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
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"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
1222
eh/src/util.rs
1222
eh/src/util.rs
File diff suppressed because it is too large
Load diff
|
|
@ -39,7 +39,6 @@ rustPlatform.buildRustPackage (finalAttrs: {
|
||||||
doInstallCheck = true;
|
doInstallCheck = true;
|
||||||
|
|
||||||
strictDeps = true;
|
strictDeps = true;
|
||||||
|
|
||||||
nativeBuildInputs = [installShellFiles];
|
nativeBuildInputs = [installShellFiles];
|
||||||
|
|
||||||
postInstall = ''
|
postInstall = ''
|
||||||
|
|
@ -58,6 +57,7 @@ rustPlatform.buildRustPackage (finalAttrs: {
|
||||||
|
|
||||||
meta = {
|
meta = {
|
||||||
description = "Ergonomic Nix CLI helper";
|
description = "Ergonomic Nix CLI helper";
|
||||||
|
homepage = "https://github.com/notashelf/eh";
|
||||||
maintainers = with lib.maintainers; [NotAShelf];
|
maintainers = with lib.maintainers; [NotAShelf];
|
||||||
license = lib.licenses.mpl20;
|
license = lib.licenses.mpl20;
|
||||||
mainProgram = "eh";
|
mainProgram = "eh";
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,14 @@
|
||||||
rustfmt,
|
rustfmt,
|
||||||
clippy,
|
clippy,
|
||||||
taplo,
|
taplo,
|
||||||
rust-analyzer-unwrapped,
|
rust-analyzer,
|
||||||
rustPlatform,
|
cargo-nextest,
|
||||||
}:
|
}:
|
||||||
mkShell {
|
mkShell {
|
||||||
name = "rust";
|
name = "rust";
|
||||||
|
|
||||||
packages = [
|
strictDeps = true;
|
||||||
|
nativeBuildInputs = [
|
||||||
rustc
|
rustc
|
||||||
cargo
|
cargo
|
||||||
|
|
||||||
|
|
@ -19,8 +20,8 @@ mkShell {
|
||||||
clippy
|
clippy
|
||||||
cargo
|
cargo
|
||||||
taplo
|
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