use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
use crate::ingest::resolve::ResolvedPrimarySource;
use crate::pipeline::{IngestTrigger, MediumType, PatternEntry};
pub const BINDING_VERSION: u32 = 1;
pub const PREPARATION_IMPL_VERSION: u32 = 0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum CoverageSemantics {
#[default]
Exhaustive,
Curated,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum BuildMode {
Discovery,
OneShot,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildOperation {
pub mode: BuildMode,
pub trigger: IngestTrigger,
pub batch_size: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub post_actions: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SyncOperation {
pub trigger: IngestTrigger,
pub batch_size: u32,
}
pub const DEFAULT_ADJUDICATION_CAP: u32 = 50;
pub const DEFAULT_FULL_RESYNC_EVERY: u32 = 20;
fn default_adjudication_cap() -> u32 {
DEFAULT_ADJUDICATION_CAP
}
fn default_full_resync_every() -> u32 {
DEFAULT_FULL_RESYNC_EVERY
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VerifyOperation {
pub trigger: IngestTrigger,
pub batch_size: u32,
#[serde(default = "default_adjudication_cap")]
pub adjudication_cap: u32,
#[serde(default = "default_full_resync_every")]
pub full_resync_every: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum PruneGuarantee {
NeverClobber,
#[default]
ConflictFlag,
}
impl PruneGuarantee {
pub fn as_wire(&self) -> &'static str {
match self {
PruneGuarantee::NeverClobber => "never-clobber",
PruneGuarantee::ConflictFlag => "conflict-flag",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PruneConfig {
#[serde(default)]
pub guarantee: PruneGuarantee,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Operations {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub build: Option<BuildOperation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sync: Option<SyncOperation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verify: Option<VerifyOperation>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BindingV1 {
pub version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub intent: Option<String>,
#[serde(default)]
pub source_facets: Vec<String>,
#[serde(default)]
pub reference_mems: Vec<String>,
pub destination_mem: String,
#[serde(default)]
pub deny_paths: Vec<String>,
#[serde(default)]
pub coverage_semantics: CoverageSemantics,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rules: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prune: Option<PruneConfig>,
pub operations: Operations,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedBinding {
pub binding: BindingV1,
pub primary_sources: Vec<ResolvedPrimarySource>,
}
#[derive(Serialize)]
struct HashFacet<'a> {
facet: &'a str,
patterns: &'a [PatternEntry],
preparation: &'a Option<String>,
preparation_impl_version: u32,
medium_type: MediumType,
medium_pointer: &'a str,
change_detection: &'a Option<String>,
}
#[derive(Serialize)]
struct HashInput<'a> {
version: u32,
intent: &'a Option<String>,
source_facets: Vec<HashFacet<'a>>,
reference_mems: &'a [String],
destination_mem: &'a str,
deny_paths: &'a [String],
coverage_semantics: CoverageSemantics,
rules: &'a Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
build_mode: Option<BuildMode>,
}
fn canonical_json(value: &serde_json::Value) -> String {
fn sorted(v: &serde_json::Value) -> serde_json::Value {
match v {
serde_json::Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
let mut out = serde_json::Map::new();
for k in keys {
out.insert(k.clone(), sorted(&map[k]));
}
serde_json::Value::Object(out)
}
serde_json::Value::Array(items) => {
serde_json::Value::Array(items.iter().map(sorted).collect())
}
other => other.clone(),
}
}
serde_json::to_string(&sorted(value)).expect("canonical JSON serializes")
}
pub fn hash_binding(resolved: &ResolvedBinding) -> String {
let source_facets: Vec<HashFacet<'_>> = resolved
.primary_sources
.iter()
.map(|p| HashFacet {
facet: &p.facet_ref,
patterns: &p.scope,
preparation: &p.preparation,
preparation_impl_version: PREPARATION_IMPL_VERSION,
medium_type: p.medium_type,
medium_pointer: &p.medium_pointer,
change_detection: &p.declared_change_detection,
})
.collect();
let input = HashInput {
version: resolved.binding.version,
intent: &resolved.binding.intent,
source_facets,
reference_mems: &resolved.binding.reference_mems,
destination_mem: &resolved.binding.destination_mem,
deny_paths: &resolved.binding.deny_paths,
coverage_semantics: resolved.binding.coverage_semantics,
rules: &resolved.binding.rules,
build_mode: resolved.binding.operations.build.as_ref().map(|b| b.mode),
};
let value = serde_json::to_value(&input).expect("hash input serializes to a JSON value");
let canonical = canonical_json(&value);
let digest = Sha256::digest(canonical.as_bytes());
format!("{digest:x}")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MediumCapabilities {
pub enumerable: bool,
pub change_signal: bool,
pub base_version_retrievable: bool,
pub anchor_namespace: &'static str,
pub glob_deny_legal: bool,
}
pub fn medium_capabilities(medium_type: MediumType) -> MediumCapabilities {
match medium_type {
MediumType::Codebase => MediumCapabilities {
enumerable: true,
change_signal: true,
base_version_retrievable: true,
anchor_namespace: "path",
glob_deny_legal: true,
},
MediumType::Filesystem => MediumCapabilities {
enumerable: true,
change_signal: true,
base_version_retrievable: true,
anchor_namespace: "path",
glob_deny_legal: true,
},
MediumType::Git => MediumCapabilities {
enumerable: true,
change_signal: true,
base_version_retrievable: true,
anchor_namespace: "path+commit",
glob_deny_legal: true,
},
MediumType::Graph => MediumCapabilities {
enumerable: true,
change_signal: true,
base_version_retrievable: true,
anchor_namespace: "entity",
glob_deny_legal: false,
},
MediumType::Web => MediumCapabilities {
enumerable: false,
change_signal: false,
base_version_retrievable: false,
anchor_namespace: "url",
glob_deny_legal: false,
},
}
}
pub fn prune_guarantee_for_medium(medium_type: MediumType) -> PruneGuarantee {
if medium_capabilities(medium_type).base_version_retrievable {
PruneGuarantee::NeverClobber
} else {
PruneGuarantee::ConflictFlag
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Operation {
Sync,
Verify,
}
impl Operation {
fn name(self) -> &'static str {
match self {
Operation::Sync => "sync",
Operation::Verify => "verify",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum CapabilityError {
#[error(
"operation '{operation}' is out of scope for facet '{facet}' over a '{medium_type}' \
medium: this medium has no change signal this cycle (deferred — operator decision 7)"
)]
OperationOutOfScope {
operation: &'static str,
facet: String,
medium_type: String,
},
#[error(
"glob deny_paths are illegal for facet '{facet}' over a '{medium_type}' medium: its \
'{anchor_namespace}' namespace is not path-shaped"
)]
GlobDenyIllegal {
facet: String,
medium_type: String,
anchor_namespace: &'static str,
},
#[error(
"facet '{facet}' declares preparation '{preparation}', which has no implementation \
(preparation impl version {impl_version})"
)]
PreparationUnsupported {
facet: String,
preparation: String,
impl_version: u32,
},
#[error(
"prune guarantee '{requested}' is unsupported for facet '{facet}' over a \
'{medium_type}' medium: its base leg is not retrievable, so only '{supported}' \
degradation is possible — set the binding's prune guarantee to '{supported}', or \
point the facet at a git-backed medium"
)]
PruneGuaranteeUnsupported {
facet: String,
medium_type: String,
requested: &'static str,
supported: &'static str,
},
}
pub fn validate_binding(resolved: &ResolvedBinding) -> Result<(), Vec<CapabilityError>> {
let mut refusals = Vec::new();
let has_deny = !resolved.binding.deny_paths.is_empty();
let sync_declared = resolved.binding.operations.sync.is_some();
let verify_declared = resolved.binding.operations.verify.is_some();
let requested_prune = resolved
.binding
.prune
.as_ref()
.map(|p| p.guarantee)
.filter(|g| *g == PruneGuarantee::NeverClobber);
for source in &resolved.primary_sources {
let caps = medium_capabilities(source.medium_type);
let medium_type = serde_json::to_value(source.medium_type)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_default();
if let Some(prep) = &source.preparation {
refusals.push(CapabilityError::PreparationUnsupported {
facet: source.facet_ref.clone(),
preparation: prep.clone(),
impl_version: PREPARATION_IMPL_VERSION,
});
}
if !caps.change_signal {
for (declared, op) in [
(sync_declared, Operation::Sync),
(verify_declared, Operation::Verify),
] {
if declared {
refusals.push(CapabilityError::OperationOutOfScope {
operation: op.name(),
facet: source.facet_ref.clone(),
medium_type: medium_type.clone(),
});
}
}
}
if has_deny && !caps.glob_deny_legal {
refusals.push(CapabilityError::GlobDenyIllegal {
facet: source.facet_ref.clone(),
medium_type: medium_type.clone(),
anchor_namespace: caps.anchor_namespace,
});
}
if requested_prune.is_some() && !caps.base_version_retrievable {
refusals.push(CapabilityError::PruneGuaranteeUnsupported {
facet: source.facet_ref.clone(),
medium_type: medium_type.clone(),
requested: PruneGuarantee::NeverClobber.as_wire(),
supported: prune_guarantee_for_medium(source.medium_type).as_wire(),
});
}
}
if refusals.is_empty() {
Ok(())
} else {
Err(refusals)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pipeline::PatternMode;
fn build_op() -> BuildOperation {
BuildOperation {
mode: BuildMode::Discovery,
trigger: IngestTrigger::Loop,
batch_size: 20,
post_actions: None,
}
}
fn binding() -> BindingV1 {
BindingV1 {
version: BINDING_VERSION,
intent: Some("prose for the agent".to_string()),
source_facets: vec!["source-tree".to_string()],
reference_mems: vec!["engine".to_string()],
destination_mem: "plugin".to_string(),
deny_paths: vec!["VISION.md".to_string(), "dev/**".to_string()],
coverage_semantics: CoverageSemantics::Exhaustive,
rules: Some(serde_json::json!({ "routing": "…" })),
prune: None,
operations: Operations {
build: Some(build_op()),
sync: Some(SyncOperation {
trigger: IngestTrigger::Manual,
batch_size: 20,
}),
verify: Some(VerifyOperation {
trigger: IngestTrigger::Manual,
batch_size: 20,
adjudication_cap: DEFAULT_ADJUDICATION_CAP,
full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
}),
},
}
}
fn allow(path: &str) -> PatternEntry {
PatternEntry {
path: path.to_string(),
mode: PatternMode::Allow,
}
}
fn primary(
facet: &str,
medium_type: MediumType,
pointer: &str,
scope: Vec<PatternEntry>,
preparation: Option<&str>,
change_detection: Option<&str>,
) -> ResolvedPrimarySource {
ResolvedPrimarySource {
facet_ref: facet.to_string(),
medium: "m".to_string(),
medium_type,
medium_pointer: pointer.to_string(),
declared_change_detection: change_detection.map(str::to_string),
scope,
preparation: preparation.map(str::to_string),
}
}
fn resolved(binding: BindingV1, sources: Vec<ResolvedPrimarySource>) -> ResolvedBinding {
ResolvedBinding {
binding,
primary_sources: sources,
}
}
fn one_codebase_source() -> Vec<ResolvedPrimarySource> {
vec![primary(
"source-tree",
MediumType::Codebase,
"../public",
vec![allow("../public/**/*.rs")],
None,
None,
)]
}
#[test]
fn binding_round_trips() {
let b = binding();
let json = serde_json::to_string(&b).unwrap();
let back: BindingV1 = serde_json::from_str(&json).unwrap();
assert_eq!(back, b);
}
#[test]
fn real_shaped_v1_json_deserializes() {
let src = r#"{
"version": 1,
"intent": "prose for the agent",
"source_facets": ["source-tree"],
"reference_mems": ["engine"],
"destination_mem": "plugin",
"deny_paths": ["VISION.md", "dev/**"],
"coverage_semantics": "exhaustive",
"rules": { "routing": "…" },
"operations": {
"build": { "mode": "discovery", "trigger": "loop", "batch_size": 20, "post_actions": { "archive_source": true } },
"sync": { "trigger": "manual", "batch_size": 20 },
"verify": { "trigger": "manual", "batch_size": 20 }
}
}"#;
let b: BindingV1 = serde_json::from_str(src).unwrap();
assert_eq!(b.version, 1);
assert_eq!(b.destination_mem, "plugin");
assert_eq!(b.coverage_semantics, CoverageSemantics::Exhaustive);
assert_eq!(
b.operations.build.as_ref().unwrap().mode,
BuildMode::Discovery
);
assert_eq!(
b.operations.build.as_ref().unwrap().trigger,
IngestTrigger::Loop
);
assert_eq!(
b.operations.build.as_ref().unwrap().post_actions,
Some(serde_json::json!({ "archive_source": true }))
);
assert!(b.operations.sync.is_some());
assert!(b.operations.verify.is_some());
}
#[test]
fn coverage_defaults_and_one_shot_wire_form() {
let src = r#"{
"version": 1,
"destination_mem": "m",
"operations": { "build": { "mode": "one-shot", "trigger": "manual", "batch_size": 5 } }
}"#;
let b: BindingV1 = serde_json::from_str(src).unwrap();
assert_eq!(b.coverage_semantics, CoverageSemantics::Exhaustive);
assert_eq!(
b.operations.build.as_ref().unwrap().mode,
BuildMode::OneShot
);
assert!(b.operations.sync.is_none());
assert!(b.operations.verify.is_none());
assert_eq!(
serde_json::to_string(&BuildMode::OneShot).unwrap(),
r#""one-shot""#
);
}
#[test]
fn verify_tier3_knobs_default_and_round_trip() {
let src = r#"{
"version": 1,
"destination_mem": "m",
"operations": {
"build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 },
"verify": { "trigger": "manual", "batch_size": 20 }
}
}"#;
let b: BindingV1 = serde_json::from_str(src).unwrap();
let v = b.operations.verify.as_ref().unwrap();
assert_eq!(v.adjudication_cap, DEFAULT_ADJUDICATION_CAP);
assert_eq!(v.full_resync_every, DEFAULT_FULL_RESYNC_EVERY);
let explicit = VerifyOperation {
trigger: IngestTrigger::Manual,
batch_size: 10,
adjudication_cap: 7,
full_resync_every: 3,
};
let json = serde_json::to_string(&explicit).unwrap();
let back: VerifyOperation = serde_json::from_str(&json).unwrap();
assert_eq!(back, explicit);
assert!(json.contains("adjudication_cap"));
assert!(json.contains("full_resync_every"));
}
#[test]
fn tier3_knobs_do_not_change_the_hash() {
let base = hash_binding(&resolved(binding(), one_codebase_source()));
let mut tuned = binding();
let v = tuned.operations.verify.as_mut().unwrap();
v.adjudication_cap = 999;
v.full_resync_every = 1;
assert_eq!(
base,
hash_binding(&resolved(tuned, one_codebase_source())),
"tier-3 verify knobs are excluded from hash(D)"
);
}
#[test]
fn refinement_mode_is_rejected() {
let src = r#"{
"version": 1,
"destination_mem": "m",
"operations": { "build": { "mode": "refinement", "trigger": "loop", "batch_size": 20 } }
}"#;
let err = serde_json::from_str::<BindingV1>(src).unwrap_err();
assert!(
err.to_string().contains("refinement") || err.to_string().contains("unknown variant"),
"unexpected error: {err}"
);
}
#[test]
fn version_is_required() {
let src = r#"{
"destination_mem": "m",
"operations": { "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 } }
}"#;
assert!(serde_json::from_str::<BindingV1>(src).is_err());
}
#[test]
fn hash_is_stable_and_recomputable() {
let r = resolved(binding(), one_codebase_source());
let h1 = hash_binding(&r);
let h2 = hash_binding(&r);
assert_eq!(h1, h2);
assert_eq!(h1.len(), 64);
assert!(
h1.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
);
}
#[test]
fn changing_a_facet_pattern_changes_the_hash() {
let base = hash_binding(&resolved(binding(), one_codebase_source()));
let changed = hash_binding(&resolved(
binding(),
vec![primary(
"source-tree",
MediumType::Codebase,
"../public",
vec![allow("../public/**/*.md")], None,
None,
)],
));
assert_ne!(base, changed);
}
#[test]
fn changing_a_medium_pointer_changes_the_hash() {
let base = hash_binding(&resolved(binding(), one_codebase_source()));
let changed = hash_binding(&resolved(
binding(),
vec![primary(
"source-tree",
MediumType::Codebase,
"../elsewhere", vec![allow("../public/**/*.rs")],
None,
None,
)],
));
assert_ne!(base, changed);
}
#[test]
fn scheduling_knobs_do_not_change_the_hash() {
let base = hash_binding(&resolved(binding(), one_codebase_source()));
let mut b_trigger = binding();
b_trigger.operations.build.as_mut().unwrap().trigger = IngestTrigger::Manual;
assert_eq!(
base,
hash_binding(&resolved(b_trigger, one_codebase_source())),
"trigger is excluded"
);
let mut b_batch = binding();
b_batch.operations.build.as_mut().unwrap().batch_size = 999;
assert_eq!(
base,
hash_binding(&resolved(b_batch, one_codebase_source())),
"batch_size is excluded"
);
let mut b_post = binding();
b_post.operations.build.as_mut().unwrap().post_actions =
Some(serde_json::json!({ "archive_source": false }));
assert_eq!(
base,
hash_binding(&resolved(b_post, one_codebase_source())),
"post_actions is excluded"
);
let mut b_sync = binding();
b_sync.operations.sync = None;
assert_eq!(
base,
hash_binding(&resolved(b_sync, one_codebase_source())),
"sync block is excluded"
);
}
#[test]
fn changing_build_mode_changes_the_hash() {
let base = hash_binding(&resolved(binding(), one_codebase_source()));
let mut b = binding();
b.operations.build.as_mut().unwrap().mode = BuildMode::OneShot;
assert_ne!(base, hash_binding(&resolved(b, one_codebase_source())));
}
#[test]
fn absent_build_deserializes_and_hashes() {
let src = r#"{
"version": 1,
"destination_mem": "m",
"operations": { "verify": { "trigger": "manual", "batch_size": 5 } }
}"#;
let b: BindingV1 = serde_json::from_str(src).unwrap();
assert!(b.operations.build.is_none(), "absent build parses to None");
let h = hash_binding(&resolved(b, one_codebase_source()));
assert_eq!(h.len(), 64);
}
#[test]
fn capability_matrix_matches_d6_table() {
let web = medium_capabilities(MediumType::Web);
assert!(!web.enumerable && !web.change_signal && !web.base_version_retrievable);
assert!(!web.glob_deny_legal);
assert_eq!(web.anchor_namespace, "url");
let graph = medium_capabilities(MediumType::Graph);
assert!(graph.enumerable && graph.change_signal && graph.base_version_retrievable);
assert!(!graph.glob_deny_legal, "graph namespace is not path-shaped");
assert_eq!(graph.anchor_namespace, "entity");
for ty in [
MediumType::Codebase,
MediumType::Filesystem,
MediumType::Git,
] {
let c = medium_capabilities(ty);
assert!(c.enumerable && c.change_signal && c.base_version_retrievable);
assert!(c.glob_deny_legal, "{ty:?} allows glob deny_paths");
}
assert_eq!(
medium_capabilities(MediumType::Git).anchor_namespace,
"path+commit"
);
}
#[test]
fn sync_and_verify_over_web_refuse() {
let mut b = binding();
b.deny_paths.clear();
let sources = vec![primary(
"web-facet",
MediumType::Web,
"https://example.com",
vec![],
None,
None,
)];
let errs = validate_binding(&resolved(b, sources)).unwrap_err();
let ops: Vec<&str> = errs
.iter()
.filter_map(|e| match e {
CapabilityError::OperationOutOfScope { operation, .. } => Some(*operation),
_ => None,
})
.collect();
assert!(ops.contains(&"sync"), "sync refused: {errs:?}");
assert!(ops.contains(&"verify"), "verify refused: {errs:?}");
}
#[test]
fn glob_deny_over_graph_refuses() {
let mut b = binding();
b.operations.sync = None;
b.operations.verify = None;
b.deny_paths = vec!["some/**".to_string()];
let sources = vec![primary(
"graph-facet",
MediumType::Graph,
"home",
vec![],
None,
None,
)];
let errs = validate_binding(&resolved(b, sources)).unwrap_err();
assert!(
errs.iter()
.any(|e| matches!(e, CapabilityError::GlobDenyIllegal { .. })),
"expected GlobDenyIllegal, got {errs:?}"
);
}
#[test]
fn declared_preparation_refuses() {
let mut b = binding();
b.operations.sync = None;
b.operations.verify = None;
b.deny_paths.clear();
let sources = vec![primary(
"manual-pages",
MediumType::Filesystem,
"../docs",
vec![],
Some("pdf-to-markdown"),
None,
)];
let errs = validate_binding(&resolved(b, sources)).unwrap_err();
assert!(
errs.iter().any(|e| matches!(
e,
CapabilityError::PreparationUnsupported { preparation, .. } if preparation == "pdf-to-markdown"
)),
"expected PreparationUnsupported, got {errs:?}"
);
}
#[test]
fn legal_combinations_validate_clean() {
for ty in [
MediumType::Codebase,
MediumType::Filesystem,
MediumType::Git,
] {
let sources = vec![primary(
"f",
ty,
"../src",
vec![allow("../src/**")],
None,
None,
)];
assert!(
validate_binding(&resolved(binding(), sources)).is_ok(),
"{ty:?} build+sync+verify should validate clean"
);
}
let mut graph_binding = binding();
graph_binding.deny_paths.clear();
let graph_sources = vec![primary("g", MediumType::Graph, "home", vec![], None, None)];
assert!(
validate_binding(&resolved(graph_binding, graph_sources)).is_ok(),
"graph build+sync+verify with no glob deny should validate clean"
);
}
#[test]
fn prune_block_is_additive_and_round_trips() {
let src = r#"{
"version": 1,
"destination_mem": "m",
"operations": { "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 } }
}"#;
let b: BindingV1 = serde_json::from_str(src).unwrap();
assert!(b.prune.is_none(), "absent prune parses to None");
let with_default = r#"{
"version": 1,
"destination_mem": "m",
"prune": {},
"operations": { "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 } }
}"#;
let b: BindingV1 = serde_json::from_str(with_default).unwrap();
assert_eq!(
b.prune.as_ref().unwrap().guarantee,
PruneGuarantee::ConflictFlag
);
let explicit = PruneConfig {
guarantee: PruneGuarantee::NeverClobber,
};
let json = serde_json::to_string(&explicit).unwrap();
assert!(json.contains("never-clobber"));
assert_eq!(
serde_json::from_str::<PruneConfig>(&json).unwrap(),
explicit
);
}
#[test]
fn prune_does_not_change_the_hash() {
let base = hash_binding(&resolved(binding(), one_codebase_source()));
let mut pruned = binding();
pruned.prune = Some(PruneConfig {
guarantee: PruneGuarantee::NeverClobber,
});
assert_eq!(
base,
hash_binding(&resolved(pruned, one_codebase_source())),
"prune policy is excluded from hash(D)"
);
}
#[test]
fn prune_guarantee_per_medium_matches_capability_matrix() {
for ty in [
MediumType::Codebase,
MediumType::Filesystem,
MediumType::Git,
MediumType::Graph,
] {
assert_eq!(
prune_guarantee_for_medium(ty),
PruneGuarantee::NeverClobber,
"{ty:?} can retrieve a base leg → never-clobber"
);
}
assert_eq!(
prune_guarantee_for_medium(MediumType::Web),
PruneGuarantee::ConflictFlag,
"web has no retrievable base leg → conflict-flag only"
);
}
#[test]
fn never_clobber_prune_over_web_refuses_with_remedy() {
let mut b = binding();
b.operations.sync = None; b.operations.verify = None;
b.deny_paths.clear();
b.prune = Some(PruneConfig {
guarantee: PruneGuarantee::NeverClobber,
});
let sources = vec![primary(
"web-facet",
MediumType::Web,
"https://example.com",
vec![],
None,
None,
)];
let errs = validate_binding(&resolved(b, sources)).unwrap_err();
let refusal = errs
.iter()
.find_map(|e| match e {
CapabilityError::PruneGuaranteeUnsupported {
requested,
supported,
..
} => Some((*requested, *supported)),
_ => None,
})
.expect("expected a PruneGuaranteeUnsupported refusal");
assert_eq!(refusal, ("never-clobber", "conflict-flag"));
let msg = errs
.iter()
.find(|e| matches!(e, CapabilityError::PruneGuaranteeUnsupported { .. }))
.unwrap()
.to_string();
assert!(
msg.contains("conflict-flag"),
"remedy names the downgrade: {msg}"
);
}
#[test]
fn prune_guarantee_supported_validates_clean() {
let mut nc = binding();
nc.prune = Some(PruneConfig {
guarantee: PruneGuarantee::NeverClobber,
});
assert!(validate_binding(&resolved(nc, one_codebase_source())).is_ok());
let mut cf = binding();
cf.operations.sync = None;
cf.operations.verify = None;
cf.deny_paths.clear();
cf.prune = Some(PruneConfig {
guarantee: PruneGuarantee::ConflictFlag,
});
let web = vec![primary(
"web-facet",
MediumType::Web,
"https://example.com",
vec![],
None,
None,
)];
assert!(validate_binding(&resolved(cf, web)).is_ok());
}
#[test]
fn web_build_only_validates_clean() {
let mut b = binding();
b.operations.sync = None;
b.operations.verify = None;
b.deny_paths.clear();
let sources = vec![primary(
"web-facet",
MediumType::Web,
"https://example.com",
vec![],
None,
None,
)];
assert!(validate_binding(&resolved(b, sources)).is_ok());
}
}