Skip to main content

memstead_base/
pipeline.rs

1//! Pipeline primitives — the inline [`Source`] and the migrate-local legacy
2//! shapes.
3//!
4//! A pipeline is **one record**: the versioned [`crate::binding::Binding`]
5//! (v2) under `projections/<mem>/<name>.json`, which alone fully defines the
6//! obligation — intent, inline sources, reference mems, destination, deny
7//! paths, coverage semantics, and operations. [`Source`] is the record's
8//! inline source entry; *medium* and *facet* survive only as the names of a
9//! source description's two halves (where it lives / which part of it),
10//! never as standalone records.
11//!
12//! - The **medium half** of a [`Source`]: `type` / `pointer` /
13//!   `change_detection` — a typed reference to a body of information.
14//! - The **facet half**: `scope` (allow/deny patterns), an optional
15//!   `engagement` contract, and an optional deterministic `preparation` step.
16//!
17//! These are operator-edited configs. The loader's job is load + validate +
18//! expose read-only; nothing here fetches, transforms, or schedules.
19//!
20//! [`Medium`] / [`Facet`] / [`Projection`] are **migrate-local** legacy
21//! shapes: parsed only by `memstead projection migrate`'s conversion legs
22//! (gen-1 root-folder, gen-2 four-primitive, v1 three-file). No live path
23//! constructs or reads them, same as [`crate::pipeline_store::LegacyIngest`].
24
25use serde::{Deserialize, Serialize};
26
27/// What kind of surface a [`Medium`] references. The string forms match the
28/// `type` field of the legacy `scopes/<mem>/<name>.json` records, so
29/// the migration shim maps them without translation. `pdf` (and other
30/// non-text mediums) join this enum with their follow-up plans.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum MediumType {
34    /// A source tree of code.
35    Codebase,
36    /// A directory of files (non-code).
37    Filesystem,
38    /// Another mem's graph (reachable as the reserved id `graph` for "home").
39    Graph,
40    /// A git history.
41    Git,
42    /// Web sources.
43    Web,
44}
45
46/// One inline **source** of a v2 [`crate::binding::Binding`] — the full
47/// description of a body of information the pipeline reads, carrying both
48/// halves the retired standalone records used to split: the *medium* half
49/// (where it lives — `type` / `pointer` / `change_detection`) and the
50/// *facet* half (which part of it — `scope` / `engagement` / `preparation`).
51///
52/// `name` is required and unique within the record: it keys per-source
53/// sync/verify state (`<mem>/<binding>/<source>#synced`) exactly as facet
54/// names did before the consolidation, which is why migration preserves
55/// facet names as source names byte-verbatim.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct Source {
58    /// Stable name — keys per-source sync/verify state.
59    pub name: String,
60    /// What kind of surface this source references (the medium half).
61    #[serde(rename = "type")]
62    pub medium_type: MediumType,
63    /// Where the body of information lives — a path, URL, or mem id,
64    /// interpreted per [`Self::medium_type`]. Opaque to this layer.
65    pub pointer: String,
66    /// Optional declared change-detection strategy — `none` / `git` /
67    /// `mtime` / `auto`. Unset (the common case) means `auto`: the ingest
68    /// resolver probes for a git work tree over [`Self::pointer`] and picks
69    /// `git` or `mtime`. A graph-typed source ignores this and always uses
70    /// the graph snapshot signal.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub change_detection: Option<String>,
73    /// Allow/deny selection over the source (the facet half). A source with
74    /// **no allow patterns is *unscoped*** — a typed refusal at run time (no
75    /// strategy diffs or enumerates the whole territory; the brief reports
76    /// it as unmonitored), not "everything". A source that truly wants
77    /// everything writes `**/*`.
78    #[serde(default)]
79    pub scope: Vec<PatternEntry>,
80    /// Engagement contract — verbs, tools, terminology, discipline.
81    /// Free-form because the shape differs by medium type; the engine does
82    /// not interpret it.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub engagement: Option<serde_json::Value>,
85    /// Optional deterministic preparation — the identifier of a
86    /// preparation registered in the engine's [`crate::preparation`]
87    /// registry (today `entity-load-bearing` on graph sources and
88    /// `dated-entries` on path-shaped ones). At most one per source.
89    /// The edit/validate paths refuse an identifier the registry does not
90    /// know ([`crate::binding::CapabilityError::PreparationUnsupported`]);
91    /// a record that acquired an unknown one by hand is accepted at rest and
92    /// reported unsupported at run time (the brief prints "Skipping." and
93    /// exits 0) — both paths apply the one registry rule.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub preparation: Option<String>,
96}
97
98/// Legacy **Medium** (migrate-local) — the standalone territory record of the
99/// retired three-file store. Parsed only by the migration legs; the live
100/// model carries this content inline as a [`Source`]'s medium half.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct Medium {
103    /// Stable name — facets and projections reference a medium by this.
104    pub name: String,
105    /// What kind of surface this is.
106    #[serde(rename = "type")]
107    pub medium_type: MediumType,
108    /// Where the body of information lives — a path, URL, or mem id,
109    /// interpreted per [`Self::medium_type`]. Opaque to this layer.
110    pub pointer: String,
111    /// An optional declared change-detection strategy for sources reading
112    /// this medium — `none` / `git` / `mtime` / `auto`. Unset (the common
113    /// case) means `auto`: the ingest resolver probes for a git work tree
114    /// over [`Self::pointer`] and picks `git` or `mtime`. A graph-typed
115    /// medium ignores this and always uses the graph snapshot signal.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub change_detection: Option<String>,
118}
119
120/// Whether a [`PatternEntry`] admits or excludes the matched paths.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "lowercase")]
123pub enum PatternMode {
124    /// Paths matching this pattern are in reach.
125    Allow,
126    /// Paths matching this pattern are excluded.
127    Deny,
128}
129
130/// One allow/deny glob in a [`Facet`]'s selection over its medium. Mirrors the
131/// `{ path, mode }` entries of the legacy scope `tree`.
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct PatternEntry {
134    /// Glob pattern, interpreted relative to the referenced medium's pointer.
135    pub path: String,
136    /// Whether the pattern admits or excludes.
137    pub mode: PatternMode,
138}
139
140/// Legacy **Facet** (migrate-local) — the standalone engagement record of
141/// the retired three-file store. Parsed only by the migration legs; the live
142/// model carries this content inline as a [`Source`]'s facet half.
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct Facet {
145    /// Stable name — projections reference a facet by this.
146    pub name: String,
147    /// The [`Medium`] (by name) this facet is a perspective on. A facet
148    /// always references exactly one medium.
149    pub medium: String,
150    /// Allow/deny selection over the referenced medium. A facet with **no
151    /// allow patterns is *unscoped*** — a typed refusal at run time (no
152    /// strategy diffs or enumerates the whole medium; the brief reports it as
153    /// unmonitored), not "whole medium". A facet that truly wants everything
154    /// writes `**/*`.
155    #[serde(default)]
156    pub scope: Vec<PatternEntry>,
157    /// Engagement contract — verbs, tools, terminology, discipline. Free-form
158    /// because the shape differs by medium type and by source/destination
159    /// side; the engine does not interpret it.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub engagement: Option<serde_json::Value>,
162    /// Optional preparation identifier, carried verbatim into the folded
163    /// [`Source::preparation`] by migration (never dropped, so the registry
164    /// refusal surfaces on the migrated record). A facet that
165    /// names a preparation the engine's registry does not know is accepted at
166    /// rest but reported unsupported at run time — no silent skip, no crash.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub preparation: Option<String>,
169}
170
171/// Legacy **Projection** (migrate-local) — the gen-2 obligation record that
172/// referenced facets by name. Parsed only by the migration legs; the live
173/// obligation is the v2 [`crate::binding::Binding`] with inline sources.
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175pub struct Projection {
176    /// What the projection is trying to accomplish — prose for the agent.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub intent: Option<String>,
179    /// Source facets (by name) the projection consumes.
180    #[serde(default)]
181    pub source_facets: Vec<String>,
182    /// Read-only reference mems that supply cross-mem context.
183    #[serde(default)]
184    pub reference_mems: Vec<String>,
185    /// The mem this projection writes into.
186    pub destination_mem: String,
187    /// Free-form projection rules (e.g. a one-shot lens `routing` string).
188    /// Opaque to the engine — consumed only by the one-shot brief renderer.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub rules: Option<serde_json::Value>,
191}
192
193/// What sets a binding's operation running — the `trigger` of a
194/// [`crate::binding::BuildOperation`] / `SyncOperation` / `VerifyOperation`.
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "kebab-case")]
197pub enum IngestTrigger {
198    /// Repeated runs (the ingest skill loops it).
199    Loop,
200    /// Operator-initiated.
201    Manual,
202    /// Fired by an external event.
203    OnEvent,
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    /// A v2 inline source round-trips: the medium half (`type` lowercase on
211    /// the wire, unset `change_detection` omitted) and the facet half
212    /// (`scope` present, unset `engagement`/`preparation` omitted) in one
213    /// record — the plan's wire example shape.
214    #[test]
215    fn source_round_trips_with_both_halves() {
216        let s = Source {
217            name: "source-tree".to_string(),
218            medium_type: MediumType::Codebase,
219            pointer: "../public".to_string(),
220            change_detection: None,
221            scope: vec![
222                PatternEntry {
223                    path: "../public/**/*.rs".to_string(),
224                    mode: PatternMode::Allow,
225                },
226                PatternEntry {
227                    path: "../public/target/**".to_string(),
228                    mode: PatternMode::Deny,
229                },
230            ],
231            engagement: None,
232            preparation: None,
233        };
234        let json = serde_json::to_string(&s).unwrap();
235        assert!(json.contains(r#""type":"codebase""#), "got {json}");
236        assert!(json.contains(r#""mode":"deny""#), "got {json}");
237        for absent in ["change_detection", "engagement", "preparation"] {
238            assert!(!json.contains(absent), "unset {absent} omitted: {json}");
239        }
240        let back: Source = serde_json::from_str(&json).unwrap();
241        assert_eq!(back, s);
242    }
243
244    /// A source declaring the optional slots round-trips them.
245    #[test]
246    fn source_optional_slots_round_trip_when_set() {
247        let s = Source {
248            name: "manual-pages".to_string(),
249            medium_type: MediumType::Filesystem,
250            pointer: "../docs".to_string(),
251            change_detection: Some("mtime".to_string()),
252            scope: Vec::new(),
253            engagement: Some(serde_json::json!({ "readVerb": "Read PDF" })),
254            preparation: Some("pdf-to-markdown".to_string()),
255        };
256        let json = serde_json::to_string(&s).unwrap();
257        assert!(json.contains(r#""change_detection":"mtime""#), "got {json}");
258        let back: Source = serde_json::from_str(&json).unwrap();
259        assert_eq!(back, s);
260    }
261
262    /// A medium round-trips and its `type` serialises to the lowercase form
263    /// the legacy scope JSON used.
264    #[test]
265    fn medium_round_trips_with_lowercase_type() {
266        let m = Medium {
267            name: "source-tree".to_string(),
268            medium_type: MediumType::Codebase,
269            pointer: "../macos".to_string(),
270            change_detection: None,
271        };
272        let json = serde_json::to_string(&m).unwrap();
273        assert!(
274            !json.contains("change_detection"),
275            "unset change_detection is omitted on the wire: {json}"
276        );
277        assert!(json.contains(r#""type":"codebase""#), "got {json}");
278        let back: Medium = serde_json::from_str(&json).unwrap();
279        assert_eq!(back, m);
280    }
281
282    /// A medium declaring a `change_detection` strategy round-trips with the
283    /// value present; the field is the optional slot the ingest resolver
284    /// reads to pick a source's change-detection strategy.
285    #[test]
286    fn medium_change_detection_round_trips_when_set() {
287        let m = Medium {
288            name: "manuals".to_string(),
289            medium_type: MediumType::Filesystem,
290            pointer: "../docs".to_string(),
291            change_detection: Some("mtime".to_string()),
292        };
293        let json = serde_json::to_string(&m).unwrap();
294        assert!(json.contains(r#""change_detection":"mtime""#), "got {json}");
295        let back: Medium = serde_json::from_str(&json).unwrap();
296        assert_eq!(back.change_detection.as_deref(), Some("mtime"));
297        assert_eq!(back, m);
298    }
299
300    /// A source facet with allow/deny scope and no preparation round-trips,
301    /// and the unset `preparation`/`engagement` keys are omitted on the wire.
302    #[test]
303    fn facet_round_trips_and_omits_unset_optional_fields() {
304        let f = Facet {
305            name: "source-files".to_string(),
306            medium: "source-tree".to_string(),
307            scope: vec![
308                PatternEntry {
309                    path: "../macos/**/*.swift".to_string(),
310                    mode: PatternMode::Allow,
311                },
312                PatternEntry {
313                    path: "../macos/specs/**".to_string(),
314                    mode: PatternMode::Deny,
315                },
316            ],
317            engagement: None,
318            preparation: None,
319        };
320        let json = serde_json::to_string(&f).unwrap();
321        assert!(
322            !json.contains("preparation"),
323            "unset preparation omitted: {json}"
324        );
325        assert!(
326            !json.contains("engagement"),
327            "unset engagement omitted: {json}"
328        );
329        assert!(json.contains(r#""mode":"deny""#), "got {json}");
330        let back: Facet = serde_json::from_str(&json).unwrap();
331        assert_eq!(back, f);
332        assert_eq!(back.preparation, None);
333    }
334
335    /// A facet declaring a preparation identifier round-trips with the value
336    /// present, verbatim: whether the identifier is registered is the
337    /// validator's business (`validate_binding`), never the record's.
338    #[test]
339    fn facet_preparation_slot_round_trips_when_set() {
340        let f = Facet {
341            name: "manual-pages".to_string(),
342            medium: "manuals".to_string(),
343            scope: Vec::new(),
344            engagement: Some(serde_json::json!({ "readVerb": "Read PDF" })),
345            preparation: Some("pdf-to-markdown".to_string()),
346        };
347        let json = serde_json::to_string(&f).unwrap();
348        let back: Facet = serde_json::from_str(&json).unwrap();
349        assert_eq!(back.preparation.as_deref(), Some("pdf-to-markdown"));
350        assert_eq!(back, f);
351    }
352
353    /// A projection maps source facets + reference mems to one destination.
354    #[test]
355    fn projection_round_trips() {
356        let p = Projection {
357            intent: Some("Swift desktop app source.".to_string()),
358            source_facets: vec!["source-files".to_string()],
359            reference_mems: vec!["engine".to_string()],
360            destination_mem: "macos".to_string(),
361            rules: None,
362        };
363        let json = serde_json::to_string(&p).unwrap();
364        let back: Projection = serde_json::from_str(&json).unwrap();
365        assert_eq!(back, p);
366        assert_eq!(back.destination_mem, "macos");
367    }
368
369    /// `IngestTrigger`'s kebab-case variants serialise as the doc names
370    /// (`on-event`) — the wire forms a binding's operation `trigger` uses.
371    #[test]
372    fn ingest_trigger_uses_kebab_wire_forms() {
373        let on_event = serde_json::to_string(&IngestTrigger::OnEvent).unwrap();
374        assert_eq!(on_event, r#""on-event""#);
375        let loop_ = serde_json::to_string(&IngestTrigger::Loop).unwrap();
376        assert_eq!(loop_, r#""loop""#);
377    }
378}