diff --git a/src/darwin.rs b/src/darwin.rs index fb1b4427..a6e62c66 100644 --- a/src/darwin.rs +++ b/src/darwin.rs @@ -1,21 +1,26 @@ -use std::env; - use color_eyre::eyre::{bail, Context}; -use tracing::{debug, info, warn}; +use tracing::{debug, warn}; -use crate::commands; use crate::commands::Command; use crate::installable::Installable; use crate::interface::{DarwinArgs, DarwinRebuildArgs, DarwinReplArgs, DarwinSubcommand}; -use crate::nixos::toplevel_for; use crate::update::update; use crate::util::get_hostname; +use crate::util::platform; use crate::Result; +/// System profile path for darwin configurations const SYSTEM_PROFILE: &str = "/nix/var/nix/profiles/system"; +/// Current system profile path for darwin const CURRENT_PROFILE: &str = "/run/current-system"; impl DarwinArgs { + /// Entry point for processing darwin commands + /// + /// Handles the different subcommands for darwin configurations: + /// - Switch: Builds and activates the configuration + /// - Build: Only builds the configuration + /// - Repl: Opens a REPL for exploring the configuration pub fn run(self) -> Result<()> { use DarwinRebuildVariant::{Build, Switch}; match self.subcommand { @@ -31,18 +36,30 @@ impl DarwinArgs { } } +/// Variants of the darwin rebuild operation +/// +/// Each variant represents a different mode of operation: +/// - Switch: Build and activate the configuration +/// - Build: Only build the configuration without activation enum DarwinRebuildVariant { Switch, Build, } impl DarwinRebuildArgs { + /// Performs the rebuild operation for darwin configurations + /// + /// This function handles building and potentially activating darwin configurations. + /// It first builds the configuration, then shows a diff of changes compared to the + /// current system, and finally activates the configuration if needed. + /// + /// The darwin activation process is unique and requires special handling compared + /// to `NixOS`, particularly around determining whether root privileges are needed. fn rebuild(self, variant: DarwinRebuildVariant) -> Result<()> { use DarwinRebuildVariant::{Build, Switch}; - if nix::unistd::Uid::effective().is_root() { - bail!("Don't run nh os as root. I will call sudo internally as needed"); - } + // Check if running as root + platform::check_not_root(false)?; if self.update_args.update { update(&self.common.installable, self.update_args.update_input)?; @@ -50,89 +67,53 @@ impl DarwinRebuildArgs { let hostname = self.hostname.ok_or(()).or_else(|()| get_hostname())?; - let out_path: Box = match self.common.out_link { - Some(ref p) => Box::new(p.clone()), - None => Box::new({ - let dir = tempfile::Builder::new().prefix("nh-os").tempdir()?; - (dir.as_ref().join("result"), dir) - }), - }; - + // Create temporary output path + let out_path = platform::create_output_path(self.common.out_link, "nh-darwin")?; debug!(?out_path); - // Use NH_DARWIN_FLAKE if available, otherwise use the provided installable - let installable = if let Ok(darwin_flake) = env::var("NH_DARWIN_FLAKE") { - debug!("Using NH_DARWIN_FLAKE: {}", darwin_flake); - - let mut elems = darwin_flake.splitn(2, '#'); - let reference = elems.next().unwrap().to_owned(); - let attribute = elems - .next() - .map(crate::installable::parse_attribute) - .unwrap_or_default(); - - Installable::Flake { - reference, - attribute, - } - } else { - self.common.installable.clone() - }; - - let mut processed_installable = installable; - if let Installable::Flake { - ref mut attribute, .. - } = processed_installable - { - // If user explicitly selects some other attribute, don't push darwinConfigurations - if attribute.is_empty() { - attribute.push(String::from("darwinConfigurations")); - attribute.push(hostname.clone()); - } - } - - let toplevel = toplevel_for(hostname, processed_installable, "toplevel"); - - commands::Build::new(toplevel) - .extra_arg("--out-link") - .extra_arg(out_path.get_path()) - .extra_args(&self.extra_args) - .message("Building Darwin configuration") - .nom(!self.common.no_nom) - .run()?; - - let target_profile = out_path.get_path().to_owned(); - - target_profile.try_exists().context("Doesn't exist")?; - - Command::new("nvd") - .arg("diff") - .arg(CURRENT_PROFILE) - .arg(&target_profile) - .message("Comparing changes") - .run()?; - - if self.common.ask && !self.common.dry && !matches!(variant, Build) { - info!("Apply the config?"); - let confirmation = dialoguer::Confirm::new().default(false).interact()?; - - if !confirmation { - bail!("User rejected the new config"); - } + // Resolve the installable from env var or from the provided argument + let installable = platform::resolve_env_installable("NH_DARWIN_FLAKE") + .unwrap_or_else(|| self.common.installable.clone()); + + // Build the darwin configuration with proper attribute path handling + let target_profile = platform::handle_rebuild_workflow( + installable, + "darwinConfigurations", + &["toplevel"], + Some(hostname), + out_path.as_ref(), + &self.extra_args, + None, // Darwin doesn't use remote builders + "Building Darwin configuration", + self.common.no_nom, + "", // Darwin doesn't use specialisations like NixOS + false, + None, + CURRENT_PROFILE, + false, + )?; + + // Allow users to confirm before applying changes + if !platform::confirm_action( + self.common.ask && !matches!(variant, Build), + self.common.dry, + )? { + return Ok(()); } if matches!(variant, Switch) { Command::new("nix") .args(["build", "--no-link", "--profile", SYSTEM_PROFILE]) - .arg(out_path.get_path()) + .arg(&target_profile) .elevate(true) .dry(self.common.dry) .run()?; - let darwin_rebuild = out_path.get_path().join("sw/bin/darwin-rebuild"); - let activate_user = out_path.get_path().join("activate-user"); + let darwin_rebuild = target_profile.join("sw/bin/darwin-rebuild"); + let activate_user = target_profile.join("activate-user"); - // Determine if we need to elevate privileges + // Darwin activation may or may not need root privileges + // This checks if we need elevation based on the activation-user script let needs_elevation = !activate_user .try_exists() .context("Failed to check if activate-user file exists")? @@ -140,7 +121,7 @@ impl DarwinRebuildArgs { .context("Failed to read activate-user file")? .contains("# nix-darwin: deprecated"); - // Create and run the activation command with or without elevation + // Actually activate the configuration using darwin-rebuild Command::new(darwin_rebuild) .arg("activate") .message("Activating configuration") @@ -151,54 +132,31 @@ impl DarwinRebuildArgs { // Make sure out_path is not accidentally dropped // https://docs.rs/tempfile/3.12.0/tempfile/index.html#early-drop-pitfall - drop(out_path); Ok(()) } } impl DarwinReplArgs { + /// Opens a Nix REPL for exploring darwin configurations + /// + /// Provides an interactive environment to explore and evaluate + /// components of a darwin configuration. fn run(self) -> Result<()> { - // Use NH_DARWIN_FLAKE if available, otherwise use the provided installable - let mut target_installable = if let Ok(darwin_flake) = env::var("NH_DARWIN_FLAKE") { - debug!("Using NH_DARWIN_FLAKE: {}", darwin_flake); - - let mut elems = darwin_flake.splitn(2, '#'); - let reference = elems.next().unwrap().to_owned(); - let attribute = elems - .next() - .map(crate::installable::parse_attribute) - .unwrap_or_default(); - - Installable::Flake { - reference, - attribute, - } - } else { - self.installable - }; - - if matches!(target_installable, Installable::Store { .. }) { + if let Installable::Store { .. } = self.installable { bail!("Nix doesn't support nix store installables."); } let hostname = self.hostname.ok_or(()).or_else(|()| get_hostname())?; - if let Installable::Flake { - ref mut attribute, .. - } = target_installable - { - if attribute.is_empty() { - attribute.push(String::from("darwinConfigurations")); - attribute.push(hostname); - } - } - - Command::new("nix") - .arg("repl") - .args(target_installable.to_args()) - .run()?; - - Ok(()) + // Open an interactive REPL session for exploring darwin configurations + platform::run_repl( + platform::resolve_env_installable("NH_DARWIN_FLAKE") + .unwrap_or_else(|| self.installable), + "darwinConfigurations", + &[], // REPL doesn't need additional path elements + Some(hostname), + &[], // No extra REPL args + ) } } diff --git a/src/generations.rs b/src/generations.rs index 1e00f200..2431f1c9 100644 --- a/src/generations.rs +++ b/src/generations.rs @@ -43,7 +43,7 @@ pub fn from_dir(generation_dir: &Path) -> Option { }) } -pub fn describe(generation_dir: &Path, current_profile: &Path) -> Option { +pub fn describe(generation_dir: &Path, _current_profile: &Path) -> Option { let generation_number = from_dir(generation_dir)?; // Get metadata once and reuse for both date and existence checks diff --git a/src/home.rs b/src/home.rs index 1817ea9b..f8ae83a1 100644 --- a/src/home.rs +++ b/src/home.rs @@ -1,19 +1,21 @@ use std::env; -use std::ffi::OsString; use std::path::PathBuf; -use color_eyre::eyre::bail; use color_eyre::Result; use tracing::{debug, info, warn}; -use crate::commands; use crate::commands::Command; -use crate::installable::Installable; use crate::interface::{self, HomeRebuildArgs, HomeReplArgs, HomeSubcommand}; use crate::update::update; -use crate::util::get_hostname; +use crate::util::platform; impl interface::HomeArgs { + /// Entry point for processing home-manager commands + /// + /// Handles the different subcommands for home-manager configurations: + /// - Switch: Builds and activates the configuration + /// - Build: Only builds the configuration + /// - Repl: Opens a REPL for exploring the configuration pub fn run(self) -> Result<()> { use HomeRebuildVariant::{Build, Switch}; match self.subcommand { @@ -29,6 +31,11 @@ impl interface::HomeArgs { } } +/// Variants of the home-manager rebuild operation +/// +/// Represents different actions that can be taken with a home-manager configuration: +/// - Build: Only build the configuration without activating it +/// - Switch: Build and activate the configuration #[derive(Debug)] enum HomeRebuildVariant { Build, @@ -36,6 +43,18 @@ enum HomeRebuildVariant { } impl HomeRebuildArgs { + /// Performs the rebuild operation for home-manager configurations + /// + /// This function handles building and potentially activating home-manager configurations. + /// The workflow: + /// 1. Updates the flake if requested + /// 2. Creates a temporary output path + /// 3. Builds the configuration with proper specialisation handling + /// 4. Compares with the previous generation if it exists + /// 5. Activates the configuration if needed + /// + /// Home Manager has its own specialisation mechanism which this function handles + /// by looking in ~/.local/share/home-manager/specialisation. fn rebuild(self, variant: HomeRebuildVariant) -> Result<()> { use HomeRebuildVariant::Build; @@ -43,50 +62,38 @@ impl HomeRebuildArgs { update(&self.common.installable, self.update_args.update_input)?; } - let out_path: Box = match self.common.out_link { - Some(ref p) => Box::new(p.clone()), - None => Box::new({ - let dir = tempfile::Builder::new().prefix("nh-home").tempdir()?; - (dir.as_ref().join("result"), dir) - }), - }; - + // Create output path + let out_path = platform::create_output_path(self.common.out_link, "nh-home")?; debug!(?out_path); // Use NH_HOME_FLAKE if available, otherwise use the provided installable - let installable = if let Ok(home_flake) = env::var("NH_HOME_FLAKE") { - debug!("Using NH_HOME_FLAKE: {}", home_flake); + let installable = platform::resolve_env_installable("NH_HOME_FLAKE") + .unwrap_or_else(|| self.common.installable.clone()); - let mut elems = home_flake.splitn(2, '#'); - let reference = elems.next().unwrap().to_owned(); - let attribute = elems - .next() - .map(crate::installable::parse_attribute) - .unwrap_or_default(); - - Installable::Flake { - reference, - attribute, - } - } else { - self.common.installable.clone() - }; + // Set up the specialisation path + let spec_location = + PathBuf::from(std::env::var("HOME")?).join(".local/share/home-manager/specialisation"); - let toplevel = toplevel_for( + // Get the target profile + let _target_profile = platform::handle_rebuild_workflow( installable, - true, + "homeConfigurations", + &["config", "home", "activationPackage"], + None, // No explicit hostname for home-manager + out_path.as_ref(), &self.extra_args, - self.configuration.clone(), + None, // No builder + "Building Home-Manager configuration", + self.common.no_nom, + spec_location + .to_str() + .unwrap_or(".local/share/home-manager/specialisation"), + self.no_specialisation, + self.specialisation.clone(), + "", // Empty current profile - we'll handle the comparison separately + true, // Skip comparison as we'll do it manually )?; - commands::Build::new(toplevel) - .extra_arg("--out-link") - .extra_arg(out_path.get_path()) - .extra_args(&self.extra_args) - .message("Building Home-Manager configuration") - .nom(!self.common.no_nom) - .run()?; - let prev_generation: Option = [ PathBuf::from("/nix/var/nix/profiles/per-user") .join(env::var("USER").expect("Couldn't get username")) @@ -99,9 +106,6 @@ impl HomeRebuildArgs { debug!(?prev_generation); - let spec_location = - PathBuf::from(std::env::var("HOME")?).join(".local/share/home-manager/specialisation"); - let current_specialisation = std::fs::read_to_string(spec_location.to_str().unwrap()).ok(); let target_specialisation = if self.no_specialisation { @@ -117,14 +121,14 @@ impl HomeRebuildArgs { Some(spec) => Box::new(out_path.get_path().join("specialisation").join(spec)), }; - // just do nothing for None case (fresh installs) + // Just do nothing for None case (fresh installs) if let Some(generation) = prev_generation { - Command::new("nvd") - .arg("diff") - .arg(generation) - .arg(target_profile.get_path()) - .message("Comparing changes") - .run()?; + platform::compare_configurations( + generation.to_str().unwrap_or(""), + target_profile.get_path(), + false, + "Comparing changes", + )?; } if self.common.dry || matches!(variant, Build) { @@ -134,13 +138,9 @@ impl HomeRebuildArgs { return Ok(()); } - if self.common.ask { - info!("Apply the config?"); - let confirmation = dialoguer::Confirm::new().default(false).interact()?; - - if !confirmation { - bail!("User rejected the new config"); - } + // Check if user wants to proceed + if !platform::confirm_action(self.common.ask, self.common.dry)? { + return Ok(()); } if let Some(ext) = &self.backup_extension { @@ -160,220 +160,28 @@ impl HomeRebuildArgs { } } -fn toplevel_for( - installable: Installable, - push_drv: bool, - extra_args: I, - configuration_name: Option, -) -> Result -where - I: IntoIterator, - S: AsRef, -{ - let mut res = installable; - let extra_args: Vec = { - let mut vec = Vec::new(); - for elem in extra_args { - vec.push(elem.as_ref().to_owned()); - } - vec - }; - - let toplevel = ["config", "home", "activationPackage"] - .into_iter() - .map(String::from); - - match res { - Installable::Flake { - ref reference, - ref mut attribute, - } => { - // If user explicitly selects some other attribute in the installable itself - // then don't push homeConfigurations - if !attribute.is_empty() { - debug!( - "Using explicit attribute path from installable: {:?}", - attribute - ); - return Ok(res); - } - - attribute.push(String::from("homeConfigurations")); - - let flake_reference = reference.clone(); - let mut found_config = false; - - // Check if an explicit configuration name was provided via the flag - if let Some(config_name) = configuration_name { - // Verify the provided configuration exists - let func = format!(r#" x: x ? "{config_name}" "#); - let check_res = commands::Command::new("nix") - .arg("eval") - .args(&extra_args) - .arg("--apply") - .arg(func) - .args( - (Installable::Flake { - reference: flake_reference.clone(), - attribute: attribute.clone(), - }) - .to_args(), - ) - .run_capture() - .map_err(|e| { - color_eyre::eyre::eyre!( - "Failed running nix eval to check for explicit configuration '{}': {}", - config_name, - e - ) - })?; - - if check_res.map(|s| s.trim().to_owned()).as_deref() == Some("true") { - debug!("Using explicit configuration from flag: {}", config_name); - attribute.push(config_name); - if push_drv { - attribute.extend(toplevel.clone()); - } - found_config = true; - } else { - // Explicit config provided but not found - let tried_attr_path = { - let mut attr_path = attribute.clone(); - attr_path.push(config_name); - Installable::Flake { - reference: flake_reference, - attribute: attr_path, - } - .to_args() - .join(" ") - }; - bail!("Explicitly specified home-manager configuration not found: {tried_attr_path}"); - } - } - - // If no explicit config was found via flag, try automatic detection - if !found_config { - let username = std::env::var("USER").expect("Couldn't get username"); - let hostname = get_hostname()?; - let mut tried = vec![]; - - for attr_name in [format!("{username}@{hostname}"), username] { - let func = format!(r#" x: x ? "{attr_name}" "#); - let check_res = commands::Command::new("nix") - .arg("eval") - .args(&extra_args) - .arg("--apply") - .arg(func) - .args( - (Installable::Flake { - reference: flake_reference.clone(), - attribute: attribute.clone(), - }) - .to_args(), - ) - .run_capture() - .map_err(|e| { - color_eyre::eyre::eyre!( - "Failed running nix eval to check for automatic configuration '{}': {}", - attr_name, - e - ) - })?; - - let current_try_attr = { - let mut attr_path = attribute.clone(); - attr_path.push(attr_name.clone()); - attr_path - }; - tried.push(current_try_attr.clone()); - - match check_res.map(|s| s.trim().to_owned()).as_deref() { - Some("true") => { - debug!("Using automatically detected configuration: {}", attr_name); - attribute.push(attr_name); - if push_drv { - attribute.extend(toplevel.clone()); - } - found_config = true; - break; - } - _ => { - continue; - } - } - } - - // If still not found after automatic detection, error out - if !found_config { - let tried_str = tried - .into_iter() - .map(|a| { - Installable::Flake { - reference: flake_reference.clone(), - attribute: a, - } - .to_args() - .join(" ") - }) - .collect::>() - .join(", "); - bail!("Couldn't find home-manager configuration automatically, tried: {tried_str}"); - } - } - } - Installable::File { - ref mut attribute, .. - } => { - if push_drv { - attribute.extend(toplevel); - } - } - Installable::Expression { - ref mut attribute, .. - } => { - if push_drv { - attribute.extend(toplevel); - } - } - Installable::Store { .. } => {} - } - - Ok(res) -} - impl HomeReplArgs { + /// Opens a Nix REPL for exploring home-manager configurations + /// + /// Provides an interactive environment to explore and evaluate + /// components of a home-manager configuration. This is useful for + /// debugging or exploring available options. fn run(self) -> Result<()> { // Use NH_HOME_FLAKE if available, otherwise use the provided installable - let installable = if let Ok(home_flake) = env::var("NH_HOME_FLAKE") { - debug!("Using NH_HOME_FLAKE: {}", home_flake); - - let mut elems = home_flake.splitn(2, '#'); - let reference = elems.next().unwrap().to_owned(); - let attribute = elems - .next() - .map(crate::installable::parse_attribute) - .unwrap_or_default(); - - Installable::Flake { - reference, - attribute, - } - } else { - self.installable - }; + let installable = + platform::resolve_env_installable("NH_HOME_FLAKE").unwrap_or_else(|| self.installable); - let toplevel = toplevel_for( + // Launch an interactive REPL session for exploring the configuration + platform::run_repl( installable, - false, - &self.extra_args, - self.configuration.clone(), - )?; - - Command::new("nix") - .arg("repl") - .args(toplevel.to_args()) - .run()?; - - Ok(()) + "homeConfigurations", + &[], // No trailing path components + None, // No explicit hostname + &self + .extra_args + .iter() + .map(std::string::ToString::to_string) + .collect::>(), + ) } } diff --git a/src/installable.rs b/src/installable.rs index e8d59e2a..f35e923f 100644 --- a/src/installable.rs +++ b/src/installable.rs @@ -4,7 +4,6 @@ use std::{env, fs}; use clap::error::ErrorKind; use clap::{Arg, ArgAction, Args, FromArgMatches}; use color_eyre::owo_colors::OwoColorize; - // Reference: https://nix.dev/manual/nix/2.18/command-ref/new-cli/nix #[derive(Debug, Clone)] @@ -26,6 +25,15 @@ pub enum Installable { }, } +impl Default for Installable { + fn default() -> Self { + Self::Flake { + reference: ".".to_string(), + attribute: Vec::new(), + } + } +} + impl FromArgMatches for Installable { fn from_arg_matches(matches: &clap::ArgMatches) -> Result { let mut matches = matches.clone(); diff --git a/src/json.rs b/src/json.rs index 60b109a3..a45ef1e8 100644 --- a/src/json.rs +++ b/src/json.rs @@ -28,6 +28,7 @@ impl Display for Error { impl std::error::Error for Error {} +#[allow(dead_code)] impl<'v> Value<'v> { pub const fn new(value: &'v serde_json::Value) -> Self { Self { diff --git a/src/nixos.rs b/src/nixos.rs index 775828d7..58d7c7c9 100644 --- a/src/nixos.rs +++ b/src/nixos.rs @@ -1,4 +1,3 @@ -use std::env; use std::fs; use std::path::{Path, PathBuf}; @@ -6,44 +5,90 @@ use color_eyre::eyre::{bail, Context}; use color_eyre::eyre::{eyre, Result}; use tracing::{debug, info, warn}; -use crate::commands; use crate::commands::Command; use crate::generations; use crate::installable::Installable; use crate::interface::OsSubcommand::{self}; -use crate::interface::{ - self, OsBuildVmArgs, OsGenerationsArgs, OsRebuildArgs, OsReplArgs, OsRollbackArgs, -}; +use crate::interface::{self, OsGenerationsArgs, OsRebuildArgs, OsReplArgs, OsRollbackArgs}; use crate::update::update; use crate::util::ensure_ssh_key_login; use crate::util::get_hostname; +use crate::util::platform; +/// Path to the system profile on `NixOS` const SYSTEM_PROFILE: &str = "/nix/var/nix/profiles/system"; +/// Path to the current system profile on `NixOS` const CURRENT_PROFILE: &str = "/run/current-system"; - +/// Path where `NixOS` stores specialisation information const SPEC_LOCATION: &str = "/etc/specialisation"; impl interface::OsArgs { + /// Entry point for processing `NixOS` commands + /// + /// Handles the various subcommands for `NixOS` configurations: + /// - Switch: Builds, activates, and makes the configuration the boot default + /// - Boot: Builds and makes the configuration the boot default + /// - Test: Builds and activates the configuration + /// - Build: Only builds the configuration + /// - Repl: Opens a REPL for exploring the configuration + /// - Info: Lists available generations + /// - Rollback: Reverts to a previous generation + /// - `BuildVm`: Builds a `NixOS` VM image pub fn run(self) -> Result<()> { use OsRebuildVariant::{Boot, Build, Switch, Test}; + // Always resolve installable from env var at the top, or use the provided one + let fallback_installable = match &self.subcommand { + OsSubcommand::Boot(args) => args.common.installable.clone(), + OsSubcommand::Test(args) => args.common.installable.clone(), + OsSubcommand::Switch(args) => args.common.installable.clone(), + OsSubcommand::Build(args) => args.common.installable.clone(), + OsSubcommand::BuildVm(args) => args.common.common.installable.clone(), + OsSubcommand::Repl(args) => args.installable.clone(), + _ => Installable::default(), // fallback for Info/Rollback, not used + }; + + let resolved_installable = + platform::resolve_env_installable("NH_OS_FLAKE").unwrap_or(fallback_installable); match self.subcommand { - OsSubcommand::Boot(args) => args.rebuild(Boot, None), - OsSubcommand::Test(args) => args.rebuild(Test, None), - OsSubcommand::Switch(args) => args.rebuild(Switch, None), + OsSubcommand::Boot(args) => { + args.rebuild_with_installable(Boot, None, resolved_installable) + } + OsSubcommand::Test(args) => { + args.rebuild_with_installable(Test, None, resolved_installable) + } + OsSubcommand::Switch(args) => { + args.rebuild_with_installable(Switch, None, resolved_installable) + } OsSubcommand::Build(args) => { if args.common.ask || args.common.dry { warn!("`--ask` and `--dry` have no effect for `nh os build`"); } - args.rebuild(Build, None) + args.rebuild_with_installable(Build, None, resolved_installable) + } + OsSubcommand::BuildVm(args) => { + let final_attr = get_final_attr(true, args.with_bootloader); + args.common.rebuild_with_installable( + OsRebuildVariant::BuildVm, + Some(final_attr), + resolved_installable, + ) } - OsSubcommand::BuildVm(args) => args.build_vm(), - OsSubcommand::Repl(args) => args.run(), + OsSubcommand::Repl(args) => args.run_with_installable(resolved_installable), OsSubcommand::Info(args) => args.info(), OsSubcommand::Rollback(args) => args.rollback(), } } } +/// Variants of the `NixOS` rebuild operation +/// +/// Each variant represents a different mode of operation with distinct +/// activation behaviors: +/// - Build: Only build the configuration +/// - Switch: Build, activate, and make it the boot default +/// - Boot: Build and make it the boot default +/// - Test: Build and activate +/// - `BuildVm`: Build a VM image for testing #[derive(Debug)] enum OsRebuildVariant { Build, @@ -53,139 +98,85 @@ enum OsRebuildVariant { BuildVm, } -impl OsBuildVmArgs { - fn build_vm(self) -> Result<()> { - let final_attr = get_final_attr(true, self.with_bootloader); - self.common - .rebuild(OsRebuildVariant::BuildVm, Some(final_attr)) - } -} - impl OsRebuildArgs { - // final_attr is the attribute of config.system.build.X to evaluate. - fn rebuild(self, variant: OsRebuildVariant, final_attr: Option) -> Result<()> { + /// Rebuilds a `NixOS` configuration with the given variant and installable + /// + /// This is the core function for building and deploying `NixOS` configurations. + /// It handles: + /// 1. SSH key login for remote operations + /// 2. Root privilege management + /// 3. Flake updates if requested + /// 4. Hostname resolution and validation + /// 5. Building the configuration + /// 6. Specialisation handling + /// 7. Remote deployment if `target_host` is specified + /// 8. Configuration activation based on variant + /// + /// The different variants determine which aspects of deployment are executed: + /// - Build: Only build the configuration + /// - Switch: Build, activate, and make boot default + /// - Boot: Build and make boot default + /// - Test: Build and activate + /// - `BuildVm`: Build a VM image + fn rebuild_with_installable( + self, + variant: OsRebuildVariant, + final_attr: Option, + installable: Installable, + ) -> Result<()> { use OsRebuildVariant::{Boot, Build, BuildVm, Switch, Test}; - if self.build_host.is_some() || self.target_host.is_some() { - // if it fails its okay let _ = ensure_ssh_key_login(); } - let elevate = if self.bypass_root_check { - warn!("Bypassing root check, now running nix as root"); - false - } else { - if nix::unistd::Uid::effective().is_root() { - bail!("Don't run nh os as root. I will call sudo internally as needed"); - } - true - }; + // Check for root privileges and elevate if needed + let elevate = platform::check_not_root(self.bypass_root_check)?; if self.update_args.update { update(&self.common.installable, self.update_args.update_input)?; } - let system_hostname = match get_hostname() { - Ok(hostname) => Some(hostname), - Err(err) => { - tracing::warn!("{}", err.to_string()); - None - } - }; - - let target_hostname = match &self.hostname { - Some(h) => h.to_owned(), - None => match &system_hostname { - Some(hostname) => { - tracing::warn!("Guessing system is {hostname} for a VM image. If this isn't intended, use --hostname to change."); - hostname.clone() - } - None => return Err(eyre!("Unable to fetch hostname, and no hostname supplied.")), - }, - }; - - let out_path: Box = match self.common.out_link { - Some(ref p) => Box::new(p.clone()), - None => Box::new({ - let dir = tempfile::Builder::new().prefix("nh-os").tempdir()?; - (dir.as_ref().join("result"), dir) - }), - }; + // Determine hostname and handle hostname mismatch + let (target_hostname, hostname_mismatch) = platform::get_target_hostname( + self.hostname.clone(), + true, // Skip comparison when system hostname != target hostname + )?; + // Create temporary output path for the build result + let out_path = platform::create_output_path(self.common.out_link, "nh-os")?; debug!(?out_path); - // Use NH_OS_FLAKE if available, otherwise use the provided installable - let installable = if let Ok(os_flake) = env::var("NH_OS_FLAKE") { - debug!("Using NH_OS_FLAKE: {}", os_flake); - - let mut elems = os_flake.splitn(2, '#'); - let reference = elems.next().unwrap().to_owned(); - let attribute = elems - .next() - .map(crate::installable::parse_attribute) - .unwrap_or_default(); - - Installable::Flake { - reference, - attribute, - } - } else { - self.common.installable.clone() + // Determine the final attribute path + let final_attribute_path = match final_attr { + Some(ref attr) => attr.as_str(), + None => match variant { + BuildVm => "vm", // We moved with_bootloader check to get_final_attr + _ => "toplevel", + }, }; - let toplevel = toplevel_for( - &target_hostname, + // Configure and build the NixOS configuration + let target_profile = platform::handle_rebuild_workflow( installable, - final_attr.unwrap_or(String::from("toplevel")).as_str(), - ); - - let message = match variant { - BuildVm => "Building NixOS VM image", - _ => "Building NixOS configuration", - }; - - commands::Build::new(toplevel) - .extra_arg("--out-link") - .extra_arg(out_path.get_path()) - .extra_args(&self.extra_args) - .builder(self.build_host.clone()) - .message(message) - .nom(!self.common.no_nom) - .run()?; - - let current_specialisation = std::fs::read_to_string(SPEC_LOCATION).ok(); - - let target_specialisation = if self.no_specialisation { - None - } else { - current_specialisation.or_else(|| self.specialisation.clone()) - }; - - debug!("target_specialisation: {target_specialisation:?}"); - - let target_profile = match &target_specialisation { - None => out_path.get_path().to_owned(), - Some(spec) => out_path.get_path().join("specialisation").join(spec), - }; - - debug!("exists: {}", target_profile.exists()); - - target_profile.try_exists().context("Doesn't exist")?; - - if self.build_host.is_none() - && self.target_host.is_none() - && system_hostname.map_or(true, |h| h == target_hostname) - { - Command::new("nvd") - .arg("diff") - .arg(CURRENT_PROFILE) - .arg(&target_profile) - .message("Comparing changes") - .run()?; - } else { - debug!("Not running nvd as the target hostname is different from the system hostname."); - } - + "nixosConfigurations", + &["config", "system", "build", final_attribute_path], + Some(target_hostname), + out_path.as_ref(), + &self.extra_args, + self.build_host.clone(), + match variant { + BuildVm => "Building NixOS VM image", + _ => "Building NixOS configuration", + }, + self.common.no_nom, + SPEC_LOCATION, + self.no_specialisation, + self.specialisation.clone(), + CURRENT_PROFILE, + hostname_mismatch, + )?; + + // Handle dry run mode or check if confirmation is needed if self.common.dry || matches!(variant, Build | BuildVm) { if self.common.ask { warn!("--ask has no effect as dry run was requested"); @@ -193,15 +184,11 @@ impl OsRebuildArgs { return Ok(()); } - if self.common.ask { - info!("Apply the config?"); - let confirmation = dialoguer::Confirm::new().default(false).interact()?; - - if !confirmation { - bail!("User rejected the new config"); - } + if !platform::confirm_action(self.common.ask, self.common.dry)? { + return Ok(()); } + // Copy to target host if needed if let Some(target_host) = &self.target_host { Command::new("nix") .args([ @@ -214,21 +201,18 @@ impl OsRebuildArgs { .run()?; }; + // Activate configuration for test and switch variants if let Test | Switch = variant { - // !! Use the target profile aka spec-namespaced - let switch_to_configuration = - target_profile.join("bin").join("switch-to-configuration"); - let switch_to_configuration = switch_to_configuration.canonicalize().unwrap(); - let switch_to_configuration = switch_to_configuration.to_str().unwrap(); - - Command::new(switch_to_configuration) - .arg("test") - .ssh(self.target_host.clone()) - .message("Activating configuration") - .elevate(elevate) - .run()?; + platform::activate_nixos_configuration( + &target_profile, + "test", + self.target_host.clone(), + elevate, + "Activating configuration", + )?; } + // Add configuration to bootloader for boot and switch variants if let Boot | Switch = variant { Command::new("nix") .elevate(elevate) @@ -237,41 +221,34 @@ impl OsRebuildArgs { .ssh(self.target_host.clone()) .run()?; - // !! Use the base profile aka no spec-namespace - let switch_to_configuration = out_path - .get_path() - .join("bin") - .join("switch-to-configuration"); - let switch_to_configuration = switch_to_configuration.canonicalize().unwrap(); - let switch_to_configuration = switch_to_configuration.to_str().unwrap(); - - Command::new(switch_to_configuration) - .arg("boot") - .ssh(self.target_host) - .elevate(elevate) - .message("Adding configuration to bootloader") - .run()?; + platform::activate_nixos_configuration( + out_path.get_path(), + "boot", + self.target_host, + elevate, + "Adding configuration to bootloader", + )?; } - // Make sure out_path is not accidentally dropped - // https://docs.rs/tempfile/3.12.0/tempfile/index.html#early-drop-pitfall drop(out_path); - Ok(()) } } impl OsRollbackArgs { + /// Rolls back the system to a previous generation + /// + /// This function: + /// 1. Finds the generation to roll back to (previous or specified) + /// 2. Shows a diff between current and target generations + /// 3. Sets the system profile to point to the target generation + /// 4. Activates the configuration + /// 5. Handles failures by rolling back the profile symlink if activation fails + /// + /// Generation specialisations are properly handled during rollback. fn rollback(&self) -> Result<()> { - let elevate = if self.bypass_root_check { - warn!("Bypassing root check, now running nix as root"); - false - } else { - if nix::unistd::Uid::effective().is_root() { - bail!("Don't run nh os as root. I will call sudo internally as needed"); - } - true - }; + // Check if we need root permissions + let elevate = platform::check_not_root(self.bypass_root_check)?; // Find previous generation or specific generation let target_generation = if let Some(gen_number) = self.to { @@ -288,24 +265,20 @@ impl OsRollbackArgs { .unwrap_or(Path::new("/nix/var/nix/profiles")); let generation_link = profile_dir.join(format!("system-{}-link", target_generation.number)); - // Handle specialisations - let current_specialisation = fs::read_to_string(SPEC_LOCATION).ok(); - - let target_specialisation = if self.no_specialisation { - None - } else { - self.specialisation.clone().or(current_specialisation) - }; - - debug!("target_specialisation: {target_specialisation:?}"); - - // Compare changes between current and target generation - Command::new("nvd") - .arg("diff") - .arg(CURRENT_PROFILE) - .arg(&generation_link) - .message("Comparing changes") - .run()?; + // Handle any system specialisations + let target_specialisation = platform::process_specialisation( + self.no_specialisation, + self.specialisation.clone(), + SPEC_LOCATION, + )?; + + // Show diff between current and target configuration + platform::compare_configurations( + CURRENT_PROFILE, + &generation_link, + false, + "Comparing changes", + )?; if self.dry { info!( @@ -315,13 +288,9 @@ impl OsRollbackArgs { return Ok(()); } - if self.ask { - info!("Roll back to generation {}?", target_generation.number); - let confirmation = dialoguer::Confirm::new().default(false).interact()?; - - if !confirmation { - bail!("User rejected the rollback"); - } + // Ask for confirmation if needed + if !platform::confirm_action(self.ask, self.dry)? { + return Ok(()); } // Get current generation number for potential rollback @@ -346,25 +315,12 @@ impl OsRollbackArgs { .run()?; // Set up rollback protection flag - let mut rollback_profile = false; - - // Determine the correct profile to use with specialisations - let final_profile = match &target_specialisation { - None => generation_link, - Some(spec) => { - let spec_path = generation_link.join("specialisation").join(spec); - if !spec_path.exists() { - warn!( - "Specialisation '{}' does not exist in generation {}", - spec, target_generation.number - ); - warn!("Using base configuration without specialisations"); - generation_link - } else { - spec_path - } - } - }; + let mut _rollback_profile = false; + + // Get the final profile path with specialisation if any + let target_specialisation_option = target_specialisation; + let final_profile = + platform::get_target_profile(&generation_link, &target_specialisation_option); // Activate the configuration info!("Activating..."); @@ -383,10 +339,10 @@ impl OsRollbackArgs { ); } Err(e) => { - rollback_profile = true; + _rollback_profile = true; // If activation fails, rollback the profile - if rollback_profile && current_gen_number > 0 { + if _rollback_profile && current_gen_number > 0 { let current_gen_link = profile_dir.join(format!("system-{current_gen_number}-link")); @@ -407,6 +363,15 @@ impl OsRollbackArgs { } } +/// Finds the previous generation in the system profile +/// +/// This function: +/// 1. Searches for available system generations +/// 2. Identifies which one is currently active +/// 3. Returns the generation immediately before the current one +/// +/// Returns an error if there are no generations or if the current +/// generation is already the oldest one. fn find_previous_generation() -> Result { let profile_path = PathBuf::from(SYSTEM_PROFILE); @@ -453,6 +418,10 @@ fn find_previous_generation() -> Result { Ok(generations[current_idx - 1].clone()) } +/// Finds a specific generation by its number +/// +/// Searches the system profiles directory for a generation with the +/// specified number and returns its information if found. fn find_generation_by_number(number: u64) -> Result { let profile_path = PathBuf::from(SYSTEM_PROFILE); @@ -484,6 +453,10 @@ fn find_generation_by_number(number: u64) -> Result Ok(generations[0].clone()) } +/// Gets the number of the currently active generation +/// +/// This is useful for rollback operations, especially when needing +/// to restore the system if activation of an older generation fails. fn get_current_generation_number() -> Result { let profile_path = PathBuf::from(SYSTEM_PROFILE); @@ -510,6 +483,13 @@ fn get_current_generation_number() -> Result { .map_err(|_| eyre!("Invalid generation number")) } +/// Determines the final attribute name for VM builds +/// +/// Returns the appropriate Nix attribute based on whether +/// the VM should include a bootloader: +/// - "vmWithBootLoader" for VM with bootloader +/// - "vm" for standard VM +/// - "toplevel" for regular builds pub fn get_final_attr(build_vm: bool, with_bootloader: bool) -> String { let attr = if build_vm && with_bootloader { "vmWithBootLoader" @@ -521,92 +501,47 @@ pub fn get_final_attr(build_vm: bool, with_bootloader: bool) -> String { String::from(attr) } -pub fn toplevel_for>( - hostname: S, - installable: Installable, - final_attr: &str, -) -> Installable { - let mut res = installable; - let hostname = hostname.as_ref().to_owned(); - - let toplevel = ["config", "system", "build", final_attr] - .into_iter() - .map(String::from); - - match res { - Installable::Flake { - ref mut attribute, .. - } => { - // If user explicitly selects some other attribute, don't push nixosConfigurations - if attribute.is_empty() { - attribute.push(String::from("nixosConfigurations")); - attribute.push(hostname); - } - attribute.extend(toplevel); - } - Installable::File { - ref mut attribute, .. - } => { - attribute.extend(toplevel); - } - Installable::Expression { - ref mut attribute, .. - } => { - attribute.extend(toplevel); - } - Installable::Store { .. } => {} - } - - res -} - impl OsReplArgs { - fn run(self) -> Result<()> { - // Use NH_OS_FLAKE if available, otherwise use the provided installable - let mut target_installable = if let Ok(os_flake) = env::var("NH_OS_FLAKE") { - debug!("Using NH_OS_FLAKE: {}", os_flake); - - let mut elems = os_flake.splitn(2, '#'); - let reference = elems.next().unwrap().to_owned(); - let attribute = elems - .next() - .map(crate::installable::parse_attribute) - .unwrap_or_default(); - - Installable::Flake { - reference, - attribute, - } - } else { - self.installable + /// Opens a Nix REPL for exploring `NixOS` configurations + /// + /// Provides an interactive environment to explore and evaluate + /// components of a `NixOS` configuration. This is useful for + /// debugging or exploring available options. + fn run_with_installable(self, installable: Installable) -> Result<()> { + // Get hostname, with fallback to system hostname + let hostname = match self.hostname { + Some(h) => h, + None => match get_hostname() { + Ok(h) => { + debug!("Auto-detected hostname: {}", h); + h + } + Err(e) => { + warn!("Failed to get hostname automatically: {}", e); + bail!("Unable to fetch hostname, and no hostname supplied. Please specify with --hostname"); + } + }, }; - if matches!(target_installable, Installable::Store { .. }) { - bail!("Nix doesn't support nix store installables."); - } - - let hostname = self.hostname.ok_or(()).or_else(|()| get_hostname())?; - - if let Installable::Flake { - ref mut attribute, .. - } = target_installable - { - if attribute.is_empty() { - attribute.push(String::from("nixosConfigurations")); - attribute.push(hostname); - } - } - - Command::new("nix") - .arg("repl") - .args(target_installable.to_args()) - .run()?; - - Ok(()) + // Open a Nix REPL for interactively exploring the NixOS configuration + platform::run_repl( + installable, + "nixosConfigurations", + &[], // No trailing path needed for REPL + Some(hostname), + &[], // No extra args + ) } } impl OsGenerationsArgs { + /// Lists information about available `NixOS` generations + /// + /// This function: + /// 1. Identifies the profile and confirms it exists + /// 2. Finds all generations associated with that profile + /// 3. Collects metadata about each generation (number, date, etc.) + /// 4. Displays the information in a formatted list fn info(&self) -> Result<()> { let profile = match self.profile { Some(ref p) => PathBuf::from(p), diff --git a/src/util.rs b/src/util.rs index 5a7aa4f5..0a6c1035 100644 --- a/src/util.rs +++ b/src/util.rs @@ -8,6 +8,9 @@ use tempfile::TempDir; use crate::commands::Command; +// Platform-specific functionality abstracted into shared components +pub mod platform; + /// Retrieves the installed Nix version as a string. /// /// This function executes the `nix --version` command, parses the output to diff --git a/src/util/platform.rs b/src/util/platform.rs new file mode 100644 index 00000000..14f2e039 --- /dev/null +++ b/src/util/platform.rs @@ -0,0 +1,649 @@ +use std::env; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +use color_eyre::eyre::bail; +use color_eyre::eyre::WrapErr; +use color_eyre::Result; +use tracing::{debug, info, warn}; + +use crate::commands; +use crate::installable::Installable; + +/// Resolves an Installable from an environment variable. +/// +/// Returns `Some(Installable)` if the environment variable is set and can be parsed, +/// or `None` if the environment variable is not set. +pub fn resolve_env_installable(var: &str) -> Option { + env::var(var).ok().map(|val| { + let mut elems = val.splitn(2, '#'); + let reference = elems.next().unwrap().to_owned(); + let attribute = elems + .next() + .map(crate::installable::parse_attribute) + .unwrap_or_default(); + Installable::Flake { + reference, + attribute, + } + }) +} + +/// Extends an Installable with the appropriate attribute path for a platform. +/// +/// - `config_type`: e.g. "homeConfigurations", "nixosConfigurations", "darwinConfigurations" +/// - `extra_path`: e.g. ["config", "home", "activationPackage"] +/// - `config_name`: Optional configuration name (e.g. username@hostname) +/// - `push_drv`: Whether to push the drv path (platform-specific) +/// - `extra_args`: Extra args for nix eval (for config detection) +pub fn extend_installable_for_platform( + mut installable: Installable, + config_type: &str, + extra_path: &[&str], + config_name: Option, + push_drv: bool, + extra_args: &[OsString], +) -> Result { + use tracing::debug; + + use crate::util::get_hostname; + + match &mut installable { + Installable::Flake { + reference, + attribute, + } => { + // If attribute path is already specified, use it as-is + if !attribute.is_empty() { + debug!( + "Using explicit attribute path from installable: {:?}", + attribute + ); + return Ok(installable); + } + + // Otherwise, build the attribute path + attribute.push(config_type.to_string()); + let flake_reference = reference.clone(); + + // Try to find the configuration by name if one was provided + if let Some(config_name) = config_name { + if find_config_in_flake( + &config_name, + attribute, + &flake_reference, + extra_args, + push_drv, + extra_path, + )? { + return Ok(installable); + } + + return Err(color_eyre::eyre::eyre!( + "Explicitly specified configuration not found in flake." + )); + } + + // Try to auto-detect the configuration + let username = std::env::var("USER").unwrap_or_else(|_| "user".to_string()); + let hostname = get_hostname().unwrap_or_else(|_| "host".to_string()); + + for attr_name in [format!("{username}@{hostname}"), username] { + if find_config_in_flake( + &attr_name, + attribute, + &flake_reference, + extra_args, + push_drv, + extra_path, + )? { + return Ok(installable); + } + } + + return Err(color_eyre::eyre::eyre!( + "Couldn't find configuration automatically in flake." + )); + } + Installable::File { attribute, .. } | Installable::Expression { attribute, .. } => { + if push_drv { + attribute.extend(extra_path.iter().map(|s| (*s).to_string())); + } + } + Installable::Store { .. } => { + // Nothing to do for store paths + } + } + Ok(installable) +} + +/// Find a configuration in a flake +/// +/// Returns true if the configuration was found, false otherwise +fn find_config_in_flake( + config_name: &str, + attribute: &mut Vec, + flake_reference: &str, + extra_args: &[OsString], + push_drv: bool, + extra_path: &[&str], +) -> Result { + let func = format!(r#"x: x ? "{config_name}""#); + let check_res = commands::Command::new("nix") + .arg("eval") + .args(extra_args) + .arg("--apply") + .arg(&func) + .args( + (Installable::Flake { + reference: flake_reference.to_string(), + attribute: attribute.clone(), + }) + .to_args(), + ) + .run_capture(); + + if let Ok(res) = check_res { + if res.map(|s| s.trim().to_owned()).as_deref() == Some("true") { + debug!("Found configuration: {}", config_name); + attribute.push(config_name.to_string()); + + if push_drv { + attribute.extend(extra_path.iter().map(|s| (*s).to_string())); + } + + return Ok(true); + } + } + + Ok(false) +} + +/// Handles common specialisation logic for all platforms +pub fn handle_specialisation( + specialisation_path: &str, + no_specialisation: bool, + explicit_specialisation: Option, +) -> Option { + if no_specialisation { + None + } else { + let current_specialisation = std::fs::read_to_string(specialisation_path).ok(); + explicit_specialisation.or(current_specialisation) + } +} + +/// Checks if the user wants to proceed with applying the configuration +pub fn confirm_action(ask: bool, dry: bool) -> Result { + use tracing::{info, warn}; + + if dry { + if ask { + warn!("--ask has no effect as dry run was requested"); + } + return Ok(false); + } + + if ask { + info!("Apply the config?"); + let confirmation = dialoguer::Confirm::new().default(false).interact()?; + + if !confirmation { + bail!("User rejected the new config"); + } + } + + Ok(true) +} + +/// Common function to ensure we're not running as root +pub fn check_not_root(bypass_root_check: bool) -> Result { + use tracing::warn; + + if bypass_root_check { + warn!("Bypassing root check, now running nix as root"); + return Ok(false); + } + + if nix::unistd::Uid::effective().is_root() { + // Protect users from themselves + bail!("Don't run nh os as root. I will call sudo internally as needed"); + } + + Ok(true) +} + +/// Creates a temporary output path for build results +pub fn create_output_path( + out_link: Option>, + prefix: &str, +) -> Result> { + let out_path: Box = match out_link { + Some(ref p) => Box::new(std::path::PathBuf::from(p.as_ref())), + None => Box::new({ + let dir = tempfile::Builder::new().prefix(prefix).tempdir()?; + (dir.as_ref().join("result"), dir) + }), + }; + + Ok(out_path) +} + +/// Compare configurations using nvd diff +pub fn compare_configurations( + current_profile: &str, + target_profile: &std::path::Path, + skip_compare: bool, + message: &str, +) -> Result<()> { + if skip_compare { + debug!("Skipping configuration comparison"); + return Ok(()); + } + + commands::Command::new("nvd") + .arg("diff") + .arg(current_profile) + .arg(target_profile) + .message(message) + .run() + .with_context(|| { + format!( + "Failed to compare configurations with nvd: {} vs {}", + current_profile, + target_profile.display() + ) + })?; + + Ok(()) +} + +/// Build a configuration using the nix build command +pub fn build_configuration( + installable: Installable, + out_path: &dyn crate::util::MaybeTempPath, + extra_args: &[impl AsRef], + builder: Option, + message: &str, + no_nom: bool, +) -> Result<()> { + commands::Build::new(installable) + .extra_arg("--out-link") + .extra_arg(out_path.get_path()) + .extra_args(extra_args) + .builder(builder) + .message(message) + .nom(!no_nom) + .run() + .with_context(|| format!("Failed to build configuration: {}", message))?; + + Ok(()) +} + +/// Determine the target profile path considering specialisation +pub fn get_target_profile( + out_path: &dyn crate::util::MaybeTempPath, + target_specialisation: &Option, +) -> PathBuf { + match target_specialisation { + None => out_path.get_path().to_owned(), + Some(spec) => out_path.get_path().join("specialisation").join(spec), + } +} + +/// Common logic for handling REPL for different platforms +pub fn run_repl( + installable: Installable, + config_type: &str, + extra_path: &[&str], + config_name: Option, + extra_args: &[String], +) -> Result<()> { + // Store paths don't work with REPL + if let Installable::Store { .. } = installable { + bail!("Nix doesn't support nix store installables with repl."); + } + + let installable = extend_installable_for_platform( + installable, + config_type, + extra_path, + config_name, + false, + &[], + )?; + + debug!("Running nix repl with installable: {:?}", installable); + + // NOTE: Using stdlib Command directly is necessary for interactive REPL + // Interactivity implodes otherwise. + use std::process::{Command as StdCommand, Stdio}; + + let mut command = StdCommand::new("nix"); + command.arg("repl"); + + // Add installable arguments + for arg in installable.to_args() { + command.arg(arg); + } + + // Add any extra arguments + for arg in extra_args { + command.arg(arg); + } + + // Configure for interactive use + command + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + + // Execute and wait for completion + let status = command.status()?; + + if !status.success() { + bail!("nix repl exited with non-zero status: {}", status); + } + + Ok(()) +} + +/// Process the target specialisation based on common patterns +pub fn process_specialisation( + no_specialisation: bool, + specialisation: Option, + specialisation_path: &str, +) -> Result> { + let target_specialisation = + handle_specialisation(specialisation_path, no_specialisation, specialisation); + + debug!("target_specialisation: {target_specialisation:?}"); + + Ok(target_specialisation) +} + +/// Execute common actions for a rebuild operation across platforms +/// +/// This function handles the core workflow for building and managing system +/// configurations across different platforms (`NixOS`, Darwin, Home Manager). +/// It unifies what would otherwise be duplicated across platform-specific modules. +/// +/// The function takes care of: +/// 1. Properly configuring the attribute path based on platform type +/// 2. Building the configuration +/// 3. Handling specialisations where applicable +/// 4. Comparing the new configuration with the current one +/// +/// # Arguments +/// +/// * `installable` - The Nix installable representing the configuration +/// * `config_type` - The configuration type (e.g., "nixosConfigurations", "darwinConfigurations") +/// * `extra_path` - Additional path elements for the attribute path +/// * `config_name` - Optional hostname or configuration name +/// * `out_path` - Output path for the build result +/// * `extra_args` - Additional arguments to pass to the build command +/// * `builder` - Optional remote builder to use +/// * `message` - Message to display during the build process +/// * `no_nom` - Whether to disable nix-output-monitor +/// * `specialisation_path` - Path to read specialisations from +/// * `no_specialisation` - Whether to ignore specialisations +/// * `specialisation` - Optional explicit specialisation to use +/// * `current_profile` - Path to the current system profile for comparison +/// * `skip_compare` - Whether to skip comparing the new and current configuration +/// +/// # Returns +/// +/// The path to the built configuration, which can be used for activation +#[allow(clippy::too_many_arguments)] +pub fn handle_rebuild_workflow( + installable: Installable, + config_type: &str, + extra_path: &[&str], + config_name: Option, + out_path: &dyn crate::util::MaybeTempPath, + extra_args: &[impl AsRef], + builder: Option, + message: &str, + no_nom: bool, + specialisation_path: &str, + no_specialisation: bool, + specialisation: Option, + current_profile: &str, + skip_compare: bool, +) -> Result { + // Convert the extra_args to OsString for the config struct + let extra_args_vec: Vec = extra_args + .iter() + .map(|arg| arg.as_ref().to_os_string()) + .collect(); + + // Create a config struct from the parameters + let config = RebuildWorkflowConfig { + installable, + config_type, + extra_path, + config_name, + out_path, + extra_args: extra_args_vec, + builder, + message, + no_nom, + specialisation_path, + no_specialisation, + specialisation, + current_profile, + skip_compare, + }; + + // Delegate to the new implementation + handle_rebuild_workflow_with_config(config) +} + +/// Determine proper hostname based on provided or automatically detected +pub fn get_target_hostname( + explicit_hostname: Option, + skip_if_mismatch: bool, +) -> Result<(String, bool)> { + let system_hostname = match crate::util::get_hostname() { + Ok(hostname) => { + debug!("Auto-detected hostname: {}", hostname); + Some(hostname) + } + Err(err) => { + warn!("Failed to detect hostname: {}", err); + None + } + }; + + let target_hostname = match explicit_hostname { + Some(hostname) => hostname, + None => match system_hostname.clone() { + Some(hostname) => hostname, + None => bail!("Unable to fetch hostname automatically. Please specify explicitly with --hostname.") + } + }; + + // Skip comparison when system hostname != target hostname if requested + let hostname_mismatch = skip_if_mismatch + && system_hostname.is_some() + && system_hostname.unwrap() != target_hostname; + + debug!( + ?target_hostname, + ?hostname_mismatch, + "Determined target hostname" + ); + Ok((target_hostname, hostname_mismatch)) +} + +/// Common function to activate configurations in `NixOS` +pub fn activate_nixos_configuration( + target_profile: &Path, + variant: &str, + target_host: Option, + elevate: bool, + message: &str, +) -> Result<()> { + let switch_to_configuration = target_profile.join("bin").join("switch-to-configuration"); + let switch_to_configuration = switch_to_configuration.canonicalize().map_err(|e| { + color_eyre::eyre::eyre!("Failed to canonicalize switch-to-configuration path: {}", e) + })?; + + commands::Command::new(switch_to_configuration) + .arg(variant) + .ssh(target_host) + .message(message) + .elevate(elevate) + .run() +} + +/// Configuration options for rebuilding workflows +pub struct RebuildWorkflowConfig<'a> { + /// The Nix installable representing the configuration + pub installable: Installable, + + /// The configuration type (e.g., "nixosConfigurations", "darwinConfigurations") + pub config_type: &'a str, + + /// Additional path elements for the attribute path + pub extra_path: &'a [&'a str], + + /// Optional hostname or configuration name + pub config_name: Option, + + /// Output path for the build result + pub out_path: &'a dyn crate::util::MaybeTempPath, + + /// Additional arguments to pass to the build command as OsStrings + pub extra_args: Vec, + + /// Optional remote builder to use + pub builder: Option, + + /// Message to display during the build process + pub message: &'a str, + + /// Whether to disable nix-output-monitor + pub no_nom: bool, + + /// Path to read specialisations from + pub specialisation_path: &'a str, + + /// Whether to ignore specialisations + pub no_specialisation: bool, + + /// Optional explicit specialisation to use + pub specialisation: Option, + + /// Path to the current system profile for comparison + pub current_profile: &'a str, + + /// Whether to skip comparing the new and current configuration + pub skip_compare: bool, +} + +/// Execute common actions for a rebuild operation across platforms using configuration struct +/// +/// This function takes a configuration struct instead of many individual parameters +fn handle_rebuild_workflow_with_config(config: RebuildWorkflowConfig) -> Result { + // Special handling for darwin configurations + if config.config_type == "darwinConfigurations" { + // First construct the proper attribute path for darwin configs + let mut processed_installable = config.installable; + if let Installable::Flake { + ref mut attribute, .. + } = processed_installable + { + // Only set the attribute path if user hasn't already specified one + if attribute.is_empty() { + attribute.push(String::from(config.config_type)); + if let Some(name) = &config.config_name { + attribute.push(name.clone()); + } + } + } + + // Next, add config.system.build. to the path to access the derivation + let mut toplevel_attr = processed_installable; + if let Installable::Flake { + ref mut attribute, .. + } = toplevel_attr + { + // All darwin configurations expose their outputs under system.build + let toplevel_path = ["config", "system", "build"]; + attribute.extend(toplevel_path.iter().map(|s| (*s).to_string())); + + // Add the final component (usually "toplevel") + if !config.extra_path.is_empty() { + attribute.push(config.extra_path[0].to_string()); + } + } + + // Build the configuration + build_configuration( + toplevel_attr, + config.out_path, + &config.extra_args, + config.builder.clone(), + config.message, + config.no_nom, + )?; + + // Darwin doesn't use the specialisation mechanism like NixOS + let target_profile = config.out_path.get_path().to_owned(); + + // Run the diff to show changes + if !config.skip_compare { + compare_configurations( + config.current_profile, + &target_profile, + false, + "Comparing changes", + )?; + } + + return Ok(target_profile); + } + + // Configure the installable with platform-specific attributes + let configured_installable = extend_installable_for_platform( + config.installable, + config.config_type, + config.extra_path, + config.config_name.clone(), + true, + &config.extra_args, + )?; + + // Build the configuration + build_configuration( + configured_installable, + config.out_path, + &config.extra_args, + config.builder.clone(), + config.message, + config.no_nom, + )?; + + // Process any specialisations (NixOS/Home-Manager specific feature) + let target_specialisation = process_specialisation( + config.no_specialisation, + config.specialisation.clone(), + config.specialisation_path, + )?; + + // Get target profile path + let target_profile = get_target_profile(config.out_path, &target_specialisation); + + // Compare configurations if applicable + if !config.skip_compare { + compare_configurations( + config.current_profile, + &target_profile, + false, + "Comparing changes", + )?; + } + + Ok(target_profile) +}