Skip to main content

ralph/runutil/
revert.rs

1//! Git revert prompting and application helpers.
2//!
3//! Responsibilities:
4//! - Apply `GitRevertMode` policies (Enabled/Disabled/Ask).
5//! - Provide prompt context/types for interactive clients (CLI prompts, GUI wrappers).
6//! - Parse prompt responses in a deterministic, testable way.
7//!
8//! Not handled here:
9//! - Runner execution or abort classification.
10//!
11//! Invariants/assumptions:
12//! - `apply_git_revert_mode*` never mutates repo state unless mode=Enabled or user chooses Revert.
13//! - Non-interactive stdin (non-TTY) in Ask mode defaults to "keep changes".
14//! - If an interactive UI cannot complete the prompt (reply channel closes / coordination
15//!   failure), the prompt handler returns an error and the run aborts; no default decision
16//!   is assumed.
17
18use anyhow::Result;
19use std::io::{BufRead, BufReader, IsTerminal, Write};
20use std::path::Path;
21use std::sync::Arc;
22
23use crate::contracts::GitRevertMode;
24use crate::git;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum RevertSource {
28    Auto,
29    User,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum RevertOutcome {
34    Reverted { source: RevertSource },
35    Skipped { reason: String },
36    Continue { message: String },
37    Proceed { reason: String },
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum RevertDecision {
42    Revert,
43    Keep,
44    Continue { message: String },
45    Proceed,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct RevertPromptContext {
50    pub label: String,
51    pub allow_proceed: bool,
52    pub preface: Option<String>,
53}
54
55impl RevertPromptContext {
56    pub fn new(label: &str, allow_proceed: bool) -> Self {
57        Self {
58            label: label.to_string(),
59            allow_proceed,
60            preface: None,
61        }
62    }
63
64    pub fn with_preface(mut self, preface: impl Into<String>) -> Self {
65        let preface = preface.into();
66        if preface.trim().is_empty() {
67            return self;
68        }
69        self.preface = Some(preface);
70        self
71    }
72}
73
74pub type RevertPromptHandler =
75    Arc<dyn Fn(&RevertPromptContext) -> Result<RevertDecision> + Send + Sync>;
76
77pub fn apply_git_revert_mode(
78    repo_root: &Path,
79    mode: GitRevertMode,
80    prompt_label: &str,
81    revert_prompt: Option<&RevertPromptHandler>,
82) -> Result<RevertOutcome> {
83    apply_git_revert_mode_with_context(
84        repo_root,
85        mode,
86        RevertPromptContext::new(prompt_label, false),
87        revert_prompt,
88    )
89}
90
91pub fn apply_git_revert_mode_with_context(
92    repo_root: &Path,
93    mode: GitRevertMode,
94    prompt_context: RevertPromptContext,
95    revert_prompt: Option<&RevertPromptHandler>,
96) -> Result<RevertOutcome> {
97    match mode {
98        GitRevertMode::Enabled => {
99            git::revert_uncommitted(repo_root)?;
100            Ok(RevertOutcome::Reverted {
101                source: RevertSource::Auto,
102            })
103        }
104        GitRevertMode::Disabled => Ok(RevertOutcome::Skipped {
105            reason: "git_revert_mode=disabled".to_string(),
106        }),
107        GitRevertMode::Ask => {
108            if let Some(prompt) = revert_prompt {
109                let decision = prompt(&prompt_context)?;
110                return apply_revert_decision(repo_root, decision, prompt_context.allow_proceed);
111            }
112            let stdin = std::io::stdin();
113            if !stdin.is_terminal() {
114                return Ok(RevertOutcome::Skipped {
115                    reason: "stdin is not a TTY; keeping changes".to_string(),
116                });
117            }
118            let choice = prompt_revert_choice(&prompt_context)?;
119            apply_revert_decision(repo_root, choice, prompt_context.allow_proceed)
120        }
121    }
122}
123
124fn apply_revert_decision(
125    repo_root: &Path,
126    decision: RevertDecision,
127    allow_proceed: bool,
128) -> Result<RevertOutcome> {
129    match decision {
130        RevertDecision::Revert => {
131            git::revert_uncommitted(repo_root)?;
132            Ok(RevertOutcome::Reverted {
133                source: RevertSource::User,
134            })
135        }
136        RevertDecision::Keep => Ok(RevertOutcome::Skipped {
137            reason: "user chose to keep changes".to_string(),
138        }),
139        RevertDecision::Continue { message } => Ok(RevertOutcome::Continue {
140            message: message.trim_end_matches(['\n', '\r']).to_string(),
141        }),
142        RevertDecision::Proceed => {
143            if allow_proceed {
144                Ok(RevertOutcome::Proceed {
145                    reason: "user chose to proceed".to_string(),
146                })
147            } else {
148                Ok(RevertOutcome::Skipped {
149                    reason: "proceed not allowed; keeping changes".to_string(),
150                })
151            }
152        }
153    }
154}
155
156pub fn format_revert_failure_message(base: &str, outcome: RevertOutcome) -> String {
157    match outcome {
158        RevertOutcome::Reverted { .. } => format!("{base} Uncommitted changes were reverted."),
159        RevertOutcome::Skipped { reason } => format!("{base} Revert skipped ({reason})."),
160        RevertOutcome::Continue { .. } => {
161            format!("{base} Continue requested. No changes were reverted.")
162        }
163        RevertOutcome::Proceed { .. } => {
164            format!("{base} Proceed requested. No changes were reverted.")
165        }
166    }
167}
168
169fn prompt_revert_choice(prompt_context: &RevertPromptContext) -> Result<RevertDecision> {
170    let stdin = std::io::stdin();
171    let mut reader = BufReader::new(stdin.lock());
172    let mut stderr = std::io::stderr();
173    prompt_revert_choice_with_io(prompt_context, &mut reader, &mut stderr)
174}
175
176pub fn prompt_revert_choice_with_io<R: BufRead, W: Write>(
177    prompt_context: &RevertPromptContext,
178    reader: &mut R,
179    writer: &mut W,
180) -> Result<RevertDecision> {
181    if let Some(preface) = prompt_context.preface.as_ref()
182        && !preface.trim().is_empty()
183    {
184        write!(writer, "{preface}")?;
185        if !preface.ends_with('\n') {
186            writeln!(writer)?;
187        }
188        writer.flush().ok();
189    }
190
191    let mut prompt = format!(
192        "{}: action? [1=keep (default), 2=revert, 3=other",
193        prompt_context.label
194    );
195    if prompt_context.allow_proceed {
196        prompt.push_str(", 4=keep+continue");
197    }
198    prompt.push_str("]: ");
199    write!(writer, "{prompt}")?;
200    writer.flush().ok();
201
202    let mut input = String::new();
203    reader.read_line(&mut input)?;
204
205    let mut decision = parse_revert_response(&input, prompt_context.allow_proceed);
206
207    if matches!(decision, RevertDecision::Continue { ref message } if message.is_empty()) {
208        write!(
209            writer,
210            "{}: enter message to send (empty => keep): ",
211            prompt_context.label
212        )?;
213        writer.flush().ok();
214
215        let mut msg = String::new();
216        reader.read_line(&mut msg)?;
217        let msg = msg.trim_end_matches(['\n', '\r']);
218        if msg.trim().is_empty() {
219            decision = RevertDecision::Keep;
220        } else {
221            decision = RevertDecision::Continue {
222                message: msg.to_string(),
223            };
224        }
225    }
226
227    Ok(decision)
228}
229
230pub fn parse_revert_response(input: &str, allow_proceed: bool) -> RevertDecision {
231    let raw = input.trim_end_matches(['\n', '\r']);
232    let normalized = raw.trim().to_lowercase();
233
234    match normalized.as_str() {
235        "" => RevertDecision::Keep,
236        "1" | "k" | "keep" => RevertDecision::Keep,
237        "2" | "r" | "revert" => RevertDecision::Revert,
238        "3" => RevertDecision::Continue {
239            message: String::new(),
240        },
241        "4" if allow_proceed => RevertDecision::Proceed,
242        _ => RevertDecision::Continue {
243            message: raw.to_string(),
244        },
245    }
246}