1use 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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
53pub enum BindingMigrateError {
54 #[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 ingest: String,
65 },
66 #[error(
68 "ingest '{ingest}' has a malformed projection ref '{projection}'; expected \"<mem>/<name>\""
69 )]
70 MalformedProjectionRef {
71 ingest: String,
73 projection: String,
75 },
76 #[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 ingest: String,
86 projection_ref: String,
88 mem: String,
90 available: Vec<String>,
92 },
93}
94
95fn fmt_list(names: &[String]) -> String {
97 if names.is_empty() {
98 "(none)".to_string()
99 } else {
100 names.join(", ")
101 }
102}
103
104fn 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#[derive(Debug, Clone, PartialEq)]
131pub struct MigratedBinding {
132 pub id: String,
135 pub mem: String,
138 pub name: String,
140 pub ingest_name: String,
143 pub binding: BindingV1,
145 pub notes: Vec<String>,
148}
149
150pub(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 sync: None,
207 verify: None,
208 },
209 };
210 Ok((binding, notes))
211}
212
213pub 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
273pub 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 #[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 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 #[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 #[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(), "VISION.md".to_string(), "../public/target/**".to_string(), ]
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 #[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 #[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 #[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 #[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 #[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 #[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}