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)]
357 pub content: Option<String>,
358 #[serde(default)]
359 pub hash_stability: Option<String>,
360 #[serde(default)]
361 pub derived_from: Option<Vec<String>>,
362 #[serde(default)]
363 pub binding: Option<String>,
364 #[serde(default)]
365 pub source: Option<String>,
366}
367
368#[derive(Debug, Clone, Default, Serialize, Deserialize)]
376pub struct AnchorUnsetInput {
377 #[serde(default)]
378 pub artifact: Option<String>,
379 #[serde(default)]
380 pub grain: Option<String>,
381 #[serde(default)]
382 pub class: Option<String>,
383}
384
385impl AnchorUnsetInput {
386 pub fn validate(&self) -> Result<AnchorUnset, AnchorValidationError> {
390 let artifact = self
391 .artifact
392 .as_deref()
393 .map(str::trim)
394 .filter(|s| !s.is_empty())
395 .map(str::to_string)
396 .ok_or(AnchorValidationError::MissingArtifact)?;
397 let grain = match self.grain.as_deref() {
398 None => None,
399 Some(s) => Some(AnchorGrain::from_wire(s).ok_or_else(|| {
400 AnchorValidationError::UnknownGrain {
401 got: Some(s.to_string()),
402 allowed: AnchorGrain::WIRE_VALUES,
403 }
404 })?),
405 };
406 let class = match self.class.as_deref() {
407 None => None,
408 Some(s) => Some(AnchorProvenanceClass::from_wire(s).ok_or_else(|| {
409 AnchorValidationError::UnknownClass {
410 got: Some(s.to_string()),
411 allowed: AnchorProvenanceClass::WIRE_VALUES,
412 }
413 })?),
414 };
415 Ok(AnchorUnset {
416 artifact,
417 grain,
418 class,
419 })
420 }
421}
422
423#[derive(Debug, Clone, PartialEq, Eq)]
429pub struct AnchorUnset {
430 pub artifact: String,
432 pub grain: Option<AnchorGrain>,
434 pub class: Option<AnchorProvenanceClass>,
436}
437
438impl AnchorUnset {
439 pub fn matches(&self, anchor: &Anchor) -> bool {
441 anchor.artifact == self.artifact
442 && self.grain.is_none_or(|g| anchor.grain == g)
443 && self.class.is_none_or(|c| anchor.class == c)
444 }
445}
446
447#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
451pub enum AnchorValidationError {
452 #[error("unknown anchor provenance class {got:?}; allowed: {}", allowed.join(", "))]
454 UnknownClass {
455 got: Option<String>,
456 allowed: &'static [&'static str],
457 },
458 #[error("unknown anchor grain {got:?}; allowed: {}", allowed.join(", "))]
460 UnknownGrain {
461 got: Option<String>,
462 allowed: &'static [&'static str],
463 },
464 #[error("unknown anchor hash stability {got:?}; allowed: {}", allowed.join(", "))]
466 UnknownHashStability {
467 got: String,
468 allowed: &'static [&'static str],
469 },
470 #[error("anchor is missing its artifact reference")]
472 MissingArtifact,
473 #[error("anchor class '{class}' carries no hash semantics — a content hash is not permitted")]
476 HashOnNonHashClass { class: &'static str },
477 #[error(
480 "anchor supplies both `hash` and `content`; supply one — the engine computes the hash from `content`"
481 )]
482 ContentAndHash,
483 #[error(
488 "anchor grain '{grain}' does not accept `content`: its prepared form is not computed \
489 from supplied bytes (accepted for span / file / url)"
490 )]
491 ContentNotAcceptedForGrain { grain: &'static str },
492 #[error(
495 "anchor artifact {artifact:?} names a delivery unit the supplied `content` does not \
496 yield; supply the whole file's content, or address a unit it contains"
497 )]
498 UnitAbsentFromContent { artifact: String },
499 #[error("anchor `source`, when present, must be a non-empty source name")]
503 EmptySource,
504 #[error(
513 "anchor `source` {got:?} is not declared by the anchor's producing binding; \
514 declared sources: {}",
515 declared.join(", ")
516 )]
517 SourceNotDeclared { got: String, declared: Vec<String> },
518 #[error(
525 "anchor artifact {artifact:?} resolves under no candidate path (tried: {}); artifact \
526 paths are source-relative (joined onto the source's pointer) or workspace-relative — \
527 write the path exactly as the brief lists it",
528 candidates.join(", ")
529 )]
530 ArtifactUnresolvable {
531 artifact: String,
532 candidates: Vec<String>,
533 },
534 #[error(
537 "anchor grain '{grain}' is unsupported by a '{medium_type}' medium: its \
538 '{anchor_namespace}' namespace does not admit that grain"
539 )]
540 GrainNamespaceUnsupported {
541 grain: &'static str,
542 medium_type: String,
543 anchor_namespace: &'static str,
544 },
545}
546
547impl AnchorValidationError {
548 pub fn code(&self) -> &'static str {
550 INVALID_ANCHOR_CODE
551 }
552
553 pub fn detail(&self) -> BTreeMap<String, serde_json::Value> {
556 let mut d = BTreeMap::new();
557 match self {
558 AnchorValidationError::UnknownClass { got, allowed } => {
559 d.insert("field".into(), "class".into());
560 d.insert("got".into(), serde_json::json!(got));
561 d.insert("allowed".into(), serde_json::json!(allowed));
562 }
563 AnchorValidationError::UnknownGrain { got, allowed } => {
564 d.insert("field".into(), "grain".into());
565 d.insert("got".into(), serde_json::json!(got));
566 d.insert("allowed".into(), serde_json::json!(allowed));
567 }
568 AnchorValidationError::UnknownHashStability { got, allowed } => {
569 d.insert("field".into(), "hash_stability".into());
570 d.insert("got".into(), serde_json::json!(got));
571 d.insert("allowed".into(), serde_json::json!(allowed));
572 }
573 AnchorValidationError::MissingArtifact => {
574 d.insert("field".into(), "artifact".into());
575 }
576 AnchorValidationError::EmptySource => {
577 d.insert("field".into(), "source".into());
578 }
579 AnchorValidationError::SourceNotDeclared { got, declared } => {
580 d.insert("field".into(), "source".into());
581 d.insert("got".into(), serde_json::json!(got));
582 d.insert("declared".into(), serde_json::json!(declared));
583 }
584 AnchorValidationError::HashOnNonHashClass { class } => {
585 d.insert("field".into(), "hash".into());
586 d.insert("class".into(), serde_json::json!(class));
587 }
588 AnchorValidationError::ContentAndHash => {
589 d.insert("field".into(), "content".into());
590 d.insert(
591 "expected".into(),
592 serde_json::json!("either `hash` or `content`, never both"),
593 );
594 }
595 AnchorValidationError::ContentNotAcceptedForGrain { grain } => {
596 d.insert("field".into(), "content".into());
597 d.insert("grain".into(), serde_json::json!(grain));
598 d.insert(
599 "accepted_grains".into(),
600 serde_json::json!(["span", "file", "url"]),
601 );
602 }
603 AnchorValidationError::UnitAbsentFromContent { artifact } => {
604 d.insert("field".into(), "content".into());
605 d.insert("got".into(), serde_json::json!(artifact));
606 }
607 AnchorValidationError::ArtifactUnresolvable {
608 artifact,
609 candidates,
610 } => {
611 d.insert("field".into(), "artifact".into());
612 d.insert("got".into(), serde_json::json!(artifact));
613 d.insert("candidates_tried".into(), serde_json::json!(candidates));
614 d.insert(
615 "expected".into(),
616 serde_json::json!(
617 "a source-relative path (joined onto the source's pointer) or a \
618 workspace-relative path that resolves to an existing artifact"
619 ),
620 );
621 }
622 AnchorValidationError::GrainNamespaceUnsupported {
623 grain,
624 medium_type,
625 anchor_namespace,
626 } => {
627 d.insert("field".into(), "grain".into());
628 d.insert("grain".into(), serde_json::json!(grain));
629 d.insert("medium_type".into(), serde_json::json!(medium_type));
630 d.insert(
631 "anchor_namespace".into(),
632 serde_json::json!(anchor_namespace),
633 );
634 }
635 }
636 d
637 }
638}
639
640impl AnchorInput {
641 pub fn validate(&self, medium: Option<(&str, &str)>) -> Result<Anchor, AnchorValidationError> {
664 let class = match self
665 .class
666 .as_deref()
667 .and_then(AnchorProvenanceClass::from_wire)
668 {
669 Some(c) => c,
670 None => {
671 return Err(AnchorValidationError::UnknownClass {
672 got: self.class.clone(),
673 allowed: AnchorProvenanceClass::WIRE_VALUES,
674 });
675 }
676 };
677 let grain = match self.grain.as_deref().and_then(AnchorGrain::from_wire) {
678 Some(g) => g,
679 None => {
680 return Err(AnchorValidationError::UnknownGrain {
681 got: self.grain.clone(),
682 allowed: AnchorGrain::WIRE_VALUES,
683 });
684 }
685 };
686
687 let artifact = self
688 .artifact
689 .as_deref()
690 .map(str::trim)
691 .filter(|s| !s.is_empty())
692 .map(str::to_string)
693 .ok_or(AnchorValidationError::MissingArtifact)?;
694
695 let hash_stability = match self.hash_stability.as_deref() {
698 None => crate::preparation::default_hash_stability(grain),
699 Some(s) => AnchorHashStability::from_wire(s).ok_or_else(|| {
700 AnchorValidationError::UnknownHashStability {
701 got: s.to_string(),
702 allowed: AnchorHashStability::WIRE_VALUES,
703 }
704 })?,
705 };
706
707 let hash = self
709 .hash
710 .as_deref()
711 .map(str::trim)
712 .filter(|s| !s.is_empty())
713 .map(str::to_string);
714 if (hash.is_some() || self.content.is_some()) && !class.is_hash_bearing() {
715 return Err(AnchorValidationError::HashOnNonHashClass {
716 class: class.as_wire(),
717 });
718 }
719 let hash = match self.content.as_deref() {
723 None => hash,
724 Some(_) if hash.is_some() => return Err(AnchorValidationError::ContentAndHash),
725 Some(content) => {
726 match crate::preparation::supplied_content_hash(grain, content.as_bytes()) {
727 Some(h) => Some(h),
728 None => {
729 return Err(AnchorValidationError::ContentNotAcceptedForGrain {
730 grain: grain.as_wire(),
731 });
732 }
733 }
734 }
735 };
736
737 if let Some((medium_type, namespace)) = medium
739 && !grain.supported_by_namespace(namespace)
740 {
741 let anchor_namespace = match namespace {
744 "path" => "path",
745 "path+commit" => "path+commit",
746 "entity" => "entity",
747 "url" => "url",
748 _ => "path",
749 };
750 return Err(AnchorValidationError::GrainNamespaceUnsupported {
751 grain: grain.as_wire(),
752 medium_type: medium_type.to_string(),
753 anchor_namespace,
754 });
755 }
756
757 let source = match self.source.as_deref() {
762 None => None,
763 Some(raw) => {
764 let trimmed = raw.trim();
765 if trimmed.is_empty() {
766 return Err(AnchorValidationError::EmptySource);
767 }
768 Some(trimmed.to_string())
769 }
770 };
771
772 Ok(Anchor {
773 artifact,
774 grain,
775 class,
776 at_version: self.at_version.clone(),
777 hash,
778 hash_stability,
779 derived_from: self.derived_from.clone().unwrap_or_default(),
780 binding: self
781 .binding
782 .as_deref()
783 .map(str::trim)
784 .filter(|s| !s.is_empty())
785 .map(str::to_string),
786 source,
787 })
788 }
789}
790
791pub fn prepared_content_hash(bytes: &[u8]) -> String {
818 use sha2::{Digest as _, Sha256};
819 let digest = match std::str::from_utf8(bytes) {
820 Ok(text) => {
821 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
822 let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
823 Sha256::digest(normalized.trim_end_matches('\n').as_bytes())
824 }
825 Err(_) => Sha256::digest(bytes),
826 };
827 crate::hex_lower(&digest)[..16].to_string()
828}
829
830#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
835pub struct ObservedArtifactHash {
836 pub entity: String,
838 pub artifact: String,
840 pub hash: String,
842}
843
844#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
850#[serde(rename_all = "lowercase")]
851pub enum AnchorState {
852 Resolves,
855 Drifted,
858 Recheck,
862 Orphaned,
865}
866
867impl AnchorState {
868 pub fn as_wire(&self) -> &'static str {
870 match self {
871 AnchorState::Resolves => "resolves",
872 AnchorState::Drifted => "drifted",
873 AnchorState::Recheck => "recheck",
874 AnchorState::Orphaned => "orphaned",
875 }
876 }
877}
878
879#[derive(Debug, Clone, PartialEq, Eq)]
881pub enum ArtifactObservation {
882 Absent,
884 Present { current_hash: Option<String> },
888}
889
890pub fn resolve_anchor(anchor: &Anchor, observation: &ArtifactObservation) -> AnchorState {
902 let current_hash = match observation {
903 ArtifactObservation::Absent => return AnchorState::Orphaned,
904 ArtifactObservation::Present { current_hash } => current_hash,
905 };
906 if !anchor.class.is_hash_bearing() {
907 return AnchorState::Resolves;
908 }
909 match (&anchor.hash, current_hash) {
910 (Some(recorded), Some(current)) if recorded == current => AnchorState::Resolves,
911 (Some(_), Some(_)) => match anchor.hash_stability {
912 AnchorHashStability::Stable => AnchorState::Drifted,
913 AnchorHashStability::Unstable => AnchorState::Recheck,
914 },
915 _ => AnchorState::Recheck,
917 }
918}
919
920#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
926pub struct EntityAnchorComposition {
927 pub by_class: BTreeMap<String, usize>,
929 pub by_grain: BTreeMap<String, usize>,
931 pub derived_inputs: Vec<Vec<String>>,
934 pub tree_grain_artifacts: Vec<String>,
939}
940
941pub fn compose_entity_anchors(anchors: &[Anchor]) -> EntityAnchorComposition {
944 let mut comp = EntityAnchorComposition::default();
945 for a in anchors {
946 *comp
947 .by_class
948 .entry(a.class.as_wire().to_string())
949 .or_insert(0) += 1;
950 *comp
951 .by_grain
952 .entry(a.grain.as_wire().to_string())
953 .or_insert(0) += 1;
954 if a.class == AnchorProvenanceClass::Derived {
955 comp.derived_inputs.push(a.derived_from.clone());
956 }
957 if a.grain == AnchorGrain::Tree {
958 comp.tree_grain_artifacts.push(a.artifact.clone());
959 }
960 }
961 comp
962}
963
964#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
976pub struct AnchorSidecar {
977 pub version: u32,
979 #[serde(default)]
982 pub entities: BTreeMap<String, Vec<Anchor>>,
983}
984
985impl Default for AnchorSidecar {
986 fn default() -> Self {
987 Self {
988 version: ANCHOR_SIDECAR_VERSION,
989 entities: BTreeMap::new(),
990 }
991 }
992}
993
994impl AnchorSidecar {
995 pub fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
998 if bytes.iter().all(u8::is_ascii_whitespace) {
999 return Ok(Self::default());
1000 }
1001 let sidecar: Self = serde_json::from_slice(bytes)?;
1002 if sidecar.version != ANCHOR_SIDECAR_VERSION {
1011 return Err(serde::de::Error::custom(format!(
1012 "unsupported anchors sidecar version {} (this engine reads version {}) — \
1013 the file was written by a different engine; upgrade, or remove the sidecar \
1014 to re-record anchors",
1015 sidecar.version, ANCHOR_SIDECAR_VERSION
1016 )));
1017 }
1018 Ok(sidecar)
1019 }
1020
1021 pub fn to_bytes(&self) -> Vec<u8> {
1024 let mut s = serde_json::to_string_pretty(self).expect("anchor sidecar serialises");
1025 s.push('\n');
1026 s.into_bytes()
1027 }
1028
1029 pub fn get(&self, entity_id: &str) -> &[Anchor] {
1031 self.entities
1032 .get(entity_id)
1033 .map(Vec::as_slice)
1034 .unwrap_or(&[])
1035 }
1036
1037 pub fn set(&mut self, entity_id: &str, anchors: Vec<Anchor>) {
1040 if anchors.is_empty() {
1041 self.entities.remove(entity_id);
1042 } else {
1043 self.entities.insert(entity_id.to_string(), anchors);
1044 }
1045 }
1046
1047 pub fn merge(&mut self, entity_id: &str, unsets: &[AnchorUnset], incoming: Vec<Anchor>) {
1059 let mut row = self.entities.remove(entity_id).unwrap_or_default();
1060 row.retain(|a| !unsets.iter().any(|u| u.matches(a)));
1061 for anchor in incoming {
1062 match row.iter_mut().find(|e| {
1063 e.artifact == anchor.artifact && e.grain == anchor.grain && e.class == anchor.class
1064 }) {
1065 Some(existing) => *existing = anchor,
1066 None => row.push(anchor),
1067 }
1068 }
1069 if !row.is_empty() {
1070 self.entities.insert(entity_id.to_string(), row);
1071 }
1072 }
1073
1074 pub fn redact_artifact_references(&mut self) {
1082 for anchors in self.entities.values_mut() {
1083 for anchor in anchors {
1084 anchor.artifact = REDACTED_ARTIFACT_SENTINEL.to_string();
1085 for input in &mut anchor.derived_from {
1086 *input = REDACTED_ARTIFACT_SENTINEL.to_string();
1087 }
1088 }
1089 }
1090 }
1091
1092 pub fn validate_artifact_references(&self) -> Result<(), String> {
1098 for (entity_id, anchors) in &self.entities {
1099 for anchor in anchors {
1100 if anchor.artifact.trim().is_empty() {
1101 return Err(format!(
1102 "entity `{entity_id}` carries an anchor with an empty artifact \
1103 reference"
1104 ));
1105 }
1106 if anchor.derived_from.iter().any(|d| d.trim().is_empty()) {
1107 return Err(format!(
1108 "entity `{entity_id}` carries an anchor with an empty \
1109 `derived_from` entry"
1110 ));
1111 }
1112 }
1113 }
1114 Ok(())
1115 }
1116
1117 pub fn remove(&mut self, entity_id: &str) {
1119 self.entities.remove(entity_id);
1120 }
1121
1122 pub fn rename(&mut self, from: &str, to: &str) {
1127 if let Some(anchors) = self.entities.remove(from) {
1128 self.entities.insert(to.to_string(), anchors);
1129 }
1130 }
1131
1132 pub fn is_empty(&self) -> bool {
1134 self.entities.is_empty()
1135 }
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140 use super::*;
1141
1142 #[test]
1147 fn redaction_blanks_references_and_keeps_trust_metadata() {
1148 let mut sidecar = AnchorSidecar::default();
1149 sidecar.set(
1150 "m--alpha",
1151 vec![
1152 Anchor {
1153 artifact: "src/lib.rs".into(),
1154 grain: AnchorGrain::File,
1155 class: AnchorProvenanceClass::Anchored,
1156 at_version: Some(AnchorVersion::Commit("abc123".into())),
1157 hash: Some("h1".into()),
1158 hash_stability: AnchorHashStability::Stable,
1159 derived_from: vec![],
1160 binding: Some("bhash".into()),
1161 source: Some("source-tree".into()),
1162 },
1163 Anchor {
1164 artifact: "docs/summary.md".into(),
1165 grain: AnchorGrain::File,
1166 class: AnchorProvenanceClass::Derived,
1167 at_version: None,
1168 hash: Some("h2".into()),
1169 hash_stability: AnchorHashStability::Unstable,
1170 derived_from: vec!["notes/a.md".into(), "notes/b.md".into()],
1171 binding: None,
1172 source: None,
1173 },
1174 ],
1175 );
1176
1177 sidecar.redact_artifact_references();
1178
1179 let anchors = sidecar.get("m--alpha");
1180 assert_eq!(anchors.len(), 2, "no anchor entry is dropped");
1181 for a in anchors {
1182 assert_eq!(a.artifact, REDACTED_ARTIFACT_SENTINEL);
1183 for d in &a.derived_from {
1184 assert_eq!(d, REDACTED_ARTIFACT_SENTINEL);
1185 }
1186 }
1187 assert_eq!(
1188 anchors[0].at_version,
1189 Some(AnchorVersion::Commit("abc123".into()))
1190 );
1191 assert_eq!(anchors[0].hash.as_deref(), Some("h1"));
1192 assert_eq!(anchors[0].binding.as_deref(), Some("bhash"));
1193 assert_eq!(anchors[0].source.as_deref(), Some("source-tree"));
1194 assert_eq!(anchors[1].class, AnchorProvenanceClass::Derived);
1195 assert_eq!(anchors[1].derived_from.len(), 2, "derivation arity kept");
1196 sidecar.validate_artifact_references().unwrap();
1199 }
1200
1201 #[test]
1205 fn empty_artifact_references_are_refused() {
1206 let mut sidecar = AnchorSidecar::default();
1207 sidecar.set(
1208 "m--alpha",
1209 vec![Anchor {
1210 artifact: "".into(),
1211 grain: AnchorGrain::File,
1212 class: AnchorProvenanceClass::Anchored,
1213 at_version: None,
1214 hash: None,
1215 hash_stability: AnchorHashStability::Stable,
1216 derived_from: vec![],
1217 binding: None,
1218 source: None,
1219 }],
1220 );
1221 assert!(sidecar.validate_artifact_references().is_err());
1222
1223 let mut sidecar = AnchorSidecar::default();
1224 sidecar.set(
1225 "m--beta",
1226 vec![Anchor {
1227 artifact: "docs/x.md".into(),
1228 grain: AnchorGrain::File,
1229 class: AnchorProvenanceClass::Derived,
1230 at_version: None,
1231 hash: None,
1232 hash_stability: AnchorHashStability::Stable,
1233 derived_from: vec![" ".into()],
1234 binding: None,
1235 source: None,
1236 }],
1237 );
1238 assert!(sidecar.validate_artifact_references().is_err());
1239 }
1240
1241 #[test]
1244 fn class_wire_strings_are_stable() {
1245 assert_eq!(AnchorProvenanceClass::Anchored.as_wire(), "anchored");
1246 assert_eq!(AnchorProvenanceClass::Derived.as_wire(), "derived");
1247 assert_eq!(AnchorProvenanceClass::Authored.as_wire(), "authored");
1248 assert_eq!(AnchorProvenanceClass::InformedBy.as_wire(), "informed-by");
1249 for w in AnchorProvenanceClass::WIRE_VALUES {
1250 assert_eq!(AnchorProvenanceClass::from_wire(w).unwrap().as_wire(), *w);
1251 }
1252 assert!(AnchorProvenanceClass::from_wire("bogus").is_none());
1253 }
1254
1255 #[test]
1256 fn grain_wire_strings_are_stable() {
1257 for w in AnchorGrain::WIRE_VALUES {
1258 assert_eq!(AnchorGrain::from_wire(w).unwrap().as_wire(), *w);
1259 }
1260 assert_eq!(
1261 AnchorGrain::WIRE_VALUES,
1262 &["span", "file", "tree", "url", "entity"]
1263 );
1264 assert!(AnchorGrain::from_wire("chunk").is_none());
1265 }
1266
1267 #[test]
1268 fn stability_and_state_wire_strings_are_stable() {
1269 assert_eq!(AnchorHashStability::Stable.as_wire(), "stable");
1270 assert_eq!(AnchorHashStability::Unstable.as_wire(), "unstable");
1271 assert_eq!(AnchorState::Resolves.as_wire(), "resolves");
1272 assert_eq!(AnchorState::Drifted.as_wire(), "drifted");
1273 assert_eq!(AnchorState::Recheck.as_wire(), "recheck");
1274 assert_eq!(AnchorState::Orphaned.as_wire(), "orphaned");
1275 }
1276
1277 #[test]
1278 fn only_anchored_and_derived_are_hash_bearing() {
1279 assert!(AnchorProvenanceClass::Anchored.is_hash_bearing());
1280 assert!(AnchorProvenanceClass::Derived.is_hash_bearing());
1281 assert!(!AnchorProvenanceClass::Authored.is_hash_bearing());
1282 assert!(!AnchorProvenanceClass::InformedBy.is_hash_bearing());
1283 }
1284
1285 #[test]
1288 fn grain_namespace_support_matches_capability_matrix() {
1289 for g in [AnchorGrain::Span, AnchorGrain::File, AnchorGrain::Tree] {
1291 assert!(g.supported_by_namespace("path"));
1292 assert!(g.supported_by_namespace("path+commit"));
1293 assert!(!g.supported_by_namespace("url"));
1294 assert!(!g.supported_by_namespace("entity"));
1295 }
1296 assert!(AnchorGrain::Url.supported_by_namespace("url"));
1297 assert!(!AnchorGrain::Url.supported_by_namespace("path"));
1298 assert!(AnchorGrain::Entity.supported_by_namespace("entity"));
1299 assert!(!AnchorGrain::Entity.supported_by_namespace("path"));
1300 }
1301
1302 fn valid_input() -> AnchorInput {
1305 AnchorInput {
1306 artifact: Some("src/lib.rs".into()),
1307 grain: Some("file".into()),
1308 class: Some("anchored".into()),
1309 hash_stability: Some("stable".into()),
1310 hash: Some("abc123".into()),
1311 ..Default::default()
1312 }
1313 }
1314
1315 #[test]
1316 fn validate_accepts_a_well_formed_anchor() {
1317 let a = valid_input().validate(Some(("codebase", "path"))).unwrap();
1318 assert_eq!(a.artifact, "src/lib.rs");
1319 assert_eq!(a.grain, AnchorGrain::File);
1320 assert_eq!(a.class, AnchorProvenanceClass::Anchored);
1321 assert_eq!(a.hash.as_deref(), Some("abc123"));
1322 assert_eq!(a.hash_stability, AnchorHashStability::Stable);
1323 }
1324
1325 #[test]
1328 fn validate_defaults_hash_stability_to_stable() {
1329 for grain in ["span", "file", "tree"] {
1330 let mut i = valid_input();
1331 i.grain = Some(grain.into());
1332 i.hash_stability = None;
1333 let a = i.validate(None).unwrap();
1334 assert_eq!(a.hash_stability, AnchorHashStability::Stable, "{grain}");
1335 }
1336 let mut e = valid_input();
1337 e.grain = Some("entity".into());
1338 e.artifact = Some("m--e".into());
1339 e.hash_stability = None;
1340 assert_eq!(
1341 e.validate(None).unwrap().hash_stability,
1342 AnchorHashStability::Stable
1343 );
1344 }
1345
1346 #[test]
1350 fn validate_defaults_url_grain_to_unstable_unless_declared() {
1351 let mut i = valid_input();
1352 i.grain = Some("url".into());
1353 i.artifact = Some("https://example.invalid/doc".into());
1354 i.hash_stability = None;
1355 assert_eq!(
1356 i.validate(None).unwrap().hash_stability,
1357 AnchorHashStability::Unstable
1358 );
1359 i.hash_stability = Some("stable".into());
1360 assert_eq!(
1361 i.validate(None).unwrap().hash_stability,
1362 AnchorHashStability::Stable
1363 );
1364 }
1365
1366 #[test]
1373 fn content_yields_the_prepared_hash_through_the_registry() {
1374 let mut u = valid_input();
1375 u.grain = Some("url".into());
1376 u.artifact = Some("https://example.invalid/doc".into());
1377 u.hash = None;
1378 u.hash_stability = None;
1379 u.content = Some("<p>hello</p>\r\n".into());
1380 let a = u.validate(None).unwrap();
1381 assert_eq!(
1382 a.hash.as_deref(),
1383 Some(crate::preparation::url_prepared_hash(b"<p>hello</p>\n").as_str())
1384 );
1385 assert_eq!(a.hash_stability, AnchorHashStability::Unstable);
1386
1387 let mut f = valid_input();
1388 f.hash = None;
1389 f.content = Some("fn a() {}\n".into());
1390 assert_eq!(
1391 f.validate(None).unwrap().hash.as_deref(),
1392 Some(prepared_content_hash(b"fn a() {}").as_str())
1393 );
1394
1395 let mut both = valid_input();
1396 both.content = Some("x".into());
1397 assert_eq!(
1398 both.validate(None).unwrap_err(),
1399 AnchorValidationError::ContentAndHash
1400 );
1401
1402 let mut ent = valid_input();
1403 ent.grain = Some("entity".into());
1404 ent.artifact = Some("m--e".into());
1405 ent.hash = None;
1406 ent.content = Some("x".into());
1407 let err = ent.validate(None).unwrap_err();
1408 assert_eq!(
1409 err,
1410 AnchorValidationError::ContentNotAcceptedForGrain { grain: "entity" }
1411 );
1412 assert_eq!(err.detail()["field"], "content");
1413
1414 let mut tree = valid_input();
1415 tree.grain = Some("tree".into());
1416 tree.hash = None;
1417 tree.content = Some("x".into());
1418 assert!(matches!(
1419 tree.validate(None).unwrap_err(),
1420 AnchorValidationError::ContentNotAcceptedForGrain { grain: "tree" }
1421 ));
1422
1423 let mut informed = valid_input();
1424 informed.class = Some("informed-by".into());
1425 informed.hash = None;
1426 informed.content = Some("x".into());
1427 assert!(matches!(
1428 informed.validate(None).unwrap_err(),
1429 AnchorValidationError::HashOnNonHashClass { .. }
1430 ));
1431 }
1432
1433 #[test]
1434 fn validate_refuses_unknown_class() {
1435 let mut i = valid_input();
1436 i.class = Some("guessed".into());
1437 let err = i.validate(None).unwrap_err();
1438 assert_eq!(err.code(), INVALID_ANCHOR_CODE);
1439 assert!(matches!(err, AnchorValidationError::UnknownClass { .. }));
1440 assert_eq!(err.detail()["field"], serde_json::json!("class"));
1441 }
1442
1443 #[test]
1444 fn validate_refuses_unknown_grain() {
1445 let mut i = valid_input();
1446 i.grain = Some("paragraph".into());
1447 let err = i.validate(None).unwrap_err();
1448 assert!(matches!(err, AnchorValidationError::UnknownGrain { .. }));
1449 }
1450
1451 #[test]
1452 fn validate_refuses_missing_artifact() {
1453 let mut i = valid_input();
1454 i.artifact = Some(" ".into());
1455 let err = i.validate(None).unwrap_err();
1456 assert!(matches!(err, AnchorValidationError::MissingArtifact));
1457 i.artifact = None;
1458 assert!(matches!(
1459 valid_input_with_artifact(None).validate(None).unwrap_err(),
1460 AnchorValidationError::MissingArtifact
1461 ));
1462 let _ = i;
1463 }
1464
1465 fn valid_input_with_artifact(a: Option<String>) -> AnchorInput {
1466 AnchorInput {
1467 artifact: a,
1468 ..valid_input()
1469 }
1470 }
1471
1472 #[test]
1473 fn validate_refuses_hash_on_non_hash_class() {
1474 let mut i = valid_input();
1475 i.class = Some("authored".into());
1476 let err = i.validate(None).unwrap_err();
1478 assert!(matches!(
1479 err,
1480 AnchorValidationError::HashOnNonHashClass { class: "authored" }
1481 ));
1482 }
1483
1484 #[test]
1485 fn validate_accepts_non_hash_class_without_hash() {
1486 let mut i = valid_input();
1487 i.class = Some("informed-by".into());
1488 i.hash = None;
1489 let a = i.validate(None).unwrap();
1490 assert_eq!(a.class, AnchorProvenanceClass::InformedBy);
1491 assert!(a.hash.is_none());
1492 }
1493
1494 #[test]
1495 fn validate_refuses_grain_unsupported_by_medium_namespace() {
1496 let mut i = valid_input();
1498 i.grain = Some("span".into());
1499 i.class = Some("authored".into());
1500 i.hash = None;
1501 let err = i.validate(Some(("web", "url"))).unwrap_err();
1502 match err {
1503 AnchorValidationError::GrainNamespaceUnsupported {
1504 grain,
1505 anchor_namespace,
1506 ..
1507 } => {
1508 assert_eq!(grain, "span");
1509 assert_eq!(anchor_namespace, "url");
1510 }
1511 other => panic!("expected GrainNamespaceUnsupported, got {other:?}"),
1512 }
1513 }
1514
1515 #[test]
1516 fn validate_skips_namespace_check_without_medium_context() {
1517 let mut i = valid_input();
1519 i.grain = Some("span".into());
1520 assert!(i.validate(None).is_ok());
1521 }
1522
1523 #[test]
1529 fn prepared_hash_is_stable_across_byte_noise() {
1530 let base = prepared_content_hash(b"fn a() {}\nfn b() {}\n");
1531 assert_eq!(prepared_content_hash(b"fn a() {}\r\nfn b() {}\r\n"), base);
1533 assert_eq!(prepared_content_hash(b"fn a() {}\rfn b() {}\r"), base);
1534 assert_eq!(prepared_content_hash(b"fn a() {}\nfn b() {}"), base);
1536 assert_eq!(prepared_content_hash(b"fn a() {}\nfn b() {}\n\n\n"), base);
1537 assert_eq!(
1539 prepared_content_hash("\u{feff}fn a() {}\nfn b() {}\n".as_bytes()),
1540 base
1541 );
1542 assert_ne!(prepared_content_hash(b"fn a() {}\nfn c() {}\n"), base);
1544 assert_eq!(base.len(), 16);
1546 assert!(
1547 base.chars()
1548 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
1549 );
1550 }
1551
1552 #[test]
1555 fn prepared_hash_preserves_interior_whitespace() {
1556 assert_ne!(
1557 prepared_content_hash(b"line one \nline two\n"),
1558 prepared_content_hash(b"line one\nline two\n")
1559 );
1560 }
1561
1562 #[test]
1565 fn prepared_hash_hashes_binary_bytes_raw() {
1566 let bin_a = [0xff_u8, 0xfe, 0x00, 0x0d, 0x0a];
1567 let bin_b = [0xff_u8, 0xfe, 0x00, 0x0a];
1568 assert_ne!(prepared_content_hash(&bin_a), prepared_content_hash(&bin_b));
1569 assert_eq!(prepared_content_hash(&bin_a), prepared_content_hash(&bin_a));
1571 }
1572
1573 fn anchor(
1576 class: AnchorProvenanceClass,
1577 hash: Option<&str>,
1578 stab: AnchorHashStability,
1579 ) -> Anchor {
1580 Anchor {
1581 artifact: "src/lib.rs".into(),
1582 grain: AnchorGrain::File,
1583 class,
1584 at_version: None,
1585 hash: hash.map(str::to_string),
1586 hash_stability: stab,
1587 derived_from: Vec::new(),
1588 binding: None,
1589 source: None,
1590 }
1591 }
1592
1593 #[test]
1594 fn resolves_when_hash_matches() {
1595 let a = anchor(
1596 AnchorProvenanceClass::Anchored,
1597 Some("h1"),
1598 AnchorHashStability::Stable,
1599 );
1600 let obs = ArtifactObservation::Present {
1601 current_hash: Some("h1".into()),
1602 };
1603 assert_eq!(resolve_anchor(&a, &obs), AnchorState::Resolves);
1604 }
1605
1606 #[test]
1607 fn stable_hash_break_drifts_unstable_rechecks() {
1608 let stable = anchor(
1609 AnchorProvenanceClass::Anchored,
1610 Some("h1"),
1611 AnchorHashStability::Stable,
1612 );
1613 let unstable = anchor(
1614 AnchorProvenanceClass::Anchored,
1615 Some("h1"),
1616 AnchorHashStability::Unstable,
1617 );
1618 let obs = ArtifactObservation::Present {
1619 current_hash: Some("h2".into()),
1620 };
1621 assert_eq!(resolve_anchor(&stable, &obs), AnchorState::Drifted);
1622 assert_eq!(resolve_anchor(&unstable, &obs), AnchorState::Recheck);
1623 }
1624
1625 #[test]
1626 fn absent_artifact_is_orphaned() {
1627 let a = anchor(
1628 AnchorProvenanceClass::Anchored,
1629 Some("h1"),
1630 AnchorHashStability::Stable,
1631 );
1632 assert_eq!(
1633 resolve_anchor(&a, &ArtifactObservation::Absent),
1634 AnchorState::Orphaned
1635 );
1636 }
1637
1638 #[test]
1639 fn non_hash_classes_never_drift() {
1640 for class in [
1641 AnchorProvenanceClass::Authored,
1642 AnchorProvenanceClass::InformedBy,
1643 ] {
1644 let a = anchor(class, None, AnchorHashStability::Stable);
1645 let obs = ArtifactObservation::Present {
1648 current_hash: Some("whatever".into()),
1649 };
1650 assert_eq!(resolve_anchor(&a, &obs), AnchorState::Resolves);
1651 assert_eq!(
1653 resolve_anchor(&a, &ArtifactObservation::Absent),
1654 AnchorState::Orphaned
1655 );
1656 }
1657 }
1658
1659 #[test]
1660 fn unavailable_hash_rechecks_not_drifts() {
1661 let a = anchor(
1662 AnchorProvenanceClass::Anchored,
1663 Some("h1"),
1664 AnchorHashStability::Stable,
1665 );
1666 let obs = ArtifactObservation::Present { current_hash: None };
1667 assert_eq!(resolve_anchor(&a, &obs), AnchorState::Recheck);
1668 }
1669
1670 #[test]
1673 fn composition_counts_classes_grains_and_tree_fanout() {
1674 let anchors = vec![
1675 Anchor {
1676 artifact: "a.rs".into(),
1677 grain: AnchorGrain::File,
1678 class: AnchorProvenanceClass::Anchored,
1679 at_version: None,
1680 hash: Some("h".into()),
1681 hash_stability: AnchorHashStability::Stable,
1682 derived_from: Vec::new(),
1683 binding: None,
1684 source: None,
1685 },
1686 Anchor {
1687 artifact: "src/".into(),
1688 grain: AnchorGrain::Tree,
1689 class: AnchorProvenanceClass::Derived,
1690 at_version: None,
1691 hash: Some("t".into()),
1692 hash_stability: AnchorHashStability::Stable,
1693 derived_from: vec!["a.rs".into(), "b.rs".into()],
1694 binding: None,
1695 source: None,
1696 },
1697 ];
1698 let comp = compose_entity_anchors(&anchors);
1699 assert_eq!(comp.by_class["anchored"], 1);
1700 assert_eq!(comp.by_class["derived"], 1);
1701 assert_eq!(comp.by_grain["file"], 1);
1702 assert_eq!(comp.by_grain["tree"], 1);
1703 assert_eq!(comp.tree_grain_artifacts, vec!["src/".to_string()]);
1705 assert_eq!(
1706 comp.derived_inputs,
1707 vec![vec!["a.rs".to_string(), "b.rs".to_string()]]
1708 );
1709 }
1710
1711 #[test]
1714 fn sidecar_round_trips_and_prunes_empty() {
1715 let mut sc = AnchorSidecar::default();
1716 assert!(sc.is_empty());
1717 let a = anchor(
1718 AnchorProvenanceClass::Anchored,
1719 Some("h1"),
1720 AnchorHashStability::Stable,
1721 );
1722 sc.set("specs--x", vec![a.clone()]);
1723 assert_eq!(sc.get("specs--x").len(), 1);
1724
1725 let bytes = sc.to_bytes();
1726 let round = AnchorSidecar::from_bytes(&bytes).unwrap();
1727 assert_eq!(round, sc);
1728
1729 sc.set("specs--x", vec![]);
1731 assert!(sc.is_empty());
1732 assert!(sc.get("specs--x").is_empty());
1733 }
1734
1735 fn file_anchor(artifact: &str, hash: &str) -> Anchor {
1738 Anchor {
1739 artifact: artifact.into(),
1740 grain: AnchorGrain::File,
1741 class: AnchorProvenanceClass::Anchored,
1742 at_version: None,
1743 hash: Some(hash.into()),
1744 hash_stability: AnchorHashStability::Stable,
1745 derived_from: Vec::new(),
1746 binding: None,
1747 source: None,
1748 }
1749 }
1750
1751 #[test]
1754 fn merge_appends_new_triple_without_touching_others() {
1755 let mut sc = AnchorSidecar::default();
1756 sc.set(
1757 "m--e",
1758 vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
1759 );
1760 sc.merge("m--e", &[], vec![file_anchor("c.rs", "h-c")]);
1761 let row = sc.get("m--e");
1762 assert_eq!(row.len(), 3);
1763 assert_eq!(row[0], file_anchor("a.rs", "h-a"));
1764 assert_eq!(row[1], file_anchor("b.rs", "h-b"));
1765 assert_eq!(row[2], file_anchor("c.rs", "h-c"));
1766 }
1767
1768 #[test]
1772 fn merge_replaces_same_triple_in_place() {
1773 let mut sc = AnchorSidecar::default();
1774 sc.set(
1775 "m--e",
1776 vec![file_anchor("a.rs", "h-old"), file_anchor("b.rs", "h-b")],
1777 );
1778 sc.merge("m--e", &[], vec![file_anchor("a.rs", "h-new")]);
1779 let row = sc.get("m--e");
1780 assert_eq!(row.len(), 2);
1781 assert_eq!(row[0], file_anchor("a.rs", "h-new"));
1782 assert_eq!(row[1], file_anchor("b.rs", "h-b"));
1783 }
1784
1785 #[test]
1789 fn merge_treats_grain_and_class_as_identity() {
1790 let mut sc = AnchorSidecar::default();
1791 sc.set("m--e", vec![file_anchor("a.rs", "h-a")]);
1792 let mut span = file_anchor("a.rs", "h-span");
1793 span.grain = AnchorGrain::Span;
1794 let mut informed = file_anchor("a.rs", "h-a");
1795 informed.class = AnchorProvenanceClass::InformedBy;
1796 informed.hash = None;
1797 sc.merge("m--e", &[], vec![span, informed]);
1798 assert_eq!(sc.get("m--e").len(), 3);
1799 }
1800
1801 #[test]
1804 fn merge_full_resend_and_empty_are_noops() {
1805 let mut sc = AnchorSidecar::default();
1806 sc.set(
1807 "m--e",
1808 vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
1809 );
1810 let before = sc.to_bytes();
1811 sc.merge(
1812 "m--e",
1813 &[],
1814 vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
1815 );
1816 assert_eq!(sc.to_bytes(), before, "full re-send is byte-stable");
1817 sc.merge("m--e", &[], Vec::new());
1818 assert_eq!(sc.to_bytes(), before, "empty merge is a no-op");
1819 }
1820
1821 #[test]
1825 fn unset_selects_by_artifact_with_optional_narrowing() {
1826 let mut span = file_anchor("a.rs", "h-span");
1827 span.grain = AnchorGrain::Span;
1828 let mut sc = AnchorSidecar::default();
1829 sc.set(
1830 "m--e",
1831 vec![
1832 file_anchor("a.rs", "h-a"),
1833 span.clone(),
1834 file_anchor("b.rs", "h-b"),
1835 ],
1836 );
1837
1838 let narrowed = AnchorUnset {
1840 artifact: "a.rs".into(),
1841 grain: Some(AnchorGrain::Span),
1842 class: None,
1843 };
1844 sc.merge("m--e", &[narrowed], Vec::new());
1845 assert_eq!(
1846 sc.get("m--e"),
1847 &[file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")]
1848 );
1849
1850 let missing = AnchorUnset {
1852 artifact: "never-there.rs".into(),
1853 grain: None,
1854 class: None,
1855 };
1856 sc.merge("m--e", &[missing], Vec::new());
1857 assert_eq!(sc.get("m--e").len(), 2);
1858
1859 let bare = AnchorUnset {
1861 artifact: "a.rs".into(),
1862 grain: None,
1863 class: None,
1864 };
1865 sc.merge("m--e", &[bare], Vec::new());
1866 assert_eq!(sc.get("m--e"), &[file_anchor("b.rs", "h-b")]);
1867 }
1868
1869 #[test]
1873 fn unset_applies_before_merge() {
1874 let mut span = file_anchor("a.rs", "h-span");
1875 span.grain = AnchorGrain::Span;
1876 let mut sc = AnchorSidecar::default();
1877 sc.set("m--e", vec![file_anchor("a.rs", "h-old"), span]);
1878 let bare = AnchorUnset {
1879 artifact: "a.rs".into(),
1880 grain: None,
1881 class: None,
1882 };
1883 sc.merge("m--e", &[bare], vec![file_anchor("a.rs", "h-new")]);
1884 assert_eq!(sc.get("m--e"), &[file_anchor("a.rs", "h-new")]);
1885 }
1886
1887 #[test]
1890 fn merge_prunes_row_emptied_by_unset() {
1891 let mut sc = AnchorSidecar::default();
1892 sc.set("m--e", vec![file_anchor("a.rs", "h-a")]);
1893 let bare = AnchorUnset {
1894 artifact: "a.rs".into(),
1895 grain: None,
1896 class: None,
1897 };
1898 sc.merge("m--e", &[bare], Vec::new());
1899 assert!(sc.is_empty());
1900 assert!(!sc.to_bytes().windows(5).any(|w| w == b"m--e\""));
1901 }
1902
1903 #[test]
1906 fn unset_input_validates_typed() {
1907 let ok = AnchorUnsetInput {
1908 artifact: Some(" a.rs ".into()),
1909 grain: Some("span".into()),
1910 class: None,
1911 }
1912 .validate()
1913 .unwrap();
1914 assert_eq!(ok.artifact, "a.rs");
1915 assert_eq!(ok.grain, Some(AnchorGrain::Span));
1916 assert_eq!(ok.class, None);
1917
1918 let missing = AnchorUnsetInput::default().validate().unwrap_err();
1919 assert!(matches!(missing, AnchorValidationError::MissingArtifact));
1920 assert_eq!(missing.code(), INVALID_ANCHOR_CODE);
1921
1922 let bad_grain = AnchorUnsetInput {
1923 artifact: Some("a.rs".into()),
1924 grain: Some("paragraph".into()),
1925 class: None,
1926 }
1927 .validate()
1928 .unwrap_err();
1929 assert!(matches!(
1930 bad_grain,
1931 AnchorValidationError::UnknownGrain { .. }
1932 ));
1933
1934 let bad_class = AnchorUnsetInput {
1935 artifact: Some("a.rs".into()),
1936 grain: None,
1937 class: Some("guessed".into()),
1938 }
1939 .validate()
1940 .unwrap_err();
1941 assert!(matches!(
1942 bad_class,
1943 AnchorValidationError::UnknownClass { .. }
1944 ));
1945 }
1946
1947 #[test]
1948 fn sidecar_rename_leaves_zero_rows_under_old_id() {
1949 let mut sc = AnchorSidecar::default();
1950 sc.set(
1951 "specs--old",
1952 vec![anchor(
1953 AnchorProvenanceClass::Anchored,
1954 Some("h"),
1955 AnchorHashStability::Stable,
1956 )],
1957 );
1958 sc.rename("specs--old", "specs--new");
1959 assert!(sc.get("specs--old").is_empty());
1960 assert_eq!(sc.get("specs--new").len(), 1);
1961 }
1962
1963 #[test]
1964 fn sidecar_remove_drops_entity_anchors() {
1965 let mut sc = AnchorSidecar::default();
1966 sc.set(
1967 "specs--gone",
1968 vec![anchor(
1969 AnchorProvenanceClass::Anchored,
1970 Some("h"),
1971 AnchorHashStability::Stable,
1972 )],
1973 );
1974 sc.remove("specs--gone");
1975 assert!(sc.get("specs--gone").is_empty());
1976 sc.remove("specs--gone");
1978 }
1979
1980 #[test]
1981 fn empty_bytes_parse_as_empty_sidecar() {
1982 assert!(AnchorSidecar::from_bytes(b"").unwrap().is_empty());
1983 assert!(AnchorSidecar::from_bytes(b" \n ").unwrap().is_empty());
1984 }
1985
1986 #[test]
1987 fn anchor_json_shape_omits_empty_optionals() {
1988 let a = anchor(
1989 AnchorProvenanceClass::Anchored,
1990 Some("h1"),
1991 AnchorHashStability::Stable,
1992 );
1993 let v = serde_json::to_value(&a).unwrap();
1994 assert_eq!(v["artifact"], "src/lib.rs");
1995 assert_eq!(v["grain"], "file");
1996 assert_eq!(v["class"], "anchored");
1997 assert_eq!(v["hash"], "h1");
1998 assert_eq!(v["hash_stability"], "stable");
1999 assert!(v.get("at_version").is_none());
2001 assert!(v.get("derived_from").is_none());
2002 assert!(v.get("binding").is_none());
2003 }
2004
2005 #[test]
2006 fn anchor_version_serialises_tagged() {
2007 let a = Anchor {
2008 at_version: Some(AnchorVersion::Commit("deadbeef".into())),
2009 ..anchor(
2010 AnchorProvenanceClass::Anchored,
2011 Some("h"),
2012 AnchorHashStability::Stable,
2013 )
2014 };
2015 let v = serde_json::to_value(&a).unwrap();
2016 assert_eq!(v["at_version"]["kind"], "commit");
2017 assert_eq!(v["at_version"]["value"], "deadbeef");
2018 }
2019
2020 #[test]
2024 fn validate_source_carried_absent_or_refused_when_empty() {
2025 let mut input = AnchorInput {
2026 artifact: Some("src/lib.rs".into()),
2027 grain: Some("file".into()),
2028 class: Some("anchored".into()),
2029 ..Default::default()
2030 };
2031 assert_eq!(
2032 input.validate(None).unwrap().source,
2033 None,
2034 "absent stays absent"
2035 );
2036
2037 input.source = Some(" api-docs ".into());
2038 assert_eq!(
2039 input.validate(None).unwrap().source.as_deref(),
2040 Some("api-docs"),
2041 "non-empty name is carried (trimmed)"
2042 );
2043
2044 input.source = Some(" ".into());
2045 let err = input.validate(None).unwrap_err();
2046 assert_eq!(err.code(), INVALID_ANCHOR_CODE);
2047 assert!(matches!(err, AnchorValidationError::EmptySource));
2048 assert_eq!(
2049 err.detail().get("field"),
2050 Some(&serde_json::json!("source"))
2051 );
2052 }
2053
2054 #[test]
2058 fn source_is_additive_on_the_persisted_shape() {
2059 let pre_plan = r#"{
2060 "artifact": "src/lib.rs",
2061 "grain": "file",
2062 "class": "anchored",
2063 "hash_stability": "stable"
2064 }"#;
2065 let a: Anchor = serde_json::from_str(pre_plan).expect("pre-plan anchor loads");
2066 assert_eq!(a.source, None, "no backfill, no default");
2067
2068 let sourced = Anchor {
2069 source: Some("api-docs".into()),
2070 ..a
2071 };
2072 let json = serde_json::to_string(&sourced).unwrap();
2073 let back: Anchor = serde_json::from_str(&json).unwrap();
2074 assert_eq!(back.source.as_deref(), Some("api-docs"));
2075 }
2076}