1#![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#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum MergePhase {
41 Prepare,
43 Build,
45 Validate,
47 Commit,
49 Cleanup,
51 Complete,
53 Aborted,
55}
56
57impl MergePhase {
58 #[must_use]
60 pub const fn is_terminal(&self) -> bool {
61 matches!(self, Self::Complete | Self::Aborted)
62 }
63
64 #[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 #[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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
106pub struct CommandResult {
107 pub command: String,
109 pub passed: bool,
111 pub exit_code: Option<i32>,
113 pub stdout: String,
115 pub stderr: String,
117 pub duration_ms: u64,
119}
120
121#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
129pub struct ValidationResult {
130 pub passed: bool,
132 pub exit_code: Option<i32>,
136 pub stdout: String,
138 pub stderr: String,
140 pub duration_ms: u64,
142 #[serde(default, skip_serializing_if = "Vec::is_empty")]
145 pub command_results: Vec<CommandResult>,
146}
147
148#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
157pub struct MergeStateFile {
158 pub phase: MergePhase,
160
161 pub sources: Vec<WorkspaceId>,
163
164 pub epoch_before: EpochId,
166
167 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
173 pub frozen_heads: BTreeMap<WorkspaceId, GitOid>,
174
175 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub epoch_candidate: Option<GitOid>,
178
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub validation_result: Option<ValidationResult>,
182
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub epoch_after: Option<EpochId>,
186
187 pub started_at: u64,
189
190 pub updated_at: u64,
192
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub abort_reason: Option<String>,
196}
197
198impl MergeStateFile {
199 #[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 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 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 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 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 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 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 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 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 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 #[must_use]
365 pub fn default_path(manifold_dir: &Path) -> PathBuf {
366 manifold_dir.join("merge-state.json")
367 }
368}
369
370#[derive(Clone, Debug, PartialEq, Eq)]
376pub enum RecoveryOutcome {
377 NoMergeInProgress,
379 AbortedPreCommit { from: MergePhase },
381 RetryValidate,
383 CheckCommit,
385 RetryCleanup,
387 Terminal { phase: MergePhase },
389}
390
391#[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
407pub 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 remove_merge_state_if_exists(merge_state_path)?;
430 }
431
432 Ok(outcome)
433}
434
435pub 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#[derive(Clone, Debug, PartialEq, Eq)]
475pub enum MergeStateError {
476 InvalidTransition {
478 from: MergePhase,
480 to: MergePhase,
482 },
483 NotFound(PathBuf),
485 Serialize(String),
487 Deserialize(String),
489 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#[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 #[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 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 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 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 assert!(!MergePhase::Build.can_transition_to(&MergePhase::Prepare));
585 assert!(!MergePhase::Complete.can_transition_to(&MergePhase::Cleanup));
586
587 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 #[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 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 #[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 assert!(json.contains('\n'));
739 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 #[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 let state1 = MergeStateFile::new(test_sources(), test_epoch(), 1000);
790 state1.write_atomic(&path).unwrap();
791
792 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 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 assert!(!dir.path().join(".merge-state.tmp").exists());
835 }
836
837 #[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 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 #[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 #[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 #[test]
1170 fn full_lifecycle_persist_each_phase() {
1171 let dir = tempfile::tempdir().unwrap();
1172 let path = MergeStateFile::default_path(dir.path());
1173
1174 let mut state = MergeStateFile::new(test_sources(), test_epoch(), 1000);
1176 state.write_atomic(&path).unwrap();
1177
1178 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 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 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 state.advance(MergePhase::Cleanup, 1004).unwrap();
1205 state.write_atomic(&path).unwrap();
1206
1207 state.advance(MergePhase::Complete, 1005).unwrap();
1209 state.write_atomic(&path).unwrap();
1210
1211 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}