1use std::collections::BTreeMap;
52
53use serde::{Deserialize, Serialize};
54
55pub const ANCHOR_SIDECAR_PATH: &str = ".memstead/anchors.json";
60
61pub const ANCHOR_SIDECAR_VERSION: u32 = 1;
63
64pub const INVALID_ANCHOR_CODE: &str = "INVALID_ANCHOR";
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "kebab-case")]
88pub enum AnchorProvenanceClass {
89 Anchored,
90 Derived,
91 Authored,
92 InformedBy,
93}
94
95impl AnchorProvenanceClass {
96 pub const WIRE_VALUES: &'static [&'static str] =
99 &["anchored", "derived", "authored", "informed-by"];
100
101 pub fn as_wire(&self) -> &'static str {
103 match self {
104 AnchorProvenanceClass::Anchored => "anchored",
105 AnchorProvenanceClass::Derived => "derived",
106 AnchorProvenanceClass::Authored => "authored",
107 AnchorProvenanceClass::InformedBy => "informed-by",
108 }
109 }
110
111 pub fn from_wire(s: &str) -> Option<Self> {
114 match s {
115 "anchored" => Some(AnchorProvenanceClass::Anchored),
116 "derived" => Some(AnchorProvenanceClass::Derived),
117 "authored" => Some(AnchorProvenanceClass::Authored),
118 "informed-by" => Some(AnchorProvenanceClass::InformedBy),
119 _ => None,
120 }
121 }
122
123 pub fn is_hash_bearing(&self) -> bool {
129 matches!(
130 self,
131 AnchorProvenanceClass::Anchored | AnchorProvenanceClass::Derived
132 )
133 }
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "lowercase")]
150pub enum AnchorGrain {
151 Span,
152 File,
153 Tree,
154 Url,
155 Entity,
156}
157
158impl AnchorGrain {
159 pub const WIRE_VALUES: &'static [&'static str] = &["span", "file", "tree", "url", "entity"];
161
162 pub fn as_wire(&self) -> &'static str {
164 match self {
165 AnchorGrain::Span => "span",
166 AnchorGrain::File => "file",
167 AnchorGrain::Tree => "tree",
168 AnchorGrain::Url => "url",
169 AnchorGrain::Entity => "entity",
170 }
171 }
172
173 pub fn from_wire(s: &str) -> Option<Self> {
175 match s {
176 "span" => Some(AnchorGrain::Span),
177 "file" => Some(AnchorGrain::File),
178 "tree" => Some(AnchorGrain::Tree),
179 "url" => Some(AnchorGrain::Url),
180 "entity" => Some(AnchorGrain::Entity),
181 _ => None,
182 }
183 }
184
185 pub fn supported_by_namespace(&self, anchor_namespace: &str) -> bool {
194 let path_shaped = matches!(anchor_namespace, "path" | "path+commit");
195 match self {
196 AnchorGrain::Span | AnchorGrain::File | AnchorGrain::Tree => path_shaped,
197 AnchorGrain::Url => anchor_namespace == "url",
198 AnchorGrain::Entity => anchor_namespace == "entity",
199 }
200 }
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(rename_all = "lowercase")]
216pub enum AnchorHashStability {
217 Stable,
218 Unstable,
219}
220
221impl AnchorHashStability {
222 pub const WIRE_VALUES: &'static [&'static str] = &["stable", "unstable"];
224
225 pub fn as_wire(&self) -> &'static str {
227 match self {
228 AnchorHashStability::Stable => "stable",
229 AnchorHashStability::Unstable => "unstable",
230 }
231 }
232
233 pub fn from_wire(s: &str) -> Option<Self> {
235 match s {
236 "stable" => Some(AnchorHashStability::Stable),
237 "unstable" => Some(AnchorHashStability::Unstable),
238 _ => None,
239 }
240 }
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(tag = "kind", content = "value", rename_all = "lowercase")]
257pub enum AnchorVersion {
258 Commit(String),
260 Snapshot(String),
262 Etag(String),
264}
265
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277pub struct Anchor {
278 pub artifact: String,
282 pub grain: AnchorGrain,
284 pub class: AnchorProvenanceClass,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub at_version: Option<AnchorVersion>,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub hash: Option<String>,
295 pub hash_stability: AnchorHashStability,
298 #[serde(default, skip_serializing_if = "Vec::is_empty")]
301 pub derived_from: Vec<String>,
302 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub binding: Option<String>,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub source: Option<String>,
316}
317
318#[derive(Debug, Clone, Default, Serialize, Deserialize)]
328pub struct AnchorInput {
329 #[serde(default)]
330 pub artifact: Option<String>,
331 #[serde(default)]
332 pub grain: Option<String>,
333 #[serde(default)]
334 pub class: Option<String>,
335 #[serde(default)]
336 pub at_version: Option<AnchorVersion>,
337 #[serde(default)]
338 pub hash: Option<String>,
339 #[serde(default)]
340 pub hash_stability: Option<String>,
341 #[serde(default)]
342 pub derived_from: Option<Vec<String>>,
343 #[serde(default)]
344 pub binding: Option<String>,
345 #[serde(default)]
346 pub source: Option<String>,
347}
348
349#[derive(Debug, Clone, Default, Serialize, Deserialize)]
357pub struct AnchorUnsetInput {
358 #[serde(default)]
359 pub artifact: Option<String>,
360 #[serde(default)]
361 pub grain: Option<String>,
362 #[serde(default)]
363 pub class: Option<String>,
364}
365
366impl AnchorUnsetInput {
367 pub fn validate(&self) -> Result<AnchorUnset, AnchorValidationError> {
371 let artifact = self
372 .artifact
373 .as_deref()
374 .map(str::trim)
375 .filter(|s| !s.is_empty())
376 .map(str::to_string)
377 .ok_or(AnchorValidationError::MissingArtifact)?;
378 let grain = match self.grain.as_deref() {
379 None => None,
380 Some(s) => Some(AnchorGrain::from_wire(s).ok_or_else(|| {
381 AnchorValidationError::UnknownGrain {
382 got: Some(s.to_string()),
383 allowed: AnchorGrain::WIRE_VALUES,
384 }
385 })?),
386 };
387 let class = match self.class.as_deref() {
388 None => None,
389 Some(s) => Some(AnchorProvenanceClass::from_wire(s).ok_or_else(|| {
390 AnchorValidationError::UnknownClass {
391 got: Some(s.to_string()),
392 allowed: AnchorProvenanceClass::WIRE_VALUES,
393 }
394 })?),
395 };
396 Ok(AnchorUnset {
397 artifact,
398 grain,
399 class,
400 })
401 }
402}
403
404#[derive(Debug, Clone, PartialEq, Eq)]
410pub struct AnchorUnset {
411 pub artifact: String,
413 pub grain: Option<AnchorGrain>,
415 pub class: Option<AnchorProvenanceClass>,
417}
418
419impl AnchorUnset {
420 pub fn matches(&self, anchor: &Anchor) -> bool {
422 anchor.artifact == self.artifact
423 && self.grain.is_none_or(|g| anchor.grain == g)
424 && self.class.is_none_or(|c| anchor.class == c)
425 }
426}
427
428#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
432pub enum AnchorValidationError {
433 #[error("unknown anchor provenance class {got:?}; allowed: {}", allowed.join(", "))]
435 UnknownClass {
436 got: Option<String>,
437 allowed: &'static [&'static str],
438 },
439 #[error("unknown anchor grain {got:?}; allowed: {}", allowed.join(", "))]
441 UnknownGrain {
442 got: Option<String>,
443 allowed: &'static [&'static str],
444 },
445 #[error("unknown anchor hash stability {got:?}; allowed: {}", allowed.join(", "))]
447 UnknownHashStability {
448 got: String,
449 allowed: &'static [&'static str],
450 },
451 #[error("anchor is missing its artifact reference")]
453 MissingArtifact,
454 #[error("anchor class '{class}' carries no hash semantics — a content hash is not permitted")]
457 HashOnNonHashClass { class: &'static str },
458 #[error("anchor `source`, when present, must be a non-empty source name")]
462 EmptySource,
463 #[error(
469 "anchor `source` {got:?} is not declared by the anchor's producing binding; declared sources: {}",
470 declared.join(", ")
471 )]
472 SourceNotDeclared { got: String, declared: Vec<String> },
473 #[error(
476 "anchor grain '{grain}' is unsupported by a '{medium_type}' medium: its \
477 '{anchor_namespace}' namespace does not admit that grain"
478 )]
479 GrainNamespaceUnsupported {
480 grain: &'static str,
481 medium_type: String,
482 anchor_namespace: &'static str,
483 },
484}
485
486impl AnchorValidationError {
487 pub fn code(&self) -> &'static str {
489 INVALID_ANCHOR_CODE
490 }
491
492 pub fn detail(&self) -> BTreeMap<String, serde_json::Value> {
495 let mut d = BTreeMap::new();
496 match self {
497 AnchorValidationError::UnknownClass { got, allowed } => {
498 d.insert("field".into(), "class".into());
499 d.insert("got".into(), serde_json::json!(got));
500 d.insert("allowed".into(), serde_json::json!(allowed));
501 }
502 AnchorValidationError::UnknownGrain { got, allowed } => {
503 d.insert("field".into(), "grain".into());
504 d.insert("got".into(), serde_json::json!(got));
505 d.insert("allowed".into(), serde_json::json!(allowed));
506 }
507 AnchorValidationError::UnknownHashStability { got, allowed } => {
508 d.insert("field".into(), "hash_stability".into());
509 d.insert("got".into(), serde_json::json!(got));
510 d.insert("allowed".into(), serde_json::json!(allowed));
511 }
512 AnchorValidationError::MissingArtifact => {
513 d.insert("field".into(), "artifact".into());
514 }
515 AnchorValidationError::EmptySource => {
516 d.insert("field".into(), "source".into());
517 }
518 AnchorValidationError::SourceNotDeclared { got, declared } => {
519 d.insert("field".into(), "source".into());
520 d.insert("got".into(), serde_json::json!(got));
521 d.insert("declared".into(), serde_json::json!(declared));
522 }
523 AnchorValidationError::HashOnNonHashClass { class } => {
524 d.insert("field".into(), "hash".into());
525 d.insert("class".into(), serde_json::json!(class));
526 }
527 AnchorValidationError::GrainNamespaceUnsupported {
528 grain,
529 medium_type,
530 anchor_namespace,
531 } => {
532 d.insert("field".into(), "grain".into());
533 d.insert("grain".into(), serde_json::json!(grain));
534 d.insert("medium_type".into(), serde_json::json!(medium_type));
535 d.insert(
536 "anchor_namespace".into(),
537 serde_json::json!(anchor_namespace),
538 );
539 }
540 }
541 d
542 }
543}
544
545impl AnchorInput {
546 pub fn validate(&self, medium: Option<(&str, &str)>) -> Result<Anchor, AnchorValidationError> {
565 let class = match self
566 .class
567 .as_deref()
568 .and_then(AnchorProvenanceClass::from_wire)
569 {
570 Some(c) => c,
571 None => {
572 return Err(AnchorValidationError::UnknownClass {
573 got: self.class.clone(),
574 allowed: AnchorProvenanceClass::WIRE_VALUES,
575 });
576 }
577 };
578 let grain = match self.grain.as_deref().and_then(AnchorGrain::from_wire) {
579 Some(g) => g,
580 None => {
581 return Err(AnchorValidationError::UnknownGrain {
582 got: self.grain.clone(),
583 allowed: AnchorGrain::WIRE_VALUES,
584 });
585 }
586 };
587
588 let artifact = self
589 .artifact
590 .as_deref()
591 .map(str::trim)
592 .filter(|s| !s.is_empty())
593 .map(str::to_string)
594 .ok_or(AnchorValidationError::MissingArtifact)?;
595
596 let hash_stability = match self.hash_stability.as_deref() {
599 None => AnchorHashStability::Stable,
600 Some(s) => AnchorHashStability::from_wire(s).ok_or_else(|| {
601 AnchorValidationError::UnknownHashStability {
602 got: s.to_string(),
603 allowed: AnchorHashStability::WIRE_VALUES,
604 }
605 })?,
606 };
607
608 let hash = self
610 .hash
611 .as_deref()
612 .map(str::trim)
613 .filter(|s| !s.is_empty())
614 .map(str::to_string);
615 if hash.is_some() && !class.is_hash_bearing() {
616 return Err(AnchorValidationError::HashOnNonHashClass {
617 class: class.as_wire(),
618 });
619 }
620
621 if let Some((medium_type, namespace)) = medium
623 && !grain.supported_by_namespace(namespace)
624 {
625 let anchor_namespace = match namespace {
628 "path" => "path",
629 "path+commit" => "path+commit",
630 "entity" => "entity",
631 "url" => "url",
632 _ => "path",
633 };
634 return Err(AnchorValidationError::GrainNamespaceUnsupported {
635 grain: grain.as_wire(),
636 medium_type: medium_type.to_string(),
637 anchor_namespace,
638 });
639 }
640
641 let source = match self.source.as_deref() {
646 None => None,
647 Some(raw) => {
648 let trimmed = raw.trim();
649 if trimmed.is_empty() {
650 return Err(AnchorValidationError::EmptySource);
651 }
652 Some(trimmed.to_string())
653 }
654 };
655
656 Ok(Anchor {
657 artifact,
658 grain,
659 class,
660 at_version: self.at_version.clone(),
661 hash,
662 hash_stability,
663 derived_from: self.derived_from.clone().unwrap_or_default(),
664 binding: self
665 .binding
666 .as_deref()
667 .map(str::trim)
668 .filter(|s| !s.is_empty())
669 .map(str::to_string),
670 source,
671 })
672 }
673}
674
675pub fn prepared_content_hash(bytes: &[u8]) -> String {
702 use sha2::{Digest as _, Sha256};
703 let digest = match std::str::from_utf8(bytes) {
704 Ok(text) => {
705 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
706 let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
707 Sha256::digest(normalized.trim_end_matches('\n').as_bytes())
708 }
709 Err(_) => Sha256::digest(bytes),
710 };
711 crate::hex_lower(&digest)[..16].to_string()
712}
713
714#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
719pub struct ObservedArtifactHash {
720 pub entity: String,
722 pub artifact: String,
724 pub hash: String,
726}
727
728#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
734#[serde(rename_all = "lowercase")]
735pub enum AnchorState {
736 Resolves,
739 Drifted,
742 Recheck,
746 Orphaned,
749}
750
751impl AnchorState {
752 pub fn as_wire(&self) -> &'static str {
754 match self {
755 AnchorState::Resolves => "resolves",
756 AnchorState::Drifted => "drifted",
757 AnchorState::Recheck => "recheck",
758 AnchorState::Orphaned => "orphaned",
759 }
760 }
761}
762
763#[derive(Debug, Clone, PartialEq, Eq)]
765pub enum ArtifactObservation {
766 Absent,
768 Present { current_hash: Option<String> },
772}
773
774pub fn resolve_anchor(anchor: &Anchor, observation: &ArtifactObservation) -> AnchorState {
786 let current_hash = match observation {
787 ArtifactObservation::Absent => return AnchorState::Orphaned,
788 ArtifactObservation::Present { current_hash } => current_hash,
789 };
790 if !anchor.class.is_hash_bearing() {
791 return AnchorState::Resolves;
792 }
793 match (&anchor.hash, current_hash) {
794 (Some(recorded), Some(current)) if recorded == current => AnchorState::Resolves,
795 (Some(_), Some(_)) => match anchor.hash_stability {
796 AnchorHashStability::Stable => AnchorState::Drifted,
797 AnchorHashStability::Unstable => AnchorState::Recheck,
798 },
799 _ => AnchorState::Recheck,
801 }
802}
803
804#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
810pub struct EntityAnchorComposition {
811 pub by_class: BTreeMap<String, usize>,
813 pub by_grain: BTreeMap<String, usize>,
815 pub derived_inputs: Vec<Vec<String>>,
818 pub tree_grain_artifacts: Vec<String>,
823}
824
825pub fn compose_entity_anchors(anchors: &[Anchor]) -> EntityAnchorComposition {
828 let mut comp = EntityAnchorComposition::default();
829 for a in anchors {
830 *comp
831 .by_class
832 .entry(a.class.as_wire().to_string())
833 .or_insert(0) += 1;
834 *comp
835 .by_grain
836 .entry(a.grain.as_wire().to_string())
837 .or_insert(0) += 1;
838 if a.class == AnchorProvenanceClass::Derived {
839 comp.derived_inputs.push(a.derived_from.clone());
840 }
841 if a.grain == AnchorGrain::Tree {
842 comp.tree_grain_artifacts.push(a.artifact.clone());
843 }
844 }
845 comp
846}
847
848#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
860pub struct AnchorSidecar {
861 pub version: u32,
863 #[serde(default)]
866 pub entities: BTreeMap<String, Vec<Anchor>>,
867}
868
869impl Default for AnchorSidecar {
870 fn default() -> Self {
871 Self {
872 version: ANCHOR_SIDECAR_VERSION,
873 entities: BTreeMap::new(),
874 }
875 }
876}
877
878impl AnchorSidecar {
879 pub fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
882 if bytes.iter().all(u8::is_ascii_whitespace) {
883 return Ok(Self::default());
884 }
885 serde_json::from_slice(bytes)
886 }
887
888 pub fn to_bytes(&self) -> Vec<u8> {
891 let mut s = serde_json::to_string_pretty(self).expect("anchor sidecar serialises");
892 s.push('\n');
893 s.into_bytes()
894 }
895
896 pub fn get(&self, entity_id: &str) -> &[Anchor] {
898 self.entities
899 .get(entity_id)
900 .map(Vec::as_slice)
901 .unwrap_or(&[])
902 }
903
904 pub fn set(&mut self, entity_id: &str, anchors: Vec<Anchor>) {
907 if anchors.is_empty() {
908 self.entities.remove(entity_id);
909 } else {
910 self.entities.insert(entity_id.to_string(), anchors);
911 }
912 }
913
914 pub fn merge(&mut self, entity_id: &str, unsets: &[AnchorUnset], incoming: Vec<Anchor>) {
926 let mut row = self.entities.remove(entity_id).unwrap_or_default();
927 row.retain(|a| !unsets.iter().any(|u| u.matches(a)));
928 for anchor in incoming {
929 match row.iter_mut().find(|e| {
930 e.artifact == anchor.artifact && e.grain == anchor.grain && e.class == anchor.class
931 }) {
932 Some(existing) => *existing = anchor,
933 None => row.push(anchor),
934 }
935 }
936 if !row.is_empty() {
937 self.entities.insert(entity_id.to_string(), row);
938 }
939 }
940
941 pub fn remove(&mut self, entity_id: &str) {
943 self.entities.remove(entity_id);
944 }
945
946 pub fn rename(&mut self, from: &str, to: &str) {
951 if let Some(anchors) = self.entities.remove(from) {
952 self.entities.insert(to.to_string(), anchors);
953 }
954 }
955
956 pub fn is_empty(&self) -> bool {
958 self.entities.is_empty()
959 }
960}
961
962#[cfg(test)]
963mod tests {
964 use super::*;
965
966 #[test]
969 fn class_wire_strings_are_stable() {
970 assert_eq!(AnchorProvenanceClass::Anchored.as_wire(), "anchored");
971 assert_eq!(AnchorProvenanceClass::Derived.as_wire(), "derived");
972 assert_eq!(AnchorProvenanceClass::Authored.as_wire(), "authored");
973 assert_eq!(AnchorProvenanceClass::InformedBy.as_wire(), "informed-by");
974 for w in AnchorProvenanceClass::WIRE_VALUES {
975 assert_eq!(AnchorProvenanceClass::from_wire(w).unwrap().as_wire(), *w);
976 }
977 assert!(AnchorProvenanceClass::from_wire("bogus").is_none());
978 }
979
980 #[test]
981 fn grain_wire_strings_are_stable() {
982 for w in AnchorGrain::WIRE_VALUES {
983 assert_eq!(AnchorGrain::from_wire(w).unwrap().as_wire(), *w);
984 }
985 assert_eq!(
986 AnchorGrain::WIRE_VALUES,
987 &["span", "file", "tree", "url", "entity"]
988 );
989 assert!(AnchorGrain::from_wire("chunk").is_none());
990 }
991
992 #[test]
993 fn stability_and_state_wire_strings_are_stable() {
994 assert_eq!(AnchorHashStability::Stable.as_wire(), "stable");
995 assert_eq!(AnchorHashStability::Unstable.as_wire(), "unstable");
996 assert_eq!(AnchorState::Resolves.as_wire(), "resolves");
997 assert_eq!(AnchorState::Drifted.as_wire(), "drifted");
998 assert_eq!(AnchorState::Recheck.as_wire(), "recheck");
999 assert_eq!(AnchorState::Orphaned.as_wire(), "orphaned");
1000 }
1001
1002 #[test]
1003 fn only_anchored_and_derived_are_hash_bearing() {
1004 assert!(AnchorProvenanceClass::Anchored.is_hash_bearing());
1005 assert!(AnchorProvenanceClass::Derived.is_hash_bearing());
1006 assert!(!AnchorProvenanceClass::Authored.is_hash_bearing());
1007 assert!(!AnchorProvenanceClass::InformedBy.is_hash_bearing());
1008 }
1009
1010 #[test]
1013 fn grain_namespace_support_matches_capability_matrix() {
1014 for g in [AnchorGrain::Span, AnchorGrain::File, AnchorGrain::Tree] {
1016 assert!(g.supported_by_namespace("path"));
1017 assert!(g.supported_by_namespace("path+commit"));
1018 assert!(!g.supported_by_namespace("url"));
1019 assert!(!g.supported_by_namespace("entity"));
1020 }
1021 assert!(AnchorGrain::Url.supported_by_namespace("url"));
1022 assert!(!AnchorGrain::Url.supported_by_namespace("path"));
1023 assert!(AnchorGrain::Entity.supported_by_namespace("entity"));
1024 assert!(!AnchorGrain::Entity.supported_by_namespace("path"));
1025 }
1026
1027 fn valid_input() -> AnchorInput {
1030 AnchorInput {
1031 artifact: Some("src/lib.rs".into()),
1032 grain: Some("file".into()),
1033 class: Some("anchored".into()),
1034 hash_stability: Some("stable".into()),
1035 hash: Some("abc123".into()),
1036 ..Default::default()
1037 }
1038 }
1039
1040 #[test]
1041 fn validate_accepts_a_well_formed_anchor() {
1042 let a = valid_input().validate(Some(("codebase", "path"))).unwrap();
1043 assert_eq!(a.artifact, "src/lib.rs");
1044 assert_eq!(a.grain, AnchorGrain::File);
1045 assert_eq!(a.class, AnchorProvenanceClass::Anchored);
1046 assert_eq!(a.hash.as_deref(), Some("abc123"));
1047 assert_eq!(a.hash_stability, AnchorHashStability::Stable);
1048 }
1049
1050 #[test]
1051 fn validate_defaults_hash_stability_to_stable() {
1052 let mut i = valid_input();
1053 i.hash_stability = None;
1054 let a = i.validate(None).unwrap();
1055 assert_eq!(a.hash_stability, AnchorHashStability::Stable);
1056 }
1057
1058 #[test]
1059 fn validate_refuses_unknown_class() {
1060 let mut i = valid_input();
1061 i.class = Some("guessed".into());
1062 let err = i.validate(None).unwrap_err();
1063 assert_eq!(err.code(), INVALID_ANCHOR_CODE);
1064 assert!(matches!(err, AnchorValidationError::UnknownClass { .. }));
1065 assert_eq!(err.detail()["field"], serde_json::json!("class"));
1066 }
1067
1068 #[test]
1069 fn validate_refuses_unknown_grain() {
1070 let mut i = valid_input();
1071 i.grain = Some("paragraph".into());
1072 let err = i.validate(None).unwrap_err();
1073 assert!(matches!(err, AnchorValidationError::UnknownGrain { .. }));
1074 }
1075
1076 #[test]
1077 fn validate_refuses_missing_artifact() {
1078 let mut i = valid_input();
1079 i.artifact = Some(" ".into());
1080 let err = i.validate(None).unwrap_err();
1081 assert!(matches!(err, AnchorValidationError::MissingArtifact));
1082 i.artifact = None;
1083 assert!(matches!(
1084 valid_input_with_artifact(None).validate(None).unwrap_err(),
1085 AnchorValidationError::MissingArtifact
1086 ));
1087 let _ = i;
1088 }
1089
1090 fn valid_input_with_artifact(a: Option<String>) -> AnchorInput {
1091 AnchorInput {
1092 artifact: a,
1093 ..valid_input()
1094 }
1095 }
1096
1097 #[test]
1098 fn validate_refuses_hash_on_non_hash_class() {
1099 let mut i = valid_input();
1100 i.class = Some("authored".into());
1101 let err = i.validate(None).unwrap_err();
1103 assert!(matches!(
1104 err,
1105 AnchorValidationError::HashOnNonHashClass { class: "authored" }
1106 ));
1107 }
1108
1109 #[test]
1110 fn validate_accepts_non_hash_class_without_hash() {
1111 let mut i = valid_input();
1112 i.class = Some("informed-by".into());
1113 i.hash = None;
1114 let a = i.validate(None).unwrap();
1115 assert_eq!(a.class, AnchorProvenanceClass::InformedBy);
1116 assert!(a.hash.is_none());
1117 }
1118
1119 #[test]
1120 fn validate_refuses_grain_unsupported_by_medium_namespace() {
1121 let mut i = valid_input();
1123 i.grain = Some("span".into());
1124 i.class = Some("authored".into());
1125 i.hash = None;
1126 let err = i.validate(Some(("web", "url"))).unwrap_err();
1127 match err {
1128 AnchorValidationError::GrainNamespaceUnsupported {
1129 grain,
1130 anchor_namespace,
1131 ..
1132 } => {
1133 assert_eq!(grain, "span");
1134 assert_eq!(anchor_namespace, "url");
1135 }
1136 other => panic!("expected GrainNamespaceUnsupported, got {other:?}"),
1137 }
1138 }
1139
1140 #[test]
1141 fn validate_skips_namespace_check_without_medium_context() {
1142 let mut i = valid_input();
1144 i.grain = Some("span".into());
1145 assert!(i.validate(None).is_ok());
1146 }
1147
1148 #[test]
1154 fn prepared_hash_is_stable_across_byte_noise() {
1155 let base = prepared_content_hash(b"fn a() {}\nfn b() {}\n");
1156 assert_eq!(prepared_content_hash(b"fn a() {}\r\nfn b() {}\r\n"), base);
1158 assert_eq!(prepared_content_hash(b"fn a() {}\rfn b() {}\r"), base);
1159 assert_eq!(prepared_content_hash(b"fn a() {}\nfn b() {}"), base);
1161 assert_eq!(prepared_content_hash(b"fn a() {}\nfn b() {}\n\n\n"), base);
1162 assert_eq!(
1164 prepared_content_hash("\u{feff}fn a() {}\nfn b() {}\n".as_bytes()),
1165 base
1166 );
1167 assert_ne!(prepared_content_hash(b"fn a() {}\nfn c() {}\n"), base);
1169 assert_eq!(base.len(), 16);
1171 assert!(
1172 base.chars()
1173 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
1174 );
1175 }
1176
1177 #[test]
1180 fn prepared_hash_preserves_interior_whitespace() {
1181 assert_ne!(
1182 prepared_content_hash(b"line one \nline two\n"),
1183 prepared_content_hash(b"line one\nline two\n")
1184 );
1185 }
1186
1187 #[test]
1190 fn prepared_hash_hashes_binary_bytes_raw() {
1191 let bin_a = [0xff_u8, 0xfe, 0x00, 0x0d, 0x0a];
1192 let bin_b = [0xff_u8, 0xfe, 0x00, 0x0a];
1193 assert_ne!(prepared_content_hash(&bin_a), prepared_content_hash(&bin_b));
1194 assert_eq!(prepared_content_hash(&bin_a), prepared_content_hash(&bin_a));
1196 }
1197
1198 fn anchor(
1201 class: AnchorProvenanceClass,
1202 hash: Option<&str>,
1203 stab: AnchorHashStability,
1204 ) -> Anchor {
1205 Anchor {
1206 artifact: "src/lib.rs".into(),
1207 grain: AnchorGrain::File,
1208 class,
1209 at_version: None,
1210 hash: hash.map(str::to_string),
1211 hash_stability: stab,
1212 derived_from: Vec::new(),
1213 binding: None,
1214 source: None,
1215 }
1216 }
1217
1218 #[test]
1219 fn resolves_when_hash_matches() {
1220 let a = anchor(
1221 AnchorProvenanceClass::Anchored,
1222 Some("h1"),
1223 AnchorHashStability::Stable,
1224 );
1225 let obs = ArtifactObservation::Present {
1226 current_hash: Some("h1".into()),
1227 };
1228 assert_eq!(resolve_anchor(&a, &obs), AnchorState::Resolves);
1229 }
1230
1231 #[test]
1232 fn stable_hash_break_drifts_unstable_rechecks() {
1233 let stable = anchor(
1234 AnchorProvenanceClass::Anchored,
1235 Some("h1"),
1236 AnchorHashStability::Stable,
1237 );
1238 let unstable = anchor(
1239 AnchorProvenanceClass::Anchored,
1240 Some("h1"),
1241 AnchorHashStability::Unstable,
1242 );
1243 let obs = ArtifactObservation::Present {
1244 current_hash: Some("h2".into()),
1245 };
1246 assert_eq!(resolve_anchor(&stable, &obs), AnchorState::Drifted);
1247 assert_eq!(resolve_anchor(&unstable, &obs), AnchorState::Recheck);
1248 }
1249
1250 #[test]
1251 fn absent_artifact_is_orphaned() {
1252 let a = anchor(
1253 AnchorProvenanceClass::Anchored,
1254 Some("h1"),
1255 AnchorHashStability::Stable,
1256 );
1257 assert_eq!(
1258 resolve_anchor(&a, &ArtifactObservation::Absent),
1259 AnchorState::Orphaned
1260 );
1261 }
1262
1263 #[test]
1264 fn non_hash_classes_never_drift() {
1265 for class in [
1266 AnchorProvenanceClass::Authored,
1267 AnchorProvenanceClass::InformedBy,
1268 ] {
1269 let a = anchor(class, None, AnchorHashStability::Stable);
1270 let obs = ArtifactObservation::Present {
1273 current_hash: Some("whatever".into()),
1274 };
1275 assert_eq!(resolve_anchor(&a, &obs), AnchorState::Resolves);
1276 assert_eq!(
1278 resolve_anchor(&a, &ArtifactObservation::Absent),
1279 AnchorState::Orphaned
1280 );
1281 }
1282 }
1283
1284 #[test]
1285 fn unavailable_hash_rechecks_not_drifts() {
1286 let a = anchor(
1287 AnchorProvenanceClass::Anchored,
1288 Some("h1"),
1289 AnchorHashStability::Stable,
1290 );
1291 let obs = ArtifactObservation::Present { current_hash: None };
1292 assert_eq!(resolve_anchor(&a, &obs), AnchorState::Recheck);
1293 }
1294
1295 #[test]
1298 fn composition_counts_classes_grains_and_tree_fanout() {
1299 let anchors = vec![
1300 Anchor {
1301 artifact: "a.rs".into(),
1302 grain: AnchorGrain::File,
1303 class: AnchorProvenanceClass::Anchored,
1304 at_version: None,
1305 hash: Some("h".into()),
1306 hash_stability: AnchorHashStability::Stable,
1307 derived_from: Vec::new(),
1308 binding: None,
1309 source: None,
1310 },
1311 Anchor {
1312 artifact: "src/".into(),
1313 grain: AnchorGrain::Tree,
1314 class: AnchorProvenanceClass::Derived,
1315 at_version: None,
1316 hash: Some("t".into()),
1317 hash_stability: AnchorHashStability::Stable,
1318 derived_from: vec!["a.rs".into(), "b.rs".into()],
1319 binding: None,
1320 source: None,
1321 },
1322 ];
1323 let comp = compose_entity_anchors(&anchors);
1324 assert_eq!(comp.by_class["anchored"], 1);
1325 assert_eq!(comp.by_class["derived"], 1);
1326 assert_eq!(comp.by_grain["file"], 1);
1327 assert_eq!(comp.by_grain["tree"], 1);
1328 assert_eq!(comp.tree_grain_artifacts, vec!["src/".to_string()]);
1330 assert_eq!(
1331 comp.derived_inputs,
1332 vec![vec!["a.rs".to_string(), "b.rs".to_string()]]
1333 );
1334 }
1335
1336 #[test]
1339 fn sidecar_round_trips_and_prunes_empty() {
1340 let mut sc = AnchorSidecar::default();
1341 assert!(sc.is_empty());
1342 let a = anchor(
1343 AnchorProvenanceClass::Anchored,
1344 Some("h1"),
1345 AnchorHashStability::Stable,
1346 );
1347 sc.set("specs--x", vec![a.clone()]);
1348 assert_eq!(sc.get("specs--x").len(), 1);
1349
1350 let bytes = sc.to_bytes();
1351 let round = AnchorSidecar::from_bytes(&bytes).unwrap();
1352 assert_eq!(round, sc);
1353
1354 sc.set("specs--x", vec![]);
1356 assert!(sc.is_empty());
1357 assert!(sc.get("specs--x").is_empty());
1358 }
1359
1360 fn file_anchor(artifact: &str, hash: &str) -> Anchor {
1363 Anchor {
1364 artifact: artifact.into(),
1365 grain: AnchorGrain::File,
1366 class: AnchorProvenanceClass::Anchored,
1367 at_version: None,
1368 hash: Some(hash.into()),
1369 hash_stability: AnchorHashStability::Stable,
1370 derived_from: Vec::new(),
1371 binding: None,
1372 source: None,
1373 }
1374 }
1375
1376 #[test]
1379 fn merge_appends_new_triple_without_touching_others() {
1380 let mut sc = AnchorSidecar::default();
1381 sc.set(
1382 "m--e",
1383 vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
1384 );
1385 sc.merge("m--e", &[], vec![file_anchor("c.rs", "h-c")]);
1386 let row = sc.get("m--e");
1387 assert_eq!(row.len(), 3);
1388 assert_eq!(row[0], file_anchor("a.rs", "h-a"));
1389 assert_eq!(row[1], file_anchor("b.rs", "h-b"));
1390 assert_eq!(row[2], file_anchor("c.rs", "h-c"));
1391 }
1392
1393 #[test]
1397 fn merge_replaces_same_triple_in_place() {
1398 let mut sc = AnchorSidecar::default();
1399 sc.set(
1400 "m--e",
1401 vec![file_anchor("a.rs", "h-old"), file_anchor("b.rs", "h-b")],
1402 );
1403 sc.merge("m--e", &[], vec![file_anchor("a.rs", "h-new")]);
1404 let row = sc.get("m--e");
1405 assert_eq!(row.len(), 2);
1406 assert_eq!(row[0], file_anchor("a.rs", "h-new"));
1407 assert_eq!(row[1], file_anchor("b.rs", "h-b"));
1408 }
1409
1410 #[test]
1414 fn merge_treats_grain_and_class_as_identity() {
1415 let mut sc = AnchorSidecar::default();
1416 sc.set("m--e", vec![file_anchor("a.rs", "h-a")]);
1417 let mut span = file_anchor("a.rs", "h-span");
1418 span.grain = AnchorGrain::Span;
1419 let mut informed = file_anchor("a.rs", "h-a");
1420 informed.class = AnchorProvenanceClass::InformedBy;
1421 informed.hash = None;
1422 sc.merge("m--e", &[], vec![span, informed]);
1423 assert_eq!(sc.get("m--e").len(), 3);
1424 }
1425
1426 #[test]
1429 fn merge_full_resend_and_empty_are_noops() {
1430 let mut sc = AnchorSidecar::default();
1431 sc.set(
1432 "m--e",
1433 vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
1434 );
1435 let before = sc.to_bytes();
1436 sc.merge(
1437 "m--e",
1438 &[],
1439 vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
1440 );
1441 assert_eq!(sc.to_bytes(), before, "full re-send is byte-stable");
1442 sc.merge("m--e", &[], Vec::new());
1443 assert_eq!(sc.to_bytes(), before, "empty merge is a no-op");
1444 }
1445
1446 #[test]
1450 fn unset_selects_by_artifact_with_optional_narrowing() {
1451 let mut span = file_anchor("a.rs", "h-span");
1452 span.grain = AnchorGrain::Span;
1453 let mut sc = AnchorSidecar::default();
1454 sc.set(
1455 "m--e",
1456 vec![
1457 file_anchor("a.rs", "h-a"),
1458 span.clone(),
1459 file_anchor("b.rs", "h-b"),
1460 ],
1461 );
1462
1463 let narrowed = AnchorUnset {
1465 artifact: "a.rs".into(),
1466 grain: Some(AnchorGrain::Span),
1467 class: None,
1468 };
1469 sc.merge("m--e", &[narrowed], Vec::new());
1470 assert_eq!(
1471 sc.get("m--e"),
1472 &[file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")]
1473 );
1474
1475 let missing = AnchorUnset {
1477 artifact: "never-there.rs".into(),
1478 grain: None,
1479 class: None,
1480 };
1481 sc.merge("m--e", &[missing], Vec::new());
1482 assert_eq!(sc.get("m--e").len(), 2);
1483
1484 let bare = AnchorUnset {
1486 artifact: "a.rs".into(),
1487 grain: None,
1488 class: None,
1489 };
1490 sc.merge("m--e", &[bare], Vec::new());
1491 assert_eq!(sc.get("m--e"), &[file_anchor("b.rs", "h-b")]);
1492 }
1493
1494 #[test]
1498 fn unset_applies_before_merge() {
1499 let mut span = file_anchor("a.rs", "h-span");
1500 span.grain = AnchorGrain::Span;
1501 let mut sc = AnchorSidecar::default();
1502 sc.set("m--e", vec![file_anchor("a.rs", "h-old"), span]);
1503 let bare = AnchorUnset {
1504 artifact: "a.rs".into(),
1505 grain: None,
1506 class: None,
1507 };
1508 sc.merge("m--e", &[bare], vec![file_anchor("a.rs", "h-new")]);
1509 assert_eq!(sc.get("m--e"), &[file_anchor("a.rs", "h-new")]);
1510 }
1511
1512 #[test]
1515 fn merge_prunes_row_emptied_by_unset() {
1516 let mut sc = AnchorSidecar::default();
1517 sc.set("m--e", vec![file_anchor("a.rs", "h-a")]);
1518 let bare = AnchorUnset {
1519 artifact: "a.rs".into(),
1520 grain: None,
1521 class: None,
1522 };
1523 sc.merge("m--e", &[bare], Vec::new());
1524 assert!(sc.is_empty());
1525 assert!(!sc.to_bytes().windows(5).any(|w| w == b"m--e\""));
1526 }
1527
1528 #[test]
1531 fn unset_input_validates_typed() {
1532 let ok = AnchorUnsetInput {
1533 artifact: Some(" a.rs ".into()),
1534 grain: Some("span".into()),
1535 class: None,
1536 }
1537 .validate()
1538 .unwrap();
1539 assert_eq!(ok.artifact, "a.rs");
1540 assert_eq!(ok.grain, Some(AnchorGrain::Span));
1541 assert_eq!(ok.class, None);
1542
1543 let missing = AnchorUnsetInput::default().validate().unwrap_err();
1544 assert!(matches!(missing, AnchorValidationError::MissingArtifact));
1545 assert_eq!(missing.code(), INVALID_ANCHOR_CODE);
1546
1547 let bad_grain = AnchorUnsetInput {
1548 artifact: Some("a.rs".into()),
1549 grain: Some("paragraph".into()),
1550 class: None,
1551 }
1552 .validate()
1553 .unwrap_err();
1554 assert!(matches!(
1555 bad_grain,
1556 AnchorValidationError::UnknownGrain { .. }
1557 ));
1558
1559 let bad_class = AnchorUnsetInput {
1560 artifact: Some("a.rs".into()),
1561 grain: None,
1562 class: Some("guessed".into()),
1563 }
1564 .validate()
1565 .unwrap_err();
1566 assert!(matches!(
1567 bad_class,
1568 AnchorValidationError::UnknownClass { .. }
1569 ));
1570 }
1571
1572 #[test]
1573 fn sidecar_rename_leaves_zero_rows_under_old_id() {
1574 let mut sc = AnchorSidecar::default();
1575 sc.set(
1576 "specs--old",
1577 vec![anchor(
1578 AnchorProvenanceClass::Anchored,
1579 Some("h"),
1580 AnchorHashStability::Stable,
1581 )],
1582 );
1583 sc.rename("specs--old", "specs--new");
1584 assert!(sc.get("specs--old").is_empty());
1585 assert_eq!(sc.get("specs--new").len(), 1);
1586 }
1587
1588 #[test]
1589 fn sidecar_remove_drops_entity_anchors() {
1590 let mut sc = AnchorSidecar::default();
1591 sc.set(
1592 "specs--gone",
1593 vec![anchor(
1594 AnchorProvenanceClass::Anchored,
1595 Some("h"),
1596 AnchorHashStability::Stable,
1597 )],
1598 );
1599 sc.remove("specs--gone");
1600 assert!(sc.get("specs--gone").is_empty());
1601 sc.remove("specs--gone");
1603 }
1604
1605 #[test]
1606 fn empty_bytes_parse_as_empty_sidecar() {
1607 assert!(AnchorSidecar::from_bytes(b"").unwrap().is_empty());
1608 assert!(AnchorSidecar::from_bytes(b" \n ").unwrap().is_empty());
1609 }
1610
1611 #[test]
1612 fn anchor_json_shape_omits_empty_optionals() {
1613 let a = anchor(
1614 AnchorProvenanceClass::Anchored,
1615 Some("h1"),
1616 AnchorHashStability::Stable,
1617 );
1618 let v = serde_json::to_value(&a).unwrap();
1619 assert_eq!(v["artifact"], "src/lib.rs");
1620 assert_eq!(v["grain"], "file");
1621 assert_eq!(v["class"], "anchored");
1622 assert_eq!(v["hash"], "h1");
1623 assert_eq!(v["hash_stability"], "stable");
1624 assert!(v.get("at_version").is_none());
1626 assert!(v.get("derived_from").is_none());
1627 assert!(v.get("binding").is_none());
1628 }
1629
1630 #[test]
1631 fn anchor_version_serialises_tagged() {
1632 let a = Anchor {
1633 at_version: Some(AnchorVersion::Commit("deadbeef".into())),
1634 ..anchor(
1635 AnchorProvenanceClass::Anchored,
1636 Some("h"),
1637 AnchorHashStability::Stable,
1638 )
1639 };
1640 let v = serde_json::to_value(&a).unwrap();
1641 assert_eq!(v["at_version"]["kind"], "commit");
1642 assert_eq!(v["at_version"]["value"], "deadbeef");
1643 }
1644
1645 #[test]
1649 fn validate_source_carried_absent_or_refused_when_empty() {
1650 let mut input = AnchorInput {
1651 artifact: Some("src/lib.rs".into()),
1652 grain: Some("file".into()),
1653 class: Some("anchored".into()),
1654 ..Default::default()
1655 };
1656 assert_eq!(
1657 input.validate(None).unwrap().source,
1658 None,
1659 "absent stays absent"
1660 );
1661
1662 input.source = Some(" api-docs ".into());
1663 assert_eq!(
1664 input.validate(None).unwrap().source.as_deref(),
1665 Some("api-docs"),
1666 "non-empty name is carried (trimmed)"
1667 );
1668
1669 input.source = Some(" ".into());
1670 let err = input.validate(None).unwrap_err();
1671 assert_eq!(err.code(), INVALID_ANCHOR_CODE);
1672 assert!(matches!(err, AnchorValidationError::EmptySource));
1673 assert_eq!(
1674 err.detail().get("field"),
1675 Some(&serde_json::json!("source"))
1676 );
1677 }
1678
1679 #[test]
1683 fn source_is_additive_on_the_persisted_shape() {
1684 let pre_plan = r#"{
1685 "artifact": "src/lib.rs",
1686 "grain": "file",
1687 "class": "anchored",
1688 "hash_stability": "stable"
1689 }"#;
1690 let a: Anchor = serde_json::from_str(pre_plan).expect("pre-plan anchor loads");
1691 assert_eq!(a.source, None, "no backfill, no default");
1692
1693 let sourced = Anchor {
1694 source: Some("api-docs".into()),
1695 ..a
1696 };
1697 let json = serde_json::to_string(&sourced).unwrap();
1698 let back: Anchor = serde_json::from_str(&json).unwrap();
1699 assert_eq!(back.source.as_deref(), Some("api-docs"));
1700 }
1701}