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 = 2;
65
66pub const ANCHOR_SIDECAR_VERSIONS_READ: &[u32] = &[1, 2];
69
70pub const INVALID_ANCHOR_CODE: &str = "INVALID_ANCHOR";
74
75pub const REDACTED_ARTIFACT_SENTINEL: &str = "[redacted]";
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "kebab-case")]
103pub enum AnchorProvenanceClass {
104 Anchored,
105 Derived,
106 Authored,
107 InformedBy,
108}
109
110impl AnchorProvenanceClass {
111 pub const WIRE_VALUES: &'static [&'static str] =
114 &["anchored", "derived", "authored", "informed-by"];
115
116 pub fn as_wire(&self) -> &'static str {
118 match self {
119 AnchorProvenanceClass::Anchored => "anchored",
120 AnchorProvenanceClass::Derived => "derived",
121 AnchorProvenanceClass::Authored => "authored",
122 AnchorProvenanceClass::InformedBy => "informed-by",
123 }
124 }
125
126 pub fn from_wire(s: &str) -> Option<Self> {
129 match s {
130 "anchored" => Some(AnchorProvenanceClass::Anchored),
131 "derived" => Some(AnchorProvenanceClass::Derived),
132 "authored" => Some(AnchorProvenanceClass::Authored),
133 "informed-by" => Some(AnchorProvenanceClass::InformedBy),
134 _ => None,
135 }
136 }
137
138 pub fn is_hash_bearing(&self) -> bool {
144 matches!(
145 self,
146 AnchorProvenanceClass::Anchored | AnchorProvenanceClass::Derived
147 )
148 }
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(rename_all = "lowercase")]
165pub enum AnchorGrain {
166 Span,
167 File,
168 Tree,
169 Url,
170 Entity,
171}
172
173impl AnchorGrain {
174 pub const WIRE_VALUES: &'static [&'static str] = &["span", "file", "tree", "url", "entity"];
176
177 pub fn as_wire(&self) -> &'static str {
179 match self {
180 AnchorGrain::Span => "span",
181 AnchorGrain::File => "file",
182 AnchorGrain::Tree => "tree",
183 AnchorGrain::Url => "url",
184 AnchorGrain::Entity => "entity",
185 }
186 }
187
188 pub fn from_wire(s: &str) -> Option<Self> {
190 match s {
191 "span" => Some(AnchorGrain::Span),
192 "file" => Some(AnchorGrain::File),
193 "tree" => Some(AnchorGrain::Tree),
194 "url" => Some(AnchorGrain::Url),
195 "entity" => Some(AnchorGrain::Entity),
196 _ => None,
197 }
198 }
199
200 pub fn supported_by_namespace(&self, anchor_namespace: &str) -> bool {
212 let path_shaped = matches!(anchor_namespace, "path" | "path+commit");
213 match self {
214 AnchorGrain::Span | AnchorGrain::File | AnchorGrain::Tree => path_shaped,
215 AnchorGrain::Url => true,
216 AnchorGrain::Entity => anchor_namespace == "entity",
217 }
218 }
219
220 pub fn is_path_shaped(&self) -> bool {
222 matches!(
223 self,
224 AnchorGrain::Span | AnchorGrain::File | AnchorGrain::Tree
225 )
226 }
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
241#[serde(rename_all = "lowercase")]
242pub enum AnchorHashStability {
243 Stable,
244 Unstable,
245}
246
247impl AnchorHashStability {
248 pub const WIRE_VALUES: &'static [&'static str] = &["stable", "unstable"];
250
251 pub fn as_wire(&self) -> &'static str {
253 match self {
254 AnchorHashStability::Stable => "stable",
255 AnchorHashStability::Unstable => "unstable",
256 }
257 }
258
259 pub fn from_wire(s: &str) -> Option<Self> {
261 match s {
262 "stable" => Some(AnchorHashStability::Stable),
263 "unstable" => Some(AnchorHashStability::Unstable),
264 _ => None,
265 }
266 }
267}
268
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
282#[serde(tag = "kind", content = "value", rename_all = "lowercase")]
283pub enum AnchorVersion {
284 Commit(String),
286 Snapshot(String),
288 Etag(String),
290}
291
292#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
303pub struct Anchor {
304 pub artifact: String,
308 pub grain: AnchorGrain,
310 pub class: AnchorProvenanceClass,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub at_version: Option<AnchorVersion>,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub hash: Option<String>,
321 pub hash_stability: AnchorHashStability,
324 #[serde(default, skip_serializing_if = "Vec::is_empty")]
327 pub derived_from: Vec<String>,
328 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub binding: Option<String>,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub source: Option<String>,
342 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
350 pub span_unvalidated: bool,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
357 pub hash_source: Option<AnchorHashSource>,
358 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub last_observed: Option<AnchorObservation>,
367}
368
369#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
372pub struct AnchorObservation {
373 pub at: String,
377 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub hash: Option<String>,
381 pub state: AnchorState,
383}
384
385#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
389#[serde(rename_all = "kebab-case")]
390pub enum AnchorHashSource {
391 Author,
393 Backfill,
395}
396
397impl Anchor {
398 pub fn same_as_supplied(&self, other: &Anchor) -> bool {
406 self.artifact == other.artifact
407 && self.grain == other.grain
408 && self.class == other.class
409 && self.at_version == other.at_version
410 && self.hash_stability == other.hash_stability
411 && self.derived_from == other.derived_from
412 && self.binding == other.binding
413 && self.source == other.source
414 && self.span_unvalidated == other.span_unvalidated
415 && other
416 .hash
417 .as_ref()
418 .is_none_or(|h| Some(h) == self.hash.as_ref())
419 }
420}
421
422impl AnchorHashSource {
423 pub fn as_wire(self) -> &'static str {
424 match self {
425 AnchorHashSource::Author => "author",
426 AnchorHashSource::Backfill => "backfill",
427 }
428 }
429}
430
431#[derive(Debug, Clone, Copy, PartialEq, Eq)]
445pub enum SpanLocator<'a> {
446 Lines { start: usize, end: usize },
448 Unit(&'a str),
450}
451
452pub fn parse_span_locator(artifact: &str) -> Result<Option<SpanLocator<'_>>, &'static str> {
469 let locator = match artifact.split_once('#') {
470 None => return Ok(None),
471 Some((_, loc)) if loc.trim().is_empty() => {
472 return Err("the span locator after `#` is empty");
473 }
474 Some((_, loc)) => loc,
475 };
476 let looks_like_lines = locator.starts_with('L')
479 && locator[1..]
480 .chars()
481 .next()
482 .is_some_and(|c| c.is_ascii_digit());
483 if !looks_like_lines {
484 return Ok(Some(SpanLocator::Unit(locator)));
485 }
486 let (start_raw, end_raw) = match locator.split_once('-') {
487 None => (locator, locator),
488 Some((a, b)) => (a, b),
489 };
490 let num = |part: &str| -> Option<usize> {
491 part.strip_prefix('L')
492 .filter(|d| !d.is_empty() && d.chars().all(|c| c.is_ascii_digit()))
493 .and_then(|d| d.parse::<usize>().ok())
494 };
495 let (Some(start), Some(end)) = (num(start_raw), num(end_raw)) else {
496 return Err("a line-range span locator must read `L<start>` or `L<start>-L<end>`");
497 };
498 if start == 0 {
499 return Err("line numbers are 1-based, so `L0` addresses nothing");
500 }
501 if end < start {
502 return Err("a line-range span locator ends before it starts");
503 }
504 Ok(Some(SpanLocator::Lines { start, end }))
505}
506
507#[derive(Debug, Clone, Default, Serialize, Deserialize)]
517pub struct AnchorInput {
518 #[serde(default)]
519 pub artifact: Option<String>,
520 #[serde(default)]
521 pub grain: Option<String>,
522 #[serde(default)]
523 pub class: Option<String>,
524 #[serde(default)]
525 pub at_version: Option<AnchorVersion>,
526 #[serde(default)]
527 pub hash: Option<String>,
528 #[serde(default)]
537 pub content: Option<String>,
538 #[serde(default)]
539 pub hash_stability: Option<String>,
540 #[serde(default)]
541 pub derived_from: Option<Vec<String>>,
542 #[serde(default)]
543 pub binding: Option<String>,
544 #[serde(default)]
545 pub source: Option<String>,
546}
547
548#[derive(Debug, Clone, Default, Serialize, Deserialize)]
556pub struct AnchorUnsetInput {
557 #[serde(default)]
558 pub artifact: Option<String>,
559 #[serde(default)]
560 pub grain: Option<String>,
561 #[serde(default)]
562 pub class: Option<String>,
563}
564
565impl AnchorUnsetInput {
566 pub fn validate(&self) -> Result<AnchorUnset, AnchorValidationError> {
570 let artifact = self
571 .artifact
572 .as_deref()
573 .map(str::trim)
574 .filter(|s| !s.is_empty())
575 .map(str::to_string)
576 .ok_or(AnchorValidationError::MissingArtifact)?;
577 let grain = match self.grain.as_deref() {
578 None => None,
579 Some(s) => Some(AnchorGrain::from_wire(s).ok_or_else(|| {
580 AnchorValidationError::UnknownGrain {
581 got: Some(s.to_string()),
582 allowed: AnchorGrain::WIRE_VALUES,
583 }
584 })?),
585 };
586 let class = match self.class.as_deref() {
587 None => None,
588 Some(s) => Some(AnchorProvenanceClass::from_wire(s).ok_or_else(|| {
589 AnchorValidationError::UnknownClass {
590 got: Some(s.to_string()),
591 allowed: AnchorProvenanceClass::WIRE_VALUES,
592 }
593 })?),
594 };
595 Ok(AnchorUnset {
596 artifact,
597 grain,
598 class,
599 })
600 }
601}
602
603#[derive(Debug, Clone, PartialEq, Eq)]
609pub struct AnchorUnset {
610 pub artifact: String,
612 pub grain: Option<AnchorGrain>,
614 pub class: Option<AnchorProvenanceClass>,
616}
617
618impl AnchorUnset {
619 pub fn matches(&self, anchor: &Anchor) -> bool {
621 anchor.artifact == self.artifact
622 && self.grain.is_none_or(|g| anchor.grain == g)
623 && self.class.is_none_or(|c| anchor.class == c)
624 }
625}
626
627#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
631pub enum AnchorValidationError {
632 #[error("unknown anchor provenance class {got:?}; allowed: {}", allowed.join(", "))]
634 UnknownClass {
635 got: Option<String>,
636 allowed: &'static [&'static str],
637 },
638 #[error("unknown anchor grain {got:?}; allowed: {}", allowed.join(", "))]
640 UnknownGrain {
641 got: Option<String>,
642 allowed: &'static [&'static str],
643 },
644 #[error("unknown anchor hash stability {got:?}; allowed: {}", allowed.join(", "))]
646 UnknownHashStability {
647 got: String,
648 allowed: &'static [&'static str],
649 },
650 #[error("anchor is missing its artifact reference")]
652 MissingArtifact,
653 #[error("anchor class '{class}' carries no hash semantics — a content hash is not permitted")]
656 HashOnNonHashClass { class: &'static str },
657 #[error(
660 "anchor supplies both `hash` and `content`; supply one — the engine computes the hash from `content`"
661 )]
662 ContentAndHash,
663 #[error(
668 "anchor grain '{grain}' does not accept `content`: its prepared form is not computed \
669 from supplied bytes (accepted for span / file / url)"
670 )]
671 ContentNotAcceptedForGrain { grain: &'static str },
672 #[error(
675 "anchor artifact {artifact:?} names a delivery unit the supplied `content` does not \
676 yield; supply the whole file's content, or address a unit it contains"
677 )]
678 UnitAbsentFromContent { artifact: String },
679 #[error("anchor artifact {artifact:?} is not a usable span reference: {reason}")]
684 SpanLocatorUnusable {
685 artifact: String,
686 reason: &'static str,
687 },
688 #[error(
693 "anchor artifact {artifact:?} names lines the supplied `content` does not have \
694 (it has {lines} line(s)); address a range the artifact contains"
695 )]
696 SpanOutsideContent { artifact: String, lines: usize },
697 #[error(
703 "the anchors payload names {artifact:?} at grain `{grain}` and class `{class}` more \
704 than once; that triple is one row, so the repeats would silently collapse to the \
705 last one: send it once, or vary the grain or class"
706 )]
707 DuplicateAnchorTriple {
708 artifact: String,
709 grain: &'static str,
710 class: &'static str,
711 },
712 #[error("anchor `source`, when present, must be a non-empty source name")]
716 EmptySource,
717 #[error(
726 "anchor `source` {got:?} is not declared by the anchor's producing binding; \
727 declared sources: {}",
728 declared.join(", ")
729 )]
730 SourceNotDeclared { got: String, declared: Vec<String> },
731 #[error(
738 "anchor artifact {artifact:?} resolves under no candidate path (tried: {}); artifact \
739 paths are source-relative (joined onto the source's pointer) or workspace-relative — \
740 write the path exactly as the brief lists it",
741 candidates.join(", ")
742 )]
743 ArtifactUnresolvable {
744 artifact: String,
745 candidates: Vec<String>,
746 },
747 #[error(
750 "anchor grain '{grain}' is unsupported by a '{medium_type}' medium: its \
751 '{anchor_namespace}' namespace does not admit that grain"
752 )]
753 GrainNamespaceUnsupported {
754 grain: &'static str,
755 medium_type: String,
756 anchor_namespace: &'static str,
757 },
758 #[error(
762 "anchor grain '{grain}' selects within a path namespace, but its artifact '{artifact}' is a URL — a URL never enters a path namespace; use `grain: url` for the resource"
763 )]
764 PathGrainOnUrlArtifact {
765 grain: &'static str,
766 artifact: String,
767 },
768}
769
770pub fn looks_like_url(artifact: &str) -> bool {
772 let Some((scheme, rest)) = artifact.split_once("://") else {
773 return false;
774 };
775 !rest.is_empty()
776 && scheme
777 .chars()
778 .next()
779 .is_some_and(|c| c.is_ascii_alphabetic())
780 && scheme
781 .chars()
782 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
783}
784
785impl AnchorValidationError {
786 pub fn code(&self) -> &'static str {
788 INVALID_ANCHOR_CODE
789 }
790
791 pub fn detail(&self) -> BTreeMap<String, serde_json::Value> {
794 let mut d = BTreeMap::new();
795 match self {
796 AnchorValidationError::UnknownClass { got, allowed } => {
797 d.insert("field".into(), "class".into());
798 d.insert("got".into(), serde_json::json!(got));
799 d.insert("allowed".into(), serde_json::json!(allowed));
800 }
801 AnchorValidationError::UnknownGrain { got, allowed } => {
802 d.insert("field".into(), "grain".into());
803 d.insert("got".into(), serde_json::json!(got));
804 d.insert("allowed".into(), serde_json::json!(allowed));
805 }
806 AnchorValidationError::UnknownHashStability { got, allowed } => {
807 d.insert("field".into(), "hash_stability".into());
808 d.insert("got".into(), serde_json::json!(got));
809 d.insert("allowed".into(), serde_json::json!(allowed));
810 }
811 AnchorValidationError::MissingArtifact => {
812 d.insert("field".into(), "artifact".into());
813 }
814 AnchorValidationError::EmptySource => {
815 d.insert("field".into(), "source".into());
816 }
817 AnchorValidationError::SourceNotDeclared { got, declared } => {
818 d.insert("field".into(), "source".into());
819 d.insert("got".into(), serde_json::json!(got));
820 d.insert("declared".into(), serde_json::json!(declared));
821 }
822 AnchorValidationError::HashOnNonHashClass { class } => {
823 d.insert("field".into(), "hash".into());
824 d.insert("class".into(), serde_json::json!(class));
825 }
826 AnchorValidationError::ContentAndHash => {
827 d.insert("field".into(), "content".into());
828 d.insert(
829 "expected".into(),
830 serde_json::json!("either `hash` or `content`, never both"),
831 );
832 }
833 AnchorValidationError::ContentNotAcceptedForGrain { grain } => {
834 d.insert("field".into(), "content".into());
835 d.insert("grain".into(), serde_json::json!(grain));
836 d.insert(
837 "accepted_grains".into(),
838 serde_json::json!(["span", "file", "url"]),
839 );
840 }
841 AnchorValidationError::UnitAbsentFromContent { artifact } => {
842 d.insert("field".into(), "content".into());
843 d.insert("got".into(), serde_json::json!(artifact));
844 }
845 AnchorValidationError::SpanLocatorUnusable { artifact, reason } => {
846 d.insert("field".into(), "artifact".into());
847 d.insert("got".into(), serde_json::json!(artifact));
848 d.insert("expected".into(), serde_json::json!(reason));
849 }
850 AnchorValidationError::SpanOutsideContent { artifact, lines } => {
851 d.insert("field".into(), "artifact".into());
852 d.insert("got".into(), serde_json::json!(artifact));
853 d.insert("content_lines".into(), serde_json::json!(lines));
854 }
855 AnchorValidationError::DuplicateAnchorTriple {
856 artifact,
857 grain,
858 class,
859 } => {
860 d.insert("field".into(), "anchors".into());
861 d.insert(
862 "got".into(),
863 serde_json::json!({ "artifact": artifact, "grain": grain, "class": class }),
864 );
865 d.insert(
866 "expected".into(),
867 serde_json::json!(
868 "each (artifact, grain, class) triple at most once per payload"
869 ),
870 );
871 }
872 AnchorValidationError::ArtifactUnresolvable {
873 artifact,
874 candidates,
875 } => {
876 d.insert("field".into(), "artifact".into());
877 d.insert("got".into(), serde_json::json!(artifact));
878 d.insert("candidates_tried".into(), serde_json::json!(candidates));
879 d.insert(
880 "expected".into(),
881 serde_json::json!(
882 "a source-relative path (joined onto the source's pointer) or a \
883 workspace-relative path that resolves to an existing artifact"
884 ),
885 );
886 }
887 AnchorValidationError::GrainNamespaceUnsupported {
888 grain,
889 medium_type,
890 anchor_namespace,
891 } => {
892 d.insert("field".into(), "grain".into());
893 d.insert("grain".into(), serde_json::json!(grain));
894 d.insert("medium_type".into(), serde_json::json!(medium_type));
895 d.insert(
896 "anchor_namespace".into(),
897 serde_json::json!(anchor_namespace),
898 );
899 }
900 AnchorValidationError::PathGrainOnUrlArtifact { grain, artifact } => {
901 d.insert("field".into(), "grain".into());
902 d.insert("grain".into(), serde_json::json!(grain));
903 d.insert("got".into(), serde_json::json!(artifact));
904 d.insert(
905 "expected".into(),
906 serde_json::json!(
907 "`grain: url` for a web resource — a URL never enters a path namespace"
908 ),
909 );
910 }
911 }
912 d
913 }
914}
915
916impl AnchorInput {
917 pub fn validate(&self, medium: Option<(&str, &str)>) -> Result<Anchor, AnchorValidationError> {
940 let class = match self
941 .class
942 .as_deref()
943 .and_then(AnchorProvenanceClass::from_wire)
944 {
945 Some(c) => c,
946 None => {
947 return Err(AnchorValidationError::UnknownClass {
948 got: self.class.clone(),
949 allowed: AnchorProvenanceClass::WIRE_VALUES,
950 });
951 }
952 };
953 let grain = match self.grain.as_deref().and_then(AnchorGrain::from_wire) {
954 Some(g) => g,
955 None => {
956 return Err(AnchorValidationError::UnknownGrain {
957 got: self.grain.clone(),
958 allowed: AnchorGrain::WIRE_VALUES,
959 });
960 }
961 };
962
963 let artifact = self
964 .artifact
965 .as_deref()
966 .map(str::trim)
967 .filter(|s| !s.is_empty())
968 .map(str::to_string)
969 .ok_or(AnchorValidationError::MissingArtifact)?;
970
971 let hash_stability = match self.hash_stability.as_deref() {
974 None => crate::preparation::default_hash_stability(grain),
975 Some(s) => AnchorHashStability::from_wire(s).ok_or_else(|| {
976 AnchorValidationError::UnknownHashStability {
977 got: s.to_string(),
978 allowed: AnchorHashStability::WIRE_VALUES,
979 }
980 })?,
981 };
982
983 let hash = self
985 .hash
986 .as_deref()
987 .map(str::trim)
988 .filter(|s| !s.is_empty())
989 .map(str::to_string);
990 if (hash.is_some() || self.content.is_some()) && !class.is_hash_bearing() {
991 return Err(AnchorValidationError::HashOnNonHashClass {
992 class: class.as_wire(),
993 });
994 }
995 let hash = match self.content.as_deref() {
999 None => hash,
1000 Some(_) if hash.is_some() => return Err(AnchorValidationError::ContentAndHash),
1001 Some(content) => {
1002 match crate::preparation::supplied_content_hash(grain, content.as_bytes()) {
1003 Some(h) => Some(h),
1004 None => {
1005 return Err(AnchorValidationError::ContentNotAcceptedForGrain {
1006 grain: grain.as_wire(),
1007 });
1008 }
1009 }
1010 }
1011 };
1012
1013 let mut span_unvalidated = false;
1019 if grain == AnchorGrain::Span {
1020 let locator = parse_span_locator(&artifact).map_err(|reason| {
1021 AnchorValidationError::SpanLocatorUnusable {
1022 artifact: artifact.clone(),
1023 reason,
1024 }
1025 })?;
1026 match (locator, self.content.as_deref()) {
1027 (Some(SpanLocator::Lines { end, .. }), Some(content)) => {
1028 let lines = content.lines().count();
1029 if end > lines {
1030 return Err(AnchorValidationError::SpanOutsideContent {
1031 artifact: artifact.clone(),
1032 lines,
1033 });
1034 }
1035 }
1036 (Some(SpanLocator::Unit(_)), Some(_)) => {}
1040 (None, _) => {}
1044 (Some(_), None) => span_unvalidated = true,
1045 }
1046 }
1047
1048 if grain.is_path_shaped() && looks_like_url(&artifact) {
1051 return Err(AnchorValidationError::PathGrainOnUrlArtifact {
1052 grain: grain.as_wire(),
1053 artifact,
1054 });
1055 }
1056
1057 if let Some((medium_type, namespace)) = medium
1061 && !grain.supported_by_namespace(namespace)
1062 {
1063 let anchor_namespace = match namespace {
1066 "path" => "path",
1067 "path+commit" => "path+commit",
1068 "entity" => "entity",
1069 "url" => "url",
1070 _ => "path",
1071 };
1072 return Err(AnchorValidationError::GrainNamespaceUnsupported {
1073 grain: grain.as_wire(),
1074 medium_type: medium_type.to_string(),
1075 anchor_namespace,
1076 });
1077 }
1078
1079 let source = match self.source.as_deref() {
1084 None => None,
1085 Some(raw) => {
1086 let trimmed = raw.trim();
1087 if trimmed.is_empty() {
1088 return Err(AnchorValidationError::EmptySource);
1089 }
1090 Some(trimmed.to_string())
1091 }
1092 };
1093
1094 Ok(Anchor {
1095 artifact,
1096 grain,
1097 class,
1098 at_version: self.at_version.clone(),
1099 hash_source: hash.is_some().then_some(AnchorHashSource::Author),
1102 hash,
1103 hash_stability,
1104 derived_from: self.derived_from.clone().unwrap_or_default(),
1105 binding: self
1106 .binding
1107 .as_deref()
1108 .map(str::trim)
1109 .filter(|s| !s.is_empty())
1110 .map(str::to_string),
1111 source,
1112 span_unvalidated,
1113 last_observed: None,
1114 })
1115 }
1116}
1117
1118pub fn prepared_content_hash(bytes: &[u8]) -> String {
1145 use sha2::{Digest as _, Sha256};
1146 let digest = match std::str::from_utf8(bytes) {
1147 Ok(text) => {
1148 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
1149 let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
1150 Sha256::digest(normalized.trim_end_matches('\n').as_bytes())
1151 }
1152 Err(_) => Sha256::digest(bytes),
1153 };
1154 crate::hex_lower(&digest)[..16].to_string()
1155}
1156
1157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1162pub struct ObservedArtifactHash {
1163 pub entity: String,
1165 pub artifact: String,
1167 pub hash: String,
1169}
1170
1171pub const INVALID_OBSERVATION_CODE: &str = "INVALID_OBSERVATION";
1177
1178#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1185pub struct SuppliedObservationInput {
1186 #[serde(default)]
1189 pub artifact: Option<String>,
1190 #[serde(default)]
1193 pub hash: Option<String>,
1194 #[serde(default)]
1198 pub content: Option<String>,
1199 #[serde(default)]
1201 pub absent: Option<bool>,
1202 #[serde(default)]
1205 pub observed_at: Option<String>,
1206}
1207
1208#[derive(Debug, Clone, PartialEq, Eq)]
1210pub enum SuppliedOutcome {
1211 Present { hash: String },
1213 Absent,
1215}
1216
1217#[derive(Debug, Clone, PartialEq, Eq)]
1219pub struct SuppliedObservation {
1220 pub artifact: String,
1221 pub at: String,
1223 pub outcome: SuppliedOutcome,
1224}
1225
1226#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1228pub enum ObservationValidationError {
1229 #[error("observation row {row}: `artifact` is required and must be non-empty")]
1230 MissingArtifact { row: usize },
1231 #[error(
1232 "observation row {row} (`{artifact}`): give exactly one of `hash`, `content`, or \
1233 `absent: true`"
1234 )]
1235 OutcomeAmbiguous { row: usize, artifact: String },
1236 #[error(
1237 "observation row {row} (`{artifact}`): `observed_at` '{got}' is not an ISO-8601 \
1238 timestamp (`YYYY-MM-DDTHH:MM:SSZ`) or date (`YYYY-MM-DD`)"
1239 )]
1240 BadTimestamp {
1241 row: usize,
1242 artifact: String,
1243 got: String,
1244 },
1245 #[error("observation rows name `{artifact}` more than once (rows {first} and {second})")]
1246 DuplicateArtifact {
1247 artifact: String,
1248 first: usize,
1249 second: usize,
1250 },
1251}
1252
1253impl ObservationValidationError {
1254 pub fn code(&self) -> &'static str {
1256 INVALID_OBSERVATION_CODE
1257 }
1258
1259 pub fn detail(&self) -> BTreeMap<String, serde_json::Value> {
1261 let mut d = BTreeMap::new();
1262 match self {
1263 ObservationValidationError::MissingArtifact { row } => {
1264 d.insert("row".into(), serde_json::json!(row));
1265 d.insert("field".into(), "artifact".into());
1266 }
1267 ObservationValidationError::OutcomeAmbiguous { row, artifact } => {
1268 d.insert("row".into(), serde_json::json!(row));
1269 d.insert("artifact".into(), serde_json::json!(artifact));
1270 d.insert(
1271 "expected".into(),
1272 serde_json::json!("exactly one of `hash`, `content`, `absent: true`"),
1273 );
1274 }
1275 ObservationValidationError::BadTimestamp { row, artifact, got } => {
1276 d.insert("row".into(), serde_json::json!(row));
1277 d.insert("artifact".into(), serde_json::json!(artifact));
1278 d.insert("field".into(), "observed_at".into());
1279 d.insert("got".into(), serde_json::json!(got));
1280 }
1281 ObservationValidationError::DuplicateArtifact {
1282 artifact,
1283 first,
1284 second,
1285 } => {
1286 d.insert("artifact".into(), serde_json::json!(artifact));
1287 d.insert("rows".into(), serde_json::json!([first, second]));
1288 }
1289 }
1290 d
1291 }
1292}
1293
1294fn timestamp_is_wellformed(ts: &str) -> bool {
1297 let b = ts.as_bytes();
1298 let date_ok = b.len() >= 10
1299 && b[..10].iter().enumerate().all(|(i, c)| {
1300 if i == 4 || i == 7 {
1301 *c == b'-'
1302 } else {
1303 c.is_ascii_digit()
1304 }
1305 });
1306 if !date_ok {
1307 return false;
1308 }
1309 if b.len() == 10 {
1310 return true;
1311 }
1312 b.len() == 20
1313 && b[10] == b'T'
1314 && b[19] == b'Z'
1315 && b[11..19].iter().enumerate().all(|(i, c)| {
1316 if i == 2 || i == 5 {
1317 *c == b':'
1318 } else {
1319 c.is_ascii_digit()
1320 }
1321 })
1322}
1323
1324pub fn validate_supplied_observations(
1329 rows: &[SuppliedObservationInput],
1330 now: &str,
1331) -> Result<BTreeMap<String, SuppliedObservation>, ObservationValidationError> {
1332 let mut out: BTreeMap<String, SuppliedObservation> = BTreeMap::new();
1333 let mut first_row: BTreeMap<String, usize> = BTreeMap::new();
1334 for (i, row) in rows.iter().enumerate() {
1335 let n = i + 1;
1336 let artifact = row
1337 .artifact
1338 .as_deref()
1339 .map(str::trim)
1340 .filter(|s| !s.is_empty())
1341 .ok_or(ObservationValidationError::MissingArtifact { row: n })?
1342 .to_string();
1343 let absent = row.absent.unwrap_or(false);
1344 let given = usize::from(row.hash.is_some())
1345 + usize::from(row.content.is_some())
1346 + usize::from(absent);
1347 if given != 1 {
1348 return Err(ObservationValidationError::OutcomeAmbiguous { row: n, artifact });
1349 }
1350 let at = match row.observed_at.as_deref().map(str::trim) {
1351 None | Some("") => now.to_string(),
1352 Some(ts) if timestamp_is_wellformed(ts) => ts.to_string(),
1353 Some(ts) => {
1354 return Err(ObservationValidationError::BadTimestamp {
1355 row: n,
1356 artifact,
1357 got: ts.to_string(),
1358 });
1359 }
1360 };
1361 if let Some(first) = first_row.get(&artifact) {
1362 return Err(ObservationValidationError::DuplicateArtifact {
1363 artifact,
1364 first: *first,
1365 second: n,
1366 });
1367 }
1368 let outcome = if absent {
1369 SuppliedOutcome::Absent
1370 } else if let Some(hash) = &row.hash {
1371 SuppliedOutcome::Present {
1372 hash: hash.trim().to_string(),
1373 }
1374 } else {
1375 SuppliedOutcome::Present {
1376 hash: prepared_content_hash(row.content.as_deref().unwrap_or_default().as_bytes()),
1377 }
1378 };
1379 first_row.insert(artifact.clone(), n);
1380 out.insert(
1381 artifact.clone(),
1382 SuppliedObservation {
1383 artifact,
1384 at,
1385 outcome,
1386 },
1387 );
1388 }
1389 Ok(out)
1390}
1391
1392pub fn iso_days_since_epoch(ts: &str) -> Option<i64> {
1396 if !timestamp_is_wellformed(ts) {
1397 return None;
1398 }
1399 let y: i64 = ts[..4].parse().ok()?;
1400 let m: u32 = ts[5..7].parse().ok()?;
1401 let d: u32 = ts[8..10].parse().ok()?;
1402 if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
1403 return None;
1404 }
1405 let y = if m <= 2 { y - 1 } else { y };
1406 let era = if y >= 0 { y } else { y - 399 } / 400;
1407 let yoe = y - era * 400;
1408 let mp = ((m + 9) % 12) as i64;
1409 let doy = (153 * mp + 2) / 5 + d as i64 - 1;
1410 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1411 Some(era * 146097 + doe - 719468)
1412}
1413
1414pub fn days_between(observed_at: &str, now: &str) -> Option<u64> {
1417 let a = iso_days_since_epoch(observed_at)?;
1418 let b = iso_days_since_epoch(now)?;
1419 Some((b - a).max(0) as u64)
1420}
1421
1422#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1428#[serde(rename_all = "lowercase")]
1429pub enum AnchorState {
1430 Resolves,
1433 Drifted,
1436 Recheck,
1440 Orphaned,
1443}
1444
1445impl AnchorState {
1446 pub fn as_wire(&self) -> &'static str {
1448 match self {
1449 AnchorState::Resolves => "resolves",
1450 AnchorState::Drifted => "drifted",
1451 AnchorState::Recheck => "recheck",
1452 AnchorState::Orphaned => "orphaned",
1453 }
1454 }
1455}
1456
1457#[derive(Debug, Clone, PartialEq, Eq)]
1459pub enum ArtifactObservation {
1460 Absent,
1462 Present { current_hash: Option<String> },
1466}
1467
1468pub fn resolve_anchor(anchor: &Anchor, observation: &ArtifactObservation) -> AnchorState {
1480 let current_hash = match observation {
1481 ArtifactObservation::Absent => return AnchorState::Orphaned,
1482 ArtifactObservation::Present { current_hash } => current_hash,
1483 };
1484 if !anchor.class.is_hash_bearing() {
1485 return AnchorState::Resolves;
1486 }
1487 match (&anchor.hash, current_hash) {
1488 (Some(recorded), Some(current)) if recorded == current => AnchorState::Resolves,
1489 (Some(_), Some(_)) => match anchor.hash_stability {
1490 AnchorHashStability::Stable => AnchorState::Drifted,
1491 AnchorHashStability::Unstable => AnchorState::Recheck,
1492 },
1493 _ => AnchorState::Recheck,
1495 }
1496}
1497
1498#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1504pub struct EntityAnchorComposition {
1505 pub by_class: BTreeMap<String, usize>,
1507 pub by_grain: BTreeMap<String, usize>,
1509 pub derived_inputs: Vec<Vec<String>>,
1512 pub tree_grain_artifacts: Vec<String>,
1517}
1518
1519pub fn compose_entity_anchors(anchors: &[Anchor]) -> EntityAnchorComposition {
1522 let mut comp = EntityAnchorComposition::default();
1523 for a in anchors {
1524 *comp
1525 .by_class
1526 .entry(a.class.as_wire().to_string())
1527 .or_insert(0) += 1;
1528 *comp
1529 .by_grain
1530 .entry(a.grain.as_wire().to_string())
1531 .or_insert(0) += 1;
1532 if a.class == AnchorProvenanceClass::Derived {
1533 comp.derived_inputs.push(a.derived_from.clone());
1534 }
1535 if a.grain == AnchorGrain::Tree {
1536 comp.tree_grain_artifacts.push(a.artifact.clone());
1537 }
1538 }
1539 comp
1540}
1541
1542#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1554pub struct AnchorSidecar {
1555 pub version: u32,
1557 #[serde(default)]
1560 pub entities: BTreeMap<String, Vec<Anchor>>,
1561}
1562
1563impl Default for AnchorSidecar {
1564 fn default() -> Self {
1565 Self {
1566 version: ANCHOR_SIDECAR_VERSION,
1567 entities: BTreeMap::new(),
1568 }
1569 }
1570}
1571
1572impl AnchorSidecar {
1573 pub fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
1576 if bytes.iter().all(u8::is_ascii_whitespace) {
1577 return Ok(Self::default());
1578 }
1579 let sidecar: Self = serde_json::from_slice(bytes)?;
1580 if !ANCHOR_SIDECAR_VERSIONS_READ.contains(&sidecar.version) {
1589 return Err(serde::de::Error::custom(format!(
1590 "unsupported anchors sidecar version {} (this engine reads versions {}) — \
1591 the file was written by a different engine; upgrade, or remove the sidecar \
1592 to re-record anchors",
1593 sidecar.version,
1594 ANCHOR_SIDECAR_VERSIONS_READ
1595 .iter()
1596 .map(u32::to_string)
1597 .collect::<Vec<_>>()
1598 .join(", ")
1599 )));
1600 }
1601 let mut sidecar = sidecar;
1605 sidecar.version = ANCHOR_SIDECAR_VERSION;
1606 Ok(sidecar)
1607 }
1608
1609 pub fn to_bytes(&self) -> Vec<u8> {
1612 let mut s = serde_json::to_string_pretty(self).expect("anchor sidecar serialises");
1613 s.push('\n');
1614 s.into_bytes()
1615 }
1616
1617 pub fn get(&self, entity_id: &str) -> &[Anchor] {
1619 self.entities
1620 .get(entity_id)
1621 .map(Vec::as_slice)
1622 .unwrap_or(&[])
1623 }
1624
1625 pub fn set(&mut self, entity_id: &str, anchors: Vec<Anchor>) {
1628 if anchors.is_empty() {
1629 self.entities.remove(entity_id);
1630 } else {
1631 self.entities.insert(entity_id.to_string(), anchors);
1632 }
1633 }
1634
1635 pub fn merge(
1647 &mut self,
1648 entity_id: &str,
1649 unsets: &[AnchorUnset],
1650 incoming: Vec<Anchor>,
1651 rebaseline: bool,
1652 ) -> bool {
1653 let mut row = self.entities.remove(entity_id).unwrap_or_default();
1654 let before = row.len();
1655 row.retain(|a| !unsets.iter().any(|u| u.matches(a)));
1656 let mut changed = row.len() != before;
1657 for anchor in incoming {
1658 match row.iter_mut().find(|e| {
1659 e.artifact == anchor.artifact && e.grain == anchor.grain && e.class == anchor.class
1660 }) {
1661 Some(existing) => {
1662 if !rebaseline && existing.same_as_supplied(&anchor) {
1682 continue;
1683 }
1684 *existing = anchor;
1685 changed = true;
1686 }
1687 None => {
1688 row.push(anchor);
1689 changed = true;
1690 }
1691 }
1692 }
1693 if !row.is_empty() {
1694 self.entities.insert(entity_id.to_string(), row);
1695 }
1696 changed
1697 }
1698
1699 pub fn redact_artifact_references(&mut self) {
1707 for anchors in self.entities.values_mut() {
1708 for anchor in anchors {
1709 anchor.artifact = REDACTED_ARTIFACT_SENTINEL.to_string();
1710 for input in &mut anchor.derived_from {
1711 *input = REDACTED_ARTIFACT_SENTINEL.to_string();
1712 }
1713 }
1714 }
1715 }
1716
1717 pub fn validate_artifact_references(&self) -> Result<(), String> {
1723 for (entity_id, anchors) in &self.entities {
1724 for anchor in anchors {
1725 if anchor.artifact.trim().is_empty() {
1726 return Err(format!(
1727 "entity `{entity_id}` carries an anchor with an empty artifact \
1728 reference"
1729 ));
1730 }
1731 if anchor.derived_from.iter().any(|d| d.trim().is_empty()) {
1732 return Err(format!(
1733 "entity `{entity_id}` carries an anchor with an empty \
1734 `derived_from` entry"
1735 ));
1736 }
1737 }
1738 }
1739 Ok(())
1740 }
1741
1742 pub fn remove(&mut self, entity_id: &str) {
1744 self.entities.remove(entity_id);
1745 }
1746
1747 pub fn rename(&mut self, from: &str, to: &str) {
1752 if let Some(anchors) = self.entities.remove(from) {
1753 self.entities.insert(to.to_string(), anchors);
1754 }
1755 }
1756
1757 pub fn is_empty(&self) -> bool {
1759 self.entities.is_empty()
1760 }
1761}
1762
1763#[cfg(test)]
1764mod tests {
1765 use super::*;
1766
1767 #[test]
1772 fn redaction_blanks_references_and_keeps_trust_metadata() {
1773 let mut sidecar = AnchorSidecar::default();
1774 sidecar.set(
1775 "m--alpha",
1776 vec![
1777 Anchor {
1778 artifact: "src/lib.rs".into(),
1779 grain: AnchorGrain::File,
1780 class: AnchorProvenanceClass::Anchored,
1781 at_version: Some(AnchorVersion::Commit("abc123".into())),
1782 hash: Some("h1".into()),
1783 hash_stability: AnchorHashStability::Stable,
1784 derived_from: vec![],
1785 binding: Some("bhash".into()),
1786 source: Some("source-tree".into()),
1787 span_unvalidated: false,
1788 hash_source: None,
1789 last_observed: None,
1790 },
1791 Anchor {
1792 artifact: "docs/summary.md".into(),
1793 grain: AnchorGrain::File,
1794 class: AnchorProvenanceClass::Derived,
1795 at_version: None,
1796 hash: Some("h2".into()),
1797 hash_stability: AnchorHashStability::Unstable,
1798 derived_from: vec!["notes/a.md".into(), "notes/b.md".into()],
1799 binding: None,
1800 source: None,
1801 span_unvalidated: false,
1802 hash_source: None,
1803 last_observed: None,
1804 },
1805 ],
1806 );
1807
1808 sidecar.redact_artifact_references();
1809
1810 let anchors = sidecar.get("m--alpha");
1811 assert_eq!(anchors.len(), 2, "no anchor entry is dropped");
1812 for a in anchors {
1813 assert_eq!(a.artifact, REDACTED_ARTIFACT_SENTINEL);
1814 for d in &a.derived_from {
1815 assert_eq!(d, REDACTED_ARTIFACT_SENTINEL);
1816 }
1817 }
1818 assert_eq!(
1819 anchors[0].at_version,
1820 Some(AnchorVersion::Commit("abc123".into()))
1821 );
1822 assert_eq!(anchors[0].hash.as_deref(), Some("h1"));
1823 assert_eq!(anchors[0].binding.as_deref(), Some("bhash"));
1824 assert_eq!(anchors[0].source.as_deref(), Some("source-tree"));
1825 assert_eq!(anchors[1].class, AnchorProvenanceClass::Derived);
1826 assert_eq!(anchors[1].derived_from.len(), 2, "derivation arity kept");
1827 sidecar.validate_artifact_references().unwrap();
1830 }
1831
1832 #[test]
1836 fn empty_artifact_references_are_refused() {
1837 let mut sidecar = AnchorSidecar::default();
1838 sidecar.set(
1839 "m--alpha",
1840 vec![Anchor {
1841 artifact: "".into(),
1842 grain: AnchorGrain::File,
1843 class: AnchorProvenanceClass::Anchored,
1844 at_version: None,
1845 hash: None,
1846 hash_stability: AnchorHashStability::Stable,
1847 derived_from: vec![],
1848 binding: None,
1849 source: None,
1850 span_unvalidated: false,
1851 hash_source: None,
1852 last_observed: None,
1853 }],
1854 );
1855 assert!(sidecar.validate_artifact_references().is_err());
1856
1857 let mut sidecar = AnchorSidecar::default();
1858 sidecar.set(
1859 "m--beta",
1860 vec![Anchor {
1861 artifact: "docs/x.md".into(),
1862 grain: AnchorGrain::File,
1863 class: AnchorProvenanceClass::Derived,
1864 at_version: None,
1865 hash: None,
1866 hash_stability: AnchorHashStability::Stable,
1867 derived_from: vec![" ".into()],
1868 binding: None,
1869 source: None,
1870 span_unvalidated: false,
1871 hash_source: None,
1872 last_observed: None,
1873 }],
1874 );
1875 assert!(sidecar.validate_artifact_references().is_err());
1876 }
1877
1878 #[test]
1881 fn class_wire_strings_are_stable() {
1882 assert_eq!(AnchorProvenanceClass::Anchored.as_wire(), "anchored");
1883 assert_eq!(AnchorProvenanceClass::Derived.as_wire(), "derived");
1884 assert_eq!(AnchorProvenanceClass::Authored.as_wire(), "authored");
1885 assert_eq!(AnchorProvenanceClass::InformedBy.as_wire(), "informed-by");
1886 for w in AnchorProvenanceClass::WIRE_VALUES {
1887 assert_eq!(AnchorProvenanceClass::from_wire(w).unwrap().as_wire(), *w);
1888 }
1889 assert!(AnchorProvenanceClass::from_wire("bogus").is_none());
1890 }
1891
1892 #[test]
1893 fn grain_wire_strings_are_stable() {
1894 for w in AnchorGrain::WIRE_VALUES {
1895 assert_eq!(AnchorGrain::from_wire(w).unwrap().as_wire(), *w);
1896 }
1897 assert_eq!(
1898 AnchorGrain::WIRE_VALUES,
1899 &["span", "file", "tree", "url", "entity"]
1900 );
1901 assert!(AnchorGrain::from_wire("chunk").is_none());
1902 }
1903
1904 #[test]
1905 fn stability_and_state_wire_strings_are_stable() {
1906 assert_eq!(AnchorHashStability::Stable.as_wire(), "stable");
1907 assert_eq!(AnchorHashStability::Unstable.as_wire(), "unstable");
1908 assert_eq!(AnchorState::Resolves.as_wire(), "resolves");
1909 assert_eq!(AnchorState::Drifted.as_wire(), "drifted");
1910 assert_eq!(AnchorState::Recheck.as_wire(), "recheck");
1911 assert_eq!(AnchorState::Orphaned.as_wire(), "orphaned");
1912 }
1913
1914 #[test]
1915 fn only_anchored_and_derived_are_hash_bearing() {
1916 assert!(AnchorProvenanceClass::Anchored.is_hash_bearing());
1917 assert!(AnchorProvenanceClass::Derived.is_hash_bearing());
1918 assert!(!AnchorProvenanceClass::Authored.is_hash_bearing());
1919 assert!(!AnchorProvenanceClass::InformedBy.is_hash_bearing());
1920 }
1921
1922 #[test]
1925 fn grain_namespace_support_matches_capability_matrix() {
1926 for g in [AnchorGrain::Span, AnchorGrain::File, AnchorGrain::Tree] {
1928 assert!(g.supported_by_namespace("path"));
1929 assert!(g.supported_by_namespace("path+commit"));
1930 assert!(!g.supported_by_namespace("url"));
1931 assert!(!g.supported_by_namespace("entity"));
1932 }
1933 assert!(AnchorGrain::Url.supported_by_namespace("url"));
1934 assert!(AnchorGrain::Url.supported_by_namespace("path"));
1936 assert!(AnchorGrain::Url.supported_by_namespace("entity"));
1937 assert!(AnchorGrain::Entity.supported_by_namespace("entity"));
1938 assert!(!AnchorGrain::Entity.supported_by_namespace("path"));
1939 }
1940
1941 fn valid_input() -> AnchorInput {
1944 AnchorInput {
1945 artifact: Some("src/lib.rs".into()),
1946 grain: Some("file".into()),
1947 class: Some("anchored".into()),
1948 hash_stability: Some("stable".into()),
1949 hash: Some("abc123".into()),
1950 ..Default::default()
1951 }
1952 }
1953
1954 fn span_input(artifact: &str) -> AnchorInput {
1955 AnchorInput {
1956 artifact: Some(artifact.into()),
1957 grain: Some("span".into()),
1958 class: Some("anchored".into()),
1959 ..Default::default()
1960 }
1961 }
1962
1963 #[test]
1967 fn a_span_locator_that_addresses_nothing_is_refused() {
1968 for artifact in [
1969 "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", ] {
1977 let err = span_input(artifact)
1978 .validate(Some(("codebase", "path")))
1979 .expect_err(artifact);
1980 assert!(
1981 matches!(err, AnchorValidationError::SpanLocatorUnusable { .. }),
1982 "{artifact} refused as {err:?}"
1983 );
1984 assert_eq!(err.code(), INVALID_ANCHOR_CODE);
1985 assert!(err.detail().contains_key("expected"), "carries the repair");
1986 }
1987 }
1988
1989 #[test]
1994 fn a_usable_span_locator_still_writes() {
1995 for artifact in [
1996 "src/lib.rs",
1997 "src/lib.rs#L1",
1998 "src/lib.rs#L4-L7",
1999 "logs/ops.md#2026-08-25T00:00:00",
2000 ] {
2001 span_input(artifact)
2002 .validate(Some(("codebase", "path")))
2003 .unwrap_or_else(|e| panic!("{artifact} refused: {e}"));
2004 }
2005 }
2006
2007 #[test]
2011 fn a_span_beyond_supplied_content_is_refused() {
2012 let mut i = span_input("src/lib.rs#L2-L9");
2013 i.content = Some(
2014 "one
2015two
2016three
2017"
2018 .into(),
2019 );
2020 let err = i.validate(Some(("codebase", "path"))).unwrap_err();
2021 match err {
2022 AnchorValidationError::SpanOutsideContent { lines, .. } => assert_eq!(lines, 3),
2023 other => panic!("wrong refusal: {other:?}"),
2024 }
2025
2026 let mut ok = span_input("src/lib.rs#L2-L3");
2027 ok.content = Some(
2028 "one
2029two
2030three
2031"
2032 .into(),
2033 );
2034 let a = ok.validate(Some(("codebase", "path"))).unwrap();
2035 assert!(
2036 !a.span_unvalidated,
2037 "a span checked against content is not unvalidated"
2038 );
2039 }
2040
2041 #[test]
2046 fn an_uncheckable_span_is_accepted_and_recorded_as_unchecked() {
2047 let a = span_input("src/lib.rs#L4-L7")
2048 .validate(Some(("codebase", "path")))
2049 .unwrap();
2050 assert!(a.span_unvalidated);
2051
2052 let whole_file = span_input("src/lib.rs")
2053 .validate(Some(("codebase", "path")))
2054 .unwrap();
2055 assert!(
2056 !whole_file.span_unvalidated,
2057 "no locator addresses the whole artifact, which the existence gate checks"
2058 );
2059
2060 let file_grain = valid_input().validate(Some(("codebase", "path"))).unwrap();
2061 assert!(!file_grain.span_unvalidated, "never set off the span grain");
2062 }
2063
2064 #[test]
2067 fn an_authored_hash_records_that_the_author_pinned_it() {
2068 let a = valid_input().validate(Some(("codebase", "path"))).unwrap();
2069 assert_eq!(a.hash_source, Some(AnchorHashSource::Author));
2070
2071 let mut hashless = valid_input();
2072 hashless.hash = None;
2073 let b = hashless.validate(Some(("codebase", "path"))).unwrap();
2074 assert_eq!(b.hash_source, None, "no baseline, no origin to record");
2075 }
2076
2077 #[test]
2081 fn a_re_pin_keeps_the_baseline_it_did_not_mention() {
2082 let mut sc = AnchorSidecar::default();
2083 let mut pinned = file_anchor("src/a.rs", "h-original");
2084 pinned.hash_source = Some(AnchorHashSource::Author);
2085 sc.set("m--e", vec![pinned]);
2086
2087 let mut repin = file_anchor("src/a.rs", "");
2088 repin.hash = None;
2089 repin.hash_source = None;
2090 sc.merge("m--e", &[], vec![repin], false);
2091 let row = &sc.entities["m--e"][0];
2092 assert_eq!(
2093 row.hash.as_deref(),
2094 Some("h-original"),
2095 "the baseline the caller did not mention survives"
2096 );
2097 assert_eq!(row.hash_source, Some(AnchorHashSource::Author));
2098
2099 sc.merge("m--e", &[], vec![file_anchor("src/a.rs", "h-new")], false);
2100 assert_eq!(
2101 sc.entities["m--e"][0].hash.as_deref(),
2102 Some("h-new"),
2103 "a supplied hash still replaces"
2104 );
2105
2106 let unset = AnchorUnset {
2109 artifact: "src/a.rs".into(),
2110 grain: None,
2111 class: None,
2112 };
2113 let mut fresh = file_anchor("src/a.rs", "");
2114 fresh.hash = None;
2115 fresh.hash_source = None;
2116 sc.merge("m--e", &[unset], vec![fresh], false);
2117 assert_eq!(
2118 sc.entities["m--e"][0].hash, None,
2119 "unset-then-write is how a caller clears a baseline"
2120 );
2121 }
2122
2123 #[test]
2124 fn validate_accepts_a_well_formed_anchor() {
2125 let a = valid_input().validate(Some(("codebase", "path"))).unwrap();
2126 assert_eq!(a.artifact, "src/lib.rs");
2127 assert_eq!(a.grain, AnchorGrain::File);
2128 assert_eq!(a.class, AnchorProvenanceClass::Anchored);
2129 assert_eq!(a.hash.as_deref(), Some("abc123"));
2130 assert_eq!(a.hash_stability, AnchorHashStability::Stable);
2131 }
2132
2133 #[test]
2136 fn validate_defaults_hash_stability_to_stable() {
2137 for grain in ["span", "file", "tree"] {
2138 let mut i = valid_input();
2139 i.grain = Some(grain.into());
2140 i.hash_stability = None;
2141 let a = i.validate(None).unwrap();
2142 assert_eq!(a.hash_stability, AnchorHashStability::Stable, "{grain}");
2143 }
2144 let mut e = valid_input();
2145 e.grain = Some("entity".into());
2146 e.artifact = Some("m--e".into());
2147 e.hash_stability = None;
2148 assert_eq!(
2149 e.validate(None).unwrap().hash_stability,
2150 AnchorHashStability::Stable
2151 );
2152 }
2153
2154 #[test]
2158 fn validate_defaults_url_grain_to_unstable_unless_declared() {
2159 let mut i = valid_input();
2160 i.grain = Some("url".into());
2161 i.artifact = Some("https://example.invalid/doc".into());
2162 i.hash_stability = None;
2163 assert_eq!(
2164 i.validate(None).unwrap().hash_stability,
2165 AnchorHashStability::Unstable
2166 );
2167 i.hash_stability = Some("stable".into());
2168 assert_eq!(
2169 i.validate(None).unwrap().hash_stability,
2170 AnchorHashStability::Stable
2171 );
2172 }
2173
2174 #[test]
2181 fn content_yields_the_prepared_hash_through_the_registry() {
2182 let mut u = valid_input();
2183 u.grain = Some("url".into());
2184 u.artifact = Some("https://example.invalid/doc".into());
2185 u.hash = None;
2186 u.hash_stability = None;
2187 u.content = Some("<p>hello</p>\r\n".into());
2188 let a = u.validate(None).unwrap();
2189 assert_eq!(
2190 a.hash.as_deref(),
2191 Some(crate::preparation::url_prepared_hash(b"<p>hello</p>\n").as_str())
2192 );
2193 assert_eq!(a.hash_stability, AnchorHashStability::Unstable);
2194
2195 let mut f = valid_input();
2196 f.hash = None;
2197 f.content = Some("fn a() {}\n".into());
2198 assert_eq!(
2199 f.validate(None).unwrap().hash.as_deref(),
2200 Some(prepared_content_hash(b"fn a() {}").as_str())
2201 );
2202
2203 let mut both = valid_input();
2204 both.content = Some("x".into());
2205 assert_eq!(
2206 both.validate(None).unwrap_err(),
2207 AnchorValidationError::ContentAndHash
2208 );
2209
2210 let mut ent = valid_input();
2211 ent.grain = Some("entity".into());
2212 ent.artifact = Some("m--e".into());
2213 ent.hash = None;
2214 ent.content = Some("x".into());
2215 let err = ent.validate(None).unwrap_err();
2216 assert_eq!(
2217 err,
2218 AnchorValidationError::ContentNotAcceptedForGrain { grain: "entity" }
2219 );
2220 assert_eq!(err.detail()["field"], "content");
2221
2222 let mut tree = valid_input();
2223 tree.grain = Some("tree".into());
2224 tree.hash = None;
2225 tree.content = Some("x".into());
2226 assert!(matches!(
2227 tree.validate(None).unwrap_err(),
2228 AnchorValidationError::ContentNotAcceptedForGrain { grain: "tree" }
2229 ));
2230
2231 let mut informed = valid_input();
2232 informed.class = Some("informed-by".into());
2233 informed.hash = None;
2234 informed.content = Some("x".into());
2235 assert!(matches!(
2236 informed.validate(None).unwrap_err(),
2237 AnchorValidationError::HashOnNonHashClass { .. }
2238 ));
2239 }
2240
2241 #[test]
2242 fn validate_refuses_unknown_class() {
2243 let mut i = valid_input();
2244 i.class = Some("guessed".into());
2245 let err = i.validate(None).unwrap_err();
2246 assert_eq!(err.code(), INVALID_ANCHOR_CODE);
2247 assert!(matches!(err, AnchorValidationError::UnknownClass { .. }));
2248 assert_eq!(err.detail()["field"], serde_json::json!("class"));
2249 }
2250
2251 #[test]
2252 fn validate_refuses_unknown_grain() {
2253 let mut i = valid_input();
2254 i.grain = Some("paragraph".into());
2255 let err = i.validate(None).unwrap_err();
2256 assert!(matches!(err, AnchorValidationError::UnknownGrain { .. }));
2257 }
2258
2259 #[test]
2260 fn validate_refuses_missing_artifact() {
2261 let mut i = valid_input();
2262 i.artifact = Some(" ".into());
2263 let err = i.validate(None).unwrap_err();
2264 assert!(matches!(err, AnchorValidationError::MissingArtifact));
2265 i.artifact = None;
2266 assert!(matches!(
2267 valid_input_with_artifact(None).validate(None).unwrap_err(),
2268 AnchorValidationError::MissingArtifact
2269 ));
2270 let _ = i;
2271 }
2272
2273 fn valid_input_with_artifact(a: Option<String>) -> AnchorInput {
2274 AnchorInput {
2275 artifact: a,
2276 ..valid_input()
2277 }
2278 }
2279
2280 #[test]
2281 fn validate_refuses_hash_on_non_hash_class() {
2282 let mut i = valid_input();
2283 i.class = Some("authored".into());
2284 let err = i.validate(None).unwrap_err();
2286 assert!(matches!(
2287 err,
2288 AnchorValidationError::HashOnNonHashClass { class: "authored" }
2289 ));
2290 }
2291
2292 #[test]
2293 fn validate_accepts_non_hash_class_without_hash() {
2294 let mut i = valid_input();
2295 i.class = Some("informed-by".into());
2296 i.hash = None;
2297 let a = i.validate(None).unwrap();
2298 assert_eq!(a.class, AnchorProvenanceClass::InformedBy);
2299 assert!(a.hash.is_none());
2300 }
2301
2302 #[test]
2303 fn validate_refuses_grain_unsupported_by_medium_namespace() {
2304 let mut i = valid_input();
2306 i.grain = Some("span".into());
2307 i.class = Some("authored".into());
2308 i.hash = None;
2309 let err = i.validate(Some(("web", "url"))).unwrap_err();
2310 match err {
2311 AnchorValidationError::GrainNamespaceUnsupported {
2312 grain,
2313 anchor_namespace,
2314 ..
2315 } => {
2316 assert_eq!(grain, "span");
2317 assert_eq!(anchor_namespace, "url");
2318 }
2319 other => panic!("expected GrainNamespaceUnsupported, got {other:?}"),
2320 }
2321 }
2322
2323 #[test]
2324 fn validate_skips_namespace_check_without_medium_context() {
2325 let mut i = valid_input();
2327 i.grain = Some("span".into());
2328 assert!(i.validate(None).is_ok());
2329 }
2330
2331 #[test]
2337 fn prepared_hash_is_stable_across_byte_noise() {
2338 let base = prepared_content_hash(b"fn a() {}\nfn b() {}\n");
2339 assert_eq!(prepared_content_hash(b"fn a() {}\r\nfn b() {}\r\n"), base);
2341 assert_eq!(prepared_content_hash(b"fn a() {}\rfn b() {}\r"), base);
2342 assert_eq!(prepared_content_hash(b"fn a() {}\nfn b() {}"), base);
2344 assert_eq!(prepared_content_hash(b"fn a() {}\nfn b() {}\n\n\n"), base);
2345 assert_eq!(
2347 prepared_content_hash("\u{feff}fn a() {}\nfn b() {}\n".as_bytes()),
2348 base
2349 );
2350 assert_ne!(prepared_content_hash(b"fn a() {}\nfn c() {}\n"), base);
2352 assert_eq!(base.len(), 16);
2354 assert!(
2355 base.chars()
2356 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
2357 );
2358 }
2359
2360 #[test]
2363 fn prepared_hash_preserves_interior_whitespace() {
2364 assert_ne!(
2365 prepared_content_hash(b"line one \nline two\n"),
2366 prepared_content_hash(b"line one\nline two\n")
2367 );
2368 }
2369
2370 #[test]
2373 fn prepared_hash_hashes_binary_bytes_raw() {
2374 let bin_a = [0xff_u8, 0xfe, 0x00, 0x0d, 0x0a];
2375 let bin_b = [0xff_u8, 0xfe, 0x00, 0x0a];
2376 assert_ne!(prepared_content_hash(&bin_a), prepared_content_hash(&bin_b));
2377 assert_eq!(prepared_content_hash(&bin_a), prepared_content_hash(&bin_a));
2379 }
2380
2381 fn anchor(
2384 class: AnchorProvenanceClass,
2385 hash: Option<&str>,
2386 stab: AnchorHashStability,
2387 ) -> Anchor {
2388 Anchor {
2389 artifact: "src/lib.rs".into(),
2390 grain: AnchorGrain::File,
2391 class,
2392 at_version: None,
2393 hash: hash.map(str::to_string),
2394 hash_stability: stab,
2395 derived_from: Vec::new(),
2396 binding: None,
2397 source: None,
2398 span_unvalidated: false,
2399 hash_source: None,
2400 last_observed: None,
2401 }
2402 }
2403
2404 #[test]
2405 fn resolves_when_hash_matches() {
2406 let a = anchor(
2407 AnchorProvenanceClass::Anchored,
2408 Some("h1"),
2409 AnchorHashStability::Stable,
2410 );
2411 let obs = ArtifactObservation::Present {
2412 current_hash: Some("h1".into()),
2413 };
2414 assert_eq!(resolve_anchor(&a, &obs), AnchorState::Resolves);
2415 }
2416
2417 #[test]
2418 fn stable_hash_break_drifts_unstable_rechecks() {
2419 let stable = anchor(
2420 AnchorProvenanceClass::Anchored,
2421 Some("h1"),
2422 AnchorHashStability::Stable,
2423 );
2424 let unstable = anchor(
2425 AnchorProvenanceClass::Anchored,
2426 Some("h1"),
2427 AnchorHashStability::Unstable,
2428 );
2429 let obs = ArtifactObservation::Present {
2430 current_hash: Some("h2".into()),
2431 };
2432 assert_eq!(resolve_anchor(&stable, &obs), AnchorState::Drifted);
2433 assert_eq!(resolve_anchor(&unstable, &obs), AnchorState::Recheck);
2434 }
2435
2436 #[test]
2437 fn absent_artifact_is_orphaned() {
2438 let a = anchor(
2439 AnchorProvenanceClass::Anchored,
2440 Some("h1"),
2441 AnchorHashStability::Stable,
2442 );
2443 assert_eq!(
2444 resolve_anchor(&a, &ArtifactObservation::Absent),
2445 AnchorState::Orphaned
2446 );
2447 }
2448
2449 #[test]
2450 fn non_hash_classes_never_drift() {
2451 for class in [
2452 AnchorProvenanceClass::Authored,
2453 AnchorProvenanceClass::InformedBy,
2454 ] {
2455 let a = anchor(class, None, AnchorHashStability::Stable);
2456 let obs = ArtifactObservation::Present {
2459 current_hash: Some("whatever".into()),
2460 };
2461 assert_eq!(resolve_anchor(&a, &obs), AnchorState::Resolves);
2462 assert_eq!(
2464 resolve_anchor(&a, &ArtifactObservation::Absent),
2465 AnchorState::Orphaned
2466 );
2467 }
2468 }
2469
2470 #[test]
2471 fn unavailable_hash_rechecks_not_drifts() {
2472 let a = anchor(
2473 AnchorProvenanceClass::Anchored,
2474 Some("h1"),
2475 AnchorHashStability::Stable,
2476 );
2477 let obs = ArtifactObservation::Present { current_hash: None };
2478 assert_eq!(resolve_anchor(&a, &obs), AnchorState::Recheck);
2479 }
2480
2481 #[test]
2484 fn composition_counts_classes_grains_and_tree_fanout() {
2485 let anchors = vec![
2486 Anchor {
2487 artifact: "a.rs".into(),
2488 grain: AnchorGrain::File,
2489 class: AnchorProvenanceClass::Anchored,
2490 at_version: None,
2491 hash: Some("h".into()),
2492 hash_stability: AnchorHashStability::Stable,
2493 derived_from: Vec::new(),
2494 binding: None,
2495 source: None,
2496 span_unvalidated: false,
2497 hash_source: None,
2498 last_observed: None,
2499 },
2500 Anchor {
2501 artifact: "src/".into(),
2502 grain: AnchorGrain::Tree,
2503 class: AnchorProvenanceClass::Derived,
2504 at_version: None,
2505 hash: Some("t".into()),
2506 hash_stability: AnchorHashStability::Stable,
2507 derived_from: vec!["a.rs".into(), "b.rs".into()],
2508 binding: None,
2509 source: None,
2510 span_unvalidated: false,
2511 hash_source: None,
2512 last_observed: None,
2513 },
2514 ];
2515 let comp = compose_entity_anchors(&anchors);
2516 assert_eq!(comp.by_class["anchored"], 1);
2517 assert_eq!(comp.by_class["derived"], 1);
2518 assert_eq!(comp.by_grain["file"], 1);
2519 assert_eq!(comp.by_grain["tree"], 1);
2520 assert_eq!(comp.tree_grain_artifacts, vec!["src/".to_string()]);
2522 assert_eq!(
2523 comp.derived_inputs,
2524 vec![vec!["a.rs".to_string(), "b.rs".to_string()]]
2525 );
2526 }
2527
2528 #[test]
2531 fn sidecar_round_trips_and_prunes_empty() {
2532 let mut sc = AnchorSidecar::default();
2533 assert!(sc.is_empty());
2534 let a = anchor(
2535 AnchorProvenanceClass::Anchored,
2536 Some("h1"),
2537 AnchorHashStability::Stable,
2538 );
2539 sc.set("specs--x", vec![a.clone()]);
2540 assert_eq!(sc.get("specs--x").len(), 1);
2541
2542 let bytes = sc.to_bytes();
2543 let round = AnchorSidecar::from_bytes(&bytes).unwrap();
2544 assert_eq!(round, sc);
2545
2546 sc.set("specs--x", vec![]);
2548 assert!(sc.is_empty());
2549 assert!(sc.get("specs--x").is_empty());
2550 }
2551
2552 fn file_anchor(artifact: &str, hash: &str) -> Anchor {
2555 Anchor {
2556 artifact: artifact.into(),
2557 grain: AnchorGrain::File,
2558 class: AnchorProvenanceClass::Anchored,
2559 at_version: None,
2560 hash: Some(hash.into()),
2561 hash_stability: AnchorHashStability::Stable,
2562 derived_from: Vec::new(),
2563 binding: None,
2564 source: None,
2565 span_unvalidated: false,
2566 hash_source: None,
2567 last_observed: None,
2568 }
2569 }
2570
2571 #[test]
2574 fn merge_appends_new_triple_without_touching_others() {
2575 let mut sc = AnchorSidecar::default();
2576 sc.set(
2577 "m--e",
2578 vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
2579 );
2580 sc.merge("m--e", &[], vec![file_anchor("c.rs", "h-c")], false);
2581 let row = sc.get("m--e");
2582 assert_eq!(row.len(), 3);
2583 assert_eq!(row[0], file_anchor("a.rs", "h-a"));
2584 assert_eq!(row[1], file_anchor("b.rs", "h-b"));
2585 assert_eq!(row[2], file_anchor("c.rs", "h-c"));
2586 }
2587
2588 #[test]
2592 fn merge_replaces_same_triple_in_place() {
2593 let mut sc = AnchorSidecar::default();
2594 sc.set(
2595 "m--e",
2596 vec![file_anchor("a.rs", "h-old"), file_anchor("b.rs", "h-b")],
2597 );
2598 sc.merge("m--e", &[], vec![file_anchor("a.rs", "h-new")], false);
2599 let row = sc.get("m--e");
2600 assert_eq!(row.len(), 2);
2601 assert_eq!(row[0], file_anchor("a.rs", "h-new"));
2602 assert_eq!(row[1], file_anchor("b.rs", "h-b"));
2603 }
2604
2605 #[test]
2609 fn merge_treats_grain_and_class_as_identity() {
2610 let mut sc = AnchorSidecar::default();
2611 sc.set("m--e", vec![file_anchor("a.rs", "h-a")]);
2612 let mut span = file_anchor("a.rs", "h-span");
2613 span.grain = AnchorGrain::Span;
2614 let mut informed = file_anchor("a.rs", "h-a");
2615 informed.class = AnchorProvenanceClass::InformedBy;
2616 informed.hash = None;
2617 sc.merge("m--e", &[], vec![span, informed], false);
2618 assert_eq!(sc.get("m--e").len(), 3);
2619 }
2620
2621 #[test]
2624 fn merge_full_resend_and_empty_are_noops() {
2625 let mut sc = AnchorSidecar::default();
2626 sc.set(
2627 "m--e",
2628 vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
2629 );
2630 let before = sc.to_bytes();
2631 sc.merge(
2632 "m--e",
2633 &[],
2634 vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
2635 false,
2636 );
2637 assert_eq!(sc.to_bytes(), before, "full re-send is byte-stable");
2638 sc.merge("m--e", &[], Vec::new(), false);
2639 assert_eq!(sc.to_bytes(), before, "empty merge is a no-op");
2640 }
2641
2642 #[test]
2646 fn unset_selects_by_artifact_with_optional_narrowing() {
2647 let mut span = file_anchor("a.rs", "h-span");
2648 span.grain = AnchorGrain::Span;
2649 let mut sc = AnchorSidecar::default();
2650 sc.set(
2651 "m--e",
2652 vec![
2653 file_anchor("a.rs", "h-a"),
2654 span.clone(),
2655 file_anchor("b.rs", "h-b"),
2656 ],
2657 );
2658
2659 let narrowed = AnchorUnset {
2661 artifact: "a.rs".into(),
2662 grain: Some(AnchorGrain::Span),
2663 class: None,
2664 };
2665 sc.merge("m--e", &[narrowed], Vec::new(), false);
2666 assert_eq!(
2667 sc.get("m--e"),
2668 &[file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")]
2669 );
2670
2671 let missing = AnchorUnset {
2673 artifact: "never-there.rs".into(),
2674 grain: None,
2675 class: None,
2676 };
2677 sc.merge("m--e", &[missing], Vec::new(), false);
2678 assert_eq!(sc.get("m--e").len(), 2);
2679
2680 let bare = AnchorUnset {
2682 artifact: "a.rs".into(),
2683 grain: None,
2684 class: None,
2685 };
2686 sc.merge("m--e", &[bare], Vec::new(), false);
2687 assert_eq!(sc.get("m--e"), &[file_anchor("b.rs", "h-b")]);
2688 }
2689
2690 #[test]
2694 fn unset_applies_before_merge() {
2695 let mut span = file_anchor("a.rs", "h-span");
2696 span.grain = AnchorGrain::Span;
2697 let mut sc = AnchorSidecar::default();
2698 sc.set("m--e", vec![file_anchor("a.rs", "h-old"), span]);
2699 let bare = AnchorUnset {
2700 artifact: "a.rs".into(),
2701 grain: None,
2702 class: None,
2703 };
2704 sc.merge("m--e", &[bare], vec![file_anchor("a.rs", "h-new")], false);
2705 assert_eq!(sc.get("m--e"), &[file_anchor("a.rs", "h-new")]);
2706 }
2707
2708 #[test]
2711 fn merge_prunes_row_emptied_by_unset() {
2712 let mut sc = AnchorSidecar::default();
2713 sc.set("m--e", vec![file_anchor("a.rs", "h-a")]);
2714 let bare = AnchorUnset {
2715 artifact: "a.rs".into(),
2716 grain: None,
2717 class: None,
2718 };
2719 sc.merge("m--e", &[bare], Vec::new(), false);
2720 assert!(sc.is_empty());
2721 assert!(!sc.to_bytes().windows(5).any(|w| w == b"m--e\""));
2722 }
2723
2724 #[test]
2727 fn unset_input_validates_typed() {
2728 let ok = AnchorUnsetInput {
2729 artifact: Some(" a.rs ".into()),
2730 grain: Some("span".into()),
2731 class: None,
2732 }
2733 .validate()
2734 .unwrap();
2735 assert_eq!(ok.artifact, "a.rs");
2736 assert_eq!(ok.grain, Some(AnchorGrain::Span));
2737 assert_eq!(ok.class, None);
2738
2739 let missing = AnchorUnsetInput::default().validate().unwrap_err();
2740 assert!(matches!(missing, AnchorValidationError::MissingArtifact));
2741 assert_eq!(missing.code(), INVALID_ANCHOR_CODE);
2742
2743 let bad_grain = AnchorUnsetInput {
2744 artifact: Some("a.rs".into()),
2745 grain: Some("paragraph".into()),
2746 class: None,
2747 }
2748 .validate()
2749 .unwrap_err();
2750 assert!(matches!(
2751 bad_grain,
2752 AnchorValidationError::UnknownGrain { .. }
2753 ));
2754
2755 let bad_class = AnchorUnsetInput {
2756 artifact: Some("a.rs".into()),
2757 grain: None,
2758 class: Some("guessed".into()),
2759 }
2760 .validate()
2761 .unwrap_err();
2762 assert!(matches!(
2763 bad_class,
2764 AnchorValidationError::UnknownClass { .. }
2765 ));
2766 }
2767
2768 #[test]
2769 fn sidecar_rename_leaves_zero_rows_under_old_id() {
2770 let mut sc = AnchorSidecar::default();
2771 sc.set(
2772 "specs--old",
2773 vec![anchor(
2774 AnchorProvenanceClass::Anchored,
2775 Some("h"),
2776 AnchorHashStability::Stable,
2777 )],
2778 );
2779 sc.rename("specs--old", "specs--new");
2780 assert!(sc.get("specs--old").is_empty());
2781 assert_eq!(sc.get("specs--new").len(), 1);
2782 }
2783
2784 #[test]
2785 fn sidecar_remove_drops_entity_anchors() {
2786 let mut sc = AnchorSidecar::default();
2787 sc.set(
2788 "specs--gone",
2789 vec![anchor(
2790 AnchorProvenanceClass::Anchored,
2791 Some("h"),
2792 AnchorHashStability::Stable,
2793 )],
2794 );
2795 sc.remove("specs--gone");
2796 assert!(sc.get("specs--gone").is_empty());
2797 sc.remove("specs--gone");
2799 }
2800
2801 #[test]
2802 fn empty_bytes_parse_as_empty_sidecar() {
2803 assert!(AnchorSidecar::from_bytes(b"").unwrap().is_empty());
2804 assert!(AnchorSidecar::from_bytes(b" \n ").unwrap().is_empty());
2805 }
2806
2807 #[test]
2808 fn anchor_json_shape_omits_empty_optionals() {
2809 let a = anchor(
2810 AnchorProvenanceClass::Anchored,
2811 Some("h1"),
2812 AnchorHashStability::Stable,
2813 );
2814 let v = serde_json::to_value(&a).unwrap();
2815 assert_eq!(v["artifact"], "src/lib.rs");
2816 assert_eq!(v["grain"], "file");
2817 assert_eq!(v["class"], "anchored");
2818 assert_eq!(v["hash"], "h1");
2819 assert_eq!(v["hash_stability"], "stable");
2820 assert!(v.get("at_version").is_none());
2822 assert!(v.get("derived_from").is_none());
2823 assert!(v.get("binding").is_none());
2824 }
2825
2826 #[test]
2827 fn anchor_version_serialises_tagged() {
2828 let a = Anchor {
2829 at_version: Some(AnchorVersion::Commit("deadbeef".into())),
2830 ..anchor(
2831 AnchorProvenanceClass::Anchored,
2832 Some("h"),
2833 AnchorHashStability::Stable,
2834 )
2835 };
2836 let v = serde_json::to_value(&a).unwrap();
2837 assert_eq!(v["at_version"]["kind"], "commit");
2838 assert_eq!(v["at_version"]["value"], "deadbeef");
2839 }
2840
2841 #[test]
2845 fn validate_source_carried_absent_or_refused_when_empty() {
2846 let mut input = AnchorInput {
2847 artifact: Some("src/lib.rs".into()),
2848 grain: Some("file".into()),
2849 class: Some("anchored".into()),
2850 ..Default::default()
2851 };
2852 assert_eq!(
2853 input.validate(None).unwrap().source,
2854 None,
2855 "absent stays absent"
2856 );
2857
2858 input.source = Some(" api-docs ".into());
2859 assert_eq!(
2860 input.validate(None).unwrap().source.as_deref(),
2861 Some("api-docs"),
2862 "non-empty name is carried (trimmed)"
2863 );
2864
2865 input.source = Some(" ".into());
2866 let err = input.validate(None).unwrap_err();
2867 assert_eq!(err.code(), INVALID_ANCHOR_CODE);
2868 assert!(matches!(err, AnchorValidationError::EmptySource));
2869 assert_eq!(
2870 err.detail().get("field"),
2871 Some(&serde_json::json!("source"))
2872 );
2873 }
2874
2875 #[test]
2879 fn source_is_additive_on_the_persisted_shape() {
2880 let pre_plan = r#"{
2881 "artifact": "src/lib.rs",
2882 "grain": "file",
2883 "class": "anchored",
2884 "hash_stability": "stable"
2885 }"#;
2886 let a: Anchor = serde_json::from_str(pre_plan).expect("pre-plan anchor loads");
2887 assert_eq!(a.source, None, "no backfill, no default");
2888
2889 let sourced = Anchor {
2890 source: Some("api-docs".into()),
2891 ..a
2892 };
2893 let json = serde_json::to_string(&sourced).unwrap();
2894 let back: Anchor = serde_json::from_str(&json).unwrap();
2895 assert_eq!(back.source.as_deref(), Some("api-docs"));
2896 }
2897
2898 #[test]
2901 fn sidecar_v1_loads_and_upgrades_in_memory_v3_refuses() {
2902 let v1 = br#"{"version":1,"entities":{"m--e":[{"artifact":"https://x.test/a","grain":"url","class":"informed-by","hash_stability":"unstable"}]}}"#;
2903 let sc = AnchorSidecar::from_bytes(v1).expect("version 1 loads");
2904 assert_eq!(sc.version, ANCHOR_SIDECAR_VERSION, "upgraded in memory");
2905 assert_eq!(sc.get("m--e").len(), 1);
2906 assert!(sc.get("m--e")[0].last_observed.is_none(), "rows unchanged");
2907 let rewritten = String::from_utf8(sc.to_bytes()).unwrap();
2908 assert!(rewritten.contains("\"version\": 2"), "{rewritten}");
2909
2910 let v3 = br#"{"version":3,"entities":{}}"#;
2911 let err = AnchorSidecar::from_bytes(v3).expect_err("unknown higher version refuses");
2912 assert!(
2913 err.to_string()
2914 .contains("unsupported anchors sidecar version 3"),
2915 "{err}"
2916 );
2917 }
2918
2919 #[test]
2920 fn last_observed_round_trips_and_is_absent_when_none() {
2921 let mut a = valid_input().validate(None).unwrap();
2922 let json = serde_json::to_value(&a).unwrap();
2923 assert!(json.get("last_observed").is_none());
2924 a.last_observed = Some(AnchorObservation {
2925 at: "2026-09-01T10:00:00Z".into(),
2926 hash: Some("abc".into()),
2927 state: AnchorState::Resolves,
2928 });
2929 let json = serde_json::to_value(&a).unwrap();
2930 assert_eq!(json["last_observed"]["state"], "resolves");
2931 let back: Anchor = serde_json::from_value(json).unwrap();
2932 assert_eq!(back, a);
2933 }
2934
2935 #[test]
2936 fn url_grain_is_admitted_beside_a_path_medium_and_path_grains_refuse_a_url_artifact() {
2937 let mut i = valid_input();
2938 i.grain = Some("url".into());
2939 i.artifact = Some("https://example.org/doc.pdf".into());
2940 i.class = Some("anchored".into());
2941 i.hash = None;
2942 i.content = Some("the document text".into());
2943 i.hash_stability = None;
2944 let a = i
2945 .validate(Some(("filesystem", "path")))
2946 .expect("url beside a path medium is legal");
2947 assert_eq!(a.grain, AnchorGrain::Url);
2948 assert_eq!(a.hash_source, Some(AnchorHashSource::Author));
2949 assert_eq!(
2950 a.hash_stability,
2951 AnchorHashStability::Unstable,
2952 "url default"
2953 );
2954
2955 for grain in ["span", "file", "tree"] {
2956 let mut i = valid_input();
2957 i.grain = Some(grain.into());
2958 i.artifact = Some("https://example.org/doc.pdf#L1-L3".into());
2959 i.class = Some("informed-by".into());
2960 i.hash = None;
2961 let err = i.validate(Some(("filesystem", "path"))).unwrap_err();
2962 assert!(
2963 matches!(&err, AnchorValidationError::PathGrainOnUrlArtifact { grain: g, .. } if *g == grain),
2964 "{grain}: {err:?}"
2965 );
2966 assert_eq!(err.code(), INVALID_ANCHOR_CODE);
2967 assert!(err.to_string().contains("never enters a path namespace"));
2968 }
2969 assert!(looks_like_url("https://a.b/c"));
2970 assert!(looks_like_url("file://x"));
2971 assert!(!looks_like_url("src/main.rs"));
2972 assert!(!looks_like_url("://nope"));
2973 assert!(!looks_like_url("http://"));
2974 }
2975
2976 #[test]
2977 fn supplied_observations_validate_all_or_nothing() {
2978 let now = "2026-09-02T12:00:00Z";
2979 let rows = vec![
2980 SuppliedObservationInput {
2981 artifact: Some("https://a.test/1".into()),
2982 hash: Some("h1".into()),
2983 ..Default::default()
2984 },
2985 SuppliedObservationInput {
2986 artifact: Some("https://a.test/2".into()),
2987 content: Some("body\r\n".into()),
2988 observed_at: Some("2026-08-01".into()),
2989 ..Default::default()
2990 },
2991 SuppliedObservationInput {
2992 artifact: Some("https://a.test/3".into()),
2993 absent: Some(true),
2994 ..Default::default()
2995 },
2996 ];
2997 let ok = validate_supplied_observations(&rows, now).unwrap();
2998 assert_eq!(ok.len(), 3);
2999 assert_eq!(ok["https://a.test/1"].at, now);
3000 assert_eq!(
3001 ok["https://a.test/2"].outcome,
3002 SuppliedOutcome::Present {
3003 hash: prepared_content_hash(b"body\r\n")
3004 },
3005 "content hashes under the write path's canonicalization"
3006 );
3007 assert_eq!(ok["https://a.test/2"].at, "2026-08-01");
3008 assert_eq!(ok["https://a.test/3"].outcome, SuppliedOutcome::Absent);
3009
3010 let bad = vec![SuppliedObservationInput {
3012 artifact: Some("https://a.test/1".into()),
3013 hash: Some("h".into()),
3014 content: Some("c".into()),
3015 ..Default::default()
3016 }];
3017 let err = validate_supplied_observations(&bad, now).unwrap_err();
3018 assert!(matches!(
3019 err,
3020 ObservationValidationError::OutcomeAmbiguous { row: 1, .. }
3021 ));
3022 assert_eq!(err.code(), INVALID_OBSERVATION_CODE);
3023 let bad = vec![SuppliedObservationInput {
3025 artifact: Some("https://a.test/1".into()),
3026 ..Default::default()
3027 }];
3028 assert!(matches!(
3029 validate_supplied_observations(&bad, now).unwrap_err(),
3030 ObservationValidationError::OutcomeAmbiguous { .. }
3031 ));
3032 let bad = vec![SuppliedObservationInput {
3033 artifact: Some("https://a.test/1".into()),
3034 hash: Some("h".into()),
3035 observed_at: Some("yesterday".into()),
3036 ..Default::default()
3037 }];
3038 assert!(matches!(
3039 validate_supplied_observations(&bad, now).unwrap_err(),
3040 ObservationValidationError::BadTimestamp { .. }
3041 ));
3042 let dup = vec![rows[0].clone(), rows[0].clone()];
3043 assert!(matches!(
3044 validate_supplied_observations(&dup, now).unwrap_err(),
3045 ObservationValidationError::DuplicateArtifact {
3046 first: 1,
3047 second: 2,
3048 ..
3049 }
3050 ));
3051 assert!(matches!(
3052 validate_supplied_observations(&[SuppliedObservationInput::default()], now)
3053 .unwrap_err(),
3054 ObservationValidationError::MissingArtifact { row: 1 }
3055 ));
3056 }
3057
3058 #[test]
3059 fn days_between_ages_by_civil_date() {
3060 assert_eq!(days_between("2026-08-01", "2026-09-02T00:00:00Z"), Some(32));
3061 assert_eq!(
3062 days_between("2026-09-02T23:59:59Z", "2026-09-02T00:00:00Z"),
3063 Some(0)
3064 );
3065 assert_eq!(days_between("2026-09-03", "2026-09-02"), Some(0), "floored");
3066 assert_eq!(days_between("garbage", "2026-09-02"), None);
3067 assert_eq!(iso_days_since_epoch("1970-01-01"), Some(0));
3068 assert_eq!(iso_days_since_epoch("2000-03-01"), Some(11017));
3069 }
3070}