use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, Source};
pub const BINDING_VERSION: u32 = 2;
pub const PREPARATION_IMPL_VERSION: u32 = 0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CoverageSemantics {
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>,
}
pub const DEFAULT_SCAFFOLD_DENY_PATHS: &[&str] = &[
"**/.DS_Store",
"**/.git/**",
"**/node_modules/**",
"**/Thumbs.db",
];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Binding {
pub version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub intent: Option<String>,
#[serde(default)]
pub sources: Vec<Source>,
#[serde(default)]
pub reference_mems: Vec<String>,
pub destination_mem: String,
#[serde(default)]
pub deny_paths: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub coverage_semantics: Option<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(Serialize)]
struct HashSource<'a> {
source: &'a str,
patterns: &'a [PatternEntry],
preparation: &'a Option<String>,
preparation_impl_version: u32,
medium_type: MediumType,
pointer: &'a str,
change_detection: &'a Option<String>,
}
#[derive(Serialize)]
struct HashInput<'a> {
version: u32,
intent: &'a Option<String>,
sources: Vec<HashSource<'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(binding: &Binding) -> String {
let sources: Vec<HashSource<'_>> = binding
.sources
.iter()
.map(|s| HashSource {
source: &s.name,
patterns: &s.scope,
preparation: &s.preparation,
preparation_impl_version: PREPARATION_IMPL_VERSION,
medium_type: s.medium_type,
pointer: &s.pointer,
change_detection: &s.change_detection,
})
.collect();
let input = HashInput {
version: binding.version,
intent: &binding.intent,
sources,
reference_mems: &binding.reference_mems,
destination_mem: &binding.destination_mem,
deny_paths: &binding.deny_paths,
coverage_semantics: effective_coverage_semantics(binding).value,
rules: &binding.rules,
build_mode: 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());
crate::hex_lower(&digest)
}
#[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,
},
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EffectiveCoverage {
pub value: CoverageSemantics,
pub declared: bool,
}
pub fn effective_coverage_semantics(binding: &Binding) -> EffectiveCoverage {
if let Some(declared) = binding.coverage_semantics {
return EffectiveCoverage {
value: declared,
declared: true,
};
}
let all_enumerable = binding
.sources
.iter()
.all(|s| medium_capabilities(s.medium_type).enumerable);
EffectiveCoverage {
value: if all_enumerable {
CoverageSemantics::Exhaustive
} else {
CoverageSemantics::Curated
},
declared: 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("a source has an empty name: every source names itself (the name keys its state)")]
EmptySourceName,
#[error(
"duplicate source name '{name}': source names are unique within a binding \
(they key per-source sync/verify state)"
)]
DuplicateSourceName {
name: String,
},
#[error(
"operation '{operation}' is out of scope for source '{source_name}' over a '{medium_type}' \
medium: this medium has no change signal this cycle (deferred — operator decision 7)"
)]
OperationOutOfScope {
operation: &'static str,
source_name: String,
medium_type: String,
},
#[error(
"glob deny_paths are illegal for source '{source_name}' over a '{medium_type}' medium: its \
'{anchor_namespace}' namespace is not path-shaped"
)]
GlobDenyIllegal {
source_name: String,
medium_type: String,
anchor_namespace: &'static str,
},
#[error(
"source '{source_name}' declares preparation '{preparation}', which has no implementation \
(preparation impl version {impl_version})"
)]
PreparationUnsupported {
source_name: String,
preparation: String,
impl_version: u32,
},
#[error(
"coverage_semantics 'exhaustive' is unsupported for source '{source_name}' over a \
'{medium_type}' medium: its scope is not enumerable (S(D) is not computable), so \
exhaustive coverage cannot be asserted — declare 'curated', or omit the field to \
resolve per medium"
)]
CoverageExhaustiveUnsupported {
source_name: String,
medium_type: String,
},
#[error(
"prune guarantee '{requested}' is unsupported for source '{source_name}' 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 source at a git-backed medium"
)]
PruneGuaranteeUnsupported {
source_name: String,
medium_type: String,
requested: &'static str,
supported: &'static str,
},
}
pub fn validate_binding(binding: &Binding) -> Result<(), Vec<CapabilityError>> {
let mut refusals = Vec::new();
let has_deny = !binding.deny_paths.is_empty();
let sync_declared = binding.operations.sync.is_some();
let verify_declared = binding.operations.verify.is_some();
let requested_prune = binding
.prune
.as_ref()
.map(|p| p.guarantee)
.filter(|g| *g == PruneGuarantee::NeverClobber);
let mut seen_names: Vec<&str> = Vec::new();
for source in &binding.sources {
if source.name.is_empty() {
refusals.push(CapabilityError::EmptySourceName);
} else if seen_names.contains(&source.name.as_str()) {
refusals.push(CapabilityError::DuplicateSourceName {
name: source.name.clone(),
});
} else {
seen_names.push(&source.name);
}
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 {
source_name: source.name.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(),
source_name: source.name.clone(),
medium_type: medium_type.clone(),
});
}
}
}
if has_deny && !caps.glob_deny_legal {
refusals.push(CapabilityError::GlobDenyIllegal {
source_name: source.name.clone(),
medium_type: medium_type.clone(),
anchor_namespace: caps.anchor_namespace,
});
}
if binding.coverage_semantics == Some(CoverageSemantics::Exhaustive) && !caps.enumerable {
refusals.push(CapabilityError::CoverageExhaustiveUnsupported {
source_name: source.name.clone(),
medium_type: medium_type.clone(),
});
}
if requested_prune.is_some() && !caps.base_version_retrievable {
refusals.push(CapabilityError::PruneGuaranteeUnsupported {
source_name: source.name.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 allow(path: &str) -> PatternEntry {
PatternEntry {
path: path.to_string(),
mode: PatternMode::Allow,
}
}
fn source(
name: &str,
medium_type: MediumType,
pointer: &str,
scope: Vec<PatternEntry>,
preparation: Option<&str>,
change_detection: Option<&str>,
) -> Source {
Source {
name: name.to_string(),
medium_type,
pointer: pointer.to_string(),
change_detection: change_detection.map(str::to_string),
scope,
engagement: None,
preparation: preparation.map(str::to_string),
}
}
fn codebase_source() -> Source {
source(
"source-tree",
MediumType::Codebase,
"../public",
vec![allow("../public/**/*.rs")],
None,
None,
)
}
fn binding() -> Binding {
Binding {
version: BINDING_VERSION,
intent: Some("prose for the agent".to_string()),
sources: vec![codebase_source()],
reference_mems: vec!["engine".to_string()],
destination_mem: "plugin".to_string(),
deny_paths: vec!["VISION.md".to_string(), "dev/**".to_string()],
coverage_semantics: None,
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,
}),
},
}
}
#[test]
fn binding_round_trips() {
let b = binding();
let json = serde_json::to_string(&b).unwrap();
let back: Binding = serde_json::from_str(&json).unwrap();
assert_eq!(back, b);
}
#[test]
fn plan_shaped_v2_json_deserializes() {
let src = r#"{
"version": 2,
"intent": "prose the building agent reads before every run",
"sources": [
{
"name": "source-tree",
"type": "codebase",
"pointer": "../public",
"change_detection": "auto",
"scope": [
{ "path": "../public/**/*.rs", "mode": "allow" },
{ "path": "../public/target/**", "mode": "deny" }
]
}
],
"reference_mems": ["engineering"],
"destination_mem": "engine",
"deny_paths": ["../dev/**"],
"coverage_semantics": "exhaustive",
"operations": {
"build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 },
"sync": { "trigger": "loop", "batch_size": 20 },
"verify": { "trigger": "loop", "batch_size": 20,
"adjudication_cap": 50, "full_resync_every": 20 }
}
}"#;
let b: Binding = serde_json::from_str(src).unwrap();
assert_eq!(b.version, 2);
assert_eq!(b.destination_mem, "engine");
assert_eq!(b.sources.len(), 1);
let s = &b.sources[0];
assert_eq!(s.name, "source-tree");
assert_eq!(s.medium_type, MediumType::Codebase);
assert_eq!(s.pointer, "../public");
assert_eq!(s.change_detection.as_deref(), Some("auto"));
assert_eq!(s.scope.len(), 2);
assert_eq!(b.reference_mems, vec!["engineering".to_string()]);
assert_eq!(b.coverage_semantics, Some(CoverageSemantics::Exhaustive));
assert_eq!(
b.operations.build.as_ref().unwrap().mode,
BuildMode::Discovery
);
assert!(b.operations.sync.is_some());
assert_eq!(b.operations.verify.as_ref().unwrap().adjudication_cap, 50);
}
#[test]
fn coverage_defaults_and_one_shot_wire_form() {
let src = r#"{
"version": 2,
"destination_mem": "m",
"operations": { "build": { "mode": "one-shot", "trigger": "manual", "batch_size": 5 } }
}"#;
let b: Binding = serde_json::from_str(src).unwrap();
assert_eq!(b.coverage_semantics, None, "absent = not stated");
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": 2,
"destination_mem": "m",
"operations": {
"build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 },
"verify": { "trigger": "manual", "batch_size": 20 }
}
}"#;
let b: Binding = 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(&binding());
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(&tuned),
"tier-3 verify knobs are excluded from hash(D)"
);
}
#[test]
fn refinement_mode_is_rejected() {
let src = r#"{
"version": 2,
"destination_mem": "m",
"operations": { "build": { "mode": "refinement", "trigger": "loop", "batch_size": 20 } }
}"#;
let err = serde_json::from_str::<Binding>(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::<Binding>(src).is_err());
}
#[test]
fn hash_is_stable_and_recomputable() {
let b = binding();
let h1 = hash_binding(&b);
let h2 = hash_binding(&b);
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_source_pattern_changes_the_hash() {
let base = hash_binding(&binding());
let mut changed = binding();
changed.sources[0].scope = vec![allow("../public/**/*.md")];
assert_ne!(base, hash_binding(&changed));
}
#[test]
fn changing_a_source_pointer_changes_the_hash() {
let base = hash_binding(&binding());
let mut changed = binding();
changed.sources[0].pointer = "../elsewhere".to_string();
assert_ne!(base, hash_binding(&changed));
}
#[test]
fn scheduling_knobs_do_not_change_the_hash() {
let base = hash_binding(&binding());
let mut b_trigger = binding();
b_trigger.operations.build.as_mut().unwrap().trigger = IngestTrigger::Manual;
assert_eq!(base, hash_binding(&b_trigger), "trigger is excluded");
let mut b_batch = binding();
b_batch.operations.build.as_mut().unwrap().batch_size = 999;
assert_eq!(base, hash_binding(&b_batch), "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(&b_post), "post_actions is excluded");
let mut b_sync = binding();
b_sync.operations.sync = None;
assert_eq!(base, hash_binding(&b_sync), "sync block is excluded");
let mut b_engage = binding();
b_engage.sources[0].engagement = Some(serde_json::json!({ "readVerb": "Study" }));
assert_eq!(base, hash_binding(&b_engage), "engagement is excluded");
}
#[test]
fn changing_build_mode_changes_the_hash() {
let base = hash_binding(&binding());
let mut b = binding();
b.operations.build.as_mut().unwrap().mode = BuildMode::OneShot;
assert_ne!(base, hash_binding(&b));
}
#[test]
fn absent_build_deserializes_and_hashes() {
let src = r#"{
"version": 2,
"destination_mem": "m",
"operations": { "verify": { "trigger": "manual", "batch_size": 5 } }
}"#;
let b: Binding = serde_json::from_str(src).unwrap();
assert!(b.operations.build.is_none(), "absent build parses to None");
let h = hash_binding(&b);
assert_eq!(h.len(), 64);
}
#[test]
fn capability_matrix_rows() {
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 empty_and_duplicate_source_names_refuse() {
let mut b = binding();
b.deny_paths.clear();
b.sources = vec![
source("", MediumType::Codebase, "../a", vec![], None, None),
source("dup", MediumType::Codebase, "../b", vec![], None, None),
source("dup", MediumType::Codebase, "../c", vec![], None, None),
];
let errs = validate_binding(&b).unwrap_err();
assert!(
errs.iter()
.any(|e| matches!(e, CapabilityError::EmptySourceName)),
"expected EmptySourceName, got {errs:?}"
);
assert!(
errs.iter().any(|e| matches!(
e,
CapabilityError::DuplicateSourceName { name } if name == "dup"
)),
"expected DuplicateSourceName, got {errs:?}"
);
}
#[test]
fn sync_and_verify_over_web_refuse() {
let mut b = binding();
b.deny_paths.clear();
b.sources = vec![source(
"web-source",
MediumType::Web,
"https://example.com",
vec![],
None,
None,
)];
let errs = validate_binding(&b).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()];
b.sources = vec![source(
"graph-source",
MediumType::Graph,
"home",
vec![],
None,
None,
)];
let errs = validate_binding(&b).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();
b.sources = vec![source(
"manual-pages",
MediumType::Filesystem,
"../docs",
vec![],
Some("pdf-to-markdown"),
None,
)];
let errs = validate_binding(&b).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 mut b = binding();
b.sources = vec![source(
"f",
ty,
"../src",
vec![allow("../src/**")],
None,
None,
)];
assert!(
validate_binding(&b).is_ok(),
"{ty:?} build+sync+verify should validate clean"
);
}
let mut graph_binding = binding();
graph_binding.deny_paths.clear();
graph_binding.sources = vec![source("g", MediumType::Graph, "home", vec![], None, None)];
assert!(
validate_binding(&graph_binding).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": 2,
"destination_mem": "m",
"operations": { "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 } }
}"#;
let b: Binding = serde_json::from_str(src).unwrap();
assert!(b.prune.is_none(), "absent prune parses to None");
let with_default = r#"{
"version": 2,
"destination_mem": "m",
"prune": {},
"operations": { "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 } }
}"#;
let b: Binding = 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(&binding());
let mut pruned = binding();
pruned.prune = Some(PruneConfig {
guarantee: PruneGuarantee::NeverClobber,
});
assert_eq!(
base,
hash_binding(&pruned),
"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,
});
b.sources = vec![source(
"web-source",
MediumType::Web,
"https://example.com",
vec![],
None,
None,
)];
let errs = validate_binding(&b).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(&nc).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,
});
cf.sources = vec![source(
"web-source",
MediumType::Web,
"https://example.com",
vec![],
None,
None,
)];
assert!(validate_binding(&cf).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();
b.sources = vec![source(
"web-source",
MediumType::Web,
"https://example.com",
vec![],
None,
None,
)];
assert!(validate_binding(&b).is_ok());
}
fn web_source(name: &str) -> Source {
source(
name,
MediumType::Web,
"https://example.test",
vec![allow("**/*")],
None,
None,
)
}
#[test]
fn coverage_resolves_per_medium_when_undeclared() {
let enumerable = binding();
assert_eq!(enumerable.coverage_semantics, None);
let eff = effective_coverage_semantics(&enumerable);
assert_eq!(eff.value, CoverageSemantics::Exhaustive);
assert!(!eff.declared, "resolved, not declared");
validate_binding(&enumerable).expect("undeclared over enumerable validates");
let mut mixed = binding();
mixed.sources.push(web_source("front"));
mixed.operations.sync = None;
mixed.operations.verify = None;
mixed.deny_paths.clear();
let eff = effective_coverage_semantics(&mixed);
assert_eq!(eff.value, CoverageSemantics::Curated);
assert!(!eff.declared);
validate_binding(&mixed).expect("undeclared over web validates (resolves, never refuses)");
let mut curated = mixed.clone();
curated.coverage_semantics = Some(CoverageSemantics::Curated);
validate_binding(&curated).expect("explicit curated validates over any medium");
let eff = effective_coverage_semantics(&curated);
assert_eq!(eff.value, CoverageSemantics::Curated);
assert!(eff.declared);
}
#[test]
fn explicit_exhaustive_over_non_enumerable_refuses() {
let mut only = binding();
only.sources = vec![web_source("front")];
only.operations.sync = None;
only.operations.verify = None;
only.deny_paths.clear();
only.coverage_semantics = Some(CoverageSemantics::Exhaustive);
let errs = validate_binding(&only).expect_err("must refuse");
assert_eq!(errs.len(), 1, "only this refusal: {errs:?}");
match &errs[0] {
CapabilityError::CoverageExhaustiveUnsupported {
source_name,
medium_type,
} => {
assert_eq!(source_name, "front");
assert_eq!(medium_type, "web");
}
other => panic!("expected CoverageExhaustiveUnsupported, got {other:?}"),
}
let msg = errs[0].to_string();
assert!(
msg.contains("'front'") && msg.contains("'web'") && msg.contains("curated"),
"refusal names source, medium, and the curated remedy: {msg}"
);
let mut multi = binding();
multi.sources = vec![web_source("front")];
multi.operations.verify = None;
multi.deny_paths.clear();
multi.coverage_semantics = Some(CoverageSemantics::Exhaustive);
assert!(multi.operations.sync.is_some(), "fixture declares sync");
let errs = validate_binding(&multi).expect_err("must refuse");
assert!(
errs.iter()
.any(|e| matches!(e, CapabilityError::CoverageExhaustiveUnsupported { .. })),
"coverage refusal present: {errs:?}"
);
assert!(
errs.iter()
.any(|e| matches!(e, CapabilityError::OperationOutOfScope { .. })),
"reported alongside the sync refusal, not replacing it: {errs:?}"
);
let mut ok = binding();
ok.coverage_semantics = Some(CoverageSemantics::Exhaustive);
validate_binding(&ok).expect("explicit exhaustive over enumerable validates");
}
#[test]
fn hash_serialises_the_resolved_coverage_value() {
let undeclared = binding();
let mut declared = binding();
declared.coverage_semantics = Some(CoverageSemantics::Exhaustive);
assert_eq!(
hash_binding(&undeclared),
hash_binding(&declared),
"undeclared over enumerable keeps the pre-optionality hash"
);
let mut curated = binding();
curated.coverage_semantics = Some(CoverageSemantics::Curated);
assert_ne!(hash_binding(&undeclared), hash_binding(&curated));
let mut web_undeclared = binding();
web_undeclared.sources = vec![web_source("front")];
let mut web_curated = web_undeclared.clone();
web_curated.coverage_semantics = Some(CoverageSemantics::Curated);
assert_eq!(
hash_binding(&web_undeclared),
hash_binding(&web_curated),
"undeclared over web resolves (and hashes) as curated"
);
}
}