Skip to main content

memstead_base/
pipeline_edit.rs

1//! Edit operations over the four-primitive pipeline store, with referential
2//! integrity.
3//!
4//! Sits above the dumb file ops in [`crate::pipeline_store`]: each function
5//! loads the current store, enforces the Medium ← Facet ← Projection ← Ingest
6//! reference model (no clobber, no dangling reference), then writes. The
7//! engine's in-memory `pipeline_configs` cache is refreshed by the `Engine`
8//! wrapper methods that call these — not here — so these functions stay pure
9//! disk ops that unit-test with a bare `TempDir`.
10//!
11//! Identity is the file stem `(mem, name)`. Mediums and facets additionally
12//! carry an embedded `name` field kept equal to the stem (facets reference
13//! mediums by name, projections reference facets by name); rename here updates
14//! the file location, the embedded field, and every dependent reference
15//! together, so the store never holds a self-inconsistent or dangling record.
16//!
17//! Scope: medium / facet / projection — the editing chain the macOS app's
18//! pipeline editor drives. Ingests are read for integrity (a projection can't
19//! be deleted while an ingest runs it) and repointed on a projection rename,
20//! but interactive ingest editing has no consumer yet and is not exposed here.
21
22use std::path::Path;
23
24use crate::engine::Engine;
25use crate::pipeline::{Facet, Medium, Projection};
26use crate::pipeline_store::{self, PipelineConfigs};
27use crate::workspace_store::StoreError;
28
29/// Failure modes of a pipeline edit. Distinct from the entity-centric
30/// [`crate::engine::EngineError`] — these describe four-primitive store edits.
31/// `key` is the display identity: `"<mem>/<name>"`.
32#[derive(Debug, thiserror::Error)]
33pub enum PipelineEditError {
34    /// The engine was not booted from a workspace root, so there is no
35    /// four-primitive store to edit (e.g. an engine built from a bare mount
36    /// list in a test or in-memory consumer).
37    #[error("engine has no workspace root — pipeline edits require a workspace-backed engine")]
38    NoWorkspaceRoot,
39    /// The edit landed on disk but its provenance record (the
40    /// `__MEMSTEAD` mirror commit carrying the note) could not be
41    /// committed. The disk state is live; the audit trail is missing
42    /// this event — callers surface it rather than silently dropping
43    /// the note.
44    #[error("pipeline edit landed, but recording provenance failed: {0}")]
45    Provenance(String),
46    /// A create targeted a `(mem, name)` that already holds a record.
47    #[error("{primitive} '{key}' already exists")]
48    AlreadyExists {
49        primitive: &'static str,
50        key: String,
51    },
52    /// An update / delete / rename targeted a record that does not exist.
53    #[error("{primitive} '{key}' does not exist")]
54    NotFound {
55        primitive: &'static str,
56        key: String,
57    },
58    /// A delete was refused because other records still reference the target.
59    #[error("{primitive} '{key}' is referenced by {referrers:?} — remove or repoint them first")]
60    Referenced {
61        primitive: &'static str,
62        key: String,
63        referrers: Vec<String>,
64    },
65    /// A rename target `(mem, new)` already holds a record.
66    #[error("rename target {primitive} '{key}' already exists")]
67    RenameTargetExists {
68        primitive: &'static str,
69        key: String,
70    },
71    /// A JSON-string edit entry point received a payload that did not
72    /// deserialize into the target primitive.
73    #[error("invalid {primitive} JSON: {message}")]
74    InvalidJson {
75        primitive: &'static str,
76        message: String,
77    },
78    /// Underlying store IO / parse failure.
79    #[error(transparent)]
80    Store(#[from] StoreError),
81}
82
83fn key(mem: &str, name: &str) -> String {
84    format!("{mem}/{name}")
85}
86
87fn medium_exists(c: &PipelineConfigs, mem: &str, name: &str) -> bool {
88    c.mediums.iter().any(|r| r.mem == mem && r.name == name)
89}
90
91fn facet_exists(c: &PipelineConfigs, mem: &str, name: &str) -> bool {
92    c.facets.iter().any(|r| r.mem == mem && r.name == name)
93}
94
95fn projection_exists(c: &PipelineConfigs, mem: &str, name: &str) -> bool {
96    c.projections.iter().any(|r| r.mem == mem && r.name == name)
97}
98
99/// Facet names (same mem) whose `medium` points at `name`.
100fn facets_referencing_medium(c: &PipelineConfigs, mem: &str, name: &str) -> Vec<String> {
101    c.facets
102        .iter()
103        .filter(|r| r.mem == mem && r.config.medium == name)
104        .map(|r| r.name.clone())
105        .collect()
106}
107
108/// Projection names (same mem) whose `source_facets` contain `name`.
109fn projections_referencing_facet(c: &PipelineConfigs, mem: &str, name: &str) -> Vec<String> {
110    c.projections
111        .iter()
112        .filter(|r| r.mem == mem && r.config.source_facets.iter().any(|f| f == name))
113        .map(|r| r.name.clone())
114        .collect()
115}
116
117/// Ingest names whose `projection` points at `<mem>/<name>`.
118fn ingests_referencing_projection(c: &PipelineConfigs, mem: &str, name: &str) -> Vec<String> {
119    let target = key(mem, name);
120    c.ingests
121        .iter()
122        .filter(|r| r.config.projection == target)
123        .map(|r| r.name.clone())
124        .collect()
125}
126
127// --- Medium ----------------------------------------------------------------
128
129/// Create a medium. Refuses if `(mem, name)` already holds one.
130pub fn add_medium(
131    root: &Path,
132    mem: &str,
133    name: &str,
134    medium: &Medium,
135) -> Result<(), PipelineEditError> {
136    let configs = pipeline_store::load_legacy_pipeline_configs(root)?;
137    if medium_exists(&configs, mem, name) {
138        return Err(PipelineEditError::AlreadyExists {
139            primitive: "medium",
140            key: key(mem, name),
141        });
142    }
143    pipeline_store::write_medium(root, mem, name, medium)?;
144    Ok(())
145}
146
147/// Overwrite an existing medium. Refuses if `(mem, name)` does not exist.
148pub fn update_medium(
149    root: &Path,
150    mem: &str,
151    name: &str,
152    medium: &Medium,
153) -> Result<(), PipelineEditError> {
154    let configs = pipeline_store::load_legacy_pipeline_configs(root)?;
155    if !medium_exists(&configs, mem, name) {
156        return Err(PipelineEditError::NotFound {
157            primitive: "medium",
158            key: key(mem, name),
159        });
160    }
161    pipeline_store::write_medium(root, mem, name, medium)?;
162    Ok(())
163}
164
165/// Delete a medium. Refuses if any facet in the same mem still references it.
166pub fn delete_medium(root: &Path, mem: &str, name: &str) -> Result<(), PipelineEditError> {
167    let configs = pipeline_store::load_legacy_pipeline_configs(root)?;
168    if !medium_exists(&configs, mem, name) {
169        return Err(PipelineEditError::NotFound {
170            primitive: "medium",
171            key: key(mem, name),
172        });
173    }
174    let referrers = facets_referencing_medium(&configs, mem, name);
175    if !referrers.is_empty() {
176        return Err(PipelineEditError::Referenced {
177            primitive: "medium",
178            key: key(mem, name),
179            referrers,
180        });
181    }
182    pipeline_store::delete_medium(root, mem, name)?;
183    Ok(())
184}
185
186/// Rename a medium within its mem, updating its embedded `name` and every
187/// dependent facet's `medium` reference. No-op when `old == new`.
188pub fn rename_medium(
189    root: &Path,
190    mem: &str,
191    old: &str,
192    new: &str,
193) -> Result<(), PipelineEditError> {
194    if old == new {
195        return Ok(());
196    }
197    let configs = pipeline_store::load_legacy_pipeline_configs(root)?;
198    let existing = configs
199        .mediums
200        .iter()
201        .find(|r| r.mem == mem && r.name == old)
202        .ok_or_else(|| PipelineEditError::NotFound {
203            primitive: "medium",
204            key: key(mem, old),
205        })?;
206    if medium_exists(&configs, mem, new) {
207        return Err(PipelineEditError::RenameTargetExists {
208            primitive: "medium",
209            key: key(mem, new),
210        });
211    }
212    let mut renamed = existing.config.clone();
213    renamed.name = new.to_string();
214    // Write the new stem first, then repoint referrers, then drop the old —
215    // no point in the sequence leaves a facet pointing at a missing medium.
216    pipeline_store::write_medium(root, mem, new, &renamed)?;
217    for facet in facets_referencing_medium(&configs, mem, old) {
218        if let Some(rec) = configs
219            .facets
220            .iter()
221            .find(|r| r.mem == mem && r.name == facet)
222        {
223            let mut updated = rec.config.clone();
224            updated.medium = new.to_string();
225            pipeline_store::write_facet(root, mem, &facet, &updated)?;
226        }
227    }
228    pipeline_store::delete_medium(root, mem, old)?;
229    Ok(())
230}
231
232// --- Facet -----------------------------------------------------------------
233
234/// Create a facet. Refuses if `(mem, name)` already holds one.
235pub fn add_facet(
236    root: &Path,
237    mem: &str,
238    name: &str,
239    facet: &Facet,
240) -> Result<(), PipelineEditError> {
241    let configs = pipeline_store::load_legacy_pipeline_configs(root)?;
242    if facet_exists(&configs, mem, name) {
243        return Err(PipelineEditError::AlreadyExists {
244            primitive: "facet",
245            key: key(mem, name),
246        });
247    }
248    pipeline_store::write_facet(root, mem, name, facet)?;
249    Ok(())
250}
251
252/// Overwrite an existing facet. Refuses if `(mem, name)` does not exist.
253pub fn update_facet(
254    root: &Path,
255    mem: &str,
256    name: &str,
257    facet: &Facet,
258) -> Result<(), PipelineEditError> {
259    let configs = pipeline_store::load_legacy_pipeline_configs(root)?;
260    if !facet_exists(&configs, mem, name) {
261        return Err(PipelineEditError::NotFound {
262            primitive: "facet",
263            key: key(mem, name),
264        });
265    }
266    pipeline_store::write_facet(root, mem, name, facet)?;
267    Ok(())
268}
269
270/// Delete a facet. Refuses if any projection in the same mem references it.
271pub fn delete_facet(root: &Path, mem: &str, name: &str) -> Result<(), PipelineEditError> {
272    let configs = pipeline_store::load_legacy_pipeline_configs(root)?;
273    if !facet_exists(&configs, mem, name) {
274        return Err(PipelineEditError::NotFound {
275            primitive: "facet",
276            key: key(mem, name),
277        });
278    }
279    let referrers = projections_referencing_facet(&configs, mem, name);
280    if !referrers.is_empty() {
281        return Err(PipelineEditError::Referenced {
282            primitive: "facet",
283            key: key(mem, name),
284            referrers,
285        });
286    }
287    pipeline_store::delete_facet(root, mem, name)?;
288    Ok(())
289}
290
291/// Rename a facet within its mem, updating its embedded `name` and every
292/// dependent projection's `source_facets` entry. No-op when `old == new`.
293pub fn rename_facet(root: &Path, mem: &str, old: &str, new: &str) -> Result<(), PipelineEditError> {
294    if old == new {
295        return Ok(());
296    }
297    let configs = pipeline_store::load_legacy_pipeline_configs(root)?;
298    let existing = configs
299        .facets
300        .iter()
301        .find(|r| r.mem == mem && r.name == old)
302        .ok_or_else(|| PipelineEditError::NotFound {
303            primitive: "facet",
304            key: key(mem, old),
305        })?;
306    if facet_exists(&configs, mem, new) {
307        return Err(PipelineEditError::RenameTargetExists {
308            primitive: "facet",
309            key: key(mem, new),
310        });
311    }
312    let mut renamed = existing.config.clone();
313    renamed.name = new.to_string();
314    pipeline_store::write_facet(root, mem, new, &renamed)?;
315    for proj in projections_referencing_facet(&configs, mem, old) {
316        if let Some(rec) = configs
317            .projections
318            .iter()
319            .find(|r| r.mem == mem && r.name == proj)
320        {
321            let mut updated = rec.config.clone();
322            for f in updated.source_facets.iter_mut() {
323                if f == old {
324                    *f = new.to_string();
325                }
326            }
327            pipeline_store::write_projection(root, mem, &proj, &updated)?;
328        }
329    }
330    pipeline_store::delete_facet(root, mem, old)?;
331    Ok(())
332}
333
334// --- Projection ------------------------------------------------------------
335
336/// Create a projection. Refuses if `(mem, name)` already holds one.
337pub fn add_projection(
338    root: &Path,
339    mem: &str,
340    name: &str,
341    projection: &Projection,
342) -> Result<(), PipelineEditError> {
343    let configs = pipeline_store::load_legacy_pipeline_configs(root)?;
344    if projection_exists(&configs, mem, name) {
345        return Err(PipelineEditError::AlreadyExists {
346            primitive: "projection",
347            key: key(mem, name),
348        });
349    }
350    pipeline_store::write_projection(root, mem, name, projection)?;
351    Ok(())
352}
353
354/// Overwrite an existing projection. Refuses if `(mem, name)` does not exist.
355pub fn update_projection(
356    root: &Path,
357    mem: &str,
358    name: &str,
359    projection: &Projection,
360) -> Result<(), PipelineEditError> {
361    let configs = pipeline_store::load_legacy_pipeline_configs(root)?;
362    if !projection_exists(&configs, mem, name) {
363        return Err(PipelineEditError::NotFound {
364            primitive: "projection",
365            key: key(mem, name),
366        });
367    }
368    pipeline_store::write_projection(root, mem, name, projection)?;
369    Ok(())
370}
371
372/// Delete a projection. Refuses if any ingest still runs it.
373pub fn delete_projection(root: &Path, mem: &str, name: &str) -> Result<(), PipelineEditError> {
374    let configs = pipeline_store::load_legacy_pipeline_configs(root)?;
375    if !projection_exists(&configs, mem, name) {
376        return Err(PipelineEditError::NotFound {
377            primitive: "projection",
378            key: key(mem, name),
379        });
380    }
381    let referrers = ingests_referencing_projection(&configs, mem, name);
382    if !referrers.is_empty() {
383        return Err(PipelineEditError::Referenced {
384            primitive: "projection",
385            key: key(mem, name),
386            referrers,
387        });
388    }
389    pipeline_store::delete_projection(root, mem, name)?;
390    Ok(())
391}
392
393/// Rename a projection within its mem (a projection has no embedded name, so
394/// the file moves), repointing every ingest whose `projection` was
395/// `<mem>/<old>`. No-op when `old == new`.
396pub fn rename_projection(
397    root: &Path,
398    mem: &str,
399    old: &str,
400    new: &str,
401) -> Result<(), PipelineEditError> {
402    if old == new {
403        return Ok(());
404    }
405    let configs = pipeline_store::load_legacy_pipeline_configs(root)?;
406    if !projection_exists(&configs, mem, old) {
407        return Err(PipelineEditError::NotFound {
408            primitive: "projection",
409            key: key(mem, old),
410        });
411    }
412    if projection_exists(&configs, mem, new) {
413        return Err(PipelineEditError::RenameTargetExists {
414            primitive: "projection",
415            key: key(mem, new),
416        });
417    }
418    pipeline_store::rename_projection(root, mem, old, new)?;
419    let new_ref = key(mem, new);
420    for ingest in ingests_referencing_projection(&configs, mem, old) {
421        if let Some(rec) = configs.ingests.iter().find(|r| r.name == ingest) {
422            let mut updated = rec.config.clone();
423            updated.projection = new_ref.clone();
424            pipeline_store::write_ingest(root, &ingest, &updated)?;
425        }
426    }
427    Ok(())
428}
429
430// --- Engine surface --------------------------------------------------------
431//
432// Thin wrappers that route an edit through the free functions above (disk +
433// referential integrity) and then refresh the engine's in-memory
434// `pipeline_configs` snapshot so a subsequent `pipeline_configs()` read sees
435// the change. They use only the engine's public accessors, so this block stays
436// out of the `engine` module's internals.
437
438impl Engine {
439    fn pipeline_edit_root(&self) -> Result<std::path::PathBuf, PipelineEditError> {
440        self.workspace_root()
441            .map(Path::to_path_buf)
442            .ok_or(PipelineEditError::NoWorkspaceRoot)
443    }
444
445    fn refresh_pipeline_configs(&mut self, root: &Path) -> Result<(), PipelineEditError> {
446        // The referential-integrity edit layer operates on the four-primitive
447        // (legacy) shape — the version-gated binding loader is the live brief
448        // path's, not the editor's.
449        self.set_pipeline_configs(pipeline_store::load_legacy_pipeline_configs(root)?);
450        Ok(())
451    }
452
453    /// Provenance bridge with error translation: the disk write has
454    /// already landed when this runs, so a failure here is surfaced as
455    /// `Provenance` (edit live, audit record missing) rather than
456    /// rolling anything back.
457    fn pipeline_provenance(
458        &self,
459        mem: &str,
460        kind: &str,
461        edits: &[(String, Option<Vec<u8>>)],
462        note: Option<&str>,
463        verb: &str,
464    ) -> Result<(), PipelineEditError> {
465        self.record_pipeline_edit_provenance(mem, kind, edits, note, verb)
466            .map_err(|e| PipelineEditError::Provenance(e.to_string()))
467    }
468
469    /// Create a medium and refresh the in-memory snapshot. See [`add_medium`].
470    pub fn add_medium(
471        &mut self,
472        mem: &str,
473        name: &str,
474        medium: &Medium,
475        note: Option<&str>,
476    ) -> Result<(), PipelineEditError> {
477        let root = self.pipeline_edit_root()?;
478        add_medium(&root, mem, name, medium)?;
479        let bytes =
480            serde_json::to_vec_pretty(medium).map_err(|e| PipelineEditError::InvalidJson {
481                primitive: "config",
482                message: e.to_string(),
483            })?;
484        self.pipeline_provenance(
485            mem,
486            "mediums",
487            &[(name.to_string(), Some(bytes))],
488            note,
489            "add",
490        )?;
491        self.refresh_pipeline_configs(&root)
492    }
493
494    /// Overwrite a medium and refresh the snapshot. See [`update_medium`].
495    pub fn update_medium(
496        &mut self,
497        mem: &str,
498        name: &str,
499        medium: &Medium,
500        note: Option<&str>,
501    ) -> Result<(), PipelineEditError> {
502        let root = self.pipeline_edit_root()?;
503        update_medium(&root, mem, name, medium)?;
504        let bytes =
505            serde_json::to_vec_pretty(medium).map_err(|e| PipelineEditError::InvalidJson {
506                primitive: "config",
507                message: e.to_string(),
508            })?;
509        self.pipeline_provenance(
510            mem,
511            "mediums",
512            &[(name.to_string(), Some(bytes))],
513            note,
514            "update",
515        )?;
516        self.refresh_pipeline_configs(&root)
517    }
518
519    /// Delete a medium and refresh the snapshot. See [`delete_medium`].
520    pub fn delete_medium(
521        &mut self,
522        mem: &str,
523        name: &str,
524        note: Option<&str>,
525    ) -> Result<(), PipelineEditError> {
526        let root = self.pipeline_edit_root()?;
527        delete_medium(&root, mem, name)?;
528        self.pipeline_provenance(mem, "mediums", &[(name.to_string(), None)], note, "delete")?;
529        self.refresh_pipeline_configs(&root)
530    }
531
532    /// Rename a medium and refresh the snapshot. See [`rename_medium`].
533    pub fn rename_medium(
534        &mut self,
535        mem: &str,
536        old: &str,
537        new: &str,
538        note: Option<&str>,
539    ) -> Result<(), PipelineEditError> {
540        let root = self.pipeline_edit_root()?;
541        rename_medium(&root, mem, old, new)?;
542        // Mirror the rename as remove-old + upsert-new in one commit.
543        // The in-memory snapshot still holds the record under its old
544        // name here (refresh runs after), and a rename changes the
545        // name only — the config bytes are those of the old record.
546        let bytes = self
547            .pipeline_configs()
548            .mediums
549            .iter()
550            .find(|r| r.mem == mem && r.name == old)
551            .map(|r| serde_json::to_vec_pretty(&r.config))
552            .transpose()
553            .map_err(|e| PipelineEditError::InvalidJson {
554                primitive: "config",
555                message: e.to_string(),
556            })?;
557        self.pipeline_provenance(
558            mem,
559            "mediums",
560            &[(old.to_string(), None), (new.to_string(), bytes)],
561            note,
562            "rename",
563        )?;
564        self.refresh_pipeline_configs(&root)
565    }
566
567    /// Create a facet and refresh the snapshot. See [`add_facet`].
568    pub fn add_facet(
569        &mut self,
570        mem: &str,
571        name: &str,
572        facet: &Facet,
573        note: Option<&str>,
574    ) -> Result<(), PipelineEditError> {
575        let root = self.pipeline_edit_root()?;
576        add_facet(&root, mem, name, facet)?;
577        let bytes =
578            serde_json::to_vec_pretty(facet).map_err(|e| PipelineEditError::InvalidJson {
579                primitive: "config",
580                message: e.to_string(),
581            })?;
582        self.pipeline_provenance(
583            mem,
584            "facets",
585            &[(name.to_string(), Some(bytes))],
586            note,
587            "add",
588        )?;
589        self.refresh_pipeline_configs(&root)
590    }
591
592    /// Overwrite a facet and refresh the snapshot. See [`update_facet`].
593    pub fn update_facet(
594        &mut self,
595        mem: &str,
596        name: &str,
597        facet: &Facet,
598        note: Option<&str>,
599    ) -> Result<(), PipelineEditError> {
600        let root = self.pipeline_edit_root()?;
601        update_facet(&root, mem, name, facet)?;
602        let bytes =
603            serde_json::to_vec_pretty(facet).map_err(|e| PipelineEditError::InvalidJson {
604                primitive: "config",
605                message: e.to_string(),
606            })?;
607        self.pipeline_provenance(
608            mem,
609            "facets",
610            &[(name.to_string(), Some(bytes))],
611            note,
612            "update",
613        )?;
614        self.refresh_pipeline_configs(&root)
615    }
616
617    /// Delete a facet and refresh the snapshot. See [`delete_facet`].
618    pub fn delete_facet(
619        &mut self,
620        mem: &str,
621        name: &str,
622        note: Option<&str>,
623    ) -> Result<(), PipelineEditError> {
624        let root = self.pipeline_edit_root()?;
625        delete_facet(&root, mem, name)?;
626        self.pipeline_provenance(mem, "facets", &[(name.to_string(), None)], note, "delete")?;
627        self.refresh_pipeline_configs(&root)
628    }
629
630    /// Rename a facet and refresh the snapshot. See [`rename_facet`].
631    pub fn rename_facet(
632        &mut self,
633        mem: &str,
634        old: &str,
635        new: &str,
636        note: Option<&str>,
637    ) -> Result<(), PipelineEditError> {
638        let root = self.pipeline_edit_root()?;
639        rename_facet(&root, mem, old, new)?;
640        // Mirror the rename as remove-old + upsert-new in one commit.
641        // The in-memory snapshot still holds the record under its old
642        // name here (refresh runs after), and a rename changes the
643        // name only — the config bytes are those of the old record.
644        let bytes = self
645            .pipeline_configs()
646            .facets
647            .iter()
648            .find(|r| r.mem == mem && r.name == old)
649            .map(|r| serde_json::to_vec_pretty(&r.config))
650            .transpose()
651            .map_err(|e| PipelineEditError::InvalidJson {
652                primitive: "config",
653                message: e.to_string(),
654            })?;
655        self.pipeline_provenance(
656            mem,
657            "facets",
658            &[(old.to_string(), None), (new.to_string(), bytes)],
659            note,
660            "rename",
661        )?;
662        self.refresh_pipeline_configs(&root)
663    }
664
665    /// Create a projection and refresh the snapshot. See [`add_projection`].
666    pub fn add_projection(
667        &mut self,
668        mem: &str,
669        name: &str,
670        projection: &Projection,
671        note: Option<&str>,
672    ) -> Result<(), PipelineEditError> {
673        let root = self.pipeline_edit_root()?;
674        add_projection(&root, mem, name, projection)?;
675        let bytes =
676            serde_json::to_vec_pretty(projection).map_err(|e| PipelineEditError::InvalidJson {
677                primitive: "config",
678                message: e.to_string(),
679            })?;
680        self.pipeline_provenance(
681            mem,
682            "projections",
683            &[(name.to_string(), Some(bytes))],
684            note,
685            "add",
686        )?;
687        self.refresh_pipeline_configs(&root)
688    }
689
690    /// Overwrite a projection and refresh the snapshot. See [`update_projection`].
691    pub fn update_projection(
692        &mut self,
693        mem: &str,
694        name: &str,
695        projection: &Projection,
696        note: Option<&str>,
697    ) -> Result<(), PipelineEditError> {
698        let root = self.pipeline_edit_root()?;
699        update_projection(&root, mem, name, projection)?;
700        let bytes =
701            serde_json::to_vec_pretty(projection).map_err(|e| PipelineEditError::InvalidJson {
702                primitive: "config",
703                message: e.to_string(),
704            })?;
705        self.pipeline_provenance(
706            mem,
707            "projections",
708            &[(name.to_string(), Some(bytes))],
709            note,
710            "update",
711        )?;
712        self.refresh_pipeline_configs(&root)
713    }
714
715    /// Delete a projection and refresh the snapshot. See [`delete_projection`].
716    pub fn delete_projection(
717        &mut self,
718        mem: &str,
719        name: &str,
720        note: Option<&str>,
721    ) -> Result<(), PipelineEditError> {
722        let root = self.pipeline_edit_root()?;
723        delete_projection(&root, mem, name)?;
724        self.pipeline_provenance(
725            mem,
726            "projections",
727            &[(name.to_string(), None)],
728            note,
729            "delete",
730        )?;
731        self.refresh_pipeline_configs(&root)
732    }
733
734    /// Rename a projection and refresh the snapshot. See [`rename_projection`].
735    pub fn rename_projection(
736        &mut self,
737        mem: &str,
738        old: &str,
739        new: &str,
740        note: Option<&str>,
741    ) -> Result<(), PipelineEditError> {
742        let root = self.pipeline_edit_root()?;
743        rename_projection(&root, mem, old, new)?;
744        // Mirror the rename as remove-old + upsert-new in one commit.
745        // The in-memory snapshot still holds the record under its old
746        // name here (refresh runs after), and a rename changes the
747        // name only — the config bytes are those of the old record.
748        let bytes = self
749            .pipeline_configs()
750            .projections
751            .iter()
752            .find(|r| r.mem == mem && r.name == old)
753            .map(|r| serde_json::to_vec_pretty(&r.config))
754            .transpose()
755            .map_err(|e| PipelineEditError::InvalidJson {
756                primitive: "config",
757                message: e.to_string(),
758            })?;
759        self.pipeline_provenance(
760            mem,
761            "projections",
762            &[(old.to_string(), None), (new.to_string(), bytes)],
763            note,
764            "rename",
765        )?;
766        self.refresh_pipeline_configs(&root)
767    }
768
769    // JSON-string entry points for serialization-boundary callers (UniFFI,
770    // CLI) that carry a primitive as JSON rather than a typed value. They
771    // deserialize here — where serde already lives — and delegate to the
772    // typed methods above, so the FFI translation layer needs no JSON
773    // dependency of its own. Only `add`/`update` carry a payload; `delete`
774    // and `rename` take plain string identifiers and use the typed methods
775    // directly.
776
777    /// [`Self::add_medium`] from a JSON-encoded [`Medium`].
778    pub fn add_medium_json(
779        &mut self,
780        mem: &str,
781        name: &str,
782        medium_json: &str,
783        note: Option<&str>,
784    ) -> Result<(), PipelineEditError> {
785        self.add_medium(mem, name, &parse_json(medium_json, "medium")?, note)
786    }
787
788    /// [`Self::update_medium`] from a JSON-encoded [`Medium`].
789    pub fn update_medium_json(
790        &mut self,
791        mem: &str,
792        name: &str,
793        medium_json: &str,
794        note: Option<&str>,
795    ) -> Result<(), PipelineEditError> {
796        self.update_medium(mem, name, &parse_json(medium_json, "medium")?, note)
797    }
798
799    /// [`Self::add_facet`] from a JSON-encoded [`Facet`].
800    pub fn add_facet_json(
801        &mut self,
802        mem: &str,
803        name: &str,
804        facet_json: &str,
805        note: Option<&str>,
806    ) -> Result<(), PipelineEditError> {
807        self.add_facet(mem, name, &parse_json(facet_json, "facet")?, note)
808    }
809
810    /// [`Self::update_facet`] from a JSON-encoded [`Facet`].
811    pub fn update_facet_json(
812        &mut self,
813        mem: &str,
814        name: &str,
815        facet_json: &str,
816        note: Option<&str>,
817    ) -> Result<(), PipelineEditError> {
818        self.update_facet(mem, name, &parse_json(facet_json, "facet")?, note)
819    }
820
821    /// Add a projection from a JSON-encoded [`Projection`], scaffolding a v1
822    /// [`BindingV1`] around it (D14). The caller supplies the projection-level
823    /// fields (`intent` / `source_facets` / `reference_mems` /
824    /// `destination_mem` / `rules`); the engine wraps them in a fresh binding
825    /// with a default `operations.build` block (discovery / loop / batch 20)
826    /// and empty `deny_paths`, then writes the versioned binding file. This is
827    /// the projection-update path D14 routes operations-block edits through —
828    /// the app edits the projection fields, the engine owns the binding shape.
829    pub fn add_projection_json(
830        &mut self,
831        mem: &str,
832        name: &str,
833        projection_json: &str,
834        note: Option<&str>,
835    ) -> Result<(), PipelineEditError> {
836        let root = self.pipeline_edit_root()?;
837        let incoming: Projection = parse_json(projection_json, "projection")?;
838        let binding = crate::binding::BindingV1 {
839            version: crate::binding::BINDING_VERSION,
840            intent: incoming.intent,
841            source_facets: incoming.source_facets,
842            reference_mems: incoming.reference_mems,
843            destination_mem: incoming.destination_mem,
844            deny_paths: Vec::new(),
845            coverage_semantics: crate::binding::CoverageSemantics::default(),
846            rules: incoming.rules,
847            prune: None,
848            operations: crate::binding::Operations {
849                build: Some(crate::binding::BuildOperation {
850                    mode: crate::binding::BuildMode::Discovery,
851                    trigger: crate::pipeline::IngestTrigger::Loop,
852                    batch_size: 20,
853                    post_actions: None,
854                }),
855                sync: None,
856                verify: None,
857            },
858        };
859        self.write_binding_edit(mem, name, &binding, &root, note, "add")
860    }
861
862    /// Update a projection from a JSON-encoded [`Projection`], **preserving**
863    /// the binding's operations block, `deny_paths`, `coverage_semantics`, and
864    /// `version` (D14). Reads the existing v1 binding, overlays the incoming
865    /// projection-level fields, and writes it back — so a projection-field edit
866    /// never silently strips the operations that make the binding runnable.
867    pub fn update_projection_json(
868        &mut self,
869        mem: &str,
870        name: &str,
871        projection_json: &str,
872        note: Option<&str>,
873    ) -> Result<(), PipelineEditError> {
874        let root = self.pipeline_edit_root()?;
875        let incoming: Projection = parse_json(projection_json, "projection")?;
876        let mut binding = pipeline_store::read_binding(&root, mem, name)?;
877        binding.intent = incoming.intent;
878        binding.source_facets = incoming.source_facets;
879        binding.reference_mems = incoming.reference_mems;
880        binding.destination_mem = incoming.destination_mem;
881        if incoming.rules.is_some() {
882            binding.rules = incoming.rules;
883        }
884        self.write_binding_edit(mem, name, &binding, &root, note, "update")
885    }
886
887    /// Shared binding writer for the projection edit path: persist the binding,
888    /// record provenance against `mem`, and refresh the in-memory snapshot.
889    fn write_binding_edit(
890        &mut self,
891        mem: &str,
892        name: &str,
893        binding: &crate::binding::BindingV1,
894        root: &Path,
895        note: Option<&str>,
896        verb: &str,
897    ) -> Result<(), PipelineEditError> {
898        pipeline_store::write_binding(root, mem, name, binding)?;
899        let bytes =
900            serde_json::to_vec_pretty(binding).map_err(|e| PipelineEditError::InvalidJson {
901                primitive: "config",
902                message: e.to_string(),
903            })?;
904        self.pipeline_provenance(
905            mem,
906            "projections",
907            &[(name.to_string(), Some(bytes))],
908            note,
909            verb,
910        )?;
911        self.refresh_pipeline_configs(root)
912    }
913}
914
915/// Deserialize a pipeline primitive from JSON, mapping a parse failure to a
916/// typed [`PipelineEditError::InvalidJson`] naming the primitive.
917fn parse_json<T: serde::de::DeserializeOwned>(
918    json: &str,
919    primitive: &'static str,
920) -> Result<T, PipelineEditError> {
921    serde_json::from_str(json).map_err(|e| PipelineEditError::InvalidJson {
922        primitive,
923        message: e.to_string(),
924    })
925}
926
927#[cfg(test)]
928mod tests {
929    use super::*;
930    use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
931    use crate::pipeline_store::{LegacyIngest, LegacyIngestMode};
932    use tempfile::TempDir;
933
934    fn medium(name: &str) -> Medium {
935        Medium {
936            name: name.to_string(),
937            medium_type: MediumType::Codebase,
938            pointer: "../src".to_string(),
939            change_detection: None,
940        }
941    }
942
943    fn facet(name: &str, medium: &str) -> Facet {
944        Facet {
945            name: name.to_string(),
946            medium: medium.to_string(),
947            scope: vec![PatternEntry {
948                path: "**/*.rs".to_string(),
949                mode: PatternMode::Allow,
950            }],
951            engagement: None,
952            preparation: None,
953        }
954    }
955
956    fn projection(facets: &[&str]) -> Projection {
957        Projection {
958            intent: Some("test".to_string()),
959            source_facets: facets.iter().map(|s| s.to_string()).collect(),
960            reference_mems: vec![],
961            destination_mem: "v".to_string(),
962            rules: None,
963        }
964    }
965
966    fn ingest(projection: &str) -> LegacyIngest {
967        LegacyIngest {
968            projection: projection.to_string(),
969            mode: LegacyIngestMode::Discovery,
970            trigger: IngestTrigger::Loop,
971            batch_size: 10,
972            deny_paths: vec![],
973            post_actions: None,
974        }
975    }
976
977    #[test]
978    fn add_then_duplicate_medium_refuses() {
979        let tmp = TempDir::new().unwrap();
980        let root = tmp.path();
981        add_medium(root, "v", "m", &medium("m")).unwrap();
982        let err = add_medium(root, "v", "m", &medium("m")).unwrap_err();
983        assert!(
984            matches!(err, PipelineEditError::AlreadyExists { .. }),
985            "got {err:?}"
986        );
987    }
988
989    #[test]
990    fn update_missing_medium_refuses() {
991        let tmp = TempDir::new().unwrap();
992        let err = update_medium(tmp.path(), "v", "m", &medium("m")).unwrap_err();
993        assert!(
994            matches!(err, PipelineEditError::NotFound { .. }),
995            "got {err:?}"
996        );
997    }
998
999    #[test]
1000    fn delete_medium_refused_while_a_facet_references_it() {
1001        let tmp = TempDir::new().unwrap();
1002        let root = tmp.path();
1003        add_medium(root, "v", "m", &medium("m")).unwrap();
1004        add_facet(root, "v", "f", &facet("f", "m")).unwrap();
1005
1006        let err = delete_medium(root, "v", "m").unwrap_err();
1007        match err {
1008            PipelineEditError::Referenced { referrers, .. } => assert_eq!(referrers, vec!["f"]),
1009            other => panic!("expected Referenced, got {other:?}"),
1010        }
1011        // Removing the facet frees the medium.
1012        delete_facet(root, "v", "f").unwrap();
1013        delete_medium(root, "v", "m").unwrap();
1014        let configs = pipeline_store::load_legacy_pipeline_configs(root).unwrap();
1015        assert!(configs.mediums.is_empty() && configs.facets.is_empty());
1016    }
1017
1018    #[test]
1019    fn rename_medium_repoints_dependent_facets_and_updates_embedded_name() {
1020        let tmp = TempDir::new().unwrap();
1021        let root = tmp.path();
1022        add_medium(root, "v", "old", &medium("old")).unwrap();
1023        add_facet(root, "v", "f", &facet("f", "old")).unwrap();
1024
1025        rename_medium(root, "v", "old", "new").unwrap();
1026
1027        let configs = pipeline_store::load_legacy_pipeline_configs(root).unwrap();
1028        assert_eq!(configs.mediums.len(), 1);
1029        assert_eq!(configs.mediums[0].name, "new");
1030        // Embedded name tracks the stem.
1031        assert_eq!(configs.mediums[0].config.name, "new");
1032        // Dependent facet now points at the new medium name.
1033        assert_eq!(configs.facets[0].config.medium, "new");
1034    }
1035
1036    #[test]
1037    fn rename_medium_refuses_existing_target() {
1038        let tmp = TempDir::new().unwrap();
1039        let root = tmp.path();
1040        add_medium(root, "v", "a", &medium("a")).unwrap();
1041        add_medium(root, "v", "b", &medium("b")).unwrap();
1042        let err = rename_medium(root, "v", "a", "b").unwrap_err();
1043        assert!(
1044            matches!(err, PipelineEditError::RenameTargetExists { .. }),
1045            "got {err:?}"
1046        );
1047        // Nothing lost.
1048        let configs = pipeline_store::load_legacy_pipeline_configs(root).unwrap();
1049        assert_eq!(configs.mediums.len(), 2);
1050    }
1051
1052    #[test]
1053    fn rename_medium_to_same_name_is_a_noop() {
1054        let tmp = TempDir::new().unwrap();
1055        let root = tmp.path();
1056        add_medium(root, "v", "m", &medium("m")).unwrap();
1057        rename_medium(root, "v", "m", "m").unwrap();
1058        let configs = pipeline_store::load_legacy_pipeline_configs(root).unwrap();
1059        assert_eq!(configs.mediums.len(), 1);
1060        assert_eq!(configs.mediums[0].config, medium("m"));
1061    }
1062
1063    #[test]
1064    fn delete_facet_refused_while_a_projection_references_it() {
1065        let tmp = TempDir::new().unwrap();
1066        let root = tmp.path();
1067        add_facet(root, "v", "f", &facet("f", "m")).unwrap();
1068        add_projection(root, "v", "p", &projection(&["f"])).unwrap();
1069        let err = delete_facet(root, "v", "f").unwrap_err();
1070        assert!(
1071            matches!(err, PipelineEditError::Referenced { .. }),
1072            "got {err:?}"
1073        );
1074    }
1075
1076    #[test]
1077    fn rename_facet_repoints_dependent_projections() {
1078        let tmp = TempDir::new().unwrap();
1079        let root = tmp.path();
1080        add_facet(root, "v", "old", &facet("old", "m")).unwrap();
1081        add_projection(root, "v", "p", &projection(&["old", "other"])).unwrap();
1082
1083        rename_facet(root, "v", "old", "new").unwrap();
1084
1085        let configs = pipeline_store::load_legacy_pipeline_configs(root).unwrap();
1086        assert_eq!(configs.facets[0].name, "new");
1087        assert_eq!(configs.facets[0].config.name, "new");
1088        assert_eq!(
1089            configs.projections[0].config.source_facets,
1090            vec!["new", "other"]
1091        );
1092    }
1093
1094    #[test]
1095    fn delete_projection_refused_while_an_ingest_runs_it() {
1096        let tmp = TempDir::new().unwrap();
1097        let root = tmp.path();
1098        add_projection(root, "v", "p", &projection(&[])).unwrap();
1099        pipeline_store::write_ingest(root, "i", &ingest("v/p")).unwrap();
1100        let err = delete_projection(root, "v", "p").unwrap_err();
1101        match err {
1102            PipelineEditError::Referenced { referrers, .. } => assert_eq!(referrers, vec!["i"]),
1103            other => panic!("expected Referenced, got {other:?}"),
1104        }
1105    }
1106
1107    #[test]
1108    fn parse_json_accepts_a_valid_medium() {
1109        let m: Medium =
1110            parse_json(r#"{"name":"m","type":"codebase","pointer":".."}"#, "medium").unwrap();
1111        assert_eq!(m.name, "m");
1112        assert_eq!(m.medium_type, MediumType::Codebase);
1113    }
1114
1115    #[test]
1116    fn parse_json_maps_a_bad_payload_to_invalid_json() {
1117        let err = parse_json::<Medium>("{ not json", "medium").unwrap_err();
1118        assert!(
1119            matches!(
1120                err,
1121                PipelineEditError::InvalidJson {
1122                    primitive: "medium",
1123                    ..
1124                }
1125            ),
1126            "got {err:?}"
1127        );
1128    }
1129
1130    #[test]
1131    fn rename_projection_repoints_dependent_ingests() {
1132        let tmp = TempDir::new().unwrap();
1133        let root = tmp.path();
1134        add_projection(root, "v", "old", &projection(&[])).unwrap();
1135        pipeline_store::write_ingest(root, "i", &ingest("v/old")).unwrap();
1136
1137        rename_projection(root, "v", "old", "new").unwrap();
1138
1139        let configs = pipeline_store::load_legacy_pipeline_configs(root).unwrap();
1140        assert_eq!(configs.projections[0].name, "new");
1141        assert_eq!(configs.ingests[0].config.projection, "v/new");
1142    }
1143}