Files
coyote/src/config/conflict.rs
T

365 lines
11 KiB
Rust

//! 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<bool> {
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<ConflictAction> {
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<String> {
#[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<Vec<String>> = 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<bool> {
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);
}
}