1use std::collections::BTreeMap;
52
53use serde::{Deserialize, Serialize};
54
55pub const ANCHOR_SIDECAR_PATH: &str = ".memstead/anchors.json";
60
61pub const ANCHOR_SIDECAR_VERSION: u32 = 1;
63
64pub const INVALID_ANCHOR_CODE: &str = "INVALID_ANCHOR";
68
69pub const REDACTED_ARTIFACT_SENTINEL: &str = "[redacted]";
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "kebab-case")]
97pub enum AnchorProvenanceClass {
98 Anchored,
99 Derived,
100 Authored,
101 InformedBy,
102}
103
104impl AnchorProvenanceClass {
105 pub const WIRE_VALUES: &'static [&'static str] =
108 &["anchored", "derived", "authored", "informed-by"];
109
110 pub fn as_wire(&self) -> &'static str {
112 match self {
113 AnchorProvenanceClass::Anchored => "anchored",
114 AnchorProvenanceClass::Derived => "derived",
115 AnchorProvenanceClass::Authored => "authored",
116 AnchorProvenanceClass::InformedBy => "informed-by",
117 }
118 }
119
120 pub fn from_wire(s: &str) -> Option<Self> {
123 match s {
124 "anchored" => Some(AnchorProvenanceClass::Anchored),
125 "derived" => Some(AnchorProvenanceClass::Derived),
126 "authored" => Some(AnchorProvenanceClass::Authored),
127 "informed-by" => Some(AnchorProvenanceClass::InformedBy),
128 _ => None,
129 }
130 }
131
132 pub fn is_hash_bearing(&self) -> bool {
138 matches!(
139 self,
140 AnchorProvenanceClass::Anchored | AnchorProvenanceClass::Derived
141 )
142 }
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(rename_all = "lowercase")]
159pub enum AnchorGrain {
160 Span,
161 File,
162 Tree,
163 Url,
164 Entity,
165}
166
167impl AnchorGrain {
168 pub const WIRE_VALUES: &'static [&'static str] = &["span", "file", "tree", "url", "entity"];
170
171 pub fn as_wire(&self) -> &'static str {
173 match self {
174 AnchorGrain::Span => "span",
175 AnchorGrain::File => "file",
176 AnchorGrain::Tree => "tree",
177 AnchorGrain::Url => "url",
178 AnchorGrain::Entity => "entity",
179 }
180 }
181
182 pub fn from_wire(s: &str) -> Option<Self> {
184 match s {
185 "span" => Some(AnchorGrain::Span),
186 "file" => Some(AnchorGrain::File),
187 "tree" => Some(AnchorGrain::Tree),
188 "url" => Some(AnchorGrain::Url),
189 "entity" => Some(AnchorGrain::Entity),
190 _ => None,
191 }
192 }
193
194 pub fn supported_by_namespace(&self, anchor_namespace: &str) -> bool {
203 let path_shaped = matches!(anchor_namespace, "path" | "path+commit");
204 match self {
205 AnchorGrain::Span | AnchorGrain::File | AnchorGrain::Tree => path_shaped,
206 AnchorGrain::Url => anchor_namespace == "url",
207 AnchorGrain::Entity => anchor_namespace == "entity",
208 }
209 }
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
224#[serde(rename_all = "lowercase")]
225pub enum AnchorHashStability {
226 Stable,
227 Unstable,
228}
229
230impl AnchorHashStability {
231 pub const WIRE_VALUES: &'static [&'static str] = &["stable", "unstable"];
233
234 pub fn as_wire(&self) -> &'static str {
236 match self {
237 AnchorHashStability::Stable => "stable",
238 AnchorHashStability::Unstable => "unstable",
239 }
240 }
241
242 pub fn from_wire(s: &str) -> Option<Self> {
244 match s {
245 "stable" => Some(AnchorHashStability::Stable),
246 "unstable" => Some(AnchorHashStability::Unstable),
247 _ => None,
248 }
249 }
250}
251
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265#[serde(tag = "kind", content = "value", rename_all = "lowercase")]
266pub enum AnchorVersion {
267 Commit(String),
269 Snapshot(String),
271 Etag(String),
273}
274
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
286pub struct Anchor {
287 pub artifact: String,
291 pub grain: AnchorGrain,
293 pub class: AnchorProvenanceClass,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub at_version: Option<AnchorVersion>,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub hash: Option<String>,
304 pub hash_stability: AnchorHashStability,
307 #[serde(default, skip_serializing_if = "Vec::is_empty")]
310 pub derived_from: Vec<String>,
311 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub binding: Option<String>,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub source: Option<String>,
325}
326
327#[derive(Debug, Clone, Default, Serialize, Deserialize)]
337pub struct AnchorInput {
338 #[serde(default)]
339 pub artifact: Option<String>,
340 #[serde(default)]
341 pub grain: Option<String>,
342 #[serde(default)]
343 pub class: Option<String>,
344 #[serde(default)]
345 pub at_version: Option<AnchorVersion>,
346 #[serde(default)]
347 pub hash: Option<String>,
348 #[serde(default)]
349 pub hash_stability: Option<String>,
350 #[serde(default)]
351 pub derived_from: Option<Vec<String>>,
352 #[serde(default)]
353 pub binding: Option<String>,
354 #[serde(default)]
355 pub source: Option<String>,
356}
357
358#[derive(Debug, Clone, Default, Serialize, Deserialize)]
366pub struct AnchorUnsetInput {
367 #[serde(default)]
368 pub artifact: Option<String>,
369 #[serde(default)]
370 pub grain: Option<String>,
371 #[serde(default)]
372 pub class: Option<String>,
373}
374
375impl AnchorUnsetInput {
376 pub fn validate(&self) -> Result<AnchorUnset, AnchorValidationError> {
380 let artifact = self
381 .artifact
382 .as_deref()
383 .map(str::trim)
384 .filter(|s| !s.is_empty())
385 .map(str::to_string)
386 .ok_or(AnchorValidationError::MissingArtifact)?;
387 let grain = match self.grain.as_deref() {
388 None => None,
389 Some(s) => Some(AnchorGrain::from_wire(s).ok_or_else(|| {
390 AnchorValidationError::UnknownGrain {
391 got: Some(s.to_string()),
392 allowed: AnchorGrain::WIRE_VALUES,
393 }
394 })?),
395 };
396 let class = match self.class.as_deref() {
397 None => None,
398 Some(s) => Some(AnchorProvenanceClass::from_wire(s).ok_or_else(|| {
399 AnchorValidationError::UnknownClass {
400 got: Some(s.to_string()),
401 allowed: AnchorProvenanceClass::WIRE_VALUES,
402 }
403 })?),
404 };
405 Ok(AnchorUnset {
406 artifact,
407 grain,
408 class,
409 })
410 }
411}
412
413#[derive(Debug, Clone, PartialEq, Eq)]
419pub struct AnchorUnset {
420 pub artifact: String,
422 pub grain: Option<AnchorGrain>,
424 pub class: Option<AnchorProvenanceClass>,
426}
427
428impl AnchorUnset {
429 pub fn matches(&self, anchor: &Anchor) -> bool {
431 anchor.artifact == self.artifact
432 && self.grain.is_none_or(|g| anchor.grain == g)
433 && self.class.is_none_or(|c| anchor.class == c)
434 }
435}
436
437#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
441pub enum AnchorValidationError {
442 #[error("unknown anchor provenance class {got:?}; allowed: {}", allowed.join(", "))]
444 UnknownClass {
445 got: Option<String>,
446 allowed: &'static [&'static str],
447 },
448 #[error("unknown anchor grain {got:?}; allowed: {}", allowed.join(", "))]
450 UnknownGrain {
451 got: Option<String>,
452 allowed: &'static [&'static str],
453 },
454 #[error("unknown anchor hash stability {got:?}; allowed: {}", allowed.join(", "))]
456 UnknownHashStability {
457 got: String,
458 allowed: &'static [&'static str],
459 },
460 #[error("anchor is missing its artifact reference")]
462 MissingArtifact,
463 #[error("anchor class '{class}' carries no hash semantics — a content hash is not permitted")]
466 HashOnNonHashClass { class: &'static str },
467 #[error("anchor `source`, when present, must be a non-empty source name")]
471 EmptySource,
472 #[error(
481 "anchor `source` {got:?} is not declared by the anchor's producing binding; \
482 declared sources: {}",
483 declared.join(", ")
484 )]
485 SourceNotDeclared { got: String, declared: Vec<String> },
486 #[error(
493 "anchor artifact {artifact:?} resolves under no candidate path (tried: {}); artifact \
494 paths are source-relative (joined onto the source's pointer) or workspace-relative — \
495 write the path exactly as the brief lists it",
496 candidates.join(", ")
497 )]
498 ArtifactUnresolvable {
499 artifact: String,
500 candidates: Vec<String>,
501 },
502 #[error(
505 "anchor grain '{grain}' is unsupported by a '{medium_type}' medium: its \
506 '{anchor_namespace}' namespace does not admit that grain"
507 )]
508 GrainNamespaceUnsupported {
509 grain: &'static str,
510 medium_type: String,
511 anchor_namespace: &'static str,
512 },
513}
514
515impl AnchorValidationError {
516 pub fn code(&self) -> &'static str {
518 INVALID_ANCHOR_CODE
519 }
520
521 pub fn detail(&self) -> BTreeMap<String, serde_json::Value> {
524 let mut d = BTreeMap::new();
525 match self {
526 AnchorValidationError::UnknownClass { got, allowed } => {
527 d.insert("field".into(), "class".into());
528 d.insert("got".into(), serde_json::json!(got));
529 d.insert("allowed".into(), serde_json::json!(allowed));
530 }
531 AnchorValidationError::UnknownGrain { got, allowed } => {
532 d.insert("field".into(), "grain".into());
533 d.insert("got".into(), serde_json::json!(got));
534 d.insert("allowed".into(), serde_json::json!(allowed));
535 }
536 AnchorValidationError::UnknownHashStability { got, allowed } => {
537 d.insert("field".into(), "hash_stability".into());
538 d.insert("got".into(), serde_json::json!(got));
539 d.insert("allowed".into(), serde_json::json!(allowed));
540 }
541 AnchorValidationError::MissingArtifact => {
542 d.insert("field".into(), "artifact".into());
543 }
544 AnchorValidationError::EmptySource => {
545 d.insert("field".into(), "source".into());
546 }
547 AnchorValidationError::SourceNotDeclared { got, declared } => {
548 d.insert("field".into(), "source".into());
549 d.insert("got".into(), serde_json::json!(got));
550 d.insert("declared".into(), serde_json::json!(declared));
551 }
552 AnchorValidationError::HashOnNonHashClass { class } => {
553 d.insert("field".into(), "hash".into());
554 d.insert("class".into(), serde_json::json!(class));
555 }
556 AnchorValidationError::ArtifactUnresolvable {
557 artifact,
558 candidates,
559 } => {
560 d.insert("field".into(), "artifact".into());
561 d.insert("got".into(), serde_json::json!(artifact));
562 d.insert("candidates_tried".into(), serde_json::json!(candidates));
563 d.insert(
564 "expected".into(),
565 serde_json::json!(
566 "a source-relative path (joined onto the source's pointer) or a \
567 workspace-relative path that resolves to an existing artifact"
568 ),
569 );
570 }
571 AnchorValidationError::GrainNamespaceUnsupported {
572 grain,
573 medium_type,
574 anchor_namespace,
575 } => {
576 d.insert("field".into(), "grain".into());
577 d.insert("grain".into(), serde_json::json!(grain));
578 d.insert("medium_type".into(), serde_json::json!(medium_type));
579 d.insert(
580 "anchor_namespace".into(),
581 serde_json::json!(anchor_namespace),
582 );
583 }
584 }
585 d
586 }
587}
588
589impl AnchorInput {
590 pub fn validate(&self, medium: Option<(&str, &str)>) -> Result<Anchor, AnchorValidationError> {
609 let class = match self
610 .class
611 .as_deref()
612 .and_then(AnchorProvenanceClass::from_wire)
613 {
614 Some(c) => c,
615 None => {
616 return Err(AnchorValidationError::UnknownClass {
617 got: self.class.clone(),
618 allowed: AnchorProvenanceClass::WIRE_VALUES,
619 });
620 }
621 };
622 let grain = match self.grain.as_deref().and_then(AnchorGrain::from_wire) {
623 Some(g) => g,
624 None => {
625 return Err(AnchorValidationError::UnknownGrain {
626 got: self.grain.clone(),
627 allowed: AnchorGrain::WIRE_VALUES,
628 });
629 }
630 };
631
632 let artifact = self
633 .artifact
634 .as_deref()
635 .map(str::trim)
636 .filter(|s| !s.is_empty())
637 .map(str::to_string)
638 .ok_or(AnchorValidationError::MissingArtifact)?;
639
640 let hash_stability = match self.hash_stability.as_deref() {
643 None => AnchorHashStability::Stable,
644 Some(s) => AnchorHashStability::from_wire(s).ok_or_else(|| {
645 AnchorValidationError::UnknownHashStability {
646 got: s.to_string(),
647 allowed: AnchorHashStability::WIRE_VALUES,
648 }
649 })?,
650 };
651
652 let hash = self
654 .hash
655 .as_deref()
656 .map(str::trim)
657 .filter(|s| !s.is_empty())
658 .map(str::to_string);
659 if hash.is_some() && !class.is_hash_bearing() {
660 return Err(AnchorValidationError::HashOnNonHashClass {
661 class: class.as_wire(),
662 });
663 }
664
665 if let Some((medium_type, namespace)) = medium
667 && !grain.supported_by_namespace(namespace)
668 {
669 let anchor_namespace = match namespace {
672 "path" => "path",
673 "path+commit" => "path+commit",
674 "entity" => "entity",
675 "url" => "url",
676 _ => "path",
677 };
678 return Err(AnchorValidationError::GrainNamespaceUnsupported {
679 grain: grain.as_wire(),
680 medium_type: medium_type.to_string(),
681 anchor_namespace,
682 });
683 }
684
685 let source = match self.source.as_deref() {
690 None => None,
691 Some(raw) => {
692 let trimmed = raw.trim();
693 if trimmed.is_empty() {
694 return Err(AnchorValidationError::EmptySource);
695 }
696 Some(trimmed.to_string())
697 }
698 };
699
700 Ok(Anchor {
701 artifact,
702 grain,
703 class,
704 at_version: self.at_version.clone(),
705 hash,
706 hash_stability,
707 derived_from: self.derived_from.clone().unwrap_or_default(),
708 binding: self
709 .binding
710 .as_deref()
711 .map(str::trim)
712 .filter(|s| !s.is_empty())
713 .map(str::to_string),
714 source,
715 })
716 }
717}
718
719pub fn prepared_content_hash(bytes: &[u8]) -> String {
746 use sha2::{Digest as _, Sha256};
747 let digest = match std::str::from_utf8(bytes) {
748 Ok(text) => {
749 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
750 let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
751 Sha256::digest(normalized.trim_end_matches('\n').as_bytes())
752 }
753 Err(_) => Sha256::digest(bytes),
754 };
755 crate::hex_lower(&digest)[..16].to_string()
756}
757
758#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
763pub struct ObservedArtifactHash {
764 pub entity: String,
766 pub artifact: String,
768 pub hash: String,
770}
771
772#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
778#[serde(rename_all = "lowercase")]
779pub enum AnchorState {
780 Resolves,
783 Drifted,
786 Recheck,
790 Orphaned,
793}
794
795impl AnchorState {
796 pub fn as_wire(&self) -> &'static str {
798 match self {
799 AnchorState::Resolves => "resolves",
800 AnchorState::Drifted => "drifted",
801 AnchorState::Recheck => "recheck",
802 AnchorState::Orphaned => "orphaned",
803 }
804 }
805}
806
807#[derive(Debug, Clone, PartialEq, Eq)]
809pub enum ArtifactObservation {
810 Absent,
812 Present { current_hash: Option<String> },
816}
817
818pub fn resolve_anchor(anchor: &Anchor, observation: &ArtifactObservation) -> AnchorState {
830 let current_hash = match observation {
831 ArtifactObservation::Absent => return AnchorState::Orphaned,
832 ArtifactObservation::Present { current_hash } => current_hash,
833 };
834 if !anchor.class.is_hash_bearing() {
835 return AnchorState::Resolves;
836 }
837 match (&anchor.hash, current_hash) {
838 (Some(recorded), Some(current)) if recorded == current => AnchorState::Resolves,
839 (Some(_), Some(_)) => match anchor.hash_stability {
840 AnchorHashStability::Stable => AnchorState::Drifted,
841 AnchorHashStability::Unstable => AnchorState::Recheck,
842 },
843 _ => AnchorState::Recheck,
845 }
846}
847
848#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
854pub struct EntityAnchorComposition {
855 pub by_class: BTreeMap<String, usize>,
857 pub by_grain: BTreeMap<String, usize>,
859 pub derived_inputs: Vec<Vec<String>>,
862 pub tree_grain_artifacts: Vec<String>,
867}
868
869pub fn compose_entity_anchors(anchors: &[Anchor]) -> EntityAnchorComposition {
872 let mut comp = EntityAnchorComposition::default();
873 for a in anchors {
874 *comp
875 .by_class
876 .entry(a.class.as_wire().to_string())
877 .or_insert(0) += 1;
878 *comp
879 .by_grain
880 .entry(a.grain.as_wire().to_string())
881 .or_insert(0) += 1;
882 if a.class == AnchorProvenanceClass::Derived {
883 comp.derived_inputs.push(a.derived_from.clone());
884 }
885 if a.grain == AnchorGrain::Tree {
886 comp.tree_grain_artifacts.push(a.artifact.clone());
887 }
888 }
889 comp
890}
891
892#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
904pub struct AnchorSidecar {
905 pub version: u32,
907 #[serde(default)]
910 pub entities: BTreeMap<String, Vec<Anchor>>,
911}
912
913impl Default for AnchorSidecar {
914 fn default() -> Self {
915 Self {
916 version: ANCHOR_SIDECAR_VERSION,
917 entities: BTreeMap::new(),
918 }
919 }
920}
921
922impl AnchorSidecar {
923 pub fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
926 if bytes.iter().all(u8::is_ascii_whitespace) {
927 return Ok(Self::default());
928 }
929 let sidecar: Self = serde_json::from_slice(bytes)?;
930 if sidecar.version != ANCHOR_SIDECAR_VERSION {
939 return Err(serde::de::Error::custom(format!(
940 "unsupported anchors sidecar version {} (this engine reads version {}) — \
941 the file was written by a different engine; upgrade, or remove the sidecar \
942 to re-record anchors",
943 sidecar.version, ANCHOR_SIDECAR_VERSION
944 )));
945 }
946 Ok(sidecar)
947 }
948
949 pub fn to_bytes(&self) -> Vec<u8> {
952 let mut s = serde_json::to_string_pretty(self).expect("anchor sidecar serialises");
953 s.push('\n');
954 s.into_bytes()
955 }
956
957 pub fn get(&self, entity_id: &str) -> &[Anchor] {
959 self.entities
960 .get(entity_id)
961 .map(Vec::as_slice)
962 .unwrap_or(&[])
963 }
964
965 pub fn set(&mut self, entity_id: &str, anchors: Vec<Anchor>) {
968 if anchors.is_empty() {
969 self.entities.remove(entity_id);
970 } else {
971 self.entities.insert(entity_id.to_string(), anchors);
972 }
973 }
974
975 pub fn merge(&mut self, entity_id: &str, unsets: &[AnchorUnset], incoming: Vec<Anchor>) {
987 let mut row = self.entities.remove(entity_id).unwrap_or_default();
988 row.retain(|a| !unsets.iter().any(|u| u.matches(a)));
989 for anchor in incoming {
990 match row.iter_mut().find(|e| {
991 e.artifact == anchor.artifact && e.grain == anchor.grain && e.class == anchor.class
992 }) {
993 Some(existing) => *existing = anchor,
994 None => row.push(anchor),
995 }
996 }
997 if !row.is_empty() {
998 self.entities.insert(entity_id.to_string(), row);
999 }
1000 }
1001
1002 pub fn redact_artifact_references(&mut self) {
1010 for anchors in self.entities.values_mut() {
1011 for anchor in anchors {
1012 anchor.artifact = REDACTED_ARTIFACT_SENTINEL.to_string();
1013 for input in &mut anchor.derived_from {
1014 *input = REDACTED_ARTIFACT_SENTINEL.to_string();
1015 }
1016 }
1017 }
1018 }
1019
1020 pub fn validate_artifact_references(&self) -> Result<(), String> {
1026 for (entity_id, anchors) in &self.entities {
1027 for anchor in anchors {
1028 if anchor.artifact.trim().is_empty() {
1029 return Err(format!(
1030 "entity `{entity_id}` carries an anchor with an empty artifact \
1031 reference"
1032 ));
1033 }
1034 if anchor.derived_from.iter().any(|d| d.trim().is_empty()) {
1035 return Err(format!(
1036 "entity `{entity_id}` carries an anchor with an empty \
1037 `derived_from` entry"
1038 ));
1039 }
1040 }
1041 }
1042 Ok(())
1043 }
1044
1045 pub fn remove(&mut self, entity_id: &str) {
1047 self.entities.remove(entity_id);
1048 }
1049
1050 pub fn rename(&mut self, from: &str, to: &str) {
1055 if let Some(anchors) = self.entities.remove(from) {
1056 self.entities.insert(to.to_string(), anchors);
1057 }
1058 }
1059
1060 pub fn is_empty(&self) -> bool {
1062 self.entities.is_empty()
1063 }
1064}
1065
1066#[cfg(test)]
1067mod tests {
1068 use super::*;
1069
1070 #[test]
1075 fn redaction_blanks_references_and_keeps_trust_metadata() {
1076 let mut sidecar = AnchorSidecar::default();
1077 sidecar.set(
1078 "m--alpha",
1079 vec![
1080 Anchor {
1081 artifact: "src/lib.rs".into(),
1082 grain: AnchorGrain::File,
1083 class: AnchorProvenanceClass::Anchored,
1084 at_version: Some(AnchorVersion::Commit("abc123".into())),
1085 hash: Some("h1".into()),
1086 hash_stability: AnchorHashStability::Stable,
1087 derived_from: vec![],
1088 binding: Some("bhash".into()),
1089 source: Some("source-tree".into()),
1090 },
1091 Anchor {
1092 artifact: "docs/summary.md".into(),
1093 grain: AnchorGrain::File,
1094 class: AnchorProvenanceClass::Derived,
1095 at_version: None,
1096 hash: Some("h2".into()),
1097 hash_stability: AnchorHashStability::Unstable,
1098 derived_from: vec!["notes/a.md".into(), "notes/b.md".into()],
1099 binding: None,
1100 source: None,
1101 },
1102 ],
1103 );
1104
1105 sidecar.redact_artifact_references();
1106
1107 let anchors = sidecar.get("m--alpha");
1108 assert_eq!(anchors.len(), 2, "no anchor entry is dropped");
1109 for a in anchors {
1110 assert_eq!(a.artifact, REDACTED_ARTIFACT_SENTINEL);
1111 for d in &a.derived_from {
1112 assert_eq!(d, REDACTED_ARTIFACT_SENTINEL);
1113 }
1114 }
1115 assert_eq!(
1116 anchors[0].at_version,
1117 Some(AnchorVersion::Commit("abc123".into()))
1118 );
1119 assert_eq!(anchors[0].hash.as_deref(), Some("h1"));
1120 assert_eq!(anchors[0].binding.as_deref(), Some("bhash"));
1121 assert_eq!(anchors[0].source.as_deref(), Some("source-tree"));
1122 assert_eq!(anchors[1].class, AnchorProvenanceClass::Derived);
1123 assert_eq!(anchors[1].derived_from.len(), 2, "derivation arity kept");
1124 sidecar.validate_artifact_references().unwrap();
1127 }
1128
1129 #[test]
1133 fn empty_artifact_references_are_refused() {
1134 let mut sidecar = AnchorSidecar::default();
1135 sidecar.set(
1136 "m--alpha",
1137 vec![Anchor {
1138 artifact: "".into(),
1139 grain: AnchorGrain::File,
1140 class: AnchorProvenanceClass::Anchored,
1141 at_version: None,
1142 hash: None,
1143 hash_stability: AnchorHashStability::Stable,
1144 derived_from: vec![],
1145 binding: None,
1146 source: None,
1147 }],
1148 );
1149 assert!(sidecar.validate_artifact_references().is_err());
1150
1151 let mut sidecar = AnchorSidecar::default();
1152 sidecar.set(
1153 "m--beta",
1154 vec![Anchor {
1155 artifact: "docs/x.md".into(),
1156 grain: AnchorGrain::File,
1157 class: AnchorProvenanceClass::Derived,
1158 at_version: None,
1159 hash: None,
1160 hash_stability: AnchorHashStability::Stable,
1161 derived_from: vec![" ".into()],
1162 binding: None,
1163 source: None,
1164 }],
1165 );
1166 assert!(sidecar.validate_artifact_references().is_err());
1167 }
1168
1169 #[test]
1172 fn class_wire_strings_are_stable() {
1173 assert_eq!(AnchorProvenanceClass::Anchored.as_wire(), "anchored");
1174 assert_eq!(AnchorProvenanceClass::Derived.as_wire(), "derived");
1175 assert_eq!(AnchorProvenanceClass::Authored.as_wire(), "authored");
1176 assert_eq!(AnchorProvenanceClass::InformedBy.as_wire(), "informed-by");
1177 for w in AnchorProvenanceClass::WIRE_VALUES {
1178 assert_eq!(AnchorProvenanceClass::from_wire(w).unwrap().as_wire(), *w);
1179 }
1180 assert!(AnchorProvenanceClass::from_wire("bogus").is_none());
1181 }
1182
1183 #[test]
1184 fn grain_wire_strings_are_stable() {
1185 for w in AnchorGrain::WIRE_VALUES {
1186 assert_eq!(AnchorGrain::from_wire(w).unwrap().as_wire(), *w);
1187 }
1188 assert_eq!(
1189 AnchorGrain::WIRE_VALUES,
1190 &["span", "file", "tree", "url", "entity"]
1191 );
1192 assert!(AnchorGrain::from_wire("chunk").is_none());
1193 }
1194
1195 #[test]
1196 fn stability_and_state_wire_strings_are_stable() {
1197 assert_eq!(AnchorHashStability::Stable.as_wire(), "stable");
1198 assert_eq!(AnchorHashStability::Unstable.as_wire(), "unstable");
1199 assert_eq!(AnchorState::Resolves.as_wire(), "resolves");
1200 assert_eq!(AnchorState::Drifted.as_wire(), "drifted");
1201 assert_eq!(AnchorState::Recheck.as_wire(), "recheck");
1202 assert_eq!(AnchorState::Orphaned.as_wire(), "orphaned");
1203 }
1204
1205 #[test]
1206 fn only_anchored_and_derived_are_hash_bearing() {
1207 assert!(AnchorProvenanceClass::Anchored.is_hash_bearing());
1208 assert!(AnchorProvenanceClass::Derived.is_hash_bearing());
1209 assert!(!AnchorProvenanceClass::Authored.is_hash_bearing());
1210 assert!(!AnchorProvenanceClass::InformedBy.is_hash_bearing());
1211 }
1212
1213 #[test]
1216 fn grain_namespace_support_matches_capability_matrix() {
1217 for g in [AnchorGrain::Span, AnchorGrain::File, AnchorGrain::Tree] {
1219 assert!(g.supported_by_namespace("path"));
1220 assert!(g.supported_by_namespace("path+commit"));
1221 assert!(!g.supported_by_namespace("url"));
1222 assert!(!g.supported_by_namespace("entity"));
1223 }
1224 assert!(AnchorGrain::Url.supported_by_namespace("url"));
1225 assert!(!AnchorGrain::Url.supported_by_namespace("path"));
1226 assert!(AnchorGrain::Entity.supported_by_namespace("entity"));
1227 assert!(!AnchorGrain::Entity.supported_by_namespace("path"));
1228 }
1229
1230 fn valid_input() -> AnchorInput {
1233 AnchorInput {
1234 artifact: Some("src/lib.rs".into()),
1235 grain: Some("file".into()),
1236 class: Some("anchored".into()),
1237 hash_stability: Some("stable".into()),
1238 hash: Some("abc123".into()),
1239 ..Default::default()
1240 }
1241 }
1242
1243 #[test]
1244 fn validate_accepts_a_well_formed_anchor() {
1245 let a = valid_input().validate(Some(("codebase", "path"))).unwrap();
1246 assert_eq!(a.artifact, "src/lib.rs");
1247 assert_eq!(a.grain, AnchorGrain::File);
1248 assert_eq!(a.class, AnchorProvenanceClass::Anchored);
1249 assert_eq!(a.hash.as_deref(), Some("abc123"));
1250 assert_eq!(a.hash_stability, AnchorHashStability::Stable);
1251 }
1252
1253 #[test]
1254 fn validate_defaults_hash_stability_to_stable() {
1255 let mut i = valid_input();
1256 i.hash_stability = None;
1257 let a = i.validate(None).unwrap();
1258 assert_eq!(a.hash_stability, AnchorHashStability::Stable);
1259 }
1260
1261 #[test]
1262 fn validate_refuses_unknown_class() {
1263 let mut i = valid_input();
1264 i.class = Some("guessed".into());
1265 let err = i.validate(None).unwrap_err();
1266 assert_eq!(err.code(), INVALID_ANCHOR_CODE);
1267 assert!(matches!(err, AnchorValidationError::UnknownClass { .. }));
1268 assert_eq!(err.detail()["field"], serde_json::json!("class"));
1269 }
1270
1271 #[test]
1272 fn validate_refuses_unknown_grain() {
1273 let mut i = valid_input();
1274 i.grain = Some("paragraph".into());
1275 let err = i.validate(None).unwrap_err();
1276 assert!(matches!(err, AnchorValidationError::UnknownGrain { .. }));
1277 }
1278
1279 #[test]
1280 fn validate_refuses_missing_artifact() {
1281 let mut i = valid_input();
1282 i.artifact = Some(" ".into());
1283 let err = i.validate(None).unwrap_err();
1284 assert!(matches!(err, AnchorValidationError::MissingArtifact));
1285 i.artifact = None;
1286 assert!(matches!(
1287 valid_input_with_artifact(None).validate(None).unwrap_err(),
1288 AnchorValidationError::MissingArtifact
1289 ));
1290 let _ = i;
1291 }
1292
1293 fn valid_input_with_artifact(a: Option<String>) -> AnchorInput {
1294 AnchorInput {
1295 artifact: a,
1296 ..valid_input()
1297 }
1298 }
1299
1300 #[test]
1301 fn validate_refuses_hash_on_non_hash_class() {
1302 let mut i = valid_input();
1303 i.class = Some("authored".into());
1304 let err = i.validate(None).unwrap_err();
1306 assert!(matches!(
1307 err,
1308 AnchorValidationError::HashOnNonHashClass { class: "authored" }
1309 ));
1310 }
1311
1312 #[test]
1313 fn validate_accepts_non_hash_class_without_hash() {
1314 let mut i = valid_input();
1315 i.class = Some("informed-by".into());
1316 i.hash = None;
1317 let a = i.validate(None).unwrap();
1318 assert_eq!(a.class, AnchorProvenanceClass::InformedBy);
1319 assert!(a.hash.is_none());
1320 }
1321
1322 #[test]
1323 fn validate_refuses_grain_unsupported_by_medium_namespace() {
1324 let mut i = valid_input();
1326 i.grain = Some("span".into());
1327 i.class = Some("authored".into());
1328 i.hash = None;
1329 let err = i.validate(Some(("web", "url"))).unwrap_err();
1330 match err {
1331 AnchorValidationError::GrainNamespaceUnsupported {
1332 grain,
1333 anchor_namespace,
1334 ..
1335 } => {
1336 assert_eq!(grain, "span");
1337 assert_eq!(anchor_namespace, "url");
1338 }
1339 other => panic!("expected GrainNamespaceUnsupported, got {other:?}"),
1340 }
1341 }
1342
1343 #[test]
1344 fn validate_skips_namespace_check_without_medium_context() {
1345 let mut i = valid_input();
1347 i.grain = Some("span".into());
1348 assert!(i.validate(None).is_ok());
1349 }
1350
1351 #[test]
1357 fn prepared_hash_is_stable_across_byte_noise() {
1358 let base = prepared_content_hash(b"fn a() {}\nfn b() {}\n");
1359 assert_eq!(prepared_content_hash(b"fn a() {}\r\nfn b() {}\r\n"), base);
1361 assert_eq!(prepared_content_hash(b"fn a() {}\rfn b() {}\r"), base);
1362 assert_eq!(prepared_content_hash(b"fn a() {}\nfn b() {}"), base);
1364 assert_eq!(prepared_content_hash(b"fn a() {}\nfn b() {}\n\n\n"), base);
1365 assert_eq!(
1367 prepared_content_hash("\u{feff}fn a() {}\nfn b() {}\n".as_bytes()),
1368 base
1369 );
1370 assert_ne!(prepared_content_hash(b"fn a() {}\nfn c() {}\n"), base);
1372 assert_eq!(base.len(), 16);
1374 assert!(
1375 base.chars()
1376 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
1377 );
1378 }
1379
1380 #[test]
1383 fn prepared_hash_preserves_interior_whitespace() {
1384 assert_ne!(
1385 prepared_content_hash(b"line one \nline two\n"),
1386 prepared_content_hash(b"line one\nline two\n")
1387 );
1388 }
1389
1390 #[test]
1393 fn prepared_hash_hashes_binary_bytes_raw() {
1394 let bin_a = [0xff_u8, 0xfe, 0x00, 0x0d, 0x0a];
1395 let bin_b = [0xff_u8, 0xfe, 0x00, 0x0a];
1396 assert_ne!(prepared_content_hash(&bin_a), prepared_content_hash(&bin_b));
1397 assert_eq!(prepared_content_hash(&bin_a), prepared_content_hash(&bin_a));
1399 }
1400
1401 fn anchor(
1404 class: AnchorProvenanceClass,
1405 hash: Option<&str>,
1406 stab: AnchorHashStability,
1407 ) -> Anchor {
1408 Anchor {
1409 artifact: "src/lib.rs".into(),
1410 grain: AnchorGrain::File,
1411 class,
1412 at_version: None,
1413 hash: hash.map(str::to_string),
1414 hash_stability: stab,
1415 derived_from: Vec::new(),
1416 binding: None,
1417 source: None,
1418 }
1419 }
1420
1421 #[test]
1422 fn resolves_when_hash_matches() {
1423 let a = anchor(
1424 AnchorProvenanceClass::Anchored,
1425 Some("h1"),
1426 AnchorHashStability::Stable,
1427 );
1428 let obs = ArtifactObservation::Present {
1429 current_hash: Some("h1".into()),
1430 };
1431 assert_eq!(resolve_anchor(&a, &obs), AnchorState::Resolves);
1432 }
1433
1434 #[test]
1435 fn stable_hash_break_drifts_unstable_rechecks() {
1436 let stable = anchor(
1437 AnchorProvenanceClass::Anchored,
1438 Some("h1"),
1439 AnchorHashStability::Stable,
1440 );
1441 let unstable = anchor(
1442 AnchorProvenanceClass::Anchored,
1443 Some("h1"),
1444 AnchorHashStability::Unstable,
1445 );
1446 let obs = ArtifactObservation::Present {
1447 current_hash: Some("h2".into()),
1448 };
1449 assert_eq!(resolve_anchor(&stable, &obs), AnchorState::Drifted);
1450 assert_eq!(resolve_anchor(&unstable, &obs), AnchorState::Recheck);
1451 }
1452
1453 #[test]
1454 fn absent_artifact_is_orphaned() {
1455 let a = anchor(
1456 AnchorProvenanceClass::Anchored,
1457 Some("h1"),
1458 AnchorHashStability::Stable,
1459 );
1460 assert_eq!(
1461 resolve_anchor(&a, &ArtifactObservation::Absent),
1462 AnchorState::Orphaned
1463 );
1464 }
1465
1466 #[test]
1467 fn non_hash_classes_never_drift() {
1468 for class in [
1469 AnchorProvenanceClass::Authored,
1470 AnchorProvenanceClass::InformedBy,
1471 ] {
1472 let a = anchor(class, None, AnchorHashStability::Stable);
1473 let obs = ArtifactObservation::Present {
1476 current_hash: Some("whatever".into()),
1477 };
1478 assert_eq!(resolve_anchor(&a, &obs), AnchorState::Resolves);
1479 assert_eq!(
1481 resolve_anchor(&a, &ArtifactObservation::Absent),
1482 AnchorState::Orphaned
1483 );
1484 }
1485 }
1486
1487 #[test]
1488 fn unavailable_hash_rechecks_not_drifts() {
1489 let a = anchor(
1490 AnchorProvenanceClass::Anchored,
1491 Some("h1"),
1492 AnchorHashStability::Stable,
1493 );
1494 let obs = ArtifactObservation::Present { current_hash: None };
1495 assert_eq!(resolve_anchor(&a, &obs), AnchorState::Recheck);
1496 }
1497
1498 #[test]
1501 fn composition_counts_classes_grains_and_tree_fanout() {
1502 let anchors = vec![
1503 Anchor {
1504 artifact: "a.rs".into(),
1505 grain: AnchorGrain::File,
1506 class: AnchorProvenanceClass::Anchored,
1507 at_version: None,
1508 hash: Some("h".into()),
1509 hash_stability: AnchorHashStability::Stable,
1510 derived_from: Vec::new(),
1511 binding: None,
1512 source: None,
1513 },
1514 Anchor {
1515 artifact: "src/".into(),
1516 grain: AnchorGrain::Tree,
1517 class: AnchorProvenanceClass::Derived,
1518 at_version: None,
1519 hash: Some("t".into()),
1520 hash_stability: AnchorHashStability::Stable,
1521 derived_from: vec!["a.rs".into(), "b.rs".into()],
1522 binding: None,
1523 source: None,
1524 },
1525 ];
1526 let comp = compose_entity_anchors(&anchors);
1527 assert_eq!(comp.by_class["anchored"], 1);
1528 assert_eq!(comp.by_class["derived"], 1);
1529 assert_eq!(comp.by_grain["file"], 1);
1530 assert_eq!(comp.by_grain["tree"], 1);
1531 assert_eq!(comp.tree_grain_artifacts, vec!["src/".to_string()]);
1533 assert_eq!(
1534 comp.derived_inputs,
1535 vec![vec!["a.rs".to_string(), "b.rs".to_string()]]
1536 );
1537 }
1538
1539 #[test]
1542 fn sidecar_round_trips_and_prunes_empty() {
1543 let mut sc = AnchorSidecar::default();
1544 assert!(sc.is_empty());
1545 let a = anchor(
1546 AnchorProvenanceClass::Anchored,
1547 Some("h1"),
1548 AnchorHashStability::Stable,
1549 );
1550 sc.set("specs--x", vec![a.clone()]);
1551 assert_eq!(sc.get("specs--x").len(), 1);
1552
1553 let bytes = sc.to_bytes();
1554 let round = AnchorSidecar::from_bytes(&bytes).unwrap();
1555 assert_eq!(round, sc);
1556
1557 sc.set("specs--x", vec![]);
1559 assert!(sc.is_empty());
1560 assert!(sc.get("specs--x").is_empty());
1561 }
1562
1563 fn file_anchor(artifact: &str, hash: &str) -> Anchor {
1566 Anchor {
1567 artifact: artifact.into(),
1568 grain: AnchorGrain::File,
1569 class: AnchorProvenanceClass::Anchored,
1570 at_version: None,
1571 hash: Some(hash.into()),
1572 hash_stability: AnchorHashStability::Stable,
1573 derived_from: Vec::new(),
1574 binding: None,
1575 source: None,
1576 }
1577 }
1578
1579 #[test]
1582 fn merge_appends_new_triple_without_touching_others() {
1583 let mut sc = AnchorSidecar::default();
1584 sc.set(
1585 "m--e",
1586 vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
1587 );
1588 sc.merge("m--e", &[], vec![file_anchor("c.rs", "h-c")]);
1589 let row = sc.get("m--e");
1590 assert_eq!(row.len(), 3);
1591 assert_eq!(row[0], file_anchor("a.rs", "h-a"));
1592 assert_eq!(row[1], file_anchor("b.rs", "h-b"));
1593 assert_eq!(row[2], file_anchor("c.rs", "h-c"));
1594 }
1595
1596 #[test]
1600 fn merge_replaces_same_triple_in_place() {
1601 let mut sc = AnchorSidecar::default();
1602 sc.set(
1603 "m--e",
1604 vec![file_anchor("a.rs", "h-old"), file_anchor("b.rs", "h-b")],
1605 );
1606 sc.merge("m--e", &[], vec![file_anchor("a.rs", "h-new")]);
1607 let row = sc.get("m--e");
1608 assert_eq!(row.len(), 2);
1609 assert_eq!(row[0], file_anchor("a.rs", "h-new"));
1610 assert_eq!(row[1], file_anchor("b.rs", "h-b"));
1611 }
1612
1613 #[test]
1617 fn merge_treats_grain_and_class_as_identity() {
1618 let mut sc = AnchorSidecar::default();
1619 sc.set("m--e", vec![file_anchor("a.rs", "h-a")]);
1620 let mut span = file_anchor("a.rs", "h-span");
1621 span.grain = AnchorGrain::Span;
1622 let mut informed = file_anchor("a.rs", "h-a");
1623 informed.class = AnchorProvenanceClass::InformedBy;
1624 informed.hash = None;
1625 sc.merge("m--e", &[], vec![span, informed]);
1626 assert_eq!(sc.get("m--e").len(), 3);
1627 }
1628
1629 #[test]
1632 fn merge_full_resend_and_empty_are_noops() {
1633 let mut sc = AnchorSidecar::default();
1634 sc.set(
1635 "m--e",
1636 vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
1637 );
1638 let before = sc.to_bytes();
1639 sc.merge(
1640 "m--e",
1641 &[],
1642 vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
1643 );
1644 assert_eq!(sc.to_bytes(), before, "full re-send is byte-stable");
1645 sc.merge("m--e", &[], Vec::new());
1646 assert_eq!(sc.to_bytes(), before, "empty merge is a no-op");
1647 }
1648
1649 #[test]
1653 fn unset_selects_by_artifact_with_optional_narrowing() {
1654 let mut span = file_anchor("a.rs", "h-span");
1655 span.grain = AnchorGrain::Span;
1656 let mut sc = AnchorSidecar::default();
1657 sc.set(
1658 "m--e",
1659 vec![
1660 file_anchor("a.rs", "h-a"),
1661 span.clone(),
1662 file_anchor("b.rs", "h-b"),
1663 ],
1664 );
1665
1666 let narrowed = AnchorUnset {
1668 artifact: "a.rs".into(),
1669 grain: Some(AnchorGrain::Span),
1670 class: None,
1671 };
1672 sc.merge("m--e", &[narrowed], Vec::new());
1673 assert_eq!(
1674 sc.get("m--e"),
1675 &[file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")]
1676 );
1677
1678 let missing = AnchorUnset {
1680 artifact: "never-there.rs".into(),
1681 grain: None,
1682 class: None,
1683 };
1684 sc.merge("m--e", &[missing], Vec::new());
1685 assert_eq!(sc.get("m--e").len(), 2);
1686
1687 let bare = AnchorUnset {
1689 artifact: "a.rs".into(),
1690 grain: None,
1691 class: None,
1692 };
1693 sc.merge("m--e", &[bare], Vec::new());
1694 assert_eq!(sc.get("m--e"), &[file_anchor("b.rs", "h-b")]);
1695 }
1696
1697 #[test]
1701 fn unset_applies_before_merge() {
1702 let mut span = file_anchor("a.rs", "h-span");
1703 span.grain = AnchorGrain::Span;
1704 let mut sc = AnchorSidecar::default();
1705 sc.set("m--e", vec![file_anchor("a.rs", "h-old"), span]);
1706 let bare = AnchorUnset {
1707 artifact: "a.rs".into(),
1708 grain: None,
1709 class: None,
1710 };
1711 sc.merge("m--e", &[bare], vec![file_anchor("a.rs", "h-new")]);
1712 assert_eq!(sc.get("m--e"), &[file_anchor("a.rs", "h-new")]);
1713 }
1714
1715 #[test]
1718 fn merge_prunes_row_emptied_by_unset() {
1719 let mut sc = AnchorSidecar::default();
1720 sc.set("m--e", vec![file_anchor("a.rs", "h-a")]);
1721 let bare = AnchorUnset {
1722 artifact: "a.rs".into(),
1723 grain: None,
1724 class: None,
1725 };
1726 sc.merge("m--e", &[bare], Vec::new());
1727 assert!(sc.is_empty());
1728 assert!(!sc.to_bytes().windows(5).any(|w| w == b"m--e\""));
1729 }
1730
1731 #[test]
1734 fn unset_input_validates_typed() {
1735 let ok = AnchorUnsetInput {
1736 artifact: Some(" a.rs ".into()),
1737 grain: Some("span".into()),
1738 class: None,
1739 }
1740 .validate()
1741 .unwrap();
1742 assert_eq!(ok.artifact, "a.rs");
1743 assert_eq!(ok.grain, Some(AnchorGrain::Span));
1744 assert_eq!(ok.class, None);
1745
1746 let missing = AnchorUnsetInput::default().validate().unwrap_err();
1747 assert!(matches!(missing, AnchorValidationError::MissingArtifact));
1748 assert_eq!(missing.code(), INVALID_ANCHOR_CODE);
1749
1750 let bad_grain = AnchorUnsetInput {
1751 artifact: Some("a.rs".into()),
1752 grain: Some("paragraph".into()),
1753 class: None,
1754 }
1755 .validate()
1756 .unwrap_err();
1757 assert!(matches!(
1758 bad_grain,
1759 AnchorValidationError::UnknownGrain { .. }
1760 ));
1761
1762 let bad_class = AnchorUnsetInput {
1763 artifact: Some("a.rs".into()),
1764 grain: None,
1765 class: Some("guessed".into()),
1766 }
1767 .validate()
1768 .unwrap_err();
1769 assert!(matches!(
1770 bad_class,
1771 AnchorValidationError::UnknownClass { .. }
1772 ));
1773 }
1774
1775 #[test]
1776 fn sidecar_rename_leaves_zero_rows_under_old_id() {
1777 let mut sc = AnchorSidecar::default();
1778 sc.set(
1779 "specs--old",
1780 vec![anchor(
1781 AnchorProvenanceClass::Anchored,
1782 Some("h"),
1783 AnchorHashStability::Stable,
1784 )],
1785 );
1786 sc.rename("specs--old", "specs--new");
1787 assert!(sc.get("specs--old").is_empty());
1788 assert_eq!(sc.get("specs--new").len(), 1);
1789 }
1790
1791 #[test]
1792 fn sidecar_remove_drops_entity_anchors() {
1793 let mut sc = AnchorSidecar::default();
1794 sc.set(
1795 "specs--gone",
1796 vec![anchor(
1797 AnchorProvenanceClass::Anchored,
1798 Some("h"),
1799 AnchorHashStability::Stable,
1800 )],
1801 );
1802 sc.remove("specs--gone");
1803 assert!(sc.get("specs--gone").is_empty());
1804 sc.remove("specs--gone");
1806 }
1807
1808 #[test]
1809 fn empty_bytes_parse_as_empty_sidecar() {
1810 assert!(AnchorSidecar::from_bytes(b"").unwrap().is_empty());
1811 assert!(AnchorSidecar::from_bytes(b" \n ").unwrap().is_empty());
1812 }
1813
1814 #[test]
1815 fn anchor_json_shape_omits_empty_optionals() {
1816 let a = anchor(
1817 AnchorProvenanceClass::Anchored,
1818 Some("h1"),
1819 AnchorHashStability::Stable,
1820 );
1821 let v = serde_json::to_value(&a).unwrap();
1822 assert_eq!(v["artifact"], "src/lib.rs");
1823 assert_eq!(v["grain"], "file");
1824 assert_eq!(v["class"], "anchored");
1825 assert_eq!(v["hash"], "h1");
1826 assert_eq!(v["hash_stability"], "stable");
1827 assert!(v.get("at_version").is_none());
1829 assert!(v.get("derived_from").is_none());
1830 assert!(v.get("binding").is_none());
1831 }
1832
1833 #[test]
1834 fn anchor_version_serialises_tagged() {
1835 let a = Anchor {
1836 at_version: Some(AnchorVersion::Commit("deadbeef".into())),
1837 ..anchor(
1838 AnchorProvenanceClass::Anchored,
1839 Some("h"),
1840 AnchorHashStability::Stable,
1841 )
1842 };
1843 let v = serde_json::to_value(&a).unwrap();
1844 assert_eq!(v["at_version"]["kind"], "commit");
1845 assert_eq!(v["at_version"]["value"], "deadbeef");
1846 }
1847
1848 #[test]
1852 fn validate_source_carried_absent_or_refused_when_empty() {
1853 let mut input = AnchorInput {
1854 artifact: Some("src/lib.rs".into()),
1855 grain: Some("file".into()),
1856 class: Some("anchored".into()),
1857 ..Default::default()
1858 };
1859 assert_eq!(
1860 input.validate(None).unwrap().source,
1861 None,
1862 "absent stays absent"
1863 );
1864
1865 input.source = Some(" api-docs ".into());
1866 assert_eq!(
1867 input.validate(None).unwrap().source.as_deref(),
1868 Some("api-docs"),
1869 "non-empty name is carried (trimmed)"
1870 );
1871
1872 input.source = Some(" ".into());
1873 let err = input.validate(None).unwrap_err();
1874 assert_eq!(err.code(), INVALID_ANCHOR_CODE);
1875 assert!(matches!(err, AnchorValidationError::EmptySource));
1876 assert_eq!(
1877 err.detail().get("field"),
1878 Some(&serde_json::json!("source"))
1879 );
1880 }
1881
1882 #[test]
1886 fn source_is_additive_on_the_persisted_shape() {
1887 let pre_plan = r#"{
1888 "artifact": "src/lib.rs",
1889 "grain": "file",
1890 "class": "anchored",
1891 "hash_stability": "stable"
1892 }"#;
1893 let a: Anchor = serde_json::from_str(pre_plan).expect("pre-plan anchor loads");
1894 assert_eq!(a.source, None, "no backfill, no default");
1895
1896 let sourced = Anchor {
1897 source: Some("api-docs".into()),
1898 ..a
1899 };
1900 let json = serde_json::to_string(&sourced).unwrap();
1901 let back: Anchor = serde_json::from_str(&json).unwrap();
1902 assert_eq!(back.source.as_deref(), Some("api-docs"));
1903 }
1904}