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 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
333 pub span_unvalidated: bool,
334 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub hash_source: Option<AnchorHashSource>,
341}
342
343#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
347#[serde(rename_all = "kebab-case")]
348pub enum AnchorHashSource {
349 Author,
351 Backfill,
353}
354
355impl AnchorHashSource {
356 pub fn as_wire(self) -> &'static str {
357 match self {
358 AnchorHashSource::Author => "author",
359 AnchorHashSource::Backfill => "backfill",
360 }
361 }
362}
363
364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
378pub enum SpanLocator<'a> {
379 Lines { start: usize, end: usize },
381 Unit(&'a str),
383}
384
385pub fn parse_span_locator(artifact: &str) -> Result<Option<SpanLocator<'_>>, &'static str> {
402 let locator = match artifact.split_once('#') {
403 None => return Ok(None),
404 Some((_, loc)) if loc.trim().is_empty() => {
405 return Err("the span locator after `#` is empty");
406 }
407 Some((_, loc)) => loc,
408 };
409 let looks_like_lines = locator.starts_with('L')
412 && locator[1..]
413 .chars()
414 .next()
415 .is_some_and(|c| c.is_ascii_digit());
416 if !looks_like_lines {
417 return Ok(Some(SpanLocator::Unit(locator)));
418 }
419 let (start_raw, end_raw) = match locator.split_once('-') {
420 None => (locator, locator),
421 Some((a, b)) => (a, b),
422 };
423 let num = |part: &str| -> Option<usize> {
424 part.strip_prefix('L')
425 .filter(|d| !d.is_empty() && d.chars().all(|c| c.is_ascii_digit()))
426 .and_then(|d| d.parse::<usize>().ok())
427 };
428 let (Some(start), Some(end)) = (num(start_raw), num(end_raw)) else {
429 return Err("a line-range span locator must read `L<start>` or `L<start>-L<end>`");
430 };
431 if start == 0 {
432 return Err("line numbers are 1-based, so `L0` addresses nothing");
433 }
434 if end < start {
435 return Err("a line-range span locator ends before it starts");
436 }
437 Ok(Some(SpanLocator::Lines { start, end }))
438}
439
440#[derive(Debug, Clone, Default, Serialize, Deserialize)]
450pub struct AnchorInput {
451 #[serde(default)]
452 pub artifact: Option<String>,
453 #[serde(default)]
454 pub grain: Option<String>,
455 #[serde(default)]
456 pub class: Option<String>,
457 #[serde(default)]
458 pub at_version: Option<AnchorVersion>,
459 #[serde(default)]
460 pub hash: Option<String>,
461 #[serde(default)]
470 pub content: Option<String>,
471 #[serde(default)]
472 pub hash_stability: Option<String>,
473 #[serde(default)]
474 pub derived_from: Option<Vec<String>>,
475 #[serde(default)]
476 pub binding: Option<String>,
477 #[serde(default)]
478 pub source: Option<String>,
479}
480
481#[derive(Debug, Clone, Default, Serialize, Deserialize)]
489pub struct AnchorUnsetInput {
490 #[serde(default)]
491 pub artifact: Option<String>,
492 #[serde(default)]
493 pub grain: Option<String>,
494 #[serde(default)]
495 pub class: Option<String>,
496}
497
498impl AnchorUnsetInput {
499 pub fn validate(&self) -> Result<AnchorUnset, AnchorValidationError> {
503 let artifact = self
504 .artifact
505 .as_deref()
506 .map(str::trim)
507 .filter(|s| !s.is_empty())
508 .map(str::to_string)
509 .ok_or(AnchorValidationError::MissingArtifact)?;
510 let grain = match self.grain.as_deref() {
511 None => None,
512 Some(s) => Some(AnchorGrain::from_wire(s).ok_or_else(|| {
513 AnchorValidationError::UnknownGrain {
514 got: Some(s.to_string()),
515 allowed: AnchorGrain::WIRE_VALUES,
516 }
517 })?),
518 };
519 let class = match self.class.as_deref() {
520 None => None,
521 Some(s) => Some(AnchorProvenanceClass::from_wire(s).ok_or_else(|| {
522 AnchorValidationError::UnknownClass {
523 got: Some(s.to_string()),
524 allowed: AnchorProvenanceClass::WIRE_VALUES,
525 }
526 })?),
527 };
528 Ok(AnchorUnset {
529 artifact,
530 grain,
531 class,
532 })
533 }
534}
535
536#[derive(Debug, Clone, PartialEq, Eq)]
542pub struct AnchorUnset {
543 pub artifact: String,
545 pub grain: Option<AnchorGrain>,
547 pub class: Option<AnchorProvenanceClass>,
549}
550
551impl AnchorUnset {
552 pub fn matches(&self, anchor: &Anchor) -> bool {
554 anchor.artifact == self.artifact
555 && self.grain.is_none_or(|g| anchor.grain == g)
556 && self.class.is_none_or(|c| anchor.class == c)
557 }
558}
559
560#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
564pub enum AnchorValidationError {
565 #[error("unknown anchor provenance class {got:?}; allowed: {}", allowed.join(", "))]
567 UnknownClass {
568 got: Option<String>,
569 allowed: &'static [&'static str],
570 },
571 #[error("unknown anchor grain {got:?}; allowed: {}", allowed.join(", "))]
573 UnknownGrain {
574 got: Option<String>,
575 allowed: &'static [&'static str],
576 },
577 #[error("unknown anchor hash stability {got:?}; allowed: {}", allowed.join(", "))]
579 UnknownHashStability {
580 got: String,
581 allowed: &'static [&'static str],
582 },
583 #[error("anchor is missing its artifact reference")]
585 MissingArtifact,
586 #[error("anchor class '{class}' carries no hash semantics — a content hash is not permitted")]
589 HashOnNonHashClass { class: &'static str },
590 #[error(
593 "anchor supplies both `hash` and `content`; supply one — the engine computes the hash from `content`"
594 )]
595 ContentAndHash,
596 #[error(
601 "anchor grain '{grain}' does not accept `content`: its prepared form is not computed \
602 from supplied bytes (accepted for span / file / url)"
603 )]
604 ContentNotAcceptedForGrain { grain: &'static str },
605 #[error(
608 "anchor artifact {artifact:?} names a delivery unit the supplied `content` does not \
609 yield; supply the whole file's content, or address a unit it contains"
610 )]
611 UnitAbsentFromContent { artifact: String },
612 #[error("anchor artifact {artifact:?} is not a usable span reference: {reason}")]
617 SpanLocatorUnusable {
618 artifact: String,
619 reason: &'static str,
620 },
621 #[error(
626 "anchor artifact {artifact:?} names lines the supplied `content` does not have \
627 (it has {lines} line(s)); address a range the artifact contains"
628 )]
629 SpanOutsideContent { artifact: String, lines: usize },
630 #[error(
636 "the anchors payload names {artifact:?} at grain `{grain}` and class `{class}` more \
637 than once; that triple is one row, so the repeats would silently collapse to the \
638 last one: send it once, or vary the grain or class"
639 )]
640 DuplicateAnchorTriple {
641 artifact: String,
642 grain: &'static str,
643 class: &'static str,
644 },
645 #[error("anchor `source`, when present, must be a non-empty source name")]
649 EmptySource,
650 #[error(
659 "anchor `source` {got:?} is not declared by the anchor's producing binding; \
660 declared sources: {}",
661 declared.join(", ")
662 )]
663 SourceNotDeclared { got: String, declared: Vec<String> },
664 #[error(
671 "anchor artifact {artifact:?} resolves under no candidate path (tried: {}); artifact \
672 paths are source-relative (joined onto the source's pointer) or workspace-relative — \
673 write the path exactly as the brief lists it",
674 candidates.join(", ")
675 )]
676 ArtifactUnresolvable {
677 artifact: String,
678 candidates: Vec<String>,
679 },
680 #[error(
683 "anchor grain '{grain}' is unsupported by a '{medium_type}' medium: its \
684 '{anchor_namespace}' namespace does not admit that grain"
685 )]
686 GrainNamespaceUnsupported {
687 grain: &'static str,
688 medium_type: String,
689 anchor_namespace: &'static str,
690 },
691}
692
693impl AnchorValidationError {
694 pub fn code(&self) -> &'static str {
696 INVALID_ANCHOR_CODE
697 }
698
699 pub fn detail(&self) -> BTreeMap<String, serde_json::Value> {
702 let mut d = BTreeMap::new();
703 match self {
704 AnchorValidationError::UnknownClass { got, allowed } => {
705 d.insert("field".into(), "class".into());
706 d.insert("got".into(), serde_json::json!(got));
707 d.insert("allowed".into(), serde_json::json!(allowed));
708 }
709 AnchorValidationError::UnknownGrain { got, allowed } => {
710 d.insert("field".into(), "grain".into());
711 d.insert("got".into(), serde_json::json!(got));
712 d.insert("allowed".into(), serde_json::json!(allowed));
713 }
714 AnchorValidationError::UnknownHashStability { got, allowed } => {
715 d.insert("field".into(), "hash_stability".into());
716 d.insert("got".into(), serde_json::json!(got));
717 d.insert("allowed".into(), serde_json::json!(allowed));
718 }
719 AnchorValidationError::MissingArtifact => {
720 d.insert("field".into(), "artifact".into());
721 }
722 AnchorValidationError::EmptySource => {
723 d.insert("field".into(), "source".into());
724 }
725 AnchorValidationError::SourceNotDeclared { got, declared } => {
726 d.insert("field".into(), "source".into());
727 d.insert("got".into(), serde_json::json!(got));
728 d.insert("declared".into(), serde_json::json!(declared));
729 }
730 AnchorValidationError::HashOnNonHashClass { class } => {
731 d.insert("field".into(), "hash".into());
732 d.insert("class".into(), serde_json::json!(class));
733 }
734 AnchorValidationError::ContentAndHash => {
735 d.insert("field".into(), "content".into());
736 d.insert(
737 "expected".into(),
738 serde_json::json!("either `hash` or `content`, never both"),
739 );
740 }
741 AnchorValidationError::ContentNotAcceptedForGrain { grain } => {
742 d.insert("field".into(), "content".into());
743 d.insert("grain".into(), serde_json::json!(grain));
744 d.insert(
745 "accepted_grains".into(),
746 serde_json::json!(["span", "file", "url"]),
747 );
748 }
749 AnchorValidationError::UnitAbsentFromContent { artifact } => {
750 d.insert("field".into(), "content".into());
751 d.insert("got".into(), serde_json::json!(artifact));
752 }
753 AnchorValidationError::SpanLocatorUnusable { artifact, reason } => {
754 d.insert("field".into(), "artifact".into());
755 d.insert("got".into(), serde_json::json!(artifact));
756 d.insert("expected".into(), serde_json::json!(reason));
757 }
758 AnchorValidationError::SpanOutsideContent { artifact, lines } => {
759 d.insert("field".into(), "artifact".into());
760 d.insert("got".into(), serde_json::json!(artifact));
761 d.insert("content_lines".into(), serde_json::json!(lines));
762 }
763 AnchorValidationError::DuplicateAnchorTriple {
764 artifact,
765 grain,
766 class,
767 } => {
768 d.insert("field".into(), "anchors".into());
769 d.insert(
770 "got".into(),
771 serde_json::json!({ "artifact": artifact, "grain": grain, "class": class }),
772 );
773 d.insert(
774 "expected".into(),
775 serde_json::json!(
776 "each (artifact, grain, class) triple at most once per payload"
777 ),
778 );
779 }
780 AnchorValidationError::ArtifactUnresolvable {
781 artifact,
782 candidates,
783 } => {
784 d.insert("field".into(), "artifact".into());
785 d.insert("got".into(), serde_json::json!(artifact));
786 d.insert("candidates_tried".into(), serde_json::json!(candidates));
787 d.insert(
788 "expected".into(),
789 serde_json::json!(
790 "a source-relative path (joined onto the source's pointer) or a \
791 workspace-relative path that resolves to an existing artifact"
792 ),
793 );
794 }
795 AnchorValidationError::GrainNamespaceUnsupported {
796 grain,
797 medium_type,
798 anchor_namespace,
799 } => {
800 d.insert("field".into(), "grain".into());
801 d.insert("grain".into(), serde_json::json!(grain));
802 d.insert("medium_type".into(), serde_json::json!(medium_type));
803 d.insert(
804 "anchor_namespace".into(),
805 serde_json::json!(anchor_namespace),
806 );
807 }
808 }
809 d
810 }
811}
812
813impl AnchorInput {
814 pub fn validate(&self, medium: Option<(&str, &str)>) -> Result<Anchor, AnchorValidationError> {
837 let class = match self
838 .class
839 .as_deref()
840 .and_then(AnchorProvenanceClass::from_wire)
841 {
842 Some(c) => c,
843 None => {
844 return Err(AnchorValidationError::UnknownClass {
845 got: self.class.clone(),
846 allowed: AnchorProvenanceClass::WIRE_VALUES,
847 });
848 }
849 };
850 let grain = match self.grain.as_deref().and_then(AnchorGrain::from_wire) {
851 Some(g) => g,
852 None => {
853 return Err(AnchorValidationError::UnknownGrain {
854 got: self.grain.clone(),
855 allowed: AnchorGrain::WIRE_VALUES,
856 });
857 }
858 };
859
860 let artifact = self
861 .artifact
862 .as_deref()
863 .map(str::trim)
864 .filter(|s| !s.is_empty())
865 .map(str::to_string)
866 .ok_or(AnchorValidationError::MissingArtifact)?;
867
868 let hash_stability = match self.hash_stability.as_deref() {
871 None => crate::preparation::default_hash_stability(grain),
872 Some(s) => AnchorHashStability::from_wire(s).ok_or_else(|| {
873 AnchorValidationError::UnknownHashStability {
874 got: s.to_string(),
875 allowed: AnchorHashStability::WIRE_VALUES,
876 }
877 })?,
878 };
879
880 let hash = self
882 .hash
883 .as_deref()
884 .map(str::trim)
885 .filter(|s| !s.is_empty())
886 .map(str::to_string);
887 if (hash.is_some() || self.content.is_some()) && !class.is_hash_bearing() {
888 return Err(AnchorValidationError::HashOnNonHashClass {
889 class: class.as_wire(),
890 });
891 }
892 let hash = match self.content.as_deref() {
896 None => hash,
897 Some(_) if hash.is_some() => return Err(AnchorValidationError::ContentAndHash),
898 Some(content) => {
899 match crate::preparation::supplied_content_hash(grain, content.as_bytes()) {
900 Some(h) => Some(h),
901 None => {
902 return Err(AnchorValidationError::ContentNotAcceptedForGrain {
903 grain: grain.as_wire(),
904 });
905 }
906 }
907 }
908 };
909
910 let mut span_unvalidated = false;
916 if grain == AnchorGrain::Span {
917 let locator = parse_span_locator(&artifact).map_err(|reason| {
918 AnchorValidationError::SpanLocatorUnusable {
919 artifact: artifact.clone(),
920 reason,
921 }
922 })?;
923 match (locator, self.content.as_deref()) {
924 (Some(SpanLocator::Lines { end, .. }), Some(content)) => {
925 let lines = content.lines().count();
926 if end > lines {
927 return Err(AnchorValidationError::SpanOutsideContent {
928 artifact: artifact.clone(),
929 lines,
930 });
931 }
932 }
933 (Some(SpanLocator::Unit(_)), Some(_)) => {}
937 (None, _) => {}
941 (Some(_), None) => span_unvalidated = true,
942 }
943 }
944
945 if let Some((medium_type, namespace)) = medium
947 && !grain.supported_by_namespace(namespace)
948 {
949 let anchor_namespace = match namespace {
952 "path" => "path",
953 "path+commit" => "path+commit",
954 "entity" => "entity",
955 "url" => "url",
956 _ => "path",
957 };
958 return Err(AnchorValidationError::GrainNamespaceUnsupported {
959 grain: grain.as_wire(),
960 medium_type: medium_type.to_string(),
961 anchor_namespace,
962 });
963 }
964
965 let source = match self.source.as_deref() {
970 None => None,
971 Some(raw) => {
972 let trimmed = raw.trim();
973 if trimmed.is_empty() {
974 return Err(AnchorValidationError::EmptySource);
975 }
976 Some(trimmed.to_string())
977 }
978 };
979
980 Ok(Anchor {
981 artifact,
982 grain,
983 class,
984 at_version: self.at_version.clone(),
985 hash_source: hash.is_some().then_some(AnchorHashSource::Author),
988 hash,
989 hash_stability,
990 derived_from: self.derived_from.clone().unwrap_or_default(),
991 binding: self
992 .binding
993 .as_deref()
994 .map(str::trim)
995 .filter(|s| !s.is_empty())
996 .map(str::to_string),
997 source,
998 span_unvalidated,
999 })
1000 }
1001}
1002
1003pub fn prepared_content_hash(bytes: &[u8]) -> String {
1030 use sha2::{Digest as _, Sha256};
1031 let digest = match std::str::from_utf8(bytes) {
1032 Ok(text) => {
1033 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
1034 let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
1035 Sha256::digest(normalized.trim_end_matches('\n').as_bytes())
1036 }
1037 Err(_) => Sha256::digest(bytes),
1038 };
1039 crate::hex_lower(&digest)[..16].to_string()
1040}
1041
1042#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1047pub struct ObservedArtifactHash {
1048 pub entity: String,
1050 pub artifact: String,
1052 pub hash: String,
1054}
1055
1056#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1062#[serde(rename_all = "lowercase")]
1063pub enum AnchorState {
1064 Resolves,
1067 Drifted,
1070 Recheck,
1074 Orphaned,
1077}
1078
1079impl AnchorState {
1080 pub fn as_wire(&self) -> &'static str {
1082 match self {
1083 AnchorState::Resolves => "resolves",
1084 AnchorState::Drifted => "drifted",
1085 AnchorState::Recheck => "recheck",
1086 AnchorState::Orphaned => "orphaned",
1087 }
1088 }
1089}
1090
1091#[derive(Debug, Clone, PartialEq, Eq)]
1093pub enum ArtifactObservation {
1094 Absent,
1096 Present { current_hash: Option<String> },
1100}
1101
1102pub fn resolve_anchor(anchor: &Anchor, observation: &ArtifactObservation) -> AnchorState {
1114 let current_hash = match observation {
1115 ArtifactObservation::Absent => return AnchorState::Orphaned,
1116 ArtifactObservation::Present { current_hash } => current_hash,
1117 };
1118 if !anchor.class.is_hash_bearing() {
1119 return AnchorState::Resolves;
1120 }
1121 match (&anchor.hash, current_hash) {
1122 (Some(recorded), Some(current)) if recorded == current => AnchorState::Resolves,
1123 (Some(_), Some(_)) => match anchor.hash_stability {
1124 AnchorHashStability::Stable => AnchorState::Drifted,
1125 AnchorHashStability::Unstable => AnchorState::Recheck,
1126 },
1127 _ => AnchorState::Recheck,
1129 }
1130}
1131
1132#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1138pub struct EntityAnchorComposition {
1139 pub by_class: BTreeMap<String, usize>,
1141 pub by_grain: BTreeMap<String, usize>,
1143 pub derived_inputs: Vec<Vec<String>>,
1146 pub tree_grain_artifacts: Vec<String>,
1151}
1152
1153pub fn compose_entity_anchors(anchors: &[Anchor]) -> EntityAnchorComposition {
1156 let mut comp = EntityAnchorComposition::default();
1157 for a in anchors {
1158 *comp
1159 .by_class
1160 .entry(a.class.as_wire().to_string())
1161 .or_insert(0) += 1;
1162 *comp
1163 .by_grain
1164 .entry(a.grain.as_wire().to_string())
1165 .or_insert(0) += 1;
1166 if a.class == AnchorProvenanceClass::Derived {
1167 comp.derived_inputs.push(a.derived_from.clone());
1168 }
1169 if a.grain == AnchorGrain::Tree {
1170 comp.tree_grain_artifacts.push(a.artifact.clone());
1171 }
1172 }
1173 comp
1174}
1175
1176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1188pub struct AnchorSidecar {
1189 pub version: u32,
1191 #[serde(default)]
1194 pub entities: BTreeMap<String, Vec<Anchor>>,
1195}
1196
1197impl Default for AnchorSidecar {
1198 fn default() -> Self {
1199 Self {
1200 version: ANCHOR_SIDECAR_VERSION,
1201 entities: BTreeMap::new(),
1202 }
1203 }
1204}
1205
1206impl AnchorSidecar {
1207 pub fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
1210 if bytes.iter().all(u8::is_ascii_whitespace) {
1211 return Ok(Self::default());
1212 }
1213 let sidecar: Self = serde_json::from_slice(bytes)?;
1214 if sidecar.version != ANCHOR_SIDECAR_VERSION {
1223 return Err(serde::de::Error::custom(format!(
1224 "unsupported anchors sidecar version {} (this engine reads version {}) — \
1225 the file was written by a different engine; upgrade, or remove the sidecar \
1226 to re-record anchors",
1227 sidecar.version, ANCHOR_SIDECAR_VERSION
1228 )));
1229 }
1230 Ok(sidecar)
1231 }
1232
1233 pub fn to_bytes(&self) -> Vec<u8> {
1236 let mut s = serde_json::to_string_pretty(self).expect("anchor sidecar serialises");
1237 s.push('\n');
1238 s.into_bytes()
1239 }
1240
1241 pub fn get(&self, entity_id: &str) -> &[Anchor] {
1243 self.entities
1244 .get(entity_id)
1245 .map(Vec::as_slice)
1246 .unwrap_or(&[])
1247 }
1248
1249 pub fn set(&mut self, entity_id: &str, anchors: Vec<Anchor>) {
1252 if anchors.is_empty() {
1253 self.entities.remove(entity_id);
1254 } else {
1255 self.entities.insert(entity_id.to_string(), anchors);
1256 }
1257 }
1258
1259 pub fn merge(&mut self, entity_id: &str, unsets: &[AnchorUnset], incoming: Vec<Anchor>) {
1271 let mut row = self.entities.remove(entity_id).unwrap_or_default();
1272 row.retain(|a| !unsets.iter().any(|u| u.matches(a)));
1273 for mut anchor in incoming {
1274 match row.iter_mut().find(|e| {
1275 e.artifact == anchor.artifact && e.grain == anchor.grain && e.class == anchor.class
1276 }) {
1277 Some(existing) => {
1278 if anchor.hash.is_none()
1288 && let Some(kept) = existing.hash.clone()
1289 {
1290 anchor.hash = Some(kept);
1291 anchor.hash_source = existing.hash_source;
1292 }
1293 *existing = anchor;
1294 }
1295 None => row.push(anchor),
1296 }
1297 }
1298 if !row.is_empty() {
1299 self.entities.insert(entity_id.to_string(), row);
1300 }
1301 }
1302
1303 pub fn redact_artifact_references(&mut self) {
1311 for anchors in self.entities.values_mut() {
1312 for anchor in anchors {
1313 anchor.artifact = REDACTED_ARTIFACT_SENTINEL.to_string();
1314 for input in &mut anchor.derived_from {
1315 *input = REDACTED_ARTIFACT_SENTINEL.to_string();
1316 }
1317 }
1318 }
1319 }
1320
1321 pub fn validate_artifact_references(&self) -> Result<(), String> {
1327 for (entity_id, anchors) in &self.entities {
1328 for anchor in anchors {
1329 if anchor.artifact.trim().is_empty() {
1330 return Err(format!(
1331 "entity `{entity_id}` carries an anchor with an empty artifact \
1332 reference"
1333 ));
1334 }
1335 if anchor.derived_from.iter().any(|d| d.trim().is_empty()) {
1336 return Err(format!(
1337 "entity `{entity_id}` carries an anchor with an empty \
1338 `derived_from` entry"
1339 ));
1340 }
1341 }
1342 }
1343 Ok(())
1344 }
1345
1346 pub fn remove(&mut self, entity_id: &str) {
1348 self.entities.remove(entity_id);
1349 }
1350
1351 pub fn rename(&mut self, from: &str, to: &str) {
1356 if let Some(anchors) = self.entities.remove(from) {
1357 self.entities.insert(to.to_string(), anchors);
1358 }
1359 }
1360
1361 pub fn is_empty(&self) -> bool {
1363 self.entities.is_empty()
1364 }
1365}
1366
1367#[cfg(test)]
1368mod tests {
1369 use super::*;
1370
1371 #[test]
1376 fn redaction_blanks_references_and_keeps_trust_metadata() {
1377 let mut sidecar = AnchorSidecar::default();
1378 sidecar.set(
1379 "m--alpha",
1380 vec![
1381 Anchor {
1382 artifact: "src/lib.rs".into(),
1383 grain: AnchorGrain::File,
1384 class: AnchorProvenanceClass::Anchored,
1385 at_version: Some(AnchorVersion::Commit("abc123".into())),
1386 hash: Some("h1".into()),
1387 hash_stability: AnchorHashStability::Stable,
1388 derived_from: vec![],
1389 binding: Some("bhash".into()),
1390 source: Some("source-tree".into()),
1391 span_unvalidated: false,
1392 hash_source: None,
1393 },
1394 Anchor {
1395 artifact: "docs/summary.md".into(),
1396 grain: AnchorGrain::File,
1397 class: AnchorProvenanceClass::Derived,
1398 at_version: None,
1399 hash: Some("h2".into()),
1400 hash_stability: AnchorHashStability::Unstable,
1401 derived_from: vec!["notes/a.md".into(), "notes/b.md".into()],
1402 binding: None,
1403 source: None,
1404 span_unvalidated: false,
1405 hash_source: None,
1406 },
1407 ],
1408 );
1409
1410 sidecar.redact_artifact_references();
1411
1412 let anchors = sidecar.get("m--alpha");
1413 assert_eq!(anchors.len(), 2, "no anchor entry is dropped");
1414 for a in anchors {
1415 assert_eq!(a.artifact, REDACTED_ARTIFACT_SENTINEL);
1416 for d in &a.derived_from {
1417 assert_eq!(d, REDACTED_ARTIFACT_SENTINEL);
1418 }
1419 }
1420 assert_eq!(
1421 anchors[0].at_version,
1422 Some(AnchorVersion::Commit("abc123".into()))
1423 );
1424 assert_eq!(anchors[0].hash.as_deref(), Some("h1"));
1425 assert_eq!(anchors[0].binding.as_deref(), Some("bhash"));
1426 assert_eq!(anchors[0].source.as_deref(), Some("source-tree"));
1427 assert_eq!(anchors[1].class, AnchorProvenanceClass::Derived);
1428 assert_eq!(anchors[1].derived_from.len(), 2, "derivation arity kept");
1429 sidecar.validate_artifact_references().unwrap();
1432 }
1433
1434 #[test]
1438 fn empty_artifact_references_are_refused() {
1439 let mut sidecar = AnchorSidecar::default();
1440 sidecar.set(
1441 "m--alpha",
1442 vec![Anchor {
1443 artifact: "".into(),
1444 grain: AnchorGrain::File,
1445 class: AnchorProvenanceClass::Anchored,
1446 at_version: None,
1447 hash: None,
1448 hash_stability: AnchorHashStability::Stable,
1449 derived_from: vec![],
1450 binding: None,
1451 source: None,
1452 span_unvalidated: false,
1453 hash_source: None,
1454 }],
1455 );
1456 assert!(sidecar.validate_artifact_references().is_err());
1457
1458 let mut sidecar = AnchorSidecar::default();
1459 sidecar.set(
1460 "m--beta",
1461 vec![Anchor {
1462 artifact: "docs/x.md".into(),
1463 grain: AnchorGrain::File,
1464 class: AnchorProvenanceClass::Derived,
1465 at_version: None,
1466 hash: None,
1467 hash_stability: AnchorHashStability::Stable,
1468 derived_from: vec![" ".into()],
1469 binding: None,
1470 source: None,
1471 span_unvalidated: false,
1472 hash_source: None,
1473 }],
1474 );
1475 assert!(sidecar.validate_artifact_references().is_err());
1476 }
1477
1478 #[test]
1481 fn class_wire_strings_are_stable() {
1482 assert_eq!(AnchorProvenanceClass::Anchored.as_wire(), "anchored");
1483 assert_eq!(AnchorProvenanceClass::Derived.as_wire(), "derived");
1484 assert_eq!(AnchorProvenanceClass::Authored.as_wire(), "authored");
1485 assert_eq!(AnchorProvenanceClass::InformedBy.as_wire(), "informed-by");
1486 for w in AnchorProvenanceClass::WIRE_VALUES {
1487 assert_eq!(AnchorProvenanceClass::from_wire(w).unwrap().as_wire(), *w);
1488 }
1489 assert!(AnchorProvenanceClass::from_wire("bogus").is_none());
1490 }
1491
1492 #[test]
1493 fn grain_wire_strings_are_stable() {
1494 for w in AnchorGrain::WIRE_VALUES {
1495 assert_eq!(AnchorGrain::from_wire(w).unwrap().as_wire(), *w);
1496 }
1497 assert_eq!(
1498 AnchorGrain::WIRE_VALUES,
1499 &["span", "file", "tree", "url", "entity"]
1500 );
1501 assert!(AnchorGrain::from_wire("chunk").is_none());
1502 }
1503
1504 #[test]
1505 fn stability_and_state_wire_strings_are_stable() {
1506 assert_eq!(AnchorHashStability::Stable.as_wire(), "stable");
1507 assert_eq!(AnchorHashStability::Unstable.as_wire(), "unstable");
1508 assert_eq!(AnchorState::Resolves.as_wire(), "resolves");
1509 assert_eq!(AnchorState::Drifted.as_wire(), "drifted");
1510 assert_eq!(AnchorState::Recheck.as_wire(), "recheck");
1511 assert_eq!(AnchorState::Orphaned.as_wire(), "orphaned");
1512 }
1513
1514 #[test]
1515 fn only_anchored_and_derived_are_hash_bearing() {
1516 assert!(AnchorProvenanceClass::Anchored.is_hash_bearing());
1517 assert!(AnchorProvenanceClass::Derived.is_hash_bearing());
1518 assert!(!AnchorProvenanceClass::Authored.is_hash_bearing());
1519 assert!(!AnchorProvenanceClass::InformedBy.is_hash_bearing());
1520 }
1521
1522 #[test]
1525 fn grain_namespace_support_matches_capability_matrix() {
1526 for g in [AnchorGrain::Span, AnchorGrain::File, AnchorGrain::Tree] {
1528 assert!(g.supported_by_namespace("path"));
1529 assert!(g.supported_by_namespace("path+commit"));
1530 assert!(!g.supported_by_namespace("url"));
1531 assert!(!g.supported_by_namespace("entity"));
1532 }
1533 assert!(AnchorGrain::Url.supported_by_namespace("url"));
1534 assert!(!AnchorGrain::Url.supported_by_namespace("path"));
1535 assert!(AnchorGrain::Entity.supported_by_namespace("entity"));
1536 assert!(!AnchorGrain::Entity.supported_by_namespace("path"));
1537 }
1538
1539 fn valid_input() -> AnchorInput {
1542 AnchorInput {
1543 artifact: Some("src/lib.rs".into()),
1544 grain: Some("file".into()),
1545 class: Some("anchored".into()),
1546 hash_stability: Some("stable".into()),
1547 hash: Some("abc123".into()),
1548 ..Default::default()
1549 }
1550 }
1551
1552 fn span_input(artifact: &str) -> AnchorInput {
1553 AnchorInput {
1554 artifact: Some(artifact.into()),
1555 grain: Some("span".into()),
1556 class: Some("anchored".into()),
1557 ..Default::default()
1558 }
1559 }
1560
1561 #[test]
1565 fn a_span_locator_that_addresses_nothing_is_refused() {
1566 for artifact in [
1567 "src/lib.rs#", "src/lib.rs# ", "src/lib.rs#L0", "src/lib.rs#L0-L4", "src/lib.rs#L9-L2", "src/lib.rs#L4-L", "src/lib.rs#L4-x", ] {
1575 let err = span_input(artifact)
1576 .validate(Some(("codebase", "path")))
1577 .expect_err(artifact);
1578 assert!(
1579 matches!(err, AnchorValidationError::SpanLocatorUnusable { .. }),
1580 "{artifact} refused as {err:?}"
1581 );
1582 assert_eq!(err.code(), INVALID_ANCHOR_CODE);
1583 assert!(err.detail().contains_key("expected"), "carries the repair");
1584 }
1585 }
1586
1587 #[test]
1592 fn a_usable_span_locator_still_writes() {
1593 for artifact in [
1594 "src/lib.rs",
1595 "src/lib.rs#L1",
1596 "src/lib.rs#L4-L7",
1597 "logs/ops.md#2026-08-25T00:00:00",
1598 ] {
1599 span_input(artifact)
1600 .validate(Some(("codebase", "path")))
1601 .unwrap_or_else(|e| panic!("{artifact} refused: {e}"));
1602 }
1603 }
1604
1605 #[test]
1609 fn a_span_beyond_supplied_content_is_refused() {
1610 let mut i = span_input("src/lib.rs#L2-L9");
1611 i.content = Some(
1612 "one
1613two
1614three
1615"
1616 .into(),
1617 );
1618 let err = i.validate(Some(("codebase", "path"))).unwrap_err();
1619 match err {
1620 AnchorValidationError::SpanOutsideContent { lines, .. } => assert_eq!(lines, 3),
1621 other => panic!("wrong refusal: {other:?}"),
1622 }
1623
1624 let mut ok = span_input("src/lib.rs#L2-L3");
1625 ok.content = Some(
1626 "one
1627two
1628three
1629"
1630 .into(),
1631 );
1632 let a = ok.validate(Some(("codebase", "path"))).unwrap();
1633 assert!(
1634 !a.span_unvalidated,
1635 "a span checked against content is not unvalidated"
1636 );
1637 }
1638
1639 #[test]
1644 fn an_uncheckable_span_is_accepted_and_recorded_as_unchecked() {
1645 let a = span_input("src/lib.rs#L4-L7")
1646 .validate(Some(("codebase", "path")))
1647 .unwrap();
1648 assert!(a.span_unvalidated);
1649
1650 let whole_file = span_input("src/lib.rs")
1651 .validate(Some(("codebase", "path")))
1652 .unwrap();
1653 assert!(
1654 !whole_file.span_unvalidated,
1655 "no locator addresses the whole artifact, which the existence gate checks"
1656 );
1657
1658 let file_grain = valid_input().validate(Some(("codebase", "path"))).unwrap();
1659 assert!(!file_grain.span_unvalidated, "never set off the span grain");
1660 }
1661
1662 #[test]
1665 fn an_authored_hash_records_that_the_author_pinned_it() {
1666 let a = valid_input().validate(Some(("codebase", "path"))).unwrap();
1667 assert_eq!(a.hash_source, Some(AnchorHashSource::Author));
1668
1669 let mut hashless = valid_input();
1670 hashless.hash = None;
1671 let b = hashless.validate(Some(("codebase", "path"))).unwrap();
1672 assert_eq!(b.hash_source, None, "no baseline, no origin to record");
1673 }
1674
1675 #[test]
1679 fn a_re_pin_keeps_the_baseline_it_did_not_mention() {
1680 let mut sc = AnchorSidecar::default();
1681 let mut pinned = file_anchor("src/a.rs", "h-original");
1682 pinned.hash_source = Some(AnchorHashSource::Author);
1683 sc.set("m--e", vec![pinned]);
1684
1685 let mut repin = file_anchor("src/a.rs", "");
1686 repin.hash = None;
1687 repin.hash_source = None;
1688 sc.merge("m--e", &[], vec![repin]);
1689 let row = &sc.entities["m--e"][0];
1690 assert_eq!(
1691 row.hash.as_deref(),
1692 Some("h-original"),
1693 "the baseline the caller did not mention survives"
1694 );
1695 assert_eq!(row.hash_source, Some(AnchorHashSource::Author));
1696
1697 sc.merge("m--e", &[], vec![file_anchor("src/a.rs", "h-new")]);
1698 assert_eq!(
1699 sc.entities["m--e"][0].hash.as_deref(),
1700 Some("h-new"),
1701 "a supplied hash still replaces"
1702 );
1703
1704 let unset = AnchorUnset {
1707 artifact: "src/a.rs".into(),
1708 grain: None,
1709 class: None,
1710 };
1711 let mut fresh = file_anchor("src/a.rs", "");
1712 fresh.hash = None;
1713 fresh.hash_source = None;
1714 sc.merge("m--e", &[unset], vec![fresh]);
1715 assert_eq!(
1716 sc.entities["m--e"][0].hash, None,
1717 "unset-then-write is how a caller clears a baseline"
1718 );
1719 }
1720
1721 #[test]
1722 fn validate_accepts_a_well_formed_anchor() {
1723 let a = valid_input().validate(Some(("codebase", "path"))).unwrap();
1724 assert_eq!(a.artifact, "src/lib.rs");
1725 assert_eq!(a.grain, AnchorGrain::File);
1726 assert_eq!(a.class, AnchorProvenanceClass::Anchored);
1727 assert_eq!(a.hash.as_deref(), Some("abc123"));
1728 assert_eq!(a.hash_stability, AnchorHashStability::Stable);
1729 }
1730
1731 #[test]
1734 fn validate_defaults_hash_stability_to_stable() {
1735 for grain in ["span", "file", "tree"] {
1736 let mut i = valid_input();
1737 i.grain = Some(grain.into());
1738 i.hash_stability = None;
1739 let a = i.validate(None).unwrap();
1740 assert_eq!(a.hash_stability, AnchorHashStability::Stable, "{grain}");
1741 }
1742 let mut e = valid_input();
1743 e.grain = Some("entity".into());
1744 e.artifact = Some("m--e".into());
1745 e.hash_stability = None;
1746 assert_eq!(
1747 e.validate(None).unwrap().hash_stability,
1748 AnchorHashStability::Stable
1749 );
1750 }
1751
1752 #[test]
1756 fn validate_defaults_url_grain_to_unstable_unless_declared() {
1757 let mut i = valid_input();
1758 i.grain = Some("url".into());
1759 i.artifact = Some("https://example.invalid/doc".into());
1760 i.hash_stability = None;
1761 assert_eq!(
1762 i.validate(None).unwrap().hash_stability,
1763 AnchorHashStability::Unstable
1764 );
1765 i.hash_stability = Some("stable".into());
1766 assert_eq!(
1767 i.validate(None).unwrap().hash_stability,
1768 AnchorHashStability::Stable
1769 );
1770 }
1771
1772 #[test]
1779 fn content_yields_the_prepared_hash_through_the_registry() {
1780 let mut u = valid_input();
1781 u.grain = Some("url".into());
1782 u.artifact = Some("https://example.invalid/doc".into());
1783 u.hash = None;
1784 u.hash_stability = None;
1785 u.content = Some("<p>hello</p>\r\n".into());
1786 let a = u.validate(None).unwrap();
1787 assert_eq!(
1788 a.hash.as_deref(),
1789 Some(crate::preparation::url_prepared_hash(b"<p>hello</p>\n").as_str())
1790 );
1791 assert_eq!(a.hash_stability, AnchorHashStability::Unstable);
1792
1793 let mut f = valid_input();
1794 f.hash = None;
1795 f.content = Some("fn a() {}\n".into());
1796 assert_eq!(
1797 f.validate(None).unwrap().hash.as_deref(),
1798 Some(prepared_content_hash(b"fn a() {}").as_str())
1799 );
1800
1801 let mut both = valid_input();
1802 both.content = Some("x".into());
1803 assert_eq!(
1804 both.validate(None).unwrap_err(),
1805 AnchorValidationError::ContentAndHash
1806 );
1807
1808 let mut ent = valid_input();
1809 ent.grain = Some("entity".into());
1810 ent.artifact = Some("m--e".into());
1811 ent.hash = None;
1812 ent.content = Some("x".into());
1813 let err = ent.validate(None).unwrap_err();
1814 assert_eq!(
1815 err,
1816 AnchorValidationError::ContentNotAcceptedForGrain { grain: "entity" }
1817 );
1818 assert_eq!(err.detail()["field"], "content");
1819
1820 let mut tree = valid_input();
1821 tree.grain = Some("tree".into());
1822 tree.hash = None;
1823 tree.content = Some("x".into());
1824 assert!(matches!(
1825 tree.validate(None).unwrap_err(),
1826 AnchorValidationError::ContentNotAcceptedForGrain { grain: "tree" }
1827 ));
1828
1829 let mut informed = valid_input();
1830 informed.class = Some("informed-by".into());
1831 informed.hash = None;
1832 informed.content = Some("x".into());
1833 assert!(matches!(
1834 informed.validate(None).unwrap_err(),
1835 AnchorValidationError::HashOnNonHashClass { .. }
1836 ));
1837 }
1838
1839 #[test]
1840 fn validate_refuses_unknown_class() {
1841 let mut i = valid_input();
1842 i.class = Some("guessed".into());
1843 let err = i.validate(None).unwrap_err();
1844 assert_eq!(err.code(), INVALID_ANCHOR_CODE);
1845 assert!(matches!(err, AnchorValidationError::UnknownClass { .. }));
1846 assert_eq!(err.detail()["field"], serde_json::json!("class"));
1847 }
1848
1849 #[test]
1850 fn validate_refuses_unknown_grain() {
1851 let mut i = valid_input();
1852 i.grain = Some("paragraph".into());
1853 let err = i.validate(None).unwrap_err();
1854 assert!(matches!(err, AnchorValidationError::UnknownGrain { .. }));
1855 }
1856
1857 #[test]
1858 fn validate_refuses_missing_artifact() {
1859 let mut i = valid_input();
1860 i.artifact = Some(" ".into());
1861 let err = i.validate(None).unwrap_err();
1862 assert!(matches!(err, AnchorValidationError::MissingArtifact));
1863 i.artifact = None;
1864 assert!(matches!(
1865 valid_input_with_artifact(None).validate(None).unwrap_err(),
1866 AnchorValidationError::MissingArtifact
1867 ));
1868 let _ = i;
1869 }
1870
1871 fn valid_input_with_artifact(a: Option<String>) -> AnchorInput {
1872 AnchorInput {
1873 artifact: a,
1874 ..valid_input()
1875 }
1876 }
1877
1878 #[test]
1879 fn validate_refuses_hash_on_non_hash_class() {
1880 let mut i = valid_input();
1881 i.class = Some("authored".into());
1882 let err = i.validate(None).unwrap_err();
1884 assert!(matches!(
1885 err,
1886 AnchorValidationError::HashOnNonHashClass { class: "authored" }
1887 ));
1888 }
1889
1890 #[test]
1891 fn validate_accepts_non_hash_class_without_hash() {
1892 let mut i = valid_input();
1893 i.class = Some("informed-by".into());
1894 i.hash = None;
1895 let a = i.validate(None).unwrap();
1896 assert_eq!(a.class, AnchorProvenanceClass::InformedBy);
1897 assert!(a.hash.is_none());
1898 }
1899
1900 #[test]
1901 fn validate_refuses_grain_unsupported_by_medium_namespace() {
1902 let mut i = valid_input();
1904 i.grain = Some("span".into());
1905 i.class = Some("authored".into());
1906 i.hash = None;
1907 let err = i.validate(Some(("web", "url"))).unwrap_err();
1908 match err {
1909 AnchorValidationError::GrainNamespaceUnsupported {
1910 grain,
1911 anchor_namespace,
1912 ..
1913 } => {
1914 assert_eq!(grain, "span");
1915 assert_eq!(anchor_namespace, "url");
1916 }
1917 other => panic!("expected GrainNamespaceUnsupported, got {other:?}"),
1918 }
1919 }
1920
1921 #[test]
1922 fn validate_skips_namespace_check_without_medium_context() {
1923 let mut i = valid_input();
1925 i.grain = Some("span".into());
1926 assert!(i.validate(None).is_ok());
1927 }
1928
1929 #[test]
1935 fn prepared_hash_is_stable_across_byte_noise() {
1936 let base = prepared_content_hash(b"fn a() {}\nfn b() {}\n");
1937 assert_eq!(prepared_content_hash(b"fn a() {}\r\nfn b() {}\r\n"), base);
1939 assert_eq!(prepared_content_hash(b"fn a() {}\rfn b() {}\r"), base);
1940 assert_eq!(prepared_content_hash(b"fn a() {}\nfn b() {}"), base);
1942 assert_eq!(prepared_content_hash(b"fn a() {}\nfn b() {}\n\n\n"), base);
1943 assert_eq!(
1945 prepared_content_hash("\u{feff}fn a() {}\nfn b() {}\n".as_bytes()),
1946 base
1947 );
1948 assert_ne!(prepared_content_hash(b"fn a() {}\nfn c() {}\n"), base);
1950 assert_eq!(base.len(), 16);
1952 assert!(
1953 base.chars()
1954 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
1955 );
1956 }
1957
1958 #[test]
1961 fn prepared_hash_preserves_interior_whitespace() {
1962 assert_ne!(
1963 prepared_content_hash(b"line one \nline two\n"),
1964 prepared_content_hash(b"line one\nline two\n")
1965 );
1966 }
1967
1968 #[test]
1971 fn prepared_hash_hashes_binary_bytes_raw() {
1972 let bin_a = [0xff_u8, 0xfe, 0x00, 0x0d, 0x0a];
1973 let bin_b = [0xff_u8, 0xfe, 0x00, 0x0a];
1974 assert_ne!(prepared_content_hash(&bin_a), prepared_content_hash(&bin_b));
1975 assert_eq!(prepared_content_hash(&bin_a), prepared_content_hash(&bin_a));
1977 }
1978
1979 fn anchor(
1982 class: AnchorProvenanceClass,
1983 hash: Option<&str>,
1984 stab: AnchorHashStability,
1985 ) -> Anchor {
1986 Anchor {
1987 artifact: "src/lib.rs".into(),
1988 grain: AnchorGrain::File,
1989 class,
1990 at_version: None,
1991 hash: hash.map(str::to_string),
1992 hash_stability: stab,
1993 derived_from: Vec::new(),
1994 binding: None,
1995 source: None,
1996 span_unvalidated: false,
1997 hash_source: None,
1998 }
1999 }
2000
2001 #[test]
2002 fn resolves_when_hash_matches() {
2003 let a = anchor(
2004 AnchorProvenanceClass::Anchored,
2005 Some("h1"),
2006 AnchorHashStability::Stable,
2007 );
2008 let obs = ArtifactObservation::Present {
2009 current_hash: Some("h1".into()),
2010 };
2011 assert_eq!(resolve_anchor(&a, &obs), AnchorState::Resolves);
2012 }
2013
2014 #[test]
2015 fn stable_hash_break_drifts_unstable_rechecks() {
2016 let stable = anchor(
2017 AnchorProvenanceClass::Anchored,
2018 Some("h1"),
2019 AnchorHashStability::Stable,
2020 );
2021 let unstable = anchor(
2022 AnchorProvenanceClass::Anchored,
2023 Some("h1"),
2024 AnchorHashStability::Unstable,
2025 );
2026 let obs = ArtifactObservation::Present {
2027 current_hash: Some("h2".into()),
2028 };
2029 assert_eq!(resolve_anchor(&stable, &obs), AnchorState::Drifted);
2030 assert_eq!(resolve_anchor(&unstable, &obs), AnchorState::Recheck);
2031 }
2032
2033 #[test]
2034 fn absent_artifact_is_orphaned() {
2035 let a = anchor(
2036 AnchorProvenanceClass::Anchored,
2037 Some("h1"),
2038 AnchorHashStability::Stable,
2039 );
2040 assert_eq!(
2041 resolve_anchor(&a, &ArtifactObservation::Absent),
2042 AnchorState::Orphaned
2043 );
2044 }
2045
2046 #[test]
2047 fn non_hash_classes_never_drift() {
2048 for class in [
2049 AnchorProvenanceClass::Authored,
2050 AnchorProvenanceClass::InformedBy,
2051 ] {
2052 let a = anchor(class, None, AnchorHashStability::Stable);
2053 let obs = ArtifactObservation::Present {
2056 current_hash: Some("whatever".into()),
2057 };
2058 assert_eq!(resolve_anchor(&a, &obs), AnchorState::Resolves);
2059 assert_eq!(
2061 resolve_anchor(&a, &ArtifactObservation::Absent),
2062 AnchorState::Orphaned
2063 );
2064 }
2065 }
2066
2067 #[test]
2068 fn unavailable_hash_rechecks_not_drifts() {
2069 let a = anchor(
2070 AnchorProvenanceClass::Anchored,
2071 Some("h1"),
2072 AnchorHashStability::Stable,
2073 );
2074 let obs = ArtifactObservation::Present { current_hash: None };
2075 assert_eq!(resolve_anchor(&a, &obs), AnchorState::Recheck);
2076 }
2077
2078 #[test]
2081 fn composition_counts_classes_grains_and_tree_fanout() {
2082 let anchors = vec![
2083 Anchor {
2084 artifact: "a.rs".into(),
2085 grain: AnchorGrain::File,
2086 class: AnchorProvenanceClass::Anchored,
2087 at_version: None,
2088 hash: Some("h".into()),
2089 hash_stability: AnchorHashStability::Stable,
2090 derived_from: Vec::new(),
2091 binding: None,
2092 source: None,
2093 span_unvalidated: false,
2094 hash_source: None,
2095 },
2096 Anchor {
2097 artifact: "src/".into(),
2098 grain: AnchorGrain::Tree,
2099 class: AnchorProvenanceClass::Derived,
2100 at_version: None,
2101 hash: Some("t".into()),
2102 hash_stability: AnchorHashStability::Stable,
2103 derived_from: vec!["a.rs".into(), "b.rs".into()],
2104 binding: None,
2105 source: None,
2106 span_unvalidated: false,
2107 hash_source: None,
2108 },
2109 ];
2110 let comp = compose_entity_anchors(&anchors);
2111 assert_eq!(comp.by_class["anchored"], 1);
2112 assert_eq!(comp.by_class["derived"], 1);
2113 assert_eq!(comp.by_grain["file"], 1);
2114 assert_eq!(comp.by_grain["tree"], 1);
2115 assert_eq!(comp.tree_grain_artifacts, vec!["src/".to_string()]);
2117 assert_eq!(
2118 comp.derived_inputs,
2119 vec![vec!["a.rs".to_string(), "b.rs".to_string()]]
2120 );
2121 }
2122
2123 #[test]
2126 fn sidecar_round_trips_and_prunes_empty() {
2127 let mut sc = AnchorSidecar::default();
2128 assert!(sc.is_empty());
2129 let a = anchor(
2130 AnchorProvenanceClass::Anchored,
2131 Some("h1"),
2132 AnchorHashStability::Stable,
2133 );
2134 sc.set("specs--x", vec![a.clone()]);
2135 assert_eq!(sc.get("specs--x").len(), 1);
2136
2137 let bytes = sc.to_bytes();
2138 let round = AnchorSidecar::from_bytes(&bytes).unwrap();
2139 assert_eq!(round, sc);
2140
2141 sc.set("specs--x", vec![]);
2143 assert!(sc.is_empty());
2144 assert!(sc.get("specs--x").is_empty());
2145 }
2146
2147 fn file_anchor(artifact: &str, hash: &str) -> Anchor {
2150 Anchor {
2151 artifact: artifact.into(),
2152 grain: AnchorGrain::File,
2153 class: AnchorProvenanceClass::Anchored,
2154 at_version: None,
2155 hash: Some(hash.into()),
2156 hash_stability: AnchorHashStability::Stable,
2157 derived_from: Vec::new(),
2158 binding: None,
2159 source: None,
2160 span_unvalidated: false,
2161 hash_source: None,
2162 }
2163 }
2164
2165 #[test]
2168 fn merge_appends_new_triple_without_touching_others() {
2169 let mut sc = AnchorSidecar::default();
2170 sc.set(
2171 "m--e",
2172 vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
2173 );
2174 sc.merge("m--e", &[], vec![file_anchor("c.rs", "h-c")]);
2175 let row = sc.get("m--e");
2176 assert_eq!(row.len(), 3);
2177 assert_eq!(row[0], file_anchor("a.rs", "h-a"));
2178 assert_eq!(row[1], file_anchor("b.rs", "h-b"));
2179 assert_eq!(row[2], file_anchor("c.rs", "h-c"));
2180 }
2181
2182 #[test]
2186 fn merge_replaces_same_triple_in_place() {
2187 let mut sc = AnchorSidecar::default();
2188 sc.set(
2189 "m--e",
2190 vec![file_anchor("a.rs", "h-old"), file_anchor("b.rs", "h-b")],
2191 );
2192 sc.merge("m--e", &[], vec![file_anchor("a.rs", "h-new")]);
2193 let row = sc.get("m--e");
2194 assert_eq!(row.len(), 2);
2195 assert_eq!(row[0], file_anchor("a.rs", "h-new"));
2196 assert_eq!(row[1], file_anchor("b.rs", "h-b"));
2197 }
2198
2199 #[test]
2203 fn merge_treats_grain_and_class_as_identity() {
2204 let mut sc = AnchorSidecar::default();
2205 sc.set("m--e", vec![file_anchor("a.rs", "h-a")]);
2206 let mut span = file_anchor("a.rs", "h-span");
2207 span.grain = AnchorGrain::Span;
2208 let mut informed = file_anchor("a.rs", "h-a");
2209 informed.class = AnchorProvenanceClass::InformedBy;
2210 informed.hash = None;
2211 sc.merge("m--e", &[], vec![span, informed]);
2212 assert_eq!(sc.get("m--e").len(), 3);
2213 }
2214
2215 #[test]
2218 fn merge_full_resend_and_empty_are_noops() {
2219 let mut sc = AnchorSidecar::default();
2220 sc.set(
2221 "m--e",
2222 vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
2223 );
2224 let before = sc.to_bytes();
2225 sc.merge(
2226 "m--e",
2227 &[],
2228 vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
2229 );
2230 assert_eq!(sc.to_bytes(), before, "full re-send is byte-stable");
2231 sc.merge("m--e", &[], Vec::new());
2232 assert_eq!(sc.to_bytes(), before, "empty merge is a no-op");
2233 }
2234
2235 #[test]
2239 fn unset_selects_by_artifact_with_optional_narrowing() {
2240 let mut span = file_anchor("a.rs", "h-span");
2241 span.grain = AnchorGrain::Span;
2242 let mut sc = AnchorSidecar::default();
2243 sc.set(
2244 "m--e",
2245 vec![
2246 file_anchor("a.rs", "h-a"),
2247 span.clone(),
2248 file_anchor("b.rs", "h-b"),
2249 ],
2250 );
2251
2252 let narrowed = AnchorUnset {
2254 artifact: "a.rs".into(),
2255 grain: Some(AnchorGrain::Span),
2256 class: None,
2257 };
2258 sc.merge("m--e", &[narrowed], Vec::new());
2259 assert_eq!(
2260 sc.get("m--e"),
2261 &[file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")]
2262 );
2263
2264 let missing = AnchorUnset {
2266 artifact: "never-there.rs".into(),
2267 grain: None,
2268 class: None,
2269 };
2270 sc.merge("m--e", &[missing], Vec::new());
2271 assert_eq!(sc.get("m--e").len(), 2);
2272
2273 let bare = AnchorUnset {
2275 artifact: "a.rs".into(),
2276 grain: None,
2277 class: None,
2278 };
2279 sc.merge("m--e", &[bare], Vec::new());
2280 assert_eq!(sc.get("m--e"), &[file_anchor("b.rs", "h-b")]);
2281 }
2282
2283 #[test]
2287 fn unset_applies_before_merge() {
2288 let mut span = file_anchor("a.rs", "h-span");
2289 span.grain = AnchorGrain::Span;
2290 let mut sc = AnchorSidecar::default();
2291 sc.set("m--e", vec![file_anchor("a.rs", "h-old"), span]);
2292 let bare = AnchorUnset {
2293 artifact: "a.rs".into(),
2294 grain: None,
2295 class: None,
2296 };
2297 sc.merge("m--e", &[bare], vec![file_anchor("a.rs", "h-new")]);
2298 assert_eq!(sc.get("m--e"), &[file_anchor("a.rs", "h-new")]);
2299 }
2300
2301 #[test]
2304 fn merge_prunes_row_emptied_by_unset() {
2305 let mut sc = AnchorSidecar::default();
2306 sc.set("m--e", vec![file_anchor("a.rs", "h-a")]);
2307 let bare = AnchorUnset {
2308 artifact: "a.rs".into(),
2309 grain: None,
2310 class: None,
2311 };
2312 sc.merge("m--e", &[bare], Vec::new());
2313 assert!(sc.is_empty());
2314 assert!(!sc.to_bytes().windows(5).any(|w| w == b"m--e\""));
2315 }
2316
2317 #[test]
2320 fn unset_input_validates_typed() {
2321 let ok = AnchorUnsetInput {
2322 artifact: Some(" a.rs ".into()),
2323 grain: Some("span".into()),
2324 class: None,
2325 }
2326 .validate()
2327 .unwrap();
2328 assert_eq!(ok.artifact, "a.rs");
2329 assert_eq!(ok.grain, Some(AnchorGrain::Span));
2330 assert_eq!(ok.class, None);
2331
2332 let missing = AnchorUnsetInput::default().validate().unwrap_err();
2333 assert!(matches!(missing, AnchorValidationError::MissingArtifact));
2334 assert_eq!(missing.code(), INVALID_ANCHOR_CODE);
2335
2336 let bad_grain = AnchorUnsetInput {
2337 artifact: Some("a.rs".into()),
2338 grain: Some("paragraph".into()),
2339 class: None,
2340 }
2341 .validate()
2342 .unwrap_err();
2343 assert!(matches!(
2344 bad_grain,
2345 AnchorValidationError::UnknownGrain { .. }
2346 ));
2347
2348 let bad_class = AnchorUnsetInput {
2349 artifact: Some("a.rs".into()),
2350 grain: None,
2351 class: Some("guessed".into()),
2352 }
2353 .validate()
2354 .unwrap_err();
2355 assert!(matches!(
2356 bad_class,
2357 AnchorValidationError::UnknownClass { .. }
2358 ));
2359 }
2360
2361 #[test]
2362 fn sidecar_rename_leaves_zero_rows_under_old_id() {
2363 let mut sc = AnchorSidecar::default();
2364 sc.set(
2365 "specs--old",
2366 vec![anchor(
2367 AnchorProvenanceClass::Anchored,
2368 Some("h"),
2369 AnchorHashStability::Stable,
2370 )],
2371 );
2372 sc.rename("specs--old", "specs--new");
2373 assert!(sc.get("specs--old").is_empty());
2374 assert_eq!(sc.get("specs--new").len(), 1);
2375 }
2376
2377 #[test]
2378 fn sidecar_remove_drops_entity_anchors() {
2379 let mut sc = AnchorSidecar::default();
2380 sc.set(
2381 "specs--gone",
2382 vec![anchor(
2383 AnchorProvenanceClass::Anchored,
2384 Some("h"),
2385 AnchorHashStability::Stable,
2386 )],
2387 );
2388 sc.remove("specs--gone");
2389 assert!(sc.get("specs--gone").is_empty());
2390 sc.remove("specs--gone");
2392 }
2393
2394 #[test]
2395 fn empty_bytes_parse_as_empty_sidecar() {
2396 assert!(AnchorSidecar::from_bytes(b"").unwrap().is_empty());
2397 assert!(AnchorSidecar::from_bytes(b" \n ").unwrap().is_empty());
2398 }
2399
2400 #[test]
2401 fn anchor_json_shape_omits_empty_optionals() {
2402 let a = anchor(
2403 AnchorProvenanceClass::Anchored,
2404 Some("h1"),
2405 AnchorHashStability::Stable,
2406 );
2407 let v = serde_json::to_value(&a).unwrap();
2408 assert_eq!(v["artifact"], "src/lib.rs");
2409 assert_eq!(v["grain"], "file");
2410 assert_eq!(v["class"], "anchored");
2411 assert_eq!(v["hash"], "h1");
2412 assert_eq!(v["hash_stability"], "stable");
2413 assert!(v.get("at_version").is_none());
2415 assert!(v.get("derived_from").is_none());
2416 assert!(v.get("binding").is_none());
2417 }
2418
2419 #[test]
2420 fn anchor_version_serialises_tagged() {
2421 let a = Anchor {
2422 at_version: Some(AnchorVersion::Commit("deadbeef".into())),
2423 ..anchor(
2424 AnchorProvenanceClass::Anchored,
2425 Some("h"),
2426 AnchorHashStability::Stable,
2427 )
2428 };
2429 let v = serde_json::to_value(&a).unwrap();
2430 assert_eq!(v["at_version"]["kind"], "commit");
2431 assert_eq!(v["at_version"]["value"], "deadbeef");
2432 }
2433
2434 #[test]
2438 fn validate_source_carried_absent_or_refused_when_empty() {
2439 let mut input = AnchorInput {
2440 artifact: Some("src/lib.rs".into()),
2441 grain: Some("file".into()),
2442 class: Some("anchored".into()),
2443 ..Default::default()
2444 };
2445 assert_eq!(
2446 input.validate(None).unwrap().source,
2447 None,
2448 "absent stays absent"
2449 );
2450
2451 input.source = Some(" api-docs ".into());
2452 assert_eq!(
2453 input.validate(None).unwrap().source.as_deref(),
2454 Some("api-docs"),
2455 "non-empty name is carried (trimmed)"
2456 );
2457
2458 input.source = Some(" ".into());
2459 let err = input.validate(None).unwrap_err();
2460 assert_eq!(err.code(), INVALID_ANCHOR_CODE);
2461 assert!(matches!(err, AnchorValidationError::EmptySource));
2462 assert_eq!(
2463 err.detail().get("field"),
2464 Some(&serde_json::json!("source"))
2465 );
2466 }
2467
2468 #[test]
2472 fn source_is_additive_on_the_persisted_shape() {
2473 let pre_plan = r#"{
2474 "artifact": "src/lib.rs",
2475 "grain": "file",
2476 "class": "anchored",
2477 "hash_stability": "stable"
2478 }"#;
2479 let a: Anchor = serde_json::from_str(pre_plan).expect("pre-plan anchor loads");
2480 assert_eq!(a.source, None, "no backfill, no default");
2481
2482 let sourced = Anchor {
2483 source: Some("api-docs".into()),
2484 ..a
2485 };
2486 let json = serde_json::to_string(&sourced).unwrap();
2487 let back: Anchor = serde_json::from_str(&json).unwrap();
2488 assert_eq!(back.source.as_deref(), Some("api-docs"));
2489 }
2490}