1use 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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
38pub enum BindingMigrateError {
39 #[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 ingest: String,
50 },
51 #[error(
53 "ingest '{ingest}' has a malformed projection ref '{projection}'; expected \"<mem>/<name>\""
54 )]
55 MalformedProjectionRef {
56 ingest: String,
58 projection: String,
60 },
61 #[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 ingest: String,
71 projection_ref: String,
73 mem: String,
75 available: Vec<String>,
77 },
78 #[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 owner: String,
88 facet: String,
90 mem: String,
92 available: Vec<String>,
94 },
95 #[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 owner: String,
104 facet: String,
106 medium: String,
108 mem: String,
110 available: Vec<String>,
112 },
113 #[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 orphans: Vec<String>,
125 },
126}
127
128fn fmt_list(names: &[String]) -> String {
130 if names.is_empty() {
131 "(none)".to_string()
132 } else {
133 names.join(", ")
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Deserialize)]
141pub struct LegacyBindingV1 {
142 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 #[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
165fn 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
188fn 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#[derive(Debug, Clone, PartialEq)]
251pub struct MigratedBinding {
252 pub id: String,
254 pub mem: String,
257 pub name: String,
259 pub ingest_name: String,
263 pub consumed_facets: Vec<String>,
266 pub binding: Binding,
268 pub notes: Vec<String>,
271}
272
273pub(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 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 sync: None,
337 verify: None,
338 },
339 };
340 Ok((binding, notes))
341}
342
343pub 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
412pub 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
437pub 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 #[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 assert_eq!(b.coverage_semantics, None);
597 assert_eq!(b.rules, Some(serde_json::json!({ "routing": "r" })));
598 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 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 #[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 #[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(), "VISION.md".to_string(), "../public/target/**".to_string(), ]
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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 assert!(b.operations.sync.is_some());
803 assert!(b.operations.verify.is_none());
804 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 #[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 #[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 #[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 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 #[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}