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            identity: self.current_identity.clone(),
325            logical_operation_id: None,
326            entity_ids: None,
327        };
328        let write_id = backend.commit(
329            &format!("memstead: resolve-conflict {id} (side: {})", side.as_wire()),
330            &ctx,
331        )?;
332        backend.append_provenance(
333            &Provenance::new(
334                std::time::SystemTime::now(),
335                ProvenanceKind::Update,
336                Some(id.to_string()),
337                actor,
338                client.cloned(),
339                note.map(String::from),
340            )
341            .with_role(self.current_role)
342            .with_identity(self.current_identity.clone()),
343        )?;
344        self.record_self_write(mount_idx, &write_id);
345        let stamp_warnings = self.stamp_mutation_versions(mount_idx);
346
347        // Reload so the resolved entity enters the store and the
348        // conflict load-error clears — the caller's next read sees a
349        // clean mem, not a stale refusal.
350        self.reload_each_writable_mem()?;
351
352        Ok(ResolveConflictOutcome {
353            warnings: stamp_warnings,
354            id: id.clone(),
355            side: side.as_wire(),
356            write_id,
357        })
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    const CONFLICTED: &str = "---\ntype: spec\n---\n# Torn\n\n## Identity\n\n\
366<<<<<<< HEAD\nours line\n||||||| base\nbase line\n=======\ntheirs line\n\
367>>>>>>> feature\n\n## Purpose\n\nshared tail\n";
368
369    /// Build a booted folder workspace with one mem (`specs`) whose
370    /// files are exactly `files`. Returns `(tempdir, engine)`.
371    fn folder_workspace(files: &[(&str, &str)]) -> (tempfile::TempDir, Engine) {
372        use crate::workspace::{Mount, MountLifecycle};
373        use crate::workspace_store::WorkspaceStoreAdapter;
374
375        let tmp = tempfile::TempDir::new().unwrap();
376        let mem_dir = tmp.path().join("specs");
377        std::fs::create_dir_all(&mem_dir).unwrap();
378        for (name, content) in files {
379            std::fs::write(mem_dir.join(name), content).unwrap();
380        }
381        let memstead = tmp.path().join(".memstead");
382        std::fs::create_dir_all(&memstead).unwrap();
383        std::fs::write(
384            memstead.join("workspace.toml"),
385            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
386        )
387        .unwrap();
388        let mount = Mount {
389            mem: "specs".to_string(),
390            schema: Some(memstead_schema::SchemaRef::new(
391                "default",
392                semver::Version::new(1, 0, 0),
393            )),
394            storage: MountStorage::Folder { path: mem_dir },
395            capability: MountCapability::Write,
396            lifecycle: MountLifecycle::Eager,
397            cross_linkable: true,
398            migration_target: None,
399        };
400        crate::FileWorkspaceStore::new()
401            .save_state(
402                tmp.path(),
403                &crate::workspace::Workspace {
404                    mounts: vec![mount],
405                    settings: crate::workspace::WorkspaceSettings::default(),
406                },
407            )
408            .unwrap();
409        let engine = Engine::from_workspace_root(tmp.path()).expect("workspace boots");
410        (tmp, engine)
411    }
412
413    const CLEAN: &str = "---\ntype: spec\n---\n# Fine\n\n## Identity\n\nis\n\n## Purpose\n\nok\n";
414
415    /// Plan 07 criteria 1/3/4 on a live folder workspace: the load
416    /// refusal names the resolve remedy, the conflicted entity lists,
417    /// resolving to theirs makes the mem load clean with the entity
418    /// valid, and the resolution lands in the provenance ledger with
419    /// its note. Complements: resolving the already-clean entity
420    /// refuses `NOT_CONFLICTED`; a missing id refuses not-found.
421    #[test]
422    fn conflicted_folder_entity_lists_resolves_and_reads_clean() {
423        let (tmp, mut engine) = folder_workspace(&[("torn.md", CONFLICTED), ("fine.md", CLEAN)]);
424
425        // The parse failure an agent hits names the remedy.
426        let errors = engine.load_errors();
427        assert_eq!(errors.len(), 1, "exactly the conflicted file refuses");
428        assert!(
429            errors[0].1.contains("memstead conflicts resolve"),
430            "load error names the resolve operation: {}",
431            errors[0].1
432        );
433
434        // The conflicted entity is identified; the clean one is not.
435        let listed = engine.list_merge_conflicts(None).unwrap();
436        assert_eq!(listed.len(), 1);
437        assert_eq!(listed[0].id.as_ref(), "specs--torn");
438        assert_eq!(listed[0].file_path, "torn.md");
439
440        // Resolve to theirs.
441        let id = EntityId("specs--torn".into());
442        let outcome = engine
443            .resolve_merge_conflict(
444                &id,
445                ConflictSide::Theirs,
446                Actor::Cli,
447                None,
448                Some("keeping upstream wording"),
449            )
450            .expect("resolution succeeds");
451        assert_eq!(outcome.side, "theirs");
452
453        // The mem loads clean and the entity reads validly.
454        assert!(
455            engine.load_errors().is_empty(),
456            "{:?}",
457            engine.load_errors()
458        );
459        let entity = engine.get_entity(&id).expect("resolved entity is loaded");
460        assert!(!entity.stub);
461        assert!(
462            entity
463                .sections
464                .get("identity")
465                .unwrap()
466                .contains("theirs line"),
467            "the kept side's content is live: {:?}",
468            entity.sections.get("identity")
469        );
470        let on_disk = std::fs::read_to_string(tmp.path().join("specs").join("torn.md")).unwrap();
471        assert!(!on_disk.contains("<<<<<<<") && !on_disk.contains("ours line"));
472
473        // Provenance: the resolution is an attributed ledger entry
474        // carrying the note — never an untracked file swap.
475        let ledger = std::fs::read_to_string(
476            tmp.path()
477                .join("specs")
478                .join(".memstead")
479                .join("changes.jsonl"),
480        )
481        .expect("folder provenance ledger exists");
482        assert!(
483            ledger.contains("specs--torn") && ledger.contains("keeping upstream wording"),
484            "ledger records the resolution with its note: {ledger}"
485        );
486
487        // Complements: already-clean refuses NOT_CONFLICTED; unknown
488        // id refuses not-found.
489        let err = engine
490            .resolve_merge_conflict(&id, ConflictSide::Ours, Actor::Cli, None, None)
491            .unwrap_err();
492        assert_eq!(err.code(), "NOT_CONFLICTED");
493        let err = engine
494            .resolve_merge_conflict(
495                &EntityId("specs--absent".into()),
496                ConflictSide::Ours,
497                Actor::Cli,
498                None,
499                None,
500            )
501            .unwrap_err();
502        assert_eq!(err.code(), "ENTITY_NOT_FOUND");
503    }
504
505    /// Complement: a fenced code example DOCUMENTING conflict markers
506    /// is legal content — it loads without a conflict refusal and does
507    /// not list as conflicted (the detector evaluates masked content).
508    #[test]
509    fn fenced_marker_example_is_not_a_conflict() {
510        let doc = "---\ntype: spec\n---\n# Git Lore\n\n## Identity\n\n\
511```text\n<<<<<<< HEAD\nexample\n=======\nexample\n>>>>>>> branch\n```\n\n\
512## Purpose\n\nteaching\n";
513        let (_tmp, engine) = folder_workspace(&[("lore.md", doc)]);
514        assert!(
515            engine.load_errors().is_empty(),
516            "{:?}",
517            engine.load_errors()
518        );
519        assert!(engine.list_merge_conflicts(None).unwrap().is_empty());
520        assert!(engine.get_entity(&EntityId("specs--lore".into())).is_some());
521    }
522
523    /// Plan 07 criterion 2: a chosen side that fails entity validation
524    /// refuses with the validation error and writes nothing. The
525    /// fixture is a nested conflict (recursive-merge shape): the
526    /// theirs side still carries marker residue after extraction, so
527    /// writing it would put the mem right back into the unloadable
528    /// state — resolution refuses; the clean ours side resolves.
529    #[test]
530    fn invalid_chosen_side_refuses_and_writes_nothing() {
531        let nested = "---\ntype: spec\n---\n# Nested\n\n## Identity\n\n\
532<<<<<<< HEAD\nours\n=======\n<<<<<<< inner\ntheirs-a\n=======\ntheirs-b\n\
533>>>>>>> inner\n>>>>>>> outer\n\n## Purpose\n\np\n";
534        let (tmp, mut engine) = folder_workspace(&[("nested.md", nested)]);
535
536        let id = EntityId("specs--nested".into());
537        let err = engine
538            .resolve_merge_conflict(&id, ConflictSide::Theirs, Actor::Cli, None, None)
539            .unwrap_err();
540        assert_eq!(err.code(), "INVALID_INPUT", "got: {err}");
541        assert!(
542            err.to_string().contains("still carries conflict markers"),
543            "refusal names the residue: {err}"
544        );
545        // Nothing was written: the original markers are still on disk.
546        let on_disk = std::fs::read_to_string(tmp.path().join("specs").join("nested.md")).unwrap();
547        assert!(
548            on_disk.contains("<<<<<<< HEAD"),
549            "file untouched on refusal"
550        );
551
552        // The ours side is clean and resolves fine.
553        engine
554            .resolve_merge_conflict(&id, ConflictSide::Ours, Actor::Cli, None, None)
555            .expect("clean side resolves");
556        assert!(engine.load_errors().is_empty());
557    }
558
559    #[test]
560    fn extract_sides_and_diff3_base_drops() {
561        let ours = extract_conflict_side(CONFLICTED, ConflictSide::Ours).unwrap();
562        assert!(ours.contains("ours line"));
563        assert!(!ours.contains("theirs line") && !ours.contains("base line"));
564        assert!(ours.contains("shared tail"));
565        let theirs = extract_conflict_side(CONFLICTED, ConflictSide::Theirs).unwrap();
566        assert!(theirs.contains("theirs line"));
567        assert!(!theirs.contains("ours line") && !theirs.contains("base line"));
568        assert!(!theirs.contains("<<<<<<<") && !theirs.contains(">>>>>>>"));
569    }
570
571    #[test]
572    fn malformed_markers_refuse() {
573        let unterminated = "a\n<<<<<<< HEAD\nours\n=======\ntheirs\n";
574        assert!(extract_conflict_side(unterminated, ConflictSide::Ours).is_err());
575        let inverted = "a\n<<<<<<< HEAD\nours\n>>>>>>> feature\n";
576        assert!(extract_conflict_side(inverted, ConflictSide::Ours).is_err());
577    }
578}