diff --git a/crates/xtask/Cargo.toml b/crates/xtask/Cargo.toml index 2e7cd1a..4343ed0 100644 --- a/crates/xtask/Cargo.toml +++ b/crates/xtask/Cargo.toml @@ -11,4 +11,4 @@ publish = false [dependencies] clap.workspace = true clap_complete.workspace = true -eh.workspace = true +eh.workspace = true diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index ad449c4..a580cda 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -42,6 +42,7 @@ enum Binary { Nr, Ns, Nb, + Nu, } impl Binary { @@ -50,6 +51,7 @@ impl Binary { Self::Nr => "nr", Self::Ns => "ns", 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); for binary in multicall_binaries { @@ -153,7 +155,7 @@ fn generate_completions( println!("completion file generated: {}", completion_file.display()); // Create symlinks for multicall binaries - let multicall_names = ["nb", "nr", "ns"]; + let multicall_names = ["nb", "nr", "ns", "nu"]; for name in &multicall_names { let symlink_path = output_dir.join(format!("{name}.{shell}")); if symlink_path.exists() { diff --git a/eh/Cargo.toml b/eh/Cargo.toml index d221297..92dd751 100644 --- a/eh/Cargo.toml +++ b/eh/Cargo.toml @@ -11,10 +11,12 @@ crate-type = [ "lib" ] name = "eh" [dependencies] -clap.workspace = true -eh-log.workspace = true -regex.workspace = true -tempfile.workspace = true -thiserror.workspace = true -walkdir.workspace = true -yansi.workspace = true +clap.workspace = true +dialoguer.workspace = true +eh-log.workspace = true +regex.workspace = true +serde_json.workspace = true +tempfile.workspace = true +thiserror.workspace = true +walkdir.workspace = true +yansi.workspace = true diff --git a/eh/src/error.rs b/eh/src/error.rs index 909453a..9ebc9b6 100644 --- a/eh/src/error.rs +++ b/eh/src/error.rs @@ -45,6 +45,15 @@ pub enum EhError { #[error("invalid input '{input}': {reason}")] 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 = std::result::Result; @@ -65,6 +74,9 @@ impl EhError { Self::Utf8(_) => 10, Self::Timeout { .. } => 11, Self::PreEvalFailed { .. } => 12, + Self::JsonParse { .. } => 13, + Self::NoFlakeInputs => 14, + Self::UpdateCancelled => 0, } } @@ -92,12 +104,19 @@ impl EhError { Self::InvalidInput { .. } => { 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::Regex(_) | Self::Utf8(_) | Self::HashFixFailed { .. } | Self::ProcessExit { .. } - | Self::CommandFailed { .. } => None, + | Self::CommandFailed { .. } + | Self::UpdateCancelled => None, } } } @@ -156,6 +175,15 @@ mod tests { 12 ); 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] @@ -221,6 +249,14 @@ mod tests { .hint() .is_some() ); + assert!( + EhError::JsonParse { + detail: "x".into(), + } + .hint() + .is_some() + ); + assert!(EhError::NoFlakeInputs.hint().is_some()); // Variants without hints assert!( EhError::CommandFailed { @@ -230,5 +266,6 @@ mod tests { .is_none() ); assert!(EhError::ProcessExit { code: 1 }.hint().is_none()); + assert!(EhError::UpdateCancelled.hint().is_none()); } } diff --git a/eh/src/lib.rs b/eh/src/lib.rs index 5dd2c7b..23c5e78 100644 --- a/eh/src/lib.rs +++ b/eh/src/lib.rs @@ -3,6 +3,7 @@ pub mod command; pub mod error; pub mod run; pub mod shell; +pub mod update; pub mod util; pub use clap::{CommandFactory, Parser, Subcommand}; @@ -34,4 +35,9 @@ pub enum Command { #[arg(trailing_var_arg = true)] args: Vec, }, + /// Update flake inputs interactively + Update { + #[arg(trailing_var_arg = true)] + args: Vec, + }, } diff --git a/eh/src/main.rs b/eh/src/main.rs index 8cc4d11..8ce8b6b 100644 --- a/eh/src/main.rs +++ b/eh/src/main.rs @@ -9,6 +9,7 @@ mod command; mod error; mod run; mod shell; +mod update; mod util; fn main() { @@ -17,11 +18,14 @@ fn main() { match result { Ok(code) => std::process::exit(code), Err(e) => { - eh_log::log_error!("{e}"); - if let Some(hint) = e.hint() { - eh_log::log_hint!("{hint}"); + let code = e.exit_code(); + if code != 0 { + 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", "ns" => "shell", "nb" => "build", + "nu" => "update", _ => return None, }; @@ -60,6 +65,10 @@ fn dispatch_multicall( return Some(Ok(0)); } + if subcommand == "update" { + return Some(update::handle_update(&rest)); + } + let hash_extractor = util::RegexHashExtractor; let fixer = util::DefaultNixFileFixer; let classifier = util::DefaultNixErrorClassifier; @@ -110,6 +119,8 @@ fn run_app() -> Result { build::handle_nix_build(&args, &hash_extractor, &fixer, &classifier) }, + Some(Command::Update { args }) => update::handle_update(&args), + None => { Cli::command().print_help()?; println!(); diff --git a/eh/src/update.rs b/eh/src/update.rs new file mode 100644 index 0000000..df4184e --- /dev/null +++ b/eh/src/update.rs @@ -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> { + 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 = inputs.keys().cloned().collect(); + names.sort(); + Ok(names) +} + +/// Fetch flake input names by running `nix flake metadata --json`. +fn fetch_flake_inputs() -> Result> { + 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> { + 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 { + 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))); + } +}