eh: improve error and warning glyphs; move logger to new crate

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I563b37a9f38f8dcec6dda7693ae45e826a6a6964
This commit is contained in:
raf 2026-01-30 18:31:19 +03:00
commit e6d1b90b97
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
8 changed files with 107 additions and 56 deletions

8
Cargo.lock generated
View file

@ -81,6 +81,7 @@ name = "eh"
version = "0.1.3" version = "0.1.3"
dependencies = [ dependencies = [
"clap", "clap",
"eh-log",
"regex", "regex",
"tempfile", "tempfile",
"thiserror", "thiserror",
@ -88,6 +89,13 @@ dependencies = [
"yansi", "yansi",
] ]
[[package]]
name = "eh-log"
version = "0.1.3"
dependencies = [
"yansi",
]
[[package]] [[package]]
name = "errno" name = "errno"
version = "0.3.14" version = "0.3.14"

View file

@ -21,7 +21,8 @@ thiserror = "2.0.17"
walkdir = "2.5.0" walkdir = "2.5.0"
yansi = "1.0.1" yansi = "1.0.1"
eh = { path = "./eh" } eh = { path = "./eh" }
eh-log = { path = "./crates/eh-log" }
[profile.release] [profile.release]
codegen-units = 1 codegen-units = 1

10
crates/eh-log/Cargo.toml Normal file
View file

@ -0,0 +1,10 @@
[package]
name = "eh-log"
description = "Styled logging for eh"
version.workspace = true
edition.workspace = true
authors.workspace = true
rust-version.workspace = true
[dependencies]
yansi.workspace = true

31
crates/eh-log/src/lib.rs Normal file
View file

@ -0,0 +1,31 @@
use std::fmt;
use yansi::Paint;
pub fn info(args: fmt::Arguments) {
eprintln!(" {} {args}", "->".green().bold());
}
pub fn warn(args: fmt::Arguments) {
eprintln!(" {} {args}", "->".yellow().bold());
}
pub fn error(args: fmt::Arguments) {
eprintln!(" {} {args}", "!".red().bold());
}
pub fn hint(args: fmt::Arguments) {
eprintln!(" {} {args}", "~".yellow().dim());
}
#[macro_export]
macro_rules! log_info { ($($t:tt)*) => { $crate::info(format_args!($($t)*)) } }
#[macro_export]
macro_rules! log_warn { ($($t:tt)*) => { $crate::warn(format_args!($($t)*)) } }
#[macro_export]
macro_rules! log_error { ($($t:tt)*) => { $crate::error(format_args!($($t)*)) } }
#[macro_export]
macro_rules! log_hint { ($($t:tt)*) => { $crate::hint(format_args!($($t)*)) } }

View file

@ -12,6 +12,7 @@ name = "eh"
[dependencies] [dependencies]
clap.workspace = true clap.workspace = true
eh-log.workspace = true
regex.workspace = true regex.workspace = true
tempfile.workspace = true tempfile.workspace = true
thiserror.workspace = true thiserror.workspace = true

View file

@ -4,46 +4,46 @@ use thiserror::Error;
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum EhError { pub enum EhError {
#[error("Nix command 'nix {command}' failed")] #[error("nix {command} failed")]
NixCommandFailed { command: String }, NixCommandFailed { command: String },
#[error("IO error: {0}")] #[error("io: {0}")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
#[error("Regex error: {0}")] #[error("regex: {0}")]
Regex(#[from] regex::Error), Regex(#[from] regex::Error),
#[error("UTF-8 conversion error: {0}")] #[error("utf-8 conversion: {0}")]
Utf8(#[from] std::string::FromUtf8Error), Utf8(#[from] std::string::FromUtf8Error),
#[error("Hash extraction failed: could not parse hash from nix output")] #[error("could not extract hash from nix output")]
HashExtractionFailed { stderr: String }, HashExtractionFailed { stderr: String },
#[error("No Nix files found in the current directory")] #[error("no .nix files found in the current directory")]
NoNixFilesFound, NoNixFilesFound,
#[error("Failed to fix hash in file: {path}")] #[error("could not update hash in {path}")]
HashFixFailed { path: String }, HashFixFailed { path: String },
#[error("Process exited with code: {code}")] #[error("process exited with code {code}")]
ProcessExit { code: i32 }, ProcessExit { code: i32 },
#[error("Command execution failed: {command}")] #[error("command '{command}' failed")]
CommandFailed { command: String }, CommandFailed { command: String },
#[error("Command '{command}' timed out after {} seconds", duration.as_secs())] #[error("nix {command} timed out after {} seconds", duration.as_secs())]
Timeout { Timeout {
command: String, command: String,
duration: Duration, duration: Duration,
}, },
#[error("Pre-evaluation of '{expression}' failed: {stderr}")] #[error("'{expression}' failed to evaluate: {stderr}")]
PreEvalFailed { PreEvalFailed {
expression: String, expression: String,
stderr: String, stderr: String,
}, },
#[error("Invalid input: {input} - {reason}")] #[error("invalid input '{input}': {reason}")]
InvalidInput { input: String, reason: String }, InvalidInput { input: String, reason: String },
} }
@ -72,31 +72,25 @@ impl EhError {
pub fn hint(&self) -> Option<&str> { pub fn hint(&self) -> Option<&str> {
match self { match self {
Self::NixCommandFailed { .. } => { Self::NixCommandFailed { .. } => {
Some("Run with --show-trace for more details from nix") Some("run with --show-trace for more details")
}, },
Self::PreEvalFailed { .. } => { Self::PreEvalFailed { .. } => {
Some( Some("check that the expression exists and is spelled correctly")
"Check that the expression exists in the flake and is spelled \
correctly",
)
}, },
Self::HashExtractionFailed { .. } => { Self::HashExtractionFailed { .. } => {
Some( Some("nix reported a hash mismatch but the hash could not be parsed")
"The nix output contained a hash mismatch but the hash could not be \
parsed",
)
}, },
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 { .. } => { Self::Timeout { .. } => {
Some( Some(
"The command took too long; try a faster network or a smaller \ "the command took too long; try a faster network or a smaller \
derivation", derivation",
) )
}, },
Self::InvalidInput { .. } => { Self::InvalidInput { .. } => {
Some("Avoid shell metacharacters in nix arguments") Some("avoid shell metacharacters in nix arguments")
}, },
Self::Io(_) Self::Io(_)
| Self::Regex(_) | Self::Regex(_)
@ -170,10 +164,7 @@ mod tests {
command: "build".into(), command: "build".into(),
duration: Duration::from_secs(300), duration: Duration::from_secs(300),
}; };
assert_eq!( assert_eq!(err.to_string(), "nix build timed out after 300 seconds");
err.to_string(),
"Command 'build' timed out after 300 seconds"
);
let err = EhError::PreEvalFailed { let err = EhError::PreEvalFailed {
expression: "nixpkgs#hello".into(), expression: "nixpkgs#hello".into(),
@ -185,7 +176,7 @@ mod tests {
let err = EhError::HashExtractionFailed { let err = EhError::HashExtractionFailed {
stderr: "some output".into(), stderr: "some output".into(),
}; };
assert!(err.to_string().contains("could not parse hash")); assert!(err.to_string().contains("could not extract hash"));
} }
#[test] #[test]

View file

@ -2,6 +2,7 @@ use std::{env, path::Path};
use eh::{Cli, Command, CommandFactory, Parser}; use eh::{Cli, Command, CommandFactory, Parser};
use error::Result; use error::Result;
use yansi::Paint;
mod build; mod build;
mod command; mod command;
@ -16,9 +17,9 @@ fn main() {
match result { match result {
Ok(code) => std::process::exit(code), Ok(code) => std::process::exit(code),
Err(e) => { Err(e) => {
eprintln!("Error: {e}"); eh_log::log_error!("{e}");
if let Some(hint) = e.hint() { if let Some(hint) = e.hint() {
eprintln!("Hint: {hint}"); eh_log::log_hint!("{hint}");
} }
std::process::exit(e.exit_code()); std::process::exit(e.exit_code());
}, },
@ -41,14 +42,21 @@ fn dispatch_multicall(
// Handle --help/-h/--version before forwarding to nix // Handle --help/-h/--version before forwarding to nix
if rest.iter().any(|a| a == "--help" || a == "-h") { if rest.iter().any(|a| a == "--help" || a == "-h") {
eprintln!("{app_name}: shorthand for 'eh {subcommand}'"); eprintln!(
eprintln!("Usage: {app_name} [args...]"); "{}: shorthand for '{}'",
eprintln!("All arguments are forwarded to 'nix {subcommand}'."); app_name.bold(),
format!("eh {subcommand}").bold()
);
eprintln!(" {} {app_name} [args...]", "usage:".green().bold());
eprintln!(
" All arguments are forwarded to '{}'.",
format!("nix {subcommand}").dim()
);
return Some(Ok(0)); return Some(Ok(0));
} }
if rest.iter().any(|a| a == "--version") { if rest.iter().any(|a| a == "--version") {
eprintln!("{app_name} (eh {})", env!("CARGO_PKG_VERSION")); eprintln!("{} (eh {})", app_name.bold(), env!("CARGO_PKG_VERSION"));
return Some(Ok(0)); return Some(Ok(0));
} }

View file

@ -4,6 +4,7 @@ use std::{
sync::LazyLock, sync::LazyLock,
}; };
use eh_log::{log_info, log_warn};
use regex::Regex; use regex::Regex;
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
use walkdir::WalkDir; use walkdir::WalkDir;
@ -92,7 +93,7 @@ impl NixFileFixer for DefaultNixFileFixer {
let mut fixed = false; let mut fixed = false;
for file_path in nix_files { for file_path in nix_files {
if self.fix_hash_in_file(&file_path, old_hash, new_hash)? { if self.fix_hash_in_file(&file_path, old_hash, new_hash)? {
println!("Updated hash in {}", file_path.display()); log_info!("updated hash in {}", file_path.display().bold());
fixed = true; fixed = true;
} }
} }
@ -251,12 +252,11 @@ fn package_name(args: &[String]) -> &str {
/// Print a retry message with consistent formatting. /// Print a retry message with consistent formatting.
/// Format: ` -> <pkg>: <reason>, setting <ENV>=1` /// Format: ` -> <pkg>: <reason>, setting <ENV>=1`
fn print_retry_msg(pkg: &str, reason: &str, env_var: &str) { fn print_retry_msg(pkg: &str, reason: &str, env_var: &str) {
eprintln!( log_warn!(
" {} {}: {}, setting {}", "{}: {}, setting {}",
"->".yellow().bold(),
pkg.bold(), pkg.bold(),
reason, reason,
format!("{env_var}=1").bold(), format!("{env_var}=1").bold()
); );
} }
@ -316,14 +316,17 @@ fn pre_evaluate(args: &[String]) -> Result<RetryAction> {
return Ok(action); return Ok(action);
} }
// For other eval failures, warn but let the actual command handle the // Non-retryable eval failure — fail fast with a clear message
// error with full streaming output rather than halting here. // rather than running the full command and showing the same error again.
let err = EhError::PreEvalFailed { let stderr_clean = stderr
.trim()
.strip_prefix("error:")
.unwrap_or(stderr.trim())
.trim();
Err(EhError::PreEvalFailed {
expression: eval_arg.clone(), expression: eval_arg.clone(),
stderr: stderr.trim().to_string(), stderr: stderr_clean.to_string(),
}; })
eprintln!(" {} {}", "->".yellow().bold(), err,);
Ok(RetryAction::None)
} }
pub fn validate_nix_args(args: &[String]) -> Result<()> { pub fn validate_nix_args(args: &[String]) -> Result<()> {
@ -398,10 +401,9 @@ pub fn handle_nix_with_retry(
let old_hash = hash_extractor.extract_old_hash(&stderr); let old_hash = hash_extractor.extract_old_hash(&stderr);
match fixer.fix_hash_in_files(old_hash.as_deref(), &new_hash) { match fixer.fix_hash_in_files(old_hash.as_deref(), &new_hash) {
Ok(true) => { Ok(true) => {
eprintln!( log_info!(
" {} {}: hash mismatch corrected in local files, rebuilding", "{}: hash mismatch corrected in local files, rebuilding",
"->".green().bold(), pkg.bold()
pkg.bold(),
); );
let mut retry_cmd = NixCommand::new(subcommand) let mut retry_cmd = NixCommand::new(subcommand)
.print_build_logs(true) .print_build_logs(true)
@ -416,10 +418,9 @@ pub fn handle_nix_with_retry(
// No files were fixed, continue with normal error handling // No files were fixed, continue with normal error handling
}, },
Err(EhError::NoNixFilesFound) => { Err(EhError::NoNixFilesFound) => {
eprintln!( log_warn!(
" {} {}: hash mismatch detected but no .nix files found to update", "{}: hash mismatch detected but no .nix files found to update",
"->".yellow().bold(), pkg.bold()
pkg.bold(),
); );
// Continue with normal error handling // Continue with normal error handling
}, },