use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub const ANCHOR_SIDECAR_PATH: &str = ".memstead/anchors.json";
pub const ANCHOR_SIDECAR_VERSION: u32 = 1;
pub const INVALID_ANCHOR_CODE: &str = "INVALID_ANCHOR";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AnchorProvenanceClass {
Anchored,
Derived,
Authored,
InformedBy,
}
impl AnchorProvenanceClass {
pub const WIRE_VALUES: &'static [&'static str] =
&["anchored", "derived", "authored", "informed-by"];
pub fn as_wire(&self) -> &'static str {
match self {
AnchorProvenanceClass::Anchored => "anchored",
AnchorProvenanceClass::Derived => "derived",
AnchorProvenanceClass::Authored => "authored",
AnchorProvenanceClass::InformedBy => "informed-by",
}
}
pub fn from_wire(s: &str) -> Option<Self> {
match s {
"anchored" => Some(AnchorProvenanceClass::Anchored),
"derived" => Some(AnchorProvenanceClass::Derived),
"authored" => Some(AnchorProvenanceClass::Authored),
"informed-by" => Some(AnchorProvenanceClass::InformedBy),
_ => None,
}
}
pub fn is_hash_bearing(&self) -> bool {
matches!(
self,
AnchorProvenanceClass::Anchored | AnchorProvenanceClass::Derived
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AnchorGrain {
Span,
File,
Tree,
Url,
Entity,
}
impl AnchorGrain {
pub const WIRE_VALUES: &'static [&'static str] = &["span", "file", "tree", "url", "entity"];
pub fn as_wire(&self) -> &'static str {
match self {
AnchorGrain::Span => "span",
AnchorGrain::File => "file",
AnchorGrain::Tree => "tree",
AnchorGrain::Url => "url",
AnchorGrain::Entity => "entity",
}
}
pub fn from_wire(s: &str) -> Option<Self> {
match s {
"span" => Some(AnchorGrain::Span),
"file" => Some(AnchorGrain::File),
"tree" => Some(AnchorGrain::Tree),
"url" => Some(AnchorGrain::Url),
"entity" => Some(AnchorGrain::Entity),
_ => None,
}
}
pub fn supported_by_namespace(&self, anchor_namespace: &str) -> bool {
let path_shaped = matches!(anchor_namespace, "path" | "path+commit");
match self {
AnchorGrain::Span | AnchorGrain::File | AnchorGrain::Tree => path_shaped,
AnchorGrain::Url => anchor_namespace == "url",
AnchorGrain::Entity => anchor_namespace == "entity",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AnchorHashStability {
Stable,
Unstable,
}
impl AnchorHashStability {
pub const WIRE_VALUES: &'static [&'static str] = &["stable", "unstable"];
pub fn as_wire(&self) -> &'static str {
match self {
AnchorHashStability::Stable => "stable",
AnchorHashStability::Unstable => "unstable",
}
}
pub fn from_wire(s: &str) -> Option<Self> {
match s {
"stable" => Some(AnchorHashStability::Stable),
"unstable" => Some(AnchorHashStability::Unstable),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "lowercase")]
pub enum AnchorVersion {
Commit(String),
Snapshot(String),
Etag(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Anchor {
pub artifact: String,
pub grain: AnchorGrain,
pub class: AnchorProvenanceClass,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub at_version: Option<AnchorVersion>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hash: Option<String>,
pub hash_stability: AnchorHashStability,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub derived_from: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub binding: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AnchorInput {
#[serde(default)]
pub artifact: Option<String>,
#[serde(default)]
pub grain: Option<String>,
#[serde(default)]
pub class: Option<String>,
#[serde(default)]
pub at_version: Option<AnchorVersion>,
#[serde(default)]
pub hash: Option<String>,
#[serde(default)]
pub hash_stability: Option<String>,
#[serde(default)]
pub derived_from: Option<Vec<String>>,
#[serde(default)]
pub binding: Option<String>,
#[serde(default)]
pub source: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AnchorUnsetInput {
#[serde(default)]
pub artifact: Option<String>,
#[serde(default)]
pub grain: Option<String>,
#[serde(default)]
pub class: Option<String>,
}
impl AnchorUnsetInput {
pub fn validate(&self) -> Result<AnchorUnset, AnchorValidationError> {
let artifact = self
.artifact
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.ok_or(AnchorValidationError::MissingArtifact)?;
let grain = match self.grain.as_deref() {
None => None,
Some(s) => Some(AnchorGrain::from_wire(s).ok_or_else(|| {
AnchorValidationError::UnknownGrain {
got: Some(s.to_string()),
allowed: AnchorGrain::WIRE_VALUES,
}
})?),
};
let class = match self.class.as_deref() {
None => None,
Some(s) => Some(AnchorProvenanceClass::from_wire(s).ok_or_else(|| {
AnchorValidationError::UnknownClass {
got: Some(s.to_string()),
allowed: AnchorProvenanceClass::WIRE_VALUES,
}
})?),
};
Ok(AnchorUnset {
artifact,
grain,
class,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AnchorUnset {
pub artifact: String,
pub grain: Option<AnchorGrain>,
pub class: Option<AnchorProvenanceClass>,
}
impl AnchorUnset {
pub fn matches(&self, anchor: &Anchor) -> bool {
anchor.artifact == self.artifact
&& self.grain.is_none_or(|g| anchor.grain == g)
&& self.class.is_none_or(|c| anchor.class == c)
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AnchorValidationError {
#[error("unknown anchor provenance class {got:?}; allowed: {}", allowed.join(", "))]
UnknownClass {
got: Option<String>,
allowed: &'static [&'static str],
},
#[error("unknown anchor grain {got:?}; allowed: {}", allowed.join(", "))]
UnknownGrain {
got: Option<String>,
allowed: &'static [&'static str],
},
#[error("unknown anchor hash stability {got:?}; allowed: {}", allowed.join(", "))]
UnknownHashStability {
got: String,
allowed: &'static [&'static str],
},
#[error("anchor is missing its artifact reference")]
MissingArtifact,
#[error("anchor class '{class}' carries no hash semantics — a content hash is not permitted")]
HashOnNonHashClass { class: &'static str },
#[error("anchor `source`, when present, must be a non-empty source name")]
EmptySource,
#[error(
"anchor `source` {got:?} is not declared by the anchor's producing binding; declared sources: {}",
declared.join(", ")
)]
SourceNotDeclared { got: String, declared: Vec<String> },
#[error(
"anchor grain '{grain}' is unsupported by a '{medium_type}' medium: its \
'{anchor_namespace}' namespace does not admit that grain"
)]
GrainNamespaceUnsupported {
grain: &'static str,
medium_type: String,
anchor_namespace: &'static str,
},
}
impl AnchorValidationError {
pub fn code(&self) -> &'static str {
INVALID_ANCHOR_CODE
}
pub fn detail(&self) -> BTreeMap<String, serde_json::Value> {
let mut d = BTreeMap::new();
match self {
AnchorValidationError::UnknownClass { got, allowed } => {
d.insert("field".into(), "class".into());
d.insert("got".into(), serde_json::json!(got));
d.insert("allowed".into(), serde_json::json!(allowed));
}
AnchorValidationError::UnknownGrain { got, allowed } => {
d.insert("field".into(), "grain".into());
d.insert("got".into(), serde_json::json!(got));
d.insert("allowed".into(), serde_json::json!(allowed));
}
AnchorValidationError::UnknownHashStability { got, allowed } => {
d.insert("field".into(), "hash_stability".into());
d.insert("got".into(), serde_json::json!(got));
d.insert("allowed".into(), serde_json::json!(allowed));
}
AnchorValidationError::MissingArtifact => {
d.insert("field".into(), "artifact".into());
}
AnchorValidationError::EmptySource => {
d.insert("field".into(), "source".into());
}
AnchorValidationError::SourceNotDeclared { got, declared } => {
d.insert("field".into(), "source".into());
d.insert("got".into(), serde_json::json!(got));
d.insert("declared".into(), serde_json::json!(declared));
}
AnchorValidationError::HashOnNonHashClass { class } => {
d.insert("field".into(), "hash".into());
d.insert("class".into(), serde_json::json!(class));
}
AnchorValidationError::GrainNamespaceUnsupported {
grain,
medium_type,
anchor_namespace,
} => {
d.insert("field".into(), "grain".into());
d.insert("grain".into(), serde_json::json!(grain));
d.insert("medium_type".into(), serde_json::json!(medium_type));
d.insert(
"anchor_namespace".into(),
serde_json::json!(anchor_namespace),
);
}
}
d
}
}
impl AnchorInput {
pub fn validate(&self, medium: Option<(&str, &str)>) -> Result<Anchor, AnchorValidationError> {
let class = match self
.class
.as_deref()
.and_then(AnchorProvenanceClass::from_wire)
{
Some(c) => c,
None => {
return Err(AnchorValidationError::UnknownClass {
got: self.class.clone(),
allowed: AnchorProvenanceClass::WIRE_VALUES,
});
}
};
let grain = match self.grain.as_deref().and_then(AnchorGrain::from_wire) {
Some(g) => g,
None => {
return Err(AnchorValidationError::UnknownGrain {
got: self.grain.clone(),
allowed: AnchorGrain::WIRE_VALUES,
});
}
};
let artifact = self
.artifact
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.ok_or(AnchorValidationError::MissingArtifact)?;
let hash_stability = match self.hash_stability.as_deref() {
None => AnchorHashStability::Stable,
Some(s) => AnchorHashStability::from_wire(s).ok_or_else(|| {
AnchorValidationError::UnknownHashStability {
got: s.to_string(),
allowed: AnchorHashStability::WIRE_VALUES,
}
})?,
};
let hash = self
.hash
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
if hash.is_some() && !class.is_hash_bearing() {
return Err(AnchorValidationError::HashOnNonHashClass {
class: class.as_wire(),
});
}
if let Some((medium_type, namespace)) = medium
&& !grain.supported_by_namespace(namespace)
{
let anchor_namespace = match namespace {
"path" => "path",
"path+commit" => "path+commit",
"entity" => "entity",
"url" => "url",
_ => "path",
};
return Err(AnchorValidationError::GrainNamespaceUnsupported {
grain: grain.as_wire(),
medium_type: medium_type.to_string(),
anchor_namespace,
});
}
let source = match self.source.as_deref() {
None => None,
Some(raw) => {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(AnchorValidationError::EmptySource);
}
Some(trimmed.to_string())
}
};
Ok(Anchor {
artifact,
grain,
class,
at_version: self.at_version.clone(),
hash,
hash_stability,
derived_from: self.derived_from.clone().unwrap_or_default(),
binding: self
.binding
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string),
source,
})
}
}
pub fn prepared_content_hash(bytes: &[u8]) -> String {
use sha2::{Digest as _, Sha256};
let digest = match std::str::from_utf8(bytes) {
Ok(text) => {
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
Sha256::digest(normalized.trim_end_matches('\n').as_bytes())
}
Err(_) => Sha256::digest(bytes),
};
crate::hex_lower(&digest)[..16].to_string()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ObservedArtifactHash {
pub entity: String,
pub artifact: String,
pub hash: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AnchorState {
Resolves,
Drifted,
Recheck,
Orphaned,
}
impl AnchorState {
pub fn as_wire(&self) -> &'static str {
match self {
AnchorState::Resolves => "resolves",
AnchorState::Drifted => "drifted",
AnchorState::Recheck => "recheck",
AnchorState::Orphaned => "orphaned",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ArtifactObservation {
Absent,
Present { current_hash: Option<String> },
}
pub fn resolve_anchor(anchor: &Anchor, observation: &ArtifactObservation) -> AnchorState {
let current_hash = match observation {
ArtifactObservation::Absent => return AnchorState::Orphaned,
ArtifactObservation::Present { current_hash } => current_hash,
};
if !anchor.class.is_hash_bearing() {
return AnchorState::Resolves;
}
match (&anchor.hash, current_hash) {
(Some(recorded), Some(current)) if recorded == current => AnchorState::Resolves,
(Some(_), Some(_)) => match anchor.hash_stability {
AnchorHashStability::Stable => AnchorState::Drifted,
AnchorHashStability::Unstable => AnchorState::Recheck,
},
_ => AnchorState::Recheck,
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct EntityAnchorComposition {
pub by_class: BTreeMap<String, usize>,
pub by_grain: BTreeMap<String, usize>,
pub derived_inputs: Vec<Vec<String>>,
pub tree_grain_artifacts: Vec<String>,
}
pub fn compose_entity_anchors(anchors: &[Anchor]) -> EntityAnchorComposition {
let mut comp = EntityAnchorComposition::default();
for a in anchors {
*comp
.by_class
.entry(a.class.as_wire().to_string())
.or_insert(0) += 1;
*comp
.by_grain
.entry(a.grain.as_wire().to_string())
.or_insert(0) += 1;
if a.class == AnchorProvenanceClass::Derived {
comp.derived_inputs.push(a.derived_from.clone());
}
if a.grain == AnchorGrain::Tree {
comp.tree_grain_artifacts.push(a.artifact.clone());
}
}
comp
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AnchorSidecar {
pub version: u32,
#[serde(default)]
pub entities: BTreeMap<String, Vec<Anchor>>,
}
impl Default for AnchorSidecar {
fn default() -> Self {
Self {
version: ANCHOR_SIDECAR_VERSION,
entities: BTreeMap::new(),
}
}
}
impl AnchorSidecar {
pub fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
if bytes.iter().all(u8::is_ascii_whitespace) {
return Ok(Self::default());
}
serde_json::from_slice(bytes)
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut s = serde_json::to_string_pretty(self).expect("anchor sidecar serialises");
s.push('\n');
s.into_bytes()
}
pub fn get(&self, entity_id: &str) -> &[Anchor] {
self.entities
.get(entity_id)
.map(Vec::as_slice)
.unwrap_or(&[])
}
pub fn set(&mut self, entity_id: &str, anchors: Vec<Anchor>) {
if anchors.is_empty() {
self.entities.remove(entity_id);
} else {
self.entities.insert(entity_id.to_string(), anchors);
}
}
pub fn merge(&mut self, entity_id: &str, unsets: &[AnchorUnset], incoming: Vec<Anchor>) {
let mut row = self.entities.remove(entity_id).unwrap_or_default();
row.retain(|a| !unsets.iter().any(|u| u.matches(a)));
for anchor in incoming {
match row.iter_mut().find(|e| {
e.artifact == anchor.artifact && e.grain == anchor.grain && e.class == anchor.class
}) {
Some(existing) => *existing = anchor,
None => row.push(anchor),
}
}
if !row.is_empty() {
self.entities.insert(entity_id.to_string(), row);
}
}
pub fn remove(&mut self, entity_id: &str) {
self.entities.remove(entity_id);
}
pub fn rename(&mut self, from: &str, to: &str) {
if let Some(anchors) = self.entities.remove(from) {
self.entities.insert(to.to_string(), anchors);
}
}
pub fn is_empty(&self) -> bool {
self.entities.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn class_wire_strings_are_stable() {
assert_eq!(AnchorProvenanceClass::Anchored.as_wire(), "anchored");
assert_eq!(AnchorProvenanceClass::Derived.as_wire(), "derived");
assert_eq!(AnchorProvenanceClass::Authored.as_wire(), "authored");
assert_eq!(AnchorProvenanceClass::InformedBy.as_wire(), "informed-by");
for w in AnchorProvenanceClass::WIRE_VALUES {
assert_eq!(AnchorProvenanceClass::from_wire(w).unwrap().as_wire(), *w);
}
assert!(AnchorProvenanceClass::from_wire("bogus").is_none());
}
#[test]
fn grain_wire_strings_are_stable() {
for w in AnchorGrain::WIRE_VALUES {
assert_eq!(AnchorGrain::from_wire(w).unwrap().as_wire(), *w);
}
assert_eq!(
AnchorGrain::WIRE_VALUES,
&["span", "file", "tree", "url", "entity"]
);
assert!(AnchorGrain::from_wire("chunk").is_none());
}
#[test]
fn stability_and_state_wire_strings_are_stable() {
assert_eq!(AnchorHashStability::Stable.as_wire(), "stable");
assert_eq!(AnchorHashStability::Unstable.as_wire(), "unstable");
assert_eq!(AnchorState::Resolves.as_wire(), "resolves");
assert_eq!(AnchorState::Drifted.as_wire(), "drifted");
assert_eq!(AnchorState::Recheck.as_wire(), "recheck");
assert_eq!(AnchorState::Orphaned.as_wire(), "orphaned");
}
#[test]
fn only_anchored_and_derived_are_hash_bearing() {
assert!(AnchorProvenanceClass::Anchored.is_hash_bearing());
assert!(AnchorProvenanceClass::Derived.is_hash_bearing());
assert!(!AnchorProvenanceClass::Authored.is_hash_bearing());
assert!(!AnchorProvenanceClass::InformedBy.is_hash_bearing());
}
#[test]
fn grain_namespace_support_matches_capability_matrix() {
for g in [AnchorGrain::Span, AnchorGrain::File, AnchorGrain::Tree] {
assert!(g.supported_by_namespace("path"));
assert!(g.supported_by_namespace("path+commit"));
assert!(!g.supported_by_namespace("url"));
assert!(!g.supported_by_namespace("entity"));
}
assert!(AnchorGrain::Url.supported_by_namespace("url"));
assert!(!AnchorGrain::Url.supported_by_namespace("path"));
assert!(AnchorGrain::Entity.supported_by_namespace("entity"));
assert!(!AnchorGrain::Entity.supported_by_namespace("path"));
}
fn valid_input() -> AnchorInput {
AnchorInput {
artifact: Some("src/lib.rs".into()),
grain: Some("file".into()),
class: Some("anchored".into()),
hash_stability: Some("stable".into()),
hash: Some("abc123".into()),
..Default::default()
}
}
#[test]
fn validate_accepts_a_well_formed_anchor() {
let a = valid_input().validate(Some(("codebase", "path"))).unwrap();
assert_eq!(a.artifact, "src/lib.rs");
assert_eq!(a.grain, AnchorGrain::File);
assert_eq!(a.class, AnchorProvenanceClass::Anchored);
assert_eq!(a.hash.as_deref(), Some("abc123"));
assert_eq!(a.hash_stability, AnchorHashStability::Stable);
}
#[test]
fn validate_defaults_hash_stability_to_stable() {
let mut i = valid_input();
i.hash_stability = None;
let a = i.validate(None).unwrap();
assert_eq!(a.hash_stability, AnchorHashStability::Stable);
}
#[test]
fn validate_refuses_unknown_class() {
let mut i = valid_input();
i.class = Some("guessed".into());
let err = i.validate(None).unwrap_err();
assert_eq!(err.code(), INVALID_ANCHOR_CODE);
assert!(matches!(err, AnchorValidationError::UnknownClass { .. }));
assert_eq!(err.detail()["field"], serde_json::json!("class"));
}
#[test]
fn validate_refuses_unknown_grain() {
let mut i = valid_input();
i.grain = Some("paragraph".into());
let err = i.validate(None).unwrap_err();
assert!(matches!(err, AnchorValidationError::UnknownGrain { .. }));
}
#[test]
fn validate_refuses_missing_artifact() {
let mut i = valid_input();
i.artifact = Some(" ".into());
let err = i.validate(None).unwrap_err();
assert!(matches!(err, AnchorValidationError::MissingArtifact));
i.artifact = None;
assert!(matches!(
valid_input_with_artifact(None).validate(None).unwrap_err(),
AnchorValidationError::MissingArtifact
));
let _ = i;
}
fn valid_input_with_artifact(a: Option<String>) -> AnchorInput {
AnchorInput {
artifact: a,
..valid_input()
}
}
#[test]
fn validate_refuses_hash_on_non_hash_class() {
let mut i = valid_input();
i.class = Some("authored".into());
let err = i.validate(None).unwrap_err();
assert!(matches!(
err,
AnchorValidationError::HashOnNonHashClass { class: "authored" }
));
}
#[test]
fn validate_accepts_non_hash_class_without_hash() {
let mut i = valid_input();
i.class = Some("informed-by".into());
i.hash = None;
let a = i.validate(None).unwrap();
assert_eq!(a.class, AnchorProvenanceClass::InformedBy);
assert!(a.hash.is_none());
}
#[test]
fn validate_refuses_grain_unsupported_by_medium_namespace() {
let mut i = valid_input();
i.grain = Some("span".into());
i.class = Some("authored".into());
i.hash = None;
let err = i.validate(Some(("web", "url"))).unwrap_err();
match err {
AnchorValidationError::GrainNamespaceUnsupported {
grain,
anchor_namespace,
..
} => {
assert_eq!(grain, "span");
assert_eq!(anchor_namespace, "url");
}
other => panic!("expected GrainNamespaceUnsupported, got {other:?}"),
}
}
#[test]
fn validate_skips_namespace_check_without_medium_context() {
let mut i = valid_input();
i.grain = Some("span".into());
assert!(i.validate(None).is_ok());
}
#[test]
fn prepared_hash_is_stable_across_byte_noise() {
let base = prepared_content_hash(b"fn a() {}\nfn b() {}\n");
assert_eq!(prepared_content_hash(b"fn a() {}\r\nfn b() {}\r\n"), base);
assert_eq!(prepared_content_hash(b"fn a() {}\rfn b() {}\r"), base);
assert_eq!(prepared_content_hash(b"fn a() {}\nfn b() {}"), base);
assert_eq!(prepared_content_hash(b"fn a() {}\nfn b() {}\n\n\n"), base);
assert_eq!(
prepared_content_hash("\u{feff}fn a() {}\nfn b() {}\n".as_bytes()),
base
);
assert_ne!(prepared_content_hash(b"fn a() {}\nfn c() {}\n"), base);
assert_eq!(base.len(), 16);
assert!(
base.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
);
}
#[test]
fn prepared_hash_preserves_interior_whitespace() {
assert_ne!(
prepared_content_hash(b"line one \nline two\n"),
prepared_content_hash(b"line one\nline two\n")
);
}
#[test]
fn prepared_hash_hashes_binary_bytes_raw() {
let bin_a = [0xff_u8, 0xfe, 0x00, 0x0d, 0x0a];
let bin_b = [0xff_u8, 0xfe, 0x00, 0x0a];
assert_ne!(prepared_content_hash(&bin_a), prepared_content_hash(&bin_b));
assert_eq!(prepared_content_hash(&bin_a), prepared_content_hash(&bin_a));
}
fn anchor(
class: AnchorProvenanceClass,
hash: Option<&str>,
stab: AnchorHashStability,
) -> Anchor {
Anchor {
artifact: "src/lib.rs".into(),
grain: AnchorGrain::File,
class,
at_version: None,
hash: hash.map(str::to_string),
hash_stability: stab,
derived_from: Vec::new(),
binding: None,
source: None,
}
}
#[test]
fn resolves_when_hash_matches() {
let a = anchor(
AnchorProvenanceClass::Anchored,
Some("h1"),
AnchorHashStability::Stable,
);
let obs = ArtifactObservation::Present {
current_hash: Some("h1".into()),
};
assert_eq!(resolve_anchor(&a, &obs), AnchorState::Resolves);
}
#[test]
fn stable_hash_break_drifts_unstable_rechecks() {
let stable = anchor(
AnchorProvenanceClass::Anchored,
Some("h1"),
AnchorHashStability::Stable,
);
let unstable = anchor(
AnchorProvenanceClass::Anchored,
Some("h1"),
AnchorHashStability::Unstable,
);
let obs = ArtifactObservation::Present {
current_hash: Some("h2".into()),
};
assert_eq!(resolve_anchor(&stable, &obs), AnchorState::Drifted);
assert_eq!(resolve_anchor(&unstable, &obs), AnchorState::Recheck);
}
#[test]
fn absent_artifact_is_orphaned() {
let a = anchor(
AnchorProvenanceClass::Anchored,
Some("h1"),
AnchorHashStability::Stable,
);
assert_eq!(
resolve_anchor(&a, &ArtifactObservation::Absent),
AnchorState::Orphaned
);
}
#[test]
fn non_hash_classes_never_drift() {
for class in [
AnchorProvenanceClass::Authored,
AnchorProvenanceClass::InformedBy,
] {
let a = anchor(class, None, AnchorHashStability::Stable);
let obs = ArtifactObservation::Present {
current_hash: Some("whatever".into()),
};
assert_eq!(resolve_anchor(&a, &obs), AnchorState::Resolves);
assert_eq!(
resolve_anchor(&a, &ArtifactObservation::Absent),
AnchorState::Orphaned
);
}
}
#[test]
fn unavailable_hash_rechecks_not_drifts() {
let a = anchor(
AnchorProvenanceClass::Anchored,
Some("h1"),
AnchorHashStability::Stable,
);
let obs = ArtifactObservation::Present { current_hash: None };
assert_eq!(resolve_anchor(&a, &obs), AnchorState::Recheck);
}
#[test]
fn composition_counts_classes_grains_and_tree_fanout() {
let anchors = vec![
Anchor {
artifact: "a.rs".into(),
grain: AnchorGrain::File,
class: AnchorProvenanceClass::Anchored,
at_version: None,
hash: Some("h".into()),
hash_stability: AnchorHashStability::Stable,
derived_from: Vec::new(),
binding: None,
source: None,
},
Anchor {
artifact: "src/".into(),
grain: AnchorGrain::Tree,
class: AnchorProvenanceClass::Derived,
at_version: None,
hash: Some("t".into()),
hash_stability: AnchorHashStability::Stable,
derived_from: vec!["a.rs".into(), "b.rs".into()],
binding: None,
source: None,
},
];
let comp = compose_entity_anchors(&anchors);
assert_eq!(comp.by_class["anchored"], 1);
assert_eq!(comp.by_class["derived"], 1);
assert_eq!(comp.by_grain["file"], 1);
assert_eq!(comp.by_grain["tree"], 1);
assert_eq!(comp.tree_grain_artifacts, vec!["src/".to_string()]);
assert_eq!(
comp.derived_inputs,
vec![vec!["a.rs".to_string(), "b.rs".to_string()]]
);
}
#[test]
fn sidecar_round_trips_and_prunes_empty() {
let mut sc = AnchorSidecar::default();
assert!(sc.is_empty());
let a = anchor(
AnchorProvenanceClass::Anchored,
Some("h1"),
AnchorHashStability::Stable,
);
sc.set("specs--x", vec![a.clone()]);
assert_eq!(sc.get("specs--x").len(), 1);
let bytes = sc.to_bytes();
let round = AnchorSidecar::from_bytes(&bytes).unwrap();
assert_eq!(round, sc);
sc.set("specs--x", vec![]);
assert!(sc.is_empty());
assert!(sc.get("specs--x").is_empty());
}
fn file_anchor(artifact: &str, hash: &str) -> Anchor {
Anchor {
artifact: artifact.into(),
grain: AnchorGrain::File,
class: AnchorProvenanceClass::Anchored,
at_version: None,
hash: Some(hash.into()),
hash_stability: AnchorHashStability::Stable,
derived_from: Vec::new(),
binding: None,
source: None,
}
}
#[test]
fn merge_appends_new_triple_without_touching_others() {
let mut sc = AnchorSidecar::default();
sc.set(
"m--e",
vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
);
sc.merge("m--e", &[], vec![file_anchor("c.rs", "h-c")]);
let row = sc.get("m--e");
assert_eq!(row.len(), 3);
assert_eq!(row[0], file_anchor("a.rs", "h-a"));
assert_eq!(row[1], file_anchor("b.rs", "h-b"));
assert_eq!(row[2], file_anchor("c.rs", "h-c"));
}
#[test]
fn merge_replaces_same_triple_in_place() {
let mut sc = AnchorSidecar::default();
sc.set(
"m--e",
vec![file_anchor("a.rs", "h-old"), file_anchor("b.rs", "h-b")],
);
sc.merge("m--e", &[], vec![file_anchor("a.rs", "h-new")]);
let row = sc.get("m--e");
assert_eq!(row.len(), 2);
assert_eq!(row[0], file_anchor("a.rs", "h-new"));
assert_eq!(row[1], file_anchor("b.rs", "h-b"));
}
#[test]
fn merge_treats_grain_and_class_as_identity() {
let mut sc = AnchorSidecar::default();
sc.set("m--e", vec![file_anchor("a.rs", "h-a")]);
let mut span = file_anchor("a.rs", "h-span");
span.grain = AnchorGrain::Span;
let mut informed = file_anchor("a.rs", "h-a");
informed.class = AnchorProvenanceClass::InformedBy;
informed.hash = None;
sc.merge("m--e", &[], vec![span, informed]);
assert_eq!(sc.get("m--e").len(), 3);
}
#[test]
fn merge_full_resend_and_empty_are_noops() {
let mut sc = AnchorSidecar::default();
sc.set(
"m--e",
vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
);
let before = sc.to_bytes();
sc.merge(
"m--e",
&[],
vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
);
assert_eq!(sc.to_bytes(), before, "full re-send is byte-stable");
sc.merge("m--e", &[], Vec::new());
assert_eq!(sc.to_bytes(), before, "empty merge is a no-op");
}
#[test]
fn unset_selects_by_artifact_with_optional_narrowing() {
let mut span = file_anchor("a.rs", "h-span");
span.grain = AnchorGrain::Span;
let mut sc = AnchorSidecar::default();
sc.set(
"m--e",
vec![
file_anchor("a.rs", "h-a"),
span.clone(),
file_anchor("b.rs", "h-b"),
],
);
let narrowed = AnchorUnset {
artifact: "a.rs".into(),
grain: Some(AnchorGrain::Span),
class: None,
};
sc.merge("m--e", &[narrowed], Vec::new());
assert_eq!(
sc.get("m--e"),
&[file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")]
);
let missing = AnchorUnset {
artifact: "never-there.rs".into(),
grain: None,
class: None,
};
sc.merge("m--e", &[missing], Vec::new());
assert_eq!(sc.get("m--e").len(), 2);
let bare = AnchorUnset {
artifact: "a.rs".into(),
grain: None,
class: None,
};
sc.merge("m--e", &[bare], Vec::new());
assert_eq!(sc.get("m--e"), &[file_anchor("b.rs", "h-b")]);
}
#[test]
fn unset_applies_before_merge() {
let mut span = file_anchor("a.rs", "h-span");
span.grain = AnchorGrain::Span;
let mut sc = AnchorSidecar::default();
sc.set("m--e", vec![file_anchor("a.rs", "h-old"), span]);
let bare = AnchorUnset {
artifact: "a.rs".into(),
grain: None,
class: None,
};
sc.merge("m--e", &[bare], vec![file_anchor("a.rs", "h-new")]);
assert_eq!(sc.get("m--e"), &[file_anchor("a.rs", "h-new")]);
}
#[test]
fn merge_prunes_row_emptied_by_unset() {
let mut sc = AnchorSidecar::default();
sc.set("m--e", vec![file_anchor("a.rs", "h-a")]);
let bare = AnchorUnset {
artifact: "a.rs".into(),
grain: None,
class: None,
};
sc.merge("m--e", &[bare], Vec::new());
assert!(sc.is_empty());
assert!(!sc.to_bytes().windows(5).any(|w| w == b"m--e\""));
}
#[test]
fn unset_input_validates_typed() {
let ok = AnchorUnsetInput {
artifact: Some(" a.rs ".into()),
grain: Some("span".into()),
class: None,
}
.validate()
.unwrap();
assert_eq!(ok.artifact, "a.rs");
assert_eq!(ok.grain, Some(AnchorGrain::Span));
assert_eq!(ok.class, None);
let missing = AnchorUnsetInput::default().validate().unwrap_err();
assert!(matches!(missing, AnchorValidationError::MissingArtifact));
assert_eq!(missing.code(), INVALID_ANCHOR_CODE);
let bad_grain = AnchorUnsetInput {
artifact: Some("a.rs".into()),
grain: Some("paragraph".into()),
class: None,
}
.validate()
.unwrap_err();
assert!(matches!(
bad_grain,
AnchorValidationError::UnknownGrain { .. }
));
let bad_class = AnchorUnsetInput {
artifact: Some("a.rs".into()),
grain: None,
class: Some("guessed".into()),
}
.validate()
.unwrap_err();
assert!(matches!(
bad_class,
AnchorValidationError::UnknownClass { .. }
));
}
#[test]
fn sidecar_rename_leaves_zero_rows_under_old_id() {
let mut sc = AnchorSidecar::default();
sc.set(
"specs--old",
vec![anchor(
AnchorProvenanceClass::Anchored,
Some("h"),
AnchorHashStability::Stable,
)],
);
sc.rename("specs--old", "specs--new");
assert!(sc.get("specs--old").is_empty());
assert_eq!(sc.get("specs--new").len(), 1);
}
#[test]
fn sidecar_remove_drops_entity_anchors() {
let mut sc = AnchorSidecar::default();
sc.set(
"specs--gone",
vec![anchor(
AnchorProvenanceClass::Anchored,
Some("h"),
AnchorHashStability::Stable,
)],
);
sc.remove("specs--gone");
assert!(sc.get("specs--gone").is_empty());
sc.remove("specs--gone");
}
#[test]
fn empty_bytes_parse_as_empty_sidecar() {
assert!(AnchorSidecar::from_bytes(b"").unwrap().is_empty());
assert!(AnchorSidecar::from_bytes(b" \n ").unwrap().is_empty());
}
#[test]
fn anchor_json_shape_omits_empty_optionals() {
let a = anchor(
AnchorProvenanceClass::Anchored,
Some("h1"),
AnchorHashStability::Stable,
);
let v = serde_json::to_value(&a).unwrap();
assert_eq!(v["artifact"], "src/lib.rs");
assert_eq!(v["grain"], "file");
assert_eq!(v["class"], "anchored");
assert_eq!(v["hash"], "h1");
assert_eq!(v["hash_stability"], "stable");
assert!(v.get("at_version").is_none());
assert!(v.get("derived_from").is_none());
assert!(v.get("binding").is_none());
}
#[test]
fn anchor_version_serialises_tagged() {
let a = Anchor {
at_version: Some(AnchorVersion::Commit("deadbeef".into())),
..anchor(
AnchorProvenanceClass::Anchored,
Some("h"),
AnchorHashStability::Stable,
)
};
let v = serde_json::to_value(&a).unwrap();
assert_eq!(v["at_version"]["kind"], "commit");
assert_eq!(v["at_version"]["value"], "deadbeef");
}
#[test]
fn validate_source_carried_absent_or_refused_when_empty() {
let mut input = AnchorInput {
artifact: Some("src/lib.rs".into()),
grain: Some("file".into()),
class: Some("anchored".into()),
..Default::default()
};
assert_eq!(
input.validate(None).unwrap().source,
None,
"absent stays absent"
);
input.source = Some(" api-docs ".into());
assert_eq!(
input.validate(None).unwrap().source.as_deref(),
Some("api-docs"),
"non-empty name is carried (trimmed)"
);
input.source = Some(" ".into());
let err = input.validate(None).unwrap_err();
assert_eq!(err.code(), INVALID_ANCHOR_CODE);
assert!(matches!(err, AnchorValidationError::EmptySource));
assert_eq!(
err.detail().get("field"),
Some(&serde_json::json!("source"))
);
}
#[test]
fn source_is_additive_on_the_persisted_shape() {
let pre_plan = r#"{
"artifact": "src/lib.rs",
"grain": "file",
"class": "anchored",
"hash_stability": "stable"
}"#;
let a: Anchor = serde_json::from_str(pre_plan).expect("pre-plan anchor loads");
assert_eq!(a.source, None, "no backfill, no default");
let sourced = Anchor {
source: Some("api-docs".into()),
..a
};
let json = serde_json::to_string(&sourced).unwrap();
let back: Anchor = serde_json::from_str(&json).unwrap();
assert_eq!(back.source.as_deref(), Some("api-docs"));
}
}