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 write_id: String,
81    /// Carries `CONFIG_WRITE_INTERVENED` when the mutation version stamp this
82    /// resolution triggered merged over another writer's config change
83    /// (04/03, criterion 3). Empty on the ordinary path.
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub warnings: Vec<crate::ops::WarningHint>,
86}
87
88/// Extract one side of a git merge conflict from raw file content.
89///
90/// Handles the standard two-way marker layout and the diff3 variant
91/// (`|||||||` base section, dropped from both sides). Operates on raw
92/// lines exactly as git wrote them — git places markers at line starts
93/// without regard for markdown structure. `Err` carries a description
94/// of the malformation (e.g. a start marker with no closing marker).
95pub 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                // base-section lines belong to neither side
133            }
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    /// Resolve a mem name to its writable FOLDER mount. The visibility
155    /// gate mirrors `search`'s (quarantined or invisible → the same
156    /// `UNKNOWN_MEM` refusal); a visible non-folder mem refuses
157    /// `CONFLICT_RESOLVE_UNSUPPORTED_BACKEND`.
158    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    /// List every entity file carrying git merge-conflict markers.
179    ///
180    /// `mem: Some(name)` scopes to that mem and refuses typed when it
181    /// is unknown or not folder-backed; `None` sweeps every writable
182    /// folder mem (non-folder mounts are simply not applicable and are
183    /// skipped — the unscoped sweep answers "what is conflicted",
184    /// never "which backends exist").
185    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    /// Resolve one conflicted entity to the chosen side.
224    ///
225    /// The chosen side must parse as a valid entity against the mem's
226    /// schema BEFORE anything is written — resolution never launders an
227    /// invalid entity into the mem. On success the resolved content is
228    /// written through the mem's backend, committed with an attributed
229    /// [`CommitContext`] (note included when given), recorded in the
230    /// provenance ledger, and the mem is reloaded so the entity reads
231    /// validly and the conflict load-error clears.
232    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        // Locate the file whose path derives to the requested id. The
244        // conflicted entity is NOT in the store (its file refused to
245        // load), so the lookup goes over the source files directly.
246        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        // Validate the chosen side as an entity BEFORE any write —
267        // resolution never launders an invalid entity into the mem.
268        // Load-grade first: the chosen side must itself be free of
269        // conflict markers (a nested conflict from a recursive merge
270        // leaves residue in one side), or the mem would refuse to load
271        // it right back. Then write-grade: the tolerant parser accepts
272        // nearly anything, so the schema checks the mutation surface
273        // applies to section shape run here too — unknown section keys
274        // and content-format violations refuse with the same typed
275        // validation errors a write would raise. Missing required
276        // sections stay soft on purpose, matching `memstead_update`'s
277        // permissive posture: resolution is an update-kind mutation on
278        // an entity that already exists, and refusing here could leave
279        // BOTH sides unresolvable — a locked door again.
280        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        // Reload so the resolved entity enters the store and the
346        // conflict load-error clears — the caller's next read sees a
347        // clean mem, not a stale refusal.
348        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    /// Build a booted folder workspace with one mem (`specs`) whose
368    /// files are exactly `files`. Returns `(tempdir, engine)`.
369    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    /// Plan 07 criteria 1/3/4 on a live folder workspace: the load
414    /// refusal names the resolve remedy, the conflicted entity lists,
415    /// resolving to theirs makes the mem load clean with the entity
416    /// valid, and the resolution lands in the provenance ledger with
417    /// its note. Complements: resolving the already-clean entity
418    /// refuses `NOT_CONFLICTED`; a missing id refuses not-found.
419    #[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        // The parse failure an agent hits names the remedy.
424        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        // The conflicted entity is identified; the clean one is not.
433        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        // Resolve to theirs.
439        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        // The mem loads clean and the entity reads validly.
452        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        // Provenance: the resolution is an attributed ledger entry
472        // carrying the note — never an untracked file swap.
473        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        // Complements: already-clean refuses NOT_CONFLICTED; unknown
486        // id refuses not-found.
487        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    /// Complement: a fenced code example DOCUMENTING conflict markers
504    /// is legal content — it loads without a conflict refusal and does
505    /// not list as conflicted (the detector evaluates masked content).
506    #[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    /// Plan 07 criterion 2: a chosen side that fails entity validation
522    /// refuses with the validation error and writes nothing. The
523    /// fixture is a nested conflict (recursive-merge shape): the
524    /// theirs side still carries marker residue after extraction, so
525    /// writing it would put the mem right back into the unloadable
526    /// state — resolution refuses; the clean ours side resolves.
527    #[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        // Nothing was written: the original markers are still on disk.
544        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        // The ours side is clean and resolves fine.
551        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}