Skip to main content

memstead_base/
pipeline_store.rs

1//! File-adapter persistence for the pipeline configs.
2//!
3//! Reads and writes [`Medium`] / [`Facet`] / [`Projection`] / v1
4//! [`BindingV1`] (and, for the legacy path, the crate-local [`LegacyIngest`])
5//! JSON under the workspace store ([`WORKSPACE_STORE_DIR`]):
6//!
7//! - `<root>/.memstead/mediums/<mem>/<name>.json`
8//! - `<root>/.memstead/facets/<mem>/<name>.json`
9//! - `<root>/.memstead/projections/<mem>/<name>.json`
10//! - `<root>/.memstead/ingests/<name>.json`  — flat; ingests are not per-mem
11//!
12//! (The plan's acceptance-criteria text lists `projections`/`ingests` under a
13//! `.graph/` path; that is a typo for `.memstead/` — the AC header, Goal, and
14//! Constraints all place every pipeline config in the `.memstead/` workspace
15//! store. All four primitives live under `.memstead/` here.)
16//!
17//! Mediums, facets, and projections are per-mem (a `<mem>` subdirectory
18//! tier preserves the "mem owns its territory" framing); ingests are flat,
19//! matching the legacy `ingests/<name>.json` layout. The record's
20//! identity is `(mem, name)` derived from the file path; `name` is the file
21//! stem.
22//!
23//! The loader's job is load + validate + expose read-only: a malformed config
24//! surfaces a typed [`StoreError::Parse`] naming the offending file (the
25//! early-validation value), rather than being silently skipped.
26
27use std::path::{Path, PathBuf};
28
29use serde::Serialize;
30use serde::de::DeserializeOwned;
31
32use serde::Deserialize;
33
34use crate::binding::BindingV1;
35use crate::pipeline::{Facet, IngestTrigger, Medium, Projection};
36use crate::workspace_store::{StoreError, WORKSPACE_STORE_DIR};
37
38/// The legacy (gen-2) flat **ingest** record — the schedule primitive the
39/// binding model retired (its `mode`/`trigger`/`batch_size`/`deny_paths`/
40/// `post_actions` collapsed into a binding's `operations.build` block).
41///
42/// This shape is **migrate-local**: it is parsed only by the legacy path —
43/// [`load_legacy_pipeline_configs`], `projection migrate` (which reads it to
44/// merge each ingest into its projection), the referential-integrity edit
45/// layer, and the gen-1 root-folder converter. No live surface constructs or
46/// runs it; the live loader speaks [`BindingV1`]. Kept `pub(crate)` and minimal
47/// so the retired `Ingest`/`IngestMode` machinery no longer lives on any public
48/// or live surface.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub(crate) struct LegacyIngest {
51    /// The projection (by name, `"<mem>/<name>"`) this ingest ran.
52    pub projection: String,
53    /// Discovery / refinement / one-shot — including the deleted `refinement`
54    /// value, parsed so `projection migrate` can *detect and refuse* it (D1).
55    pub mode: LegacyIngestMode,
56    /// Loop / manual / on-event.
57    pub trigger: IngestTrigger,
58    /// How many artifacts a single run processed.
59    pub batch_size: u32,
60    /// Paths excluded for this ingest's runs, on top of facet scope.
61    #[serde(default)]
62    pub deny_paths: Vec<String>,
63    /// Free-form post-run actions (e.g. a one-shot `archive_source` flag).
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub post_actions: Option<serde_json::Value>,
66}
67
68/// The legacy ingest mode, including the deleted `refinement` value. Parsed so
69/// `projection migrate` can detect and refuse it (D1) — never carried forward
70/// into a v1 binding.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "kebab-case")]
73pub(crate) enum LegacyIngestMode {
74    /// Build out new coverage.
75    Discovery,
76    /// Improve existing coverage — the deleted refinement-as-writer mode.
77    Refinement,
78    /// A single bounded pass.
79    OneShot,
80}
81
82/// Per-primitive subdirectory names under the workspace store.
83pub const MEDIUMS_DIR: &str = "mediums";
84/// See [`MEDIUMS_DIR`].
85pub const FACETS_DIR: &str = "facets";
86/// See [`MEDIUMS_DIR`].
87pub const PROJECTIONS_DIR: &str = "projections";
88/// See [`MEDIUMS_DIR`].
89pub const INGESTS_DIR: &str = "ingests";
90
91/// A per-mem pipeline record paired with the mem and name (file stem) that
92/// identify it on disk.
93#[derive(Debug, Clone, PartialEq, Serialize)]
94pub struct MemPipelineRecord<T> {
95    /// The mem subdirectory this record lives under.
96    pub mem: String,
97    /// The record's name — the file stem (e.g. `source-tree`).
98    pub name: String,
99    /// The parsed config.
100    pub config: T,
101}
102
103/// A flat (non-per-mem) pipeline record — used for ingests.
104#[derive(Debug, Clone, PartialEq, Serialize)]
105pub struct PipelineRecord<T> {
106    /// The record's name — the file stem (e.g. `macos-graph`).
107    pub name: String,
108    /// The parsed config.
109    pub config: T,
110}
111
112/// Every pipeline config in a workspace store, in the four-primitive shape.
113///
114/// This is the **legacy** (gen-2) shape — produced only by
115/// [`load_legacy_pipeline_configs`], consumed by the referential-integrity
116/// edit layer, the `projection migrate` transform, and the macOS
117/// `pipeline_configs_json` surface. The live loader
118/// ([`load_pipeline_configs`]) returns [`BindingConfigs`] instead.
119#[derive(Debug, Default, Clone, PartialEq, Serialize)]
120pub struct PipelineConfigs {
121    /// Per-mem mediums.
122    pub mediums: Vec<MemPipelineRecord<Medium>>,
123    /// Per-mem facets.
124    pub facets: Vec<MemPipelineRecord<Facet>>,
125    /// Per-mem projections.
126    pub projections: Vec<MemPipelineRecord<Projection>>,
127    /// Flat legacy ingests (crate-local — the migrate/edit path reads them; no
128    /// live or external surface does).
129    pub(crate) ingests: Vec<PipelineRecord<LegacyIngest>>,
130}
131
132/// Every pipeline config in a workspace store, in the **binding v1** shape
133/// (D1/D2) — mediums, facets, and one [`BindingV1`] per source→mem obligation
134/// (the projection + flat-ingest split collapsed into a single versioned
135/// record). This is what the live loader [`load_pipeline_configs`] returns and
136/// the brief / selection / cursor paths consume.
137#[derive(Debug, Default, Clone, PartialEq, Serialize)]
138pub struct BindingConfigs {
139    /// Per-mem mediums.
140    pub mediums: Vec<MemPipelineRecord<Medium>>,
141    /// Per-mem facets.
142    pub facets: Vec<MemPipelineRecord<Facet>>,
143    /// Per-mem v1 bindings (occupying the `projections/<mem>/<name>.json` tier,
144    /// stem-identity preserved). Canonical id is `<mem>/<stem>` (D3).
145    pub bindings: Vec<MemPipelineRecord<BindingV1>>,
146}
147
148/// The `<root>/.memstead/<primitive>` directory for a given primitive.
149fn primitive_dir(workspace_root: &Path, primitive: &str) -> PathBuf {
150    workspace_root.join(WORKSPACE_STORE_DIR).join(primitive)
151}
152
153/// Refuse a `mem`/`name` value that is not a single, plain path
154/// component: separators, traversal segments, drive/stream colons, NULs,
155/// and empty values would let a caller-supplied name write or delete
156/// outside the workspace's own metadata directory. Validated here — the
157/// one place every mutation's path is built — so no surface above
158/// (CLI, UniFFI, engine) can bypass it.
159fn validate_component(kind: &str, value: &str) -> Result<(), StoreError> {
160    let invalid = value.is_empty()
161        || value == "."
162        || value == ".."
163        || value.contains('/')
164        || value.contains('\\')
165        || value.contains(':')
166        || value.contains('\0');
167    if invalid {
168        return Err(StoreError::Other(format!(
169            "invalid {kind} '{}': must be a single path component \
170             (no separators, traversal segments, ':' or NUL)",
171            value.escape_default()
172        )));
173    }
174    Ok(())
175}
176
177/// File path of a per-mem record: `<root>/.memstead/<primitive>/<mem>/<name>.json`.
178fn mem_scoped_path(
179    workspace_root: &Path,
180    primitive: &str,
181    mem: &str,
182    name: &str,
183) -> Result<PathBuf, StoreError> {
184    validate_component("mem", mem)?;
185    validate_component("name", name)?;
186    Ok(primitive_dir(workspace_root, primitive)
187        .join(mem)
188        .join(format!("{name}.json")))
189}
190
191/// File path of a flat (non-per-mem) record: `<root>/.memstead/<primitive>/<name>.json`.
192fn flat_path(workspace_root: &Path, primitive: &str, name: &str) -> Result<PathBuf, StoreError> {
193    validate_component("name", name)?;
194    Ok(primitive_dir(workspace_root, primitive).join(format!("{name}.json")))
195}
196
197/// Remove the file at `path`, mapping IO failures (including a missing
198/// file) to a typed [`StoreError::Io`] naming the path. Dumb counterpart
199/// to [`write_json`] — referential-integrity / existence checks belong to
200/// the calling layer, matching the write-is-upsert / load-validates split.
201fn remove_file(path: &Path) -> Result<(), StoreError> {
202    std::fs::remove_file(path).map_err(|e| StoreError::Io {
203        path: path.to_path_buf(),
204        source: e,
205    })
206}
207
208/// Rename the record file `from` → `to`. Refuses to clobber an existing
209/// target (silent overwrite would lose a distinct record); that guard is
210/// the one non-dumb concession here because the failure mode is data loss.
211/// A missing source surfaces as [`StoreError::Io`]. Reference rewriting in
212/// dependent primitives is the calling layer's job.
213fn rename_file(from: &Path, to: &Path) -> Result<(), StoreError> {
214    if to.exists() {
215        return Err(StoreError::Other(format!(
216            "rename target already exists: {}",
217            to.display()
218        )));
219    }
220    std::fs::rename(from, to).map_err(|e| StoreError::Io {
221        path: from.to_path_buf(),
222        source: e,
223    })
224}
225
226/// Serialise `config` (pretty JSON) into `path`, creating parent directories.
227fn write_json<T: Serialize>(path: &Path, config: &T) -> Result<(), StoreError> {
228    if let Some(parent) = path.parent() {
229        std::fs::create_dir_all(parent).map_err(|e| StoreError::Io {
230            path: parent.to_path_buf(),
231            source: e,
232        })?;
233    }
234    let bytes = serde_json::to_vec_pretty(config).map_err(|e| StoreError::Parse {
235        path: path.to_path_buf(),
236        message: e.to_string(),
237    })?;
238    std::fs::write(path, bytes).map_err(|e| StoreError::Io {
239        path: path.to_path_buf(),
240        source: e,
241    })
242}
243
244/// Load every `<primitive>/<mem>/<name>.json` under the store, parsed.
245/// Absent primitive directory → empty (a workspace may declare no pipelines).
246/// A malformed file surfaces a typed parse error naming the path.
247fn load_mem_scoped<T: DeserializeOwned>(
248    workspace_root: &Path,
249    primitive: &str,
250) -> Result<Vec<MemPipelineRecord<T>>, StoreError> {
251    let dir = primitive_dir(workspace_root, primitive);
252    let mut out: Vec<MemPipelineRecord<T>> = Vec::new();
253    let mem_dirs = match std::fs::read_dir(&dir) {
254        Ok(rd) => rd,
255        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
256        Err(e) => {
257            return Err(StoreError::Io {
258                path: dir,
259                source: e,
260            });
261        }
262    };
263    for mem_entry in mem_dirs.flatten() {
264        let mem_path = mem_entry.path();
265        if !mem_path.is_dir() {
266            continue;
267        }
268        let mem = mem_entry.file_name().to_string_lossy().into_owned();
269        let files = match std::fs::read_dir(&mem_path) {
270            Ok(rd) => rd,
271            Err(e) => {
272                return Err(StoreError::Io {
273                    path: mem_path,
274                    source: e,
275                });
276            }
277        };
278        for file in files.flatten() {
279            let path = file.path();
280            if path.extension().and_then(|e| e.to_str()) != Some("json") {
281                continue;
282            }
283            let Some(name) = path.file_stem().map(|s| s.to_string_lossy().into_owned()) else {
284                continue;
285            };
286            let config = read_json::<T>(&path)?;
287            out.push(MemPipelineRecord {
288                mem: mem.clone(),
289                name,
290                config,
291            });
292        }
293    }
294    // Deterministic order so callers (and tests) see a stable enumeration.
295    out.sort_by(|a, b| (a.mem.as_str(), a.name.as_str()).cmp(&(b.mem.as_str(), b.name.as_str())));
296    Ok(out)
297}
298
299/// Load every `ingests/<name>.json` (flat), parsed.
300fn load_flat<T: DeserializeOwned>(
301    workspace_root: &Path,
302    primitive: &str,
303) -> Result<Vec<PipelineRecord<T>>, StoreError> {
304    let dir = primitive_dir(workspace_root, primitive);
305    let mut out: Vec<PipelineRecord<T>> = Vec::new();
306    let files = match std::fs::read_dir(&dir) {
307        Ok(rd) => rd,
308        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
309        Err(e) => {
310            return Err(StoreError::Io {
311                path: dir,
312                source: e,
313            });
314        }
315    };
316    for file in files.flatten() {
317        let path = file.path();
318        if path.extension().and_then(|e| e.to_str()) != Some("json") {
319            continue;
320        }
321        let Some(name) = path.file_stem().map(|s| s.to_string_lossy().into_owned()) else {
322            continue;
323        };
324        let config = read_json::<T>(&path)?;
325        out.push(PipelineRecord { name, config });
326    }
327    out.sort_by(|a, b| a.name.cmp(&b.name));
328    Ok(out)
329}
330
331/// Read + parse one JSON file into `T`, mapping IO/parse failures to typed
332/// [`StoreError`]s naming the path.
333fn read_json<T: DeserializeOwned>(path: &Path) -> Result<T, StoreError> {
334    let bytes = std::fs::read(path).map_err(|e| StoreError::Io {
335        path: path.to_path_buf(),
336        source: e,
337    })?;
338    serde_json::from_slice(&bytes).map_err(|e| StoreError::Parse {
339        path: path.to_path_buf(),
340        message: e.to_string(),
341    })
342}
343
344/// Write a medium to `<root>/.memstead/mediums/<mem>/<name>.json`.
345pub fn write_medium(
346    workspace_root: &Path,
347    mem: &str,
348    name: &str,
349    medium: &Medium,
350) -> Result<(), StoreError> {
351    write_json(
352        &mem_scoped_path(workspace_root, MEDIUMS_DIR, mem, name)?,
353        medium,
354    )
355}
356
357/// Write a facet to `<root>/.memstead/facets/<mem>/<name>.json`.
358pub fn write_facet(
359    workspace_root: &Path,
360    mem: &str,
361    name: &str,
362    facet: &Facet,
363) -> Result<(), StoreError> {
364    write_json(
365        &mem_scoped_path(workspace_root, FACETS_DIR, mem, name)?,
366        facet,
367    )
368}
369
370/// Write a projection to `<root>/.memstead/projections/<mem>/<name>.json`.
371pub fn write_projection(
372    workspace_root: &Path,
373    mem: &str,
374    name: &str,
375    projection: &Projection,
376) -> Result<(), StoreError> {
377    write_json(
378        &mem_scoped_path(workspace_root, PROJECTIONS_DIR, mem, name)?,
379        projection,
380    )
381}
382
383/// Write a legacy ingest to `<root>/.memstead/ingests/<name>.json` (flat).
384/// Crate-local dumb file op — used by the gen-1 root-folder converter and the
385/// projection-rename repoint; the live path writes bindings, not ingests.
386pub(crate) fn write_ingest(
387    workspace_root: &Path,
388    name: &str,
389    ingest: &LegacyIngest,
390) -> Result<(), StoreError> {
391    write_json(&flat_path(workspace_root, INGESTS_DIR, name)?, ingest)
392}
393
394/// Write a v1 binding to `<root>/.memstead/projections/<mem>/<name>.json`.
395///
396/// A binding (`BindingV1`) occupies the *same* per-mem projections tier and
397/// file identity a gen-2 [`Projection`] did (stem-identity preserved, D1/D3),
398/// so this overwrites the gen-2 projection file in place when promoting a
399/// workspace. Additive counterpart to [`write_projection`]; nothing in the
400/// live loader reads the v1 shape yet (that gate is a later slice).
401pub fn write_binding(
402    workspace_root: &Path,
403    mem: &str,
404    name: &str,
405    binding: &BindingV1,
406) -> Result<(), StoreError> {
407    write_json(
408        &mem_scoped_path(workspace_root, PROJECTIONS_DIR, mem, name)?,
409        binding,
410    )
411}
412
413/// Read the v1 binding at `<root>/.memstead/projections/<mem>/<name>.json`.
414///
415/// The read counterpart of [`write_binding`] — reads the *same* per-mem
416/// projections tier and file identity, parsed as a [`BindingV1`]. A missing
417/// file surfaces [`StoreError::Io`] (kind `NotFound`); a file present but not a
418/// v1 binding (e.g. a not-yet-migrated gen-2 projection) surfaces
419/// [`StoreError::Parse`]. Callers wanting a friendly "no such binding" message
420/// pre-check existence and keep the two apart. Additive; the live loader does
421/// not consult this yet (that gate is a later slice).
422pub fn read_binding(workspace_root: &Path, mem: &str, name: &str) -> Result<BindingV1, StoreError> {
423    read_json(&mem_scoped_path(
424        workspace_root,
425        PROJECTIONS_DIR,
426        mem,
427        name,
428    )?)
429}
430
431/// Delete a medium file. Missing → [`StoreError::Io`]; callers that want a
432/// friendly "no such medium" pre-check existence via [`load_pipeline_configs`].
433pub fn delete_medium(workspace_root: &Path, mem: &str, name: &str) -> Result<(), StoreError> {
434    remove_file(&mem_scoped_path(workspace_root, MEDIUMS_DIR, mem, name)?)
435}
436
437/// Delete a facet file. See [`delete_medium`] for missing-file semantics.
438pub fn delete_facet(workspace_root: &Path, mem: &str, name: &str) -> Result<(), StoreError> {
439    remove_file(&mem_scoped_path(workspace_root, FACETS_DIR, mem, name)?)
440}
441
442/// Delete a projection file. See [`delete_medium`] for missing-file semantics.
443pub fn delete_projection(workspace_root: &Path, mem: &str, name: &str) -> Result<(), StoreError> {
444    remove_file(&mem_scoped_path(
445        workspace_root,
446        PROJECTIONS_DIR,
447        mem,
448        name,
449    )?)
450}
451
452/// Delete an ingest file (flat). See [`delete_medium`] for missing-file semantics.
453pub fn delete_ingest(workspace_root: &Path, name: &str) -> Result<(), StoreError> {
454    remove_file(&flat_path(workspace_root, INGESTS_DIR, name)?)
455}
456
457// Rename is exposed only for the *nameless* records (projection, ingest),
458// whose identity is the file stem alone. Mediums and facets carry an embedded
459// `name` field that must equal the stem (facets reference mediums by name,
460// projections reference facets by name); a pure file move would leave that
461// field stale, so their rename lives in the `pipeline_edit` layer, which
462// rewrites the embedded name and dependent references together.
463
464/// Rename a projection within its mem (`old` → `new`, same `<mem>` tier).
465/// Refuses to clobber an existing target. A projection has no embedded name,
466/// so a file move is its whole rename; rewriting dependent ingest `projection`
467/// references is the calling layer's job.
468pub fn rename_projection(
469    workspace_root: &Path,
470    mem: &str,
471    old: &str,
472    new: &str,
473) -> Result<(), StoreError> {
474    rename_file(
475        &mem_scoped_path(workspace_root, PROJECTIONS_DIR, mem, old)?,
476        &mem_scoped_path(workspace_root, PROJECTIONS_DIR, mem, new)?,
477    )
478}
479
480/// Rename an ingest (flat). Refuses to clobber an existing target. An ingest
481/// has no embedded name and nothing references it, so a file move is the whole
482/// rename.
483pub fn rename_ingest(workspace_root: &Path, old: &str, new: &str) -> Result<(), StoreError> {
484    rename_file(
485        &flat_path(workspace_root, INGESTS_DIR, old)?,
486        &flat_path(workspace_root, INGESTS_DIR, new)?,
487    )
488}
489
490/// Load the **legacy** (gen-2) four-primitive store from the workspace.
491/// Absent directories resolve to empty; a malformed file surfaces a typed
492/// [`StoreError::Parse`]. This reader is the counterpart of the version-gated
493/// [`load_pipeline_configs`]: it deliberately reads the old
494/// `Projection` + flat-`Ingest` shape (parsing a v1 binding file lossily as a
495/// [`Projection`], which ignores `version`/`operations`), so
496/// `projection migrate`, the referential-integrity edit layer, and the macOS
497/// `pipeline_configs_json` surface keep working. It performs **no** version
498/// gate — it is the escape hatch the gate points migrations at.
499pub fn load_legacy_pipeline_configs(workspace_root: &Path) -> Result<PipelineConfigs, StoreError> {
500    Ok(PipelineConfigs {
501        mediums: load_mem_scoped(workspace_root, MEDIUMS_DIR)?,
502        facets: load_mem_scoped(workspace_root, FACETS_DIR)?,
503        projections: load_mem_scoped(workspace_root, PROJECTIONS_DIR)?,
504        ingests: load_flat(workspace_root, INGESTS_DIR)?,
505    })
506}
507
508/// Load every `projections/<mem>/<name>.json` as a **v1 binding**, version-gated
509/// (D2). Absent directory → empty. For each file:
510///
511/// - no `version` field → [`StoreError::LegacyProjectionStore`] (the pre-v1
512///   layout the loader no longer serves; the message names
513///   `memstead projection migrate`);
514/// - `version` present but not `1` → [`StoreError::UnknownBindingVersion`];
515/// - `version: 1` → parsed as [`BindingV1`] (a malformed operations block etc.
516///   surfaces [`StoreError::Parse`] naming the file).
517///
518/// The gate refuses the whole load on the first offending file — a version-less
519/// workspace fails loudly (pointing at migrate) rather than loading half-served.
520fn load_bindings(workspace_root: &Path) -> Result<Vec<MemPipelineRecord<BindingV1>>, StoreError> {
521    let dir = primitive_dir(workspace_root, PROJECTIONS_DIR);
522    let mut out: Vec<MemPipelineRecord<BindingV1>> = Vec::new();
523    let mem_dirs = match std::fs::read_dir(&dir) {
524        Ok(rd) => rd,
525        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
526        Err(e) => {
527            return Err(StoreError::Io {
528                path: dir,
529                source: e,
530            });
531        }
532    };
533    for mem_entry in mem_dirs.flatten() {
534        let mem_path = mem_entry.path();
535        if !mem_path.is_dir() {
536            continue;
537        }
538        let mem = mem_entry.file_name().to_string_lossy().into_owned();
539        let files = match std::fs::read_dir(&mem_path) {
540            Ok(rd) => rd,
541            Err(e) => {
542                return Err(StoreError::Io {
543                    path: mem_path,
544                    source: e,
545                });
546            }
547        };
548        for file in files.flatten() {
549            let path = file.path();
550            if path.extension().and_then(|e| e.to_str()) != Some("json") {
551                continue;
552            }
553            let Some(name) = path.file_stem().map(|s| s.to_string_lossy().into_owned()) else {
554                continue;
555            };
556            // Peek at `version` before committing to the BindingV1 shape so a
557            // pre-v1 (version-less) file yields the migrate-naming error rather
558            // than an opaque "missing field `version`" parse error.
559            let value: serde_json::Value = read_json(&path)?;
560            match value.get("version") {
561                None => return Err(StoreError::LegacyProjectionStore { path }),
562                Some(v) => {
563                    let n = v.as_i64();
564                    if n != Some(i64::from(crate::binding::BINDING_VERSION)) {
565                        return Err(StoreError::UnknownBindingVersion {
566                            path,
567                            version: n.unwrap_or(-1),
568                        });
569                    }
570                }
571            }
572            let config: BindingV1 =
573                serde_json::from_value(value).map_err(|e| StoreError::Parse {
574                    path: path.clone(),
575                    message: e.to_string(),
576                })?;
577            out.push(MemPipelineRecord {
578                mem: mem.clone(),
579                name,
580                config,
581            });
582        }
583    }
584    out.sort_by(|a, b| (a.mem.as_str(), a.name.as_str()).cmp(&(b.mem.as_str(), b.name.as_str())));
585    Ok(out)
586}
587
588/// Load the live **binding v1** store from the workspace (D2). Mediums and
589/// facets load as before; `projections/` is read as version-gated v1 bindings
590/// via [`load_bindings`] — a version-less (pre-v1) `projections/` refuses with
591/// [`StoreError::LegacyProjectionStore`] naming `memstead projection migrate`.
592/// The flat `ingests/` directory is **never read** by this path (bindings carry
593/// their operations); it is served only by [`load_legacy_pipeline_configs`] for
594/// migration.
595pub fn load_pipeline_configs(workspace_root: &Path) -> Result<BindingConfigs, StoreError> {
596    Ok(BindingConfigs {
597        mediums: load_mem_scoped(workspace_root, MEDIUMS_DIR)?,
598        facets: load_mem_scoped(workspace_root, FACETS_DIR)?,
599        bindings: load_bindings(workspace_root)?,
600    })
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606    use crate::pipeline::{MediumType, PatternEntry, PatternMode};
607    use tempfile::TempDir;
608
609    fn sample() -> (Medium, Facet, Projection, LegacyIngest) {
610        let medium = Medium {
611            name: "source-tree".to_string(),
612            medium_type: MediumType::Codebase,
613            pointer: "../macos".to_string(),
614            change_detection: None,
615        };
616        let facet = Facet {
617            name: "source-files".to_string(),
618            medium: "source-tree".to_string(),
619            scope: vec![PatternEntry {
620                path: "../macos/**/*.swift".to_string(),
621                mode: PatternMode::Allow,
622            }],
623            engagement: None,
624            preparation: None,
625        };
626        let projection = Projection {
627            intent: Some("Swift macOS app source.".to_string()),
628            source_facets: vec!["source-files".to_string()],
629            reference_mems: vec!["engine".to_string()],
630            destination_mem: "macos".to_string(),
631            rules: None,
632        };
633        let ingest = LegacyIngest {
634            projection: "macos/graph".to_string(),
635            mode: LegacyIngestMode::Discovery,
636            trigger: IngestTrigger::Loop,
637            batch_size: 20,
638            deny_paths: vec!["VISION.md".to_string()],
639            post_actions: None,
640        };
641        (medium, facet, projection, ingest)
642    }
643
644    #[test]
645    fn mutations_refuse_traversal_in_mem_and_name() {
646        // Every mutation path-builds from caller-supplied mem/name; a
647        // separator or traversal segment must refuse with a typed error
648        // and leave nothing on disk outside the store.
649        let tmp = TempDir::new().unwrap();
650        let root = tmp.path();
651        let (medium, _, _, ingest) = sample();
652
653        let evil_values = [
654            "..",
655            ".",
656            "",
657            "../escape",
658            "a/b",
659            "a\\b",
660            "..\\up",
661            "c:evil",
662            "nul\0byte",
663        ];
664        for evil in evil_values {
665            assert!(
666                write_medium(root, evil, "ok", &medium).is_err(),
667                "mem '{}' must refuse",
668                evil.escape_default()
669            );
670            assert!(
671                write_medium(root, "ok", evil, &medium).is_err(),
672                "name '{}' must refuse",
673                evil.escape_default()
674            );
675            assert!(write_ingest(root, evil, &ingest).is_err());
676            assert!(delete_medium(root, evil, "ok").is_err());
677            assert!(delete_ingest(root, evil).is_err());
678            assert!(rename_projection(root, evil, "a", "b").is_err());
679            assert!(rename_projection(root, "ok", evil, "b").is_err());
680            assert!(rename_projection(root, "ok", "a", evil).is_err());
681            assert!(rename_ingest(root, evil, "b").is_err());
682            assert!(rename_ingest(root, "a", evil).is_err());
683        }
684
685        // A traversal write must not have escaped: the only thing under
686        // the temp root may be the (empty) store dir, and the parent of
687        // the temp root gained no `escape.json`.
688        assert!(
689            !root.parent().unwrap().join("escape.json").exists(),
690            "no write may land outside the workspace"
691        );
692
693        // Existing valid names keep working.
694        write_medium(root, "macos", "source-tree", &medium).unwrap();
695        assert!(
696            root.join(".memstead/mediums/macos/source-tree.json")
697                .is_file()
698        );
699    }
700
701    #[test]
702    fn empty_store_loads_empty_configs() {
703        let tmp = TempDir::new().unwrap();
704        let configs = load_legacy_pipeline_configs(tmp.path()).unwrap();
705        assert_eq!(configs, PipelineConfigs::default());
706    }
707
708    #[test]
709    fn write_then_load_round_trips_all_four_primitives() {
710        let tmp = TempDir::new().unwrap();
711        let root = tmp.path();
712        let (medium, facet, projection, ingest) = sample();
713
714        write_medium(root, "macos", "source-tree", &medium).unwrap();
715        write_facet(root, "macos", "source-files", &facet).unwrap();
716        write_projection(root, "macos", "graph", &projection).unwrap();
717        write_ingest(root, "macos-graph", &ingest).unwrap();
718
719        // Files land at the documented `.memstead/` locations.
720        assert!(
721            root.join(".memstead/mediums/macos/source-tree.json")
722                .is_file()
723        );
724        assert!(
725            root.join(".memstead/facets/macos/source-files.json")
726                .is_file()
727        );
728        assert!(
729            root.join(".memstead/projections/macos/graph.json")
730                .is_file()
731        );
732        assert!(root.join(".memstead/ingests/macos-graph.json").is_file());
733
734        let configs = load_legacy_pipeline_configs(root).unwrap();
735        assert_eq!(configs.mediums.len(), 1);
736        assert_eq!(configs.mediums[0].mem, "macos");
737        assert_eq!(configs.mediums[0].name, "source-tree");
738        assert_eq!(configs.mediums[0].config, medium);
739        assert_eq!(configs.facets[0].config, facet);
740        assert_eq!(configs.projections[0].config, projection);
741        assert_eq!(configs.ingests.len(), 1);
742        assert_eq!(configs.ingests[0].name, "macos-graph");
743        assert_eq!(configs.ingests[0].config, ingest);
744    }
745
746    #[test]
747    fn load_enumeration_is_sorted_and_per_mem() {
748        let tmp = TempDir::new().unwrap();
749        let root = tmp.path();
750        let (medium, _, _, _) = sample();
751        write_medium(root, "engine", "z-medium", &medium).unwrap();
752        write_medium(root, "engine", "a-medium", &medium).unwrap();
753        write_medium(root, "macos", "m-medium", &medium).unwrap();
754
755        let configs = load_legacy_pipeline_configs(root).unwrap();
756        let keys: Vec<_> = configs
757            .mediums
758            .iter()
759            .map(|r| (r.mem.as_str(), r.name.as_str()))
760            .collect();
761        assert_eq!(
762            keys,
763            vec![
764                ("engine", "a-medium"),
765                ("engine", "z-medium"),
766                ("macos", "m-medium"),
767            ]
768        );
769    }
770
771    #[test]
772    fn malformed_config_surfaces_typed_parse_error_naming_the_file() {
773        let tmp = TempDir::new().unwrap();
774        let root = tmp.path();
775        let bad = root.join(".memstead/mediums/macos");
776        std::fs::create_dir_all(&bad).unwrap();
777        std::fs::write(bad.join("broken.json"), b"{ not valid json").unwrap();
778
779        let err = load_legacy_pipeline_configs(root).unwrap_err();
780        match err {
781            StoreError::Parse { path, .. } => {
782                assert!(path.ends_with("broken.json"), "got {path:?}");
783            }
784            other => panic!("expected Parse error, got {other:?}"),
785        }
786    }
787
788    #[test]
789    fn delete_removes_the_record_and_load_reflects_it() {
790        let tmp = TempDir::new().unwrap();
791        let root = tmp.path();
792        let (medium, _, _, ingest) = sample();
793        write_medium(root, "macos", "source-tree", &medium).unwrap();
794        write_ingest(root, "macos-graph", &ingest).unwrap();
795
796        delete_medium(root, "macos", "source-tree").unwrap();
797        delete_ingest(root, "macos-graph").unwrap();
798
799        assert!(
800            !root
801                .join(".memstead/mediums/macos/source-tree.json")
802                .exists()
803        );
804        assert!(!root.join(".memstead/ingests/macos-graph.json").exists());
805        let configs = load_legacy_pipeline_configs(root).unwrap();
806        assert!(configs.mediums.is_empty());
807        assert!(configs.ingests.is_empty());
808    }
809
810    #[test]
811    fn delete_of_missing_record_surfaces_io_error() {
812        let tmp = TempDir::new().unwrap();
813        let err = delete_medium(tmp.path(), "macos", "nope").unwrap_err();
814        match err {
815            StoreError::Io { source, .. } => {
816                assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
817            }
818            other => panic!("expected Io error, got {other:?}"),
819        }
820    }
821
822    #[test]
823    fn rename_moves_the_record_preserving_config() {
824        let tmp = TempDir::new().unwrap();
825        let root = tmp.path();
826        let (_, _, projection, _) = sample();
827        write_projection(root, "macos", "old-name", &projection).unwrap();
828
829        rename_projection(root, "macos", "old-name", "new-name").unwrap();
830
831        assert!(
832            !root
833                .join(".memstead/projections/macos/old-name.json")
834                .exists()
835        );
836        let configs = load_legacy_pipeline_configs(root).unwrap();
837        assert_eq!(configs.projections.len(), 1);
838        assert_eq!(configs.projections[0].name, "new-name");
839        assert_eq!(configs.projections[0].config, projection);
840    }
841
842    #[test]
843    fn rename_refuses_to_clobber_an_existing_target() {
844        let tmp = TempDir::new().unwrap();
845        let root = tmp.path();
846        let (_, _, projection, _) = sample();
847        write_projection(root, "macos", "a", &projection).unwrap();
848        write_projection(root, "macos", "b", &projection).unwrap();
849
850        let err = rename_projection(root, "macos", "a", "b").unwrap_err();
851        assert!(matches!(err, StoreError::Other(_)), "got {err:?}");
852        // Both records survive — nothing was lost.
853        assert!(root.join(".memstead/projections/macos/a.json").exists());
854        assert!(root.join(".memstead/projections/macos/b.json").exists());
855    }
856
857    #[test]
858    fn rename_of_missing_source_surfaces_io_error() {
859        let tmp = TempDir::new().unwrap();
860        let err = rename_ingest(tmp.path(), "missing", "whatever").unwrap_err();
861        assert!(matches!(err, StoreError::Io { .. }), "got {err:?}");
862    }
863
864    // ── binding v1 loader (D2 version gate) ──────────────────────────────
865
866    fn sample_binding() -> BindingV1 {
867        use crate::binding::{
868            BINDING_VERSION, BuildMode, BuildOperation, CoverageSemantics, Operations,
869        };
870        use crate::pipeline::IngestTrigger;
871        BindingV1 {
872            version: BINDING_VERSION,
873            intent: Some("prose".to_string()),
874            source_facets: vec!["source-tree".to_string()],
875            reference_mems: vec![],
876            destination_mem: "engine".to_string(),
877            deny_paths: vec![],
878            coverage_semantics: CoverageSemantics::Exhaustive,
879            rules: None,
880            prune: None,
881            operations: Operations {
882                build: Some(BuildOperation {
883                    mode: BuildMode::Discovery,
884                    trigger: IngestTrigger::Loop,
885                    batch_size: 20,
886                    post_actions: None,
887                }),
888                sync: None,
889                verify: None,
890            },
891        }
892    }
893
894    #[test]
895    fn empty_store_loads_empty_binding_configs() {
896        let tmp = TempDir::new().unwrap();
897        let configs = load_pipeline_configs(tmp.path()).unwrap();
898        assert_eq!(configs, BindingConfigs::default());
899    }
900
901    #[test]
902    fn binding_loader_round_trips_a_v1_binding() {
903        let tmp = TempDir::new().unwrap();
904        let root = tmp.path();
905        let binding = sample_binding();
906        write_binding(root, "engine", "graph", &binding).unwrap();
907
908        let configs = load_pipeline_configs(root).unwrap();
909        assert_eq!(configs.bindings.len(), 1);
910        assert_eq!(configs.bindings[0].mem, "engine");
911        assert_eq!(configs.bindings[0].name, "graph");
912        assert_eq!(configs.bindings[0].config, binding);
913    }
914
915    #[test]
916    fn version_less_projection_refuses_with_migrate_naming_error() {
917        let tmp = TempDir::new().unwrap();
918        let root = tmp.path();
919        // A gen-2 (version-less) projection file.
920        let projection = Projection {
921            intent: Some("legacy".to_string()),
922            source_facets: vec!["f".to_string()],
923            reference_mems: vec![],
924            destination_mem: "engine".to_string(),
925            rules: None,
926        };
927        write_projection(root, "engine", "graph", &projection).unwrap();
928
929        let err = load_pipeline_configs(root).unwrap_err();
930        match err {
931            StoreError::LegacyProjectionStore { path } => {
932                assert!(path.ends_with("graph.json"), "got {path:?}");
933                assert!(
934                    err_message(&StoreError::LegacyProjectionStore { path })
935                        .contains("memstead projection migrate")
936                );
937            }
938            other => panic!("expected LegacyProjectionStore, got {other:?}"),
939        }
940    }
941
942    #[test]
943    fn unknown_binding_version_refuses() {
944        let tmp = TempDir::new().unwrap();
945        let root = tmp.path();
946        let dir = root.join(".memstead/projections/engine");
947        std::fs::create_dir_all(&dir).unwrap();
948        std::fs::write(
949            dir.join("graph.json"),
950            br#"{"version": 99, "destination_mem": "engine", "operations": {"build": {"mode": "discovery", "trigger": "loop", "batch_size": 20}}}"#,
951        )
952        .unwrap();
953
954        let err = load_pipeline_configs(root).unwrap_err();
955        assert!(
956            matches!(err, StoreError::UnknownBindingVersion { version: 99, .. }),
957            "got {err:?}"
958        );
959    }
960
961    fn err_message(e: &StoreError) -> String {
962        e.to_string()
963    }
964}