1use std::path::Path;
26
27use crate::entity::id::file_path_to_id;
28use crate::entity::{EntityId, loader, parser, source::EntitySource};
29use crate::provenance::{Provenance, ProvenanceKind};
30use crate::vcs::{Actor, ClientId, CommitContext};
31use crate::workspace::{MountCapability, MountStorage};
32
33use super::{Engine, EngineError};
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum ConflictSide {
38 Ours,
39 Theirs,
40}
41
42impl ConflictSide {
43 pub fn from_wire(s: &str) -> Option<Self> {
47 match s {
48 "ours" => Some(Self::Ours),
49 "theirs" => Some(Self::Theirs),
50 _ => None,
51 }
52 }
53
54 pub fn as_wire(self) -> &'static str {
56 match self {
57 Self::Ours => "ours",
58 Self::Theirs => "theirs",
59 }
60 }
61}
62
63#[derive(Debug, Clone, serde::Serialize)]
65pub struct ConflictedEntity {
66 pub id: EntityId,
69 pub mem: String,
70 pub file_path: String,
72}
73
74#[derive(Debug, Clone, serde::Serialize)]
76pub struct ResolveConflictOutcome {
77 pub id: EntityId,
78 pub side: &'static str,
80 pub commit_sha: String,
81}
82
83pub fn extract_conflict_side(content: &str, side: ConflictSide) -> Result<String, String> {
91 #[derive(PartialEq)]
92 enum State {
93 Normal,
94 Ours,
95 Base,
96 Theirs,
97 }
98 let mut state = State::Normal;
99 let mut out: Vec<&str> = Vec::new();
100 for (n, line) in content.lines().enumerate() {
101 match state {
102 State::Normal => {
103 if line.starts_with("<<<<<<< ") {
104 state = State::Ours;
105 } else {
106 out.push(line);
107 }
108 }
109 State::Ours => {
110 if line.starts_with("|||||||") {
111 state = State::Base;
112 } else if line.trim_end() == "=======" {
113 state = State::Theirs;
114 } else if line.starts_with(">>>>>>> ") {
115 return Err(format!(
116 "line {}: end marker before `=======` separator",
117 n + 1
118 ));
119 } else if side == ConflictSide::Ours {
120 out.push(line);
121 }
122 }
123 State::Base => {
124 if line.trim_end() == "=======" {
125 state = State::Theirs;
126 }
127 }
129 State::Theirs => {
130 if line.starts_with(">>>>>>> ") {
131 state = State::Normal;
132 } else if side == ConflictSide::Theirs {
133 out.push(line);
134 }
135 }
136 }
137 }
138 if state != State::Normal {
139 return Err("unterminated conflict block (no `>>>>>>> ` end marker)".to_string());
140 }
141 let mut resolved = out.join("\n");
142 if content.ends_with('\n') && !resolved.ends_with('\n') {
143 resolved.push('\n');
144 }
145 Ok(resolved)
146}
147
148impl Engine {
149 fn folder_mount(&self, mem: &str) -> Result<(usize, std::path::PathBuf), EngineError> {
154 let mount_idx = self
155 .mounts
156 .iter()
157 .position(|m| m.mount.mem == mem)
158 .ok_or_else(|| self.unknown_mem_error(mem))?;
159 if self.quarantine_reason(mem).is_some() {
160 return Err(self.unknown_mem_error(mem));
161 }
162 if self.mounts[mount_idx].mount.capability != MountCapability::Write {
163 return Err(EngineError::ReadOnlyMount(mem.to_string()));
164 }
165 match &self.mounts[mount_idx].mount.storage {
166 MountStorage::Folder { path } => Ok((mount_idx, path.clone())),
167 _ => Err(EngineError::MergeConflictUnsupportedBackend {
168 mem: mem.to_string(),
169 }),
170 }
171 }
172
173 pub fn list_merge_conflicts(
181 &self,
182 mem: Option<&str>,
183 ) -> Result<Vec<ConflictedEntity>, EngineError> {
184 let targets: Vec<(String, std::path::PathBuf)> = match mem {
185 Some(name) => {
186 let (_, root) = self.folder_mount(name)?;
187 vec![(name.to_string(), root)]
188 }
189 None => self
190 .mounts
191 .iter()
192 .filter(|m| m.mount.capability == MountCapability::Write)
193 .filter_map(|m| match &m.mount.storage {
194 MountStorage::Folder { path } => Some((m.mount.mem.clone(), path.clone())),
195 _ => None,
196 })
197 .collect(),
198 };
199 let mut out = Vec::new();
200 for (mem_name, root) in targets {
201 let (entries, _read_errors) = EntitySource::Directory { root }
202 .read_all()
203 .map_err(|e| EngineError::InvalidInput(format!("read mem directory: {e}")))?;
204 for entry in entries {
205 if parser::has_merge_conflict_markers(&entry.content) {
206 out.push(ConflictedEntity {
207 id: file_path_to_id(&entry.relative_path, &mem_name),
208 mem: mem_name.clone(),
209 file_path: entry.relative_path,
210 });
211 }
212 }
213 }
214 out.sort_by(|a, b| a.id.0.cmp(&b.id.0));
215 Ok(out)
216 }
217
218 pub fn resolve_merge_conflict(
228 &mut self,
229 id: &EntityId,
230 side: ConflictSide,
231 actor: Actor,
232 client: Option<&ClientId>,
233 note: Option<&str>,
234 ) -> Result<ResolveConflictOutcome, EngineError> {
235 let mem = id.mem().to_string();
236 let (mount_idx, root) = self.folder_mount(&mem)?;
237
238 let (entries, _read_errors) = EntitySource::Directory { root }
242 .read_all()
243 .map_err(|e| EngineError::InvalidInput(format!("read mem directory: {e}")))?;
244 let Some(entry) = entries
245 .into_iter()
246 .find(|e| file_path_to_id(&e.relative_path, &mem) == *id)
247 else {
248 return Err(EngineError::NotFound { id: id.to_string() });
249 };
250 if !parser::has_merge_conflict_markers(&entry.content) {
251 return Err(EngineError::NotConflicted { id: id.to_string() });
252 }
253
254 let resolved = extract_conflict_side(&entry.content, side).map_err(|m| {
255 EngineError::InvalidInput(format!(
256 "malformed conflict markers in {}: {m}",
257 entry.relative_path
258 ))
259 })?;
260
261 if parser::has_merge_conflict_markers(&resolved) {
276 return Err(EngineError::InvalidInput(format!(
277 "the {} side of {} still carries conflict markers (nested conflict) — \
278 refusing to write it; resolve the other side or repair upstream first",
279 side.as_wire(),
280 entry.relative_path
281 )));
282 }
283 let schema = self
284 .schemas
285 .get(&mem)
286 .cloned()
287 .ok_or_else(|| self.unknown_mem_error(&mem))?;
288 let resolved_type = loader::resolve_type_for_entry(&schema, &resolved);
289 let parsed = parser::parse_markdown(
290 &resolved,
291 &entry.relative_path,
292 resolved_type.as_ref(),
293 &mem,
294 )?;
295 crate::runtime_validator::validate_section_keys(
296 parsed.entity.sections.keys().map(String::as_str),
297 resolved_type.as_ref(),
298 )?;
299 crate::runtime_validator::validate_section_content(
300 parsed
301 .entity
302 .sections
303 .iter()
304 .map(|(k, v)| (k.as_str(), v.as_str())),
305 )?;
306
307 let backend = self.mounts[mount_idx].backend.as_ref();
308 backend.write_entity(Path::new(&entry.relative_path), resolved.as_bytes())?;
309 let ctx = CommitContext {
310 actor,
311 client: client.cloned(),
312 tool: Some("resolve_conflict"),
313 note: note.map(String::from),
314 role: self.current_role,
315 logical_operation_id: None,
316 entity_ids: None,
317 };
318 let commit_sha = backend.commit(
319 &format!("memstead: resolve-conflict {id} (side: {})", side.as_wire()),
320 &ctx,
321 )?;
322 backend.append_provenance(
323 &Provenance::new(
324 std::time::SystemTime::now(),
325 ProvenanceKind::Update,
326 Some(id.to_string()),
327 actor,
328 client.cloned(),
329 note.map(String::from),
330 )
331 .with_role(self.current_role),
332 )?;
333 self.record_self_write(mount_idx, &commit_sha);
334 self.stamp_mutation_versions(mount_idx);
335
336 self.reload_each_writable_mem()?;
340
341 Ok(ResolveConflictOutcome {
342 id: id.clone(),
343 side: side.as_wire(),
344 commit_sha,
345 })
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 const CONFLICTED: &str = "---\ntype: spec\n---\n# Torn\n\n## Identity\n\n\
354<<<<<<< HEAD\nours line\n||||||| base\nbase line\n=======\ntheirs line\n\
355>>>>>>> feature\n\n## Purpose\n\nshared tail\n";
356
357 fn folder_workspace(files: &[(&str, &str)]) -> (tempfile::TempDir, Engine) {
360 use crate::workspace::{Mount, MountLifecycle};
361 use crate::workspace_store::WorkspaceStoreAdapter;
362
363 let tmp = tempfile::TempDir::new().unwrap();
364 let mem_dir = tmp.path().join("specs");
365 std::fs::create_dir_all(&mem_dir).unwrap();
366 for (name, content) in files {
367 std::fs::write(mem_dir.join(name), content).unwrap();
368 }
369 let memstead = tmp.path().join(".memstead");
370 std::fs::create_dir_all(&memstead).unwrap();
371 std::fs::write(
372 memstead.join("workspace.toml"),
373 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
374 )
375 .unwrap();
376 let mount = Mount {
377 mem: "specs".to_string(),
378 schema: Some(memstead_schema::SchemaRef::new(
379 "default",
380 semver::Version::new(1, 0, 0),
381 )),
382 storage: MountStorage::Folder { path: mem_dir },
383 capability: MountCapability::Write,
384 lifecycle: MountLifecycle::Eager,
385 cross_linkable: true,
386 migration_target: None,
387 };
388 crate::FileWorkspaceStore::new()
389 .save_state(
390 tmp.path(),
391 &crate::workspace::Workspace {
392 mounts: vec![mount],
393 settings: crate::workspace::WorkspaceSettings::default(),
394 },
395 )
396 .unwrap();
397 let engine = Engine::from_workspace_root(tmp.path()).expect("workspace boots");
398 (tmp, engine)
399 }
400
401 const CLEAN: &str = "---\ntype: spec\n---\n# Fine\n\n## Identity\n\nis\n\n## Purpose\n\nok\n";
402
403 #[test]
410 fn conflicted_folder_entity_lists_resolves_and_reads_clean() {
411 let (tmp, mut engine) = folder_workspace(&[("torn.md", CONFLICTED), ("fine.md", CLEAN)]);
412
413 let errors = engine.load_errors();
415 assert_eq!(errors.len(), 1, "exactly the conflicted file refuses");
416 assert!(
417 errors[0].1.contains("memstead conflicts resolve"),
418 "load error names the resolve operation: {}",
419 errors[0].1
420 );
421
422 let listed = engine.list_merge_conflicts(None).unwrap();
424 assert_eq!(listed.len(), 1);
425 assert_eq!(listed[0].id.as_ref(), "specs--torn");
426 assert_eq!(listed[0].file_path, "torn.md");
427
428 let id = EntityId("specs--torn".into());
430 let outcome = engine
431 .resolve_merge_conflict(
432 &id,
433 ConflictSide::Theirs,
434 Actor::Cli,
435 None,
436 Some("keeping upstream wording"),
437 )
438 .expect("resolution succeeds");
439 assert_eq!(outcome.side, "theirs");
440
441 assert!(
443 engine.load_errors().is_empty(),
444 "{:?}",
445 engine.load_errors()
446 );
447 let entity = engine.get_entity(&id).expect("resolved entity is loaded");
448 assert!(!entity.stub);
449 assert!(
450 entity
451 .sections
452 .get("identity")
453 .unwrap()
454 .contains("theirs line"),
455 "the kept side's content is live: {:?}",
456 entity.sections.get("identity")
457 );
458 let on_disk = std::fs::read_to_string(tmp.path().join("specs").join("torn.md")).unwrap();
459 assert!(!on_disk.contains("<<<<<<<") && !on_disk.contains("ours line"));
460
461 let ledger = std::fs::read_to_string(
464 tmp.path()
465 .join("specs")
466 .join(".memstead")
467 .join("changes.jsonl"),
468 )
469 .expect("folder provenance ledger exists");
470 assert!(
471 ledger.contains("specs--torn") && ledger.contains("keeping upstream wording"),
472 "ledger records the resolution with its note: {ledger}"
473 );
474
475 let err = engine
478 .resolve_merge_conflict(&id, ConflictSide::Ours, Actor::Cli, None, None)
479 .unwrap_err();
480 assert_eq!(err.code(), "NOT_CONFLICTED");
481 let err = engine
482 .resolve_merge_conflict(
483 &EntityId("specs--absent".into()),
484 ConflictSide::Ours,
485 Actor::Cli,
486 None,
487 None,
488 )
489 .unwrap_err();
490 assert_eq!(err.code(), "ENTITY_NOT_FOUND");
491 }
492
493 #[test]
497 fn fenced_marker_example_is_not_a_conflict() {
498 let doc = "---\ntype: spec\n---\n# Git Lore\n\n## Identity\n\n\
499```text\n<<<<<<< HEAD\nexample\n=======\nexample\n>>>>>>> branch\n```\n\n\
500## Purpose\n\nteaching\n";
501 let (_tmp, engine) = folder_workspace(&[("lore.md", doc)]);
502 assert!(
503 engine.load_errors().is_empty(),
504 "{:?}",
505 engine.load_errors()
506 );
507 assert!(engine.list_merge_conflicts(None).unwrap().is_empty());
508 assert!(engine.get_entity(&EntityId("specs--lore".into())).is_some());
509 }
510
511 #[test]
518 fn invalid_chosen_side_refuses_and_writes_nothing() {
519 let nested = "---\ntype: spec\n---\n# Nested\n\n## Identity\n\n\
520<<<<<<< HEAD\nours\n=======\n<<<<<<< inner\ntheirs-a\n=======\ntheirs-b\n\
521>>>>>>> inner\n>>>>>>> outer\n\n## Purpose\n\np\n";
522 let (tmp, mut engine) = folder_workspace(&[("nested.md", nested)]);
523
524 let id = EntityId("specs--nested".into());
525 let err = engine
526 .resolve_merge_conflict(&id, ConflictSide::Theirs, Actor::Cli, None, None)
527 .unwrap_err();
528 assert_eq!(err.code(), "INVALID_INPUT", "got: {err}");
529 assert!(
530 err.to_string().contains("still carries conflict markers"),
531 "refusal names the residue: {err}"
532 );
533 let on_disk = std::fs::read_to_string(tmp.path().join("specs").join("nested.md")).unwrap();
535 assert!(
536 on_disk.contains("<<<<<<< HEAD"),
537 "file untouched on refusal"
538 );
539
540 engine
542 .resolve_merge_conflict(&id, ConflictSide::Ours, Actor::Cli, None, None)
543 .expect("clean side resolves");
544 assert!(engine.load_errors().is_empty());
545 }
546
547 #[test]
548 fn extract_sides_and_diff3_base_drops() {
549 let ours = extract_conflict_side(CONFLICTED, ConflictSide::Ours).unwrap();
550 assert!(ours.contains("ours line"));
551 assert!(!ours.contains("theirs line") && !ours.contains("base line"));
552 assert!(ours.contains("shared tail"));
553 let theirs = extract_conflict_side(CONFLICTED, ConflictSide::Theirs).unwrap();
554 assert!(theirs.contains("theirs line"));
555 assert!(!theirs.contains("ours line") && !theirs.contains("base line"));
556 assert!(!theirs.contains("<<<<<<<") && !theirs.contains(">>>>>>>"));
557 }
558
559 #[test]
560 fn malformed_markers_refuse() {
561 let unterminated = "a\n<<<<<<< HEAD\nours\n=======\ntheirs\n";
562 assert!(extract_conflict_side(unterminated, ConflictSide::Ours).is_err());
563 let inverted = "a\n<<<<<<< HEAD\nours\n>>>>>>> feature\n";
564 assert!(extract_conflict_side(inverted, ConflictSide::Ours).is_err());
565 }
566}