Skip to main content

verbs/
revert_plan.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Pure revert planning: empty-diff gate + message assembly.
3//!
4//! Owns decision logic for `heddle revert` that can be decided from facts alone:
5//! - whether the parent→target tree diff is empty (nothing to inverse)
6//! - default commit message and human/JSON success strings
7//! - stable recovery-advice kind token for the empty-diff refusal
8//!
9//! Tree materialization, worktree FS, RecoveryAdvice construction, and snapshot
10//! I/O stay CLI-owned.
11
12// ---------------------------------------------------------------------------
13// Empty-diff preflight
14// ---------------------------------------------------------------------------
15
16/// Pure preflight for revert from the parent→target change count.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum RevertPlan {
19    /// Diff is empty; refuse with no-changes recovery advice.
20    NoChanges,
21    /// Proceed: apply inverse changes and optionally snapshot.
22    Proceed,
23}
24
25/// Plan revert from how many paths differ between parent and target trees.
26///
27/// Call after tree-diff I/O that yields a change set (or its length).
28pub fn plan_revert(change_count: usize) -> RevertPlan {
29    if revert_has_no_changes(change_count) {
30        RevertPlan::NoChanges
31    } else {
32        RevertPlan::Proceed
33    }
34}
35
36/// True when the parent→target diff has zero file changes.
37pub fn revert_has_no_changes(change_count: usize) -> bool {
38    change_count == 0
39}
40
41/// Stable recovery-advice `kind` for empty-diff refusal.
42pub fn no_changes_to_revert_kind() -> &'static str {
43    "no_changes_to_revert"
44}
45
46/// Inspect command suggested when revert refuses on an empty diff.
47pub fn revert_inspect_command(state_short: &str) -> String {
48    format!("heddle show {state_short}")
49}
50
51// ---------------------------------------------------------------------------
52// Message assembly
53// ---------------------------------------------------------------------------
54
55/// Default commit message when the user did not pass `--message`.
56pub fn default_revert_commit_message(state_short: &str) -> String {
57    format!("Revert {state_short}")
58}
59
60/// Whether success output targets JSON message shape vs human text.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum RevertMessageMode {
63    /// Human terminal lines.
64    Text,
65    /// JSON `message` field.
66    Json,
67}
68
69/// Outcome after inverse apply (with or without snapshot).
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum RevertOutcome {
72    /// `--no-commit`: inverse applied to worktree only.
73    AppliedNotCommitted,
74    /// Snapshot created with the inverse tree.
75    Committed,
76}
77
78/// Facts for assembling a success message after revert I/O.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct RevertSuccessFacts<'a> {
81    pub outcome: RevertOutcome,
82    pub state_short: &'a str,
83    /// New change id short form when [`RevertOutcome::Committed`].
84    pub new_state_id_short: Option<&'a str>,
85}
86
87/// Human/JSON success message for a completed revert.
88///
89/// Matches historical CLI strings:
90/// - no-commit text: `Reverted {state} (not committed)`
91/// - no-commit JSON: `Changes applied to worktree (not committed)`
92/// - committed text: `Reverted {state} as {new}`
93/// - committed JSON: `Created revert state {new}`
94pub fn revert_success_message(facts: &RevertSuccessFacts<'_>, mode: RevertMessageMode) -> String {
95    match (facts.outcome, mode) {
96        (RevertOutcome::AppliedNotCommitted, RevertMessageMode::Text) => {
97            format!("Reverted {} (not committed)", facts.state_short)
98        }
99        (RevertOutcome::AppliedNotCommitted, RevertMessageMode::Json) => {
100            "Changes applied to worktree (not committed)".to_string()
101        }
102        (RevertOutcome::Committed, RevertMessageMode::Text) => {
103            let new_id = facts.new_state_id_short.unwrap_or("");
104            format!("Reverted {} as {}", facts.state_short, new_id)
105        }
106        (RevertOutcome::Committed, RevertMessageMode::Json) => {
107            let new_id = facts.new_state_id_short.unwrap_or("");
108            format!("Created revert state {new_id}")
109        }
110    }
111}
112
113/// Summary line for the empty-diff RecoveryAdvice body (CLI wraps RecoveryAdvice).
114pub fn no_changes_to_revert_summary(state_short: &str) -> String {
115    format!("No changes to revert in state {state_short}")
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn empty_diff_gate() {
124        assert_eq!(plan_revert(0), RevertPlan::NoChanges);
125        assert_eq!(plan_revert(1), RevertPlan::Proceed);
126        assert_eq!(plan_revert(3), RevertPlan::Proceed);
127        assert!(revert_has_no_changes(0));
128        assert!(!revert_has_no_changes(2));
129        assert_eq!(no_changes_to_revert_kind(), "no_changes_to_revert");
130        assert_eq!(revert_inspect_command("abc1234"), "heddle show abc1234");
131        assert!(no_changes_to_revert_summary("abc").contains("abc"));
132    }
133
134    #[test]
135    fn default_and_success_messages() {
136        assert_eq!(
137            default_revert_commit_message("hs-deadbee"),
138            "Revert hs-deadbee"
139        );
140
141        let no_commit = RevertSuccessFacts {
142            outcome: RevertOutcome::AppliedNotCommitted,
143            state_short: "hs-aaaa",
144            new_state_id_short: None,
145        };
146        assert_eq!(
147            revert_success_message(&no_commit, RevertMessageMode::Text),
148            "Reverted hs-aaaa (not committed)"
149        );
150        assert_eq!(
151            revert_success_message(&no_commit, RevertMessageMode::Json),
152            "Changes applied to worktree (not committed)"
153        );
154
155        let committed = RevertSuccessFacts {
156            outcome: RevertOutcome::Committed,
157            state_short: "hs-aaaa",
158            new_state_id_short: Some("hs-bbbb"),
159        };
160        assert_eq!(
161            revert_success_message(&committed, RevertMessageMode::Text),
162            "Reverted hs-aaaa as hs-bbbb"
163        );
164        assert_eq!(
165            revert_success_message(&committed, RevertMessageMode::Json),
166            "Created revert state hs-bbbb"
167        );
168    }
169}