eh: add eh update or nu in symlink

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Iee1d7c2ed2c4b2cd5520c68ceb2b5e6d6a6a6964
This commit is contained in:
raf 2026-01-30 20:54:29 +03:00
commit 5dc7b1dcd4
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
7 changed files with 188 additions and 15 deletions

View file

@ -11,4 +11,4 @@ publish = false
[dependencies] [dependencies]
clap.workspace = true clap.workspace = true
clap_complete.workspace = true clap_complete.workspace = true
eh.workspace = true eh.workspace = true

View file

@ -42,6 +42,7 @@ enum Binary {
Nr, Nr,
Ns, Ns,
Nb, Nb,
Nu,
} }
impl Binary { impl Binary {
@ -50,6 +51,7 @@ impl Binary {
Self::Nr => "nr", Self::Nr => "nr",
Self::Ns => "ns", Self::Ns => "ns",
Self::Nb => "nb", Self::Nb => "nb",
Self::Nu => "nu",
} }
} }
} }
@ -90,7 +92,7 @@ fn create_multicall_binaries(
); );
} }
let multicall_binaries = [Binary::Nr, Binary::Ns, Binary::Nb]; let multicall_binaries = [Binary::Nr, Binary::Ns, Binary::Nb, Binary::Nu];
let bin_path = Path::new(bin_dir); let bin_path = Path::new(bin_dir);
for binary in multicall_binaries { for binary in multicall_binaries {
@ -153,7 +155,7 @@ fn generate_completions(
println!("completion file generated: {}", completion_file.display()); println!("completion file generated: {}", completion_file.display());
// Create symlinks for multicall binaries // Create symlinks for multicall binaries
let multicall_names = ["nb", "nr", "ns"]; let multicall_names = ["nb", "nr", "ns", "nu"];
for name in &multicall_names { for name in &multicall_names {
let symlink_path = output_dir.join(format!("{name}.{shell}")); let symlink_path = output_dir.join(format!("{name}.{shell}"));
if symlink_path.exists() { if symlink_path.exists() {

View file

@ -11,10 +11,12 @@ crate-type = [ "lib" ]
name = "eh" name = "eh"
[dependencies] [dependencies]
clap.workspace = true clap.workspace = true
eh-log.workspace = true dialoguer.workspace = true
regex.workspace = true eh-log.workspace = true
tempfile.workspace = true regex.workspace = true
thiserror.workspace = true serde_json.workspace = true
walkdir.workspace = true tempfile.workspace = true
yansi.workspace = true thiserror.workspace = true
walkdir.workspace = true
yansi.workspace = true

View file

@ -45,6 +45,15 @@ pub enum EhError {
#[error("invalid input '{input}': {reason}")] #[error("invalid input '{input}': {reason}")]
InvalidInput { input: String, reason: String }, InvalidInput { input: String, reason: String },
#[error("failed to parse JSON from nix output: {detail}")]
JsonParse { detail: String },
#[error("no flake inputs found in lock file")]
NoFlakeInputs,
#[error("no inputs selected")]
UpdateCancelled,
} }
pub type Result<T> = std::result::Result<T, EhError>; pub type Result<T> = std::result::Result<T, EhError>;
@ -65,6 +74,9 @@ impl EhError {
Self::Utf8(_) => 10, Self::Utf8(_) => 10,
Self::Timeout { .. } => 11, Self::Timeout { .. } => 11,
Self::PreEvalFailed { .. } => 12, Self::PreEvalFailed { .. } => 12,
Self::JsonParse { .. } => 13,
Self::NoFlakeInputs => 14,
Self::UpdateCancelled => 0,
} }
} }
@ -92,12 +104,19 @@ impl EhError {
Self::InvalidInput { .. } => { Self::InvalidInput { .. } => {
Some("avoid shell metacharacters in nix arguments") Some("avoid shell metacharacters in nix arguments")
}, },
Self::JsonParse { .. } => {
Some("ensure 'nix flake metadata --json' produces valid output")
},
Self::NoFlakeInputs => {
Some("run this from a directory with a flake.lock that has inputs")
},
Self::Io(_) Self::Io(_)
| Self::Regex(_) | Self::Regex(_)
| Self::Utf8(_) | Self::Utf8(_)
| Self::HashFixFailed { .. } | Self::HashFixFailed { .. }
| Self::ProcessExit { .. } | Self::ProcessExit { .. }
| Self::CommandFailed { .. } => None, | Self::CommandFailed { .. }
| Self::UpdateCancelled => None,
} }
} }
} }
@ -156,6 +175,15 @@ mod tests {
12 12
); );
assert_eq!(EhError::ProcessExit { code: 42 }.exit_code(), 42); assert_eq!(EhError::ProcessExit { code: 42 }.exit_code(), 42);
assert_eq!(
EhError::JsonParse {
detail: "x".into(),
}
.exit_code(),
13
);
assert_eq!(EhError::NoFlakeInputs.exit_code(), 14);
assert_eq!(EhError::UpdateCancelled.exit_code(), 0);
} }
#[test] #[test]
@ -221,6 +249,14 @@ mod tests {
.hint() .hint()
.is_some() .is_some()
); );
assert!(
EhError::JsonParse {
detail: "x".into(),
}
.hint()
.is_some()
);
assert!(EhError::NoFlakeInputs.hint().is_some());
// Variants without hints // Variants without hints
assert!( assert!(
EhError::CommandFailed { EhError::CommandFailed {
@ -230,5 +266,6 @@ mod tests {
.is_none() .is_none()
); );
assert!(EhError::ProcessExit { code: 1 }.hint().is_none()); assert!(EhError::ProcessExit { code: 1 }.hint().is_none());
assert!(EhError::UpdateCancelled.hint().is_none());
} }
} }

View file

@ -3,6 +3,7 @@ pub mod command;
pub mod error; pub mod error;
pub mod run; pub mod run;
pub mod shell; pub mod shell;
pub mod update;
pub mod util; pub mod util;
pub use clap::{CommandFactory, Parser, Subcommand}; pub use clap::{CommandFactory, Parser, Subcommand};
@ -34,4 +35,9 @@ pub enum Command {
#[arg(trailing_var_arg = true)] #[arg(trailing_var_arg = true)]
args: Vec<String>, args: Vec<String>,
}, },
/// Update flake inputs interactively
Update {
#[arg(trailing_var_arg = true)]
args: Vec<String>,
},
} }

View file

@ -9,6 +9,7 @@ mod command;
mod error; mod error;
mod run; mod run;
mod shell; mod shell;
mod update;
mod util; mod util;
fn main() { fn main() {
@ -17,11 +18,14 @@ fn main() {
match result { match result {
Ok(code) => std::process::exit(code), Ok(code) => std::process::exit(code),
Err(e) => { Err(e) => {
eh_log::log_error!("{e}"); let code = e.exit_code();
if let Some(hint) = e.hint() { if code != 0 {
eh_log::log_hint!("{hint}"); eh_log::log_error!("{e}");
if let Some(hint) = e.hint() {
eh_log::log_hint!("{hint}");
}
} }
std::process::exit(e.exit_code()); std::process::exit(code);
}, },
} }
} }
@ -37,6 +41,7 @@ fn dispatch_multicall(
"nr" => "run", "nr" => "run",
"ns" => "shell", "ns" => "shell",
"nb" => "build", "nb" => "build",
"nu" => "update",
_ => return None, _ => return None,
}; };
@ -60,6 +65,10 @@ fn dispatch_multicall(
return Some(Ok(0)); return Some(Ok(0));
} }
if subcommand == "update" {
return Some(update::handle_update(&rest));
}
let hash_extractor = util::RegexHashExtractor; let hash_extractor = util::RegexHashExtractor;
let fixer = util::DefaultNixFileFixer; let fixer = util::DefaultNixFileFixer;
let classifier = util::DefaultNixErrorClassifier; let classifier = util::DefaultNixErrorClassifier;
@ -110,6 +119,8 @@ fn run_app() -> Result<i32> {
build::handle_nix_build(&args, &hash_extractor, &fixer, &classifier) build::handle_nix_build(&args, &hash_extractor, &fixer, &classifier)
}, },
Some(Command::Update { args }) => update::handle_update(&args),
None => { None => {
Cli::command().print_help()?; Cli::command().print_help()?;
println!(); println!();

115
eh/src/update.rs Normal file
View file

@ -0,0 +1,115 @@
use crate::{
command::{NixCommand, StdIoInterceptor},
error::{EhError, Result},
};
/// Parse flake input names from `nix flake metadata --json` output.
pub fn parse_flake_inputs(stdout: &str) -> Result<Vec<String>> {
let value: serde_json::Value =
serde_json::from_str(stdout).map_err(|e| EhError::JsonParse {
detail: e.to_string(),
})?;
let inputs = value
.get("locks")
.and_then(|l| l.get("nodes"))
.and_then(|n| n.get("root"))
.and_then(|r| r.get("inputs"))
.and_then(|i| i.as_object())
.ok_or(EhError::NoFlakeInputs)?;
let mut names: Vec<String> = inputs.keys().cloned().collect();
names.sort();
Ok(names)
}
/// Fetch flake input names by running `nix flake metadata --json`.
fn fetch_flake_inputs() -> Result<Vec<String>> {
let output = NixCommand::new("flake")
.arg("metadata")
.arg("--json")
.print_build_logs(false)
.output()?;
let stdout = String::from_utf8(output.stdout)?;
parse_flake_inputs(&stdout)
}
/// Prompt the user to select inputs via a multi-select dialog.
fn prompt_input_selection(inputs: &[String]) -> Result<Vec<String>> {
let selections = dialoguer::MultiSelect::new()
.with_prompt("Select inputs to update")
.items(inputs)
.interact()
.map_err(|e| EhError::Io(std::io::Error::other(e)))?;
if selections.is_empty() {
return Err(EhError::UpdateCancelled);
}
Ok(selections.iter().map(|&i| inputs[i].clone()).collect())
}
/// Entry point for the `update` subcommand.
///
/// If `args` is non-empty, use them as explicit input names.
/// Otherwise, fetch inputs interactively and prompt for selection.
pub fn handle_update(args: &[String]) -> Result<i32> {
let selected = if args.is_empty() {
let inputs = fetch_flake_inputs()?;
if inputs.is_empty() {
return Err(EhError::NoFlakeInputs);
}
prompt_input_selection(&inputs)?
} else {
args.to_vec()
};
let mut cmd = NixCommand::new("flake").arg("lock");
for name in &selected {
cmd = cmd.arg("--update-input").arg(name);
}
eh_log::log_info!("updating inputs: {}", selected.join(", "));
let status = cmd.run_with_logs(StdIoInterceptor)?;
Ok(status.code().unwrap_or(1))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_flake_inputs() {
let json = r#"{
"locks": {
"nodes": {
"root": {
"inputs": {
"nixpkgs": "nixpkgs_2",
"home-manager": "home-manager_2",
"flake-utils": "flake-utils_2"
}
}
}
}
}"#;
let inputs = parse_flake_inputs(json).unwrap();
assert_eq!(inputs, vec!["flake-utils", "home-manager", "nixpkgs"]);
}
#[test]
fn test_parse_flake_inputs_invalid_json() {
let result = parse_flake_inputs("not json");
assert!(result.is_err());
}
#[test]
fn test_parse_flake_inputs_no_inputs() {
let json = r#"{"locks": {"nodes": {"root": {}}}}"#;
let result = parse_flake_inputs(json);
assert!(matches!(result, Err(EhError::NoFlakeInputs)));
}
}