Skip to main content

memstead_base/
binding_migrate.rs

1//! Gen-2 four-primitive → binding **v1** migration (D10, gen-2 path).
2//!
3//! Converts the current four-primitive store (per-mem [`Projection`] + flat
4//! [`Ingest`]) into v1 [`BindingV1`] records: each flat ingest is merged into
5//! the projection its `projection` ref names, collapsing the
6//! declaration/schedule split into one versioned binding. The canonical
7//! binding id is `<mem>/<stem>` — the projection's owning mem dir plus its
8//! file stem, i.e. the very string the ingest already used as its `projection`
9//! ref (D3).
10//!
11//! [`migrate_gen2_bindings`] is pure and IO-free — it transforms
12//! already-loaded [`PipelineConfigs`] into keyed bindings. The CLI
13//! (`memstead projection migrate`) wraps it with the load / write / validate
14//! IO. This module is **additive and un-wired**: it neither flips the loader
15//! version-gate (D2) nor implements the gen-1 root-folder path (also D10) —
16//! those are separate, later slices.
17//!
18//! ## Field mapping (gen-2 → v1)
19//!
20//! - `version` = 1 (`BINDING_VERSION`).
21//! - `intent` / `source_facets` / `reference_mems` / `destination_mem` /
22//!   `rules` — carried over from the [`Projection`] verbatim.
23//! - `deny_paths` — moved **up** from the per-ingest record to the binding
24//!   (strategy-invariant, E1); bare directory segments are rewritten to E1's
25//!   workspace-relative glob dialect where trivially derivable (see
26//!   [`to_glob_dialect`]), every rewrite recorded as a note.
27//! - `coverage_semantics` — defaults [`CoverageSemantics::Exhaustive`] (gen-2
28//!   has no such field).
29//! - `operations.build` — the ingest's `mode` / `trigger` / `batch_size` /
30//!   `post_actions`. `mode` maps `discovery` → [`BuildMode::Discovery`] and
31//!   `one-shot` → [`BuildMode::OneShot`]; **`refinement` is a typed migrate
32//!   error** ([`BindingMigrateError::RefinementModeDeleted`]) — the vocabulary
33//!   is deleted, not migrated (D1).
34//! - `operations.sync` / `operations.verify` — `None`. A gen-2 config declares
35//!   only the build-equivalent schedule; sync/verify are enabled later via
36//!   `projection enable`, never fabricated by migration.
37//!
38//! A **dangling ingest→projection ref** is a typed migrate error
39//! ([`BindingMigrateError::DanglingProjectionRef`]), never a silent drop (D10).
40
41use crate::binding::{
42    BINDING_VERSION, BindingV1, BuildMode, BuildOperation, CoverageSemantics, Operations,
43    ResolvedBinding,
44};
45use crate::ingest::resolve::{ResolveError, resolve_binding};
46use crate::pipeline::Projection;
47use crate::pipeline_store::{LegacyIngest, LegacyIngestMode, PipelineConfigs};
48
49/// Why a gen-2 config could not be migrated to a v1 binding. Every variant
50/// names the offending ingest so the failure is diagnosable without
51/// re-reading the store.
52#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
53pub enum BindingMigrateError {
54    /// The ingest declares build mode `refinement` — deleted from the binding
55    /// vocabulary (D1). Not migrated: refinement-as-writer is gone, so there
56    /// is no v1 shape to carry it into.
57    #[error(
58        "ingest '{ingest}' declares build mode 'refinement', which is deleted from the binding \
59         vocabulary (D1) — refinement-as-writer is gone; re-declare it as a discovery build (plus \
60         a sync/verify obligation) before migrating"
61    )]
62    RefinementModeDeleted {
63        /// The offending ingest (its file stem).
64        ingest: String,
65    },
66    /// The ingest's `projection` field is not the required `"<mem>/<name>"`.
67    #[error(
68        "ingest '{ingest}' has a malformed projection ref '{projection}'; expected \"<mem>/<name>\""
69    )]
70    MalformedProjectionRef {
71        /// The ingest whose projection ref is malformed.
72        ingest: String,
73        /// The malformed value.
74        projection: String,
75    },
76    /// The ingest references a projection that does not exist — a dangling ref.
77    /// A typed error, never a silent drop (D10).
78    #[error(
79        "ingest '{ingest}' references projection '{projection_ref}' which does not exist in mem \
80         '{mem}' (dangling ref — not migrated); available: {}",
81        fmt_list(available)
82    )]
83    DanglingProjectionRef {
84        /// The referencing ingest.
85        ingest: String,
86        /// The full `"<mem>/<name>"` ref.
87        projection_ref: String,
88        /// The mem the projection was looked up in.
89        mem: String,
90        /// The projection names that do exist in that mem.
91        available: Vec<String>,
92    },
93}
94
95/// Render a name list for an error message: `a, b, c` or `(none)`.
96fn fmt_list(names: &[String]) -> String {
97    if names.is_empty() {
98        "(none)".to_string()
99    } else {
100        names.join(", ")
101    }
102}
103
104/// Convert a legacy bare-name `deny_paths` entry to E1's workspace-relative
105/// glob dialect where trivially derivable, else carry it through unchanged
106/// (D1 — the gen-2 dialect-forward-carry).
107///
108/// A **bare directory segment** — non-empty, no path separator, no glob
109/// metacharacter (`*?[]`), and no `.` extension marker — is rewritten to a
110/// recursive-subtree glob `<segment>/**` (so `dev` → `dev/**`, matching E1's
111/// `"dev/**"` example). Anything already carrying a `/`, a glob metacharacter,
112/// or a `.` (a file like `VISION.md`, already a valid workspace-relative
113/// match) is a no-op — carried through. The return value equals the input
114/// exactly when nothing changed, so callers can detect (and note) rewrites by
115/// comparison.
116fn to_glob_dialect(entry: &str) -> String {
117    let is_bare_segment = !entry.is_empty()
118        && !entry.contains('/')
119        && !entry.contains('.')
120        && !entry.contains(['*', '?', '[', ']']);
121    if is_bare_segment {
122        format!("{entry}/**")
123    } else {
124        entry.to_string()
125    }
126}
127
128/// A single migrated binding paired with the identity and provenance the CLI
129/// needs to write it to disk and report it.
130#[derive(Debug, Clone, PartialEq)]
131pub struct MigratedBinding {
132    /// The canonical binding id `<mem>/<stem>` (D3) — the projection ref the
133    /// ingest already used.
134    pub id: String,
135    /// The projection's owning mem dir (the `.memstead/projections/<mem>/`
136    /// tier the binding file lives under).
137    pub mem: String,
138    /// The projection file stem (`<stem>` in the binding id).
139    pub name: String,
140    /// The flat ingest merged into this binding (its file stem) — used to
141    /// resolve the binding for validation and to delete the consumed ingest.
142    pub ingest_name: String,
143    /// The produced v1 binding.
144    pub binding: BindingV1,
145    /// Human-readable notes about non-identity transforms applied (e.g.
146    /// deny-path dialect rewrites). Empty when the migration was verbatim.
147    pub notes: Vec<String>,
148}
149
150/// Convert one gen-2 (`Ingest` + its `Projection`) into a v1 [`BindingV1`],
151/// returning the binding plus any per-field transform notes. Pure — no IO.
152/// `ingest_name` is used only for error messages.
153///
154/// See the [module docs](self) for the full field mapping. `refinement` mode
155/// is a typed error; every other field carries over or defaults as documented.
156pub(crate) fn binding_from_gen2(
157    ingest_name: &str,
158    ingest: &LegacyIngest,
159    projection: &Projection,
160) -> Result<(BindingV1, Vec<String>), BindingMigrateError> {
161    let mode = match ingest.mode {
162        LegacyIngestMode::Discovery => BuildMode::Discovery,
163        LegacyIngestMode::OneShot => BuildMode::OneShot,
164        LegacyIngestMode::Refinement => {
165            return Err(BindingMigrateError::RefinementModeDeleted {
166                ingest: ingest_name.to_string(),
167            });
168        }
169    };
170
171    let mut notes = Vec::new();
172    let deny_paths = ingest
173        .deny_paths
174        .iter()
175        .map(|d| {
176            let converted = to_glob_dialect(d);
177            if converted != *d {
178                notes.push(format!(
179                    "deny_paths: rewrote bare entry '{d}' to glob dialect '{converted}'"
180                ));
181            }
182            converted
183        })
184        .collect();
185
186    let binding = BindingV1 {
187        version: BINDING_VERSION,
188        intent: projection.intent.clone(),
189        source_facets: projection.source_facets.clone(),
190        reference_mems: projection.reference_mems.clone(),
191        destination_mem: projection.destination_mem.clone(),
192        deny_paths,
193        coverage_semantics: CoverageSemantics::Exhaustive,
194        rules: projection.rules.clone(),
195        prune: None,
196        operations: Operations {
197            build: Some(BuildOperation {
198                mode,
199                trigger: ingest.trigger,
200                batch_size: ingest.batch_size,
201                post_actions: ingest.post_actions.clone(),
202            }),
203            // A gen-2 config declares only the build-equivalent schedule.
204            // Sync/verify are enabled later via `projection enable`, never
205            // fabricated by migration.
206            sync: None,
207            verify: None,
208        },
209    };
210    Ok((binding, notes))
211}
212
213/// Migrate every gen-2 flat ingest in `configs` into a v1 [`MigratedBinding`],
214/// keyed by binding id (`<mem>/<stem>`, D3), in id order.
215///
216/// Ingest-driven (D10 — "merge each flat ingest into its projection"): each
217/// ingest's `projection` ref is resolved to a projection in that mem and the
218/// pair merged. A malformed ref, a dangling ref, or a `refinement` mode is a
219/// typed [`BindingMigrateError`] — the migration refuses rather than dropping
220/// or fabricating. Pure and IO-free.
221///
222/// A projection with no ingest pointing at it is inert (never runnable in
223/// gen-2) and is not emitted — nothing schedules it, so there is no obligation
224/// to promote.
225pub fn migrate_gen2_bindings(
226    configs: &PipelineConfigs,
227) -> Result<Vec<MigratedBinding>, BindingMigrateError> {
228    let mut out = Vec::new();
229    for record in &configs.ingests {
230        let ingest = &record.config;
231        let projection_ref = ingest.projection.clone();
232        let (mem, name) = projection_ref
233            .split_once('/')
234            .filter(|(m, n)| !m.is_empty() && !n.is_empty())
235            .ok_or_else(|| BindingMigrateError::MalformedProjectionRef {
236                ingest: record.name.clone(),
237                projection: projection_ref.clone(),
238            })?;
239        let mem = mem.to_string();
240        let name = name.to_string();
241
242        let projection = configs
243            .projections
244            .iter()
245            .find(|r| r.mem == mem && r.name == name)
246            .map(|r| &r.config)
247            .ok_or_else(|| BindingMigrateError::DanglingProjectionRef {
248                ingest: record.name.clone(),
249                projection_ref: projection_ref.clone(),
250                mem: mem.clone(),
251                available: configs
252                    .projections
253                    .iter()
254                    .filter(|r| r.mem == mem)
255                    .map(|r| r.name.clone())
256                    .collect(),
257            })?;
258
259        let (binding, notes) = binding_from_gen2(&record.name, ingest, projection)?;
260        out.push(MigratedBinding {
261            id: projection_ref,
262            mem,
263            name,
264            ingest_name: record.name.clone(),
265            binding,
266            notes,
267        });
268    }
269    out.sort_by(|a, b| a.id.cmp(&b.id));
270    Ok(out)
271}
272
273/// Resolve a migrated binding's primary sources (facet + medium) via the
274/// binding resolve layer, so the produced binding can be validated against the
275/// D6 capability matrix ([`crate::binding::validate_binding`]).
276///
277/// Resolves the binding's own `source_facets` against the still-loaded gen-2
278/// configs (facets/mediums in the binding-id's `<mem>` tier) via
279/// [`resolve_binding`]; reference mems are not resolved (only primary facets
280/// carry capability constraints). This gives the D6 matrix a real,
281/// config-derived consumer.
282pub fn resolve_migrated_binding(
283    configs: &PipelineConfigs,
284    binding_id: &str,
285    binding: BindingV1,
286) -> Result<ResolvedBinding, ResolveError> {
287    resolve_binding(configs, binding_id, &binding)
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use crate::binding::{CapabilityError, validate_binding};
294    use crate::pipeline::{Facet, IngestTrigger, Medium, MediumType, PatternEntry, PatternMode};
295    use crate::pipeline_store::{
296        LegacyIngest, LegacyIngestMode, MemPipelineRecord, PipelineRecord,
297    };
298
299    fn medium(mem: &str, name: &str, ty: MediumType, pointer: &str) -> MemPipelineRecord<Medium> {
300        MemPipelineRecord {
301            mem: mem.to_string(),
302            name: name.to_string(),
303            config: Medium {
304                name: name.to_string(),
305                medium_type: ty,
306                pointer: pointer.to_string(),
307                change_detection: None,
308            },
309        }
310    }
311
312    fn facet(mem: &str, name: &str, medium: &str, prep: Option<&str>) -> MemPipelineRecord<Facet> {
313        MemPipelineRecord {
314            mem: mem.to_string(),
315            name: name.to_string(),
316            config: Facet {
317                name: name.to_string(),
318                medium: medium.to_string(),
319                scope: vec![PatternEntry {
320                    path: "../src/**/*.rs".to_string(),
321                    mode: PatternMode::Allow,
322                }],
323                engagement: None,
324                preparation: prep.map(str::to_string),
325            },
326        }
327    }
328
329    fn projection(
330        mem: &str,
331        name: &str,
332        facets: &[&str],
333        refs: &[&str],
334        dest: &str,
335    ) -> MemPipelineRecord<Projection> {
336        MemPipelineRecord {
337            mem: mem.to_string(),
338            name: name.to_string(),
339            config: Projection {
340                intent: Some(format!("intent of {name}")),
341                source_facets: facets.iter().map(|s| s.to_string()).collect(),
342                reference_mems: refs.iter().map(|s| s.to_string()).collect(),
343                destination_mem: dest.to_string(),
344                rules: Some(serde_json::json!({ "routing": "r" })),
345            },
346        }
347    }
348
349    fn ingest(
350        name: &str,
351        projection: &str,
352        mode: LegacyIngestMode,
353        deny: &[&str],
354    ) -> PipelineRecord<LegacyIngest> {
355        PipelineRecord {
356            name: name.to_string(),
357            config: LegacyIngest {
358                projection: projection.to_string(),
359                mode,
360                trigger: IngestTrigger::Loop,
361                batch_size: 20,
362                deny_paths: deny.iter().map(|s| s.to_string()).collect(),
363                post_actions: Some(serde_json::json!({ "archive_source": true })),
364            },
365        }
366    }
367
368    /// A well-formed gen-2 pair migrates to a v1 binding that carries the
369    /// merged operations (mode/trigger/batch/post_actions) and the projection's
370    /// declarative fields, with build-only operations and the id `<mem>/<stem>`.
371    #[test]
372    fn migrates_a_well_formed_pair() {
373        let configs = PipelineConfigs {
374            mediums: vec![medium("engine", "src", MediumType::Codebase, "../public")],
375            facets: vec![facet("engine", "source-tree", "src", None)],
376            projections: vec![projection(
377                "engine",
378                "graph",
379                &["source-tree"],
380                &["plugin"],
381                "engine",
382            )],
383            ingests: vec![ingest(
384                "engine-graph",
385                "engine/graph",
386                LegacyIngestMode::Discovery,
387                &[],
388            )],
389        };
390
391        let migrated = migrate_gen2_bindings(&configs).unwrap();
392        assert_eq!(migrated.len(), 1);
393        let m = &migrated[0];
394        assert_eq!(m.id, "engine/graph");
395        assert_eq!(m.mem, "engine");
396        assert_eq!(m.name, "graph");
397        assert_eq!(m.ingest_name, "engine-graph");
398
399        let b = &m.binding;
400        assert_eq!(b.version, BINDING_VERSION);
401        assert_eq!(b.intent.as_deref(), Some("intent of graph"));
402        assert_eq!(b.source_facets, vec!["source-tree".to_string()]);
403        assert_eq!(b.reference_mems, vec!["plugin".to_string()]);
404        assert_eq!(b.destination_mem, "engine");
405        assert_eq!(b.coverage_semantics, CoverageSemantics::Exhaustive);
406        assert_eq!(b.rules, Some(serde_json::json!({ "routing": "r" })));
407        // Operations: build carries the merged schedule; sync/verify absent.
408        assert_eq!(
409            b.operations.build.as_ref().unwrap().mode,
410            BuildMode::Discovery
411        );
412        assert_eq!(
413            b.operations.build.as_ref().unwrap().trigger,
414            IngestTrigger::Loop
415        );
416        assert_eq!(b.operations.build.as_ref().unwrap().batch_size, 20);
417        assert_eq!(
418            b.operations.build.as_ref().unwrap().post_actions,
419            Some(serde_json::json!({ "archive_source": true }))
420        );
421        assert!(b.operations.sync.is_none());
422        assert!(b.operations.verify.is_none());
423    }
424
425    /// The produced binding round-trips losslessly through serde (the on-disk
426    /// promotion is faithful).
427    #[test]
428    fn produced_binding_round_trips() {
429        let configs = PipelineConfigs {
430            mediums: vec![medium("engine", "src", MediumType::Codebase, "../public")],
431            facets: vec![facet("engine", "source-tree", "src", None)],
432            projections: vec![projection(
433                "engine",
434                "graph",
435                &["source-tree"],
436                &[],
437                "engine",
438            )],
439            ingests: vec![ingest(
440                "engine-graph",
441                "engine/graph",
442                LegacyIngestMode::Discovery,
443                &[],
444            )],
445        };
446        let migrated = migrate_gen2_bindings(&configs).unwrap();
447        let b = &migrated[0].binding;
448        let json = serde_json::to_string(b).unwrap();
449        let back: BindingV1 = serde_json::from_str(&json).unwrap();
450        assert_eq!(&back, b);
451    }
452
453    /// `deny_paths` move up to the binding; a bare directory segment is
454    /// rewritten to the glob dialect (with a note), while glob/`/`/`.`
455    /// entries carry through unchanged.
456    #[test]
457    fn deny_paths_move_up_and_bare_segments_convert() {
458        let configs = PipelineConfigs {
459            mediums: vec![medium("engine", "src", MediumType::Codebase, "../public")],
460            facets: vec![facet("engine", "source-tree", "src", None)],
461            projections: vec![projection(
462                "engine",
463                "graph",
464                &["source-tree"],
465                &[],
466                "engine",
467            )],
468            ingests: vec![ingest(
469                "engine-graph",
470                "engine/graph",
471                LegacyIngestMode::Discovery,
472                &["dev", "VISION.md", "../public/target/**"],
473            )],
474        };
475        let migrated = migrate_gen2_bindings(&configs).unwrap();
476        let m = &migrated[0];
477        assert_eq!(
478            m.binding.deny_paths,
479            vec![
480                "dev/**".to_string(),              // bare segment → glob
481                "VISION.md".to_string(),           // has '.', carried through
482                "../public/target/**".to_string(), // has '/' + glob, carried through
483            ]
484        );
485        assert_eq!(m.notes.len(), 1, "only the bare 'dev' rewrite is noted");
486        assert!(m.notes[0].contains("dev") && m.notes[0].contains("dev/**"));
487    }
488
489    /// `one-shot` maps to the one-shot build mode.
490    #[test]
491    fn one_shot_mode_maps() {
492        let configs = PipelineConfigs {
493            projections: vec![projection("m", "p", &[], &[], "m")],
494            ingests: vec![ingest("i", "m/p", LegacyIngestMode::OneShot, &[])],
495            ..Default::default()
496        };
497        let migrated = migrate_gen2_bindings(&configs).unwrap();
498        assert_eq!(
499            migrated[0].binding.operations.build.as_ref().unwrap().mode,
500            BuildMode::OneShot
501        );
502    }
503
504    /// `refinement` mode is a typed migrate error — the vocabulary is deleted.
505    #[test]
506    fn refinement_mode_is_a_typed_error() {
507        let configs = PipelineConfigs {
508            projections: vec![projection("m", "p", &[], &[], "m")],
509            ingests: vec![ingest("i", "m/p", LegacyIngestMode::Refinement, &[])],
510            ..Default::default()
511        };
512        let err = migrate_gen2_bindings(&configs).unwrap_err();
513        assert!(
514            matches!(err, BindingMigrateError::RefinementModeDeleted { ref ingest } if ingest == "i"),
515            "got {err:?}"
516        );
517    }
518
519    /// A dangling ingest→projection ref is a typed error, never a silent drop.
520    #[test]
521    fn dangling_projection_ref_is_a_typed_error() {
522        let configs = PipelineConfigs {
523            projections: vec![projection("m", "other", &[], &[], "m")],
524            ingests: vec![ingest("i", "m/missing", LegacyIngestMode::Discovery, &[])],
525            ..Default::default()
526        };
527        let err = migrate_gen2_bindings(&configs).unwrap_err();
528        match err {
529            BindingMigrateError::DanglingProjectionRef {
530                ingest,
531                projection_ref,
532                mem,
533                available,
534            } => {
535                assert_eq!(ingest, "i");
536                assert_eq!(projection_ref, "m/missing");
537                assert_eq!(mem, "m");
538                assert_eq!(available, vec!["other".to_string()]);
539            }
540            other => panic!("expected DanglingProjectionRef, got {other:?}"),
541        }
542    }
543
544    /// A malformed projection ref (no `/`) is a typed error.
545    #[test]
546    fn malformed_projection_ref_is_a_typed_error() {
547        let configs = PipelineConfigs {
548            ingests: vec![ingest("i", "noslash", LegacyIngestMode::Discovery, &[])],
549            ..Default::default()
550        };
551        let err = migrate_gen2_bindings(&configs).unwrap_err();
552        assert!(
553            matches!(err, BindingMigrateError::MalformedProjectionRef { .. }),
554            "got {err:?}"
555        );
556    }
557
558    /// A legal codebase binding resolves and validates clean against the D6
559    /// matrix.
560    #[test]
561    fn migrated_codebase_binding_validates_clean() {
562        let configs = PipelineConfigs {
563            mediums: vec![medium("engine", "src", MediumType::Codebase, "../public")],
564            facets: vec![facet("engine", "source-tree", "src", None)],
565            projections: vec![projection(
566                "engine",
567                "graph",
568                &["source-tree"],
569                &[],
570                "engine",
571            )],
572            ingests: vec![ingest(
573                "engine-graph",
574                "engine/graph",
575                LegacyIngestMode::Discovery,
576                &["../public/target/**"],
577            )],
578        };
579        let migrated = migrate_gen2_bindings(&configs).unwrap();
580        let m = &migrated[0];
581        let resolved = resolve_migrated_binding(&configs, &m.id, m.binding.clone()).unwrap();
582        assert!(validate_binding(&resolved).is_ok());
583    }
584
585    /// A migrated binding whose facet declares a preparation surfaces the D6
586    /// capability refusal at validation.
587    #[test]
588    fn migrated_binding_with_preparation_surfaces_capability_refusal() {
589        let configs = PipelineConfigs {
590            mediums: vec![medium("docs", "manuals", MediumType::Filesystem, "../docs")],
591            facets: vec![facet("docs", "pages", "manuals", Some("pdf-to-markdown"))],
592            projections: vec![projection("docs", "manual", &["pages"], &[], "docs")],
593            ingests: vec![ingest(
594                "docs-manual",
595                "docs/manual",
596                LegacyIngestMode::Discovery,
597                &[],
598            )],
599        };
600        let migrated = migrate_gen2_bindings(&configs).unwrap();
601        let m = &migrated[0];
602        let resolved = resolve_migrated_binding(&configs, &m.id, m.binding.clone()).unwrap();
603        let errs = validate_binding(&resolved).unwrap_err();
604        assert!(
605            errs.iter().any(|e| matches!(
606                e,
607                CapabilityError::PreparationUnsupported { preparation, .. }
608                    if preparation == "pdf-to-markdown"
609            )),
610            "expected PreparationUnsupported, got {errs:?}"
611        );
612    }
613}