Skip to main content

memstead_base/
binding_migrate.rs

1//! Migration to binding **v2** — the single-record pipeline format.
2//!
3//! Two conversion legs live here, both pure and IO-free (the CLI's
4//! `memstead projection migrate` wraps them with the load / write /
5//! tree-removal IO):
6//!
7//! 1. **Gen-2 → v2** ([`migrate_gen2_bindings`]): each flat legacy ingest is
8//!    merged into the projection its `projection` ref names, and the
9//!    projection's facet references are **folded inline** — each referenced
10//!    facet joins its medium and becomes one [`Source`] under the facet's
11//!    name, byte-verbatim (source names key sync watermarks).
12//! 2. **v1 → v2** ([`fold_v1_binding`]): a three-file-store binding
13//!    ([`LegacyBindingV1`], `source_facets` by name) folds its referenced
14//!    facets + mediums inline the same way; every other field carries over
15//!    verbatim and `version` becomes 2.
16//!
17//! After both legs every medium/facet record must be **consumed**
18//! ([`check_all_consumed`]) — an orphan (a record no binding references) is a
19//! typed error, never a silent drop; the CLI removes the emptied `mediums/`
20//! and `facets/` trees only after that check passes.
21//!
22//! A **dangling reference** (ingest→projection, projection→facet,
23//! facet→medium) is a typed migrate error, never a silent drop. `refinement`
24//! build mode is a typed error — the vocabulary is deleted, not migrated.
25
26use serde::Deserialize;
27
28use crate::binding::{
29    BINDING_VERSION, Binding, BuildMode, BuildOperation, CoverageSemantics, Operations, PruneConfig,
30};
31use crate::pipeline::{Projection, Source};
32use crate::pipeline_store::{LegacyIngest, LegacyIngestMode, PipelineConfigs};
33
34/// Why a legacy config could not be migrated to a v2 binding. Every variant
35/// names the offending record so the failure is diagnosable without
36/// re-reading the store.
37#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
38pub enum BindingMigrateError {
39    /// The ingest declares build mode `refinement` — deleted from the binding
40    /// vocabulary. Not migrated: refinement-as-writer is gone, so there
41    /// is no v2 shape to carry it into.
42    #[error(
43        "ingest '{ingest}' declares build mode 'refinement', which is deleted from the binding \
44         vocabulary — refinement-as-writer is gone; re-declare it as a discovery build (plus \
45         a sync/verify obligation) before migrating"
46    )]
47    RefinementModeDeleted {
48        /// The offending ingest (its file stem).
49        ingest: String,
50    },
51    /// The ingest's `projection` field is not the required `"<mem>/<name>"`.
52    #[error(
53        "ingest '{ingest}' has a malformed projection ref '{projection}'; expected \"<mem>/<name>\""
54    )]
55    MalformedProjectionRef {
56        /// The ingest whose projection ref is malformed.
57        ingest: String,
58        /// The malformed value.
59        projection: String,
60    },
61    /// The ingest references a projection that does not exist — a dangling ref.
62    /// A typed error, never a silent drop.
63    #[error(
64        "ingest '{ingest}' references projection '{projection_ref}' which does not exist in mem \
65         '{mem}' (dangling ref — not migrated); available: {}",
66        fmt_list(available)
67    )]
68    DanglingProjectionRef {
69        /// The referencing ingest.
70        ingest: String,
71        /// The full `"<mem>/<name>"` ref.
72        projection_ref: String,
73        /// The mem the projection was looked up in.
74        mem: String,
75        /// The projection names that do exist in that mem.
76        available: Vec<String>,
77    },
78    /// A binding/projection references a facet that does not exist in its
79    /// mem — the fold cannot inline what is not there.
80    #[error(
81        "'{owner}' references facet '{facet}' not found in mem '{mem}' (dangling ref — not \
82         migrated); available: {}",
83        fmt_list(available)
84    )]
85    DanglingFacetRef {
86        /// The binding/projection id holding the reference.
87        owner: String,
88        /// The missing facet name.
89        facet: String,
90        /// The mem the facet was looked up in.
91        mem: String,
92        /// The facet names that do exist in that mem.
93        available: Vec<String>,
94    },
95    /// A facet references a medium that does not exist in its mem.
96    #[error(
97        "facet '{facet}' (folded for '{owner}') references medium '{medium}' not found in mem \
98         '{mem}' (dangling ref — not migrated); available: {}",
99        fmt_list(available)
100    )]
101    DanglingMediumRef {
102        /// The binding/projection id whose fold hit the dangling medium.
103        owner: String,
104        /// The referencing facet.
105        facet: String,
106        /// The missing medium name.
107        medium: String,
108        /// The mem the medium was looked up in.
109        mem: String,
110        /// The medium names that do exist in that mem.
111        available: Vec<String>,
112    },
113    /// After folding every binding, medium/facet records remain that no
114    /// binding referenced. Removing the trees would silently drop their
115    /// content — refused instead; the operator deletes or binds them first.
116    #[error(
117        "orphan pipeline records not referenced by any binding: {} — delete them (or bind \
118         them) before migrating; the migration removes the mediums/ and facets/ trees only \
119         when every record folded into a binding",
120        fmt_list(orphans)
121    )]
122    OrphanRecords {
123        /// `mediums/<mem>/<name>` / `facets/<mem>/<name>` style identifiers.
124        orphans: Vec<String>,
125    },
126}
127
128/// Render a name list for an error message: `a, b, c` or `(none)`.
129fn fmt_list(names: &[String]) -> String {
130    if names.is_empty() {
131        "(none)".to_string()
132    } else {
133        names.join(", ")
134    }
135}
136
137/// The retired **v1 binding** shape (migrate-local): the three-file-store
138/// record that referenced facets by name. Parsed only by the v1→v2 fold leg;
139/// the live loader refuses `version: 1` files with the migrate-naming error.
140#[derive(Debug, Clone, PartialEq, Deserialize)]
141pub struct LegacyBindingV1 {
142    /// Always `1` on disk (the loader routed the file here by that value).
143    pub version: u32,
144    #[serde(default)]
145    pub intent: Option<String>,
146    #[serde(default)]
147    pub source_facets: Vec<String>,
148    #[serde(default)]
149    pub reference_mems: Vec<String>,
150    pub destination_mem: String,
151    #[serde(default)]
152    pub deny_paths: Vec<String>,
153    /// Optional in v1 files too — a v1 file that never declared the
154    /// field migrates to an undeclared v2 field (resolved per medium),
155    /// not to a baked-in "exhaustive by silence".
156    #[serde(default)]
157    pub coverage_semantics: Option<CoverageSemantics>,
158    #[serde(default)]
159    pub rules: Option<serde_json::Value>,
160    #[serde(default)]
161    pub prune: Option<PruneConfig>,
162    pub operations: Operations,
163}
164
165/// Convert a legacy bare-name `deny_paths` entry to the workspace-relative
166/// glob dialect where trivially derivable, else carry it through unchanged
167/// (the gen-2 dialect-forward-carry).
168///
169/// A **bare directory segment** — non-empty, no path separator, no glob
170/// metacharacter (`*?[]`), and no `.` extension marker — is rewritten to a
171/// recursive-subtree glob `<segment>/**` (so `dev` → `dev/**`). Anything
172/// already carrying a `/`, a glob metacharacter, or a `.` (a file like
173/// `VISION.md`, already a valid workspace-relative match) is a no-op —
174/// carried through. The return value equals the input exactly when nothing
175/// changed, so callers can detect (and note) rewrites by comparison.
176fn to_glob_dialect(entry: &str) -> String {
177    let is_bare_segment = !entry.is_empty()
178        && !entry.contains('/')
179        && !entry.contains('.')
180        && !entry.contains(['*', '?', '[', ']']);
181    if is_bare_segment {
182        format!("{entry}/**")
183    } else {
184        entry.to_string()
185    }
186}
187
188/// Fold a list of facet references (`facet_names`, in the `<mem>` tier) into
189/// inline [`Source`]s: each facet joins its medium, the facet's name becomes
190/// the source's name **byte-verbatim** (it keys sync watermarks), the
191/// medium's `type` / `pointer` / `change_detection` become the medium half,
192/// and the facet's `scope` / `engagement` / `preparation` the facet half.
193/// `owner` names the folding binding/projection in dangling-ref errors.
194fn fold_sources(
195    owner: &str,
196    facet_names: &[String],
197    mem: &str,
198    configs: &PipelineConfigs,
199) -> Result<Vec<Source>, BindingMigrateError> {
200    let mut sources = Vec::with_capacity(facet_names.len());
201    for facet_name in facet_names {
202        let facet = configs
203            .facets
204            .iter()
205            .find(|r| r.mem == mem && r.name == *facet_name)
206            .map(|r| &r.config)
207            .ok_or_else(|| BindingMigrateError::DanglingFacetRef {
208                owner: owner.to_string(),
209                facet: facet_name.clone(),
210                mem: mem.to_string(),
211                available: configs
212                    .facets
213                    .iter()
214                    .filter(|r| r.mem == mem)
215                    .map(|r| r.name.clone())
216                    .collect(),
217            })?;
218        let medium = configs
219            .mediums
220            .iter()
221            .find(|r| r.mem == mem && r.name == facet.medium)
222            .map(|r| &r.config)
223            .ok_or_else(|| BindingMigrateError::DanglingMediumRef {
224                owner: owner.to_string(),
225                facet: facet_name.clone(),
226                medium: facet.medium.clone(),
227                mem: mem.to_string(),
228                available: configs
229                    .mediums
230                    .iter()
231                    .filter(|r| r.mem == mem)
232                    .map(|r| r.name.clone())
233                    .collect(),
234            })?;
235        sources.push(Source {
236            name: facet_name.clone(),
237            medium_type: medium.medium_type,
238            pointer: medium.pointer.clone(),
239            change_detection: medium.change_detection.clone(),
240            scope: facet.scope.clone(),
241            engagement: facet.engagement.clone(),
242            preparation: facet.preparation.clone(),
243        });
244    }
245    Ok(sources)
246}
247
248/// A single migrated binding paired with the identity and provenance the CLI
249/// needs to write it to disk and report it.
250#[derive(Debug, Clone, PartialEq)]
251pub struct MigratedBinding {
252    /// The canonical binding id `<mem>/<stem>`.
253    pub id: String,
254    /// The binding's owning mem dir (the `.memstead/projections/<mem>/`
255    /// tier the binding file lives under).
256    pub mem: String,
257    /// The projection file stem (`<stem>` in the binding id).
258    pub name: String,
259    /// The flat ingest merged into this binding (its file stem), when the
260    /// gen-2 leg produced it — used to delete the consumed ingest. Empty for
261    /// the v1→v2 fold leg (no ingest involved).
262    pub ingest_name: String,
263    /// The facet names this binding's fold consumed (the orphan check reads
264    /// them; they equal the produced source names).
265    pub consumed_facets: Vec<String>,
266    /// The produced v2 binding.
267    pub binding: Binding,
268    /// Human-readable notes about non-identity transforms applied (e.g.
269    /// deny-path dialect rewrites). Empty when the migration was verbatim.
270    pub notes: Vec<String>,
271}
272
273/// Convert one gen-2 (`Ingest` + its `Projection`) into a v2 [`Binding`],
274/// folding the projection's facet references inline. Returns the binding
275/// plus any per-field transform notes. Pure — no IO. `ingest_name` is used
276/// only for error messages.
277pub(crate) fn binding_from_gen2(
278    ingest_name: &str,
279    ingest: &LegacyIngest,
280    projection: &Projection,
281    projection_ref: &str,
282    mem: &str,
283    configs: &PipelineConfigs,
284) -> Result<(Binding, Vec<String>), BindingMigrateError> {
285    let mode = match ingest.mode {
286        LegacyIngestMode::Discovery => BuildMode::Discovery,
287        LegacyIngestMode::OneShot => BuildMode::OneShot,
288        LegacyIngestMode::Refinement => {
289            return Err(BindingMigrateError::RefinementModeDeleted {
290                ingest: ingest_name.to_string(),
291            });
292        }
293    };
294
295    let mut notes = Vec::new();
296    let deny_paths = ingest
297        .deny_paths
298        .iter()
299        .map(|d| {
300            let converted = to_glob_dialect(d);
301            if converted != *d {
302                notes.push(format!(
303                    "deny_paths: rewrote bare entry '{d}' to glob dialect '{converted}'"
304                ));
305            }
306            converted
307        })
308        .collect();
309
310    let sources = fold_sources(projection_ref, &projection.source_facets, mem, configs)?;
311
312    let binding = Binding {
313        version: BINDING_VERSION,
314        intent: projection.intent.clone(),
315        sources,
316        reference_mems: projection.reference_mems.clone(),
317        destination_mem: projection.destination_mem.clone(),
318        deny_paths,
319        // Migrated bindings never DECLARED coverage (the legacy shape had
320        // no author-visible field on this path) — leave it unstated so the
321        // effective value resolves per medium instead of baking in an
322        // "exhaustive by silence" the author never wrote.
323        coverage_semantics: None,
324        rules: projection.rules.clone(),
325        prune: None,
326        operations: Operations {
327            build: Some(BuildOperation {
328                mode,
329                trigger: ingest.trigger,
330                batch_size: ingest.batch_size,
331                post_actions: ingest.post_actions.clone(),
332            }),
333            // A gen-2 config declares only the build-equivalent schedule.
334            // Sync/verify are enabled later via `projection enable`, never
335            // fabricated by migration.
336            sync: None,
337            verify: None,
338        },
339    };
340    Ok((binding, notes))
341}
342
343/// Migrate every gen-2 flat ingest in `configs` into a v2 [`MigratedBinding`],
344/// keyed by binding id (`<mem>/<stem>`), in id order.
345///
346/// Ingest-driven ("merge each flat ingest into its projection"): each
347/// ingest's `projection` ref is resolved to a projection in that mem, the
348/// pair merged, and the projection's facet references folded inline. A
349/// malformed ref, a dangling ref, or a `refinement` mode is a typed
350/// [`BindingMigrateError`] — the migration refuses rather than dropping
351/// or fabricating. Pure and IO-free.
352///
353/// A projection with no ingest pointing at it is inert (never runnable in
354/// gen-2) and is not emitted — nothing schedules it, so there is no obligation
355/// to promote.
356pub fn migrate_gen2_bindings(
357    configs: &PipelineConfigs,
358) -> Result<Vec<MigratedBinding>, BindingMigrateError> {
359    let mut out = Vec::new();
360    for record in &configs.ingests {
361        let ingest = &record.config;
362        let projection_ref = ingest.projection.clone();
363        let (mem, name) = projection_ref
364            .split_once('/')
365            .filter(|(m, n)| !m.is_empty() && !n.is_empty())
366            .ok_or_else(|| BindingMigrateError::MalformedProjectionRef {
367                ingest: record.name.clone(),
368                projection: projection_ref.clone(),
369            })?;
370        let mem = mem.to_string();
371        let name = name.to_string();
372
373        let projection = configs
374            .projections
375            .iter()
376            .find(|r| r.mem == mem && r.name == name)
377            .map(|r| &r.config)
378            .ok_or_else(|| BindingMigrateError::DanglingProjectionRef {
379                ingest: record.name.clone(),
380                projection_ref: projection_ref.clone(),
381                mem: mem.clone(),
382                available: configs
383                    .projections
384                    .iter()
385                    .filter(|r| r.mem == mem)
386                    .map(|r| r.name.clone())
387                    .collect(),
388            })?;
389
390        let (binding, notes) = binding_from_gen2(
391            &record.name,
392            ingest,
393            projection,
394            &projection_ref,
395            &mem,
396            configs,
397        )?;
398        out.push(MigratedBinding {
399            id: projection_ref,
400            mem,
401            name,
402            ingest_name: record.name.clone(),
403            consumed_facets: projection.source_facets.clone(),
404            binding,
405            notes,
406        });
407    }
408    out.sort_by(|a, b| a.id.cmp(&b.id));
409    Ok(out)
410}
411
412/// Fold one v1 three-file-store binding into a v2 [`Binding`]: every field
413/// carries over verbatim, `version` becomes 2, and each `source_facets`
414/// entry folds its facet + medium inline under the facet's name
415/// byte-verbatim (it keys sync watermarks). Pure — no IO.
416pub fn fold_v1_binding(
417    binding_id: &str,
418    mem: &str,
419    v1: &LegacyBindingV1,
420    configs: &PipelineConfigs,
421) -> Result<Binding, BindingMigrateError> {
422    let sources = fold_sources(binding_id, &v1.source_facets, mem, configs)?;
423    Ok(Binding {
424        version: BINDING_VERSION,
425        intent: v1.intent.clone(),
426        sources,
427        reference_mems: v1.reference_mems.clone(),
428        destination_mem: v1.destination_mem.clone(),
429        deny_paths: v1.deny_paths.clone(),
430        coverage_semantics: v1.coverage_semantics,
431        rules: v1.rules.clone(),
432        prune: v1.prune.clone(),
433        operations: v1.operations.clone(),
434    })
435}
436
437/// Verify every medium/facet record in `configs` was consumed by a fold —
438/// `consumed_facets` is the union of every migrated binding's
439/// `(mem, facet-name)` pairs. A facet no binding referenced, or a medium no
440/// consumed facet engages, is an **orphan**: removing the trees would drop
441/// its content silently, so the migration refuses instead
442/// ([`BindingMigrateError::OrphanRecords`]) and names each leftover.
443pub fn check_all_consumed(
444    configs: &PipelineConfigs,
445    consumed_facets: &[(String, String)],
446) -> Result<(), BindingMigrateError> {
447    let mut orphans = Vec::new();
448    for facet in &configs.facets {
449        if !consumed_facets
450            .iter()
451            .any(|(mem, name)| *mem == facet.mem && *name == facet.name)
452        {
453            orphans.push(format!("facets/{}/{}", facet.mem, facet.name));
454        }
455    }
456    for medium in &configs.mediums {
457        let engaged = configs.facets.iter().any(|f| {
458            f.mem == medium.mem
459                && f.config.medium == medium.name
460                && consumed_facets
461                    .iter()
462                    .any(|(mem, name)| *mem == f.mem && *name == f.name)
463        });
464        if !engaged {
465            orphans.push(format!("mediums/{}/{}", medium.mem, medium.name));
466        }
467    }
468    if orphans.is_empty() {
469        Ok(())
470    } else {
471        orphans.sort();
472        Err(BindingMigrateError::OrphanRecords { orphans })
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use crate::binding::{CapabilityError, validate_binding};
480    use crate::pipeline::{Facet, IngestTrigger, Medium, MediumType, PatternEntry, PatternMode};
481    use crate::pipeline_store::{
482        LegacyIngest, LegacyIngestMode, MemPipelineRecord, PipelineRecord,
483    };
484
485    fn medium(mem: &str, name: &str, ty: MediumType, pointer: &str) -> MemPipelineRecord<Medium> {
486        MemPipelineRecord {
487            mem: mem.to_string(),
488            name: name.to_string(),
489            config: Medium {
490                name: name.to_string(),
491                medium_type: ty,
492                pointer: pointer.to_string(),
493                change_detection: None,
494            },
495        }
496    }
497
498    fn facet(mem: &str, name: &str, medium: &str, prep: Option<&str>) -> MemPipelineRecord<Facet> {
499        MemPipelineRecord {
500            mem: mem.to_string(),
501            name: name.to_string(),
502            config: Facet {
503                name: name.to_string(),
504                medium: medium.to_string(),
505                scope: vec![PatternEntry {
506                    path: "../src/**/*.rs".to_string(),
507                    mode: PatternMode::Allow,
508                }],
509                engagement: None,
510                preparation: prep.map(str::to_string),
511            },
512        }
513    }
514
515    fn projection(
516        mem: &str,
517        name: &str,
518        facets: &[&str],
519        refs: &[&str],
520        dest: &str,
521    ) -> MemPipelineRecord<Projection> {
522        MemPipelineRecord {
523            mem: mem.to_string(),
524            name: name.to_string(),
525            config: Projection {
526                intent: Some(format!("intent of {name}")),
527                source_facets: facets.iter().map(|s| s.to_string()).collect(),
528                reference_mems: refs.iter().map(|s| s.to_string()).collect(),
529                destination_mem: dest.to_string(),
530                rules: Some(serde_json::json!({ "routing": "r" })),
531            },
532        }
533    }
534
535    fn ingest(
536        name: &str,
537        projection: &str,
538        mode: LegacyIngestMode,
539        deny: &[&str],
540    ) -> PipelineRecord<LegacyIngest> {
541        PipelineRecord {
542            name: name.to_string(),
543            config: LegacyIngest {
544                projection: projection.to_string(),
545                mode,
546                trigger: IngestTrigger::Loop,
547                batch_size: 20,
548                deny_paths: deny.iter().map(|s| s.to_string()).collect(),
549                post_actions: Some(serde_json::json!({ "archive_source": true })),
550            },
551        }
552    }
553
554    fn gen2_configs() -> PipelineConfigs {
555        PipelineConfigs {
556            mediums: vec![medium("engine", "src", MediumType::Codebase, "../public")],
557            facets: vec![facet("engine", "source-tree", "src", None)],
558            projections: vec![projection(
559                "engine",
560                "graph",
561                &["source-tree"],
562                &["plugin"],
563                "engine",
564            )],
565            ingests: vec![ingest(
566                "engine-graph",
567                "engine/graph",
568                LegacyIngestMode::Discovery,
569                &[],
570            )],
571        }
572    }
573
574    /// A well-formed gen-2 pair migrates to a v2 binding: the merged
575    /// operations (mode/trigger/batch/post_actions), the projection's
576    /// declarative fields, and the facet+medium folded inline under the
577    /// facet's name byte-verbatim.
578    #[test]
579    fn migrates_a_well_formed_pair_folding_sources_inline() {
580        let migrated = migrate_gen2_bindings(&gen2_configs()).unwrap();
581        assert_eq!(migrated.len(), 1);
582        let m = &migrated[0];
583        assert_eq!(m.id, "engine/graph");
584        assert_eq!(m.mem, "engine");
585        assert_eq!(m.name, "graph");
586        assert_eq!(m.ingest_name, "engine-graph");
587        assert_eq!(m.consumed_facets, vec!["source-tree".to_string()]);
588
589        let b = &m.binding;
590        assert_eq!(b.version, BINDING_VERSION);
591        assert_eq!(b.intent.as_deref(), Some("intent of graph"));
592        assert_eq!(b.reference_mems, vec!["plugin".to_string()]);
593        assert_eq!(b.destination_mem, "engine");
594        // The v1 fixture never declared coverage — it migrates as
595        // UNSTATED (resolved per medium), not as a baked-in exhaustive.
596        assert_eq!(b.coverage_semantics, None);
597        assert_eq!(b.rules, Some(serde_json::json!({ "routing": "r" })));
598        // The fold: one inline source under the facet's name, carrying the
599        // medium half (type/pointer) and the facet half (scope).
600        assert_eq!(b.sources.len(), 1);
601        let s = &b.sources[0];
602        assert_eq!(s.name, "source-tree");
603        assert_eq!(s.medium_type, MediumType::Codebase);
604        assert_eq!(s.pointer, "../public");
605        assert_eq!(s.scope.len(), 1);
606        assert_eq!(s.engagement, None);
607        assert_eq!(s.preparation, None);
608        // Operations: build carries the merged schedule; sync/verify absent.
609        assert_eq!(
610            b.operations.build.as_ref().unwrap().mode,
611            BuildMode::Discovery
612        );
613        assert_eq!(
614            b.operations.build.as_ref().unwrap().post_actions,
615            Some(serde_json::json!({ "archive_source": true }))
616        );
617        assert!(b.operations.sync.is_none());
618        assert!(b.operations.verify.is_none());
619    }
620
621    /// The produced binding round-trips losslessly through serde (the on-disk
622    /// promotion is faithful) and validates clean.
623    #[test]
624    fn produced_binding_round_trips_and_validates() {
625        let migrated = migrate_gen2_bindings(&gen2_configs()).unwrap();
626        let b = &migrated[0].binding;
627        let json = serde_json::to_string(b).unwrap();
628        let back: Binding = serde_json::from_str(&json).unwrap();
629        assert_eq!(&back, b);
630        assert!(validate_binding(b).is_ok());
631    }
632
633    /// `deny_paths` move up to the binding; a bare directory segment is
634    /// rewritten to the glob dialect (with a note), while glob/`/`/`.`
635    /// entries carry through unchanged.
636    #[test]
637    fn deny_paths_move_up_and_bare_segments_convert() {
638        let mut configs = gen2_configs();
639        configs.ingests = vec![ingest(
640            "engine-graph",
641            "engine/graph",
642            LegacyIngestMode::Discovery,
643            &["dev", "VISION.md", "../public/target/**"],
644        )];
645        let migrated = migrate_gen2_bindings(&configs).unwrap();
646        let m = &migrated[0];
647        assert_eq!(
648            m.binding.deny_paths,
649            vec![
650                "dev/**".to_string(),              // bare segment → glob
651                "VISION.md".to_string(),           // has '.', carried through
652                "../public/target/**".to_string(), // has '/' + glob, carried through
653            ]
654        );
655        assert_eq!(m.notes.len(), 1, "only the bare 'dev' rewrite is noted");
656        assert!(m.notes[0].contains("dev") && m.notes[0].contains("dev/**"));
657    }
658
659    /// `one-shot` maps to the one-shot build mode.
660    #[test]
661    fn one_shot_mode_maps() {
662        let configs = PipelineConfigs {
663            projections: vec![projection("m", "p", &[], &[], "m")],
664            ingests: vec![ingest("i", "m/p", LegacyIngestMode::OneShot, &[])],
665            ..Default::default()
666        };
667        let migrated = migrate_gen2_bindings(&configs).unwrap();
668        assert_eq!(
669            migrated[0].binding.operations.build.as_ref().unwrap().mode,
670            BuildMode::OneShot
671        );
672    }
673
674    /// `refinement` mode is a typed migrate error — the vocabulary is deleted.
675    #[test]
676    fn refinement_mode_is_a_typed_error() {
677        let configs = PipelineConfigs {
678            projections: vec![projection("m", "p", &[], &[], "m")],
679            ingests: vec![ingest("i", "m/p", LegacyIngestMode::Refinement, &[])],
680            ..Default::default()
681        };
682        let err = migrate_gen2_bindings(&configs).unwrap_err();
683        assert!(
684            matches!(err, BindingMigrateError::RefinementModeDeleted { ref ingest } if ingest == "i"),
685            "got {err:?}"
686        );
687    }
688
689    /// A dangling ingest→projection ref is a typed error, never a silent drop.
690    #[test]
691    fn dangling_projection_ref_is_a_typed_error() {
692        let configs = PipelineConfigs {
693            projections: vec![projection("m", "other", &[], &[], "m")],
694            ingests: vec![ingest("i", "m/missing", LegacyIngestMode::Discovery, &[])],
695            ..Default::default()
696        };
697        let err = migrate_gen2_bindings(&configs).unwrap_err();
698        match err {
699            BindingMigrateError::DanglingProjectionRef {
700                ingest,
701                projection_ref,
702                mem,
703                available,
704            } => {
705                assert_eq!(ingest, "i");
706                assert_eq!(projection_ref, "m/missing");
707                assert_eq!(mem, "m");
708                assert_eq!(available, vec!["other".to_string()]);
709            }
710            other => panic!("expected DanglingProjectionRef, got {other:?}"),
711        }
712    }
713
714    /// A projection→facet dangling ref is a typed error at fold time.
715    #[test]
716    fn dangling_facet_ref_is_a_typed_error() {
717        let mut configs = gen2_configs();
718        configs.facets.clear();
719        let err = migrate_gen2_bindings(&configs).unwrap_err();
720        assert!(
721            matches!(
722                err,
723                BindingMigrateError::DanglingFacetRef { ref facet, .. } if facet == "source-tree"
724            ),
725            "got {err:?}"
726        );
727    }
728
729    /// A facet→medium dangling ref is a typed error at fold time.
730    #[test]
731    fn dangling_medium_ref_is_a_typed_error() {
732        let mut configs = gen2_configs();
733        configs.mediums.clear();
734        let err = migrate_gen2_bindings(&configs).unwrap_err();
735        assert!(
736            matches!(
737                err,
738                BindingMigrateError::DanglingMediumRef { ref medium, .. } if medium == "src"
739            ),
740            "got {err:?}"
741        );
742    }
743
744    /// A malformed projection ref (no `/`) is a typed error.
745    #[test]
746    fn malformed_projection_ref_is_a_typed_error() {
747        let configs = PipelineConfigs {
748            ingests: vec![ingest("i", "noslash", LegacyIngestMode::Discovery, &[])],
749            ..Default::default()
750        };
751        let err = migrate_gen2_bindings(&configs).unwrap_err();
752        assert!(
753            matches!(err, BindingMigrateError::MalformedProjectionRef { .. }),
754            "got {err:?}"
755        );
756    }
757
758    // ---- v1 → v2 fold ---------------------------------------------------
759
760    fn v1_binding(facets: &[&str]) -> LegacyBindingV1 {
761        LegacyBindingV1 {
762            version: 1,
763            intent: Some("v1 intent".to_string()),
764            source_facets: facets.iter().map(|s| s.to_string()).collect(),
765            reference_mems: vec!["engineering".to_string()],
766            destination_mem: "engine".to_string(),
767            deny_paths: vec!["../dev/**".to_string()],
768            coverage_semantics: None,
769            rules: None,
770            prune: None,
771            operations: Operations {
772                build: Some(BuildOperation {
773                    mode: BuildMode::Discovery,
774                    trigger: IngestTrigger::Loop,
775                    batch_size: 20,
776                    post_actions: None,
777                }),
778                sync: Some(crate::binding::SyncOperation {
779                    trigger: IngestTrigger::Loop,
780                    batch_size: 20,
781                }),
782                verify: None,
783            },
784        }
785    }
786
787    /// The v1→v2 fold carries every field verbatim, bumps `version` to 2,
788    /// and inlines each referenced facet + medium as a source under the
789    /// facet's name **byte-verbatim** — the watermark-key preservation
790    /// contract.
791    #[test]
792    fn fold_v1_binding_preserves_fields_and_source_names() {
793        let configs = gen2_configs();
794        let v1 = v1_binding(&["source-tree"]);
795        let b = fold_v1_binding("engine/graph", "engine", &v1, &configs).unwrap();
796        assert_eq!(b.version, 2);
797        assert_eq!(b.intent.as_deref(), Some("v1 intent"));
798        assert_eq!(b.reference_mems, vec!["engineering".to_string()]);
799        assert_eq!(b.destination_mem, "engine");
800        assert_eq!(b.deny_paths, vec!["../dev/**".to_string()]);
801        // The operations block carries over whole — sync survives.
802        assert!(b.operations.sync.is_some());
803        assert!(b.operations.verify.is_none());
804        // The fold: facet name preserved byte-verbatim as the source name.
805        assert_eq!(b.sources.len(), 1);
806        assert_eq!(b.sources[0].name, "source-tree");
807        assert_eq!(b.sources[0].pointer, "../public");
808    }
809
810    /// A v1 binding referencing a missing facet is a typed fold error.
811    #[test]
812    fn fold_v1_binding_dangling_facet_errors() {
813        let mut configs = gen2_configs();
814        configs.facets.clear();
815        let v1 = v1_binding(&["source-tree"]);
816        let err = fold_v1_binding("engine/graph", "engine", &v1, &configs).unwrap_err();
817        assert!(matches!(
818            err,
819            BindingMigrateError::DanglingFacetRef { ref facet, .. } if facet == "source-tree"
820        ));
821    }
822
823    /// The raw v1 JSON on disk (the dogfood shape) parses as
824    /// [`LegacyBindingV1`] — the fold leg's reader contract.
825    #[test]
826    fn legacy_v1_json_parses() {
827        let src = r#"{
828          "version": 1,
829          "intent": "Rust engine source.",
830          "source_facets": ["source-tree"],
831          "reference_mems": ["engineering"],
832          "destination_mem": "engine",
833          "deny_paths": ["../dev/**"],
834          "coverage_semantics": "exhaustive",
835          "operations": {
836            "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 },
837            "sync": { "trigger": "loop", "batch_size": 20 },
838            "verify": { "trigger": "loop", "batch_size": 20,
839                        "adjudication_cap": 50, "full_resync_every": 20 }
840          }
841        }"#;
842        let v1: LegacyBindingV1 = serde_json::from_str(src).unwrap();
843        assert_eq!(v1.version, 1);
844        assert_eq!(v1.source_facets, vec!["source-tree".to_string()]);
845        assert!(v1.operations.verify.is_some());
846    }
847
848    // ---- orphan check ---------------------------------------------------
849
850    /// All records consumed → clean; an unreferenced facet (and its now
851    /// unengaged medium) are orphans, named in the typed error.
852    #[test]
853    fn check_all_consumed_flags_orphans() {
854        let mut configs = gen2_configs();
855        configs
856            .facets
857            .push(facet("engine", "stray-facet", "stray-medium", None));
858        configs.mediums.push(medium(
859            "engine",
860            "stray-medium",
861            MediumType::Filesystem,
862            "../docs",
863        ));
864
865        let consumed = vec![("engine".to_string(), "source-tree".to_string())];
866        let err = check_all_consumed(&configs, &consumed).unwrap_err();
867        match err {
868            BindingMigrateError::OrphanRecords { orphans } => {
869                assert_eq!(
870                    orphans,
871                    vec![
872                        "facets/engine/stray-facet".to_string(),
873                        "mediums/engine/stray-medium".to_string(),
874                    ]
875                );
876            }
877            other => panic!("expected OrphanRecords, got {other:?}"),
878        }
879
880        // With the stray facet consumed too, everything is clean.
881        let consumed_all = vec![
882            ("engine".to_string(), "source-tree".to_string()),
883            ("engine".to_string(), "stray-facet".to_string()),
884        ];
885        assert!(check_all_consumed(&configs, &consumed_all).is_ok());
886    }
887
888    /// A migrated binding whose facet declares a preparation surfaces the
889    /// capability refusal at validation of the folded record.
890    #[test]
891    fn migrated_binding_with_preparation_surfaces_capability_refusal() {
892        let configs = PipelineConfigs {
893            mediums: vec![medium("docs", "manuals", MediumType::Filesystem, "../docs")],
894            facets: vec![facet("docs", "pages", "manuals", Some("pdf-to-markdown"))],
895            projections: vec![projection("docs", "manual", &["pages"], &[], "docs")],
896            ingests: vec![ingest(
897                "docs-manual",
898                "docs/manual",
899                LegacyIngestMode::Discovery,
900                &[],
901            )],
902        };
903        let migrated = migrate_gen2_bindings(&configs).unwrap();
904        let errs = validate_binding(&migrated[0].binding).unwrap_err();
905        assert!(
906            errs.iter().any(|e| matches!(
907                e,
908                CapabilityError::PreparationUnsupported { preparation, .. }
909                    if preparation == "pdf-to-markdown"
910            )),
911            "expected PreparationUnsupported, got {errs:?}"
912        );
913    }
914}