use std::path::{Path, PathBuf};
use crate::binding::{BindingV1, BuildMode, ResolvedBinding, medium_capabilities};
use crate::pipeline::{Facet, IngestTrigger, Medium, MediumType, PatternEntry};
use crate::pipeline_store::{BindingConfigs, PipelineConfigs};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolvedSource {
Primary(ResolvedPrimarySource),
Reference {
mem: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedPrimarySource {
pub facet_ref: String,
pub medium: String,
pub medium_type: MediumType,
pub medium_pointer: String,
pub declared_change_detection: Option<String>,
pub scope: Vec<PatternEntry>,
pub preparation: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedIngest {
pub name: String,
pub mode: BuildMode,
pub trigger: IngestTrigger,
pub batch_size: u32,
pub deny_paths: Vec<String>,
pub projection_ref: String,
pub projection_mem: String,
pub projection_name: String,
pub intent: Option<String>,
pub sources: Vec<ResolvedSource>,
pub destination_mem: String,
pub rules: Option<serde_json::Value>,
pub post_actions: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ResolveError {
#[error("binding '{name}' not found; available: {}", fmt_list(available))]
BindingNotFound {
name: String,
available: Vec<String>,
},
#[error("malformed binding id '{projection}'; expected \"<mem>/<name>\"")]
MalformedProjectionRef {
ingest: String,
projection: String,
},
#[error(
"projection '{projection_ref}' references facet '{facet}' not found in mem '{mem}'; available: {}",
fmt_list(available)
)]
FacetNotFound {
projection_ref: String,
facet: String,
mem: String,
available: Vec<String>,
},
#[error(
"facet '{facet}' references medium '{medium}' not found in mem '{mem}'; available: {}",
fmt_list(available)
)]
MediumNotFound {
facet: String,
medium: String,
mem: String,
available: Vec<String>,
},
}
fn fmt_list(names: &[String]) -> String {
if names.is_empty() {
"(none)".to_string()
} else {
names.join(", ")
}
}
pub fn resolve_binding(
configs: &PipelineConfigs,
binding_id: &str,
binding: &BindingV1,
) -> Result<ResolvedBinding, ResolveError> {
let (mem, _name) = binding_id
.split_once('/')
.filter(|(m, n)| !m.is_empty() && !n.is_empty())
.ok_or_else(|| ResolveError::MalformedProjectionRef {
ingest: binding_id.to_string(),
projection: binding_id.to_string(),
})?;
let mut primary_sources = Vec::with_capacity(binding.source_facets.len());
for facet_name in &binding.source_facets {
let facet: &Facet = configs
.facets
.iter()
.find(|r| r.mem == mem && r.name == *facet_name)
.map(|r| &r.config)
.ok_or_else(|| ResolveError::FacetNotFound {
projection_ref: binding_id.to_string(),
facet: facet_name.clone(),
mem: mem.to_string(),
available: configs
.facets
.iter()
.filter(|r| r.mem == mem)
.map(|r| r.name.clone())
.collect(),
})?;
let medium: &Medium = configs
.mediums
.iter()
.find(|r| r.mem == mem && r.name == facet.medium)
.map(|r| &r.config)
.ok_or_else(|| ResolveError::MediumNotFound {
facet: facet_name.clone(),
medium: facet.medium.clone(),
mem: mem.to_string(),
available: configs
.mediums
.iter()
.filter(|r| r.mem == mem)
.map(|r| r.name.clone())
.collect(),
})?;
primary_sources.push(ResolvedPrimarySource {
facet_ref: facet_name.clone(),
medium: facet.medium.clone(),
medium_type: medium.medium_type,
medium_pointer: medium.pointer.clone(),
declared_change_detection: medium.change_detection.clone(),
scope: facet.scope.clone(),
preparation: facet.preparation.clone(),
});
}
Ok(ResolvedBinding {
binding: binding.clone(),
primary_sources,
})
}
pub fn resolve_binding_run(
configs: &BindingConfigs,
binding_id: &str,
binding: &BindingV1,
) -> Result<ResolvedIngest, ResolveError> {
let (mem, name) = binding_id
.split_once('/')
.filter(|(m, n)| !m.is_empty() && !n.is_empty())
.ok_or_else(|| ResolveError::MalformedProjectionRef {
ingest: binding_id.to_string(),
projection: binding_id.to_string(),
})?;
let mem = mem.to_string();
let name = name.to_string();
let mut sources =
Vec::with_capacity(binding.source_facets.len() + binding.reference_mems.len());
for facet_name in &binding.source_facets {
let facet: &Facet = configs
.facets
.iter()
.find(|r| r.mem == mem && r.name == *facet_name)
.map(|r| &r.config)
.ok_or_else(|| ResolveError::FacetNotFound {
projection_ref: binding_id.to_string(),
facet: facet_name.clone(),
mem: mem.clone(),
available: configs
.facets
.iter()
.filter(|r| r.mem == mem)
.map(|r| r.name.clone())
.collect(),
})?;
let medium: &Medium = configs
.mediums
.iter()
.find(|r| r.mem == mem && r.name == facet.medium)
.map(|r| &r.config)
.ok_or_else(|| ResolveError::MediumNotFound {
facet: facet_name.clone(),
medium: facet.medium.clone(),
mem: mem.clone(),
available: configs
.mediums
.iter()
.filter(|r| r.mem == mem)
.map(|r| r.name.clone())
.collect(),
})?;
sources.push(ResolvedSource::Primary(ResolvedPrimarySource {
facet_ref: facet_name.clone(),
medium: facet.medium.clone(),
medium_type: medium.medium_type,
medium_pointer: medium.pointer.clone(),
declared_change_detection: medium.change_detection.clone(),
scope: facet.scope.clone(),
preparation: facet.preparation.clone(),
}));
}
for reference_mem in &binding.reference_mems {
sources.push(ResolvedSource::Reference {
mem: reference_mem.clone(),
});
}
let build = binding.operations.build.as_ref();
let mode = build.map_or(BuildMode::Discovery, |b| b.mode);
let trigger = build.map_or(IngestTrigger::Loop, |b| b.trigger);
let batch_size = build.map_or(20, |b| b.batch_size);
let post_actions = build.and_then(|b| b.post_actions.clone());
Ok(ResolvedIngest {
name: binding_id.to_string(),
mode,
trigger,
batch_size,
deny_paths: binding.deny_paths.clone(),
projection_ref: binding_id.to_string(),
projection_mem: mem,
projection_name: name,
intent: binding.intent.clone(),
sources,
destination_mem: binding.destination_mem.clone(),
rules: binding.rules.clone(),
post_actions,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangeStrategy {
None,
Git,
Mtime,
Graph,
}
pub fn resolve_change_strategy(
source: &ResolvedPrimarySource,
workspace_root: &Path,
) -> ChangeStrategy {
if source.medium_type == MediumType::Graph {
return ChangeStrategy::Graph;
}
if !medium_capabilities(source.medium_type).change_signal {
return ChangeStrategy::None;
}
match source.declared_change_detection.as_deref() {
Some("none") => ChangeStrategy::None,
Some("git") => ChangeStrategy::Git,
Some("mtime") => ChangeStrategy::Mtime,
_ => {
let base = if source.medium_pointer.is_empty() {
workspace_root.to_path_buf()
} else {
workspace_root.join(&source.medium_pointer)
};
if find_git_root(&base).is_some() {
ChangeStrategy::Git
} else {
ChangeStrategy::Mtime
}
}
}
}
pub fn find_git_root(start: &Path) -> Option<PathBuf> {
let mut dir = start.to_path_buf();
for _ in 0..64 {
if dir.join(".git").exists() {
return Some(dir);
}
match dir.parent() {
Some(parent) => dir = parent.to_path_buf(),
None => break,
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pipeline::PatternMode;
use crate::pipeline_store::MemPipelineRecord;
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 primary(
medium_type: MediumType,
pointer: &str,
declared: Option<&str>,
) -> ResolvedPrimarySource {
ResolvedPrimarySource {
facet_ref: "f".to_string(),
medium: "m".to_string(),
medium_type,
medium_pointer: pointer.to_string(),
declared_change_detection: declared.map(str::to_string),
scope: vec![],
preparation: None,
}
}
fn facet(
mem: &str,
name: &str,
medium: &str,
scope: Vec<PatternEntry>,
) -> MemPipelineRecord<Facet> {
MemPipelineRecord {
mem: mem.to_string(),
name: name.to_string(),
config: Facet {
name: name.to_string(),
medium: medium.to_string(),
scope,
engagement: None,
preparation: None,
},
}
}
fn allow(path: &str) -> PatternEntry {
PatternEntry {
path: path.to_string(),
mode: PatternMode::Allow,
}
}
fn v1_binding(dest: &str, facets: &[&str]) -> BindingV1 {
use crate::binding::{
BINDING_VERSION, BuildMode, BuildOperation, CoverageSemantics, Operations,
};
BindingV1 {
version: BINDING_VERSION,
intent: Some("prose".to_string()),
source_facets: facets.iter().map(|s| s.to_string()).collect(),
reference_mems: vec![],
destination_mem: dest.to_string(),
deny_paths: vec![],
coverage_semantics: CoverageSemantics::Exhaustive,
rules: None,
prune: None,
operations: Operations {
build: Some(BuildOperation {
mode: BuildMode::Discovery,
trigger: IngestTrigger::Loop,
batch_size: 20,
post_actions: None,
}),
sync: None,
verify: None,
},
}
}
#[test]
fn resolves_a_v1_binding() {
let configs = PipelineConfigs {
mediums: vec![medium("engine", "src", MediumType::Codebase, "../public")],
facets: vec![facet(
"engine",
"source-tree",
"src",
vec![allow("../public/**/*.rs")],
)],
..Default::default()
};
let binding = v1_binding("engine", &["source-tree"]);
let resolved = resolve_binding(&configs, "engine/graph", &binding).unwrap();
assert_eq!(resolved.primary_sources.len(), 1);
let p = &resolved.primary_sources[0];
assert_eq!(p.facet_ref, "source-tree");
assert_eq!(p.medium, "src");
assert_eq!(p.medium_type, MediumType::Codebase);
assert_eq!(p.medium_pointer, "../public");
assert_eq!(p.scope, vec![allow("../public/**/*.rs")]);
}
#[test]
fn resolve_binding_dangling_facet_errors() {
let configs = PipelineConfigs::default();
let binding = v1_binding("engine", &["missing-facet"]);
let err = resolve_binding(&configs, "engine/graph", &binding).unwrap_err();
assert!(matches!(
err,
ResolveError::FacetNotFound { ref facet, .. } if facet == "missing-facet"
));
}
#[test]
fn resolve_binding_malformed_id_errors() {
let configs = PipelineConfigs::default();
let binding = v1_binding("engine", &[]);
let err = resolve_binding(&configs, "noslash", &binding).unwrap_err();
assert!(matches!(err, ResolveError::MalformedProjectionRef { .. }));
}
#[test]
fn resolve_binding_run_produces_runtime_shape() {
use crate::binding::{
BINDING_VERSION, BuildMode, BuildOperation, CoverageSemantics, Operations,
};
let configs = BindingConfigs {
mediums: vec![MemPipelineRecord {
mem: "app".to_string(),
name: "src".to_string(),
config: Medium {
name: "src".to_string(),
medium_type: MediumType::Codebase,
pointer: "../app".to_string(),
change_detection: None,
},
}],
facets: vec![MemPipelineRecord {
mem: "app".to_string(),
name: "source-tree".to_string(),
config: Facet {
name: "source-tree".to_string(),
medium: "src".to_string(),
scope: vec![allow("../app/**/*.swift")],
engagement: None,
preparation: None,
},
}],
bindings: vec![],
};
let binding = BindingV1 {
version: BINDING_VERSION,
intent: Some("swift".to_string()),
source_facets: vec!["source-tree".to_string()],
reference_mems: vec!["engine".to_string()],
destination_mem: "app".to_string(),
deny_paths: vec!["**/VISION.md".to_string()],
coverage_semantics: CoverageSemantics::Exhaustive,
rules: None,
prune: None,
operations: Operations {
build: Some(BuildOperation {
mode: BuildMode::Discovery,
trigger: IngestTrigger::Loop,
batch_size: 20,
post_actions: Some(serde_json::json!({ "archive_source": true })),
}),
sync: None,
verify: None,
},
};
let r = resolve_binding_run(&configs, "app/graph", &binding).unwrap();
assert_eq!(r.name, "app/graph");
assert_eq!(r.projection_ref, "app/graph");
assert_eq!(r.projection_mem, "app");
assert_eq!(r.projection_name, "graph");
assert_eq!(r.mode, BuildMode::Discovery);
assert_eq!(r.batch_size, 20);
assert_eq!(r.deny_paths, ["**/VISION.md"]);
assert_eq!(r.destination_mem, "app");
assert_eq!(r.intent.as_deref(), Some("swift"));
assert_eq!(
r.post_actions,
Some(serde_json::json!({ "archive_source": true }))
);
assert_eq!(r.sources.len(), 2);
match &r.sources[0] {
ResolvedSource::Primary(p) => {
assert_eq!(p.facet_ref, "source-tree");
assert_eq!(p.medium_type, MediumType::Codebase);
assert_eq!(p.medium_pointer, "../app");
}
other => panic!("expected primary first, got {other:?}"),
}
assert_eq!(
r.sources[1],
ResolvedSource::Reference {
mem: "engine".to_string()
}
);
}
#[test]
fn resolve_binding_run_maps_one_shot() {
use crate::binding::{
BINDING_VERSION, BuildMode, BuildOperation, CoverageSemantics, Operations,
};
let configs = BindingConfigs::default();
let binding = BindingV1 {
version: BINDING_VERSION,
intent: None,
source_facets: vec![],
reference_mems: vec![],
destination_mem: "m".to_string(),
deny_paths: vec![],
coverage_semantics: CoverageSemantics::Exhaustive,
rules: None,
prune: None,
operations: Operations {
build: Some(BuildOperation {
mode: BuildMode::OneShot,
trigger: IngestTrigger::Manual,
batch_size: 5,
post_actions: None,
}),
sync: None,
verify: None,
},
};
let r = resolve_binding_run(&configs, "m/lens", &binding).unwrap();
assert_eq!(r.mode, BuildMode::OneShot);
}
#[test]
fn graph_medium_always_uses_graph_strategy() {
let root = Path::new("/nonexistent");
assert_eq!(
resolve_change_strategy(&primary(MediumType::Graph, "", None), root),
ChangeStrategy::Graph
);
assert_eq!(
resolve_change_strategy(&primary(MediumType::Graph, "", Some("mtime")), root),
ChangeStrategy::Graph
);
}
#[test]
fn web_medium_resolves_to_none_signal() {
let root = Path::new("/nonexistent");
assert_eq!(
resolve_change_strategy(&primary(MediumType::Web, "https://example.com", None), root),
ChangeStrategy::None
);
assert_eq!(
resolve_change_strategy(
&primary(MediumType::Web, "https://example.com", Some("mtime")),
root
),
ChangeStrategy::None
);
}
#[test]
fn declared_strategy_wins_for_non_graph() {
let root = Path::new("/nonexistent");
for (declared, expected) in [
("none", ChangeStrategy::None),
("git", ChangeStrategy::Git),
("mtime", ChangeStrategy::Mtime),
] {
assert_eq!(
resolve_change_strategy(&primary(MediumType::Codebase, "x", Some(declared)), root),
expected,
"declared '{declared}'"
);
}
}
#[test]
fn auto_probes_for_a_git_work_tree() {
let git = tempfile::tempdir().unwrap();
std::fs::create_dir(git.path().join(".git")).unwrap();
std::fs::create_dir(git.path().join("sub")).unwrap();
let plain = tempfile::tempdir().unwrap();
assert_eq!(
resolve_change_strategy(&primary(MediumType::Codebase, "sub", None), git.path()),
ChangeStrategy::Git
);
assert_eq!(
resolve_change_strategy(
&primary(MediumType::Codebase, "sub", Some("weird")),
git.path()
),
ChangeStrategy::Git
);
assert_eq!(
resolve_change_strategy(&primary(MediumType::Filesystem, ".", None), plain.path()),
ChangeStrategy::Mtime
);
}
#[test]
fn find_git_root_walks_up() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir(root.path().join(".git")).unwrap();
let nested = root.path().join("a/b/c");
std::fs::create_dir_all(&nested).unwrap();
assert_eq!(
find_git_root(&nested).as_deref(),
Some(root.path()),
"walks up to the work-tree root"
);
let plain = tempfile::tempdir().unwrap();
assert_eq!(find_git_root(plain.path()), None, "no .git anywhere above");
}
}