Skip to main content

verbs/
undo.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Undo list / planning domain (inspection + batch selection + apply preflight + human labels).
3//!
4//! Owns the **read-side** and **pure apply-path preflight** of `heddle undo`:
5//! - listing user-facing oplog batches for the current checkout scope
6//! - pure batch summarization for stable machine JSON field names
7//! - selecting the next N undo/redo batches
8//! - shared domain refusals for mode conflict and empty history
9//! - pure redaction / thread-worktree / state-reachability preflights given
10//!   caller-supplied batch facts (no FS / store I/O in the decision layer)
11//! - apply step order (reverse within batch for undo; forward for redo) and
12//!   preview / completed message strings
13//!
14//! Locks, store lookups, dirty-worktree checks, git-checkpoint simulation, and
15//! the apply engine remain CLI-owned (`undo.rs` + `undo_apply/*`).
16
17use std::path::PathBuf;
18
19use anyhow::{Result, anyhow};
20use chrono::{DateTime, Utc};
21use objects::{
22    HeddleError, RecoveryDetails,
23    object::{ContentHash, StateId},
24};
25use oplog::{OpBatch, RedactionUndoClass};
26use repo::Repository;
27use schemars::JsonSchema;
28use serde::Serialize;
29
30use crate::{
31    ExecutionContext, HeddleReport, MachineOutputKind, OutputDiscriminator, ReportContract,
32    schema_for_report,
33};
34
35/// Soften git-checkpoint batch descriptions for human undo listing.
36pub fn human_operation_description(description: &str) -> String {
37    if description.starts_with("git checkpoint ") {
38        return "Git commit written".to_string();
39    }
40    description.to_string()
41}
42
43/// Soften post-undo verification status for operators.
44pub fn human_post_undo_trust_status(status: &str) -> String {
45    if matches!(status, "dirty_worktree" | "uncaptured") {
46        "changes to save".to_string()
47    } else {
48        status.to_string()
49    }
50}
51
52/// Machine JSON for `heddle undo --list` (stable field names).
53#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
54pub struct UndoListReport {
55    pub output_kind: &'static str,
56    pub batches: Vec<UndoBatchSummary>,
57}
58
59impl UndoListReport {
60    pub const CONTRACT: ReportContract = ReportContract {
61        schema_name: "undo_list",
62        machine_output_kind: MachineOutputKind::Json,
63        output_discriminator: Some(OutputDiscriminator {
64            field: "output_kind",
65            value: "undo_list",
66        }),
67        schema: schema_for_report::<UndoListReport>,
68    };
69}
70
71impl HeddleReport for UndoListReport {
72    const CONTRACT: ReportContract = UndoListReport::CONTRACT;
73}
74
75/// One oplog batch as surfaced by undo list / preview / completed payloads.
76#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
77pub struct UndoBatchSummary {
78    pub batch_id: u64,
79    pub timestamp: String,
80    pub undone: bool,
81    pub partial: bool,
82    pub operations: Vec<UndoOperationSummary>,
83}
84
85/// One operation inside an undo batch summary.
86#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
87pub struct UndoOperationSummary {
88    pub id: u64,
89    pub description: String,
90    pub timestamp: String,
91    pub undone: bool,
92}
93
94/// Whether empty-history advice refers to undo or redo.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum UndoHistoryAction {
97    Undo,
98    Redo,
99}
100
101impl UndoHistoryAction {
102    pub fn as_str(self) -> &'static str {
103        match self {
104            Self::Undo => "undo",
105            Self::Redo => "redo",
106        }
107    }
108
109    pub fn empty_kind(self) -> &'static str {
110        match self {
111            Self::Undo => "nothing_to_undo",
112            Self::Redo => "nothing_to_redo",
113        }
114    }
115}
116
117/// Result of selecting batches for a multi-step undo/redo plan.
118///
119/// Pure relative to apply: holds the scoped batches that would be rewound or
120/// replayed. Callers run safety preflights and the apply engine separately.
121#[derive(Debug, Clone)]
122pub struct UndoPlan {
123    pub action: UndoHistoryAction,
124    pub steps_requested: usize,
125    pub batches: Vec<OpBatch>,
126}
127
128impl UndoPlan {
129    pub fn batch_summaries(&self) -> Vec<UndoBatchSummary> {
130        self.batches.iter().map(summarize_batch).collect()
131    }
132}
133
134/// List user-facing undo history for the current checkout scope.
135///
136/// Counts only user-facing batches toward `depth`, dropping record-less
137/// transaction markers (undo/redo commit sentinels) **before** the limit
138/// applies — matching `OpLog::recent_user_batches_scoped` (heddle#355).
139pub fn list_undo_history(repo: &Repository, depth: usize) -> Result<UndoListReport> {
140    let scope = repo.op_scope();
141    let batches = repo
142        .oplog()
143        .recent_user_batches_scoped(depth, Some(&scope))?;
144    Ok(UndoListReport {
145        output_kind: "undo_list",
146        batches: batches.iter().map(summarize_batch).collect(),
147    })
148}
149
150/// List undo history via an [`ExecutionContext`] (embeddable facade entry).
151pub fn list_undo_history_ctx(ctx: &ExecutionContext, depth: usize) -> Result<UndoListReport> {
152    let repo = ctx.require_repo()?;
153    list_undo_history(repo, depth)
154}
155
156/// Select the next `steps` undoable batches for the current checkout scope.
157///
158/// Returns [`HeddleError`] (kind `nothing_to_undo`) when no eligible batch
159/// exists. Does not run worktree / redaction / reachability preflights.
160pub fn plan_undo_batches(repo: &Repository, steps: usize) -> Result<UndoPlan> {
161    let scope = repo.op_scope();
162    let batches = repo.oplog().undo_batches_scoped(steps, Some(&scope))?;
163    require_nonempty_history(UndoHistoryAction::Undo, &batches).map_err(|e| anyhow!(e))?;
164    Ok(UndoPlan {
165        action: UndoHistoryAction::Undo,
166        steps_requested: steps,
167        batches,
168    })
169}
170
171/// Select the next `steps` redoable batches for the current checkout scope.
172///
173/// Returns [`HeddleError`] (kind `nothing_to_redo`) when no eligible batch
174/// exists. Does not run worktree / redaction / reachability preflights.
175pub fn plan_redo_batches(repo: &Repository, steps: usize) -> Result<UndoPlan> {
176    let scope = repo.op_scope();
177    let batches = repo.oplog().redo_batches_scoped(steps, Some(&scope))?;
178    require_nonempty_history(UndoHistoryAction::Redo, &batches).map_err(|e| anyhow!(e))?;
179    Ok(UndoPlan {
180        action: UndoHistoryAction::Redo,
181        steps_requested: steps,
182        batches,
183    })
184}
185
186/// Pure mode preflight: `--list` and `--preview` are mutually exclusive.
187pub fn validate_undo_list_preview_modes(list: bool, preview: bool) -> Result<(), HeddleError> {
188    if list && preview {
189        Err(undo_mode_conflict())
190    } else {
191        Ok(())
192    }
193}
194
195/// Refuse when a plan selected zero batches.
196pub fn require_nonempty_history(
197    action: UndoHistoryAction,
198    batches: &[OpBatch],
199) -> Result<(), HeddleError> {
200    if batches.is_empty() {
201        Err(empty_history_refusal(action))
202    } else {
203        Ok(())
204    }
205}
206
207/// Shared advice: `undo --list` combined with `--preview`.
208pub fn undo_mode_conflict() -> HeddleError {
209    HeddleError::recovery(
210        RecoveryDetails::safety_refusal(
211            "undo_mode_conflict",
212            "Use either --list or --preview, not both",
213            "Run `heddle undo --list` to inspect history, or `heddle undo --preview` to preview the next undo.",
214            "--list and --preview are mutually exclusive undo modes",
215            "combining them would make the command output ambiguous between history listing and undo preview",
216            "repository state was left unchanged",
217        )
218        .with_recovery_commands(vec![
219            "heddle undo --list".to_string(),
220            "heddle undo --preview".to_string(),
221        ]),
222    )
223}
224
225/// Shared advice: no undo/redo-eligible batch in the current checkout lane.
226pub fn empty_history_refusal(action: UndoHistoryAction) -> HeddleError {
227    let noun = action.as_str();
228    HeddleError::recovery(
229        RecoveryDetails::safety_refusal(
230            action.empty_kind(),
231            format!("Nothing to {noun}"),
232            "Inspect recent undo history with `heddle undo --list`.",
233            format!("there are no {noun} entries in the current checkout lane"),
234            format!("{noun} would need to move Heddle and Git state, but no eligible batch exists"),
235            "repository state was left unchanged",
236        )
237        .with_recovery_commands(vec!["heddle undo --list".to_string()]),
238    )
239}
240
241/// Summarize one [`OpBatch`] into stable list/preview JSON fields.
242pub fn summarize_batch(batch: &OpBatch) -> UndoBatchSummary {
243    let (undone, partial) = batch_status(batch);
244    let timestamp = batch
245        .entries
246        .iter()
247        .map(|entry| entry.timestamp)
248        .max()
249        .map(format_timestamp)
250        .unwrap_or_else(|| "unknown".to_string());
251
252    UndoBatchSummary {
253        batch_id: batch.id,
254        timestamp,
255        undone,
256        partial,
257        operations: batch
258            .entries
259            .iter()
260            .map(|entry| UndoOperationSummary {
261                id: entry.id,
262                description: entry.operation.description(),
263                timestamp: format_timestamp(entry.timestamp),
264                undone: entry.undone,
265            })
266            .collect(),
267    }
268}
269
270/// `(all_undone, partial)` for a batch — pure status flags for machine output.
271pub fn batch_status(batch: &OpBatch) -> (bool, bool) {
272    let any_undone = batch.entries.iter().any(|entry| entry.undone);
273    let all_undone = batch.entries.iter().all(|entry| entry.undone);
274    (all_undone, any_undone && !all_undone)
275}
276
277fn format_timestamp(timestamp: DateTime<Utc>) -> String {
278    timestamp.format("%Y-%m-%d %H:%M:%S").to_string()
279}
280
281// ---------------------------------------------------------------------------
282// Apply-path pure preflight + step plan
283// ---------------------------------------------------------------------------
284
285/// One entry as the apply engine will visit it (batch order fixed; entry order
286/// depends on undo vs redo).
287#[derive(Debug, Clone, PartialEq, Eq)]
288pub struct UndoApplyStep {
289    pub batch_id: u64,
290    pub entry_id: u64,
291    pub description: String,
292}
293
294/// Pure apply plan after batch selection: step order + stable messages.
295///
296/// Does not encode FS/store preflight outcomes — callers run
297/// [`check_redaction_undo_safe`], [`check_thread_worktree_undo_safe`], etc.
298/// before applying or advertising preview.
299#[derive(Debug, Clone)]
300pub struct UndoApplyPlan {
301    pub action: UndoHistoryAction,
302    pub preview: bool,
303    pub steps_requested: usize,
304    pub batches: Vec<OpBatch>,
305    /// Visit order for the apply engine (undo: reverse entries per batch).
306    pub steps: Vec<UndoApplyStep>,
307    /// Machine-oriented status line (`Would undo 2 batches` / `Undone 1 batch`).
308    pub message: String,
309    /// Human one-liner (`Would undo 2 saved changes` / `Undid 1 saved change`).
310    pub human_message: String,
311}
312
313impl UndoApplyPlan {
314    pub fn batch_summaries(&self) -> Vec<UndoBatchSummary> {
315        self.batches.iter().map(summarize_batch).collect()
316    }
317
318    pub fn batch_count(&self) -> usize {
319        self.batches.len()
320    }
321}
322
323/// Build an apply plan from a selected [`UndoPlan`] (pure; no preflight).
324pub fn plan_undo_apply(plan: UndoPlan, preview: bool) -> UndoApplyPlan {
325    let count = plan.batches.len();
326    let steps = match plan.action {
327        UndoHistoryAction::Undo => plan_undo_apply_steps(&plan.batches),
328        UndoHistoryAction::Redo => plan_redo_apply_steps(&plan.batches),
329    };
330    UndoApplyPlan {
331        action: plan.action,
332        preview,
333        steps_requested: plan.steps_requested,
334        batches: plan.batches,
335        steps,
336        message: machine_undo_redo_message(plan.action, count, preview),
337        human_message: human_undo_redo_message(plan.action, count, preview),
338    }
339}
340
341/// Undo apply order: each batch in selection order, entries reverse within batch.
342pub fn plan_undo_apply_steps(batches: &[OpBatch]) -> Vec<UndoApplyStep> {
343    let mut steps = Vec::new();
344    for batch in batches {
345        for entry in batch.entries.iter().rev() {
346            steps.push(UndoApplyStep {
347                batch_id: batch.id,
348                entry_id: entry.id,
349                description: entry.operation.description(),
350            });
351        }
352    }
353    steps
354}
355
356/// Redo apply order: each batch in selection order, entries forward within batch.
357pub fn plan_redo_apply_steps(batches: &[OpBatch]) -> Vec<UndoApplyStep> {
358    let mut steps = Vec::new();
359    for batch in batches {
360        for entry in &batch.entries {
361            steps.push(UndoApplyStep {
362                batch_id: batch.id,
363                entry_id: entry.id,
364                description: entry.operation.description(),
365            });
366        }
367    }
368    steps
369}
370
371/// Machine JSON `message` field for undo/redo preview or completed payloads.
372pub fn machine_undo_redo_message(action: UndoHistoryAction, count: usize, preview: bool) -> String {
373    let noun = if count == 1 { "batch" } else { "batches" };
374    match (action, preview) {
375        (UndoHistoryAction::Undo, true) => format!("Would undo {count} {noun}"),
376        (UndoHistoryAction::Undo, false) => format!("Undone {count} {noun}"),
377        (UndoHistoryAction::Redo, true) => format!("Would redo {count} {noun}"),
378        (UndoHistoryAction::Redo, false) => format!("Redone {count} {noun}"),
379    }
380}
381
382/// Human text status line for undo/redo preview or completed output.
383pub fn human_undo_redo_message(action: UndoHistoryAction, count: usize, preview: bool) -> String {
384    let noun = if count == 1 {
385        "saved change"
386    } else {
387        "saved changes"
388    };
389    let verb = match (action, preview) {
390        (UndoHistoryAction::Undo, true) => "Would undo",
391        (UndoHistoryAction::Undo, false) => "Undid",
392        (UndoHistoryAction::Redo, true) => "Would redo",
393        (UndoHistoryAction::Redo, false) => "Redid",
394    };
395    format!("{verb} {count} {noun}")
396}
397
398/// A `Purge` op participating in undo redaction safety.
399#[derive(Debug, Clone, PartialEq, Eq)]
400pub struct PurgeOpRef {
401    pub op_id: u64,
402    pub redaction_id: ContentHash,
403}
404
405/// A `Redact` op participating in undo redaction safety.
406#[derive(Debug, Clone, PartialEq, Eq)]
407pub struct RedactOpRef {
408    pub op_id: u64,
409    pub blob: ContentHash,
410    pub state: StateId,
411    pub path: String,
412}
413
414/// Batch-derived redaction facts (no store I/O).
415#[derive(Debug, Clone, PartialEq, Eq, Default)]
416pub struct RedactionUndoBatchFacts {
417    pub purges: Vec<PurgeOpRef>,
418    pub redacts: Vec<RedactOpRef>,
419}
420
421/// Collect purge/redact ops from a planned undo chain (pure scan).
422pub fn collect_redaction_undo_facts(batches: &[OpBatch]) -> RedactionUndoBatchFacts {
423    let mut facts = RedactionUndoBatchFacts::default();
424    for batch in batches {
425        for entry in &batch.entries {
426            match entry.operation.redaction_undo_class() {
427                RedactionUndoClass::Purge { redaction_id } => {
428                    facts.purges.push(PurgeOpRef {
429                        op_id: entry.id,
430                        redaction_id: *redaction_id,
431                    });
432                }
433                RedactionUndoClass::Redact { blob, state, path } => {
434                    facts.redacts.push(RedactOpRef {
435                        op_id: entry.id,
436                        blob: *blob,
437                        state: *state,
438                        path: path.to_string(),
439                    });
440                }
441                RedactionUndoClass::Other => {}
442            }
443        }
444    }
445    facts
446}
447
448/// Pure redaction-undo safety given batch facts + caller-resolved purge status.
449///
450/// Precedence (matches CLI):
451/// 1. Any purge op → refuse (`irreversible_purge_undo`)
452/// 2. Any redact whose bytes are purged → refuse (`redaction_bytes_purged`)
453/// 3. Any remaining redact without `--allow-redact-undo` → refuse
454///    (`redaction_undo_requires_confirmation`)
455pub fn check_redaction_undo_safe(
456    facts: &RedactionUndoBatchFacts,
457    // Op ids of redact entries whose blob bytes have already been purged.
458    purged_redact_op_ids: &[u64],
459    allow_redact_undo: bool,
460) -> Result<(), UndoApplyPreflightError> {
461    if !facts.purges.is_empty() {
462        return Err(UndoApplyPreflightError::IrreversiblePurge {
463            ops: facts.purges.clone(),
464        });
465    }
466    if facts.redacts.is_empty() {
467        return Ok(());
468    }
469    let purged: Vec<RedactOpRef> = facts
470        .redacts
471        .iter()
472        .filter(|r| purged_redact_op_ids.contains(&r.op_id))
473        .cloned()
474        .collect();
475    if !purged.is_empty() {
476        return Err(UndoApplyPreflightError::RedactionBytesPurged { ops: purged });
477    }
478    if !allow_redact_undo {
479        return Err(UndoApplyPreflightError::RedactionUndoRequiresConfirmation {
480            ops: facts.redacts.clone(),
481        });
482    }
483    Ok(())
484}
485
486/// Pure: whether a materialized worktree path still on disk blocks ThreadCreate undo.
487pub fn live_materialized_path_blocks_undo(path_exists: bool) -> bool {
488    path_exists
489}
490
491/// ThreadCreate in the undo chain that can orphan a materialized worktree.
492#[derive(Debug, Clone, PartialEq, Eq)]
493pub struct ThreadWorktreeHazard {
494    pub op_id: u64,
495    pub thread_name: String,
496}
497
498/// Collect ThreadCreate worktree-orphan hazards from batches (pure; no FS).
499pub fn collect_thread_worktree_hazards(batches: &[OpBatch]) -> Vec<ThreadWorktreeHazard> {
500    let mut out = Vec::new();
501    for batch in batches {
502        for entry in &batch.entries {
503            if let Some(name) = entry.operation.thread_worktree_undo_hazard_name() {
504                out.push(ThreadWorktreeHazard {
505                    op_id: entry.id,
506                    thread_name: name.to_string(),
507                });
508            }
509        }
510    }
511    out
512}
513
514/// A hazard whose materialized path still exists (caller-resolved FS fact).
515#[derive(Debug, Clone, PartialEq, Eq)]
516pub struct LiveThreadWorktree {
517    pub op_id: u64,
518    pub thread_name: String,
519    pub path: PathBuf,
520}
521
522/// Pure worktree-orphan preflight: refuse when any live materialized path remains.
523pub fn check_thread_worktree_undo_safe(
524    live: &[LiveThreadWorktree],
525) -> Result<(), UndoApplyPreflightError> {
526    if live.is_empty() {
527        Ok(())
528    } else {
529        Err(UndoApplyPreflightError::ThreadWorktreeUndoUnsafe {
530            live: live.to_vec(),
531        })
532    }
533}
534
535/// State the apply inverse/replay must load, tagged with the owning op id.
536#[derive(Debug, Clone, PartialEq, Eq)]
537pub struct RequiredStateRef {
538    pub op_id: u64,
539    pub state: StateId,
540}
541
542/// Collect states required for undo reachability (pure scan of batches).
543pub fn collect_undo_required_states(batches: &[OpBatch]) -> Vec<RequiredStateRef> {
544    let mut out = Vec::new();
545    for batch in batches {
546        for entry in &batch.entries {
547            for state in entry.operation.states_required_for_undo() {
548                out.push(RequiredStateRef {
549                    op_id: entry.id,
550                    state,
551                });
552            }
553        }
554    }
555    out
556}
557
558/// Collect states required for redo reachability (pure scan of batches).
559pub fn collect_redo_required_states(batches: &[OpBatch]) -> Vec<RequiredStateRef> {
560    let mut out = Vec::new();
561    for batch in batches {
562        for entry in &batch.entries {
563            for state in entry.operation.states_required_for_redo() {
564                out.push(RequiredStateRef {
565                    op_id: entry.id,
566                    state,
567                });
568            }
569        }
570    }
571    out
572}
573
574/// Pure: refuse when caller-resolved missing states are non-empty.
575pub fn check_states_reachable(
576    action: UndoHistoryAction,
577    missing: &[RequiredStateRef],
578) -> Result<(), UndoApplyPreflightError> {
579    if missing.is_empty() {
580        return Ok(());
581    }
582    match action {
583        UndoHistoryAction::Undo => Err(UndoApplyPreflightError::UndoStateMissing {
584            missing: missing.to_vec(),
585        }),
586        UndoHistoryAction::Redo => Err(UndoApplyPreflightError::RedoStateMissing {
587            missing: missing.to_vec(),
588        }),
589    }
590}
591
592/// A redo-unsupported redaction-adjacent op (`Redact` / `Purge`).
593#[derive(Debug, Clone, PartialEq, Eq)]
594pub struct UnsupportedRedoOp {
595    pub op_id: u64,
596    pub label: &'static str,
597}
598
599/// Collect redo-unsupported ops from batches (pure).
600pub fn collect_unsupported_redo_ops(batches: &[OpBatch]) -> Vec<UnsupportedRedoOp> {
601    let mut out = Vec::new();
602    for batch in batches {
603        for entry in &batch.entries {
604            if let Some(label) = entry.operation.redo_unsupported_label() {
605                out.push(UnsupportedRedoOp {
606                    op_id: entry.id,
607                    label,
608                });
609            }
610        }
611    }
612    out
613}
614
615/// Pure redo redaction support preflight.
616pub fn check_redaction_redo_supported(batches: &[OpBatch]) -> Result<(), UndoApplyPreflightError> {
617    let blocking = collect_unsupported_redo_ops(batches);
618    if blocking.is_empty() {
619        Ok(())
620    } else {
621        Err(UndoApplyPreflightError::RedactionRedoUnsupported { ops: blocking })
622    }
623}
624
625/// Typed apply-path preflight refusals (CLI maps to recovery advice).
626#[derive(Debug, Clone, PartialEq, Eq)]
627pub enum UndoApplyPreflightError {
628    IrreversiblePurge { ops: Vec<PurgeOpRef> },
629    RedactionBytesPurged { ops: Vec<RedactOpRef> },
630    RedactionUndoRequiresConfirmation { ops: Vec<RedactOpRef> },
631    RedactionRedoUnsupported { ops: Vec<UnsupportedRedoOp> },
632    ThreadWorktreeUndoUnsafe { live: Vec<LiveThreadWorktree> },
633    UndoStateMissing { missing: Vec<RequiredStateRef> },
634    RedoStateMissing { missing: Vec<RequiredStateRef> },
635}
636
637impl UndoApplyPreflightError {
638    /// Stable recovery-advice `kind` string (matches existing CLI refusals).
639    pub fn kind(&self) -> &'static str {
640        match self {
641            Self::IrreversiblePurge { .. } => "irreversible_purge_undo",
642            Self::RedactionBytesPurged { .. } => "redaction_bytes_purged",
643            Self::RedactionUndoRequiresConfirmation { .. } => {
644                "redaction_undo_requires_confirmation"
645            }
646            Self::RedactionRedoUnsupported { .. } => "redaction_redo_unsupported",
647            Self::ThreadWorktreeUndoUnsafe { .. } => "thread_worktree_undo_unsafe",
648            Self::UndoStateMissing { .. } => "undo_state_missing",
649            Self::RedoStateMissing { .. } => "redo_state_missing",
650        }
651    }
652}
653
654impl std::fmt::Display for UndoApplyPreflightError {
655    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
656        write!(f, "{}", self.kind())
657    }
658}
659
660impl std::error::Error for UndoApplyPreflightError {}
661
662#[cfg(test)]
663mod tests {
664    use std::sync::Arc;
665
666    use objects::object::{ContentHash, StateId};
667    use oplog::OpRecord;
668    use tempfile::TempDir;
669
670    use super::*;
671
672    fn sample_entry(id: u64, undone: bool) -> oplog::OpEntry {
673        use objects::object::Principal;
674        oplog::OpEntry {
675            id,
676            timestamp: Utc::now(),
677            operation: OpRecord::TransactionCommit {
678                transaction_id: format!("t{id}"),
679                op_count: 0,
680            },
681            undone,
682            batch_id: 1,
683            batch_index: id as u32,
684            scope: None,
685            actor: Arc::new(Principal::new("tester", "tester@example.com")),
686            operation_id: None,
687        }
688    }
689
690    #[test]
691    fn list_preview_modes_are_mutually_exclusive() {
692        assert!(validate_undo_list_preview_modes(false, false).is_ok());
693        assert!(validate_undo_list_preview_modes(true, false).is_ok());
694        assert!(validate_undo_list_preview_modes(false, true).is_ok());
695        let err = validate_undo_list_preview_modes(true, true).unwrap_err();
696        match err {
697            HeddleError::Recovery(details) => {
698                assert_eq!(details.kind, "undo_mode_conflict");
699                assert!(details.error.contains("--list") || details.error.contains("preview"));
700            }
701            other => panic!("expected recovery error, got {other:?}"),
702        }
703    }
704
705    #[test]
706    fn empty_history_kinds_match_action() {
707        let undo = empty_history_refusal(UndoHistoryAction::Undo);
708        let redo = empty_history_refusal(UndoHistoryAction::Redo);
709        match undo {
710            HeddleError::Recovery(d) => {
711                assert_eq!(d.kind, "nothing_to_undo");
712                assert!(d.error.contains("Nothing to undo"));
713            }
714            other => panic!("unexpected {other:?}"),
715        }
716        match redo {
717            HeddleError::Recovery(d) => {
718                assert_eq!(d.kind, "nothing_to_redo");
719                assert!(d.error.contains("Nothing to redo"));
720            }
721            other => panic!("unexpected {other:?}"),
722        }
723    }
724
725    #[test]
726    fn batch_status_flags_partial_and_full() {
727        let mixed = OpBatch {
728            id: 7,
729            entries: vec![sample_entry(1, true), sample_entry(2, false)],
730        };
731        assert_eq!(batch_status(&mixed), (false, true));
732
733        let all = OpBatch {
734            id: 8,
735            entries: vec![sample_entry(3, true), sample_entry(4, true)],
736        };
737        assert_eq!(batch_status(&all), (true, false));
738
739        let none = OpBatch {
740            id: 9,
741            entries: vec![sample_entry(5, false)],
742        };
743        assert_eq!(batch_status(&none), (false, false));
744    }
745
746    #[test]
747    fn summarize_batch_preserves_stable_json_field_names() {
748        let batch = OpBatch {
749            id: 42,
750            entries: vec![sample_entry(10, false)],
751        };
752        let summary = summarize_batch(&batch);
753        let value = serde_json::to_value(&summary).unwrap();
754        assert_eq!(value["batch_id"], 42);
755        assert!(value["timestamp"].is_string());
756        assert_eq!(value["undone"], false);
757        assert_eq!(value["partial"], false);
758        assert!(value["operations"].is_array());
759        assert_eq!(value["operations"][0]["id"], 10);
760        assert!(value["operations"][0]["description"].is_string());
761        assert!(value["operations"][0]["timestamp"].is_string());
762        assert_eq!(value["operations"][0]["undone"], false);
763    }
764
765    #[test]
766    fn list_undo_history_empty_repo_returns_empty_batches() {
767        let temp = TempDir::new().unwrap();
768        let repo = Repository::init_default(temp.path()).unwrap();
769        let report = list_undo_history(&repo, 10).unwrap();
770        assert_eq!(report.output_kind, "undo_list");
771        assert!(report.batches.is_empty());
772        let value = serde_json::to_value(&report).unwrap();
773        assert_eq!(value["output_kind"], "undo_list");
774        assert_eq!(value["batches"], serde_json::json!([]));
775    }
776
777    #[test]
778    fn plan_undo_empty_repo_refuses_with_nothing_to_undo() {
779        let temp = TempDir::new().unwrap();
780        let repo = Repository::init_default(temp.path()).unwrap();
781        let err = plan_undo_batches(&repo, 1).unwrap_err();
782        let heddle = err
783            .downcast_ref::<HeddleError>()
784            .expect("domain refusal should be HeddleError");
785        match heddle {
786            HeddleError::Recovery(d) => assert_eq!(d.kind, "nothing_to_undo"),
787            other => panic!("unexpected {other:?}"),
788        }
789    }
790
791    #[test]
792    fn plan_redo_empty_repo_refuses_with_nothing_to_redo() {
793        let temp = TempDir::new().unwrap();
794        let repo = Repository::init_default(temp.path()).unwrap();
795        let err = plan_redo_batches(&repo, 1).unwrap_err();
796        let heddle = err
797            .downcast_ref::<HeddleError>()
798            .expect("domain refusal should be HeddleError");
799        match heddle {
800            HeddleError::Recovery(d) => assert_eq!(d.kind, "nothing_to_redo"),
801            other => panic!("unexpected {other:?}"),
802        }
803    }
804
805    #[test]
806    fn list_and_plan_see_recorded_user_batch() {
807        let temp = TempDir::new().unwrap();
808        let repo = Repository::init_default(temp.path()).unwrap();
809        std::fs::write(temp.path().join("f.txt"), "x").unwrap();
810        let _ = repo
811            .snapshot(Some("s".to_string()), None)
812            .expect("snapshot");
813
814        let list = list_undo_history(&repo, 5).unwrap();
815        assert!(
816            !list.batches.is_empty(),
817            "snapshot should produce listable history"
818        );
819
820        let plan = plan_undo_batches(&repo, 1).unwrap();
821        assert_eq!(plan.action, UndoHistoryAction::Undo);
822        assert_eq!(plan.batches.len(), 1);
823        assert_eq!(plan.batch_summaries().len(), 1);
824
825        let apply = plan_undo_apply(plan, true);
826        assert!(apply.preview);
827        assert_eq!(apply.action, UndoHistoryAction::Undo);
828        assert!(apply.message.starts_with("Would undo"));
829        assert!(apply.human_message.starts_with("Would undo"));
830        assert_eq!(apply.batch_count(), 1);
831        assert!(!apply.steps.is_empty());
832    }
833
834    fn batch_with_entries(id: u64, entry_ids: &[u64]) -> OpBatch {
835        OpBatch {
836            id,
837            entries: entry_ids
838                .iter()
839                .map(|&eid| sample_entry(eid, false))
840                .collect(),
841        }
842    }
843
844    #[test]
845    fn undo_apply_steps_reverse_entries_within_batch() {
846        let batches = vec![batch_with_entries(1, &[10, 11, 12])];
847        let steps = plan_undo_apply_steps(&batches);
848        assert_eq!(
849            steps.iter().map(|s| s.entry_id).collect::<Vec<_>>(),
850            vec![12, 11, 10]
851        );
852        let redo = plan_redo_apply_steps(&batches);
853        assert_eq!(
854            redo.iter().map(|s| s.entry_id).collect::<Vec<_>>(),
855            vec![10, 11, 12]
856        );
857    }
858
859    #[test]
860    fn redaction_undo_preflight_precedence() {
861        let blob = ContentHash::from_bytes([1u8; 32]);
862        let redaction_id = ContentHash::from_bytes([2u8; 32]);
863        let state = StateId::from_bytes([3u8; 32]);
864        let facts = RedactionUndoBatchFacts {
865            purges: vec![PurgeOpRef {
866                op_id: 1,
867                redaction_id,
868            }],
869            redacts: vec![RedactOpRef {
870                op_id: 2,
871                blob,
872                state,
873                path: "secret.txt".into(),
874            }],
875        };
876        // Purge wins even if allow + purged list would also fire.
877        let err = check_redaction_undo_safe(&facts, &[2], true).unwrap_err();
878        assert_eq!(err.kind(), "irreversible_purge_undo");
879
880        let facts_redact_only = RedactionUndoBatchFacts {
881            purges: vec![],
882            redacts: facts.redacts.clone(),
883        };
884        let err = check_redaction_undo_safe(&facts_redact_only, &[2], true).unwrap_err();
885        assert_eq!(err.kind(), "redaction_bytes_purged");
886
887        let err = check_redaction_undo_safe(&facts_redact_only, &[], false).unwrap_err();
888        assert_eq!(err.kind(), "redaction_undo_requires_confirmation");
889
890        assert!(check_redaction_undo_safe(&facts_redact_only, &[], true).is_ok());
891        assert!(check_redaction_undo_safe(&RedactionUndoBatchFacts::default(), &[], false).is_ok());
892    }
893
894    #[test]
895    fn thread_worktree_and_state_reachability_predicates() {
896        assert!(!live_materialized_path_blocks_undo(false));
897        assert!(live_materialized_path_blocks_undo(true));
898
899        assert!(check_thread_worktree_undo_safe(&[]).is_ok());
900        let live = vec![LiveThreadWorktree {
901            op_id: 9,
902            thread_name: "feature/x".into(),
903            path: PathBuf::from("/tmp/wt"),
904        }];
905        let err = check_thread_worktree_undo_safe(&live).unwrap_err();
906        assert_eq!(err.kind(), "thread_worktree_undo_unsafe");
907
908        assert!(check_states_reachable(UndoHistoryAction::Undo, &[]).is_ok());
909        let missing = vec![RequiredStateRef {
910            op_id: 3,
911            state: StateId::from_bytes([4u8; 32]),
912        }];
913        assert_eq!(
914            check_states_reachable(UndoHistoryAction::Undo, &missing)
915                .unwrap_err()
916                .kind(),
917            "undo_state_missing"
918        );
919        assert_eq!(
920            check_states_reachable(UndoHistoryAction::Redo, &missing)
921                .unwrap_err()
922                .kind(),
923            "redo_state_missing"
924        );
925    }
926
927    #[test]
928    fn machine_and_human_messages_match_cli_shapes() {
929        assert_eq!(
930            machine_undo_redo_message(UndoHistoryAction::Undo, 1, true),
931            "Would undo 1 batch"
932        );
933        assert_eq!(
934            machine_undo_redo_message(UndoHistoryAction::Undo, 2, false),
935            "Undone 2 batches"
936        );
937        assert_eq!(
938            human_undo_redo_message(UndoHistoryAction::Redo, 1, true),
939            "Would redo 1 saved change"
940        );
941        assert_eq!(
942            human_undo_redo_message(UndoHistoryAction::Redo, 3, false),
943            "Redid 3 saved changes"
944        );
945    }
946}