1use std::path::{Path, PathBuf};
25
26use crate::binding::{BindingV1, BuildMode, ResolvedBinding, medium_capabilities};
27use crate::pipeline::{Facet, IngestTrigger, Medium, MediumType, PatternEntry};
28use crate::pipeline_store::{BindingConfigs, PipelineConfigs};
29
30#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum ResolvedSource {
35 Primary(ResolvedPrimarySource),
37 Reference {
39 mem: String,
41 },
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ResolvedPrimarySource {
48 pub facet_ref: String,
50 pub medium: String,
52 pub medium_type: MediumType,
54 pub medium_pointer: String,
56 pub declared_change_detection: Option<String>,
60 pub scope: Vec<PatternEntry>,
65 pub preparation: Option<String>,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct ResolvedIngest {
75 pub name: String,
79 pub mode: BuildMode,
82 pub trigger: IngestTrigger,
84 pub batch_size: u32,
86 pub deny_paths: Vec<String>,
88 pub projection_ref: String,
90 pub projection_mem: String,
92 pub projection_name: String,
94 pub intent: Option<String>,
97 pub sources: Vec<ResolvedSource>,
100 pub destination_mem: String,
102 pub rules: Option<serde_json::Value>,
104 pub post_actions: Option<serde_json::Value>,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
112pub enum ResolveError {
113 #[error("binding '{name}' not found; available: {}", fmt_list(available))]
115 BindingNotFound {
116 name: String,
118 available: Vec<String>,
120 },
121 #[error("malformed binding id '{projection}'; expected \"<mem>/<name>\"")]
123 MalformedProjectionRef {
124 ingest: String,
126 projection: String,
128 },
129 #[error(
131 "projection '{projection_ref}' references facet '{facet}' not found in mem '{mem}'; available: {}",
132 fmt_list(available)
133 )]
134 FacetNotFound {
135 projection_ref: String,
137 facet: String,
139 mem: String,
141 available: Vec<String>,
143 },
144 #[error(
146 "facet '{facet}' references medium '{medium}' not found in mem '{mem}'; available: {}",
147 fmt_list(available)
148 )]
149 MediumNotFound {
150 facet: String,
152 medium: String,
154 mem: String,
156 available: Vec<String>,
158 },
159}
160
161fn fmt_list(names: &[String]) -> String {
163 if names.is_empty() {
164 "(none)".to_string()
165 } else {
166 names.join(", ")
167 }
168}
169
170pub fn resolve_binding(
185 configs: &PipelineConfigs,
186 binding_id: &str,
187 binding: &BindingV1,
188) -> Result<ResolvedBinding, ResolveError> {
189 let (mem, _name) = binding_id
190 .split_once('/')
191 .filter(|(m, n)| !m.is_empty() && !n.is_empty())
192 .ok_or_else(|| ResolveError::MalformedProjectionRef {
193 ingest: binding_id.to_string(),
194 projection: binding_id.to_string(),
195 })?;
196
197 let mut primary_sources = Vec::with_capacity(binding.source_facets.len());
198 for facet_name in &binding.source_facets {
199 let facet: &Facet = configs
200 .facets
201 .iter()
202 .find(|r| r.mem == mem && r.name == *facet_name)
203 .map(|r| &r.config)
204 .ok_or_else(|| ResolveError::FacetNotFound {
205 projection_ref: binding_id.to_string(),
206 facet: facet_name.clone(),
207 mem: mem.to_string(),
208 available: configs
209 .facets
210 .iter()
211 .filter(|r| r.mem == mem)
212 .map(|r| r.name.clone())
213 .collect(),
214 })?;
215
216 let medium: &Medium = configs
217 .mediums
218 .iter()
219 .find(|r| r.mem == mem && r.name == facet.medium)
220 .map(|r| &r.config)
221 .ok_or_else(|| ResolveError::MediumNotFound {
222 facet: facet_name.clone(),
223 medium: facet.medium.clone(),
224 mem: mem.to_string(),
225 available: configs
226 .mediums
227 .iter()
228 .filter(|r| r.mem == mem)
229 .map(|r| r.name.clone())
230 .collect(),
231 })?;
232
233 primary_sources.push(ResolvedPrimarySource {
234 facet_ref: facet_name.clone(),
235 medium: facet.medium.clone(),
236 medium_type: medium.medium_type,
237 medium_pointer: medium.pointer.clone(),
238 declared_change_detection: medium.change_detection.clone(),
239 scope: facet.scope.clone(),
240 preparation: facet.preparation.clone(),
241 });
242 }
243
244 Ok(ResolvedBinding {
245 binding: binding.clone(),
246 primary_sources,
247 })
248}
249
250pub fn resolve_binding_run(
268 configs: &BindingConfigs,
269 binding_id: &str,
270 binding: &BindingV1,
271) -> Result<ResolvedIngest, ResolveError> {
272 let (mem, name) = binding_id
273 .split_once('/')
274 .filter(|(m, n)| !m.is_empty() && !n.is_empty())
275 .ok_or_else(|| ResolveError::MalformedProjectionRef {
276 ingest: binding_id.to_string(),
277 projection: binding_id.to_string(),
278 })?;
279 let mem = mem.to_string();
280 let name = name.to_string();
281
282 let mut sources =
283 Vec::with_capacity(binding.source_facets.len() + binding.reference_mems.len());
284 for facet_name in &binding.source_facets {
285 let facet: &Facet = configs
286 .facets
287 .iter()
288 .find(|r| r.mem == mem && r.name == *facet_name)
289 .map(|r| &r.config)
290 .ok_or_else(|| ResolveError::FacetNotFound {
291 projection_ref: binding_id.to_string(),
292 facet: facet_name.clone(),
293 mem: mem.clone(),
294 available: configs
295 .facets
296 .iter()
297 .filter(|r| r.mem == mem)
298 .map(|r| r.name.clone())
299 .collect(),
300 })?;
301
302 let medium: &Medium = configs
303 .mediums
304 .iter()
305 .find(|r| r.mem == mem && r.name == facet.medium)
306 .map(|r| &r.config)
307 .ok_or_else(|| ResolveError::MediumNotFound {
308 facet: facet_name.clone(),
309 medium: facet.medium.clone(),
310 mem: mem.clone(),
311 available: configs
312 .mediums
313 .iter()
314 .filter(|r| r.mem == mem)
315 .map(|r| r.name.clone())
316 .collect(),
317 })?;
318
319 sources.push(ResolvedSource::Primary(ResolvedPrimarySource {
320 facet_ref: facet_name.clone(),
321 medium: facet.medium.clone(),
322 medium_type: medium.medium_type,
323 medium_pointer: medium.pointer.clone(),
324 declared_change_detection: medium.change_detection.clone(),
325 scope: facet.scope.clone(),
326 preparation: facet.preparation.clone(),
327 }));
328 }
329 for reference_mem in &binding.reference_mems {
330 sources.push(ResolvedSource::Reference {
331 mem: reference_mem.clone(),
332 });
333 }
334
335 let build = binding.operations.build.as_ref();
340 let mode = build.map_or(BuildMode::Discovery, |b| b.mode);
341 let trigger = build.map_or(IngestTrigger::Loop, |b| b.trigger);
342 let batch_size = build.map_or(20, |b| b.batch_size);
343 let post_actions = build.and_then(|b| b.post_actions.clone());
344
345 Ok(ResolvedIngest {
346 name: binding_id.to_string(),
347 mode,
348 trigger,
349 batch_size,
350 deny_paths: binding.deny_paths.clone(),
351 projection_ref: binding_id.to_string(),
352 projection_mem: mem,
353 projection_name: name,
354 intent: binding.intent.clone(),
355 sources,
356 destination_mem: binding.destination_mem.clone(),
357 rules: binding.rules.clone(),
358 post_actions,
359 })
360}
361
362#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365pub enum ChangeStrategy {
366 None,
368 Git,
370 Mtime,
372 Graph,
374}
375
376pub fn resolve_change_strategy(
389 source: &ResolvedPrimarySource,
390 workspace_root: &Path,
391) -> ChangeStrategy {
392 if source.medium_type == MediumType::Graph {
393 return ChangeStrategy::Graph;
394 }
395 if !medium_capabilities(source.medium_type).change_signal {
400 return ChangeStrategy::None;
401 }
402 match source.declared_change_detection.as_deref() {
403 Some("none") => ChangeStrategy::None,
404 Some("git") => ChangeStrategy::Git,
405 Some("mtime") => ChangeStrategy::Mtime,
406 _ => {
408 let base = if source.medium_pointer.is_empty() {
409 workspace_root.to_path_buf()
410 } else {
411 workspace_root.join(&source.medium_pointer)
414 };
415 if find_git_root(&base).is_some() {
416 ChangeStrategy::Git
417 } else {
418 ChangeStrategy::Mtime
419 }
420 }
421 }
422}
423
424pub fn find_git_root(start: &Path) -> Option<PathBuf> {
429 let mut dir = start.to_path_buf();
430 for _ in 0..64 {
431 if dir.join(".git").exists() {
432 return Some(dir);
433 }
434 match dir.parent() {
435 Some(parent) => dir = parent.to_path_buf(),
436 None => break,
437 }
438 }
439 None
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445 use crate::pipeline::PatternMode;
446 use crate::pipeline_store::MemPipelineRecord;
447
448 fn medium(mem: &str, name: &str, ty: MediumType, pointer: &str) -> MemPipelineRecord<Medium> {
449 MemPipelineRecord {
450 mem: mem.to_string(),
451 name: name.to_string(),
452 config: Medium {
453 name: name.to_string(),
454 medium_type: ty,
455 pointer: pointer.to_string(),
456 change_detection: None,
457 },
458 }
459 }
460
461 fn primary(
462 medium_type: MediumType,
463 pointer: &str,
464 declared: Option<&str>,
465 ) -> ResolvedPrimarySource {
466 ResolvedPrimarySource {
467 facet_ref: "f".to_string(),
468 medium: "m".to_string(),
469 medium_type,
470 medium_pointer: pointer.to_string(),
471 declared_change_detection: declared.map(str::to_string),
472 scope: vec![],
473 preparation: None,
474 }
475 }
476
477 fn facet(
478 mem: &str,
479 name: &str,
480 medium: &str,
481 scope: Vec<PatternEntry>,
482 ) -> MemPipelineRecord<Facet> {
483 MemPipelineRecord {
484 mem: mem.to_string(),
485 name: name.to_string(),
486 config: Facet {
487 name: name.to_string(),
488 medium: medium.to_string(),
489 scope,
490 engagement: None,
491 preparation: None,
492 },
493 }
494 }
495
496 fn allow(path: &str) -> PatternEntry {
497 PatternEntry {
498 path: path.to_string(),
499 mode: PatternMode::Allow,
500 }
501 }
502
503 fn v1_binding(dest: &str, facets: &[&str]) -> BindingV1 {
504 use crate::binding::{
505 BINDING_VERSION, BuildMode, BuildOperation, CoverageSemantics, Operations,
506 };
507 BindingV1 {
508 version: BINDING_VERSION,
509 intent: Some("prose".to_string()),
510 source_facets: facets.iter().map(|s| s.to_string()).collect(),
511 reference_mems: vec![],
512 destination_mem: dest.to_string(),
513 deny_paths: vec![],
514 coverage_semantics: CoverageSemantics::Exhaustive,
515 rules: None,
516 prune: None,
517 operations: Operations {
518 build: Some(BuildOperation {
519 mode: BuildMode::Discovery,
520 trigger: IngestTrigger::Loop,
521 batch_size: 20,
522 post_actions: None,
523 }),
524 sync: None,
525 verify: None,
526 },
527 }
528 }
529
530 #[test]
534 fn resolves_a_v1_binding() {
535 let configs = PipelineConfigs {
536 mediums: vec![medium("engine", "src", MediumType::Codebase, "../public")],
537 facets: vec![facet(
538 "engine",
539 "source-tree",
540 "src",
541 vec![allow("../public/**/*.rs")],
542 )],
543 ..Default::default()
544 };
545 let binding = v1_binding("engine", &["source-tree"]);
546 let resolved = resolve_binding(&configs, "engine/graph", &binding).unwrap();
547 assert_eq!(resolved.primary_sources.len(), 1);
548 let p = &resolved.primary_sources[0];
549 assert_eq!(p.facet_ref, "source-tree");
550 assert_eq!(p.medium, "src");
551 assert_eq!(p.medium_type, MediumType::Codebase);
552 assert_eq!(p.medium_pointer, "../public");
553 assert_eq!(p.scope, vec![allow("../public/**/*.rs")]);
554 }
555
556 #[test]
558 fn resolve_binding_dangling_facet_errors() {
559 let configs = PipelineConfigs::default();
560 let binding = v1_binding("engine", &["missing-facet"]);
561 let err = resolve_binding(&configs, "engine/graph", &binding).unwrap_err();
562 assert!(matches!(
563 err,
564 ResolveError::FacetNotFound { ref facet, .. } if facet == "missing-facet"
565 ));
566 }
567
568 #[test]
570 fn resolve_binding_malformed_id_errors() {
571 let configs = PipelineConfigs::default();
572 let binding = v1_binding("engine", &[]);
573 let err = resolve_binding(&configs, "noslash", &binding).unwrap_err();
574 assert!(matches!(err, ResolveError::MalformedProjectionRef { .. }));
575 }
576
577 #[test]
582 fn resolve_binding_run_produces_runtime_shape() {
583 use crate::binding::{
584 BINDING_VERSION, BuildMode, BuildOperation, CoverageSemantics, Operations,
585 };
586 let configs = BindingConfigs {
587 mediums: vec![MemPipelineRecord {
588 mem: "app".to_string(),
589 name: "src".to_string(),
590 config: Medium {
591 name: "src".to_string(),
592 medium_type: MediumType::Codebase,
593 pointer: "../app".to_string(),
594 change_detection: None,
595 },
596 }],
597 facets: vec![MemPipelineRecord {
598 mem: "app".to_string(),
599 name: "source-tree".to_string(),
600 config: Facet {
601 name: "source-tree".to_string(),
602 medium: "src".to_string(),
603 scope: vec![allow("../app/**/*.swift")],
604 engagement: None,
605 preparation: None,
606 },
607 }],
608 bindings: vec![],
609 };
610 let binding = BindingV1 {
611 version: BINDING_VERSION,
612 intent: Some("swift".to_string()),
613 source_facets: vec!["source-tree".to_string()],
614 reference_mems: vec!["engine".to_string()],
615 destination_mem: "app".to_string(),
616 deny_paths: vec!["**/VISION.md".to_string()],
617 coverage_semantics: CoverageSemantics::Exhaustive,
618 rules: None,
619 prune: None,
620 operations: Operations {
621 build: Some(BuildOperation {
622 mode: BuildMode::Discovery,
623 trigger: IngestTrigger::Loop,
624 batch_size: 20,
625 post_actions: Some(serde_json::json!({ "archive_source": true })),
626 }),
627 sync: None,
628 verify: None,
629 },
630 };
631
632 let r = resolve_binding_run(&configs, "app/graph", &binding).unwrap();
633 assert_eq!(r.name, "app/graph");
634 assert_eq!(r.projection_ref, "app/graph");
635 assert_eq!(r.projection_mem, "app");
636 assert_eq!(r.projection_name, "graph");
637 assert_eq!(r.mode, BuildMode::Discovery);
638 assert_eq!(r.batch_size, 20);
639 assert_eq!(r.deny_paths, ["**/VISION.md"]);
640 assert_eq!(r.destination_mem, "app");
641 assert_eq!(r.intent.as_deref(), Some("swift"));
642 assert_eq!(
643 r.post_actions,
644 Some(serde_json::json!({ "archive_source": true }))
645 );
646 assert_eq!(r.sources.len(), 2);
647 match &r.sources[0] {
648 ResolvedSource::Primary(p) => {
649 assert_eq!(p.facet_ref, "source-tree");
650 assert_eq!(p.medium_type, MediumType::Codebase);
651 assert_eq!(p.medium_pointer, "../app");
652 }
653 other => panic!("expected primary first, got {other:?}"),
654 }
655 assert_eq!(
656 r.sources[1],
657 ResolvedSource::Reference {
658 mem: "engine".to_string()
659 }
660 );
661 }
662
663 #[test]
665 fn resolve_binding_run_maps_one_shot() {
666 use crate::binding::{
667 BINDING_VERSION, BuildMode, BuildOperation, CoverageSemantics, Operations,
668 };
669 let configs = BindingConfigs::default();
670 let binding = BindingV1 {
671 version: BINDING_VERSION,
672 intent: None,
673 source_facets: vec![],
674 reference_mems: vec![],
675 destination_mem: "m".to_string(),
676 deny_paths: vec![],
677 coverage_semantics: CoverageSemantics::Exhaustive,
678 rules: None,
679 prune: None,
680 operations: Operations {
681 build: Some(BuildOperation {
682 mode: BuildMode::OneShot,
683 trigger: IngestTrigger::Manual,
684 batch_size: 5,
685 post_actions: None,
686 }),
687 sync: None,
688 verify: None,
689 },
690 };
691 let r = resolve_binding_run(&configs, "m/lens", &binding).unwrap();
692 assert_eq!(r.mode, BuildMode::OneShot);
693 }
694
695 #[test]
698 fn graph_medium_always_uses_graph_strategy() {
699 let root = Path::new("/nonexistent");
700 assert_eq!(
701 resolve_change_strategy(&primary(MediumType::Graph, "", None), root),
702 ChangeStrategy::Graph
703 );
704 assert_eq!(
706 resolve_change_strategy(&primary(MediumType::Graph, "", Some("mtime")), root),
707 ChangeStrategy::Graph
708 );
709 }
710
711 #[test]
715 fn web_medium_resolves_to_none_signal() {
716 let root = Path::new("/nonexistent");
717 assert_eq!(
718 resolve_change_strategy(&primary(MediumType::Web, "https://example.com", None), root),
719 ChangeStrategy::None
720 );
721 assert_eq!(
723 resolve_change_strategy(
724 &primary(MediumType::Web, "https://example.com", Some("mtime")),
725 root
726 ),
727 ChangeStrategy::None
728 );
729 }
730
731 #[test]
733 fn declared_strategy_wins_for_non_graph() {
734 let root = Path::new("/nonexistent");
735 for (declared, expected) in [
736 ("none", ChangeStrategy::None),
737 ("git", ChangeStrategy::Git),
738 ("mtime", ChangeStrategy::Mtime),
739 ] {
740 assert_eq!(
741 resolve_change_strategy(&primary(MediumType::Codebase, "x", Some(declared)), root),
742 expected,
743 "declared '{declared}'"
744 );
745 }
746 }
747
748 #[test]
751 fn auto_probes_for_a_git_work_tree() {
752 let git = tempfile::tempdir().unwrap();
753 std::fs::create_dir(git.path().join(".git")).unwrap();
754 std::fs::create_dir(git.path().join("sub")).unwrap();
755 let plain = tempfile::tempdir().unwrap();
756
757 assert_eq!(
759 resolve_change_strategy(&primary(MediumType::Codebase, "sub", None), git.path()),
760 ChangeStrategy::Git
761 );
762 assert_eq!(
764 resolve_change_strategy(
765 &primary(MediumType::Codebase, "sub", Some("weird")),
766 git.path()
767 ),
768 ChangeStrategy::Git
769 );
770 assert_eq!(
772 resolve_change_strategy(&primary(MediumType::Filesystem, ".", None), plain.path()),
773 ChangeStrategy::Mtime
774 );
775 }
776
777 #[test]
780 fn find_git_root_walks_up() {
781 let root = tempfile::tempdir().unwrap();
782 std::fs::create_dir(root.path().join(".git")).unwrap();
783 let nested = root.path().join("a/b/c");
784 std::fs::create_dir_all(&nested).unwrap();
785
786 assert_eq!(
787 find_git_root(&nested).as_deref(),
788 Some(root.path()),
789 "walks up to the work-tree root"
790 );
791
792 let plain = tempfile::tempdir().unwrap();
793 assert_eq!(find_git_root(plain.path()), None, "no .git anywhere above");
794 }
795}