use crate::binding::{
BINDING_VERSION, BindingV1, BuildMode, BuildOperation, CoverageSemantics, Operations,
ResolvedBinding,
};
use crate::ingest::resolve::{ResolveError, resolve_binding};
use crate::pipeline::Projection;
use crate::pipeline_store::{LegacyIngest, LegacyIngestMode, PipelineConfigs};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum BindingMigrateError {
#[error(
"ingest '{ingest}' declares build mode 'refinement', which is deleted from the binding \
vocabulary (D1) — refinement-as-writer is gone; re-declare it as a discovery build (plus \
a sync/verify obligation) before migrating"
)]
RefinementModeDeleted {
ingest: String,
},
#[error(
"ingest '{ingest}' has a malformed projection ref '{projection}'; expected \"<mem>/<name>\""
)]
MalformedProjectionRef {
ingest: String,
projection: String,
},
#[error(
"ingest '{ingest}' references projection '{projection_ref}' which does not exist in mem \
'{mem}' (dangling ref — not migrated); available: {}",
fmt_list(available)
)]
DanglingProjectionRef {
ingest: String,
projection_ref: String,
mem: String,
available: Vec<String>,
},
}
fn fmt_list(names: &[String]) -> String {
if names.is_empty() {
"(none)".to_string()
} else {
names.join(", ")
}
}
fn to_glob_dialect(entry: &str) -> String {
let is_bare_segment = !entry.is_empty()
&& !entry.contains('/')
&& !entry.contains('.')
&& !entry.contains(['*', '?', '[', ']']);
if is_bare_segment {
format!("{entry}/**")
} else {
entry.to_string()
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct MigratedBinding {
pub id: String,
pub mem: String,
pub name: String,
pub ingest_name: String,
pub binding: BindingV1,
pub notes: Vec<String>,
}
pub(crate) fn binding_from_gen2(
ingest_name: &str,
ingest: &LegacyIngest,
projection: &Projection,
) -> Result<(BindingV1, Vec<String>), BindingMigrateError> {
let mode = match ingest.mode {
LegacyIngestMode::Discovery => BuildMode::Discovery,
LegacyIngestMode::OneShot => BuildMode::OneShot,
LegacyIngestMode::Refinement => {
return Err(BindingMigrateError::RefinementModeDeleted {
ingest: ingest_name.to_string(),
});
}
};
let mut notes = Vec::new();
let deny_paths = ingest
.deny_paths
.iter()
.map(|d| {
let converted = to_glob_dialect(d);
if converted != *d {
notes.push(format!(
"deny_paths: rewrote bare entry '{d}' to glob dialect '{converted}'"
));
}
converted
})
.collect();
let binding = BindingV1 {
version: BINDING_VERSION,
intent: projection.intent.clone(),
source_facets: projection.source_facets.clone(),
reference_mems: projection.reference_mems.clone(),
destination_mem: projection.destination_mem.clone(),
deny_paths,
coverage_semantics: CoverageSemantics::Exhaustive,
rules: projection.rules.clone(),
prune: None,
operations: Operations {
build: Some(BuildOperation {
mode,
trigger: ingest.trigger,
batch_size: ingest.batch_size,
post_actions: ingest.post_actions.clone(),
}),
sync: None,
verify: None,
},
};
Ok((binding, notes))
}
pub fn migrate_gen2_bindings(
configs: &PipelineConfigs,
) -> Result<Vec<MigratedBinding>, BindingMigrateError> {
let mut out = Vec::new();
for record in &configs.ingests {
let ingest = &record.config;
let projection_ref = ingest.projection.clone();
let (mem, name) = projection_ref
.split_once('/')
.filter(|(m, n)| !m.is_empty() && !n.is_empty())
.ok_or_else(|| BindingMigrateError::MalformedProjectionRef {
ingest: record.name.clone(),
projection: projection_ref.clone(),
})?;
let mem = mem.to_string();
let name = name.to_string();
let projection = configs
.projections
.iter()
.find(|r| r.mem == mem && r.name == name)
.map(|r| &r.config)
.ok_or_else(|| BindingMigrateError::DanglingProjectionRef {
ingest: record.name.clone(),
projection_ref: projection_ref.clone(),
mem: mem.clone(),
available: configs
.projections
.iter()
.filter(|r| r.mem == mem)
.map(|r| r.name.clone())
.collect(),
})?;
let (binding, notes) = binding_from_gen2(&record.name, ingest, projection)?;
out.push(MigratedBinding {
id: projection_ref,
mem,
name,
ingest_name: record.name.clone(),
binding,
notes,
});
}
out.sort_by(|a, b| a.id.cmp(&b.id));
Ok(out)
}
pub fn resolve_migrated_binding(
configs: &PipelineConfigs,
binding_id: &str,
binding: BindingV1,
) -> Result<ResolvedBinding, ResolveError> {
resolve_binding(configs, binding_id, &binding)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::binding::{CapabilityError, validate_binding};
use crate::pipeline::{Facet, IngestTrigger, Medium, MediumType, PatternEntry, PatternMode};
use crate::pipeline_store::{
LegacyIngest, LegacyIngestMode, MemPipelineRecord, PipelineRecord,
};
fn medium(mem: &str, name: &str, ty: MediumType, pointer: &str) -> MemPipelineRecord<Medium> {
MemPipelineRecord {
mem: mem.to_string(),
name: name.to_string(),
config: Medium {
name: name.to_string(),
medium_type: ty,
pointer: pointer.to_string(),
change_detection: None,
},
}
}
fn facet(mem: &str, name: &str, medium: &str, prep: Option<&str>) -> MemPipelineRecord<Facet> {
MemPipelineRecord {
mem: mem.to_string(),
name: name.to_string(),
config: Facet {
name: name.to_string(),
medium: medium.to_string(),
scope: vec![PatternEntry {
path: "../src/**/*.rs".to_string(),
mode: PatternMode::Allow,
}],
engagement: None,
preparation: prep.map(str::to_string),
},
}
}
fn projection(
mem: &str,
name: &str,
facets: &[&str],
refs: &[&str],
dest: &str,
) -> MemPipelineRecord<Projection> {
MemPipelineRecord {
mem: mem.to_string(),
name: name.to_string(),
config: Projection {
intent: Some(format!("intent of {name}")),
source_facets: facets.iter().map(|s| s.to_string()).collect(),
reference_mems: refs.iter().map(|s| s.to_string()).collect(),
destination_mem: dest.to_string(),
rules: Some(serde_json::json!({ "routing": "r" })),
},
}
}
fn ingest(
name: &str,
projection: &str,
mode: LegacyIngestMode,
deny: &[&str],
) -> PipelineRecord<LegacyIngest> {
PipelineRecord {
name: name.to_string(),
config: LegacyIngest {
projection: projection.to_string(),
mode,
trigger: IngestTrigger::Loop,
batch_size: 20,
deny_paths: deny.iter().map(|s| s.to_string()).collect(),
post_actions: Some(serde_json::json!({ "archive_source": true })),
},
}
}
#[test]
fn migrates_a_well_formed_pair() {
let configs = PipelineConfigs {
mediums: vec![medium("engine", "src", MediumType::Codebase, "../public")],
facets: vec![facet("engine", "source-tree", "src", None)],
projections: vec![projection(
"engine",
"graph",
&["source-tree"],
&["plugin"],
"engine",
)],
ingests: vec![ingest(
"engine-graph",
"engine/graph",
LegacyIngestMode::Discovery,
&[],
)],
};
let migrated = migrate_gen2_bindings(&configs).unwrap();
assert_eq!(migrated.len(), 1);
let m = &migrated[0];
assert_eq!(m.id, "engine/graph");
assert_eq!(m.mem, "engine");
assert_eq!(m.name, "graph");
assert_eq!(m.ingest_name, "engine-graph");
let b = &m.binding;
assert_eq!(b.version, BINDING_VERSION);
assert_eq!(b.intent.as_deref(), Some("intent of graph"));
assert_eq!(b.source_facets, vec!["source-tree".to_string()]);
assert_eq!(b.reference_mems, vec!["plugin".to_string()]);
assert_eq!(b.destination_mem, "engine");
assert_eq!(b.coverage_semantics, CoverageSemantics::Exhaustive);
assert_eq!(b.rules, Some(serde_json::json!({ "routing": "r" })));
assert_eq!(
b.operations.build.as_ref().unwrap().mode,
BuildMode::Discovery
);
assert_eq!(
b.operations.build.as_ref().unwrap().trigger,
IngestTrigger::Loop
);
assert_eq!(b.operations.build.as_ref().unwrap().batch_size, 20);
assert_eq!(
b.operations.build.as_ref().unwrap().post_actions,
Some(serde_json::json!({ "archive_source": true }))
);
assert!(b.operations.sync.is_none());
assert!(b.operations.verify.is_none());
}
#[test]
fn produced_binding_round_trips() {
let configs = PipelineConfigs {
mediums: vec![medium("engine", "src", MediumType::Codebase, "../public")],
facets: vec![facet("engine", "source-tree", "src", None)],
projections: vec![projection(
"engine",
"graph",
&["source-tree"],
&[],
"engine",
)],
ingests: vec![ingest(
"engine-graph",
"engine/graph",
LegacyIngestMode::Discovery,
&[],
)],
};
let migrated = migrate_gen2_bindings(&configs).unwrap();
let b = &migrated[0].binding;
let json = serde_json::to_string(b).unwrap();
let back: BindingV1 = serde_json::from_str(&json).unwrap();
assert_eq!(&back, b);
}
#[test]
fn deny_paths_move_up_and_bare_segments_convert() {
let configs = PipelineConfigs {
mediums: vec![medium("engine", "src", MediumType::Codebase, "../public")],
facets: vec![facet("engine", "source-tree", "src", None)],
projections: vec![projection(
"engine",
"graph",
&["source-tree"],
&[],
"engine",
)],
ingests: vec![ingest(
"engine-graph",
"engine/graph",
LegacyIngestMode::Discovery,
&["dev", "VISION.md", "../public/target/**"],
)],
};
let migrated = migrate_gen2_bindings(&configs).unwrap();
let m = &migrated[0];
assert_eq!(
m.binding.deny_paths,
vec![
"dev/**".to_string(), "VISION.md".to_string(), "../public/target/**".to_string(), ]
);
assert_eq!(m.notes.len(), 1, "only the bare 'dev' rewrite is noted");
assert!(m.notes[0].contains("dev") && m.notes[0].contains("dev/**"));
}
#[test]
fn one_shot_mode_maps() {
let configs = PipelineConfigs {
projections: vec![projection("m", "p", &[], &[], "m")],
ingests: vec![ingest("i", "m/p", LegacyIngestMode::OneShot, &[])],
..Default::default()
};
let migrated = migrate_gen2_bindings(&configs).unwrap();
assert_eq!(
migrated[0].binding.operations.build.as_ref().unwrap().mode,
BuildMode::OneShot
);
}
#[test]
fn refinement_mode_is_a_typed_error() {
let configs = PipelineConfigs {
projections: vec![projection("m", "p", &[], &[], "m")],
ingests: vec![ingest("i", "m/p", LegacyIngestMode::Refinement, &[])],
..Default::default()
};
let err = migrate_gen2_bindings(&configs).unwrap_err();
assert!(
matches!(err, BindingMigrateError::RefinementModeDeleted { ref ingest } if ingest == "i"),
"got {err:?}"
);
}
#[test]
fn dangling_projection_ref_is_a_typed_error() {
let configs = PipelineConfigs {
projections: vec![projection("m", "other", &[], &[], "m")],
ingests: vec![ingest("i", "m/missing", LegacyIngestMode::Discovery, &[])],
..Default::default()
};
let err = migrate_gen2_bindings(&configs).unwrap_err();
match err {
BindingMigrateError::DanglingProjectionRef {
ingest,
projection_ref,
mem,
available,
} => {
assert_eq!(ingest, "i");
assert_eq!(projection_ref, "m/missing");
assert_eq!(mem, "m");
assert_eq!(available, vec!["other".to_string()]);
}
other => panic!("expected DanglingProjectionRef, got {other:?}"),
}
}
#[test]
fn malformed_projection_ref_is_a_typed_error() {
let configs = PipelineConfigs {
ingests: vec![ingest("i", "noslash", LegacyIngestMode::Discovery, &[])],
..Default::default()
};
let err = migrate_gen2_bindings(&configs).unwrap_err();
assert!(
matches!(err, BindingMigrateError::MalformedProjectionRef { .. }),
"got {err:?}"
);
}
#[test]
fn migrated_codebase_binding_validates_clean() {
let configs = PipelineConfigs {
mediums: vec![medium("engine", "src", MediumType::Codebase, "../public")],
facets: vec![facet("engine", "source-tree", "src", None)],
projections: vec![projection(
"engine",
"graph",
&["source-tree"],
&[],
"engine",
)],
ingests: vec![ingest(
"engine-graph",
"engine/graph",
LegacyIngestMode::Discovery,
&["../public/target/**"],
)],
};
let migrated = migrate_gen2_bindings(&configs).unwrap();
let m = &migrated[0];
let resolved = resolve_migrated_binding(&configs, &m.id, m.binding.clone()).unwrap();
assert!(validate_binding(&resolved).is_ok());
}
#[test]
fn migrated_binding_with_preparation_surfaces_capability_refusal() {
let configs = PipelineConfigs {
mediums: vec![medium("docs", "manuals", MediumType::Filesystem, "../docs")],
facets: vec![facet("docs", "pages", "manuals", Some("pdf-to-markdown"))],
projections: vec![projection("docs", "manual", &["pages"], &[], "docs")],
ingests: vec![ingest(
"docs-manual",
"docs/manual",
LegacyIngestMode::Discovery,
&[],
)],
};
let migrated = migrate_gen2_bindings(&configs).unwrap();
let m = &migrated[0];
let resolved = resolve_migrated_binding(&configs, &m.id, m.binding.clone()).unwrap();
let errs = validate_binding(&resolved).unwrap_err();
assert!(
errs.iter().any(|e| matches!(
e,
CapabilityError::PreparationUnsupported { preparation, .. }
if preparation == "pdf-to-markdown"
)),
"expected PreparationUnsupported, got {errs:?}"
);
}
}