Skip to main content

maw/
merge_state.rs

1//! Merge state machine and persisted merge-state file.
2//!
3//! The merge state is persisted to `.manifold/merge-state.json` as
4//! human-readable JSON. Every write is atomic (write-to-temp + fsync +
5//! rename) so a crash never corrupts the file.
6//!
7//! # Lifecycle
8//!
9//! ```text
10//! Prepare → Build → Validate → Commit → Cleanup → Complete
11//!                                  │
12//!                                  └→ Aborted
13//! ```
14//!
15//! Any phase can also transition to `Aborted` on unrecoverable error.
16
17#![allow(clippy::missing_errors_doc)]
18
19use std::collections::BTreeMap;
20use std::fmt;
21use std::fs;
22use std::io::Write;
23use std::path::{Path, PathBuf};
24
25use serde::{Deserialize, Serialize};
26
27use crate::model::types::{EpochId, GitOid, WorkspaceId};
28
29// ---------------------------------------------------------------------------
30// MergePhase
31// ---------------------------------------------------------------------------
32
33/// The current phase of the merge state machine.
34///
35/// Phases progress strictly forward: `Prepare → Build → Validate → Commit →
36/// Cleanup → Complete`. The `Aborted` state can be entered from any phase
37/// except `Complete`.
38#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum MergePhase {
41    /// Freeze inputs and write merge intent.
42    Prepare,
43    /// Build the merged tree from collected workspace snapshots.
44    Build,
45    /// Run validation commands against the candidate commit.
46    Validate,
47    /// Atomically update refs (point of no return).
48    Commit,
49    /// Post-commit cleanup (remove temp files, update workspace state).
50    Cleanup,
51    /// Merge completed successfully.
52    Complete,
53    /// Merge aborted — may include a reason.
54    Aborted,
55}
56
57impl MergePhase {
58    /// Returns `true` if this is a terminal state (`Complete` or `Aborted`).
59    #[must_use]
60    pub const fn is_terminal(&self) -> bool {
61        matches!(self, Self::Complete | Self::Aborted)
62    }
63
64    /// Returns the set of valid next phases from this phase.
65    ///
66    /// `Aborted` can be reached from any non-terminal phase.
67    #[must_use]
68    pub const fn valid_transitions(&self) -> &'static [Self] {
69        match self {
70            Self::Prepare => &[Self::Build, Self::Aborted],
71            Self::Build => &[Self::Validate, Self::Aborted],
72            Self::Validate => &[Self::Commit, Self::Aborted],
73            Self::Commit => &[Self::Cleanup, Self::Aborted],
74            Self::Cleanup => &[Self::Complete, Self::Aborted],
75            Self::Complete | Self::Aborted => &[],
76        }
77    }
78
79    /// Check whether transitioning to `next` is valid.
80    #[must_use]
81    pub fn can_transition_to(&self, next: &Self) -> bool {
82        self.valid_transitions().contains(next)
83    }
84}
85
86impl fmt::Display for MergePhase {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        match self {
89            Self::Prepare => write!(f, "prepare"),
90            Self::Build => write!(f, "build"),
91            Self::Validate => write!(f, "validate"),
92            Self::Commit => write!(f, "commit"),
93            Self::Cleanup => write!(f, "cleanup"),
94            Self::Complete => write!(f, "complete"),
95            Self::Aborted => write!(f, "aborted"),
96        }
97    }
98}
99
100// ---------------------------------------------------------------------------
101// ValidationResult
102// ---------------------------------------------------------------------------
103
104/// The result of a single validation command execution.
105#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
106pub struct CommandResult {
107    /// The command string that was executed.
108    pub command: String,
109    /// Whether this command passed (exit code 0).
110    pub passed: bool,
111    /// Exit code (`None` if killed by signal/timeout).
112    pub exit_code: Option<i32>,
113    /// Captured stdout.
114    pub stdout: String,
115    /// Captured stderr.
116    pub stderr: String,
117    /// Wall-clock duration in milliseconds.
118    pub duration_ms: u64,
119}
120
121/// The outcome of a post-merge validation run.
122///
123/// When multiple commands are configured, `command_results` contains the
124/// per-command outcomes. The top-level fields (`passed`, `exit_code`, etc.)
125/// summarize the overall result: `passed` is true only if all commands
126/// passed, and `exit_code`/`stdout`/`stderr` reflect the first failing
127/// command (or the last command if all passed).
128#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
129pub struct ValidationResult {
130    /// Whether the validation passed (all commands exited 0).
131    pub passed: bool,
132    /// Exit code of the relevant command (`None` if killed by signal/timeout).
133    /// For multi-command runs, this is the exit code of the first failing
134    /// command, or the exit code of the last command if all passed.
135    pub exit_code: Option<i32>,
136    /// Captured stdout (from the relevant command).
137    pub stdout: String,
138    /// Captured stderr (from the relevant command).
139    pub stderr: String,
140    /// Total wall-clock duration in milliseconds (sum of all commands).
141    pub duration_ms: u64,
142    /// Per-command results (empty for single-command validation, for
143    /// backward compatibility with older merge-state files).
144    #[serde(default, skip_serializing_if = "Vec::is_empty")]
145    pub command_results: Vec<CommandResult>,
146}
147
148// ---------------------------------------------------------------------------
149// MergeStateFile
150// ---------------------------------------------------------------------------
151
152/// The persisted merge-state file.
153///
154/// Written to `.manifold/merge-state.json`. Every mutation is fsynced to
155/// disk so a crash always leaves a valid, recoverable file.
156#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
157pub struct MergeStateFile {
158    /// Current merge phase.
159    pub phase: MergePhase,
160
161    /// Source workspaces being merged.
162    pub sources: Vec<WorkspaceId>,
163
164    /// The epoch before this merge started.
165    pub epoch_before: EpochId,
166
167    /// Frozen workspace HEAD commit OIDs, recorded during PREPARE.
168    ///
169    /// Maps each source workspace to its HEAD at the time inputs were frozen.
170    /// After PREPARE, these are immutable references — the merge operates on
171    /// these exact commits regardless of any concurrent workspace activity.
172    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
173    pub frozen_heads: BTreeMap<WorkspaceId, GitOid>,
174
175    /// The candidate commit produced during BUILD (set in Build phase).
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub epoch_candidate: Option<GitOid>,
178
179    /// The validation result (set in Validate phase).
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub validation_result: Option<ValidationResult>,
182
183    /// The new epoch after COMMIT (set in Commit phase).
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub epoch_after: Option<EpochId>,
186
187    /// Unix timestamp (seconds) when the merge started.
188    pub started_at: u64,
189
190    /// Unix timestamp (seconds) of the last state update.
191    pub updated_at: u64,
192
193    /// Abort reason, if the merge was aborted.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub abort_reason: Option<String>,
196}
197
198impl MergeStateFile {
199    /// Create a new merge-state file in the `Prepare` phase.
200    ///
201    /// # Arguments
202    /// * `sources` - The workspaces being merged.
203    /// * `epoch_before` - The current epoch at the start of the merge.
204    /// * `now` - The current Unix timestamp in seconds.
205    #[must_use]
206    pub const fn new(sources: Vec<WorkspaceId>, epoch_before: EpochId, now: u64) -> Self {
207        Self {
208            phase: MergePhase::Prepare,
209            sources,
210            epoch_before,
211            frozen_heads: BTreeMap::new(),
212            epoch_candidate: None,
213            validation_result: None,
214            epoch_after: None,
215            started_at: now,
216            updated_at: now,
217            abort_reason: None,
218        }
219    }
220
221    /// Advance to the next phase, updating the timestamp.
222    ///
223    /// # Errors
224    /// Returns [`MergeStateError::InvalidTransition`] if the transition is
225    /// not allowed.
226    pub fn advance(&mut self, next: MergePhase, now: u64) -> Result<(), MergeStateError> {
227        if !self.phase.can_transition_to(&next) {
228            return Err(MergeStateError::InvalidTransition {
229                from: self.phase.clone(),
230                to: next,
231            });
232        }
233        self.phase = next;
234        self.updated_at = now;
235        Ok(())
236    }
237
238    /// Abort the merge with a reason.
239    ///
240    /// # Errors
241    /// Returns [`MergeStateError::InvalidTransition`] if the merge is
242    /// already in a terminal state.
243    pub fn abort(&mut self, reason: impl Into<String>, now: u64) -> Result<(), MergeStateError> {
244        if self.phase.is_terminal() {
245            return Err(MergeStateError::InvalidTransition {
246                from: self.phase.clone(),
247                to: MergePhase::Aborted,
248            });
249        }
250        self.phase = MergePhase::Aborted;
251        self.abort_reason = Some(reason.into());
252        self.updated_at = now;
253        Ok(())
254    }
255
256    /// Serialize to pretty-printed JSON.
257    ///
258    /// # Errors
259    /// Returns [`MergeStateError::Serialize`] on serialization failure.
260    pub fn to_json(&self) -> Result<String, MergeStateError> {
261        serde_json::to_string_pretty(self).map_err(|e| MergeStateError::Serialize(e.to_string()))
262    }
263
264    /// Deserialize from a JSON string.
265    ///
266    /// # Errors
267    /// Returns [`MergeStateError::Deserialize`] on parse failure.
268    pub fn from_json(json: &str) -> Result<Self, MergeStateError> {
269        serde_json::from_str(json).map_err(|e| MergeStateError::Deserialize(e.to_string()))
270    }
271
272    /// Create the merge-state file exclusively (O_CREAT | O_EXCL).
273    ///
274    /// Uses `OpenOptions::create_new(true)` so exactly one writer wins.
275    /// Returns `Ok(true)` on success, `Ok(false)` if the file already exists,
276    /// and `Err` on any other I/O error.
277    ///
278    /// The write is crash-safe: data is serialized, written, and fsynced
279    /// directly to the target path. Unlike `write_atomic`, there is no
280    /// temp+rename dance because the `O_EXCL` flag already guarantees
281    /// the file did not exist.
282    pub fn write_exclusive(&self, path: &Path) -> Result<bool, MergeStateError> {
283        use std::fs::OpenOptions;
284
285        let json = self.to_json()?;
286
287        match OpenOptions::new()
288            .write(true)
289            .create_new(true)
290            .open(path)
291        {
292            Ok(mut file) => {
293                file.write_all(json.as_bytes()).map_err(|e| {
294                    MergeStateError::Io(format!("write {}: {e}", path.display()))
295                })?;
296                file.sync_all().map_err(|e| {
297                    MergeStateError::Io(format!("fsync {}: {e}", path.display()))
298                })?;
299                Ok(true)
300            }
301            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
302            Err(e) => Err(MergeStateError::Io(format!(
303                "create_new {}: {e}",
304                path.display()
305            ))),
306        }
307    }
308
309    /// Write the merge-state file atomically with fsync.
310    ///
311    /// 1. Serialize to pretty JSON.
312    /// 2. Write to a temporary file in the same directory.
313    /// 3. fsync the temporary file.
314    /// 4. Rename (atomic on POSIX) over the target path.
315    ///
316    /// # Errors
317    /// Returns [`MergeStateError`] on I/O or serialization failure.
318    pub fn write_atomic(&self, path: &Path) -> Result<(), MergeStateError> {
319        let json = self.to_json()?;
320
321        let dir = path.parent().ok_or_else(|| {
322            MergeStateError::Io(format!("no parent directory for {}", path.display()))
323        })?;
324
325        // Write to a temporary file in the same directory (ensures same filesystem)
326        let tmp_path = dir.join(".merge-state.tmp");
327        let mut file = fs::File::create(&tmp_path)
328            .map_err(|e| MergeStateError::Io(format!("create {}: {e}", tmp_path.display())))?;
329        file.write_all(json.as_bytes())
330            .map_err(|e| MergeStateError::Io(format!("write {}: {e}", tmp_path.display())))?;
331        file.sync_all()
332            .map_err(|e| MergeStateError::Io(format!("fsync {}: {e}", tmp_path.display())))?;
333        drop(file);
334
335        // Atomic rename
336        fs::rename(&tmp_path, path).map_err(|e| {
337            MergeStateError::Io(format!(
338                "rename {} → {}: {e}",
339                tmp_path.display(),
340                path.display()
341            ))
342        })?;
343
344        Ok(())
345    }
346
347    /// Read a merge-state file from disk.
348    ///
349    /// # Errors
350    /// Returns [`MergeStateError::NotFound`] if the file does not exist.
351    /// Returns [`MergeStateError::Deserialize`] if the file is malformed.
352    pub fn read(path: &Path) -> Result<Self, MergeStateError> {
353        let contents = fs::read_to_string(path).map_err(|e| {
354            if e.kind() == std::io::ErrorKind::NotFound {
355                MergeStateError::NotFound(path.to_owned())
356            } else {
357                MergeStateError::Io(format!("read {}: {e}", path.display()))
358            }
359        })?;
360        Self::from_json(&contents)
361    }
362
363    /// Return the default merge-state file path for a `.manifold/` directory.
364    #[must_use]
365    pub fn default_path(manifold_dir: &Path) -> PathBuf {
366        manifold_dir.join("merge-state.json")
367    }
368}
369
370// ---------------------------------------------------------------------------
371// Cleanup + recovery helpers (bd-1lpe.6)
372// ---------------------------------------------------------------------------
373
374/// Outcome of crash-recovery dispatch for an interrupted merge.
375#[derive(Clone, Debug, PartialEq, Eq)]
376pub enum RecoveryOutcome {
377    /// No merge-state file was found.
378    NoMergeInProgress,
379    /// PREPARE/BUILD were safely aborted by deleting merge-state.
380    AbortedPreCommit { from: MergePhase },
381    /// VALIDATE should be re-run with frozen inputs.
382    RetryValidate,
383    /// COMMIT must inspect refs to decide finalize-vs-abort.
384    CheckCommit,
385    /// CLEANUP should be re-run (idempotent).
386    RetryCleanup,
387    /// Merge is already terminal; no recovery work needed.
388    Terminal { phase: MergePhase },
389}
390
391/// Determine recovery behavior from persisted merge-state.
392#[must_use]
393pub fn recovery_outcome_for_phase(phase: &MergePhase) -> RecoveryOutcome {
394    match phase {
395        MergePhase::Prepare | MergePhase::Build => RecoveryOutcome::AbortedPreCommit {
396            from: phase.clone(),
397        },
398        MergePhase::Validate => RecoveryOutcome::RetryValidate,
399        MergePhase::Commit => RecoveryOutcome::CheckCommit,
400        MergePhase::Cleanup => RecoveryOutcome::RetryCleanup,
401        MergePhase::Complete | MergePhase::Aborted => RecoveryOutcome::Terminal {
402            phase: phase.clone(),
403        },
404    }
405}
406
407/// Execute crash-recovery dispatch from a merge-state file.
408///
409/// Behavior matches design doc §5.10:
410/// - PREPARE/BUILD: abort by removing merge-state
411/// - VALIDATE: re-run validation
412/// - COMMIT: check refs externally to decide finalize vs abort
413/// - CLEANUP: re-run cleanup
414pub fn recover_from_merge_state(
415    merge_state_path: &Path,
416) -> Result<RecoveryOutcome, MergeStateError> {
417    let state = match MergeStateFile::read(merge_state_path) {
418        Ok(s) => s,
419        Err(MergeStateError::NotFound(_)) => return Ok(RecoveryOutcome::NoMergeInProgress),
420        Err(e) => return Err(e),
421    };
422
423    let outcome = recovery_outcome_for_phase(&state.phase);
424    if matches!(
425        outcome,
426        RecoveryOutcome::AbortedPreCommit { .. } | RecoveryOutcome::RetryCleanup
427    ) {
428        // Safe and idempotent for PREPARE/BUILD abort and post-commit cleanup completion.
429        remove_merge_state_if_exists(merge_state_path)?;
430    }
431
432    Ok(outcome)
433}
434
435/// Cleanup phase helper:
436/// - optionally destroys source workspaces via callback
437/// - removes merge-state file
438///
439/// The operation is idempotent. Re-running it is safe.
440pub fn run_cleanup_phase<D>(
441    state: &MergeStateFile,
442    merge_state_path: &Path,
443    destroy_workspaces: bool,
444    mut destroy_workspace: D,
445) -> Result<(), MergeStateError>
446where
447    D: FnMut(&WorkspaceId) -> Result<(), MergeStateError>,
448{
449    if destroy_workspaces {
450        for workspace in &state.sources {
451            destroy_workspace(workspace)?;
452        }
453    }
454
455    remove_merge_state_if_exists(merge_state_path)
456}
457
458fn remove_merge_state_if_exists(path: &Path) -> Result<(), MergeStateError> {
459    match fs::remove_file(path) {
460        Ok(()) => Ok(()),
461        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
462        Err(e) => Err(MergeStateError::Io(format!(
463            "remove {}: {e}",
464            path.display()
465        ))),
466    }
467}
468
469// ---------------------------------------------------------------------------
470// Error type
471// ---------------------------------------------------------------------------
472
473/// Errors related to merge-state operations.
474#[derive(Clone, Debug, PartialEq, Eq)]
475pub enum MergeStateError {
476    /// Invalid phase transition.
477    InvalidTransition {
478        /// The current phase.
479        from: MergePhase,
480        /// The attempted target phase.
481        to: MergePhase,
482    },
483    /// The merge-state file was not found.
484    NotFound(PathBuf),
485    /// Serialization error.
486    Serialize(String),
487    /// Deserialization error.
488    Deserialize(String),
489    /// I/O error (not "not found").
490    Io(String),
491}
492
493impl fmt::Display for MergeStateError {
494    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
495        match self {
496            Self::InvalidTransition { from, to } => {
497                write!(f, "invalid merge phase transition: {from} → {to}")
498            }
499            Self::NotFound(path) => {
500                write!(f, "merge-state file not found: {}", path.display())
501            }
502            Self::Serialize(msg) => write!(f, "merge-state serialize error: {msg}"),
503            Self::Deserialize(msg) => write!(f, "merge-state deserialize error: {msg}"),
504            Self::Io(msg) => write!(f, "merge-state I/O error: {msg}"),
505        }
506    }
507}
508
509impl std::error::Error for MergeStateError {}
510
511// ---------------------------------------------------------------------------
512// Tests
513// ---------------------------------------------------------------------------
514
515#[cfg(test)]
516#[allow(clippy::all, clippy::pedantic, clippy::nursery)]
517mod tests {
518    use super::*;
519
520    fn test_epoch() -> EpochId {
521        EpochId::new(&"a".repeat(40)).unwrap()
522    }
523
524    fn test_oid() -> GitOid {
525        GitOid::new(&"b".repeat(40)).unwrap()
526    }
527
528    fn test_sources() -> Vec<WorkspaceId> {
529        vec![
530            WorkspaceId::new("agent-1").unwrap(),
531            WorkspaceId::new("agent-2").unwrap(),
532        ]
533    }
534
535    // -- MergePhase --
536
537    #[test]
538    fn phase_display() {
539        assert_eq!(MergePhase::Prepare.to_string(), "prepare");
540        assert_eq!(MergePhase::Build.to_string(), "build");
541        assert_eq!(MergePhase::Validate.to_string(), "validate");
542        assert_eq!(MergePhase::Commit.to_string(), "commit");
543        assert_eq!(MergePhase::Cleanup.to_string(), "cleanup");
544        assert_eq!(MergePhase::Complete.to_string(), "complete");
545        assert_eq!(MergePhase::Aborted.to_string(), "aborted");
546    }
547
548    #[test]
549    fn phase_is_terminal() {
550        assert!(!MergePhase::Prepare.is_terminal());
551        assert!(!MergePhase::Build.is_terminal());
552        assert!(!MergePhase::Validate.is_terminal());
553        assert!(!MergePhase::Commit.is_terminal());
554        assert!(!MergePhase::Cleanup.is_terminal());
555        assert!(MergePhase::Complete.is_terminal());
556        assert!(MergePhase::Aborted.is_terminal());
557    }
558
559    #[test]
560    fn phase_valid_transitions() {
561        // Happy path
562        assert!(MergePhase::Prepare.can_transition_to(&MergePhase::Build));
563        assert!(MergePhase::Build.can_transition_to(&MergePhase::Validate));
564        assert!(MergePhase::Validate.can_transition_to(&MergePhase::Commit));
565        assert!(MergePhase::Commit.can_transition_to(&MergePhase::Cleanup));
566        assert!(MergePhase::Cleanup.can_transition_to(&MergePhase::Complete));
567
568        // Abort from any non-terminal
569        assert!(MergePhase::Prepare.can_transition_to(&MergePhase::Aborted));
570        assert!(MergePhase::Build.can_transition_to(&MergePhase::Aborted));
571        assert!(MergePhase::Validate.can_transition_to(&MergePhase::Aborted));
572        assert!(MergePhase::Commit.can_transition_to(&MergePhase::Aborted));
573        assert!(MergePhase::Cleanup.can_transition_to(&MergePhase::Aborted));
574    }
575
576    #[test]
577    fn phase_invalid_transitions() {
578        // Can't skip phases
579        assert!(!MergePhase::Prepare.can_transition_to(&MergePhase::Validate));
580        assert!(!MergePhase::Prepare.can_transition_to(&MergePhase::Complete));
581        assert!(!MergePhase::Build.can_transition_to(&MergePhase::Commit));
582
583        // Can't go backwards
584        assert!(!MergePhase::Build.can_transition_to(&MergePhase::Prepare));
585        assert!(!MergePhase::Complete.can_transition_to(&MergePhase::Cleanup));
586
587        // Terminal states go nowhere
588        assert!(!MergePhase::Complete.can_transition_to(&MergePhase::Aborted));
589        assert!(!MergePhase::Aborted.can_transition_to(&MergePhase::Prepare));
590    }
591
592    #[test]
593    fn phase_serde_roundtrip() {
594        let phases = vec![
595            MergePhase::Prepare,
596            MergePhase::Build,
597            MergePhase::Validate,
598            MergePhase::Commit,
599            MergePhase::Cleanup,
600            MergePhase::Complete,
601            MergePhase::Aborted,
602        ];
603        for phase in phases {
604            let json = serde_json::to_string(&phase).unwrap();
605            let decoded: MergePhase = serde_json::from_str(&json).unwrap();
606            assert_eq!(decoded, phase, "roundtrip failed for {phase}");
607        }
608    }
609
610    #[test]
611    fn phase_serde_snake_case() {
612        let json = serde_json::to_string(&MergePhase::Prepare).unwrap();
613        assert_eq!(json, "\"prepare\"");
614    }
615
616    // -- MergeStateFile --
617
618    #[test]
619    fn new_state_is_prepare() {
620        let state = MergeStateFile::new(test_sources(), test_epoch(), 1000);
621        assert_eq!(state.phase, MergePhase::Prepare);
622        assert_eq!(state.sources.len(), 2);
623        assert_eq!(state.started_at, 1000);
624        assert_eq!(state.updated_at, 1000);
625        assert!(state.epoch_candidate.is_none());
626        assert!(state.validation_result.is_none());
627        assert!(state.epoch_after.is_none());
628        assert!(state.abort_reason.is_none());
629    }
630
631    #[test]
632    fn advance_happy_path() {
633        let mut state = MergeStateFile::new(test_sources(), test_epoch(), 1000);
634
635        state.advance(MergePhase::Build, 1001).unwrap();
636        assert_eq!(state.phase, MergePhase::Build);
637        assert_eq!(state.updated_at, 1001);
638
639        state.advance(MergePhase::Validate, 1002).unwrap();
640        assert_eq!(state.phase, MergePhase::Validate);
641
642        state.advance(MergePhase::Commit, 1003).unwrap();
643        assert_eq!(state.phase, MergePhase::Commit);
644
645        state.advance(MergePhase::Cleanup, 1004).unwrap();
646        assert_eq!(state.phase, MergePhase::Cleanup);
647
648        state.advance(MergePhase::Complete, 1005).unwrap();
649        assert_eq!(state.phase, MergePhase::Complete);
650        assert_eq!(state.updated_at, 1005);
651    }
652
653    #[test]
654    fn advance_invalid_transition() {
655        let mut state = MergeStateFile::new(test_sources(), test_epoch(), 1000);
656        let err = state.advance(MergePhase::Validate, 1001).unwrap_err();
657        assert!(matches!(err, MergeStateError::InvalidTransition { .. }));
658        // Phase should not change on error
659        assert_eq!(state.phase, MergePhase::Prepare);
660    }
661
662    #[test]
663    fn advance_from_terminal_fails() {
664        let mut state = MergeStateFile::new(test_sources(), test_epoch(), 1000);
665        state.advance(MergePhase::Build, 1001).unwrap();
666        state.advance(MergePhase::Validate, 1002).unwrap();
667        state.advance(MergePhase::Commit, 1003).unwrap();
668        state.advance(MergePhase::Cleanup, 1004).unwrap();
669        state.advance(MergePhase::Complete, 1005).unwrap();
670
671        let err = state.advance(MergePhase::Aborted, 1006).unwrap_err();
672        assert!(matches!(err, MergeStateError::InvalidTransition { .. }));
673    }
674
675    #[test]
676    fn abort_from_prepare() {
677        let mut state = MergeStateFile::new(test_sources(), test_epoch(), 1000);
678        state.abort("test abort", 1001).unwrap();
679        assert_eq!(state.phase, MergePhase::Aborted);
680        assert_eq!(state.abort_reason.as_deref(), Some("test abort"));
681        assert_eq!(state.updated_at, 1001);
682    }
683
684    #[test]
685    fn abort_from_build() {
686        let mut state = MergeStateFile::new(test_sources(), test_epoch(), 1000);
687        state.advance(MergePhase::Build, 1001).unwrap();
688        state.abort("build failed", 1002).unwrap();
689        assert_eq!(state.phase, MergePhase::Aborted);
690        assert_eq!(state.abort_reason.as_deref(), Some("build failed"));
691    }
692
693    #[test]
694    fn abort_from_terminal_fails() {
695        let mut state = MergeStateFile::new(test_sources(), test_epoch(), 1000);
696        state.abort("first abort", 1001).unwrap();
697        let err = state.abort("double abort", 1002).unwrap_err();
698        assert!(matches!(err, MergeStateError::InvalidTransition { .. }));
699    }
700
701    // -- JSON serialization --
702
703    #[test]
704    fn json_roundtrip_prepare() {
705        let state = MergeStateFile::new(test_sources(), test_epoch(), 1000);
706        let json = state.to_json().unwrap();
707        let decoded = MergeStateFile::from_json(&json).unwrap();
708        assert_eq!(decoded, state);
709    }
710
711    #[test]
712    fn json_roundtrip_with_optional_fields() {
713        let mut state = MergeStateFile::new(test_sources(), test_epoch(), 1000);
714        state.advance(MergePhase::Build, 1001).unwrap();
715        state.epoch_candidate = Some(test_oid());
716        state.advance(MergePhase::Validate, 1002).unwrap();
717        state.validation_result = Some(ValidationResult {
718            passed: true,
719            exit_code: Some(0),
720            stdout: "ok".to_owned(),
721            stderr: String::new(),
722            duration_ms: 1500,
723            command_results: Vec::new(),
724        });
725        state.advance(MergePhase::Commit, 1003).unwrap();
726        state.epoch_after = Some(EpochId::new(&"c".repeat(40)).unwrap());
727
728        let json = state.to_json().unwrap();
729        let decoded = MergeStateFile::from_json(&json).unwrap();
730        assert_eq!(decoded, state);
731    }
732
733    #[test]
734    fn json_is_pretty_printed() {
735        let state = MergeStateFile::new(test_sources(), test_epoch(), 1000);
736        let json = state.to_json().unwrap();
737        // Pretty-printed JSON has newlines
738        assert!(json.contains('\n'));
739        // Contains indentation
740        assert!(json.contains("  "));
741    }
742
743    #[test]
744    fn json_omits_none_fields() {
745        let state = MergeStateFile::new(test_sources(), test_epoch(), 1000);
746        let json = state.to_json().unwrap();
747        assert!(!json.contains("epoch_candidate"));
748        assert!(!json.contains("validation_result"));
749        assert!(!json.contains("epoch_after"));
750        assert!(!json.contains("abort_reason"));
751    }
752
753    #[test]
754    fn json_includes_some_fields() {
755        let mut state = MergeStateFile::new(test_sources(), test_epoch(), 1000);
756        state.advance(MergePhase::Build, 1001).unwrap();
757        state.epoch_candidate = Some(test_oid());
758        let json = state.to_json().unwrap();
759        assert!(json.contains("epoch_candidate"));
760        assert!(json.contains(&"b".repeat(40)));
761    }
762
763    #[test]
764    fn json_deserialize_invalid() {
765        let err = MergeStateFile::from_json("not json").unwrap_err();
766        assert!(matches!(err, MergeStateError::Deserialize(_)));
767    }
768
769    // -- Atomic file I/O --
770
771    #[test]
772    fn write_and_read_roundtrip() {
773        let dir = tempfile::tempdir().unwrap();
774        let path = MergeStateFile::default_path(dir.path());
775
776        let state = MergeStateFile::new(test_sources(), test_epoch(), 1000);
777        state.write_atomic(&path).unwrap();
778
779        let loaded = MergeStateFile::read(&path).unwrap();
780        assert_eq!(loaded, state);
781    }
782
783    #[test]
784    fn write_overwrite_preserves_atomicity() {
785        let dir = tempfile::tempdir().unwrap();
786        let path = MergeStateFile::default_path(dir.path());
787
788        // Write initial state
789        let state1 = MergeStateFile::new(test_sources(), test_epoch(), 1000);
790        state1.write_atomic(&path).unwrap();
791
792        // Overwrite with advanced state
793        let mut state2 = MergeStateFile::new(test_sources(), test_epoch(), 2000);
794        state2.advance(MergePhase::Build, 2001).unwrap();
795        state2.epoch_candidate = Some(test_oid());
796        state2.write_atomic(&path).unwrap();
797
798        // Read should return state2
799        let loaded = MergeStateFile::read(&path).unwrap();
800        assert_eq!(loaded, state2);
801    }
802
803    #[test]
804    fn read_not_found() {
805        let path = PathBuf::from("/tmp/nonexistent-merge-state-test.json");
806        let err = MergeStateFile::read(&path).unwrap_err();
807        assert!(matches!(err, MergeStateError::NotFound(_)));
808    }
809
810    #[test]
811    fn read_corrupt_file() {
812        let dir = tempfile::tempdir().unwrap();
813        let path = dir.path().join("merge-state.json");
814        fs::write(&path, "corrupted data").unwrap();
815        let err = MergeStateFile::read(&path).unwrap_err();
816        assert!(matches!(err, MergeStateError::Deserialize(_)));
817    }
818
819    #[test]
820    fn default_path() {
821        let path = MergeStateFile::default_path(Path::new("/repo/.manifold"));
822        assert_eq!(path, PathBuf::from("/repo/.manifold/merge-state.json"));
823    }
824
825    #[test]
826    fn tmp_file_cleaned_up_after_write() {
827        let dir = tempfile::tempdir().unwrap();
828        let path = MergeStateFile::default_path(dir.path());
829
830        let state = MergeStateFile::new(test_sources(), test_epoch(), 1000);
831        state.write_atomic(&path).unwrap();
832
833        // Temp file should be gone after successful rename
834        assert!(!dir.path().join(".merge-state.tmp").exists());
835    }
836
837    // -- Validation result --
838
839    #[test]
840    fn validation_result_serde() {
841        let result = ValidationResult {
842            passed: false,
843            exit_code: Some(1),
844            stdout: "running tests...\n".to_owned(),
845            stderr: "FAILED: test_foo\n".to_owned(),
846            duration_ms: 5432,
847            command_results: Vec::new(),
848        };
849        let json = serde_json::to_string_pretty(&result).unwrap();
850        let decoded: ValidationResult = serde_json::from_str(&json).unwrap();
851        assert_eq!(decoded, result);
852    }
853
854    #[test]
855    fn validation_result_timeout() {
856        let result = ValidationResult {
857            passed: false,
858            exit_code: None,
859            stdout: String::new(),
860            stderr: "killed by timeout".to_owned(),
861            duration_ms: 60000,
862            command_results: Vec::new(),
863        };
864        let json = serde_json::to_string_pretty(&result).unwrap();
865        let decoded: ValidationResult = serde_json::from_str(&json).unwrap();
866        assert_eq!(decoded, result);
867        assert!(decoded.exit_code.is_none());
868    }
869
870    #[test]
871    fn validation_result_with_command_results_serde() {
872        let result = ValidationResult {
873            passed: false,
874            exit_code: Some(1),
875            stdout: String::new(),
876            stderr: "cargo test failed".to_owned(),
877            duration_ms: 5000,
878            command_results: vec![
879                CommandResult {
880                    command: "cargo check".to_owned(),
881                    passed: true,
882                    exit_code: Some(0),
883                    stdout: "ok\n".to_owned(),
884                    stderr: String::new(),
885                    duration_ms: 2000,
886                },
887                CommandResult {
888                    command: "cargo test".to_owned(),
889                    passed: false,
890                    exit_code: Some(1),
891                    stdout: String::new(),
892                    stderr: "cargo test failed\n".to_owned(),
893                    duration_ms: 3000,
894                },
895            ],
896        };
897        let json = serde_json::to_string_pretty(&result).unwrap();
898        assert!(json.contains("command_results"));
899        let decoded: ValidationResult = serde_json::from_str(&json).unwrap();
900        assert_eq!(decoded.command_results.len(), 2);
901        assert_eq!(decoded.command_results[0].command, "cargo check");
902        assert!(decoded.command_results[0].passed);
903        assert_eq!(decoded.command_results[1].command, "cargo test");
904        assert!(!decoded.command_results[1].passed);
905    }
906
907    #[test]
908    fn validation_result_backward_compat_no_command_results() {
909        // Old merge-state files don't have command_results — should deserialize fine
910        let json = r#"{
911            "passed": true,
912            "exit_code": 0,
913            "stdout": "ok",
914            "stderr": "",
915            "duration_ms": 100
916        }"#;
917        let decoded: ValidationResult = serde_json::from_str(json).unwrap();
918        assert!(decoded.passed);
919        assert!(decoded.command_results.is_empty());
920    }
921
922    // -- Cleanup + recovery helpers --
923
924    #[test]
925    fn cleanup_phase_destroys_sources_and_removes_merge_state() {
926        let dir = tempfile::tempdir().unwrap();
927        let path = MergeStateFile::default_path(dir.path());
928
929        let state = MergeStateFile::new(test_sources(), test_epoch(), 1000);
930        state.write_atomic(&path).unwrap();
931        assert!(path.exists());
932
933        let mut destroyed = Vec::new();
934        run_cleanup_phase(&state, &path, true, |ws| {
935            destroyed.push(ws.as_str().to_owned());
936            Ok(())
937        })
938        .unwrap();
939
940        assert_eq!(destroyed, vec!["agent-1".to_owned(), "agent-2".to_owned()]);
941        assert!(!path.exists());
942    }
943
944    #[test]
945    fn cleanup_phase_is_idempotent() {
946        let dir = tempfile::tempdir().unwrap();
947        let path = MergeStateFile::default_path(dir.path());
948
949        let state = MergeStateFile::new(test_sources(), test_epoch(), 1000);
950        state.write_atomic(&path).unwrap();
951
952        run_cleanup_phase(&state, &path, false, |_ws| Ok(())).unwrap();
953        run_cleanup_phase(&state, &path, false, |_ws| Ok(())).unwrap();
954
955        assert!(!path.exists());
956    }
957
958    fn state_in_phase(phase: MergePhase) -> MergeStateFile {
959        let mut state = MergeStateFile::new(test_sources(), test_epoch(), 1000);
960        match phase {
961            MergePhase::Prepare => {}
962            MergePhase::Build => {
963                state.advance(MergePhase::Build, 1001).unwrap();
964            }
965            MergePhase::Validate => {
966                state.advance(MergePhase::Build, 1001).unwrap();
967                state.advance(MergePhase::Validate, 1002).unwrap();
968            }
969            MergePhase::Commit => {
970                state.advance(MergePhase::Build, 1001).unwrap();
971                state.advance(MergePhase::Validate, 1002).unwrap();
972                state.advance(MergePhase::Commit, 1003).unwrap();
973            }
974            MergePhase::Cleanup => {
975                state.advance(MergePhase::Build, 1001).unwrap();
976                state.advance(MergePhase::Validate, 1002).unwrap();
977                state.advance(MergePhase::Commit, 1003).unwrap();
978                state.advance(MergePhase::Cleanup, 1004).unwrap();
979            }
980            MergePhase::Complete => {
981                state.advance(MergePhase::Build, 1001).unwrap();
982                state.advance(MergePhase::Validate, 1002).unwrap();
983                state.advance(MergePhase::Commit, 1003).unwrap();
984                state.advance(MergePhase::Cleanup, 1004).unwrap();
985                state.advance(MergePhase::Complete, 1005).unwrap();
986            }
987            MergePhase::Aborted => {
988                state.abort("aborted for test", 1001).unwrap();
989            }
990        }
991        state
992    }
993
994    #[test]
995    fn recovery_no_merge_state_returns_no_merge_in_progress() {
996        let dir = tempfile::tempdir().unwrap();
997        let path = MergeStateFile::default_path(dir.path());
998
999        let outcome = recover_from_merge_state(&path).unwrap();
1000        assert_eq!(outcome, RecoveryOutcome::NoMergeInProgress);
1001    }
1002
1003    #[test]
1004    fn recovery_prepare_aborts_and_deletes_state_file() {
1005        let dir = tempfile::tempdir().unwrap();
1006        let path = MergeStateFile::default_path(dir.path());
1007
1008        let state = MergeStateFile::new(test_sources(), test_epoch(), 1000);
1009        state.write_atomic(&path).unwrap();
1010
1011        let outcome = recover_from_merge_state(&path).unwrap();
1012        assert_eq!(
1013            outcome,
1014            RecoveryOutcome::AbortedPreCommit {
1015                from: MergePhase::Prepare
1016            }
1017        );
1018        assert!(!path.exists());
1019    }
1020
1021    #[test]
1022    fn recovery_build_aborts_and_deletes_state_file() {
1023        let dir = tempfile::tempdir().unwrap();
1024        let path = MergeStateFile::default_path(dir.path());
1025
1026        let state = state_in_phase(MergePhase::Build);
1027        state.write_atomic(&path).unwrap();
1028
1029        let outcome = recover_from_merge_state(&path).unwrap();
1030        assert_eq!(
1031            outcome,
1032            RecoveryOutcome::AbortedPreCommit {
1033                from: MergePhase::Build
1034            }
1035        );
1036        assert!(!path.exists());
1037    }
1038
1039    #[test]
1040    fn recovery_commit_requests_ref_check_and_keeps_state_file() {
1041        let dir = tempfile::tempdir().unwrap();
1042        let path = MergeStateFile::default_path(dir.path());
1043
1044        let state = state_in_phase(MergePhase::Commit);
1045        state.write_atomic(&path).unwrap();
1046
1047        let outcome = recover_from_merge_state(&path).unwrap();
1048        assert_eq!(outcome, RecoveryOutcome::CheckCommit);
1049        assert!(path.exists());
1050    }
1051
1052    #[test]
1053    fn recovery_validate_requests_rerun_and_keeps_state_file() {
1054        let dir = tempfile::tempdir().unwrap();
1055        let path = MergeStateFile::default_path(dir.path());
1056
1057        let mut state = MergeStateFile::new(test_sources(), test_epoch(), 1000);
1058        state.advance(MergePhase::Build, 1001).unwrap();
1059        state.advance(MergePhase::Validate, 1002).unwrap();
1060        state.write_atomic(&path).unwrap();
1061
1062        let outcome = recover_from_merge_state(&path).unwrap();
1063        assert_eq!(outcome, RecoveryOutcome::RetryValidate);
1064        assert!(path.exists());
1065    }
1066
1067    #[test]
1068    fn recovery_cleanup_requests_rerun_and_deletes_state_file() {
1069        let dir = tempfile::tempdir().unwrap();
1070        let path = MergeStateFile::default_path(dir.path());
1071
1072        let mut state = MergeStateFile::new(test_sources(), test_epoch(), 1000);
1073        state.advance(MergePhase::Build, 1001).unwrap();
1074        state.advance(MergePhase::Validate, 1002).unwrap();
1075        state.advance(MergePhase::Commit, 1003).unwrap();
1076        state.advance(MergePhase::Cleanup, 1004).unwrap();
1077        state.write_atomic(&path).unwrap();
1078
1079        let outcome = recover_from_merge_state(&path).unwrap();
1080        assert_eq!(outcome, RecoveryOutcome::RetryCleanup);
1081        assert!(!path.exists());
1082    }
1083
1084    #[test]
1085    fn recovery_precommit_abort_preserves_workspace_files() {
1086        let dir = tempfile::tempdir().unwrap();
1087        let workspace_file = dir.path().join("ws").join("agent-1").join("keep.txt");
1088        fs::create_dir_all(workspace_file.parent().unwrap()).unwrap();
1089        fs::write(&workspace_file, "important work\n").unwrap();
1090
1091        let path = MergeStateFile::default_path(dir.path());
1092        let state = state_in_phase(MergePhase::Build);
1093        state.write_atomic(&path).unwrap();
1094
1095        let outcome = recover_from_merge_state(&path).unwrap();
1096        assert!(matches!(
1097            outcome,
1098            RecoveryOutcome::AbortedPreCommit {
1099                from: MergePhase::Build
1100            }
1101        ));
1102        assert_eq!(
1103            fs::read_to_string(&workspace_file).unwrap(),
1104            "important work\n"
1105        );
1106    }
1107
1108    #[test]
1109    fn recovery_dispatch_is_repeatable_across_phases() {
1110        for _ in 0..3 {
1111            let scenarios = vec![
1112                (MergePhase::Prepare, true),
1113                (MergePhase::Build, true),
1114                (MergePhase::Validate, false),
1115                (MergePhase::Commit, false),
1116                (MergePhase::Cleanup, true),
1117            ];
1118
1119            for (phase, should_delete_state_file) in scenarios {
1120                let dir = tempfile::tempdir().unwrap();
1121                let path = MergeStateFile::default_path(dir.path());
1122                let state = state_in_phase(phase.clone());
1123                state.write_atomic(&path).unwrap();
1124
1125                let first = recover_from_merge_state(&path).unwrap();
1126                let second = recover_from_merge_state(&path).unwrap();
1127
1128                match phase {
1129                    MergePhase::Prepare | MergePhase::Build => {
1130                        assert!(matches!(first, RecoveryOutcome::AbortedPreCommit { .. }));
1131                    }
1132                    MergePhase::Validate => assert_eq!(first, RecoveryOutcome::RetryValidate),
1133                    MergePhase::Commit => assert_eq!(first, RecoveryOutcome::CheckCommit),
1134                    MergePhase::Cleanup => assert_eq!(first, RecoveryOutcome::RetryCleanup),
1135                    MergePhase::Complete | MergePhase::Aborted => unreachable!(),
1136                }
1137
1138                if should_delete_state_file {
1139                    assert_eq!(second, RecoveryOutcome::NoMergeInProgress);
1140                } else {
1141                    assert_eq!(second, first);
1142                }
1143            }
1144        }
1145    }
1146
1147    // -- Error display --
1148
1149    #[test]
1150    fn error_display_invalid_transition() {
1151        let err = MergeStateError::InvalidTransition {
1152            from: MergePhase::Prepare,
1153            to: MergePhase::Complete,
1154        };
1155        let msg = format!("{err}");
1156        assert!(msg.contains("prepare"));
1157        assert!(msg.contains("complete"));
1158    }
1159
1160    #[test]
1161    fn error_display_not_found() {
1162        let err = MergeStateError::NotFound(PathBuf::from("/foo/bar"));
1163        let msg = format!("{err}");
1164        assert!(msg.contains("/foo/bar"));
1165    }
1166
1167    // -- Full lifecycle --
1168
1169    #[test]
1170    fn full_lifecycle_persist_each_phase() {
1171        let dir = tempfile::tempdir().unwrap();
1172        let path = MergeStateFile::default_path(dir.path());
1173
1174        // Prepare
1175        let mut state = MergeStateFile::new(test_sources(), test_epoch(), 1000);
1176        state.write_atomic(&path).unwrap();
1177
1178        // Build
1179        state.advance(MergePhase::Build, 1001).unwrap();
1180        state.epoch_candidate = Some(test_oid());
1181        state.write_atomic(&path).unwrap();
1182        let loaded = MergeStateFile::read(&path).unwrap();
1183        assert_eq!(loaded.phase, MergePhase::Build);
1184        assert!(loaded.epoch_candidate.is_some());
1185
1186        // Validate
1187        state.advance(MergePhase::Validate, 1002).unwrap();
1188        state.validation_result = Some(ValidationResult {
1189            passed: true,
1190            exit_code: Some(0),
1191            stdout: "all tests passed".to_owned(),
1192            stderr: String::new(),
1193            duration_ms: 850,
1194            command_results: Vec::new(),
1195        });
1196        state.write_atomic(&path).unwrap();
1197
1198        // Commit
1199        state.advance(MergePhase::Commit, 1003).unwrap();
1200        state.epoch_after = Some(EpochId::new(&"c".repeat(40)).unwrap());
1201        state.write_atomic(&path).unwrap();
1202
1203        // Cleanup
1204        state.advance(MergePhase::Cleanup, 1004).unwrap();
1205        state.write_atomic(&path).unwrap();
1206
1207        // Complete
1208        state.advance(MergePhase::Complete, 1005).unwrap();
1209        state.write_atomic(&path).unwrap();
1210
1211        // Final read
1212        let final_state = MergeStateFile::read(&path).unwrap();
1213        assert_eq!(final_state.phase, MergePhase::Complete);
1214        assert!(final_state.epoch_candidate.is_some());
1215        assert!(final_state.validation_result.is_some());
1216        assert!(final_state.epoch_after.is_some());
1217        assert_eq!(final_state.started_at, 1000);
1218        assert_eq!(final_state.updated_at, 1005);
1219    }
1220}