Skip to main content

memstead_base/engine/
conflicts.rs

1//! Merge-conflict resolution for folder mems (backlog-sweep plan 07,
2//! decision 20).
3//!
4//! A hand-committed folder mem lives inside the user's own git
5//! repository, so an ordinary merge can write conflict markers into
6//! entity files. At that moment every other door is locked by design:
7//! the loader refuses the file (naming this operation as the remedy),
8//! and the guards correctly block git verbs and raw edits against mem
9//! content. This module is the one sanctioned door — the agent judges
10//! each conflict on its content and the engine is the pair of hands:
11//! the chosen side is validated as an entity BEFORE it lands (a broken
12//! ours side never launders into the mem), and the resolution commits
13//! as an attributed, note-carrying mutation like any other write.
14//!
15//! Scope is deliberately narrow: per-entity, two sides (ours/theirs),
16//! folder backend only. A merged-content resolution is out of scope by
17//! design — an agent wanting a merge resolves to one side as the base
18//! and then edits through the normal mutation surface, which preserves
19//! validation and provenance; the operation's note is the designated
20//! place to record "base for a manual merge; discarded side: <which>".
21//! The git-branch backend's mem-repo is engine-managed and cannot
22//! acquire merge conflicts through supported use, so it refuses typed
23//! rather than pretending applicability.
24
25use 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/// Which side of a git merge conflict to keep.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum ConflictSide {
38    Ours,
39    Theirs,
40}
41
42impl ConflictSide {
43    /// Parse the wire token (`"ours"` / `"theirs"`). `None` for an
44    /// unrecognized token so the calling surface raises a typed error
45    /// naming the bad value.
46    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    /// The wire token for this side.
55    pub fn as_wire(self) -> &'static str {
56        match self {
57            Self::Ours => "ours",
58            Self::Theirs => "theirs",
59        }
60    }
61}
62
63/// One conflicted entity file found in a folder mem.
64#[derive(Debug, Clone, serde::Serialize)]
65pub struct ConflictedEntity {
66    /// The entity id the file's path derives to — the handle
67    /// `resolve_merge_conflict` accepts.
68    pub id: EntityId,
69    pub mem: String,
70    /// Mem-relative file path, for human orientation.
71    pub file_path: String,
72}
73
74/// Outcome of a successful [`Engine::resolve_merge_conflict`].
75#[derive(Debug, Clone, serde::Serialize)]
76pub struct ResolveConflictOutcome {
77    pub id: EntityId,
78    /// The side that was kept (`"ours"` / `"theirs"`).
79    pub side: &'static str,
80    pub commit_sha: String,
81}
82
83/// Extract one side of a git merge conflict from raw file content.
84///
85/// Handles the standard two-way marker layout and the diff3 variant
86/// (`|||||||` base section, dropped from both sides). Operates on raw
87/// lines exactly as git wrote them — git places markers at line starts
88/// without regard for markdown structure. `Err` carries a description
89/// of the malformation (e.g. a start marker with no closing marker).
90pub 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                // base-section lines belong to neither side
128            }
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    /// Resolve a mem name to its writable FOLDER mount. The visibility
150    /// gate mirrors `search`'s (quarantined or invisible → the same
151    /// `UNKNOWN_MEM` refusal); a visible non-folder mem refuses
152    /// `CONFLICT_RESOLVE_UNSUPPORTED_BACKEND`.
153    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    /// List every entity file carrying git merge-conflict markers.
174    ///
175    /// `mem: Some(name)` scopes to that mem and refuses typed when it
176    /// is unknown or not folder-backed; `None` sweeps every writable
177    /// folder mem (non-folder mounts are simply not applicable and are
178    /// skipped — the unscoped sweep answers "what is conflicted",
179    /// never "which backends exist").
180    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    /// Resolve one conflicted entity to the chosen side.
219    ///
220    /// The chosen side must parse as a valid entity against the mem's
221    /// schema BEFORE anything is written — resolution never launders an
222    /// invalid entity into the mem. On success the resolved content is
223    /// written through the mem's backend, committed with an attributed
224    /// [`CommitContext`] (note included when given), recorded in the
225    /// provenance ledger, and the mem is reloaded so the entity reads
226    /// validly and the conflict load-error clears.
227    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        // Locate the file whose path derives to the requested id. The
239        // conflicted entity is NOT in the store (its file refused to
240        // load), so the lookup goes over the source files directly.
241        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        // Validate the chosen side as an entity BEFORE any write —
262        // resolution never launders an invalid entity into the mem.
263        // Load-grade first: the chosen side must itself be free of
264        // conflict markers (a nested conflict from a recursive merge
265        // leaves residue in one side), or the mem would refuse to load
266        // it right back. Then write-grade: the tolerant parser accepts
267        // nearly anything, so the schema checks the mutation surface
268        // applies to section shape run here too — unknown section keys
269        // and content-format violations refuse with the same typed
270        // validation errors a write would raise. Missing required
271        // sections stay soft on purpose, matching `memstead_update`'s
272        // permissive posture: resolution is an update-kind mutation on
273        // an entity that already exists, and refusing here could leave
274        // BOTH sides unresolvable — a locked door again.
275        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        // Reload so the resolved entity enters the store and the
337        // conflict load-error clears — the caller's next read sees a
338        // clean mem, not a stale refusal.
339        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    /// Build a booted folder workspace with one mem (`specs`) whose
358    /// files are exactly `files`. Returns `(tempdir, engine)`.
359    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    /// Plan 07 criteria 1/3/4 on a live folder workspace: the load
404    /// refusal names the resolve remedy, the conflicted entity lists,
405    /// resolving to theirs makes the mem load clean with the entity
406    /// valid, and the resolution lands in the provenance ledger with
407    /// its note. Complements: resolving the already-clean entity
408    /// refuses `NOT_CONFLICTED`; a missing id refuses not-found.
409    #[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        // The parse failure an agent hits names the remedy.
414        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        // The conflicted entity is identified; the clean one is not.
423        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        // Resolve to theirs.
429        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        // The mem loads clean and the entity reads validly.
442        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        // Provenance: the resolution is an attributed ledger entry
462        // carrying the note — never an untracked file swap.
463        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        // Complements: already-clean refuses NOT_CONFLICTED; unknown
476        // id refuses not-found.
477        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    /// Complement: a fenced code example DOCUMENTING conflict markers
494    /// is legal content — it loads without a conflict refusal and does
495    /// not list as conflicted (the detector evaluates masked content).
496    #[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    /// Plan 07 criterion 2: a chosen side that fails entity validation
512    /// refuses with the validation error and writes nothing. The
513    /// fixture is a nested conflict (recursive-merge shape): the
514    /// theirs side still carries marker residue after extraction, so
515    /// writing it would put the mem right back into the unloadable
516    /// state — resolution refuses; the clean ours side resolves.
517    #[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        // Nothing was written: the original markers are still on disk.
534        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        // The ours side is clean and resolves fine.
541        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}