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"
dependencies = [
"clap",
"eh-log",
"regex",
"tempfile",
"thiserror",
@ -88,6 +89,13 @@ dependencies = [
"yansi",
]
[[package]]
name = "eh-log"
version = "0.1.3"
dependencies = [
"yansi",
]
[[package]]
name = "errno"
version = "0.3.14"

View file

@ -22,6 +22,7 @@ walkdir = "2.5.0"
yansi = "1.0.1"
eh = { path = "./eh" }
eh-log = { path = "./crates/eh-log" }
[profile.release]
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]
clap.workspace = true
eh-log.workspace = true
regex.workspace = true
tempfile.workspace = true
thiserror.workspace = true

View file

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

View file

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

View file

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