Skip to main content

maw/merge/
plan.rs

1//! Merge preview plan types and artifact serialization.
2//!
3//! Design doc §5.12.1 specifies a deterministic merge plan that describes
4//! what a merge *would* do without actually committing. The plan is produced
5//! by running PREPARE → BUILD → VALIDATE and stopping before COMMIT.
6//!
7//! # Merge ID
8//!
9//! The `merge_id` is a stable identifier: `sha256(epoch_before || sorted(sources) || heads || config)`.
10//! Same inputs always produce the same ID, enabling caching and debugging.
11//!
12//! # Artifacts
13//!
14//! All artifacts are written via atomic rename (write-to-temp + fsync + rename) and are:
15//! - Disposable and regenerable (running `--plan` again produces the same output).
16//! - Written to `.manifold/artifacts/merge/<merge_id>/plan.json`.
17//! - Per-workspace reports written to `.manifold/artifacts/ws/<workspace_id>/report.json`.
18
19use std::collections::BTreeMap;
20use std::fs;
21use std::io::Write as _;
22use std::path::{Path, PathBuf};
23
24use serde::{Deserialize, Serialize};
25use sha2::{Digest, Sha256};
26
27use crate::model::types::{EpochId, WorkspaceId};
28
29// ---------------------------------------------------------------------------
30// PredictedConflict
31// ---------------------------------------------------------------------------
32
33/// A predicted merge conflict for a specific path.
34#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
35pub struct PredictedConflict {
36    /// Path relative to the repo root.
37    pub path: PathBuf,
38    /// Conflict kind (e.g., `"Diff3Conflict"`, `"AddAddDifferent"`, `"ModifyDelete"`).
39    pub kind: String,
40    /// The workspace IDs involved in this conflict.
41    pub sides: Vec<String>,
42}
43
44// ---------------------------------------------------------------------------
45// DriverInfo
46// ---------------------------------------------------------------------------
47
48/// Information about a merge driver that applies to a specific path.
49#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
50pub struct DriverInfo {
51    /// Path relative to the repo root.
52    pub path: PathBuf,
53    /// Driver kind: "ours", "theirs", or "regenerate".
54    pub kind: String,
55    /// Command string (only present for "regenerate" drivers).
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub command: Option<String>,
58}
59
60// ---------------------------------------------------------------------------
61// ValidationInfo
62// ---------------------------------------------------------------------------
63
64/// Validation configuration that would be applied during the merge.
65#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
66pub struct ValidationInfo {
67    /// Validation commands in execution order.
68    pub commands: Vec<String>,
69    /// Timeout in seconds per command.
70    pub timeout_seconds: u32,
71    /// On-failure policy: "warn", "block", "quarantine", "block+quarantine".
72    pub policy: String,
73}
74
75// ---------------------------------------------------------------------------
76// WorkspaceChange
77// ---------------------------------------------------------------------------
78
79/// A single file change from a workspace (for per-workspace reports).
80#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
81pub struct WorkspaceChange {
82    /// Path relative to the repo root.
83    pub path: PathBuf,
84    /// Change kind: "added", "modified", or "deleted".
85    pub kind: String,
86}
87
88// ---------------------------------------------------------------------------
89// WorkspaceReport
90// ---------------------------------------------------------------------------
91
92/// Per-workspace change report.
93///
94/// Written to `.manifold/artifacts/ws/<workspace_id>/report.json`.
95#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
96pub struct WorkspaceReport {
97    /// The workspace this report covers.
98    pub workspace_id: String,
99    /// The frozen HEAD commit OID for this workspace.
100    pub head: String,
101    /// All file changes in this workspace.
102    pub changes: Vec<WorkspaceChange>,
103}
104
105// ---------------------------------------------------------------------------
106// MergePlan
107// ---------------------------------------------------------------------------
108
109/// A deterministic, machine-parseable merge plan produced by `maw ws merge --plan`.
110///
111/// The plan describes exactly what a merge *would* do without performing any
112/// commit or ref update. Artifacts are written to `.manifold/artifacts/`.
113///
114/// Schema matches design doc §5.12.1.
115#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
116pub struct MergePlan {
117    /// Stable identifier: `sha256(epoch_before || sorted(sources) || sorted(heads) || config_hash)`.
118    pub merge_id: String,
119
120    /// The epoch commit OID before this merge.
121    pub epoch_before: String,
122
123    /// Source workspace IDs (sorted for determinism).
124    pub sources: Vec<String>,
125
126    /// All paths touched by at least one workspace (sorted).
127    pub touched_paths: Vec<PathBuf>,
128
129    /// Paths touched by two or more workspaces (potential conflicts).
130    pub overlaps: Vec<PathBuf>,
131
132    /// Conflicts predicted by the merge engine.
133    pub predicted_conflicts: Vec<PredictedConflict>,
134
135    /// Merge drivers that apply to touched paths.
136    #[serde(default, skip_serializing_if = "Vec::is_empty")]
137    pub drivers: Vec<DriverInfo>,
138
139    /// Validation configuration (absent if no validation is configured).
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub validation: Option<ValidationInfo>,
142}
143
144// ---------------------------------------------------------------------------
145// merge_id computation
146// ---------------------------------------------------------------------------
147
148/// Compute a deterministic merge ID from inputs.
149///
150/// Algorithm: SHA-256 of `epoch_oid || '\n' || sorted_sources || '\n' || sorted_heads || '\n'`.
151/// Each source is `"<workspace_id>:<head_oid>\n"`. This ensures the same inputs
152/// always produce the same ID regardless of map iteration order.
153#[must_use]
154pub fn compute_merge_id(
155    epoch: &EpochId,
156    sources: &[WorkspaceId],
157    heads: &BTreeMap<WorkspaceId, crate::model::types::GitOid>,
158) -> String {
159    let mut hasher = Sha256::new();
160
161    // Epoch OID
162    hasher.update(epoch.as_str().as_bytes());
163    hasher.update(b"\n");
164
165    // Sources (sort for determinism — BTreeMap already sorted, but sources slice may not be)
166    let mut sorted_sources: Vec<&WorkspaceId> = sources.iter().collect();
167    sorted_sources.sort_by(|a, b| a.as_str().cmp(b.as_str()));
168    for ws in &sorted_sources {
169        hasher.update(ws.as_str().as_bytes());
170        hasher.update(b"\n");
171    }
172    hasher.update(b"---\n");
173
174    // Heads (BTreeMap is already sorted by workspace ID)
175    for (ws, head) in heads {
176        hasher.update(ws.as_str().as_bytes());
177        hasher.update(b":");
178        hasher.update(head.as_str().as_bytes());
179        hasher.update(b"\n");
180    }
181
182    let result = hasher.finalize();
183    // Return lowercase hex string (64 chars)
184    let mut hex = String::with_capacity(64);
185    for b in &result {
186        use std::fmt::Write as _;
187        let _ = write!(hex, "{b:02x}");
188    }
189    hex
190}
191
192// ---------------------------------------------------------------------------
193// Artifact writing
194// ---------------------------------------------------------------------------
195
196/// Error type for plan artifact operations.
197#[derive(Clone, Debug, PartialEq, Eq)]
198pub enum PlanArtifactError {
199    /// I/O error.
200    Io(String),
201    /// Serialization error.
202    Serialize(String),
203}
204
205impl std::fmt::Display for PlanArtifactError {
206    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207        match self {
208            Self::Io(msg) => write!(f, "plan artifact I/O error: {msg}"),
209            Self::Serialize(msg) => write!(f, "plan artifact serialize error: {msg}"),
210        }
211    }
212}
213
214impl std::error::Error for PlanArtifactError {}
215
216/// Write the merge plan to `.manifold/artifacts/merge/<merge_id>/plan.json`.
217///
218/// The write is atomic (write-to-temp + fsync + rename). Returns the path
219/// to the written artifact.
220///
221/// # Errors
222///
223/// Returns [`PlanArtifactError`] on I/O or serialization failure.
224pub fn write_plan_artifact(
225    manifold_dir: &Path,
226    plan: &MergePlan,
227) -> Result<PathBuf, PlanArtifactError> {
228    let artifact_dir = manifold_dir
229        .join("artifacts")
230        .join("merge")
231        .join(&plan.merge_id);
232    write_json_artifact(&artifact_dir, "plan.json", plan)
233}
234
235/// Write a per-workspace report to `.manifold/artifacts/ws/<workspace_id>/report.json`.
236///
237/// # Errors
238///
239/// Returns [`PlanArtifactError`] on I/O or serialization failure.
240pub fn write_workspace_report_artifact(
241    manifold_dir: &Path,
242    report: &WorkspaceReport,
243) -> Result<PathBuf, PlanArtifactError> {
244    let artifact_dir = manifold_dir
245        .join("artifacts")
246        .join("ws")
247        .join(&report.workspace_id);
248    write_json_artifact(&artifact_dir, "report.json", report)
249}
250
251/// Write a JSON value atomically to `<artifact_dir>/<filename>`.
252///
253/// 1. Create `<artifact_dir>` recursively.
254/// 2. Serialize `value` to pretty JSON.
255/// 3. Write to a temp file in the same directory.
256/// 4. fsync + atomic rename.
257fn write_json_artifact<T: Serialize>(
258    artifact_dir: &Path,
259    filename: &str,
260    value: &T,
261) -> Result<PathBuf, PlanArtifactError> {
262    fs::create_dir_all(artifact_dir).map_err(|e| {
263        PlanArtifactError::Io(format!("create dir {}: {e}", artifact_dir.display()))
264    })?;
265
266    let final_path = artifact_dir.join(filename);
267    let tmp_path = artifact_dir.join(format!(".{filename}.tmp"));
268
269    let json = serde_json::to_string_pretty(value)
270        .map_err(|e| PlanArtifactError::Serialize(format!("{e}")))?;
271
272    let mut file = fs::File::create(&tmp_path)
273        .map_err(|e| PlanArtifactError::Io(format!("create {}: {e}", tmp_path.display())))?;
274    file.write_all(json.as_bytes())
275        .map_err(|e| PlanArtifactError::Io(format!("write {}: {e}", tmp_path.display())))?;
276    file.sync_all()
277        .map_err(|e| PlanArtifactError::Io(format!("fsync {}: {e}", tmp_path.display())))?;
278    drop(file);
279
280    fs::rename(&tmp_path, &final_path).map_err(|e| {
281        PlanArtifactError::Io(format!(
282            "rename {} → {}: {e}",
283            tmp_path.display(),
284            final_path.display()
285        ))
286    })?;
287
288    Ok(final_path)
289}
290
291// ---------------------------------------------------------------------------
292// Tests
293// ---------------------------------------------------------------------------
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use crate::model::types::{EpochId, GitOid, WorkspaceId};
299    use std::collections::BTreeMap;
300
301    fn test_epoch() -> EpochId {
302        EpochId::new(&"a".repeat(40)).unwrap()
303    }
304
305    fn test_oid(c: char) -> GitOid {
306        GitOid::new(&c.to_string().repeat(40)).unwrap()
307    }
308
309    fn test_ws(name: &str) -> WorkspaceId {
310        WorkspaceId::new(name).unwrap()
311    }
312
313    // -- merge_id --
314
315    #[test]
316    fn merge_id_is_64_hex_chars() {
317        let epoch = test_epoch();
318        let sources = vec![test_ws("ws-a"), test_ws("ws-b")];
319        let mut heads = BTreeMap::new();
320        heads.insert(test_ws("ws-a"), test_oid('b'));
321        heads.insert(test_ws("ws-b"), test_oid('c'));
322        let id = compute_merge_id(&epoch, &sources, &heads);
323        assert_eq!(id.len(), 64);
324        assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
325    }
326
327    #[test]
328    fn merge_id_is_deterministic() {
329        let epoch = test_epoch();
330        let sources = vec![test_ws("ws-a"), test_ws("ws-b")];
331        let mut heads = BTreeMap::new();
332        heads.insert(test_ws("ws-a"), test_oid('b'));
333        heads.insert(test_ws("ws-b"), test_oid('c'));
334
335        let id1 = compute_merge_id(&epoch, &sources, &heads);
336        let id2 = compute_merge_id(&epoch, &sources, &heads);
337        assert_eq!(id1, id2);
338    }
339
340    #[test]
341    fn merge_id_stable_regardless_of_source_order() {
342        let epoch = test_epoch();
343        let sources_ab = vec![test_ws("ws-a"), test_ws("ws-b")];
344        let sources_ba = vec![test_ws("ws-b"), test_ws("ws-a")];
345        let mut heads = BTreeMap::new();
346        heads.insert(test_ws("ws-a"), test_oid('b'));
347        heads.insert(test_ws("ws-b"), test_oid('c'));
348
349        // Sources are sorted internally, so order doesn't matter
350        let id_ab = compute_merge_id(&epoch, &sources_ab, &heads);
351        let id_ba = compute_merge_id(&epoch, &sources_ba, &heads);
352        assert_eq!(
353            id_ab, id_ba,
354            "merge_id must be stable regardless of source order"
355        );
356    }
357
358    #[test]
359    fn merge_id_changes_with_different_epoch() {
360        let epoch1 = EpochId::new(&"a".repeat(40)).unwrap();
361        let epoch2 = EpochId::new(&"b".repeat(40)).unwrap();
362        let sources = vec![test_ws("ws-a")];
363        let mut heads = BTreeMap::new();
364        heads.insert(test_ws("ws-a"), test_oid('c'));
365
366        let id1 = compute_merge_id(&epoch1, &sources, &heads);
367        let id2 = compute_merge_id(&epoch2, &sources, &heads);
368        assert_ne!(
369            id1, id2,
370            "different epochs must produce different merge_ids"
371        );
372    }
373
374    #[test]
375    fn merge_id_changes_with_different_heads() {
376        let epoch = test_epoch();
377        let sources = vec![test_ws("ws-a")];
378        let mut heads1 = BTreeMap::new();
379        heads1.insert(test_ws("ws-a"), test_oid('b'));
380        let mut heads2 = BTreeMap::new();
381        heads2.insert(test_ws("ws-a"), test_oid('c'));
382
383        let id1 = compute_merge_id(&epoch, &sources, &heads1);
384        let id2 = compute_merge_id(&epoch, &sources, &heads2);
385        assert_ne!(id1, id2, "different heads must produce different merge_ids");
386    }
387
388    // -- MergePlan serde --
389
390    fn make_plan() -> MergePlan {
391        MergePlan {
392            merge_id: "a".repeat(64),
393            epoch_before: "b".repeat(40),
394            sources: vec!["ws-a".to_owned(), "ws-b".to_owned()],
395            touched_paths: vec![PathBuf::from("src/main.rs"), PathBuf::from("README.md")],
396            overlaps: vec![PathBuf::from("README.md")],
397            predicted_conflicts: vec![PredictedConflict {
398                path: PathBuf::from("README.md"),
399                kind: "Diff3Conflict".to_owned(),
400                sides: vec!["ws-a".to_owned(), "ws-b".to_owned()],
401            }],
402            drivers: vec![DriverInfo {
403                path: PathBuf::from("Cargo.lock"),
404                kind: "regenerate".to_owned(),
405                command: Some("cargo generate-lockfile".to_owned()),
406            }],
407            validation: Some(ValidationInfo {
408                commands: vec!["cargo check".to_owned(), "cargo test".to_owned()],
409                timeout_seconds: 60,
410                policy: "block".to_owned(),
411            }),
412        }
413    }
414
415    #[test]
416    fn merge_plan_serde_roundtrip() {
417        let plan = make_plan();
418        let json = serde_json::to_string_pretty(&plan).unwrap();
419        let decoded: MergePlan = serde_json::from_str(&json).unwrap();
420        assert_eq!(decoded, plan);
421    }
422
423    #[test]
424    fn merge_plan_is_pretty_printed() {
425        let plan = make_plan();
426        let json = serde_json::to_string_pretty(&plan).unwrap();
427        assert!(json.contains('\n'));
428        assert!(json.contains("  "));
429    }
430
431    #[test]
432    fn merge_plan_omits_empty_optional_fields() {
433        let plan = MergePlan {
434            merge_id: "a".repeat(64),
435            epoch_before: "b".repeat(40),
436            sources: vec!["ws-a".to_owned()],
437            touched_paths: Vec::new(),
438            overlaps: Vec::new(),
439            predicted_conflicts: Vec::new(),
440            drivers: Vec::new(),
441            validation: None,
442        };
443        let json = serde_json::to_string_pretty(&plan).unwrap();
444        // drivers is skip_serializing_if = "Vec::is_empty"
445        assert!(!json.contains("\"drivers\""));
446        // validation is skip_serializing_if = "Option::is_none"
447        assert!(!json.contains("\"validation\""));
448    }
449
450    #[test]
451    fn validation_info_serde_roundtrip() {
452        let info = ValidationInfo {
453            commands: vec!["cargo check".to_owned()],
454            timeout_seconds: 30,
455            policy: "warn".to_owned(),
456        };
457        let json = serde_json::to_string_pretty(&info).unwrap();
458        let decoded: ValidationInfo = serde_json::from_str(&json).unwrap();
459        assert_eq!(decoded, info);
460    }
461
462    #[test]
463    fn driver_info_no_command_omitted() {
464        let info = DriverInfo {
465            path: PathBuf::from("file.txt"),
466            kind: "ours".to_owned(),
467            command: None,
468        };
469        let json = serde_json::to_string_pretty(&info).unwrap();
470        assert!(!json.contains("command"));
471    }
472
473    // -- Artifact writing --
474
475    #[test]
476    fn write_plan_artifact_creates_file() {
477        let dir = tempfile::tempdir().unwrap();
478        let manifold_dir = dir.path().join(".manifold");
479        let plan = make_plan();
480
481        let path = write_plan_artifact(&manifold_dir, &plan).unwrap();
482        assert!(path.exists());
483        assert_eq!(
484            path,
485            manifold_dir
486                .join("artifacts/merge")
487                .join(&plan.merge_id)
488                .join("plan.json")
489        );
490
491        // Verify contents round-trip
492        let contents = std::fs::read_to_string(&path).unwrap();
493        let decoded: MergePlan = serde_json::from_str(&contents).unwrap();
494        assert_eq!(decoded, plan);
495    }
496
497    #[test]
498    fn write_plan_artifact_is_atomic_no_tmp_left_behind() {
499        let dir = tempfile::tempdir().unwrap();
500        let manifold_dir = dir.path().join(".manifold");
501        let plan = make_plan();
502
503        write_plan_artifact(&manifold_dir, &plan).unwrap();
504
505        let artifact_dir = manifold_dir.join("artifacts/merge").join(&plan.merge_id);
506        assert!(!artifact_dir.join(".plan.json.tmp").exists());
507    }
508
509    #[test]
510    fn write_plan_artifact_overwrites_existing() {
511        let dir = tempfile::tempdir().unwrap();
512        let manifold_dir = dir.path().join(".manifold");
513        let mut plan = make_plan();
514
515        write_plan_artifact(&manifold_dir, &plan).unwrap();
516
517        // Modify and re-write
518        plan.overlaps = vec![PathBuf::from("new.rs")];
519        let path = write_plan_artifact(&manifold_dir, &plan).unwrap();
520
521        let contents = std::fs::read_to_string(&path).unwrap();
522        let decoded: MergePlan = serde_json::from_str(&contents).unwrap();
523        assert_eq!(decoded.overlaps, vec![PathBuf::from("new.rs")]);
524    }
525
526    #[test]
527    fn write_workspace_report_artifact_creates_file() {
528        let dir = tempfile::tempdir().unwrap();
529        let manifold_dir = dir.path().join(".manifold");
530        let report = WorkspaceReport {
531            workspace_id: "agent-1".to_owned(),
532            head: "c".repeat(40),
533            changes: vec![
534                WorkspaceChange {
535                    path: PathBuf::from("src/new.rs"),
536                    kind: "added".to_owned(),
537                },
538                WorkspaceChange {
539                    path: PathBuf::from("README.md"),
540                    kind: "modified".to_owned(),
541                },
542            ],
543        };
544
545        let path = write_workspace_report_artifact(&manifold_dir, &report).unwrap();
546        assert!(path.exists());
547        assert_eq!(path, manifold_dir.join("artifacts/ws/agent-1/report.json"));
548
549        let contents = std::fs::read_to_string(&path).unwrap();
550        let decoded: WorkspaceReport = serde_json::from_str(&contents).unwrap();
551        assert_eq!(decoded, report);
552    }
553
554    #[test]
555    fn error_display() {
556        let e = PlanArtifactError::Io("disk full".into());
557        assert!(format!("{e}").contains("disk full"));
558
559        let e = PlanArtifactError::Serialize("bad type".into());
560        assert!(format!("{e}").contains("bad type"));
561    }
562}