Skip to main content

memstead_base/
pipeline_edit.rs

1//! Edit operations over the v2 single-record pipeline store.
2//!
3//! Sits above the dumb file ops in [`crate::pipeline_store`]: each function
4//! loads the current store, enforces identity rules (no clobber, no silent
5//! overwrite), validates the candidate record in-place, then writes. The
6//! engine's in-memory `pipeline_configs` cache is refreshed by the `Engine`
7//! wrapper methods that call these — not here — so these functions stay pure
8//! disk ops that unit-test with a bare `TempDir`.
9//!
10//! One record kind, four verbs: a binding is created / patched / deleted /
11//! renamed under its `(mem, name)` file identity. The cross-record
12//! referential-integrity machinery of the three-file era (dangling facet /
13//! medium refs, delete-blocked-by-referrer, rename repointing) is gone with
14//! the references — **in-record source validation**
15//! ([`crate::binding::validate_binding`]) covers what remains: source name
16//! rules plus the medium-capability matrix.
17//!
18//! The JSON entry points ([`add_binding_json`] / [`update_binding_json`])
19//! accept a [`BindingPatch`] over the full author-editable record —
20//! `sources`, operations block, `deny_paths`, `coverage_semantics`, `rules`
21//! (clearable via explicit `null`), `prune` — with `version` engine-managed.
22//! A field absent from the payload is preserved from the stored record (the
23//! tail-preservation contract): edits merge over the stored record, never
24//! rebuild it from form fields.
25
26use std::path::Path;
27
28use serde::Deserialize;
29
30use crate::binding::{
31    BINDING_VERSION, Binding, BuildMode, BuildOperation, CapabilityError, CoverageSemantics,
32    Operations, PruneConfig, validate_binding,
33};
34use crate::engine::Engine;
35use crate::pipeline::{IngestTrigger, Source};
36use crate::pipeline_store::{self, BindingConfigs};
37use crate::workspace_store::StoreError;
38
39/// Failure modes of a pipeline edit. Distinct from the entity-centric
40/// [`crate::engine::EngineError`] — these describe binding-store edits.
41/// `key` is the display identity: `"<mem>/<name>"`.
42#[derive(Debug, thiserror::Error)]
43pub enum PipelineEditError {
44    /// The engine was not booted from a workspace root, so there is no
45    /// binding store to edit (e.g. an engine built from a bare mount
46    /// list in a test or in-memory consumer).
47    #[error("engine has no workspace root — pipeline edits require a workspace-backed engine")]
48    NoWorkspaceRoot,
49    /// The edit landed on disk but its provenance record (the
50    /// `__MEMSTEAD` mirror commit carrying the note) could not be
51    /// committed. The disk state is live; the audit trail is missing
52    /// this event — callers surface it rather than silently dropping
53    /// the note.
54    #[error("pipeline edit landed, but recording provenance failed: {0}")]
55    Provenance(String),
56    /// A create targeted a `(mem, name)` that already holds a record.
57    #[error("{primitive} '{key}' already exists")]
58    AlreadyExists {
59        primitive: &'static str,
60        key: String,
61    },
62    /// An update / delete / rename targeted a record that does not exist.
63    #[error("{primitive} '{key}' does not exist")]
64    NotFound {
65        primitive: &'static str,
66        key: String,
67    },
68    /// A rename target `(mem, new)` already holds a record.
69    #[error("rename target {primitive} '{key}' already exists")]
70    RenameTargetExists {
71        primitive: &'static str,
72        key: String,
73    },
74    /// A JSON-string edit entry point received a payload that did not
75    /// deserialize into the target shape.
76    #[error("invalid {primitive} JSON: {message}")]
77    InvalidJson {
78        primitive: &'static str,
79        message: String,
80    },
81    /// A binding edit was refused by in-record validation — a malformed
82    /// source (empty / duplicate name) or a medium-capability violation
83    /// (e.g. declaring `sync` over a medium with no change signal). Carries
84    /// only the refusals the edit would *introduce*: refusals the stored
85    /// record already produces never block an unrelated edit. Each refusal
86    /// message includes its remedy.
87    #[error(
88        "binding '{key}' edit refused by validation: {}",
89        format_refusals(refusals)
90    )]
91    Capability {
92        key: String,
93        refusals: Vec<CapabilityError>,
94    },
95    /// Underlying store IO / parse failure.
96    #[error(transparent)]
97    Store(#[from] StoreError),
98}
99
100fn key(mem: &str, name: &str) -> String {
101    format!("{mem}/{name}")
102}
103
104fn format_refusals(refusals: &[CapabilityError]) -> String {
105    refusals
106        .iter()
107        .map(|r| r.to_string())
108        .collect::<Vec<_>>()
109        .join("; ")
110}
111
112fn binding_exists(c: &BindingConfigs, mem: &str, name: &str) -> bool {
113    c.bindings.iter().any(|r| r.mem == mem && r.name == name)
114}
115
116// --- Binding record (JSON patch) --------------------------------------------
117
118/// Deserialize helper distinguishing an absent field (outer `None` —
119/// preserve) from an explicit `null` (inner `None` — clear). serde's plain
120/// `Option<Option<T>>` collapses `null` to the outer `None`; wrapping the
121/// parsed value in `Some` keeps the two cases apart.
122fn patch_field<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
123where
124    D: serde::Deserializer<'de>,
125    T: serde::Deserialize<'de>,
126{
127    Option::<T>::deserialize(deserializer).map(Some)
128}
129
130/// A partial edit of a binding record, decoded from the JSON edit entry
131/// points ([`update_binding_json`] / [`add_binding_json`]).
132///
133/// **Patch semantics**: a field absent from the payload is preserved from
134/// the stored record — the tail-preservation guarantee extends to every
135/// field. A field present is applied. The natively-optional record fields
136/// (`intent`, `rules`, `prune`) additionally distinguish explicit `null`
137/// (clear) from absence (preserve). `sources` and `operations` are replaced
138/// as whole blocks when present: each is the unit of its authoring, so an
139/// entry absent from a supplied block is removed (for operations that is
140/// legal — an absent mutating op refuses at run time with its enable
141/// remedy).
142///
143/// `version` is engine-managed and never author input: a `version` key in
144/// the payload is ignored, like every unknown key (the format grows
145/// additively — tolerance is deliberate).
146#[derive(Debug, Default, Deserialize)]
147pub struct BindingPatch {
148    /// Set / clear (`null`) / preserve (absent) the binding's intent prose.
149    #[serde(default, deserialize_with = "patch_field")]
150    pub intent: Option<Option<String>>,
151    /// Replace the inline source list when present.
152    #[serde(default)]
153    pub sources: Option<Vec<Source>>,
154    /// Replace the read-only reference-mem list when present.
155    #[serde(default)]
156    pub reference_mems: Option<Vec<String>>,
157    /// Repoint the destination mem when present (`null` preserves — a
158    /// required record field cannot be cleared).
159    #[serde(default)]
160    pub destination_mem: Option<String>,
161    /// Replace the deny-path glob list when present.
162    #[serde(default)]
163    pub deny_paths: Option<Vec<String>>,
164    /// Replace the coverage claim when present. Patch semantics are
165    /// option-as-patch: absent (`None`) = leave untouched. This surface
166    /// therefore cannot express *clearing* a declaration back to
167    /// unstated — deliberate: clearing stays out of scope, an author
168    /// edits the declaration in the binding file itself.
169    #[serde(default)]
170    pub coverage_semantics: Option<CoverageSemantics>,
171    /// Set / clear (`null`) / preserve (absent) the free-form rules value.
172    #[serde(default, deserialize_with = "patch_field")]
173    pub rules: Option<Option<serde_json::Value>>,
174    /// Set / clear (`null`) / preserve (absent) the prune policy.
175    #[serde(default, deserialize_with = "patch_field")]
176    pub prune: Option<Option<PruneConfig>>,
177    /// Replace the whole operations block when present.
178    #[serde(default)]
179    pub operations: Option<Operations>,
180}
181
182impl BindingPatch {
183    /// Overlay this patch onto `binding` (patch semantics; `version`
184    /// untouched).
185    fn apply(self, binding: &mut Binding) {
186        if let Some(v) = self.intent {
187            binding.intent = v;
188        }
189        if let Some(v) = self.sources {
190            binding.sources = v;
191        }
192        if let Some(v) = self.reference_mems {
193            binding.reference_mems = v;
194        }
195        if let Some(v) = self.destination_mem {
196            binding.destination_mem = v;
197        }
198        if let Some(v) = self.deny_paths {
199            binding.deny_paths = v;
200        }
201        if let Some(v) = self.coverage_semantics {
202            binding.coverage_semantics = Some(v);
203        }
204        if let Some(v) = self.rules {
205            binding.rules = v;
206        }
207        if let Some(v) = self.prune {
208            binding.prune = v;
209        }
210        if let Some(v) = self.operations {
211            binding.operations = v;
212        }
213    }
214}
215
216/// The default scaffold a fresh binding is patched onto: version pinned, a
217/// default `build` block (discovery / loop / batch 20), sync and verify
218/// absent, everything else empty — the caller's patch overlays it.
219fn default_binding_scaffold() -> Binding {
220    Binding {
221        version: BINDING_VERSION,
222        intent: None,
223        sources: Vec::new(),
224        reference_mems: Vec::new(),
225        destination_mem: String::new(),
226        deny_paths: Vec::new(),
227        // Unstated: the scaffold asserts nothing — the effective value
228        // resolves per medium once sources are patched in.
229        coverage_semantics: None,
230        rules: None,
231        prune: None,
232        operations: Operations {
233            build: Some(BuildOperation {
234                mode: BuildMode::Discovery,
235                trigger: IngestTrigger::Loop,
236                batch_size: 20,
237                post_actions: None,
238            }),
239            sync: None,
240            verify: None,
241        },
242    }
243}
244
245/// Create a binding from a JSON [`BindingPatch`] applied to the default
246/// scaffold. Refuses when `(mem, name)` already holds a record
247/// ([`PipelineEditError::AlreadyExists`]), when the patch leaves
248/// `destination_mem` empty ([`PipelineEditError::InvalidJson`]), or when
249/// the candidate fails in-record validation
250/// ([`PipelineEditError::Capability`] — every refusal blocks; a fresh
251/// record has no pre-existing state to grandfather). Returns the written
252/// record.
253pub fn add_binding_json(
254    root: &Path,
255    mem: &str,
256    name: &str,
257    patch_json: &str,
258) -> Result<Binding, PipelineEditError> {
259    let configs = pipeline_store::load_pipeline_configs_strict(root)?;
260    if binding_exists(&configs, mem, name) {
261        return Err(PipelineEditError::AlreadyExists {
262            primitive: "projection",
263            key: key(mem, name),
264        });
265    }
266    let patch: BindingPatch = parse_json(patch_json, "projection")?;
267    let mut binding = default_binding_scaffold();
268    patch.apply(&mut binding);
269    if binding.destination_mem.is_empty() {
270        return Err(PipelineEditError::InvalidJson {
271            primitive: "projection",
272            message: "destination_mem is required".to_string(),
273        });
274    }
275    if let Err(refusals) = validate_binding(&binding) {
276        return Err(PipelineEditError::Capability {
277            key: key(mem, name),
278            refusals,
279        });
280    }
281    pipeline_store::write_binding(root, mem, name, &binding)?;
282    Ok(binding)
283}
284
285/// Patch an existing binding from a JSON [`BindingPatch`] — the update path
286/// over the full author-editable record (`sources`, operations,
287/// `deny_paths`, `coverage_semantics`, `rules` including clearing,
288/// `prune`). Absent fields are preserved (the tail-preservation contract);
289/// explicit `null` clears `intent` / `rules` / `prune`; a present `sources`
290/// or `operations` block replaces that whole block; `version` stays
291/// engine-managed.
292///
293/// Refuses when the record does not exist
294/// ([`PipelineEditError::NotFound`]) or when the patch introduces a
295/// validation refusal the stored record did not already carry
296/// ([`PipelineEditError::Capability`] — pre-existing refusals never block
297/// an unrelated edit). Nothing is written on refusal. Returns the written
298/// record.
299pub fn update_binding_json(
300    root: &Path,
301    mem: &str,
302    name: &str,
303    patch_json: &str,
304) -> Result<Binding, PipelineEditError> {
305    let configs = pipeline_store::load_pipeline_configs_strict(root)?;
306    if !binding_exists(&configs, mem, name) {
307        return Err(PipelineEditError::NotFound {
308            primitive: "projection",
309            key: key(mem, name),
310        });
311    }
312    let patch: BindingPatch = parse_json(patch_json, "projection")?;
313    let existing = pipeline_store::read_binding(root, mem, name)?;
314    let mut patched = existing.clone();
315    patch.apply(&mut patched);
316    if let Err(refusals) = validate_binding(&patched) {
317        let before = validate_binding(&existing).err().unwrap_or_default();
318        let introduced: Vec<CapabilityError> = refusals
319            .into_iter()
320            .filter(|r| !before.contains(r))
321            .collect();
322        if !introduced.is_empty() {
323            return Err(PipelineEditError::Capability {
324                key: key(mem, name),
325                refusals: introduced,
326            });
327        }
328    }
329    pipeline_store::write_binding(root, mem, name, &patched)?;
330    Ok(patched)
331}
332
333/// Delete a binding. Refuses if `(mem, name)` does not exist. Nothing
334/// references a binding record, so there is no referential gate — the
335/// binding's own state files (advance / findings / watermarks) simply stop
336/// being consulted.
337pub fn delete_binding(root: &Path, mem: &str, name: &str) -> Result<(), PipelineEditError> {
338    let configs = pipeline_store::load_pipeline_configs_strict(root)?;
339    if !binding_exists(&configs, mem, name) {
340        return Err(PipelineEditError::NotFound {
341            primitive: "projection",
342            key: key(mem, name),
343        });
344    }
345    pipeline_store::delete_projection(root, mem, name)?;
346    Ok(())
347}
348
349/// Rename a binding within its mem (`old` → `new`, same `<mem>` tier). A
350/// binding has no embedded name, so a file move is its whole rename.
351/// Refuses a missing source or an existing target. No-op when `old == new`.
352pub fn rename_binding(
353    root: &Path,
354    mem: &str,
355    old: &str,
356    new: &str,
357) -> Result<(), PipelineEditError> {
358    if old == new {
359        return Ok(());
360    }
361    let configs = pipeline_store::load_pipeline_configs_strict(root)?;
362    if !binding_exists(&configs, mem, old) {
363        return Err(PipelineEditError::NotFound {
364            primitive: "projection",
365            key: key(mem, old),
366        });
367    }
368    if binding_exists(&configs, mem, new) {
369        return Err(PipelineEditError::RenameTargetExists {
370            primitive: "projection",
371            key: key(mem, new),
372        });
373    }
374    pipeline_store::rename_projection(root, mem, old, new)?;
375    Ok(())
376}
377
378// --- Engine surface --------------------------------------------------------
379//
380// Thin wrappers that route an edit through the free functions above (disk +
381// in-record validation) and then refresh the engine's in-memory
382// `pipeline_configs` snapshot so a subsequent `pipeline_configs()` read sees
383// the change. They use only the engine's public accessors, so this block stays
384// out of the `engine` module's internals.
385
386impl Engine {
387    fn pipeline_edit_root(&self) -> Result<std::path::PathBuf, PipelineEditError> {
388        self.workspace_root()
389            .map(Path::to_path_buf)
390            .ok_or(PipelineEditError::NoWorkspaceRoot)
391    }
392
393    fn refresh_pipeline_configs(&mut self, root: &Path) -> Result<(), PipelineEditError> {
394        self.set_pipeline_configs(pipeline_store::load_pipeline_configs_strict(root)?);
395        Ok(())
396    }
397
398    /// Provenance bridge with error translation: the disk write has
399    /// already landed when this runs, so a failure here is surfaced as
400    /// `Provenance` (edit live, audit record missing) rather than
401    /// rolling anything back.
402    fn pipeline_provenance(
403        &self,
404        mem: &str,
405        kind: &str,
406        edits: &[(String, Option<Vec<u8>>)],
407        note: Option<&str>,
408        verb: &str,
409    ) -> Result<(), PipelineEditError> {
410        self.record_pipeline_edit_provenance(mem, kind, edits, note, verb)
411            .map_err(|e| PipelineEditError::Provenance(e.to_string()))
412    }
413
414    /// Create a binding from a JSON [`BindingPatch`] applied to the default
415    /// scaffold: the caller may supply any author-editable field, including
416    /// the inline `sources` and a full `operations` block; an absent block
417    /// scaffolds the default `build` (discovery / loop / batch 20). See
418    /// [`add_binding_json`] for the refusals (duplicate, missing
419    /// `destination_mem`, in-record validation).
420    pub fn add_projection_json(
421        &mut self,
422        mem: &str,
423        name: &str,
424        projection_json: &str,
425        note: Option<&str>,
426    ) -> Result<(), PipelineEditError> {
427        let root = self.pipeline_edit_root()?;
428        let binding = add_binding_json(&root, mem, name, projection_json)?;
429        self.record_binding_edit(mem, name, &binding, &root, note, "add")
430    }
431
432    /// Patch a binding from a JSON [`BindingPatch`] — absent fields are
433    /// preserved (tail preservation, extended to every field); explicit
434    /// `null` clears `intent` / `rules` / `prune`; a present `sources` or
435    /// `operations` block replaces that whole block; `version` stays
436    /// engine-managed. See [`update_binding_json`] for the refusals
437    /// (not-found, in-record validation).
438    pub fn update_projection_json(
439        &mut self,
440        mem: &str,
441        name: &str,
442        projection_json: &str,
443        note: Option<&str>,
444    ) -> Result<(), PipelineEditError> {
445        let root = self.pipeline_edit_root()?;
446        let binding = update_binding_json(&root, mem, name, projection_json)?;
447        self.record_binding_edit(mem, name, &binding, &root, note, "update")
448    }
449
450    /// Delete a binding and refresh the snapshot. See [`delete_binding`].
451    pub fn delete_projection(
452        &mut self,
453        mem: &str,
454        name: &str,
455        note: Option<&str>,
456    ) -> Result<(), PipelineEditError> {
457        let root = self.pipeline_edit_root()?;
458        delete_binding(&root, mem, name)?;
459        self.pipeline_provenance(
460            mem,
461            "projections",
462            &[(name.to_string(), None)],
463            note,
464            "delete",
465        )?;
466        self.refresh_pipeline_configs(&root)
467    }
468
469    /// Rename a binding and refresh the snapshot. See [`rename_binding`].
470    pub fn rename_projection(
471        &mut self,
472        mem: &str,
473        old: &str,
474        new: &str,
475        note: Option<&str>,
476    ) -> Result<(), PipelineEditError> {
477        let root = self.pipeline_edit_root()?;
478        rename_binding(&root, mem, old, new)?;
479        // Mirror the rename as remove-old + upsert-new in one commit.
480        // The in-memory snapshot still holds the record under its old
481        // name here (refresh runs after), and a rename changes the
482        // name only — the config bytes are those of the old record.
483        let bytes = self
484            .pipeline_configs()
485            .bindings
486            .iter()
487            .find(|r| r.mem == mem && r.name == old)
488            .map(|r| serde_json::to_vec_pretty(&r.config))
489            .transpose()
490            .map_err(|e| PipelineEditError::InvalidJson {
491                primitive: "config",
492                message: e.to_string(),
493            })?;
494        self.pipeline_provenance(
495            mem,
496            "projections",
497            &[(old.to_string(), None), (new.to_string(), bytes)],
498            note,
499            "rename",
500        )?;
501        self.refresh_pipeline_configs(&root)
502    }
503
504    /// Shared provenance + refresh tail for the binding edit path: the disk
505    /// write has already landed in the free function; record the edit's
506    /// mirror commit and refresh the in-memory snapshot.
507    fn record_binding_edit(
508        &mut self,
509        mem: &str,
510        name: &str,
511        binding: &Binding,
512        root: &Path,
513        note: Option<&str>,
514        verb: &str,
515    ) -> Result<(), PipelineEditError> {
516        let bytes =
517            serde_json::to_vec_pretty(binding).map_err(|e| PipelineEditError::InvalidJson {
518                primitive: "config",
519                message: e.to_string(),
520            })?;
521        self.pipeline_provenance(
522            mem,
523            "projections",
524            &[(name.to_string(), Some(bytes))],
525            note,
526            verb,
527        )?;
528        self.refresh_pipeline_configs(root)
529    }
530}
531
532/// Deserialize a pipeline shape from JSON, mapping a parse failure to a
533/// typed [`PipelineEditError::InvalidJson`] naming the shape.
534fn parse_json<T: serde::de::DeserializeOwned>(
535    json: &str,
536    primitive: &'static str,
537) -> Result<T, PipelineEditError> {
538    serde_json::from_str(json).map_err(|e| PipelineEditError::InvalidJson {
539        primitive,
540        message: e.to_string(),
541    })
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547    use crate::binding::{PruneGuarantee, SyncOperation};
548    use tempfile::TempDir;
549
550    /// The base payload with one inline codebase source.
551    const BASE_PAYLOAD: &str = r#"{
552        "intent": "i",
553        "sources": [{
554            "name": "f",
555            "type": "codebase",
556            "pointer": "../src",
557            "scope": [{ "path": "**/*.rs", "mode": "allow" }]
558        }],
559        "reference_mems": [],
560        "destination_mem": "v",
561        "rules": { "routing": "r" }
562    }"#;
563
564    /// The full record used by the update tests: build+sync+verify over the
565    /// codebase source, deny_paths, curated coverage, rules, prune.
566    fn full_binding_payload() -> &'static str {
567        r#"{
568          "intent": "i",
569          "sources": [{
570              "name": "f",
571              "type": "codebase",
572              "pointer": "../src",
573              "scope": [{ "path": "**/*.rs", "mode": "allow" }]
574          }],
575          "reference_mems": ["r"],
576          "destination_mem": "v",
577          "deny_paths": ["dev/**"],
578          "coverage_semantics": "curated",
579          "rules": { "routing": "r" },
580          "prune": { "guarantee": "never-clobber" },
581          "operations": {
582            "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 },
583            "sync": { "trigger": "manual", "batch_size": 20 },
584            "verify": { "trigger": "manual", "batch_size": 20 }
585          }
586        }"#
587    }
588
589    /// A payload creates a binding scaffolded with the default build block;
590    /// the inline source is written as given.
591    #[test]
592    fn add_binding_json_scaffolds_default_build() {
593        let tmp = TempDir::new().unwrap();
594        let root = tmp.path();
595        let b = add_binding_json(root, "v", "p", BASE_PAYLOAD).unwrap();
596        assert_eq!(b.version, BINDING_VERSION);
597        let build = b.operations.build.as_ref().unwrap();
598        assert_eq!(build.mode, BuildMode::Discovery);
599        assert_eq!(build.batch_size, 20);
600        assert!(b.operations.sync.is_none() && b.operations.verify.is_none());
601        assert_eq!(b.rules, Some(serde_json::json!({ "routing": "r" })));
602        assert_eq!(b.sources.len(), 1);
603        assert_eq!(b.sources[0].name, "f");
604        assert_eq!(pipeline_store::read_binding(root, "v", "p").unwrap(), b);
605    }
606
607    /// A payload declaring the full record — operations block, deny_paths,
608    /// coverage, prune — is written as given (the scaffold is only a fallback).
609    #[test]
610    fn add_binding_json_accepts_the_full_record() {
611        let tmp = TempDir::new().unwrap();
612        let root = tmp.path();
613        let b = add_binding_json(root, "v", "p", full_binding_payload()).unwrap();
614        assert_eq!(
615            b.operations.build.as_ref().unwrap().mode,
616            BuildMode::Discovery
617        );
618        assert_eq!(b.operations.sync.as_ref().unwrap().batch_size, 20);
619        assert!(b.operations.verify.is_some());
620        assert_eq!(b.deny_paths, vec!["dev/**"]);
621        assert_eq!(b.coverage_semantics, Some(CoverageSemantics::Curated));
622        assert_eq!(
623            b.prune.as_ref().unwrap().guarantee,
624            PruneGuarantee::NeverClobber
625        );
626    }
627
628    #[test]
629    fn add_binding_json_refuses_duplicate() {
630        let tmp = TempDir::new().unwrap();
631        let root = tmp.path();
632        add_binding_json(root, "v", "p", BASE_PAYLOAD).unwrap();
633        let err = add_binding_json(root, "v", "p", BASE_PAYLOAD).unwrap_err();
634        assert!(
635            matches!(err, PipelineEditError::AlreadyExists { .. }),
636            "got {err:?}"
637        );
638    }
639
640    #[test]
641    fn add_binding_json_requires_destination_mem() {
642        let tmp = TempDir::new().unwrap();
643        let err = add_binding_json(tmp.path(), "v", "p", r#"{"intent":"i"}"#).unwrap_err();
644        match err {
645            PipelineEditError::InvalidJson { message, .. } => {
646                assert!(message.contains("destination_mem"), "got: {message}")
647            }
648            other => panic!("expected InvalidJson, got {other:?}"),
649        }
650    }
651
652    /// REFUSAL (plan criterion 3) — a malformed source (duplicate name)
653    /// blocks a create with a typed validation error; nothing lands on disk.
654    #[test]
655    fn add_binding_json_refuses_duplicate_source_names() {
656        let tmp = TempDir::new().unwrap();
657        let root = tmp.path();
658        let err = add_binding_json(
659            root,
660            "v",
661            "p",
662            r#"{
663              "sources": [
664                { "name": "dup", "type": "codebase", "pointer": "../a" },
665                { "name": "dup", "type": "codebase", "pointer": "../b" }
666              ],
667              "destination_mem": "v"
668            }"#,
669        )
670        .unwrap_err();
671        match err {
672            PipelineEditError::Capability { refusals, .. } => {
673                assert!(
674                    refusals.iter().any(|r| matches!(
675                        r,
676                        CapabilityError::DuplicateSourceName { name } if name == "dup"
677                    )),
678                    "expected DuplicateSourceName, got {refusals:?}"
679                );
680            }
681            other => panic!("expected Capability, got {other:?}"),
682        }
683        assert!(!root.join(".memstead/projections/v/p.json").exists());
684    }
685
686    /// The capability matrix at the edit seam: declaring sync over a web
687    /// (change-signal-less) source refuses at add time with the matrix's
688    /// typed message.
689    #[test]
690    fn add_binding_json_refuses_capability_violation() {
691        let tmp = TempDir::new().unwrap();
692        let root = tmp.path();
693        let err = add_binding_json(
694            root,
695            "v",
696            "p",
697            r#"{
698              "sources": [{ "name": "wf", "type": "web", "pointer": "https://example.com" }],
699              "destination_mem": "v",
700              "operations": {
701                "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 },
702                "sync": { "trigger": "manual", "batch_size": 20 }
703              }
704            }"#,
705        )
706        .unwrap_err();
707        match &err {
708            PipelineEditError::Capability { refusals, .. } => {
709                assert!(
710                    refusals.iter().any(|r| matches!(
711                        r,
712                        CapabilityError::OperationOutOfScope { operation, .. } if *operation == "sync"
713                    )),
714                    "expected an OperationOutOfScope(sync) refusal, got {refusals:?}"
715                );
716            }
717            other => panic!("expected Capability, got {other:?}"),
718        }
719        assert!(pipeline_store::read_binding(root, "v", "p").is_err());
720    }
721
722    /// Patch semantics: a single-field patch preserves every sibling field —
723    /// the tail-preservation property, extended to the full record.
724    #[test]
725    fn update_binding_json_patch_preserves_untouched_fields() {
726        let tmp = TempDir::new().unwrap();
727        let root = tmp.path();
728        let before = add_binding_json(root, "v", "p", full_binding_payload()).unwrap();
729        let after = update_binding_json(root, "v", "p", r#"{"intent":"new"}"#).unwrap();
730        assert_eq!(after.intent.as_deref(), Some("new"));
731        assert_eq!(after.version, before.version);
732        assert_eq!(after.sources, before.sources);
733        assert_eq!(after.reference_mems, before.reference_mems);
734        assert_eq!(after.destination_mem, before.destination_mem);
735        assert_eq!(after.deny_paths, before.deny_paths);
736        assert_eq!(after.coverage_semantics, before.coverage_semantics);
737        assert_eq!(after.rules, before.rules);
738        assert_eq!(after.prune, before.prune);
739        assert_eq!(after.operations, before.operations);
740    }
741
742    /// Explicit `null` clears the natively-optional fields; absence preserves
743    /// them.
744    #[test]
745    fn update_binding_json_null_clears_and_absence_preserves() {
746        let tmp = TempDir::new().unwrap();
747        let root = tmp.path();
748        add_binding_json(root, "v", "p", full_binding_payload()).unwrap();
749
750        // Absent rules/prune/intent → preserved.
751        let untouched = update_binding_json(root, "v", "p", r#"{"deny_paths":[]}"#).unwrap();
752        assert!(untouched.rules.is_some() && untouched.prune.is_some());
753        assert_eq!(untouched.intent.as_deref(), Some("i"));
754        assert!(untouched.deny_paths.is_empty());
755
756        // Explicit null → cleared.
757        let cleared =
758            update_binding_json(root, "v", "p", r#"{"rules": null, "prune": null}"#).unwrap();
759        assert!(cleared.rules.is_none() && cleared.prune.is_none());
760        assert_eq!(cleared.intent.as_deref(), Some("i"), "intent untouched");
761        assert_eq!(
762            pipeline_store::read_binding(root, "v", "p").unwrap(),
763            cleared
764        );
765    }
766
767    /// A present `operations` block replaces the whole block: ops absent from
768    /// the supplied block are removed (legal — refusal happens at run time).
769    /// A present `sources` block likewise replaces the whole source list.
770    #[test]
771    fn update_binding_json_replaces_whole_blocks() {
772        let tmp = TempDir::new().unwrap();
773        let root = tmp.path();
774        add_binding_json(root, "v", "p", full_binding_payload()).unwrap();
775        let after = update_binding_json(
776            root,
777            "v",
778            "p",
779            r#"{"operations": { "build": { "mode": "discovery", "trigger": "manual", "batch_size": 9 } }}"#,
780        )
781        .unwrap();
782        assert_eq!(after.operations.build.as_ref().unwrap().batch_size, 9);
783        assert!(
784            after.operations.sync.is_none(),
785            "sync removed with the block"
786        );
787        assert!(
788            after.operations.verify.is_none(),
789            "verify removed with the block"
790        );
791        assert_eq!(after.rules, Some(serde_json::json!({ "routing": "r" })));
792
793        let after = update_binding_json(
794            root,
795            "v",
796            "p",
797            r#"{"sources": [{ "name": "g", "type": "filesystem", "pointer": "../docs" }]}"#,
798        )
799        .unwrap();
800        assert_eq!(after.sources.len(), 1);
801        assert_eq!(after.sources[0].name, "g");
802    }
803
804    /// The update seam refuses an *introduced* capability violation, and the
805    /// stored record stays byte-identical.
806    #[test]
807    fn update_binding_json_refuses_introduced_capability_violation() {
808        let tmp = TempDir::new().unwrap();
809        let root = tmp.path();
810        let before = add_binding_json(
811            root,
812            "v",
813            "p",
814            r#"{
815              "sources": [{ "name": "wf", "type": "web", "pointer": "https://example.com" }],
816              "destination_mem": "v"
817            }"#,
818        )
819        .unwrap();
820        let err = update_binding_json(
821            root,
822            "v",
823            "p",
824            r#"{"operations": {
825              "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 },
826              "sync": { "trigger": "manual", "batch_size": 20 }
827            }}"#,
828        )
829        .unwrap_err();
830        assert!(
831            matches!(err, PipelineEditError::Capability { .. }),
832            "got {err:?}"
833        );
834        assert_eq!(
835            pipeline_store::read_binding(root, "v", "p").unwrap(),
836            before,
837            "record unchanged on refusal"
838        );
839    }
840
841    /// A refusal the stored record already produces never blocks an unrelated
842    /// edit — pre-existing config is not this edit's to answer for.
843    #[test]
844    fn update_binding_json_allows_edit_despite_preexisting_refusal() {
845        let tmp = TempDir::new().unwrap();
846        let root = tmp.path();
847        // Bypass validation: a sync-over-web binding already on disk (the
848        // store layer is dumb on purpose).
849        let broken = Binding {
850            version: BINDING_VERSION,
851            intent: None,
852            sources: vec![Source {
853                name: "wf".to_string(),
854                medium_type: crate::pipeline::MediumType::Web,
855                pointer: "https://example.com".to_string(),
856                change_detection: None,
857                scope: vec![],
858                engagement: None,
859                preparation: None,
860            }],
861            reference_mems: vec![],
862            destination_mem: "v".to_string(),
863            deny_paths: vec![],
864            coverage_semantics: None,
865            rules: None,
866            prune: None,
867            operations: Operations {
868                build: Some(BuildOperation {
869                    mode: BuildMode::Discovery,
870                    trigger: IngestTrigger::Loop,
871                    batch_size: 20,
872                    post_actions: None,
873                }),
874                sync: Some(SyncOperation {
875                    trigger: IngestTrigger::Manual,
876                    batch_size: 20,
877                }),
878                verify: None,
879            },
880        };
881        pipeline_store::write_binding(root, "v", "p", &broken).unwrap();
882
883        let after = update_binding_json(root, "v", "p", r#"{"intent":"fixed"}"#).unwrap();
884        assert_eq!(after.intent.as_deref(), Some("fixed"));
885        assert!(
886            after.operations.sync.is_some(),
887            "pre-existing sync survives"
888        );
889
890        // But a patch introducing a NEW refusal (duplicate source names) blocks.
891        let err = update_binding_json(
892            root,
893            "v",
894            "p",
895            r#"{"sources": [
896                { "name": "dup", "type": "web", "pointer": "https://a" },
897                { "name": "dup", "type": "web", "pointer": "https://b" }
898            ]}"#,
899        )
900        .unwrap_err();
901        assert!(
902            matches!(err, PipelineEditError::Capability { .. }),
903            "got {err:?}"
904        );
905    }
906
907    #[test]
908    fn update_binding_json_refuses_missing_record() {
909        let tmp = TempDir::new().unwrap();
910        let err = update_binding_json(tmp.path(), "v", "p", r#"{"intent":"x"}"#).unwrap_err();
911        assert!(
912            matches!(err, PipelineEditError::NotFound { .. }),
913            "got {err:?}"
914        );
915    }
916
917    /// Unknown additive keys are tolerated and `version` is engine-managed —
918    /// a payload carrying either never fails, never moves the version.
919    #[test]
920    fn update_binding_json_ignores_unknown_keys_and_version() {
921        let tmp = TempDir::new().unwrap();
922        let root = tmp.path();
923        add_binding_json(root, "v", "p", BASE_PAYLOAD).unwrap();
924        let after = update_binding_json(
925            root,
926            "v",
927            "p",
928            r#"{"version": 99, "future_key": { "x": 1 }, "intent": "i2"}"#,
929        )
930        .unwrap();
931        assert_eq!(
932            after.version, BINDING_VERSION,
933            "version stays engine-managed"
934        );
935        assert_eq!(after.intent.as_deref(), Some("i2"));
936    }
937
938    #[test]
939    fn delete_binding_removes_the_record() {
940        let tmp = TempDir::new().unwrap();
941        let root = tmp.path();
942        add_binding_json(root, "v", "p", BASE_PAYLOAD).unwrap();
943        delete_binding(root, "v", "p").unwrap();
944        assert!(!root.join(".memstead/projections/v/p.json").exists());
945        let err = delete_binding(root, "v", "p").unwrap_err();
946        assert!(
947            matches!(err, PipelineEditError::NotFound { .. }),
948            "got {err:?}"
949        );
950    }
951
952    #[test]
953    fn rename_binding_moves_the_record() {
954        let tmp = TempDir::new().unwrap();
955        let root = tmp.path();
956        let created = add_binding_json(root, "v", "old", BASE_PAYLOAD).unwrap();
957        rename_binding(root, "v", "old", "new").unwrap();
958        assert!(!root.join(".memstead/projections/v/old.json").exists());
959        assert_eq!(
960            pipeline_store::read_binding(root, "v", "new").unwrap(),
961            created
962        );
963    }
964
965    #[test]
966    fn rename_binding_refuses_existing_target_and_missing_source() {
967        let tmp = TempDir::new().unwrap();
968        let root = tmp.path();
969        add_binding_json(root, "v", "a", BASE_PAYLOAD).unwrap();
970        add_binding_json(root, "v", "b", BASE_PAYLOAD).unwrap();
971        let err = rename_binding(root, "v", "a", "b").unwrap_err();
972        assert!(
973            matches!(err, PipelineEditError::RenameTargetExists { .. }),
974            "got {err:?}"
975        );
976        let err = rename_binding(root, "v", "missing", "c").unwrap_err();
977        assert!(
978            matches!(err, PipelineEditError::NotFound { .. }),
979            "got {err:?}"
980        );
981        // No-op rename is fine.
982        rename_binding(root, "v", "a", "a").unwrap();
983    }
984
985    /// REFUSAL (plan criterion 1) — editing an unmigrated (pre-v2) store
986    /// surfaces the loader's migrate-naming refusal — the edit layer never
987    /// writes over a legacy store.
988    #[test]
989    fn editing_a_pre_v2_store_refuses_with_migrate_pointer() {
990        let tmp = TempDir::new().unwrap();
991        let root = tmp.path();
992        let dir = root.join(".memstead/projections/v");
993        std::fs::create_dir_all(&dir).unwrap();
994        std::fs::write(
995            dir.join("p.json"),
996            br#"{"version": 1, "source_facets": ["f"], "destination_mem": "v", "operations": {}}"#,
997        )
998        .unwrap();
999        let err = add_binding_json(root, "v", "q", BASE_PAYLOAD).unwrap_err();
1000        match err {
1001            PipelineEditError::Store(StoreError::LegacyProjectionStore { .. }) => {}
1002            other => panic!("expected LegacyProjectionStore, got {other:?}"),
1003        }
1004    }
1005}