1use 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
35pub struct PredictedConflict {
36 pub path: PathBuf,
38 pub kind: String,
40 pub sides: Vec<String>,
42}
43
44#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
50pub struct DriverInfo {
51 pub path: PathBuf,
53 pub kind: String,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub command: Option<String>,
58}
59
60#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
66pub struct ValidationInfo {
67 pub commands: Vec<String>,
69 pub timeout_seconds: u32,
71 pub policy: String,
73}
74
75#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
81pub struct WorkspaceChange {
82 pub path: PathBuf,
84 pub kind: String,
86}
87
88#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
96pub struct WorkspaceReport {
97 pub workspace_id: String,
99 pub head: String,
101 pub changes: Vec<WorkspaceChange>,
103}
104
105#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
116pub struct MergePlan {
117 pub merge_id: String,
119
120 pub epoch_before: String,
122
123 pub sources: Vec<String>,
125
126 pub touched_paths: Vec<PathBuf>,
128
129 pub overlaps: Vec<PathBuf>,
131
132 pub predicted_conflicts: Vec<PredictedConflict>,
134
135 #[serde(default, skip_serializing_if = "Vec::is_empty")]
137 pub drivers: Vec<DriverInfo>,
138
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub validation: Option<ValidationInfo>,
142}
143
144#[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 hasher.update(epoch.as_str().as_bytes());
163 hasher.update(b"\n");
164
165 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 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 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#[derive(Clone, Debug, PartialEq, Eq)]
198pub enum PlanArtifactError {
199 Io(String),
201 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
216pub 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
235pub 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
251fn 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#[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 #[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 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 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 assert!(!json.contains("\"drivers\""));
446 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 #[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 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 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}