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>,
}
#[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>,
}
#[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 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::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,
});
}
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),
})
}
}
#[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 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());
}
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,
}
}
#[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,
},
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,
},
];
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());
}
#[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");
}
}