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 step (string identifier, e.g.
86 /// `pdf-to-markdown`). Unset for every text source today. A source that
87 /// names a preparation the engine has no implementation for is accepted
88 /// at rest and reported unsupported at run time (the run prints
89 /// "Skipping." and exits 0) — not refused at declaration.
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub preparation: Option<String>,
92}
93
94/// Legacy **Medium** (migrate-local) — the standalone territory record of the
95/// retired three-file store. Parsed only by the migration legs; the live
96/// model carries this content inline as a [`Source`]'s medium half.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct Medium {
99 /// Stable name — facets and projections reference a medium by this.
100 pub name: String,
101 /// What kind of surface this is.
102 #[serde(rename = "type")]
103 pub medium_type: MediumType,
104 /// Where the body of information lives — a path, URL, or mem id,
105 /// interpreted per [`Self::medium_type`]. Opaque to this layer.
106 pub pointer: String,
107 /// An optional declared change-detection strategy for sources reading
108 /// this medium — `none` / `git` / `mtime` / `auto`. Unset (the common
109 /// case) means `auto`: the ingest resolver probes for a git work tree
110 /// over [`Self::pointer`] and picks `git` or `mtime`. A graph-typed
111 /// medium ignores this and always uses the graph snapshot signal.
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub change_detection: Option<String>,
114}
115
116/// Whether a [`PatternEntry`] admits or excludes the matched paths.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "lowercase")]
119pub enum PatternMode {
120 /// Paths matching this pattern are in reach.
121 Allow,
122 /// Paths matching this pattern are excluded.
123 Deny,
124}
125
126/// One allow/deny glob in a [`Facet`]'s selection over its medium. Mirrors the
127/// `{ path, mode }` entries of the legacy scope `tree`.
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129pub struct PatternEntry {
130 /// Glob pattern, interpreted relative to the referenced medium's pointer.
131 pub path: String,
132 /// Whether the pattern admits or excludes.
133 pub mode: PatternMode,
134}
135
136/// Legacy **Facet** (migrate-local) — the standalone engagement record of
137/// the retired three-file store. Parsed only by the migration legs; the live
138/// model carries this content inline as a [`Source`]'s facet half.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct Facet {
141 /// Stable name — projections reference a facet by this.
142 pub name: String,
143 /// The [`Medium`] (by name) this facet is a perspective on. A facet
144 /// always references exactly one medium.
145 pub medium: String,
146 /// Allow/deny selection over the referenced medium. A facet with **no
147 /// allow patterns is *unscoped*** — a typed refusal at run time (no
148 /// strategy diffs or enumerates the whole medium; the brief reports it as
149 /// unmonitored), not "whole medium". A facet that truly wants everything
150 /// writes `**/*`.
151 #[serde(default)]
152 pub scope: Vec<PatternEntry>,
153 /// Engagement contract — verbs, tools, terminology, discipline. Free-form
154 /// because the shape differs by medium type and by source/destination
155 /// side; the engine does not interpret it.
156 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub engagement: Option<serde_json::Value>,
158 /// Optional deterministic preparation step (string identifier, e.g.
159 /// `pdf-to-markdown`). Unset for every text medium today. A facet that
160 /// names a preparation the engine has no implementation for is accepted at
161 /// rest but reported unsupported at run time — no silent skip, no crash.
162 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub preparation: Option<String>,
164}
165
166/// Legacy **Projection** (migrate-local) — the gen-2 obligation record that
167/// referenced facets by name. Parsed only by the migration legs; the live
168/// obligation is the v2 [`crate::binding::Binding`] with inline sources.
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170pub struct Projection {
171 /// What the projection is trying to accomplish — prose for the agent.
172 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub intent: Option<String>,
174 /// Source facets (by name) the projection consumes.
175 #[serde(default)]
176 pub source_facets: Vec<String>,
177 /// Read-only reference mems that supply cross-mem context.
178 #[serde(default)]
179 pub reference_mems: Vec<String>,
180 /// The mem this projection writes into.
181 pub destination_mem: String,
182 /// Free-form projection rules (e.g. a one-shot lens `routing` string).
183 /// Opaque to the engine — consumed only by the one-shot brief renderer.
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub rules: Option<serde_json::Value>,
186}
187
188/// What sets a binding's operation running — the `trigger` of a
189/// [`crate::binding::BuildOperation`] / `SyncOperation` / `VerifyOperation`.
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(rename_all = "kebab-case")]
192pub enum IngestTrigger {
193 /// Repeated runs (the ingest skill loops it).
194 Loop,
195 /// Operator-initiated.
196 Manual,
197 /// Fired by an external event.
198 OnEvent,
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 /// A v2 inline source round-trips: the medium half (`type` lowercase on
206 /// the wire, unset `change_detection` omitted) and the facet half
207 /// (`scope` present, unset `engagement`/`preparation` omitted) in one
208 /// record — the plan's wire example shape.
209 #[test]
210 fn source_round_trips_with_both_halves() {
211 let s = Source {
212 name: "source-tree".to_string(),
213 medium_type: MediumType::Codebase,
214 pointer: "../public".to_string(),
215 change_detection: None,
216 scope: vec![
217 PatternEntry {
218 path: "../public/**/*.rs".to_string(),
219 mode: PatternMode::Allow,
220 },
221 PatternEntry {
222 path: "../public/target/**".to_string(),
223 mode: PatternMode::Deny,
224 },
225 ],
226 engagement: None,
227 preparation: None,
228 };
229 let json = serde_json::to_string(&s).unwrap();
230 assert!(json.contains(r#""type":"codebase""#), "got {json}");
231 assert!(json.contains(r#""mode":"deny""#), "got {json}");
232 for absent in ["change_detection", "engagement", "preparation"] {
233 assert!(!json.contains(absent), "unset {absent} omitted: {json}");
234 }
235 let back: Source = serde_json::from_str(&json).unwrap();
236 assert_eq!(back, s);
237 }
238
239 /// A source declaring the optional slots round-trips them.
240 #[test]
241 fn source_optional_slots_round_trip_when_set() {
242 let s = Source {
243 name: "manual-pages".to_string(),
244 medium_type: MediumType::Filesystem,
245 pointer: "../docs".to_string(),
246 change_detection: Some("mtime".to_string()),
247 scope: Vec::new(),
248 engagement: Some(serde_json::json!({ "readVerb": "Read PDF" })),
249 preparation: Some("pdf-to-markdown".to_string()),
250 };
251 let json = serde_json::to_string(&s).unwrap();
252 assert!(json.contains(r#""change_detection":"mtime""#), "got {json}");
253 let back: Source = serde_json::from_str(&json).unwrap();
254 assert_eq!(back, s);
255 }
256
257 /// A medium round-trips and its `type` serialises to the lowercase form
258 /// the legacy scope JSON used.
259 #[test]
260 fn medium_round_trips_with_lowercase_type() {
261 let m = Medium {
262 name: "source-tree".to_string(),
263 medium_type: MediumType::Codebase,
264 pointer: "../macos".to_string(),
265 change_detection: None,
266 };
267 let json = serde_json::to_string(&m).unwrap();
268 assert!(
269 !json.contains("change_detection"),
270 "unset change_detection is omitted on the wire: {json}"
271 );
272 assert!(json.contains(r#""type":"codebase""#), "got {json}");
273 let back: Medium = serde_json::from_str(&json).unwrap();
274 assert_eq!(back, m);
275 }
276
277 /// A medium declaring a `change_detection` strategy round-trips with the
278 /// value present; the field is the optional slot the ingest resolver
279 /// reads to pick a source's change-detection strategy.
280 #[test]
281 fn medium_change_detection_round_trips_when_set() {
282 let m = Medium {
283 name: "manuals".to_string(),
284 medium_type: MediumType::Filesystem,
285 pointer: "../docs".to_string(),
286 change_detection: Some("mtime".to_string()),
287 };
288 let json = serde_json::to_string(&m).unwrap();
289 assert!(json.contains(r#""change_detection":"mtime""#), "got {json}");
290 let back: Medium = serde_json::from_str(&json).unwrap();
291 assert_eq!(back.change_detection.as_deref(), Some("mtime"));
292 assert_eq!(back, m);
293 }
294
295 /// A source facet with allow/deny scope and no preparation round-trips,
296 /// and the unset `preparation`/`engagement` keys are omitted on the wire.
297 #[test]
298 fn facet_round_trips_and_omits_unset_optional_fields() {
299 let f = Facet {
300 name: "source-files".to_string(),
301 medium: "source-tree".to_string(),
302 scope: vec![
303 PatternEntry {
304 path: "../macos/**/*.swift".to_string(),
305 mode: PatternMode::Allow,
306 },
307 PatternEntry {
308 path: "../macos/specs/**".to_string(),
309 mode: PatternMode::Deny,
310 },
311 ],
312 engagement: None,
313 preparation: None,
314 };
315 let json = serde_json::to_string(&f).unwrap();
316 assert!(
317 !json.contains("preparation"),
318 "unset preparation omitted: {json}"
319 );
320 assert!(
321 !json.contains("engagement"),
322 "unset engagement omitted: {json}"
323 );
324 assert!(json.contains(r#""mode":"deny""#), "got {json}");
325 let back: Facet = serde_json::from_str(&json).unwrap();
326 assert_eq!(back, f);
327 assert_eq!(back.preparation, None);
328 }
329
330 /// A facet declaring a preparation identifier round-trips with the value
331 /// present — the slot is reserved even though no implementation exists.
332 #[test]
333 fn facet_preparation_slot_round_trips_when_set() {
334 let f = Facet {
335 name: "manual-pages".to_string(),
336 medium: "manuals".to_string(),
337 scope: Vec::new(),
338 engagement: Some(serde_json::json!({ "readVerb": "Read PDF" })),
339 preparation: Some("pdf-to-markdown".to_string()),
340 };
341 let json = serde_json::to_string(&f).unwrap();
342 let back: Facet = serde_json::from_str(&json).unwrap();
343 assert_eq!(back.preparation.as_deref(), Some("pdf-to-markdown"));
344 assert_eq!(back, f);
345 }
346
347 /// A projection maps source facets + reference mems to one destination.
348 #[test]
349 fn projection_round_trips() {
350 let p = Projection {
351 intent: Some("Swift desktop app source.".to_string()),
352 source_facets: vec!["source-files".to_string()],
353 reference_mems: vec!["engine".to_string()],
354 destination_mem: "macos".to_string(),
355 rules: None,
356 };
357 let json = serde_json::to_string(&p).unwrap();
358 let back: Projection = serde_json::from_str(&json).unwrap();
359 assert_eq!(back, p);
360 assert_eq!(back.destination_mem, "macos");
361 }
362
363 /// `IngestTrigger`'s kebab-case variants serialise as the doc names
364 /// (`on-event`) — the wire forms a binding's operation `trigger` uses.
365 #[test]
366 fn ingest_trigger_uses_kebab_wire_forms() {
367 let on_event = serde_json::to_string(&IngestTrigger::OnEvent).unwrap();
368 assert_eq!(on_event, r#""on-event""#);
369 let loop_ = serde_json::to_string(&IngestTrigger::Loop).unwrap();
370 assert_eq!(loop_, r#""loop""#);
371 }
372}