//! Shared keep/replace conflict resolution for installers that must not //! silently clobber locally modified files. The bundle installer and the //! builtin hook installers all funnel per-file conflicts through //! [`resolve_conflict`], so the prompt wording and the sticky //! keep-all/replace-all semantics are identical everywhere, including when //! one sticky scope spans several install locations in a single run. use crate::utils::IS_STDOUT_TERMINAL; use anyhow::{Context, Result, bail}; use inquire::Select; use std::fs; use std::path::Path; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum InstallMode { Skip, Force, Prompt, } /// A `keep-all`/`replace-all` answer outlives the file it was given for: /// the caller threads one value through every subsequent conflict in the /// same run. #[derive(Debug, Clone, Copy, PartialEq)] pub(crate) enum StickyMode { None, KeepAll, ReplaceAll, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ConflictAction { Keep, Replace, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum NonInteractive { Bail, Keep, } pub(crate) fn should_replace_existing( dst: &Path, content: &str, category: &str, mode: InstallMode, sticky: &mut StickyMode, ) -> Result { match mode { InstallMode::Skip => Ok(false), InstallMode::Force => Ok(true), InstallMode::Prompt => { if fs::read(dst).is_ok_and(|local| local == content.as_bytes()) { return Ok(false); } match resolve_conflict(dst, category, sticky, NonInteractive::Keep)? { ConflictAction::Keep => Ok(false), ConflictAction::Replace => Ok(true), } } } } pub(crate) fn resolve_conflict( dst: &Path, category: &str, sticky: &mut StickyMode, non_interactive: NonInteractive, ) -> Result { match *sticky { StickyMode::KeepAll => return Ok(ConflictAction::Keep), StickyMode::ReplaceAll => return Ok(ConflictAction::Replace), StickyMode::None => {} } if !is_interactive() { match non_interactive { NonInteractive::Keep => return Ok(ConflictAction::Keep), NonInteractive::Bail => bail!( "Refusing to overwrite local file {} non-interactively. \ Re-run in a terminal, with --install-force (installs), \ or with --yes (updates).", dst.display() ), } } let prompt = format!("Conflict at {} (category: {category})", dst.display()); let choice = select_choice(&prompt)?; match choice.as_str() { "keep" => Ok(ConflictAction::Keep), "replace" => Ok(ConflictAction::Replace), "keep-all" => { *sticky = StickyMode::KeepAll; Ok(ConflictAction::Keep) } "replace-all" => { *sticky = StickyMode::ReplaceAll; Ok(ConflictAction::Replace) } "abort" => bail!("Install aborted by user at conflict resolution."), _ => unreachable!("inquire::Select returned an unexpected option"), } } fn is_interactive() -> bool { #[cfg(test)] if let Some(forced) = prompt_script::forced_tty() { return forced; } *IS_STDOUT_TERMINAL } fn select_choice(prompt: &str) -> Result { #[cfg(test)] if prompt_script::active() { return Ok(prompt_script::next_answer(prompt)); } Select::new( prompt, vec!["keep", "replace", "keep-all", "replace-all", "abort"], ) .prompt() .map(str::to_string) .with_context(|| "failed to read conflict choice") } #[cfg(test)] pub(crate) mod prompt_script { //! Scripted stand-in for the interactive conflict prompt. //! `inquire::Select` cannot run under the test harness, so while a guard //! is installed the resolver takes its terminal answer from the guard and //! serves each prompt from a queue of scripted answers, counting every //! prompt asked. A forced-terminal guard with an empty queue pins the //! "never prompts" invariant: any prompt panics and the counter stays 0. use std::sync::Mutex; use std::sync::atomic::{AtomicUsize, Ordering}; const INACTIVE: usize = 0; const TTY: usize = 1; const NON_TTY: usize = 2; static STATE: AtomicUsize = AtomicUsize::new(INACTIVE); static ASKED: AtomicUsize = AtomicUsize::new(0); // A test that panics while holding the queue poisons its mutex; every // lock site recovers via `into_inner` so later tests keep working. static ANSWERS: Mutex> = Mutex::new(Vec::new()); /// Installs the script with a forced terminal until the guard drops. /// The script is process-global, so tests using it must serialize /// (`#[serial]`). Answers are consumed front to back; a prompt beyond /// the scripted answers panics. #[must_use] pub fn install(answers: &[&str]) -> ScriptGuard { install_with_state(TTY, answers) } /// Installs the script with the terminal forced absent, for pinning the /// non-interactive behavior regardless of where the tests actually run. #[must_use] pub fn install_non_interactive() -> ScriptGuard { install_with_state(NON_TTY, &[]) } fn install_with_state(state: usize, answers: &[&str]) -> ScriptGuard { *ANSWERS .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) = answers.iter().map(|answer| answer.to_string()).collect(); ASKED.store(0, Ordering::SeqCst); STATE.store(state, Ordering::SeqCst); ScriptGuard } pub struct ScriptGuard; impl Drop for ScriptGuard { fn drop(&mut self) { STATE.store(INACTIVE, Ordering::SeqCst); ANSWERS .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .clear(); } } /// Number of prompts asked since the script was installed. pub fn prompts_asked() -> usize { ASKED.load(Ordering::SeqCst) } pub(super) fn active() -> bool { STATE.load(Ordering::SeqCst) != INACTIVE } pub(super) fn forced_tty() -> Option { match STATE.load(Ordering::SeqCst) { TTY => Some(true), NON_TTY => Some(false), _ => None, } } pub(super) fn next_answer(prompt: &str) -> String { ASKED.fetch_add(1, Ordering::SeqCst); let mut answers = ANSWERS .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); assert!( !answers.is_empty(), "conflict prompt asked with no scripted answer left: {prompt}" ); answers.remove(0) } } #[cfg(test)] mod tests { use super::*; use serial_test::serial; use std::path::PathBuf; fn dst() -> PathBuf { PathBuf::from("/tmp/coyote-conflict-tests/example.yaml") } #[test] fn sticky_modes_short_circuit_without_prompting() { let mut sticky = StickyMode::KeepAll; assert!(matches!( resolve_conflict(&dst(), "macros", &mut sticky, NonInteractive::Bail).unwrap(), ConflictAction::Keep )); let mut sticky = StickyMode::ReplaceAll; assert!(matches!( resolve_conflict(&dst(), "macros", &mut sticky, NonInteractive::Bail).unwrap(), ConflictAction::Replace )); } #[test] #[serial] fn non_interactive_bail_keeps_the_bundle_error_shape() { let _script = prompt_script::install_non_interactive(); let mut sticky = StickyMode::None; let err = resolve_conflict(&dst(), "macros", &mut sticky, NonInteractive::Bail).unwrap_err(); assert!( err.to_string().contains("Refusing to overwrite local file"), "got: {err}" ); assert!(err.to_string().contains("example.yaml"), "got: {err}"); assert_eq!(prompt_script::prompts_asked(), 0); } #[test] #[serial] fn non_interactive_keep_resolves_without_error_or_prompt() { let _script = prompt_script::install_non_interactive(); let mut sticky = StickyMode::None; let action = resolve_conflict(&dst(), "hooks", &mut sticky, NonInteractive::Keep).unwrap(); assert!(matches!(action, ConflictAction::Keep)); assert_eq!(sticky, StickyMode::None); assert_eq!(prompt_script::prompts_asked(), 0); } #[test] #[serial] fn scripted_answers_drive_actions_and_sticky_transitions() { let _script = prompt_script::install(&["keep", "replace", "replace-all"]); let mut sticky = StickyMode::None; assert!(matches!( resolve_conflict(&dst(), "hooks", &mut sticky, NonInteractive::Keep).unwrap(), ConflictAction::Keep )); assert_eq!(sticky, StickyMode::None); assert!(matches!( resolve_conflict(&dst(), "hooks", &mut sticky, NonInteractive::Keep).unwrap(), ConflictAction::Replace )); assert_eq!(sticky, StickyMode::None); assert!(matches!( resolve_conflict(&dst(), "hooks", &mut sticky, NonInteractive::Keep).unwrap(), ConflictAction::Replace )); assert_eq!(sticky, StickyMode::ReplaceAll); // The sticky answer now short-circuits: no further prompt is asked. assert!(matches!( resolve_conflict(&dst(), "hooks", &mut sticky, NonInteractive::Keep).unwrap(), ConflictAction::Replace )); assert_eq!(prompt_script::prompts_asked(), 3); } #[test] #[serial] fn abort_answer_bails_with_the_bundle_abort_message() { let _script = prompt_script::install(&["abort"]); let mut sticky = StickyMode::None; let err = resolve_conflict(&dst(), "hooks", &mut sticky, NonInteractive::Keep).unwrap_err(); assert!( err.to_string() .contains("Install aborted by user at conflict resolution."), "got: {err}" ); } #[test] #[serial] fn should_replace_existing_skips_identical_content_without_prompting() { let _script = prompt_script::install(&[]); let dir = crate::utils::temp_file("conflict-identical-", ""); fs::create_dir_all(&dir).unwrap(); let path = dir.join("hook.sh"); fs::write(&path, "same content").unwrap(); let mut sticky = StickyMode::None; let replace = should_replace_existing( &path, "same content", "hooks", InstallMode::Prompt, &mut sticky, ) .unwrap(); let _ = fs::remove_dir_all(&dir); assert!(!replace); assert_eq!(prompt_script::prompts_asked(), 0); } #[test] #[serial] fn should_replace_existing_never_prompts_in_skip_or_force_mode() { let _script = prompt_script::install(&[]); let dir = crate::utils::temp_file("conflict-modes-", ""); fs::create_dir_all(&dir).unwrap(); let path = dir.join("hook.sh"); fs::write(&path, "local edit").unwrap(); let mut sticky = StickyMode::None; let skip = should_replace_existing(&path, "shipped", "hooks", InstallMode::Skip, &mut sticky); let force = should_replace_existing(&path, "shipped", "hooks", InstallMode::Force, &mut sticky); let _ = fs::remove_dir_all(&dir); assert!(!skip.unwrap()); assert!(force.unwrap()); assert_eq!(prompt_script::prompts_asked(), 0); } }