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 AnchorHashSource {
398 pub fn as_wire(self) -> &'static str {
399 match self {
400 AnchorHashSource::Author => "author",
401 AnchorHashSource::Backfill => "backfill",
402 }
403 }
404}
405
406#[derive(Debug, Clone, Copy, PartialEq, Eq)]
420pub enum SpanLocator<'a> {
421 Lines { start: usize, end: usize },
423 Unit(&'a str),
425}
426
427pub fn parse_span_locator(artifact: &str) -> Result<Option<SpanLocator<'_>>, &'static str> {
444 let locator = match artifact.split_once('#') {
445 None => return Ok(None),
446 Some((_, loc)) if loc.trim().is_empty() => {
447 return Err("the span locator after `#` is empty");
448 }
449 Some((_, loc)) => loc,
450 };
451 let looks_like_lines = locator.starts_with('L')
454 && locator[1..]
455 .chars()
456 .next()
457 .is_some_and(|c| c.is_ascii_digit());
458 if !looks_like_lines {
459 return Ok(Some(SpanLocator::Unit(locator)));
460 }
461 let (start_raw, end_raw) = match locator.split_once('-') {
462 None => (locator, locator),
463 Some((a, b)) => (a, b),
464 };
465 let num = |part: &str| -> Option<usize> {
466 part.strip_prefix('L')
467 .filter(|d| !d.is_empty() && d.chars().all(|c| c.is_ascii_digit()))
468 .and_then(|d| d.parse::<usize>().ok())
469 };
470 let (Some(start), Some(end)) = (num(start_raw), num(end_raw)) else {
471 return Err("a line-range span locator must read `L<start>` or `L<start>-L<end>`");
472 };
473 if start == 0 {
474 return Err("line numbers are 1-based, so `L0` addresses nothing");
475 }
476 if end < start {
477 return Err("a line-range span locator ends before it starts");
478 }
479 Ok(Some(SpanLocator::Lines { start, end }))
480}
481
482#[derive(Debug, Clone, Default, Serialize, Deserialize)]
492pub struct AnchorInput {
493 #[serde(default)]
494 pub artifact: Option<String>,
495 #[serde(default)]
496 pub grain: Option<String>,
497 #[serde(default)]
498 pub class: Option<String>,
499 #[serde(default)]
500 pub at_version: Option<AnchorVersion>,
501 #[serde(default)]
502 pub hash: Option<String>,
503 #[serde(default)]
512 pub content: Option<String>,
513 #[serde(default)]
514 pub hash_stability: Option<String>,
515 #[serde(default)]
516 pub derived_from: Option<Vec<String>>,
517 #[serde(default)]
518 pub binding: Option<String>,
519 #[serde(default)]
520 pub source: Option<String>,
521}
522
523#[derive(Debug, Clone, Default, Serialize, Deserialize)]
531pub struct AnchorUnsetInput {
532 #[serde(default)]
533 pub artifact: Option<String>,
534 #[serde(default)]
535 pub grain: Option<String>,
536 #[serde(default)]
537 pub class: Option<String>,
538}
539
540impl AnchorUnsetInput {
541 pub fn validate(&self) -> Result<AnchorUnset, AnchorValidationError> {
545 let artifact = self
546 .artifact
547 .as_deref()
548 .map(str::trim)
549 .filter(|s| !s.is_empty())
550 .map(str::to_string)
551 .ok_or(AnchorValidationError::MissingArtifact)?;
552 let grain = match self.grain.as_deref() {
553 None => None,
554 Some(s) => Some(AnchorGrain::from_wire(s).ok_or_else(|| {
555 AnchorValidationError::UnknownGrain {
556 got: Some(s.to_string()),
557 allowed: AnchorGrain::WIRE_VALUES,
558 }
559 })?),
560 };
561 let class = match self.class.as_deref() {
562 None => None,
563 Some(s) => Some(AnchorProvenanceClass::from_wire(s).ok_or_else(|| {
564 AnchorValidationError::UnknownClass {
565 got: Some(s.to_string()),
566 allowed: AnchorProvenanceClass::WIRE_VALUES,
567 }
568 })?),
569 };
570 Ok(AnchorUnset {
571 artifact,
572 grain,
573 class,
574 })
575 }
576}
577
578#[derive(Debug, Clone, PartialEq, Eq)]
584pub struct AnchorUnset {
585 pub artifact: String,
587 pub grain: Option<AnchorGrain>,
589 pub class: Option<AnchorProvenanceClass>,
591}
592
593impl AnchorUnset {
594 pub fn matches(&self, anchor: &Anchor) -> bool {
596 anchor.artifact == self.artifact
597 && self.grain.is_none_or(|g| anchor.grain == g)
598 && self.class.is_none_or(|c| anchor.class == c)
599 }
600}
601
602#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
606pub enum AnchorValidationError {
607 #[error("unknown anchor provenance class {got:?}; allowed: {}", allowed.join(", "))]
609 UnknownClass {
610 got: Option<String>,
611 allowed: &'static [&'static str],
612 },
613 #[error("unknown anchor grain {got:?}; allowed: {}", allowed.join(", "))]
615 UnknownGrain {
616 got: Option<String>,
617 allowed: &'static [&'static str],
618 },
619 #[error("unknown anchor hash stability {got:?}; allowed: {}", allowed.join(", "))]
621 UnknownHashStability {
622 got: String,
623 allowed: &'static [&'static str],
624 },
625 #[error("anchor is missing its artifact reference")]
627 MissingArtifact,
628 #[error("anchor class '{class}' carries no hash semantics — a content hash is not permitted")]
631 HashOnNonHashClass { class: &'static str },
632 #[error(
635 "anchor supplies both `hash` and `content`; supply one — the engine computes the hash from `content`"
636 )]
637 ContentAndHash,
638 #[error(
643 "anchor grain '{grain}' does not accept `content`: its prepared form is not computed \
644 from supplied bytes (accepted for span / file / url)"
645 )]
646 ContentNotAcceptedForGrain { grain: &'static str },
647 #[error(
650 "anchor artifact {artifact:?} names a delivery unit the supplied `content` does not \
651 yield; supply the whole file's content, or address a unit it contains"
652 )]
653 UnitAbsentFromContent { artifact: String },
654 #[error("anchor artifact {artifact:?} is not a usable span reference: {reason}")]
659 SpanLocatorUnusable {
660 artifact: String,
661 reason: &'static str,
662 },
663 #[error(
668 "anchor artifact {artifact:?} names lines the supplied `content` does not have \
669 (it has {lines} line(s)); address a range the artifact contains"
670 )]
671 SpanOutsideContent { artifact: String, lines: usize },
672 #[error(
678 "the anchors payload names {artifact:?} at grain `{grain}` and class `{class}` more \
679 than once; that triple is one row, so the repeats would silently collapse to the \
680 last one: send it once, or vary the grain or class"
681 )]
682 DuplicateAnchorTriple {
683 artifact: String,
684 grain: &'static str,
685 class: &'static str,
686 },
687 #[error("anchor `source`, when present, must be a non-empty source name")]
691 EmptySource,
692 #[error(
701 "anchor `source` {got:?} is not declared by the anchor's producing binding; \
702 declared sources: {}",
703 declared.join(", ")
704 )]
705 SourceNotDeclared { got: String, declared: Vec<String> },
706 #[error(
713 "anchor artifact {artifact:?} resolves under no candidate path (tried: {}); artifact \
714 paths are source-relative (joined onto the source's pointer) or workspace-relative — \
715 write the path exactly as the brief lists it",
716 candidates.join(", ")
717 )]
718 ArtifactUnresolvable {
719 artifact: String,
720 candidates: Vec<String>,
721 },
722 #[error(
725 "anchor grain '{grain}' is unsupported by a '{medium_type}' medium: its \
726 '{anchor_namespace}' namespace does not admit that grain"
727 )]
728 GrainNamespaceUnsupported {
729 grain: &'static str,
730 medium_type: String,
731 anchor_namespace: &'static str,
732 },
733 #[error(
737 "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"
738 )]
739 PathGrainOnUrlArtifact {
740 grain: &'static str,
741 artifact: String,
742 },
743}
744
745pub fn looks_like_url(artifact: &str) -> bool {
747 let Some((scheme, rest)) = artifact.split_once("://") else {
748 return false;
749 };
750 !rest.is_empty()
751 && scheme
752 .chars()
753 .next()
754 .is_some_and(|c| c.is_ascii_alphabetic())
755 && scheme
756 .chars()
757 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
758}
759
760impl AnchorValidationError {
761 pub fn code(&self) -> &'static str {
763 INVALID_ANCHOR_CODE
764 }
765
766 pub fn detail(&self) -> BTreeMap<String, serde_json::Value> {
769 let mut d = BTreeMap::new();
770 match self {
771 AnchorValidationError::UnknownClass { got, allowed } => {
772 d.insert("field".into(), "class".into());
773 d.insert("got".into(), serde_json::json!(got));
774 d.insert("allowed".into(), serde_json::json!(allowed));
775 }
776 AnchorValidationError::UnknownGrain { got, allowed } => {
777 d.insert("field".into(), "grain".into());
778 d.insert("got".into(), serde_json::json!(got));
779 d.insert("allowed".into(), serde_json::json!(allowed));
780 }
781 AnchorValidationError::UnknownHashStability { got, allowed } => {
782 d.insert("field".into(), "hash_stability".into());
783 d.insert("got".into(), serde_json::json!(got));
784 d.insert("allowed".into(), serde_json::json!(allowed));
785 }
786 AnchorValidationError::MissingArtifact => {
787 d.insert("field".into(), "artifact".into());
788 }
789 AnchorValidationError::EmptySource => {
790 d.insert("field".into(), "source".into());
791 }
792 AnchorValidationError::SourceNotDeclared { got, declared } => {
793 d.insert("field".into(), "source".into());
794 d.insert("got".into(), serde_json::json!(got));
795 d.insert("declared".into(), serde_json::json!(declared));
796 }
797 AnchorValidationError::HashOnNonHashClass { class } => {
798 d.insert("field".into(), "hash".into());
799 d.insert("class".into(), serde_json::json!(class));
800 }
801 AnchorValidationError::ContentAndHash => {
802 d.insert("field".into(), "content".into());
803 d.insert(
804 "expected".into(),
805 serde_json::json!("either `hash` or `content`, never both"),
806 );
807 }
808 AnchorValidationError::ContentNotAcceptedForGrain { grain } => {
809 d.insert("field".into(), "content".into());
810 d.insert("grain".into(), serde_json::json!(grain));
811 d.insert(
812 "accepted_grains".into(),
813 serde_json::json!(["span", "file", "url"]),
814 );
815 }
816 AnchorValidationError::UnitAbsentFromContent { artifact } => {
817 d.insert("field".into(), "content".into());
818 d.insert("got".into(), serde_json::json!(artifact));
819 }
820 AnchorValidationError::SpanLocatorUnusable { artifact, reason } => {
821 d.insert("field".into(), "artifact".into());
822 d.insert("got".into(), serde_json::json!(artifact));
823 d.insert("expected".into(), serde_json::json!(reason));
824 }
825 AnchorValidationError::SpanOutsideContent { artifact, lines } => {
826 d.insert("field".into(), "artifact".into());
827 d.insert("got".into(), serde_json::json!(artifact));
828 d.insert("content_lines".into(), serde_json::json!(lines));
829 }
830 AnchorValidationError::DuplicateAnchorTriple {
831 artifact,
832 grain,
833 class,
834 } => {
835 d.insert("field".into(), "anchors".into());
836 d.insert(
837 "got".into(),
838 serde_json::json!({ "artifact": artifact, "grain": grain, "class": class }),
839 );
840 d.insert(
841 "expected".into(),
842 serde_json::json!(
843 "each (artifact, grain, class) triple at most once per payload"
844 ),
845 );
846 }
847 AnchorValidationError::ArtifactUnresolvable {
848 artifact,
849 candidates,
850 } => {
851 d.insert("field".into(), "artifact".into());
852 d.insert("got".into(), serde_json::json!(artifact));
853 d.insert("candidates_tried".into(), serde_json::json!(candidates));
854 d.insert(
855 "expected".into(),
856 serde_json::json!(
857 "a source-relative path (joined onto the source's pointer) or a \
858 workspace-relative path that resolves to an existing artifact"
859 ),
860 );
861 }
862 AnchorValidationError::GrainNamespaceUnsupported {
863 grain,
864 medium_type,
865 anchor_namespace,
866 } => {
867 d.insert("field".into(), "grain".into());
868 d.insert("grain".into(), serde_json::json!(grain));
869 d.insert("medium_type".into(), serde_json::json!(medium_type));
870 d.insert(
871 "anchor_namespace".into(),
872 serde_json::json!(anchor_namespace),
873 );
874 }
875 AnchorValidationError::PathGrainOnUrlArtifact { grain, artifact } => {
876 d.insert("field".into(), "grain".into());
877 d.insert("grain".into(), serde_json::json!(grain));
878 d.insert("got".into(), serde_json::json!(artifact));
879 d.insert(
880 "expected".into(),
881 serde_json::json!(
882 "`grain: url` for a web resource — a URL never enters a path namespace"
883 ),
884 );
885 }
886 }
887 d
888 }
889}
890
891impl AnchorInput {
892 pub fn validate(&self, medium: Option<(&str, &str)>) -> Result<Anchor, AnchorValidationError> {
915 let class = match self
916 .class
917 .as_deref()
918 .and_then(AnchorProvenanceClass::from_wire)
919 {
920 Some(c) => c,
921 None => {
922 return Err(AnchorValidationError::UnknownClass {
923 got: self.class.clone(),
924 allowed: AnchorProvenanceClass::WIRE_VALUES,
925 });
926 }
927 };
928 let grain = match self.grain.as_deref().and_then(AnchorGrain::from_wire) {
929 Some(g) => g,
930 None => {
931 return Err(AnchorValidationError::UnknownGrain {
932 got: self.grain.clone(),
933 allowed: AnchorGrain::WIRE_VALUES,
934 });
935 }
936 };
937
938 let artifact = self
939 .artifact
940 .as_deref()
941 .map(str::trim)
942 .filter(|s| !s.is_empty())
943 .map(str::to_string)
944 .ok_or(AnchorValidationError::MissingArtifact)?;
945
946 let hash_stability = match self.hash_stability.as_deref() {
949 None => crate::preparation::default_hash_stability(grain),
950 Some(s) => AnchorHashStability::from_wire(s).ok_or_else(|| {
951 AnchorValidationError::UnknownHashStability {
952 got: s.to_string(),
953 allowed: AnchorHashStability::WIRE_VALUES,
954 }
955 })?,
956 };
957
958 let hash = self
960 .hash
961 .as_deref()
962 .map(str::trim)
963 .filter(|s| !s.is_empty())
964 .map(str::to_string);
965 if (hash.is_some() || self.content.is_some()) && !class.is_hash_bearing() {
966 return Err(AnchorValidationError::HashOnNonHashClass {
967 class: class.as_wire(),
968 });
969 }
970 let hash = match self.content.as_deref() {
974 None => hash,
975 Some(_) if hash.is_some() => return Err(AnchorValidationError::ContentAndHash),
976 Some(content) => {
977 match crate::preparation::supplied_content_hash(grain, content.as_bytes()) {
978 Some(h) => Some(h),
979 None => {
980 return Err(AnchorValidationError::ContentNotAcceptedForGrain {
981 grain: grain.as_wire(),
982 });
983 }
984 }
985 }
986 };
987
988 let mut span_unvalidated = false;
994 if grain == AnchorGrain::Span {
995 let locator = parse_span_locator(&artifact).map_err(|reason| {
996 AnchorValidationError::SpanLocatorUnusable {
997 artifact: artifact.clone(),
998 reason,
999 }
1000 })?;
1001 match (locator, self.content.as_deref()) {
1002 (Some(SpanLocator::Lines { end, .. }), Some(content)) => {
1003 let lines = content.lines().count();
1004 if end > lines {
1005 return Err(AnchorValidationError::SpanOutsideContent {
1006 artifact: artifact.clone(),
1007 lines,
1008 });
1009 }
1010 }
1011 (Some(SpanLocator::Unit(_)), Some(_)) => {}
1015 (None, _) => {}
1019 (Some(_), None) => span_unvalidated = true,
1020 }
1021 }
1022
1023 if grain.is_path_shaped() && looks_like_url(&artifact) {
1026 return Err(AnchorValidationError::PathGrainOnUrlArtifact {
1027 grain: grain.as_wire(),
1028 artifact,
1029 });
1030 }
1031
1032 if let Some((medium_type, namespace)) = medium
1036 && !grain.supported_by_namespace(namespace)
1037 {
1038 let anchor_namespace = match namespace {
1041 "path" => "path",
1042 "path+commit" => "path+commit",
1043 "entity" => "entity",
1044 "url" => "url",
1045 _ => "path",
1046 };
1047 return Err(AnchorValidationError::GrainNamespaceUnsupported {
1048 grain: grain.as_wire(),
1049 medium_type: medium_type.to_string(),
1050 anchor_namespace,
1051 });
1052 }
1053
1054 let source = match self.source.as_deref() {
1059 None => None,
1060 Some(raw) => {
1061 let trimmed = raw.trim();
1062 if trimmed.is_empty() {
1063 return Err(AnchorValidationError::EmptySource);
1064 }
1065 Some(trimmed.to_string())
1066 }
1067 };
1068
1069 Ok(Anchor {
1070 artifact,
1071 grain,
1072 class,
1073 at_version: self.at_version.clone(),
1074 hash_source: hash.is_some().then_some(AnchorHashSource::Author),
1077 hash,
1078 hash_stability,
1079 derived_from: self.derived_from.clone().unwrap_or_default(),
1080 binding: self
1081 .binding
1082 .as_deref()
1083 .map(str::trim)
1084 .filter(|s| !s.is_empty())
1085 .map(str::to_string),
1086 source,
1087 span_unvalidated,
1088 last_observed: None,
1089 })
1090 }
1091}
1092
1093pub fn prepared_content_hash(bytes: &[u8]) -> String {
1120 use sha2::{Digest as _, Sha256};
1121 let digest = match std::str::from_utf8(bytes) {
1122 Ok(text) => {
1123 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
1124 let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
1125 Sha256::digest(normalized.trim_end_matches('\n').as_bytes())
1126 }
1127 Err(_) => Sha256::digest(bytes),
1128 };
1129 crate::hex_lower(&digest)[..16].to_string()
1130}
1131
1132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1137pub struct ObservedArtifactHash {
1138 pub entity: String,
1140 pub artifact: String,
1142 pub hash: String,
1144}
1145
1146pub const INVALID_OBSERVATION_CODE: &str = "INVALID_OBSERVATION";
1152
1153#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1160pub struct SuppliedObservationInput {
1161 #[serde(default)]
1164 pub artifact: Option<String>,
1165 #[serde(default)]
1168 pub hash: Option<String>,
1169 #[serde(default)]
1173 pub content: Option<String>,
1174 #[serde(default)]
1176 pub absent: Option<bool>,
1177 #[serde(default)]
1180 pub observed_at: Option<String>,
1181}
1182
1183#[derive(Debug, Clone, PartialEq, Eq)]
1185pub enum SuppliedOutcome {
1186 Present { hash: String },
1188 Absent,
1190}
1191
1192#[derive(Debug, Clone, PartialEq, Eq)]
1194pub struct SuppliedObservation {
1195 pub artifact: String,
1196 pub at: String,
1198 pub outcome: SuppliedOutcome,
1199}
1200
1201#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1203pub enum ObservationValidationError {
1204 #[error("observation row {row}: `artifact` is required and must be non-empty")]
1205 MissingArtifact { row: usize },
1206 #[error(
1207 "observation row {row} (`{artifact}`): give exactly one of `hash`, `content`, or \
1208 `absent: true`"
1209 )]
1210 OutcomeAmbiguous { row: usize, artifact: String },
1211 #[error(
1212 "observation row {row} (`{artifact}`): `observed_at` '{got}' is not an ISO-8601 \
1213 timestamp (`YYYY-MM-DDTHH:MM:SSZ`) or date (`YYYY-MM-DD`)"
1214 )]
1215 BadTimestamp {
1216 row: usize,
1217 artifact: String,
1218 got: String,
1219 },
1220 #[error("observation rows name `{artifact}` more than once (rows {first} and {second})")]
1221 DuplicateArtifact {
1222 artifact: String,
1223 first: usize,
1224 second: usize,
1225 },
1226}
1227
1228impl ObservationValidationError {
1229 pub fn code(&self) -> &'static str {
1231 INVALID_OBSERVATION_CODE
1232 }
1233
1234 pub fn detail(&self) -> BTreeMap<String, serde_json::Value> {
1236 let mut d = BTreeMap::new();
1237 match self {
1238 ObservationValidationError::MissingArtifact { row } => {
1239 d.insert("row".into(), serde_json::json!(row));
1240 d.insert("field".into(), "artifact".into());
1241 }
1242 ObservationValidationError::OutcomeAmbiguous { row, artifact } => {
1243 d.insert("row".into(), serde_json::json!(row));
1244 d.insert("artifact".into(), serde_json::json!(artifact));
1245 d.insert(
1246 "expected".into(),
1247 serde_json::json!("exactly one of `hash`, `content`, `absent: true`"),
1248 );
1249 }
1250 ObservationValidationError::BadTimestamp { row, artifact, got } => {
1251 d.insert("row".into(), serde_json::json!(row));
1252 d.insert("artifact".into(), serde_json::json!(artifact));
1253 d.insert("field".into(), "observed_at".into());
1254 d.insert("got".into(), serde_json::json!(got));
1255 }
1256 ObservationValidationError::DuplicateArtifact {
1257 artifact,
1258 first,
1259 second,
1260 } => {
1261 d.insert("artifact".into(), serde_json::json!(artifact));
1262 d.insert("rows".into(), serde_json::json!([first, second]));
1263 }
1264 }
1265 d
1266 }
1267}
1268
1269fn timestamp_is_wellformed(ts: &str) -> bool {
1272 let b = ts.as_bytes();
1273 let date_ok = b.len() >= 10
1274 && b[..10].iter().enumerate().all(|(i, c)| {
1275 if i == 4 || i == 7 {
1276 *c == b'-'
1277 } else {
1278 c.is_ascii_digit()
1279 }
1280 });
1281 if !date_ok {
1282 return false;
1283 }
1284 if b.len() == 10 {
1285 return true;
1286 }
1287 b.len() == 20
1288 && b[10] == b'T'
1289 && b[19] == b'Z'
1290 && b[11..19].iter().enumerate().all(|(i, c)| {
1291 if i == 2 || i == 5 {
1292 *c == b':'
1293 } else {
1294 c.is_ascii_digit()
1295 }
1296 })
1297}
1298
1299pub fn validate_supplied_observations(
1304 rows: &[SuppliedObservationInput],
1305 now: &str,
1306) -> Result<BTreeMap<String, SuppliedObservation>, ObservationValidationError> {
1307 let mut out: BTreeMap<String, SuppliedObservation> = BTreeMap::new();
1308 let mut first_row: BTreeMap<String, usize> = BTreeMap::new();
1309 for (i, row) in rows.iter().enumerate() {
1310 let n = i + 1;
1311 let artifact = row
1312 .artifact
1313 .as_deref()
1314 .map(str::trim)
1315 .filter(|s| !s.is_empty())
1316 .ok_or(ObservationValidationError::MissingArtifact { row: n })?
1317 .to_string();
1318 let absent = row.absent.unwrap_or(false);
1319 let given = usize::from(row.hash.is_some())
1320 + usize::from(row.content.is_some())
1321 + usize::from(absent);
1322 if given != 1 {
1323 return Err(ObservationValidationError::OutcomeAmbiguous { row: n, artifact });
1324 }
1325 let at = match row.observed_at.as_deref().map(str::trim) {
1326 None | Some("") => now.to_string(),
1327 Some(ts) if timestamp_is_wellformed(ts) => ts.to_string(),
1328 Some(ts) => {
1329 return Err(ObservationValidationError::BadTimestamp {
1330 row: n,
1331 artifact,
1332 got: ts.to_string(),
1333 });
1334 }
1335 };
1336 if let Some(first) = first_row.get(&artifact) {
1337 return Err(ObservationValidationError::DuplicateArtifact {
1338 artifact,
1339 first: *first,
1340 second: n,
1341 });
1342 }
1343 let outcome = if absent {
1344 SuppliedOutcome::Absent
1345 } else if let Some(hash) = &row.hash {
1346 SuppliedOutcome::Present {
1347 hash: hash.trim().to_string(),
1348 }
1349 } else {
1350 SuppliedOutcome::Present {
1351 hash: prepared_content_hash(row.content.as_deref().unwrap_or_default().as_bytes()),
1352 }
1353 };
1354 first_row.insert(artifact.clone(), n);
1355 out.insert(
1356 artifact.clone(),
1357 SuppliedObservation {
1358 artifact,
1359 at,
1360 outcome,
1361 },
1362 );
1363 }
1364 Ok(out)
1365}
1366
1367pub fn iso_days_since_epoch(ts: &str) -> Option<i64> {
1371 if !timestamp_is_wellformed(ts) {
1372 return None;
1373 }
1374 let y: i64 = ts[..4].parse().ok()?;
1375 let m: u32 = ts[5..7].parse().ok()?;
1376 let d: u32 = ts[8..10].parse().ok()?;
1377 if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
1378 return None;
1379 }
1380 let y = if m <= 2 { y - 1 } else { y };
1381 let era = if y >= 0 { y } else { y - 399 } / 400;
1382 let yoe = y - era * 400;
1383 let mp = ((m + 9) % 12) as i64;
1384 let doy = (153 * mp + 2) / 5 + d as i64 - 1;
1385 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1386 Some(era * 146097 + doe - 719468)
1387}
1388
1389pub fn days_between(observed_at: &str, now: &str) -> Option<u64> {
1392 let a = iso_days_since_epoch(observed_at)?;
1393 let b = iso_days_since_epoch(now)?;
1394 Some((b - a).max(0) as u64)
1395}
1396
1397#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1403#[serde(rename_all = "lowercase")]
1404pub enum AnchorState {
1405 Resolves,
1408 Drifted,
1411 Recheck,
1415 Orphaned,
1418}
1419
1420impl AnchorState {
1421 pub fn as_wire(&self) -> &'static str {
1423 match self {
1424 AnchorState::Resolves => "resolves",
1425 AnchorState::Drifted => "drifted",
1426 AnchorState::Recheck => "recheck",
1427 AnchorState::Orphaned => "orphaned",
1428 }
1429 }
1430}
1431
1432#[derive(Debug, Clone, PartialEq, Eq)]
1434pub enum ArtifactObservation {
1435 Absent,
1437 Present { current_hash: Option<String> },
1441}
1442
1443pub fn resolve_anchor(anchor: &Anchor, observation: &ArtifactObservation) -> AnchorState {
1455 let current_hash = match observation {
1456 ArtifactObservation::Absent => return AnchorState::Orphaned,
1457 ArtifactObservation::Present { current_hash } => current_hash,
1458 };
1459 if !anchor.class.is_hash_bearing() {
1460 return AnchorState::Resolves;
1461 }
1462 match (&anchor.hash, current_hash) {
1463 (Some(recorded), Some(current)) if recorded == current => AnchorState::Resolves,
1464 (Some(_), Some(_)) => match anchor.hash_stability {
1465 AnchorHashStability::Stable => AnchorState::Drifted,
1466 AnchorHashStability::Unstable => AnchorState::Recheck,
1467 },
1468 _ => AnchorState::Recheck,
1470 }
1471}
1472
1473#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1479pub struct EntityAnchorComposition {
1480 pub by_class: BTreeMap<String, usize>,
1482 pub by_grain: BTreeMap<String, usize>,
1484 pub derived_inputs: Vec<Vec<String>>,
1487 pub tree_grain_artifacts: Vec<String>,
1492}
1493
1494pub fn compose_entity_anchors(anchors: &[Anchor]) -> EntityAnchorComposition {
1497 let mut comp = EntityAnchorComposition::default();
1498 for a in anchors {
1499 *comp
1500 .by_class
1501 .entry(a.class.as_wire().to_string())
1502 .or_insert(0) += 1;
1503 *comp
1504 .by_grain
1505 .entry(a.grain.as_wire().to_string())
1506 .or_insert(0) += 1;
1507 if a.class == AnchorProvenanceClass::Derived {
1508 comp.derived_inputs.push(a.derived_from.clone());
1509 }
1510 if a.grain == AnchorGrain::Tree {
1511 comp.tree_grain_artifacts.push(a.artifact.clone());
1512 }
1513 }
1514 comp
1515}
1516
1517#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1529pub struct AnchorSidecar {
1530 pub version: u32,
1532 #[serde(default)]
1535 pub entities: BTreeMap<String, Vec<Anchor>>,
1536}
1537
1538impl Default for AnchorSidecar {
1539 fn default() -> Self {
1540 Self {
1541 version: ANCHOR_SIDECAR_VERSION,
1542 entities: BTreeMap::new(),
1543 }
1544 }
1545}
1546
1547impl AnchorSidecar {
1548 pub fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
1551 if bytes.iter().all(u8::is_ascii_whitespace) {
1552 return Ok(Self::default());
1553 }
1554 let sidecar: Self = serde_json::from_slice(bytes)?;
1555 if !ANCHOR_SIDECAR_VERSIONS_READ.contains(&sidecar.version) {
1564 return Err(serde::de::Error::custom(format!(
1565 "unsupported anchors sidecar version {} (this engine reads versions {}) — \
1566 the file was written by a different engine; upgrade, or remove the sidecar \
1567 to re-record anchors",
1568 sidecar.version,
1569 ANCHOR_SIDECAR_VERSIONS_READ
1570 .iter()
1571 .map(u32::to_string)
1572 .collect::<Vec<_>>()
1573 .join(", ")
1574 )));
1575 }
1576 let mut sidecar = sidecar;
1580 sidecar.version = ANCHOR_SIDECAR_VERSION;
1581 Ok(sidecar)
1582 }
1583
1584 pub fn to_bytes(&self) -> Vec<u8> {
1587 let mut s = serde_json::to_string_pretty(self).expect("anchor sidecar serialises");
1588 s.push('\n');
1589 s.into_bytes()
1590 }
1591
1592 pub fn get(&self, entity_id: &str) -> &[Anchor] {
1594 self.entities
1595 .get(entity_id)
1596 .map(Vec::as_slice)
1597 .unwrap_or(&[])
1598 }
1599
1600 pub fn set(&mut self, entity_id: &str, anchors: Vec<Anchor>) {
1603 if anchors.is_empty() {
1604 self.entities.remove(entity_id);
1605 } else {
1606 self.entities.insert(entity_id.to_string(), anchors);
1607 }
1608 }
1609
1610 pub fn merge(&mut self, entity_id: &str, unsets: &[AnchorUnset], incoming: Vec<Anchor>) {
1622 let mut row = self.entities.remove(entity_id).unwrap_or_default();
1623 row.retain(|a| !unsets.iter().any(|u| u.matches(a)));
1624 for mut anchor in incoming {
1625 match row.iter_mut().find(|e| {
1626 e.artifact == anchor.artifact && e.grain == anchor.grain && e.class == anchor.class
1627 }) {
1628 Some(existing) => {
1629 if anchor.hash.is_none()
1639 && let Some(kept) = existing.hash.clone()
1640 {
1641 anchor.hash = Some(kept);
1642 anchor.hash_source = existing.hash_source;
1643 }
1644 *existing = anchor;
1645 }
1646 None => row.push(anchor),
1647 }
1648 }
1649 if !row.is_empty() {
1650 self.entities.insert(entity_id.to_string(), row);
1651 }
1652 }
1653
1654 pub fn redact_artifact_references(&mut self) {
1662 for anchors in self.entities.values_mut() {
1663 for anchor in anchors {
1664 anchor.artifact = REDACTED_ARTIFACT_SENTINEL.to_string();
1665 for input in &mut anchor.derived_from {
1666 *input = REDACTED_ARTIFACT_SENTINEL.to_string();
1667 }
1668 }
1669 }
1670 }
1671
1672 pub fn validate_artifact_references(&self) -> Result<(), String> {
1678 for (entity_id, anchors) in &self.entities {
1679 for anchor in anchors {
1680 if anchor.artifact.trim().is_empty() {
1681 return Err(format!(
1682 "entity `{entity_id}` carries an anchor with an empty artifact \
1683 reference"
1684 ));
1685 }
1686 if anchor.derived_from.iter().any(|d| d.trim().is_empty()) {
1687 return Err(format!(
1688 "entity `{entity_id}` carries an anchor with an empty \
1689 `derived_from` entry"
1690 ));
1691 }
1692 }
1693 }
1694 Ok(())
1695 }
1696
1697 pub fn remove(&mut self, entity_id: &str) {
1699 self.entities.remove(entity_id);
1700 }
1701
1702 pub fn rename(&mut self, from: &str, to: &str) {
1707 if let Some(anchors) = self.entities.remove(from) {
1708 self.entities.insert(to.to_string(), anchors);
1709 }
1710 }
1711
1712 pub fn is_empty(&self) -> bool {
1714 self.entities.is_empty()
1715 }
1716}
1717
1718#[cfg(test)]
1719mod tests {
1720 use super::*;
1721
1722 #[test]
1727 fn redaction_blanks_references_and_keeps_trust_metadata() {
1728 let mut sidecar = AnchorSidecar::default();
1729 sidecar.set(
1730 "m--alpha",
1731 vec![
1732 Anchor {
1733 artifact: "src/lib.rs".into(),
1734 grain: AnchorGrain::File,
1735 class: AnchorProvenanceClass::Anchored,
1736 at_version: Some(AnchorVersion::Commit("abc123".into())),
1737 hash: Some("h1".into()),
1738 hash_stability: AnchorHashStability::Stable,
1739 derived_from: vec![],
1740 binding: Some("bhash".into()),
1741 source: Some("source-tree".into()),
1742 span_unvalidated: false,
1743 hash_source: None,
1744 last_observed: None,
1745 },
1746 Anchor {
1747 artifact: "docs/summary.md".into(),
1748 grain: AnchorGrain::File,
1749 class: AnchorProvenanceClass::Derived,
1750 at_version: None,
1751 hash: Some("h2".into()),
1752 hash_stability: AnchorHashStability::Unstable,
1753 derived_from: vec!["notes/a.md".into(), "notes/b.md".into()],
1754 binding: None,
1755 source: None,
1756 span_unvalidated: false,
1757 hash_source: None,
1758 last_observed: None,
1759 },
1760 ],
1761 );
1762
1763 sidecar.redact_artifact_references();
1764
1765 let anchors = sidecar.get("m--alpha");
1766 assert_eq!(anchors.len(), 2, "no anchor entry is dropped");
1767 for a in anchors {
1768 assert_eq!(a.artifact, REDACTED_ARTIFACT_SENTINEL);
1769 for d in &a.derived_from {
1770 assert_eq!(d, REDACTED_ARTIFACT_SENTINEL);
1771 }
1772 }
1773 assert_eq!(
1774 anchors[0].at_version,
1775 Some(AnchorVersion::Commit("abc123".into()))
1776 );
1777 assert_eq!(anchors[0].hash.as_deref(), Some("h1"));
1778 assert_eq!(anchors[0].binding.as_deref(), Some("bhash"));
1779 assert_eq!(anchors[0].source.as_deref(), Some("source-tree"));
1780 assert_eq!(anchors[1].class, AnchorProvenanceClass::Derived);
1781 assert_eq!(anchors[1].derived_from.len(), 2, "derivation arity kept");
1782 sidecar.validate_artifact_references().unwrap();
1785 }
1786
1787 #[test]
1791 fn empty_artifact_references_are_refused() {
1792 let mut sidecar = AnchorSidecar::default();
1793 sidecar.set(
1794 "m--alpha",
1795 vec![Anchor {
1796 artifact: "".into(),
1797 grain: AnchorGrain::File,
1798 class: AnchorProvenanceClass::Anchored,
1799 at_version: None,
1800 hash: None,
1801 hash_stability: AnchorHashStability::Stable,
1802 derived_from: vec![],
1803 binding: None,
1804 source: None,
1805 span_unvalidated: false,
1806 hash_source: None,
1807 last_observed: None,
1808 }],
1809 );
1810 assert!(sidecar.validate_artifact_references().is_err());
1811
1812 let mut sidecar = AnchorSidecar::default();
1813 sidecar.set(
1814 "m--beta",
1815 vec![Anchor {
1816 artifact: "docs/x.md".into(),
1817 grain: AnchorGrain::File,
1818 class: AnchorProvenanceClass::Derived,
1819 at_version: None,
1820 hash: None,
1821 hash_stability: AnchorHashStability::Stable,
1822 derived_from: vec![" ".into()],
1823 binding: None,
1824 source: None,
1825 span_unvalidated: false,
1826 hash_source: None,
1827 last_observed: None,
1828 }],
1829 );
1830 assert!(sidecar.validate_artifact_references().is_err());
1831 }
1832
1833 #[test]
1836 fn class_wire_strings_are_stable() {
1837 assert_eq!(AnchorProvenanceClass::Anchored.as_wire(), "anchored");
1838 assert_eq!(AnchorProvenanceClass::Derived.as_wire(), "derived");
1839 assert_eq!(AnchorProvenanceClass::Authored.as_wire(), "authored");
1840 assert_eq!(AnchorProvenanceClass::InformedBy.as_wire(), "informed-by");
1841 for w in AnchorProvenanceClass::WIRE_VALUES {
1842 assert_eq!(AnchorProvenanceClass::from_wire(w).unwrap().as_wire(), *w);
1843 }
1844 assert!(AnchorProvenanceClass::from_wire("bogus").is_none());
1845 }
1846
1847 #[test]
1848 fn grain_wire_strings_are_stable() {
1849 for w in AnchorGrain::WIRE_VALUES {
1850 assert_eq!(AnchorGrain::from_wire(w).unwrap().as_wire(), *w);
1851 }
1852 assert_eq!(
1853 AnchorGrain::WIRE_VALUES,
1854 &["span", "file", "tree", "url", "entity"]
1855 );
1856 assert!(AnchorGrain::from_wire("chunk").is_none());
1857 }
1858
1859 #[test]
1860 fn stability_and_state_wire_strings_are_stable() {
1861 assert_eq!(AnchorHashStability::Stable.as_wire(), "stable");
1862 assert_eq!(AnchorHashStability::Unstable.as_wire(), "unstable");
1863 assert_eq!(AnchorState::Resolves.as_wire(), "resolves");
1864 assert_eq!(AnchorState::Drifted.as_wire(), "drifted");
1865 assert_eq!(AnchorState::Recheck.as_wire(), "recheck");
1866 assert_eq!(AnchorState::Orphaned.as_wire(), "orphaned");
1867 }
1868
1869 #[test]
1870 fn only_anchored_and_derived_are_hash_bearing() {
1871 assert!(AnchorProvenanceClass::Anchored.is_hash_bearing());
1872 assert!(AnchorProvenanceClass::Derived.is_hash_bearing());
1873 assert!(!AnchorProvenanceClass::Authored.is_hash_bearing());
1874 assert!(!AnchorProvenanceClass::InformedBy.is_hash_bearing());
1875 }
1876
1877 #[test]
1880 fn grain_namespace_support_matches_capability_matrix() {
1881 for g in [AnchorGrain::Span, AnchorGrain::File, AnchorGrain::Tree] {
1883 assert!(g.supported_by_namespace("path"));
1884 assert!(g.supported_by_namespace("path+commit"));
1885 assert!(!g.supported_by_namespace("url"));
1886 assert!(!g.supported_by_namespace("entity"));
1887 }
1888 assert!(AnchorGrain::Url.supported_by_namespace("url"));
1889 assert!(AnchorGrain::Url.supported_by_namespace("path"));
1891 assert!(AnchorGrain::Url.supported_by_namespace("entity"));
1892 assert!(AnchorGrain::Entity.supported_by_namespace("entity"));
1893 assert!(!AnchorGrain::Entity.supported_by_namespace("path"));
1894 }
1895
1896 fn valid_input() -> AnchorInput {
1899 AnchorInput {
1900 artifact: Some("src/lib.rs".into()),
1901 grain: Some("file".into()),
1902 class: Some("anchored".into()),
1903 hash_stability: Some("stable".into()),
1904 hash: Some("abc123".into()),
1905 ..Default::default()
1906 }
1907 }
1908
1909 fn span_input(artifact: &str) -> AnchorInput {
1910 AnchorInput {
1911 artifact: Some(artifact.into()),
1912 grain: Some("span".into()),
1913 class: Some("anchored".into()),
1914 ..Default::default()
1915 }
1916 }
1917
1918 #[test]
1922 fn a_span_locator_that_addresses_nothing_is_refused() {
1923 for artifact in [
1924 "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", ] {
1932 let err = span_input(artifact)
1933 .validate(Some(("codebase", "path")))
1934 .expect_err(artifact);
1935 assert!(
1936 matches!(err, AnchorValidationError::SpanLocatorUnusable { .. }),
1937 "{artifact} refused as {err:?}"
1938 );
1939 assert_eq!(err.code(), INVALID_ANCHOR_CODE);
1940 assert!(err.detail().contains_key("expected"), "carries the repair");
1941 }
1942 }
1943
1944 #[test]
1949 fn a_usable_span_locator_still_writes() {
1950 for artifact in [
1951 "src/lib.rs",
1952 "src/lib.rs#L1",
1953 "src/lib.rs#L4-L7",
1954 "logs/ops.md#2026-08-25T00:00:00",
1955 ] {
1956 span_input(artifact)
1957 .validate(Some(("codebase", "path")))
1958 .unwrap_or_else(|e| panic!("{artifact} refused: {e}"));
1959 }
1960 }
1961
1962 #[test]
1966 fn a_span_beyond_supplied_content_is_refused() {
1967 let mut i = span_input("src/lib.rs#L2-L9");
1968 i.content = Some(
1969 "one
1970two
1971three
1972"
1973 .into(),
1974 );
1975 let err = i.validate(Some(("codebase", "path"))).unwrap_err();
1976 match err {
1977 AnchorValidationError::SpanOutsideContent { lines, .. } => assert_eq!(lines, 3),
1978 other => panic!("wrong refusal: {other:?}"),
1979 }
1980
1981 let mut ok = span_input("src/lib.rs#L2-L3");
1982 ok.content = Some(
1983 "one
1984two
1985three
1986"
1987 .into(),
1988 );
1989 let a = ok.validate(Some(("codebase", "path"))).unwrap();
1990 assert!(
1991 !a.span_unvalidated,
1992 "a span checked against content is not unvalidated"
1993 );
1994 }
1995
1996 #[test]
2001 fn an_uncheckable_span_is_accepted_and_recorded_as_unchecked() {
2002 let a = span_input("src/lib.rs#L4-L7")
2003 .validate(Some(("codebase", "path")))
2004 .unwrap();
2005 assert!(a.span_unvalidated);
2006
2007 let whole_file = span_input("src/lib.rs")
2008 .validate(Some(("codebase", "path")))
2009 .unwrap();
2010 assert!(
2011 !whole_file.span_unvalidated,
2012 "no locator addresses the whole artifact, which the existence gate checks"
2013 );
2014
2015 let file_grain = valid_input().validate(Some(("codebase", "path"))).unwrap();
2016 assert!(!file_grain.span_unvalidated, "never set off the span grain");
2017 }
2018
2019 #[test]
2022 fn an_authored_hash_records_that_the_author_pinned_it() {
2023 let a = valid_input().validate(Some(("codebase", "path"))).unwrap();
2024 assert_eq!(a.hash_source, Some(AnchorHashSource::Author));
2025
2026 let mut hashless = valid_input();
2027 hashless.hash = None;
2028 let b = hashless.validate(Some(("codebase", "path"))).unwrap();
2029 assert_eq!(b.hash_source, None, "no baseline, no origin to record");
2030 }
2031
2032 #[test]
2036 fn a_re_pin_keeps_the_baseline_it_did_not_mention() {
2037 let mut sc = AnchorSidecar::default();
2038 let mut pinned = file_anchor("src/a.rs", "h-original");
2039 pinned.hash_source = Some(AnchorHashSource::Author);
2040 sc.set("m--e", vec![pinned]);
2041
2042 let mut repin = file_anchor("src/a.rs", "");
2043 repin.hash = None;
2044 repin.hash_source = None;
2045 sc.merge("m--e", &[], vec![repin]);
2046 let row = &sc.entities["m--e"][0];
2047 assert_eq!(
2048 row.hash.as_deref(),
2049 Some("h-original"),
2050 "the baseline the caller did not mention survives"
2051 );
2052 assert_eq!(row.hash_source, Some(AnchorHashSource::Author));
2053
2054 sc.merge("m--e", &[], vec![file_anchor("src/a.rs", "h-new")]);
2055 assert_eq!(
2056 sc.entities["m--e"][0].hash.as_deref(),
2057 Some("h-new"),
2058 "a supplied hash still replaces"
2059 );
2060
2061 let unset = AnchorUnset {
2064 artifact: "src/a.rs".into(),
2065 grain: None,
2066 class: None,
2067 };
2068 let mut fresh = file_anchor("src/a.rs", "");
2069 fresh.hash = None;
2070 fresh.hash_source = None;
2071 sc.merge("m--e", &[unset], vec![fresh]);
2072 assert_eq!(
2073 sc.entities["m--e"][0].hash, None,
2074 "unset-then-write is how a caller clears a baseline"
2075 );
2076 }
2077
2078 #[test]
2079 fn validate_accepts_a_well_formed_anchor() {
2080 let a = valid_input().validate(Some(("codebase", "path"))).unwrap();
2081 assert_eq!(a.artifact, "src/lib.rs");
2082 assert_eq!(a.grain, AnchorGrain::File);
2083 assert_eq!(a.class, AnchorProvenanceClass::Anchored);
2084 assert_eq!(a.hash.as_deref(), Some("abc123"));
2085 assert_eq!(a.hash_stability, AnchorHashStability::Stable);
2086 }
2087
2088 #[test]
2091 fn validate_defaults_hash_stability_to_stable() {
2092 for grain in ["span", "file", "tree"] {
2093 let mut i = valid_input();
2094 i.grain = Some(grain.into());
2095 i.hash_stability = None;
2096 let a = i.validate(None).unwrap();
2097 assert_eq!(a.hash_stability, AnchorHashStability::Stable, "{grain}");
2098 }
2099 let mut e = valid_input();
2100 e.grain = Some("entity".into());
2101 e.artifact = Some("m--e".into());
2102 e.hash_stability = None;
2103 assert_eq!(
2104 e.validate(None).unwrap().hash_stability,
2105 AnchorHashStability::Stable
2106 );
2107 }
2108
2109 #[test]
2113 fn validate_defaults_url_grain_to_unstable_unless_declared() {
2114 let mut i = valid_input();
2115 i.grain = Some("url".into());
2116 i.artifact = Some("https://example.invalid/doc".into());
2117 i.hash_stability = None;
2118 assert_eq!(
2119 i.validate(None).unwrap().hash_stability,
2120 AnchorHashStability::Unstable
2121 );
2122 i.hash_stability = Some("stable".into());
2123 assert_eq!(
2124 i.validate(None).unwrap().hash_stability,
2125 AnchorHashStability::Stable
2126 );
2127 }
2128
2129 #[test]
2136 fn content_yields_the_prepared_hash_through_the_registry() {
2137 let mut u = valid_input();
2138 u.grain = Some("url".into());
2139 u.artifact = Some("https://example.invalid/doc".into());
2140 u.hash = None;
2141 u.hash_stability = None;
2142 u.content = Some("<p>hello</p>\r\n".into());
2143 let a = u.validate(None).unwrap();
2144 assert_eq!(
2145 a.hash.as_deref(),
2146 Some(crate::preparation::url_prepared_hash(b"<p>hello</p>\n").as_str())
2147 );
2148 assert_eq!(a.hash_stability, AnchorHashStability::Unstable);
2149
2150 let mut f = valid_input();
2151 f.hash = None;
2152 f.content = Some("fn a() {}\n".into());
2153 assert_eq!(
2154 f.validate(None).unwrap().hash.as_deref(),
2155 Some(prepared_content_hash(b"fn a() {}").as_str())
2156 );
2157
2158 let mut both = valid_input();
2159 both.content = Some("x".into());
2160 assert_eq!(
2161 both.validate(None).unwrap_err(),
2162 AnchorValidationError::ContentAndHash
2163 );
2164
2165 let mut ent = valid_input();
2166 ent.grain = Some("entity".into());
2167 ent.artifact = Some("m--e".into());
2168 ent.hash = None;
2169 ent.content = Some("x".into());
2170 let err = ent.validate(None).unwrap_err();
2171 assert_eq!(
2172 err,
2173 AnchorValidationError::ContentNotAcceptedForGrain { grain: "entity" }
2174 );
2175 assert_eq!(err.detail()["field"], "content");
2176
2177 let mut tree = valid_input();
2178 tree.grain = Some("tree".into());
2179 tree.hash = None;
2180 tree.content = Some("x".into());
2181 assert!(matches!(
2182 tree.validate(None).unwrap_err(),
2183 AnchorValidationError::ContentNotAcceptedForGrain { grain: "tree" }
2184 ));
2185
2186 let mut informed = valid_input();
2187 informed.class = Some("informed-by".into());
2188 informed.hash = None;
2189 informed.content = Some("x".into());
2190 assert!(matches!(
2191 informed.validate(None).unwrap_err(),
2192 AnchorValidationError::HashOnNonHashClass { .. }
2193 ));
2194 }
2195
2196 #[test]
2197 fn validate_refuses_unknown_class() {
2198 let mut i = valid_input();
2199 i.class = Some("guessed".into());
2200 let err = i.validate(None).unwrap_err();
2201 assert_eq!(err.code(), INVALID_ANCHOR_CODE);
2202 assert!(matches!(err, AnchorValidationError::UnknownClass { .. }));
2203 assert_eq!(err.detail()["field"], serde_json::json!("class"));
2204 }
2205
2206 #[test]
2207 fn validate_refuses_unknown_grain() {
2208 let mut i = valid_input();
2209 i.grain = Some("paragraph".into());
2210 let err = i.validate(None).unwrap_err();
2211 assert!(matches!(err, AnchorValidationError::UnknownGrain { .. }));
2212 }
2213
2214 #[test]
2215 fn validate_refuses_missing_artifact() {
2216 let mut i = valid_input();
2217 i.artifact = Some(" ".into());
2218 let err = i.validate(None).unwrap_err();
2219 assert!(matches!(err, AnchorValidationError::MissingArtifact));
2220 i.artifact = None;
2221 assert!(matches!(
2222 valid_input_with_artifact(None).validate(None).unwrap_err(),
2223 AnchorValidationError::MissingArtifact
2224 ));
2225 let _ = i;
2226 }
2227
2228 fn valid_input_with_artifact(a: Option<String>) -> AnchorInput {
2229 AnchorInput {
2230 artifact: a,
2231 ..valid_input()
2232 }
2233 }
2234
2235 #[test]
2236 fn validate_refuses_hash_on_non_hash_class() {
2237 let mut i = valid_input();
2238 i.class = Some("authored".into());
2239 let err = i.validate(None).unwrap_err();
2241 assert!(matches!(
2242 err,
2243 AnchorValidationError::HashOnNonHashClass { class: "authored" }
2244 ));
2245 }
2246
2247 #[test]
2248 fn validate_accepts_non_hash_class_without_hash() {
2249 let mut i = valid_input();
2250 i.class = Some("informed-by".into());
2251 i.hash = None;
2252 let a = i.validate(None).unwrap();
2253 assert_eq!(a.class, AnchorProvenanceClass::InformedBy);
2254 assert!(a.hash.is_none());
2255 }
2256
2257 #[test]
2258 fn validate_refuses_grain_unsupported_by_medium_namespace() {
2259 let mut i = valid_input();
2261 i.grain = Some("span".into());
2262 i.class = Some("authored".into());
2263 i.hash = None;
2264 let err = i.validate(Some(("web", "url"))).unwrap_err();
2265 match err {
2266 AnchorValidationError::GrainNamespaceUnsupported {
2267 grain,
2268 anchor_namespace,
2269 ..
2270 } => {
2271 assert_eq!(grain, "span");
2272 assert_eq!(anchor_namespace, "url");
2273 }
2274 other => panic!("expected GrainNamespaceUnsupported, got {other:?}"),
2275 }
2276 }
2277
2278 #[test]
2279 fn validate_skips_namespace_check_without_medium_context() {
2280 let mut i = valid_input();
2282 i.grain = Some("span".into());
2283 assert!(i.validate(None).is_ok());
2284 }
2285
2286 #[test]
2292 fn prepared_hash_is_stable_across_byte_noise() {
2293 let base = prepared_content_hash(b"fn a() {}\nfn b() {}\n");
2294 assert_eq!(prepared_content_hash(b"fn a() {}\r\nfn b() {}\r\n"), base);
2296 assert_eq!(prepared_content_hash(b"fn a() {}\rfn b() {}\r"), base);
2297 assert_eq!(prepared_content_hash(b"fn a() {}\nfn b() {}"), base);
2299 assert_eq!(prepared_content_hash(b"fn a() {}\nfn b() {}\n\n\n"), base);
2300 assert_eq!(
2302 prepared_content_hash("\u{feff}fn a() {}\nfn b() {}\n".as_bytes()),
2303 base
2304 );
2305 assert_ne!(prepared_content_hash(b"fn a() {}\nfn c() {}\n"), base);
2307 assert_eq!(base.len(), 16);
2309 assert!(
2310 base.chars()
2311 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
2312 );
2313 }
2314
2315 #[test]
2318 fn prepared_hash_preserves_interior_whitespace() {
2319 assert_ne!(
2320 prepared_content_hash(b"line one \nline two\n"),
2321 prepared_content_hash(b"line one\nline two\n")
2322 );
2323 }
2324
2325 #[test]
2328 fn prepared_hash_hashes_binary_bytes_raw() {
2329 let bin_a = [0xff_u8, 0xfe, 0x00, 0x0d, 0x0a];
2330 let bin_b = [0xff_u8, 0xfe, 0x00, 0x0a];
2331 assert_ne!(prepared_content_hash(&bin_a), prepared_content_hash(&bin_b));
2332 assert_eq!(prepared_content_hash(&bin_a), prepared_content_hash(&bin_a));
2334 }
2335
2336 fn anchor(
2339 class: AnchorProvenanceClass,
2340 hash: Option<&str>,
2341 stab: AnchorHashStability,
2342 ) -> Anchor {
2343 Anchor {
2344 artifact: "src/lib.rs".into(),
2345 grain: AnchorGrain::File,
2346 class,
2347 at_version: None,
2348 hash: hash.map(str::to_string),
2349 hash_stability: stab,
2350 derived_from: Vec::new(),
2351 binding: None,
2352 source: None,
2353 span_unvalidated: false,
2354 hash_source: None,
2355 last_observed: None,
2356 }
2357 }
2358
2359 #[test]
2360 fn resolves_when_hash_matches() {
2361 let a = anchor(
2362 AnchorProvenanceClass::Anchored,
2363 Some("h1"),
2364 AnchorHashStability::Stable,
2365 );
2366 let obs = ArtifactObservation::Present {
2367 current_hash: Some("h1".into()),
2368 };
2369 assert_eq!(resolve_anchor(&a, &obs), AnchorState::Resolves);
2370 }
2371
2372 #[test]
2373 fn stable_hash_break_drifts_unstable_rechecks() {
2374 let stable = anchor(
2375 AnchorProvenanceClass::Anchored,
2376 Some("h1"),
2377 AnchorHashStability::Stable,
2378 );
2379 let unstable = anchor(
2380 AnchorProvenanceClass::Anchored,
2381 Some("h1"),
2382 AnchorHashStability::Unstable,
2383 );
2384 let obs = ArtifactObservation::Present {
2385 current_hash: Some("h2".into()),
2386 };
2387 assert_eq!(resolve_anchor(&stable, &obs), AnchorState::Drifted);
2388 assert_eq!(resolve_anchor(&unstable, &obs), AnchorState::Recheck);
2389 }
2390
2391 #[test]
2392 fn absent_artifact_is_orphaned() {
2393 let a = anchor(
2394 AnchorProvenanceClass::Anchored,
2395 Some("h1"),
2396 AnchorHashStability::Stable,
2397 );
2398 assert_eq!(
2399 resolve_anchor(&a, &ArtifactObservation::Absent),
2400 AnchorState::Orphaned
2401 );
2402 }
2403
2404 #[test]
2405 fn non_hash_classes_never_drift() {
2406 for class in [
2407 AnchorProvenanceClass::Authored,
2408 AnchorProvenanceClass::InformedBy,
2409 ] {
2410 let a = anchor(class, None, AnchorHashStability::Stable);
2411 let obs = ArtifactObservation::Present {
2414 current_hash: Some("whatever".into()),
2415 };
2416 assert_eq!(resolve_anchor(&a, &obs), AnchorState::Resolves);
2417 assert_eq!(
2419 resolve_anchor(&a, &ArtifactObservation::Absent),
2420 AnchorState::Orphaned
2421 );
2422 }
2423 }
2424
2425 #[test]
2426 fn unavailable_hash_rechecks_not_drifts() {
2427 let a = anchor(
2428 AnchorProvenanceClass::Anchored,
2429 Some("h1"),
2430 AnchorHashStability::Stable,
2431 );
2432 let obs = ArtifactObservation::Present { current_hash: None };
2433 assert_eq!(resolve_anchor(&a, &obs), AnchorState::Recheck);
2434 }
2435
2436 #[test]
2439 fn composition_counts_classes_grains_and_tree_fanout() {
2440 let anchors = vec![
2441 Anchor {
2442 artifact: "a.rs".into(),
2443 grain: AnchorGrain::File,
2444 class: AnchorProvenanceClass::Anchored,
2445 at_version: None,
2446 hash: Some("h".into()),
2447 hash_stability: AnchorHashStability::Stable,
2448 derived_from: Vec::new(),
2449 binding: None,
2450 source: None,
2451 span_unvalidated: false,
2452 hash_source: None,
2453 last_observed: None,
2454 },
2455 Anchor {
2456 artifact: "src/".into(),
2457 grain: AnchorGrain::Tree,
2458 class: AnchorProvenanceClass::Derived,
2459 at_version: None,
2460 hash: Some("t".into()),
2461 hash_stability: AnchorHashStability::Stable,
2462 derived_from: vec!["a.rs".into(), "b.rs".into()],
2463 binding: None,
2464 source: None,
2465 span_unvalidated: false,
2466 hash_source: None,
2467 last_observed: None,
2468 },
2469 ];
2470 let comp = compose_entity_anchors(&anchors);
2471 assert_eq!(comp.by_class["anchored"], 1);
2472 assert_eq!(comp.by_class["derived"], 1);
2473 assert_eq!(comp.by_grain["file"], 1);
2474 assert_eq!(comp.by_grain["tree"], 1);
2475 assert_eq!(comp.tree_grain_artifacts, vec!["src/".to_string()]);
2477 assert_eq!(
2478 comp.derived_inputs,
2479 vec![vec!["a.rs".to_string(), "b.rs".to_string()]]
2480 );
2481 }
2482
2483 #[test]
2486 fn sidecar_round_trips_and_prunes_empty() {
2487 let mut sc = AnchorSidecar::default();
2488 assert!(sc.is_empty());
2489 let a = anchor(
2490 AnchorProvenanceClass::Anchored,
2491 Some("h1"),
2492 AnchorHashStability::Stable,
2493 );
2494 sc.set("specs--x", vec![a.clone()]);
2495 assert_eq!(sc.get("specs--x").len(), 1);
2496
2497 let bytes = sc.to_bytes();
2498 let round = AnchorSidecar::from_bytes(&bytes).unwrap();
2499 assert_eq!(round, sc);
2500
2501 sc.set("specs--x", vec![]);
2503 assert!(sc.is_empty());
2504 assert!(sc.get("specs--x").is_empty());
2505 }
2506
2507 fn file_anchor(artifact: &str, hash: &str) -> Anchor {
2510 Anchor {
2511 artifact: artifact.into(),
2512 grain: AnchorGrain::File,
2513 class: AnchorProvenanceClass::Anchored,
2514 at_version: None,
2515 hash: Some(hash.into()),
2516 hash_stability: AnchorHashStability::Stable,
2517 derived_from: Vec::new(),
2518 binding: None,
2519 source: None,
2520 span_unvalidated: false,
2521 hash_source: None,
2522 last_observed: None,
2523 }
2524 }
2525
2526 #[test]
2529 fn merge_appends_new_triple_without_touching_others() {
2530 let mut sc = AnchorSidecar::default();
2531 sc.set(
2532 "m--e",
2533 vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
2534 );
2535 sc.merge("m--e", &[], vec![file_anchor("c.rs", "h-c")]);
2536 let row = sc.get("m--e");
2537 assert_eq!(row.len(), 3);
2538 assert_eq!(row[0], file_anchor("a.rs", "h-a"));
2539 assert_eq!(row[1], file_anchor("b.rs", "h-b"));
2540 assert_eq!(row[2], file_anchor("c.rs", "h-c"));
2541 }
2542
2543 #[test]
2547 fn merge_replaces_same_triple_in_place() {
2548 let mut sc = AnchorSidecar::default();
2549 sc.set(
2550 "m--e",
2551 vec![file_anchor("a.rs", "h-old"), file_anchor("b.rs", "h-b")],
2552 );
2553 sc.merge("m--e", &[], vec![file_anchor("a.rs", "h-new")]);
2554 let row = sc.get("m--e");
2555 assert_eq!(row.len(), 2);
2556 assert_eq!(row[0], file_anchor("a.rs", "h-new"));
2557 assert_eq!(row[1], file_anchor("b.rs", "h-b"));
2558 }
2559
2560 #[test]
2564 fn merge_treats_grain_and_class_as_identity() {
2565 let mut sc = AnchorSidecar::default();
2566 sc.set("m--e", vec![file_anchor("a.rs", "h-a")]);
2567 let mut span = file_anchor("a.rs", "h-span");
2568 span.grain = AnchorGrain::Span;
2569 let mut informed = file_anchor("a.rs", "h-a");
2570 informed.class = AnchorProvenanceClass::InformedBy;
2571 informed.hash = None;
2572 sc.merge("m--e", &[], vec![span, informed]);
2573 assert_eq!(sc.get("m--e").len(), 3);
2574 }
2575
2576 #[test]
2579 fn merge_full_resend_and_empty_are_noops() {
2580 let mut sc = AnchorSidecar::default();
2581 sc.set(
2582 "m--e",
2583 vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
2584 );
2585 let before = sc.to_bytes();
2586 sc.merge(
2587 "m--e",
2588 &[],
2589 vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
2590 );
2591 assert_eq!(sc.to_bytes(), before, "full re-send is byte-stable");
2592 sc.merge("m--e", &[], Vec::new());
2593 assert_eq!(sc.to_bytes(), before, "empty merge is a no-op");
2594 }
2595
2596 #[test]
2600 fn unset_selects_by_artifact_with_optional_narrowing() {
2601 let mut span = file_anchor("a.rs", "h-span");
2602 span.grain = AnchorGrain::Span;
2603 let mut sc = AnchorSidecar::default();
2604 sc.set(
2605 "m--e",
2606 vec![
2607 file_anchor("a.rs", "h-a"),
2608 span.clone(),
2609 file_anchor("b.rs", "h-b"),
2610 ],
2611 );
2612
2613 let narrowed = AnchorUnset {
2615 artifact: "a.rs".into(),
2616 grain: Some(AnchorGrain::Span),
2617 class: None,
2618 };
2619 sc.merge("m--e", &[narrowed], Vec::new());
2620 assert_eq!(
2621 sc.get("m--e"),
2622 &[file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")]
2623 );
2624
2625 let missing = AnchorUnset {
2627 artifact: "never-there.rs".into(),
2628 grain: None,
2629 class: None,
2630 };
2631 sc.merge("m--e", &[missing], Vec::new());
2632 assert_eq!(sc.get("m--e").len(), 2);
2633
2634 let bare = AnchorUnset {
2636 artifact: "a.rs".into(),
2637 grain: None,
2638 class: None,
2639 };
2640 sc.merge("m--e", &[bare], Vec::new());
2641 assert_eq!(sc.get("m--e"), &[file_anchor("b.rs", "h-b")]);
2642 }
2643
2644 #[test]
2648 fn unset_applies_before_merge() {
2649 let mut span = file_anchor("a.rs", "h-span");
2650 span.grain = AnchorGrain::Span;
2651 let mut sc = AnchorSidecar::default();
2652 sc.set("m--e", vec![file_anchor("a.rs", "h-old"), span]);
2653 let bare = AnchorUnset {
2654 artifact: "a.rs".into(),
2655 grain: None,
2656 class: None,
2657 };
2658 sc.merge("m--e", &[bare], vec![file_anchor("a.rs", "h-new")]);
2659 assert_eq!(sc.get("m--e"), &[file_anchor("a.rs", "h-new")]);
2660 }
2661
2662 #[test]
2665 fn merge_prunes_row_emptied_by_unset() {
2666 let mut sc = AnchorSidecar::default();
2667 sc.set("m--e", vec![file_anchor("a.rs", "h-a")]);
2668 let bare = AnchorUnset {
2669 artifact: "a.rs".into(),
2670 grain: None,
2671 class: None,
2672 };
2673 sc.merge("m--e", &[bare], Vec::new());
2674 assert!(sc.is_empty());
2675 assert!(!sc.to_bytes().windows(5).any(|w| w == b"m--e\""));
2676 }
2677
2678 #[test]
2681 fn unset_input_validates_typed() {
2682 let ok = AnchorUnsetInput {
2683 artifact: Some(" a.rs ".into()),
2684 grain: Some("span".into()),
2685 class: None,
2686 }
2687 .validate()
2688 .unwrap();
2689 assert_eq!(ok.artifact, "a.rs");
2690 assert_eq!(ok.grain, Some(AnchorGrain::Span));
2691 assert_eq!(ok.class, None);
2692
2693 let missing = AnchorUnsetInput::default().validate().unwrap_err();
2694 assert!(matches!(missing, AnchorValidationError::MissingArtifact));
2695 assert_eq!(missing.code(), INVALID_ANCHOR_CODE);
2696
2697 let bad_grain = AnchorUnsetInput {
2698 artifact: Some("a.rs".into()),
2699 grain: Some("paragraph".into()),
2700 class: None,
2701 }
2702 .validate()
2703 .unwrap_err();
2704 assert!(matches!(
2705 bad_grain,
2706 AnchorValidationError::UnknownGrain { .. }
2707 ));
2708
2709 let bad_class = AnchorUnsetInput {
2710 artifact: Some("a.rs".into()),
2711 grain: None,
2712 class: Some("guessed".into()),
2713 }
2714 .validate()
2715 .unwrap_err();
2716 assert!(matches!(
2717 bad_class,
2718 AnchorValidationError::UnknownClass { .. }
2719 ));
2720 }
2721
2722 #[test]
2723 fn sidecar_rename_leaves_zero_rows_under_old_id() {
2724 let mut sc = AnchorSidecar::default();
2725 sc.set(
2726 "specs--old",
2727 vec![anchor(
2728 AnchorProvenanceClass::Anchored,
2729 Some("h"),
2730 AnchorHashStability::Stable,
2731 )],
2732 );
2733 sc.rename("specs--old", "specs--new");
2734 assert!(sc.get("specs--old").is_empty());
2735 assert_eq!(sc.get("specs--new").len(), 1);
2736 }
2737
2738 #[test]
2739 fn sidecar_remove_drops_entity_anchors() {
2740 let mut sc = AnchorSidecar::default();
2741 sc.set(
2742 "specs--gone",
2743 vec![anchor(
2744 AnchorProvenanceClass::Anchored,
2745 Some("h"),
2746 AnchorHashStability::Stable,
2747 )],
2748 );
2749 sc.remove("specs--gone");
2750 assert!(sc.get("specs--gone").is_empty());
2751 sc.remove("specs--gone");
2753 }
2754
2755 #[test]
2756 fn empty_bytes_parse_as_empty_sidecar() {
2757 assert!(AnchorSidecar::from_bytes(b"").unwrap().is_empty());
2758 assert!(AnchorSidecar::from_bytes(b" \n ").unwrap().is_empty());
2759 }
2760
2761 #[test]
2762 fn anchor_json_shape_omits_empty_optionals() {
2763 let a = anchor(
2764 AnchorProvenanceClass::Anchored,
2765 Some("h1"),
2766 AnchorHashStability::Stable,
2767 );
2768 let v = serde_json::to_value(&a).unwrap();
2769 assert_eq!(v["artifact"], "src/lib.rs");
2770 assert_eq!(v["grain"], "file");
2771 assert_eq!(v["class"], "anchored");
2772 assert_eq!(v["hash"], "h1");
2773 assert_eq!(v["hash_stability"], "stable");
2774 assert!(v.get("at_version").is_none());
2776 assert!(v.get("derived_from").is_none());
2777 assert!(v.get("binding").is_none());
2778 }
2779
2780 #[test]
2781 fn anchor_version_serialises_tagged() {
2782 let a = Anchor {
2783 at_version: Some(AnchorVersion::Commit("deadbeef".into())),
2784 ..anchor(
2785 AnchorProvenanceClass::Anchored,
2786 Some("h"),
2787 AnchorHashStability::Stable,
2788 )
2789 };
2790 let v = serde_json::to_value(&a).unwrap();
2791 assert_eq!(v["at_version"]["kind"], "commit");
2792 assert_eq!(v["at_version"]["value"], "deadbeef");
2793 }
2794
2795 #[test]
2799 fn validate_source_carried_absent_or_refused_when_empty() {
2800 let mut input = AnchorInput {
2801 artifact: Some("src/lib.rs".into()),
2802 grain: Some("file".into()),
2803 class: Some("anchored".into()),
2804 ..Default::default()
2805 };
2806 assert_eq!(
2807 input.validate(None).unwrap().source,
2808 None,
2809 "absent stays absent"
2810 );
2811
2812 input.source = Some(" api-docs ".into());
2813 assert_eq!(
2814 input.validate(None).unwrap().source.as_deref(),
2815 Some("api-docs"),
2816 "non-empty name is carried (trimmed)"
2817 );
2818
2819 input.source = Some(" ".into());
2820 let err = input.validate(None).unwrap_err();
2821 assert_eq!(err.code(), INVALID_ANCHOR_CODE);
2822 assert!(matches!(err, AnchorValidationError::EmptySource));
2823 assert_eq!(
2824 err.detail().get("field"),
2825 Some(&serde_json::json!("source"))
2826 );
2827 }
2828
2829 #[test]
2833 fn source_is_additive_on_the_persisted_shape() {
2834 let pre_plan = r#"{
2835 "artifact": "src/lib.rs",
2836 "grain": "file",
2837 "class": "anchored",
2838 "hash_stability": "stable"
2839 }"#;
2840 let a: Anchor = serde_json::from_str(pre_plan).expect("pre-plan anchor loads");
2841 assert_eq!(a.source, None, "no backfill, no default");
2842
2843 let sourced = Anchor {
2844 source: Some("api-docs".into()),
2845 ..a
2846 };
2847 let json = serde_json::to_string(&sourced).unwrap();
2848 let back: Anchor = serde_json::from_str(&json).unwrap();
2849 assert_eq!(back.source.as_deref(), Some("api-docs"));
2850 }
2851
2852 #[test]
2855 fn sidecar_v1_loads_and_upgrades_in_memory_v3_refuses() {
2856 let v1 = br#"{"version":1,"entities":{"m--e":[{"artifact":"https://x.test/a","grain":"url","class":"informed-by","hash_stability":"unstable"}]}}"#;
2857 let sc = AnchorSidecar::from_bytes(v1).expect("version 1 loads");
2858 assert_eq!(sc.version, ANCHOR_SIDECAR_VERSION, "upgraded in memory");
2859 assert_eq!(sc.get("m--e").len(), 1);
2860 assert!(sc.get("m--e")[0].last_observed.is_none(), "rows unchanged");
2861 let rewritten = String::from_utf8(sc.to_bytes()).unwrap();
2862 assert!(rewritten.contains("\"version\": 2"), "{rewritten}");
2863
2864 let v3 = br#"{"version":3,"entities":{}}"#;
2865 let err = AnchorSidecar::from_bytes(v3).expect_err("unknown higher version refuses");
2866 assert!(
2867 err.to_string()
2868 .contains("unsupported anchors sidecar version 3"),
2869 "{err}"
2870 );
2871 }
2872
2873 #[test]
2874 fn last_observed_round_trips_and_is_absent_when_none() {
2875 let mut a = valid_input().validate(None).unwrap();
2876 let json = serde_json::to_value(&a).unwrap();
2877 assert!(json.get("last_observed").is_none());
2878 a.last_observed = Some(AnchorObservation {
2879 at: "2026-09-01T10:00:00Z".into(),
2880 hash: Some("abc".into()),
2881 state: AnchorState::Resolves,
2882 });
2883 let json = serde_json::to_value(&a).unwrap();
2884 assert_eq!(json["last_observed"]["state"], "resolves");
2885 let back: Anchor = serde_json::from_value(json).unwrap();
2886 assert_eq!(back, a);
2887 }
2888
2889 #[test]
2890 fn url_grain_is_admitted_beside_a_path_medium_and_path_grains_refuse_a_url_artifact() {
2891 let mut i = valid_input();
2892 i.grain = Some("url".into());
2893 i.artifact = Some("https://example.org/doc.pdf".into());
2894 i.class = Some("anchored".into());
2895 i.hash = None;
2896 i.content = Some("the document text".into());
2897 i.hash_stability = None;
2898 let a = i
2899 .validate(Some(("filesystem", "path")))
2900 .expect("url beside a path medium is legal");
2901 assert_eq!(a.grain, AnchorGrain::Url);
2902 assert_eq!(a.hash_source, Some(AnchorHashSource::Author));
2903 assert_eq!(
2904 a.hash_stability,
2905 AnchorHashStability::Unstable,
2906 "url default"
2907 );
2908
2909 for grain in ["span", "file", "tree"] {
2910 let mut i = valid_input();
2911 i.grain = Some(grain.into());
2912 i.artifact = Some("https://example.org/doc.pdf#L1-L3".into());
2913 i.class = Some("informed-by".into());
2914 i.hash = None;
2915 let err = i.validate(Some(("filesystem", "path"))).unwrap_err();
2916 assert!(
2917 matches!(&err, AnchorValidationError::PathGrainOnUrlArtifact { grain: g, .. } if *g == grain),
2918 "{grain}: {err:?}"
2919 );
2920 assert_eq!(err.code(), INVALID_ANCHOR_CODE);
2921 assert!(err.to_string().contains("never enters a path namespace"));
2922 }
2923 assert!(looks_like_url("https://a.b/c"));
2924 assert!(looks_like_url("file://x"));
2925 assert!(!looks_like_url("src/main.rs"));
2926 assert!(!looks_like_url("://nope"));
2927 assert!(!looks_like_url("http://"));
2928 }
2929
2930 #[test]
2931 fn supplied_observations_validate_all_or_nothing() {
2932 let now = "2026-09-02T12:00:00Z";
2933 let rows = vec![
2934 SuppliedObservationInput {
2935 artifact: Some("https://a.test/1".into()),
2936 hash: Some("h1".into()),
2937 ..Default::default()
2938 },
2939 SuppliedObservationInput {
2940 artifact: Some("https://a.test/2".into()),
2941 content: Some("body\r\n".into()),
2942 observed_at: Some("2026-08-01".into()),
2943 ..Default::default()
2944 },
2945 SuppliedObservationInput {
2946 artifact: Some("https://a.test/3".into()),
2947 absent: Some(true),
2948 ..Default::default()
2949 },
2950 ];
2951 let ok = validate_supplied_observations(&rows, now).unwrap();
2952 assert_eq!(ok.len(), 3);
2953 assert_eq!(ok["https://a.test/1"].at, now);
2954 assert_eq!(
2955 ok["https://a.test/2"].outcome,
2956 SuppliedOutcome::Present {
2957 hash: prepared_content_hash(b"body\r\n")
2958 },
2959 "content hashes under the write path's canonicalization"
2960 );
2961 assert_eq!(ok["https://a.test/2"].at, "2026-08-01");
2962 assert_eq!(ok["https://a.test/3"].outcome, SuppliedOutcome::Absent);
2963
2964 let bad = vec![SuppliedObservationInput {
2966 artifact: Some("https://a.test/1".into()),
2967 hash: Some("h".into()),
2968 content: Some("c".into()),
2969 ..Default::default()
2970 }];
2971 let err = validate_supplied_observations(&bad, now).unwrap_err();
2972 assert!(matches!(
2973 err,
2974 ObservationValidationError::OutcomeAmbiguous { row: 1, .. }
2975 ));
2976 assert_eq!(err.code(), INVALID_OBSERVATION_CODE);
2977 let bad = vec![SuppliedObservationInput {
2979 artifact: Some("https://a.test/1".into()),
2980 ..Default::default()
2981 }];
2982 assert!(matches!(
2983 validate_supplied_observations(&bad, now).unwrap_err(),
2984 ObservationValidationError::OutcomeAmbiguous { .. }
2985 ));
2986 let bad = vec![SuppliedObservationInput {
2987 artifact: Some("https://a.test/1".into()),
2988 hash: Some("h".into()),
2989 observed_at: Some("yesterday".into()),
2990 ..Default::default()
2991 }];
2992 assert!(matches!(
2993 validate_supplied_observations(&bad, now).unwrap_err(),
2994 ObservationValidationError::BadTimestamp { .. }
2995 ));
2996 let dup = vec![rows[0].clone(), rows[0].clone()];
2997 assert!(matches!(
2998 validate_supplied_observations(&dup, now).unwrap_err(),
2999 ObservationValidationError::DuplicateArtifact {
3000 first: 1,
3001 second: 2,
3002 ..
3003 }
3004 ));
3005 assert!(matches!(
3006 validate_supplied_observations(&[SuppliedObservationInput::default()], now)
3007 .unwrap_err(),
3008 ObservationValidationError::MissingArtifact { row: 1 }
3009 ));
3010 }
3011
3012 #[test]
3013 fn days_between_ages_by_civil_date() {
3014 assert_eq!(days_between("2026-08-01", "2026-09-02T00:00:00Z"), Some(32));
3015 assert_eq!(
3016 days_between("2026-09-02T23:59:59Z", "2026-09-02T00:00:00Z"),
3017 Some(0)
3018 );
3019 assert_eq!(days_between("2026-09-03", "2026-09-02"), Some(0), "floored");
3020 assert_eq!(days_between("garbage", "2026-09-02"), None);
3021 assert_eq!(iso_days_since_epoch("1970-01-01"), Some(0));
3022 assert_eq!(iso_days_since_epoch("2000-03-01"), Some(11017));
3023 }
3024}