mod error;
pub use error::Error as PromptError;
use std::io::{self, Write};
use similar::{ChangeTag, DiffOp, TextDiff};
use crate::{
ansi::Styler,
error::Error,
line_prompter::{LineOutcome, LinePrompter, RustylinePrompter},
output::ResolvedConfigFilePath,
};
pub enum PendingChange<'a> {
CreateFile {
path: &'a ResolvedConfigFilePath,
content: &'a str,
},
UpdateFileHunk {
path: &'a ResolvedConfigFilePath,
index: usize,
total: usize,
diff: &'a TextDiff<'a, 'a, str>,
ops: &'a [DiffOp],
},
CreateSymlink {
real: &'a ResolvedConfigFilePath,
symlink: &'a ResolvedConfigFilePath,
},
CreateSymlinkWithParent {
real: &'a ResolvedConfigFilePath,
symlink: &'a ResolvedConfigFilePath,
parent: &'a ResolvedConfigFilePath,
},
ReplaceWrongSymlink {
real: &'a ResolvedConfigFilePath,
symlink: &'a ResolvedConfigFilePath,
current_target: &'a std::path::Path,
},
}
#[derive(Debug, PartialEq, Eq)]
pub enum PreApplyDecision {
CommitAndPush {
message: String,
},
Continue,
Abort,
}
pub fn parse_pre_apply_input(line: &str) -> Option<PreApplyAnswer> {
match line.trim().to_ascii_lowercase().as_str() {
"c" | "commit" => Some(PreApplyAnswer::Commit),
"" | "y" | "yes" => Some(PreApplyAnswer::Continue),
"n" | "no" | "abort" => Some(PreApplyAnswer::Abort),
_ => None,
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum PreApplyAnswer {
Commit,
Continue,
Abort,
}
pub trait Prompter {
fn confirm(&mut self, change: PendingChange<'_>) -> Result<bool, Error>;
fn confirm_pre_apply(&mut self) -> Result<PreApplyDecision, Error>;
}
pub struct YesPrompter;
impl Prompter for YesPrompter {
fn confirm(&mut self, _change: PendingChange<'_>) -> Result<bool, Error> {
Ok(true)
}
fn confirm_pre_apply(&mut self) -> Result<PreApplyDecision, Error> {
Ok(PreApplyDecision::Continue)
}
}
pub struct TerminalPrompter {
color: bool,
line: RustylinePrompter,
}
impl TerminalPrompter {
pub fn new(color: bool) -> Result<Self, Error> {
let line = RustylinePrompter::new().map_err(PromptError::Read)?;
Ok(Self { color, line })
}
}
impl Prompter for TerminalPrompter {
fn confirm(&mut self, change: PendingChange<'_>) -> Result<bool, Error> {
{
let stdout = io::stdout();
let mut out = stdout.lock();
render_change(&mut out, &change, self.color).map_err(PromptError::Read)?;
}
loop {
let Some(line) = ask_line(&mut self.line, "[Y/n] ")? else {
return Ok(false);
};
match line.trim().to_ascii_lowercase().as_str() {
"" | "y" | "yes" => return Ok(true),
"n" | "no" => return Ok(false),
_ => {
self.line
.writeln("Please answer y or n.")
.map_err(PromptError::Read)?;
}
}
}
}
fn confirm_pre_apply(&mut self) -> Result<PreApplyDecision, Error> {
loop {
let Some(line) =
ask_line(&mut self.line, "[c]ommit & push / [Y]continue / [n]abort: ")?
else {
return Ok(PreApplyDecision::Abort);
};
match parse_pre_apply_input(&line) {
Some(PreApplyAnswer::Commit) => {
let message = read_commit_message(&mut self.line)?;
return Ok(PreApplyDecision::CommitAndPush { message });
}
Some(PreApplyAnswer::Continue) => return Ok(PreApplyDecision::Continue),
Some(PreApplyAnswer::Abort) => return Ok(PreApplyDecision::Abort),
None => {
self.line
.writeln("Please answer c, y, or n.")
.map_err(PromptError::Read)?;
}
}
}
}
}
fn ask_line(prompter: &mut dyn LinePrompter, prompt: &str) -> Result<Option<String>, PromptError> {
match prompter.read_line(prompt).map_err(PromptError::Read)? {
LineOutcome::Line(s) => Ok(Some(s)),
LineOutcome::Eof => Ok(None),
LineOutcome::Interrupted => Err(PromptError::Interrupted),
}
}
fn read_commit_message(prompter: &mut dyn LinePrompter) -> Result<String, PromptError> {
loop {
let Some(line) = ask_line(prompter, "Commit message: ")? else {
return Err(PromptError::Read(io::Error::new(
io::ErrorKind::UnexpectedEof,
"no commit message provided",
)));
};
let trimmed = line.trim().to_string();
if trimmed.is_empty() {
prompter
.writeln("Commit message cannot be empty.")
.map_err(PromptError::Read)?;
continue;
}
return Ok(trimmed);
}
}
pub struct DryRunPrompter {
color: bool,
}
impl DryRunPrompter {
pub fn new(color: bool) -> Self {
Self { color }
}
}
impl Prompter for DryRunPrompter {
fn confirm(&mut self, change: PendingChange<'_>) -> Result<bool, Error> {
let stdout = io::stdout();
let mut out = stdout.lock();
render_change(&mut out, &change, self.color).map_err(PromptError::Read)?;
writeln!(out, "[Y/n] (dry-run: skipping)").map_err(PromptError::Read)?;
Ok(false)
}
fn confirm_pre_apply(&mut self) -> Result<PreApplyDecision, Error> {
Ok(PreApplyDecision::Continue)
}
}
fn render_change(out: &mut dyn Write, change: &PendingChange<'_>, color: bool) -> io::Result<()> {
match change {
PendingChange::CreateFile { path, content } => {
writeln!(out, "Create {path}?")?;
render_new_file(out, content, color)
}
PendingChange::UpdateFileHunk {
path,
index,
total,
diff,
ops,
} => {
writeln!(out, "Update {path} — hunk {index}/{total}?")?;
render_single_hunk(out, diff, ops, color)
}
PendingChange::CreateSymlink { real, symlink } => {
writeln!(out, "Create symlink {symlink} -> {real}?")
}
PendingChange::CreateSymlinkWithParent {
real,
symlink,
parent,
} => writeln!(
out,
"Create symlink {symlink} -> {real}? (will also create directory {parent})"
),
PendingChange::ReplaceWrongSymlink {
real,
symlink,
current_target,
} => writeln!(
out,
"Replace symlink {symlink}: currently -> {} -> {real}?",
current_target.display(),
),
}
}
fn render_new_file(out: &mut dyn Write, content: &str, color: bool) -> io::Result<()> {
let s = Styler::new(color);
let close = s.reset();
let open = s.green();
for line in content.lines() {
writeln!(out, "{open}+{line}{close}")?;
}
if !content.ends_with('\n') && !content.is_empty() {
writeln!(out, "\\ No newline at end of file")?;
}
Ok(())
}
fn render_single_hunk(
out: &mut dyn Write,
diff: &TextDiff<'_, '_, str>,
ops: &[DiffOp],
color: bool,
) -> io::Result<()> {
let (first, last) = match ops {
[] => return Ok(()),
[only] => (only, only),
[first, .., last] => (first, last),
};
let old_start = first.old_range().start;
let old_len = last.old_range().end - old_start;
let new_start = first.new_range().start;
let new_len = last.new_range().end - new_start;
let s = Styler::new(color);
let close = s.reset();
let header_open = s.cyan();
writeln!(
out,
"{header_open}@@ -{},{} +{},{} @@{close}",
old_start + 1,
old_len,
new_start + 1,
new_len,
)?;
for op in ops {
for change in diff.iter_changes(op) {
let (prefix, open) = match change.tag() {
ChangeTag::Delete => ("-", s.red()),
ChangeTag::Insert => ("+", s.green()),
ChangeTag::Equal => (" ", s.dim()),
};
write!(out, "{open}{prefix}{change}{close}")?;
if change.missing_newline() {
writeln!(out, "\n\\ No newline at end of file")?;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::{path::Path, sync::Arc};
use similar::TextDiff;
use similar_asserts::assert_eq;
use zenops_safe_relative_path::SafeRelativePath;
use super::*;
use crate::{config_files::ConfigFilePath, output::ResolvedConfigFilePath};
fn home_path(rel: &str) -> ResolvedConfigFilePath {
let srp = SafeRelativePath::from_relative_path(rel).unwrap();
ResolvedConfigFilePath {
path: ConfigFilePath::in_home(srp),
full: Arc::from(Path::new("/home/test").join(rel)),
}
}
fn render_to_string(change: PendingChange<'_>, color: bool) -> String {
let mut buf: Vec<u8> = Vec::new();
render_change(&mut buf, &change, color).unwrap();
String::from_utf8(buf).unwrap()
}
#[test]
fn render_new_file_emits_plus_prefix_and_missing_newline_marker() {
let mut buf: Vec<u8> = Vec::new();
render_new_file(&mut buf, "alpha\nbeta", false).unwrap();
assert_eq!(
String::from_utf8(buf).unwrap(),
"+alpha\n+beta\n\\ No newline at end of file\n",
);
}
#[test]
fn render_new_file_skips_marker_when_content_ends_in_newline() {
let mut buf: Vec<u8> = Vec::new();
render_new_file(&mut buf, "alpha\nbeta\n", false).unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "+alpha\n+beta\n");
}
#[test]
fn render_new_file_empty_content_emits_nothing() {
let mut buf: Vec<u8> = Vec::new();
render_new_file(&mut buf, "", false).unwrap();
assert!(buf.is_empty());
}
#[test]
fn render_change_create_file_writes_prompt_and_body() {
let p = home_path("alpha.toml");
assert_eq!(
render_to_string(
PendingChange::CreateFile {
path: &p,
content: "x\n",
},
false,
),
"Create ~/alpha.toml?\n+x\n",
);
}
#[test]
fn render_change_create_symlink_writes_single_line() {
let real = home_path("src.txt");
let symlink = home_path("dst.txt");
assert_eq!(
render_to_string(
PendingChange::CreateSymlink {
real: &real,
symlink: &symlink,
},
false,
),
"Create symlink ~/dst.txt -> ~/src.txt?\n",
);
}
#[test]
fn render_change_create_symlink_with_parent_mentions_parent_dir() {
let real = home_path("src.txt");
let symlink = home_path("sub/dst.txt");
let parent = home_path("sub");
assert_eq!(
render_to_string(
PendingChange::CreateSymlinkWithParent {
real: &real,
symlink: &symlink,
parent: &parent,
},
false,
),
"Create symlink ~/sub/dst.txt -> ~/src.txt? (will also create directory ~/sub)\n",
);
}
#[test]
fn render_single_hunk_emits_unified_diff_header_and_markers() {
let old = "a\nb\nc\n";
let new = "a\nB\nc\n";
let diff = TextDiff::from_lines(old, new);
let groups = diff.grouped_ops(3);
assert_eq!(groups.len(), 1);
let mut buf: Vec<u8> = Vec::new();
render_single_hunk(&mut buf, &diff, &groups[0], false).unwrap();
let got = String::from_utf8(buf).unwrap();
assert!(
got.starts_with("@@ -1,3 +1,3 @@\n"),
"header wrong: {got:?}",
);
assert!(got.contains(" a\n"), "context before missing: {got:?}");
assert!(got.contains("-b\n"), "delete line missing: {got:?}");
assert!(got.contains("+B\n"), "insert line missing: {got:?}");
assert!(got.contains(" c\n"), "context after missing: {got:?}");
}
#[test]
fn render_single_hunk_uses_ansi_colors_when_enabled() {
let diff = TextDiff::from_lines("a\n", "b\n");
let groups = diff.grouped_ops(3);
let mut buf: Vec<u8> = Vec::new();
render_single_hunk(&mut buf, &diff, &groups[0], true).unwrap();
let got = String::from_utf8(buf).unwrap();
assert!(got.contains("\x1b[36m@@"), "cyan header missing: {got:?}");
assert!(got.contains("\x1b[31m-a"), "red delete missing: {got:?}");
assert!(got.contains("\x1b[32m+b"), "green insert missing: {got:?}");
assert!(got.contains("\x1b[0m"), "reset missing: {got:?}");
}
#[test]
fn render_single_hunk_marks_missing_trailing_newline() {
let diff = TextDiff::from_lines("a\n", "a");
let groups = diff.grouped_ops(3);
let mut buf: Vec<u8> = Vec::new();
render_single_hunk(&mut buf, &diff, &groups[0], false).unwrap();
let got = String::from_utf8(buf).unwrap();
assert!(
got.contains("\\ No newline at end of file"),
"missing-newline marker not emitted: {got:?}",
);
}
#[test]
fn render_change_replace_wrong_symlink_includes_current_target() {
let real = home_path("src.txt");
let symlink = home_path("dst.txt");
let current = Path::new("/somewhere/else");
assert_eq!(
render_to_string(
PendingChange::ReplaceWrongSymlink {
real: &real,
symlink: &symlink,
current_target: current,
},
false,
),
"Replace symlink ~/dst.txt: currently -> /somewhere/else -> ~/src.txt?\n",
);
}
#[test]
fn render_change_update_file_hunk_writes_header_and_diff() {
let p = home_path("alpha.toml");
let diff = TextDiff::from_lines("a\nb\nc\n", "a\nB\nc\n");
let groups = diff.grouped_ops(3);
assert_eq!(groups.len(), 1);
let mut buf: Vec<u8> = Vec::new();
render_change(
&mut buf,
&PendingChange::UpdateFileHunk {
path: &p,
index: 1,
total: 1,
diff: &diff,
ops: &groups[0],
},
false,
)
.unwrap();
let got = String::from_utf8(buf).unwrap();
assert!(
got.starts_with("Update ~/alpha.toml — hunk 1/1?\n"),
"header wrong: {got:?}",
);
assert!(got.contains("@@ -1,3 +1,3 @@"));
assert!(got.contains("-b\n"));
assert!(got.contains("+B\n"));
}
#[test]
fn parse_pre_apply_input_recognizes_commit() {
assert_eq!(parse_pre_apply_input("c"), Some(PreApplyAnswer::Commit));
assert_eq!(
parse_pre_apply_input("commit"),
Some(PreApplyAnswer::Commit)
);
assert_eq!(
parse_pre_apply_input(" COMMIT \n"),
Some(PreApplyAnswer::Commit),
);
}
#[test]
fn parse_pre_apply_input_recognizes_continue() {
assert_eq!(parse_pre_apply_input(""), Some(PreApplyAnswer::Continue));
assert_eq!(parse_pre_apply_input("y"), Some(PreApplyAnswer::Continue));
assert_eq!(parse_pre_apply_input("yes"), Some(PreApplyAnswer::Continue));
assert_eq!(
parse_pre_apply_input(" YES "),
Some(PreApplyAnswer::Continue),
);
}
#[test]
fn parse_pre_apply_input_recognizes_abort() {
assert_eq!(parse_pre_apply_input("n"), Some(PreApplyAnswer::Abort));
assert_eq!(parse_pre_apply_input("no"), Some(PreApplyAnswer::Abort));
assert_eq!(parse_pre_apply_input("abort"), Some(PreApplyAnswer::Abort),);
}
#[test]
fn parse_pre_apply_input_returns_none_for_garbage() {
assert_eq!(parse_pre_apply_input("maybe"), None);
assert_eq!(parse_pre_apply_input("?"), None);
}
struct ScriptedPrompter {
scripted: std::collections::VecDeque<LineOutcome>,
warnings: Vec<String>,
}
impl ScriptedPrompter {
fn new(outcomes: Vec<LineOutcome>) -> Self {
Self {
scripted: outcomes.into(),
warnings: Vec::new(),
}
}
}
impl LinePrompter for ScriptedPrompter {
fn read_line(&mut self, _prompt: &str) -> io::Result<LineOutcome> {
Ok(self
.scripted
.pop_front()
.expect("ScriptedPrompter ran out of outcomes"))
}
fn writeln(&mut self, msg: &str) -> io::Result<()> {
self.warnings.push(msg.to_string());
Ok(())
}
}
#[test]
fn read_commit_message_returns_trimmed_line_on_happy_path() {
let mut p = ScriptedPrompter::new(vec![LineOutcome::Line(" hello world ".into())]);
let got = read_commit_message(&mut p).unwrap();
assert_eq!(got, "hello world");
assert!(p.warnings.is_empty());
}
#[test]
fn read_commit_message_retries_on_empty_then_accepts_real_message() {
let mut p = ScriptedPrompter::new(vec![
LineOutcome::Line("".into()),
LineOutcome::Line(" ".into()),
LineOutcome::Line("real message".into()),
]);
let got = read_commit_message(&mut p).unwrap();
assert_eq!(got, "real message");
assert_eq!(p.warnings.len(), 2);
assert!(p.warnings[0].contains("cannot be empty"));
}
#[test]
fn read_commit_message_eof_returns_unexpected_eof_error() {
let mut p = ScriptedPrompter::new(vec![LineOutcome::Eof]);
let err = read_commit_message(&mut p).unwrap_err();
match err {
PromptError::Read(io_err) => {
assert_eq!(io_err.kind(), io::ErrorKind::UnexpectedEof);
}
other => panic!("expected PromptRead, got {other:?}"),
}
}
#[test]
fn read_commit_message_interrupted_returns_prompt_interrupted() {
let mut p = ScriptedPrompter::new(vec![LineOutcome::Interrupted]);
let err = read_commit_message(&mut p).unwrap_err();
assert_eq!(err, PromptError::Interrupted);
}
#[test]
fn yes_prompter_accepts_every_change_and_continues() {
let mut p = YesPrompter;
let real = home_path("src.txt");
let symlink = home_path("dst.txt");
assert!(
p.confirm(PendingChange::CreateSymlink {
real: &real,
symlink: &symlink,
})
.unwrap()
);
assert_eq!(p.confirm_pre_apply().unwrap(), PreApplyDecision::Continue);
}
}