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