use std::io::BufRead;
use std::io::IsTerminal;
use std::io::Write as _;
use std::path::Path;
use std::process::{Command, Stdio};
use crate::error::{MindError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookChoice {
RunAndContinue,
SkipAndContinue,
Abort,
}
pub fn parse_hook_choice(input: &str) -> HookChoice {
match input.trim() {
"y" | "Y" | "" => HookChoice::RunAndContinue,
"n" | "N" => HookChoice::SkipAndContinue,
"a" | "A" => HookChoice::Abort,
_ => HookChoice::SkipAndContinue,
}
}
pub fn is_tty() -> bool {
match std::env::var_os("MIND_TTY") {
Some(v) => tty_override(&v),
None => std::io::stdin().is_terminal(),
}
}
fn tty_override(value: &std::ffi::OsStr) -> bool {
!matches!(
value.to_string_lossy().trim().to_ascii_lowercase().as_str(),
"" | "0" | "false" | "no" | "off"
)
}
pub fn disclosure_text(
identity: &str,
pin_desc: &str,
commit: &str,
clone_path: &str,
command: &str,
declared_override: Option<&str>,
browse_url: Option<&str>,
) -> String {
let identity_clean = crate::sanitize::strip_ansi(identity);
let mut out = String::new();
out.push_str("====== hook: ");
out.push_str(&identity_clean);
out.push_str(" ======\n");
out.push_str(&disclosure_body(
identity,
pin_desc,
commit,
clone_path,
command,
declared_override,
browse_url,
));
out
}
fn read_choice<R: BufRead>(mut reader: R) -> Result<HookChoice> {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => Ok(HookChoice::SkipAndContinue), Ok(_) => Ok(parse_hook_choice(&line)),
Err(e) => Err(MindError::io("<stdin>", e)),
}
}
pub fn prompt_choice(disclosure: &str) -> Result<HookChoice> {
print!("{disclosure}");
println!(" [Y] run the hook [n] skip it [a] abort - install nothing");
print!("Run this hook? [Y/n/a] (default Y): ");
std::io::stdout()
.flush()
.map_err(|e| MindError::io("<stdout>", e))?;
read_choice(std::io::stdin().lock())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OptionalChoice {
Run,
Skip,
}
pub fn parse_optional_choice(input: &str) -> OptionalChoice {
match input.trim() {
"y" | "Y" | "" => OptionalChoice::Run,
_ => OptionalChoice::Skip,
}
}
fn read_optional_choice<R: BufRead>(mut reader: R) -> Result<OptionalChoice> {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => Ok(OptionalChoice::Skip), Ok(_) => Ok(parse_optional_choice(&line)),
Err(e) => Err(MindError::io("<stdin>", e)),
}
}
pub fn prompt_choice_optional(disclosure: &str) -> Result<OptionalChoice> {
print!("{disclosure}");
println!(" [Y] run [n] skip");
print!("Run this optional hook? [Y/n] (default Y): ");
std::io::stdout()
.flush()
.map_err(|e| MindError::io("<stdout>", e))?;
read_optional_choice(std::io::stdin().lock())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookAct {
Run,
Skip,
Abort,
}
pub fn decide(disclosure: &str, optional: bool, dangerously_skip: bool) -> Result<HookAct> {
if dangerously_skip {
return Ok(HookAct::Run);
}
if !is_tty() {
return Ok(HookAct::Skip);
}
if optional {
return Ok(match prompt_choice_optional(disclosure)? {
OptionalChoice::Run => HookAct::Run,
OptionalChoice::Skip => HookAct::Skip,
});
}
Ok(match prompt_choice(disclosure)? {
HookChoice::RunAndContinue => HookAct::Run,
HookChoice::SkipAndContinue => HookAct::Skip,
HookChoice::Abort => HookAct::Abort,
})
}
#[allow(clippy::too_many_arguments)]
pub fn hook_disclosure_text(
label: &str,
optional: bool,
identity: &str,
pin_desc: &str,
commit: &str,
clone_path: &str,
command: &str,
declared_override: Option<&str>,
browse_url: Option<&str>,
) -> String {
let label_clean = crate::sanitize::strip_ansi(label);
let kind = if optional { "optional" } else { "required" };
let mut out = String::new();
out.push_str("====== hook: ");
out.push_str(&label_clean);
out.push_str(" ======\n");
out.push_str(" Hook: ");
out.push_str(&label_clean);
out.push_str(" (");
out.push_str(kind);
out.push_str(")\n");
out.push_str(&disclosure_body(
identity,
pin_desc,
commit,
clone_path,
command,
declared_override,
browse_url,
));
out
}
fn disclosure_body(
identity: &str,
pin_desc: &str,
commit: &str,
clone_path: &str,
command: &str,
declared_override: Option<&str>,
browse_url: Option<&str>,
) -> String {
let identity = crate::sanitize::strip_ansi(identity);
let pin_desc = crate::sanitize::strip_ansi(pin_desc);
let commit = crate::sanitize::strip_ansi(commit);
let clone_path = crate::sanitize::strip_ansi(clone_path);
let command = crate::sanitize::strip_ansi(command);
let declared_override_owned: Option<String> =
declared_override.map(crate::sanitize::strip_ansi);
let declared_override = declared_override_owned.as_deref();
let browse_url_owned: Option<String> = browse_url.map(crate::sanitize::strip_ansi);
let browse_url = browse_url_owned.as_deref();
let mut out = String::new();
out.push_str(" Source: ");
out.push_str(&identity);
out.push('\n');
out.push_str(" Pin: ");
out.push_str(&pin_desc);
out.push('\n');
out.push_str(" Commit: ");
out.push_str(&commit);
out.push('\n');
out.push_str(" Clone: ");
out.push_str(&clone_path);
out.push('\n');
if let Some(url) = browse_url {
out.push_str(" Browse: ");
out.push_str(url);
out.push('\n');
}
if let Some(declared) = declared_override {
out.push_str(" Declared: ");
out.push_str(declared);
out.push('\n');
out.push_str(" Override: ");
out.push_str(&command);
out.push('\n');
out.push_str(" NOTE: the user-supplied command replaces the source's declared command.\n");
} else {
out.push_str(" Command: ");
out.push_str(&command);
out.push('\n');
}
out.push('\n');
out.push_str(" WARNING: this executes arbitrary code from the source with your privileges.\n");
out
}
pub fn apply_hook_override(
resolved: Vec<crate::mindfile::ResolvedHook>,
supplied: Option<&str>,
event: crate::mindfile::HookEvent,
) -> (Vec<crate::mindfile::ResolvedHook>, Option<Vec<String>>) {
use crate::mindfile::ResolvedHook;
let effective = match supplied.map(str::trim).filter(|s| !s.is_empty()) {
Some(s) => s,
None => return (resolved, None),
};
let mut replaced: Vec<String> = Vec::new();
let mut others: Vec<ResolvedHook> = Vec::new();
for hook in resolved {
if hook.event == event {
replaced.push(hook.run);
} else {
others.push(hook);
}
}
let override_hook = ResolvedHook {
run: effective.to_owned(),
name: None,
optional: false,
event,
};
let mut result = Vec::with_capacity(1 + others.len());
result.push(override_hook);
result.extend(others);
let replaced = if replaced.is_empty() {
None
} else {
Some(replaced)
};
(result, replaced)
}
pub fn apply_install_override(
resolved: Vec<crate::mindfile::ResolvedHook>,
supplied: Option<&str>,
) -> (Vec<crate::mindfile::ResolvedHook>, Option<Vec<String>>) {
apply_hook_override(resolved, supplied, crate::mindfile::HookEvent::Install)
}
pub fn run_hook(
command: &str,
clone_dir: &Path,
identity: &str,
event: &'static str,
label: &str,
) -> Result<()> {
let label = crate::sanitize::strip_ansi(label);
let command_clean = crate::sanitize::strip_ansi(command);
let _ = std::io::stdout().flush();
println!("====== (hook: {label}) ======");
let _ = std::io::stdout().flush();
let status = Command::new("sh")
.arg("-c")
.arg(command)
.current_dir(clone_dir)
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.map_err(|e| {
println!("====== (end hook: {label}) ======");
MindError::HookFailed {
event,
label: label.clone(),
identity: identity.to_string(),
command: command_clean.clone(),
status: None,
reason: e.to_string(),
streamed: false,
}
})?;
println!("====== (end hook: {label}) ======");
if status.success() {
Ok(())
} else {
Err(MindError::HookFailed {
event,
label,
identity: identity.to_string(),
command: command_clean,
status: Some(status),
reason: String::new(),
streamed: true,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
#[test]
fn decide_dangerously_skip_runs_and_non_tty_skips() {
assert_eq!(decide("d", false, true).unwrap(), HookAct::Run);
assert_eq!(decide("d", true, true).unwrap(), HookAct::Run);
assert_eq!(decide("d", false, false).unwrap(), HookAct::Skip);
assert_eq!(decide("d", true, false).unwrap(), HookAct::Skip);
}
#[test]
fn tty_override_reads_a_boolean() {
use std::ffi::OsStr;
for falsey in ["", "0", "false", "FALSE", "no", "off", " 0 "] {
assert!(
!tty_override(OsStr::new(falsey)),
"{falsey:?} must read as not-a-terminal"
);
}
for truthy in ["1", "true", "TRUE", "yes", "on", "anything"] {
assert!(
tty_override(OsStr::new(truthy)),
"{truthy:?} must read as a terminal"
);
}
}
struct TempDir(std::path::PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let path = std::env::temp_dir().join(format!(
"mind-hook-test-{}-{}-{n}",
std::process::id(),
tag
));
fs::create_dir_all(&path).expect("create temp dir");
TempDir(path)
}
fn path(&self) -> &std::path::Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn parse_hook_choice_run_on_y() {
assert_eq!(parse_hook_choice("y"), HookChoice::RunAndContinue);
assert_eq!(parse_hook_choice("Y"), HookChoice::RunAndContinue);
}
#[test]
fn parse_hook_choice_run_on_empty_default() {
assert_eq!(parse_hook_choice(""), HookChoice::RunAndContinue);
}
#[test]
fn parse_hook_choice_skip_on_n() {
assert_eq!(parse_hook_choice("n"), HookChoice::SkipAndContinue);
assert_eq!(parse_hook_choice("N"), HookChoice::SkipAndContinue);
}
#[test]
fn parse_hook_choice_abort_on_a() {
assert_eq!(parse_hook_choice("a"), HookChoice::Abort);
assert_eq!(parse_hook_choice("A"), HookChoice::Abort);
}
#[test]
fn parse_hook_choice_garbage_defaults_to_skip() {
assert_eq!(parse_hook_choice("garbage"), HookChoice::SkipAndContinue);
assert_eq!(parse_hook_choice("1"), HookChoice::SkipAndContinue);
assert_eq!(parse_hook_choice("yes"), HookChoice::SkipAndContinue);
}
#[test]
fn parse_hook_choice_trims_whitespace() {
assert_eq!(parse_hook_choice(" y "), HookChoice::RunAndContinue);
assert_eq!(parse_hook_choice("\ta\n"), HookChoice::Abort);
assert_eq!(parse_hook_choice(" n "), HookChoice::SkipAndContinue);
}
#[test]
fn read_choice_eof_returns_skip_and_continue() {
let reader = std::io::Cursor::new("");
let result = read_choice(reader).expect("read_choice should not error on EOF");
assert_eq!(
result,
HookChoice::SkipAndContinue,
"EOF must yield SkipAndContinue, not run or abort"
);
}
#[test]
fn read_choice_run_and_continue_on_y() {
let reader = std::io::Cursor::new("y\n");
let result = read_choice(reader).expect("read_choice should not error");
assert_eq!(result, HookChoice::RunAndContinue);
}
#[test]
fn read_choice_skip_and_continue_on_n() {
let reader = std::io::Cursor::new("n\n");
let result = read_choice(reader).expect("read_choice should not error");
assert_eq!(result, HookChoice::SkipAndContinue);
}
#[test]
fn read_choice_run_and_continue_on_bare_newline() {
let reader = std::io::Cursor::new("\n");
let result = read_choice(reader).expect("read_choice should not error");
assert_eq!(
result,
HookChoice::RunAndContinue,
"bare Enter must yield RunAndContinue (default Y)"
);
}
#[test]
fn read_choice_abort_on_a() {
let reader = std::io::Cursor::new("a\n");
let result = read_choice(reader).expect("read_choice should not error");
assert_eq!(result, HookChoice::Abort);
}
#[test]
fn parse_optional_choice_run_on_y() {
assert_eq!(parse_optional_choice("y"), OptionalChoice::Run);
assert_eq!(parse_optional_choice("Y"), OptionalChoice::Run);
}
#[test]
fn parse_optional_choice_run_on_empty_default() {
assert_eq!(parse_optional_choice(""), OptionalChoice::Run);
}
#[test]
fn parse_optional_choice_skip_on_n() {
assert_eq!(parse_optional_choice("n"), OptionalChoice::Skip);
}
#[test]
fn parse_optional_choice_garbage_defaults_to_skip() {
assert_eq!(parse_optional_choice("garbage"), OptionalChoice::Skip);
assert_eq!(parse_optional_choice("yes"), OptionalChoice::Skip);
assert_eq!(parse_optional_choice("1"), OptionalChoice::Skip);
}
#[test]
fn parse_optional_choice_trims_whitespace() {
assert_eq!(parse_optional_choice(" y "), OptionalChoice::Run);
assert_eq!(parse_optional_choice("\ty\n"), OptionalChoice::Run);
assert_eq!(parse_optional_choice(" n "), OptionalChoice::Skip);
}
#[test]
fn read_optional_choice_run_on_y() {
let reader = std::io::Cursor::new("y\n");
let result = read_optional_choice(reader).expect("no error");
assert_eq!(result, OptionalChoice::Run);
}
#[test]
fn read_optional_choice_skip_on_n() {
let reader = std::io::Cursor::new("n\n");
let result = read_optional_choice(reader).expect("no error");
assert_eq!(result, OptionalChoice::Skip);
}
#[test]
fn read_optional_choice_eof_returns_skip() {
let reader = std::io::Cursor::new("");
let result = read_optional_choice(reader).expect("no error on EOF");
assert_eq!(result, OptionalChoice::Skip, "EOF must yield Skip, not Run");
}
#[test]
fn read_optional_choice_run_on_bare_newline() {
let reader = std::io::Cursor::new("\n");
let result = read_optional_choice(reader).expect("no error");
assert_eq!(
result,
OptionalChoice::Run,
"bare Enter must yield Run (default Y)"
);
}
#[test]
fn disclosure_text_contains_separator_header() {
let text = disclosure_text(
"github.com/acme/tools",
"main",
"abc1234",
"/home/user/.mind/sources/github.com/acme/tools",
"make install",
None,
None,
);
assert!(
text.starts_with("====== hook: github.com/acme/tools ======\n"),
"disclosure_text must start with the separator header; got: {text}"
);
}
#[test]
fn disclosure_text_contains_required_fields() {
let text = disclosure_text(
"github.com/acme/tools",
"main",
"abc1234",
"/home/user/.mind/sources/github.com/acme/tools",
"make install",
None,
None,
);
assert!(text.contains("github.com/acme/tools"), "missing identity");
assert!(text.contains("main"), "missing pin_desc");
assert!(text.contains("abc1234"), "missing commit");
assert!(
text.contains("/home/user/.mind/sources/github.com/acme/tools"),
"missing clone_path"
);
assert!(text.contains("make install"), "missing command");
assert!(text.contains("arbitrary"), "missing arbitrary-code warning");
}
#[test]
fn disclosure_text_override_shows_both_commands_and_replacement_note() {
let text = disclosure_text(
"github.com/acme/tools",
"v1.0",
"def5678",
"/tmp/clone",
"./user-custom.sh",
Some("make install"),
None,
);
assert!(text.contains("make install"), "missing declared command");
assert!(
text.contains("./user-custom.sh"),
"missing override command"
);
assert!(
text.contains("replaces"),
"missing replacement statement; text: {text}"
);
assert!(text.contains("arbitrary"), "missing arbitrary-code warning");
}
#[test]
fn disclosure_text_no_override_does_not_mention_replacement() {
let text = disclosure_text(
"github.com/acme/tools",
"main",
"abc1234",
"/tmp/clone",
"make install",
None,
None,
);
assert!(
!text.contains("replaces"),
"should not mention replacement when no override"
);
}
#[test]
fn disclosure_text_matches_header_plus_disclosure_body() {
let identity = "github.com/acme/tools";
let pin_desc = "main";
let commit = "abc1234";
let clone_path = "/home/user/.mind/sources/github.com/acme/tools";
let command = "make install";
let via_fn = disclosure_text(identity, pin_desc, commit, clone_path, command, None, None);
let mut expected = String::new();
expected.push_str("====== hook: ");
expected.push_str(identity);
expected.push_str(" ======\n");
expected.push_str(&disclosure_body(
identity, pin_desc, commit, clone_path, command, None, None,
));
assert_eq!(
via_fn, expected,
"disclosure_text output must equal header + disclosure_body"
);
}
#[test]
fn disclosure_sanitizes_ansi_and_bidi_in_source_fields() {
let ansi_command = "\x1b[2K\x1b[1A rm -rf / \x1b[0m";
let bidi_identity = "github.com/\u{202E}kcal/evil\u{202E}";
let text = disclosure_text(
bidi_identity,
"main",
"abc1234",
"/tmp/clone",
ansi_command,
None,
None,
);
assert!(
!text.contains('\x1b'),
"disclosure_text must not contain raw ESC byte; got: {text:?}"
);
assert!(
!text.chars().any(|c| matches!(
c,
'\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'
)),
"disclosure_text must not contain bidi-override code points; got: {text:?}"
);
let ansi_label = "\x1b[31mbuild\x1b[0m\u{202E}";
let text2 = hook_disclosure_text(
ansi_label,
false,
bidi_identity,
"main",
"abc1234",
"/tmp/clone",
ansi_command,
None,
None,
);
assert!(
!text2.contains('\x1b'),
"hook_disclosure_text must not contain raw ESC byte; got: {text2:?}"
);
assert!(
!text2.chars().any(|c| matches!(
c,
'\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'
)),
"hook_disclosure_text must not contain bidi-override code points; got: {text2:?}"
);
let text3 = disclosure_text(
bidi_identity,
"v1.0",
"def5678",
"/tmp/clone",
ansi_command,
Some("\x1b[32m malicious_decl \x1b[0m"),
None,
);
assert!(
!text3.contains('\x1b'),
"disclosure with override must not contain raw ESC byte; got: {text3:?}"
);
let ansi_browse_url = "\x1b[34mhttps://github.com/evil/repo/tree/abc\u{202E}1234\x1b[0m";
let text4 = disclosure_text(
"github.com/evil/repo",
"main",
"abc1234",
"/tmp/clone",
"make install",
None,
Some(ansi_browse_url),
);
assert!(
!text4.contains('\x1b'),
"disclosure with browse_url must not contain raw ESC byte; got: {text4:?}"
);
assert!(
!text4.chars().any(|c| matches!(
c,
'\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'
)),
"disclosure with browse_url must not contain bidi-override code points; got: {text4:?}"
);
}
#[test]
fn disclosure_text_matches_header_plus_disclosure_body_with_override() {
let identity = "github.com/acme/tools";
let pin_desc = "v1.0";
let commit = "def5678";
let clone_path = "/tmp/clone";
let command = "./user-custom.sh";
let declared = "make install";
let via_fn = disclosure_text(
identity,
pin_desc,
commit,
clone_path,
command,
Some(declared),
None,
);
let mut expected = String::new();
expected.push_str("====== hook: ");
expected.push_str(identity);
expected.push_str(" ======\n");
expected.push_str(&disclosure_body(
identity,
pin_desc,
commit,
clone_path,
command,
Some(declared),
None,
));
assert_eq!(
via_fn, expected,
"disclosure_text with override must equal header + disclosure_body"
);
}
#[test]
fn disclosure_browse_url_line_present_when_some_absent_when_none() {
let with_url = disclosure_text(
"github.com/acme/tools",
"main",
"abc1234",
"/tmp/clone",
"make install",
None,
Some("https://github.com/acme/tools/tree/abc1234"),
);
assert!(
with_url.contains("Browse:"),
"disclosure must contain Browse: line when browse_url is Some; got: {with_url}"
);
assert!(
with_url.contains("https://github.com/acme/tools/tree/abc1234"),
"disclosure must contain the browse URL; got: {with_url}"
);
let without_url = disclosure_text(
"github.com/acme/tools",
"main",
"abc1234",
"/tmp/clone",
"make install",
None,
None,
);
assert!(
!without_url.contains("Browse:"),
"disclosure must not contain Browse: line when browse_url is None; got: {without_url}"
);
}
#[test]
fn disclosure_browse_line_positioned_after_clone() {
let text = disclosure_text(
"github.com/acme/tools",
"main",
"abc1234",
"/home/user/.mind/sources/github.com/acme/tools",
"make install",
None,
Some("https://github.com/acme/tools/tree/abc1234"),
);
let clone_at = text.find(" Clone: ").expect("Clone line present");
let browse_at = text.find(" Browse: ").expect("Browse line present");
assert!(
browse_at > clone_at,
"Browse: line must come after Clone: line; got: {text}"
);
let between = &text[clone_at..browse_at];
assert_eq!(
between.matches('\n').count(),
1,
"Browse: must be the line immediately after Clone:; got between: {between:?}"
);
let warn_at = text
.find("WARNING")
.or_else(|| text.find("make install"))
.expect("consent block present");
assert!(
browse_at < warn_at,
"Browse: line must precede the command/WARNING consent block; got: {text}"
);
}
#[test]
fn hook_disclosure_text_has_separator_header_with_label() {
let text = hook_disclosure_text(
"Build step",
true,
"github.com/acme/tools",
"main",
"abc1234",
"/tmp/clone",
"make install",
None,
None,
);
assert!(
text.starts_with("====== hook: Build step ======\n"),
"hook_disclosure_text must start with '====== hook: <label> ======'; got: {text}"
);
}
#[test]
fn hook_disclosure_text_optional_contains_label_and_optional_marker() {
let text = hook_disclosure_text(
"Build step",
true,
"github.com/acme/tools",
"main",
"abc1234",
"/tmp/clone",
"make install",
None,
None,
);
assert!(text.contains("Build step"), "missing label");
assert!(text.contains("optional"), "missing optional marker");
assert!(!text.contains("required"), "should not say required");
assert!(text.contains("github.com/acme/tools"), "missing identity");
assert!(text.contains("make install"), "missing command");
assert!(text.contains("arbitrary"), "missing arbitrary-code warning");
}
#[test]
fn hook_disclosure_text_required_contains_required_marker() {
let text = hook_disclosure_text(
"setup.sh",
false,
"github.com/acme/tools",
"v1.0",
"def5678",
"/tmp/clone",
"setup.sh",
None,
None,
);
assert!(text.contains("setup.sh"), "missing label/command");
assert!(text.contains("required"), "missing required marker");
assert!(text.contains("github.com/acme/tools"), "missing identity");
assert!(text.contains("arbitrary"), "missing arbitrary-code warning");
}
#[test]
fn hook_disclosure_text_override_shows_both_commands() {
let text = hook_disclosure_text(
"custom.sh",
false,
"github.com/acme/tools",
"main",
"abc1234",
"/tmp/clone",
"./user-custom.sh",
Some("make install"),
None,
);
assert!(text.contains("make install"), "missing declared command");
assert!(
text.contains("./user-custom.sh"),
"missing override command"
);
assert!(text.contains("replaces"), "missing replacement note");
assert!(text.contains("arbitrary"), "missing arbitrary-code warning");
}
#[test]
fn apply_install_override_none_supplied_returns_unchanged() {
use crate::mindfile::{HookEvent, ResolvedHook};
let hooks = vec![ResolvedHook {
run: "make install".into(),
name: None,
optional: false,
event: HookEvent::Install,
}];
let (result, replaced) = apply_install_override(hooks.clone(), None);
assert_eq!(result, hooks, "hooks must be unchanged");
assert!(replaced.is_none(), "no override => replaced is None");
}
#[test]
fn apply_install_override_empty_supplied_returns_unchanged() {
use crate::mindfile::{HookEvent, ResolvedHook};
let hooks = vec![ResolvedHook {
run: "make install".into(),
name: None,
optional: false,
event: HookEvent::Install,
}];
let (result, replaced) = apply_install_override(hooks.clone(), Some(""));
assert_eq!(result, hooks, "empty supplied => unchanged");
assert!(replaced.is_none());
let (result2, replaced2) = apply_install_override(hooks.clone(), Some(" "));
assert_eq!(result2, hooks, "whitespace supplied => unchanged");
assert!(replaced2.is_none());
}
#[test]
fn apply_install_override_replaces_declared_install_and_returns_them() {
use crate::mindfile::{HookEvent, ResolvedHook};
let hooks = vec![
ResolvedHook {
run: "make build".into(),
name: Some("Build".into()),
optional: false,
event: HookEvent::Install,
},
ResolvedHook {
run: "make install".into(),
name: None,
optional: false,
event: HookEvent::Install,
},
];
let (result, replaced) = apply_install_override(hooks, Some("./custom.sh"));
assert_eq!(result.len(), 1);
assert_eq!(result[0].run, "./custom.sh");
assert_eq!(result[0].event, HookEvent::Install);
assert!(!result[0].optional);
assert_eq!(result[0].name, None);
let replaced = replaced.expect("should be Some when install hooks were declared");
assert_eq!(replaced, vec!["make build", "make install"]);
}
#[test]
fn apply_install_override_uninstall_hooks_survive_in_order() {
use crate::mindfile::{HookEvent, ResolvedHook};
let hooks = vec![
ResolvedHook {
run: "make install".into(),
name: None,
optional: false,
event: HookEvent::Install,
},
ResolvedHook {
run: "first-uninstall".into(),
name: Some("First".into()),
optional: false,
event: HookEvent::Uninstall,
},
ResolvedHook {
run: "second-uninstall".into(),
name: None,
optional: true,
event: HookEvent::Uninstall,
},
];
let (result, replaced) = apply_install_override(hooks, Some("./override.sh"));
assert_eq!(result[0].run, "./override.sh");
assert_eq!(result[0].event, HookEvent::Install);
assert_eq!(result[1].run, "first-uninstall");
assert_eq!(result[1].event, HookEvent::Uninstall);
assert_eq!(result[2].run, "second-uninstall");
assert_eq!(result[2].event, HookEvent::Uninstall);
assert_eq!(result.len(), 3);
assert_eq!(replaced, Some(vec!["make install".to_string()]));
}
#[test]
fn apply_install_override_no_declared_install_adds_hook_replaced_is_none() {
use crate::mindfile::{HookEvent, ResolvedHook};
let hooks = vec![ResolvedHook {
run: "teardown.sh".into(),
name: None,
optional: false,
event: HookEvent::Uninstall,
}];
let (result, replaced) = apply_install_override(hooks, Some("./new-install.sh"));
assert_eq!(result.len(), 2);
assert_eq!(result[0].run, "./new-install.sh");
assert_eq!(result[0].event, HookEvent::Install);
assert_eq!(result[1].run, "teardown.sh");
assert_eq!(result[1].event, HookEvent::Uninstall);
assert!(
replaced.is_none(),
"no declared install hooks => replaced is None even when supplied is given"
);
}
#[test]
fn apply_hook_override_uninstall_replaces_uninstall_and_keeps_install() {
use crate::mindfile::{HookEvent, ResolvedHook};
let hooks = vec![
ResolvedHook {
run: "build".into(),
name: None,
optional: false,
event: HookEvent::Install,
},
ResolvedHook {
run: "old-teardown".into(),
name: None,
optional: false,
event: HookEvent::Uninstall,
},
];
let (result, replaced) =
apply_hook_override(hooks, Some("./new-teardown.sh"), HookEvent::Uninstall);
let uninstall: Vec<&ResolvedHook> = result
.iter()
.filter(|h| h.event == HookEvent::Uninstall)
.collect();
assert_eq!(uninstall.len(), 1);
assert_eq!(uninstall[0].run, "./new-teardown.sh");
assert!(
result
.iter()
.any(|h| h.event == HookEvent::Install && h.run == "build"),
"the install hook must survive an uninstall override"
);
assert_eq!(replaced.as_deref(), Some(&["old-teardown".to_string()][..]));
}
#[test]
fn run_hook_sanitizes_ansi_in_label_and_command_for_the_error() {
let dir = TempDir::new("ansi-label");
let evil_label = "\x1b[2K\x1b[1A\x1b[2Kbuild";
let evil_command = "exit 9 # \x1b[31mcolor\x1b[0m";
let result = run_hook(
evil_command,
dir.path(),
"github.com/test/repo",
"install",
evil_label,
);
let err = result.expect_err("a non-zero exit must produce HookFailed");
let msg = err.to_string();
assert!(
!msg.contains('\x1b'),
"raw ESC must not appear in the rendered error: {msg:?}"
);
match err {
MindError::HookFailed {
ref label,
ref command,
..
} => {
assert!(
!label.contains('\x1b'),
"label field must not carry raw ESC: {label:?}"
);
assert!(
label.contains("build"),
"sanitized label text must remain: {label:?}"
);
assert!(
!command.contains('\x1b'),
"command field must not carry raw ESC: {command:?}"
);
assert!(
command.contains("color"),
"sanitized command text must remain: {command:?}"
);
}
other => panic!("expected HookFailed, got: {other:?}"),
}
}
#[test]
fn run_hook_spawn_failure_produces_unstreamed_hook_failed() {
let missing_dir = std::env::temp_dir().join(format!(
"mind-hook-test-missing-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::SeqCst)
));
assert!(
!missing_dir.exists(),
"the dir must not exist for this test to force a spawn failure"
);
let result = run_hook(
"true",
&missing_dir,
"github.com/test/repo",
"install",
"spawn-fail",
);
match result {
Err(MindError::HookFailed {
ref label,
status,
streamed,
ref reason,
..
}) => {
assert_eq!(label, "spawn-fail");
assert!(!streamed, "a spawn failure never ran the hook");
assert!(
status.is_none(),
"a process that never ran has no exit status"
);
assert!(
!reason.is_empty(),
"a spawn failure must carry its own reason, since nothing was streamed"
);
}
other => panic!("expected a spawn failure to produce HookFailed, got: {other:?}"),
}
}
#[test]
fn run_hook_success_creates_marker_file() {
let dir = TempDir::new("success");
let marker = dir.path().join("marker.txt");
let marker_str = marker.to_str().expect("marker path is utf8");
let command = format!("touch {marker_str}");
let result = run_hook(
&command,
dir.path(),
"github.com/test/repo",
"install",
"test-label",
);
assert!(result.is_ok(), "expected Ok, got: {result:?}");
assert!(
marker.exists(),
"marker file should exist after successful hook"
);
}
#[test]
fn run_hook_nonzero_exit_returns_hook_failed() {
let dir = TempDir::new("fail");
let result = run_hook(
"exit 3",
dir.path(),
"github.com/test/repo",
"install",
"test-label",
);
match result {
Err(
ref e @ MindError::HookFailed {
event,
ref label,
ref identity,
ref command,
status,
reason: ref stderr,
streamed,
},
) => {
assert_eq!(event, "install", "wrong event");
assert_eq!(label, "test-label", "wrong label");
assert_eq!(identity, "github.com/test/repo", "wrong identity");
assert_eq!(command, "exit 3", "wrong command");
assert!(
status.is_some(),
"exit status should be Some for a process that ran"
);
let code = status.unwrap().code();
assert_eq!(code, Some(3), "expected exit code 3, got {code:?}");
assert!(
stderr.is_empty(),
"an inherited hook captures no stderr to attach"
);
assert!(
streamed,
"a hook that ran streamed its own output, so the error points at the frame"
);
let msg = e.to_string();
assert!(
msg.contains("its output, if any, is in the frame above"),
"a streamed hook failure must point at the frame: {msg}"
);
assert!(
!msg.contains("see the hook"),
"must not say 'see the hook's output above' when nothing was printed: {msg}"
);
}
other => panic!("expected HookFailed, got: {other:?}"),
}
}
#[test]
fn run_hook_with_output_before_failure_points_at_the_frame() {
let dir = TempDir::new("output-fail");
let result = run_hook(
"echo 'hook output'; exit 2",
dir.path(),
"github.com/test/repo",
"install",
"test-label",
);
match result {
Err(
ref e @ MindError::HookFailed {
streamed,
reason: ref stderr,
..
},
) => {
assert!(streamed, "streamed must be true when hook produced stdout");
assert!(
stderr.is_empty(),
"stderr field must be empty (output was streamed, not captured)"
);
let msg = e.to_string();
assert!(
msg.contains("its output, if any, is in the frame above"),
"must point at the frame when output was streamed: {msg}"
);
assert!(
!msg.contains("(no output)"),
"must not say '(no output)' when output was printed: {msg}"
);
}
other => panic!("expected HookFailed, got: {other:?}"),
}
}
#[test]
fn run_hook_with_stderr_output_before_failure_points_at_the_frame() {
let dir = TempDir::new("stderr-fail");
let result = run_hook(
"echo 'hook stderr' >&2; exit 1",
dir.path(),
"github.com/test/repo",
"install",
"test-label",
);
match result {
Err(ref e @ MindError::HookFailed { streamed, .. }) => {
assert!(streamed, "streamed must be true when hook produced stderr");
let msg = e.to_string();
assert!(
msg.contains("its output, if any, is in the frame above"),
"stderr output must also trigger '(see output above)': {msg}"
);
}
other => panic!("expected HookFailed, got: {other:?}"),
}
}
#[test]
fn run_hook_identity_and_command_propagate_to_error() {
let dir = TempDir::new("propagate");
let result = run_hook(
"false",
dir.path(),
"github.com/acme/special",
"install",
"my-hook",
);
match result {
Err(MindError::HookFailed {
ref identity,
ref command,
..
}) => {
assert_eq!(identity, "github.com/acme/special");
assert_eq!(command, "false");
}
other => panic!("expected HookFailed, got: {other:?}"),
}
}
}