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 write_id: String,
81 #[serde(default, skip_serializing_if = "Vec::is_empty")]
85 pub warnings: Vec<crate::ops::WarningHint>,
86}
87
88pub fn extract_conflict_side(content: &str, side: ConflictSide) -> Result<String, String> {
96 #[derive(PartialEq)]
97 enum State {
98 Normal,
99 Ours,
100 Base,
101 Theirs,
102 }
103 let mut state = State::Normal;
104 let mut out: Vec<&str> = Vec::new();
105 for (n, line) in content.lines().enumerate() {
106 match state {
107 State::Normal => {
108 if line.starts_with("<<<<<<< ") {
109 state = State::Ours;
110 } else {
111 out.push(line);
112 }
113 }
114 State::Ours => {
115 if line.starts_with("|||||||") {
116 state = State::Base;
117 } else if line.trim_end() == "=======" {
118 state = State::Theirs;
119 } else if line.starts_with(">>>>>>> ") {
120 return Err(format!(
121 "line {}: end marker before `=======` separator",
122 n + 1
123 ));
124 } else if side == ConflictSide::Ours {
125 out.push(line);
126 }
127 }
128 State::Base => {
129 if line.trim_end() == "=======" {
130 state = State::Theirs;
131 }
132 }
134 State::Theirs => {
135 if line.starts_with(">>>>>>> ") {
136 state = State::Normal;
137 } else if side == ConflictSide::Theirs {
138 out.push(line);
139 }
140 }
141 }
142 }
143 if state != State::Normal {
144 return Err("unterminated conflict block (no `>>>>>>> ` end marker)".to_string());
145 }
146 let mut resolved = out.join("\n");
147 if content.ends_with('\n') && !resolved.ends_with('\n') {
148 resolved.push('\n');
149 }
150 Ok(resolved)
151}
152
153impl Engine {
154 fn folder_mount(&self, mem: &str) -> Result<(usize, std::path::PathBuf), EngineError> {
159 let mount_idx = self
160 .mounts
161 .iter()
162 .position(|m| m.mount.mem == mem)
163 .ok_or_else(|| self.unknown_mem_error(mem))?;
164 if self.quarantine_reason(mem).is_some() {
165 return Err(self.unknown_mem_error(mem));
166 }
167 if self.mounts[mount_idx].mount.capability != MountCapability::Write {
168 return Err(EngineError::ReadOnlyMount(mem.to_string()));
169 }
170 match &self.mounts[mount_idx].mount.storage {
171 MountStorage::Folder { path } => Ok((mount_idx, path.clone())),
172 _ => Err(EngineError::MergeConflictUnsupportedBackend {
173 mem: mem.to_string(),
174 }),
175 }
176 }
177
178 pub fn list_merge_conflicts(
186 &self,
187 mem: Option<&str>,
188 ) -> Result<Vec<ConflictedEntity>, EngineError> {
189 let targets: Vec<(String, std::path::PathBuf)> = match mem {
190 Some(name) => {
191 let (_, root) = self.folder_mount(name)?;
192 vec![(name.to_string(), root)]
193 }
194 None => self
195 .mounts
196 .iter()
197 .filter(|m| m.mount.capability == MountCapability::Write)
198 .filter_map(|m| match &m.mount.storage {
199 MountStorage::Folder { path } => Some((m.mount.mem.clone(), path.clone())),
200 _ => None,
201 })
202 .collect(),
203 };
204 let mut out = Vec::new();
205 for (mem_name, root) in targets {
206 let (entries, _read_errors) = EntitySource::Directory { root }
207 .read_all()
208 .map_err(|e| EngineError::InvalidInput(format!("read mem directory: {e}")))?;
209 for entry in entries {
210 if parser::has_merge_conflict_markers(&entry.content) {
211 out.push(ConflictedEntity {
212 id: file_path_to_id(&entry.relative_path, &mem_name),
213 mem: mem_name.clone(),
214 file_path: entry.relative_path,
215 });
216 }
217 }
218 }
219 out.sort_by(|a, b| a.id.0.cmp(&b.id.0));
220 Ok(out)
221 }
222
223 pub fn resolve_merge_conflict(
233 &mut self,
234 id: &EntityId,
235 side: ConflictSide,
236 actor: Actor,
237 client: Option<&ClientId>,
238 note: Option<&str>,
239 ) -> Result<ResolveConflictOutcome, EngineError> {
240 let mem = id.mem().to_string();
241 let (mount_idx, root) = self.folder_mount(&mem)?;
242
243 let (entries, _read_errors) = EntitySource::Directory { root }
247 .read_all()
248 .map_err(|e| EngineError::InvalidInput(format!("read mem directory: {e}")))?;
249 let Some(entry) = entries
250 .into_iter()
251 .find(|e| file_path_to_id(&e.relative_path, &mem) == *id)
252 else {
253 return Err(EngineError::NotFound { id: id.to_string() });
254 };
255 if !parser::has_merge_conflict_markers(&entry.content) {
256 return Err(EngineError::NotConflicted { id: id.to_string() });
257 }
258
259 let resolved = extract_conflict_side(&entry.content, side).map_err(|m| {
260 EngineError::InvalidInput(format!(
261 "malformed conflict markers in {}: {m}",
262 entry.relative_path
263 ))
264 })?;
265
266 if parser::has_merge_conflict_markers(&resolved) {
281 return Err(EngineError::InvalidInput(format!(
282 "the {} side of {} still carries conflict markers (nested conflict) — \
283 refusing to write it; resolve the other side or repair upstream first",
284 side.as_wire(),
285 entry.relative_path
286 )));
287 }
288 let schema = self
289 .schemas
290 .get(&mem)
291 .cloned()
292 .ok_or_else(|| self.unknown_mem_error(&mem))?;
293 let resolved_type = loader::resolve_type_for_entry(&schema, &resolved);
294 let parsed = parser::parse_markdown(
295 &resolved,
296 &entry.relative_path,
297 resolved_type.as_ref(),
298 &mem,
299 )?;
300 crate::runtime_validator::validate_section_keys(
301 parsed.entity.sections.keys().map(String::as_str),
302 resolved_type.as_ref(),
303 )?;
304 let mut heading_buf: Vec<&str> = Vec::new();
305 let catch_all =
306 crate::runtime_validator::catch_all_context(resolved_type.as_ref(), &mut heading_buf);
307 crate::runtime_validator::validate_section_content(
308 parsed
309 .entity
310 .sections
311 .iter()
312 .map(|(k, v)| (k.as_str(), v.as_str())),
313 catch_all,
314 )?;
315
316 let backend = self.mounts[mount_idx].backend.as_ref();
317 backend.write_entity(Path::new(&entry.relative_path), resolved.as_bytes())?;
318 let ctx = CommitContext {
319 actor,
320 client: client.cloned(),
321 tool: Some("resolve_conflict"),
322 note: note.map(String::from),
323 role: self.current_role,
324 logical_operation_id: None,
325 entity_ids: None,
326 };
327 let write_id = backend.commit(
328 &format!("memstead: resolve-conflict {id} (side: {})", side.as_wire()),
329 &ctx,
330 )?;
331 backend.append_provenance(
332 &Provenance::new(
333 std::time::SystemTime::now(),
334 ProvenanceKind::Update,
335 Some(id.to_string()),
336 actor,
337 client.cloned(),
338 note.map(String::from),
339 )
340 .with_role(self.current_role),
341 )?;
342 self.record_self_write(mount_idx, &write_id);
343 let stamp_warnings = self.stamp_mutation_versions(mount_idx);
344
345 self.reload_each_writable_mem()?;
349
350 Ok(ResolveConflictOutcome {
351 warnings: stamp_warnings,
352 id: id.clone(),
353 side: side.as_wire(),
354 write_id,
355 })
356 }
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 const CONFLICTED: &str = "---\ntype: spec\n---\n# Torn\n\n## Identity\n\n\
364<<<<<<< HEAD\nours line\n||||||| base\nbase line\n=======\ntheirs line\n\
365>>>>>>> feature\n\n## Purpose\n\nshared tail\n";
366
367 fn folder_workspace(files: &[(&str, &str)]) -> (tempfile::TempDir, Engine) {
370 use crate::workspace::{Mount, MountLifecycle};
371 use crate::workspace_store::WorkspaceStoreAdapter;
372
373 let tmp = tempfile::TempDir::new().unwrap();
374 let mem_dir = tmp.path().join("specs");
375 std::fs::create_dir_all(&mem_dir).unwrap();
376 for (name, content) in files {
377 std::fs::write(mem_dir.join(name), content).unwrap();
378 }
379 let memstead = tmp.path().join(".memstead");
380 std::fs::create_dir_all(&memstead).unwrap();
381 std::fs::write(
382 memstead.join("workspace.toml"),
383 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
384 )
385 .unwrap();
386 let mount = Mount {
387 mem: "specs".to_string(),
388 schema: Some(memstead_schema::SchemaRef::new(
389 "default",
390 semver::Version::new(1, 0, 0),
391 )),
392 storage: MountStorage::Folder { path: mem_dir },
393 capability: MountCapability::Write,
394 lifecycle: MountLifecycle::Eager,
395 cross_linkable: true,
396 migration_target: None,
397 };
398 crate::FileWorkspaceStore::new()
399 .save_state(
400 tmp.path(),
401 &crate::workspace::Workspace {
402 mounts: vec![mount],
403 settings: crate::workspace::WorkspaceSettings::default(),
404 },
405 )
406 .unwrap();
407 let engine = Engine::from_workspace_root(tmp.path()).expect("workspace boots");
408 (tmp, engine)
409 }
410
411 const CLEAN: &str = "---\ntype: spec\n---\n# Fine\n\n## Identity\n\nis\n\n## Purpose\n\nok\n";
412
413 #[test]
420 fn conflicted_folder_entity_lists_resolves_and_reads_clean() {
421 let (tmp, mut engine) = folder_workspace(&[("torn.md", CONFLICTED), ("fine.md", CLEAN)]);
422
423 let errors = engine.load_errors();
425 assert_eq!(errors.len(), 1, "exactly the conflicted file refuses");
426 assert!(
427 errors[0].1.contains("memstead conflicts resolve"),
428 "load error names the resolve operation: {}",
429 errors[0].1
430 );
431
432 let listed = engine.list_merge_conflicts(None).unwrap();
434 assert_eq!(listed.len(), 1);
435 assert_eq!(listed[0].id.as_ref(), "specs--torn");
436 assert_eq!(listed[0].file_path, "torn.md");
437
438 let id = EntityId("specs--torn".into());
440 let outcome = engine
441 .resolve_merge_conflict(
442 &id,
443 ConflictSide::Theirs,
444 Actor::Cli,
445 None,
446 Some("keeping upstream wording"),
447 )
448 .expect("resolution succeeds");
449 assert_eq!(outcome.side, "theirs");
450
451 assert!(
453 engine.load_errors().is_empty(),
454 "{:?}",
455 engine.load_errors()
456 );
457 let entity = engine.get_entity(&id).expect("resolved entity is loaded");
458 assert!(!entity.stub);
459 assert!(
460 entity
461 .sections
462 .get("identity")
463 .unwrap()
464 .contains("theirs line"),
465 "the kept side's content is live: {:?}",
466 entity.sections.get("identity")
467 );
468 let on_disk = std::fs::read_to_string(tmp.path().join("specs").join("torn.md")).unwrap();
469 assert!(!on_disk.contains("<<<<<<<") && !on_disk.contains("ours line"));
470
471 let ledger = std::fs::read_to_string(
474 tmp.path()
475 .join("specs")
476 .join(".memstead")
477 .join("changes.jsonl"),
478 )
479 .expect("folder provenance ledger exists");
480 assert!(
481 ledger.contains("specs--torn") && ledger.contains("keeping upstream wording"),
482 "ledger records the resolution with its note: {ledger}"
483 );
484
485 let err = engine
488 .resolve_merge_conflict(&id, ConflictSide::Ours, Actor::Cli, None, None)
489 .unwrap_err();
490 assert_eq!(err.code(), "NOT_CONFLICTED");
491 let err = engine
492 .resolve_merge_conflict(
493 &EntityId("specs--absent".into()),
494 ConflictSide::Ours,
495 Actor::Cli,
496 None,
497 None,
498 )
499 .unwrap_err();
500 assert_eq!(err.code(), "ENTITY_NOT_FOUND");
501 }
502
503 #[test]
507 fn fenced_marker_example_is_not_a_conflict() {
508 let doc = "---\ntype: spec\n---\n# Git Lore\n\n## Identity\n\n\
509```text\n<<<<<<< HEAD\nexample\n=======\nexample\n>>>>>>> branch\n```\n\n\
510## Purpose\n\nteaching\n";
511 let (_tmp, engine) = folder_workspace(&[("lore.md", doc)]);
512 assert!(
513 engine.load_errors().is_empty(),
514 "{:?}",
515 engine.load_errors()
516 );
517 assert!(engine.list_merge_conflicts(None).unwrap().is_empty());
518 assert!(engine.get_entity(&EntityId("specs--lore".into())).is_some());
519 }
520
521 #[test]
528 fn invalid_chosen_side_refuses_and_writes_nothing() {
529 let nested = "---\ntype: spec\n---\n# Nested\n\n## Identity\n\n\
530<<<<<<< HEAD\nours\n=======\n<<<<<<< inner\ntheirs-a\n=======\ntheirs-b\n\
531>>>>>>> inner\n>>>>>>> outer\n\n## Purpose\n\np\n";
532 let (tmp, mut engine) = folder_workspace(&[("nested.md", nested)]);
533
534 let id = EntityId("specs--nested".into());
535 let err = engine
536 .resolve_merge_conflict(&id, ConflictSide::Theirs, Actor::Cli, None, None)
537 .unwrap_err();
538 assert_eq!(err.code(), "INVALID_INPUT", "got: {err}");
539 assert!(
540 err.to_string().contains("still carries conflict markers"),
541 "refusal names the residue: {err}"
542 );
543 let on_disk = std::fs::read_to_string(tmp.path().join("specs").join("nested.md")).unwrap();
545 assert!(
546 on_disk.contains("<<<<<<< HEAD"),
547 "file untouched on refusal"
548 );
549
550 engine
552 .resolve_merge_conflict(&id, ConflictSide::Ours, Actor::Cli, None, None)
553 .expect("clean side resolves");
554 assert!(engine.load_errors().is_empty());
555 }
556
557 #[test]
558 fn extract_sides_and_diff3_base_drops() {
559 let ours = extract_conflict_side(CONFLICTED, ConflictSide::Ours).unwrap();
560 assert!(ours.contains("ours line"));
561 assert!(!ours.contains("theirs line") && !ours.contains("base line"));
562 assert!(ours.contains("shared tail"));
563 let theirs = extract_conflict_side(CONFLICTED, ConflictSide::Theirs).unwrap();
564 assert!(theirs.contains("theirs line"));
565 assert!(!theirs.contains("ours line") && !theirs.contains("base line"));
566 assert!(!theirs.contains("<<<<<<<") && !theirs.contains(">>>>>>>"));
567 }
568
569 #[test]
570 fn malformed_markers_refuse() {
571 let unterminated = "a\n<<<<<<< HEAD\nours\n=======\ntheirs\n";
572 assert!(extract_conflict_side(unterminated, ConflictSide::Ours).is_err());
573 let inverted = "a\n<<<<<<< HEAD\nours\n>>>>>>> feature\n";
574 assert!(extract_conflict_side(inverted, ConflictSide::Ours).is_err());
575 }
576}