use std::path::{Path, PathBuf};
use crate::binding::{Binding, BuildMode, medium_capabilities};
pub use crate::pipeline::Source;
use crate::pipeline::{IngestTrigger, MediumType};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolvedSource {
Primary(Source),
Reference {
mem: 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,
},
}
fn fmt_list(names: &[String]) -> String {
if names.is_empty() {
"(none)".to_string()
} else {
names.join(", ")
}
}
pub fn resolve_binding_run(
binding_id: &str,
binding: &Binding,
) -> 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.sources.len() + binding.reference_mems.len());
for source in &binding.sources {
sources.push(ResolvedSource::Primary(source.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: &Source, 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.change_detection.as_deref() {
Some("none") => ChangeStrategy::None,
Some("git") => ChangeStrategy::Git,
Some("mtime") => ChangeStrategy::Mtime,
_ => {
if find_git_root(&source_base_path(source, workspace_root)).is_some() {
ChangeStrategy::Git
} else {
ChangeStrategy::Mtime
}
}
}
}
pub fn source_base_path(source: &Source, workspace_root: &Path) -> PathBuf {
if source.pointer.is_empty() {
workspace_root.to_path_buf()
} else {
workspace_root.join(&source.pointer)
}
}
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::binding::{BINDING_VERSION, BuildOperation, Operations};
use crate::pipeline::{PatternEntry, PatternMode};
fn source(
name: &str,
medium_type: MediumType,
pointer: &str,
declared: Option<&str>,
) -> Source {
Source {
name: name.to_string(),
medium_type,
pointer: pointer.to_string(),
change_detection: declared.map(str::to_string),
scope: vec![],
engagement: None,
preparation: None,
}
}
fn allow(path: &str) -> PatternEntry {
PatternEntry {
path: path.to_string(),
mode: PatternMode::Allow,
}
}
fn v2_binding(dest: &str, sources: Vec<Source>) -> Binding {
Binding {
version: BINDING_VERSION,
intent: Some("prose".to_string()),
sources,
reference_mems: vec![],
destination_mem: dest.to_string(),
deny_paths: vec![],
coverage_semantics: None,
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 resolve_binding_run_produces_runtime_shape() {
let mut swift_source = source("source-tree", MediumType::Codebase, "../app", None);
swift_source.scope = vec![allow("../app/**/*.swift")];
let mut binding = v2_binding("app", vec![swift_source.clone()]);
binding.intent = Some("swift".to_string());
binding.reference_mems = vec!["engine".to_string()];
binding.deny_paths = vec!["**/VISION.md".to_string()];
binding.operations.build.as_mut().unwrap().post_actions =
Some(serde_json::json!({ "archive_source": true }));
let r = resolve_binding_run("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);
assert_eq!(r.sources[0], ResolvedSource::Primary(swift_source));
assert_eq!(
r.sources[1],
ResolvedSource::Reference {
mem: "engine".to_string()
}
);
}
#[test]
fn resolve_binding_run_maps_one_shot() {
let mut binding = v2_binding("m", vec![]);
let build = binding.operations.build.as_mut().unwrap();
build.mode = BuildMode::OneShot;
build.trigger = IngestTrigger::Manual;
build.batch_size = 5;
let r = resolve_binding_run("m/lens", &binding).unwrap();
assert_eq!(r.mode, BuildMode::OneShot);
assert_eq!(r.batch_size, 5);
}
#[test]
fn resolve_binding_run_malformed_id_errors() {
let binding = v2_binding("m", vec![]);
let err = resolve_binding_run("noslash", &binding).unwrap_err();
assert!(matches!(err, ResolveError::MalformedProjectionRef { .. }));
}
#[test]
fn absent_build_resolves_to_defaults() {
let mut binding = v2_binding("m", vec![]);
binding.operations.build = None;
let r = resolve_binding_run("m/p", &binding).unwrap();
assert_eq!(r.mode, BuildMode::Discovery);
assert_eq!(r.trigger, IngestTrigger::Loop);
assert_eq!(r.batch_size, 20);
assert_eq!(r.post_actions, None);
}
#[test]
fn graph_source_always_uses_graph_strategy() {
let root = Path::new("/nonexistent");
assert_eq!(
resolve_change_strategy(&source("f", MediumType::Graph, "", None), root),
ChangeStrategy::Graph
);
assert_eq!(
resolve_change_strategy(&source("f", MediumType::Graph, "", Some("mtime")), root),
ChangeStrategy::Graph
);
}
#[test]
fn web_source_resolves_to_none_signal() {
let root = Path::new("/nonexistent");
assert_eq!(
resolve_change_strategy(
&source("w", MediumType::Web, "https://example.com", None),
root
),
ChangeStrategy::None
);
assert_eq!(
resolve_change_strategy(
&source("w", 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(
&source("f", 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(&source("f", MediumType::Codebase, "sub", None), git.path()),
ChangeStrategy::Git
);
assert_eq!(
resolve_change_strategy(
&source("f", MediumType::Codebase, "sub", Some("weird")),
git.path()
),
ChangeStrategy::Git
);
assert_eq!(
resolve_change_strategy(
&source("f", 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");
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProcessMemResolution {
pub mem: String,
pub mounted: bool,
pub declared: bool,
}
pub fn resolve_process_mem(
engine: &crate::Engine,
destination_mem: &str,
derived_name: &str,
) -> ProcessMemResolution {
let mounted_names = engine.mem_names();
if let Some(declared) = engine
.mem_config_for(destination_mem)
.and_then(|c| c.process_mem.clone())
{
let mounted = mounted_names.iter().any(|m| *m == declared);
return ProcessMemResolution {
mem: declared,
mounted,
declared: true,
};
}
let mounted = mounted_names.contains(&derived_name);
ProcessMemResolution {
mem: derived_name.to_string(),
mounted,
declared: false,
}
}