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}
308
309#[derive(Debug, Clone, Default, Serialize, Deserialize)]
319pub struct AnchorInput {
320 #[serde(default)]
321 pub artifact: Option<String>,
322 #[serde(default)]
323 pub grain: Option<String>,
324 #[serde(default)]
325 pub class: Option<String>,
326 #[serde(default)]
327 pub at_version: Option<AnchorVersion>,
328 #[serde(default)]
329 pub hash: Option<String>,
330 #[serde(default)]
331 pub hash_stability: Option<String>,
332 #[serde(default)]
333 pub derived_from: Option<Vec<String>>,
334 #[serde(default)]
335 pub binding: Option<String>,
336}
337
338#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
342pub enum AnchorValidationError {
343 #[error("unknown anchor provenance class {got:?}; allowed: {}", allowed.join(", "))]
345 UnknownClass {
346 got: Option<String>,
347 allowed: &'static [&'static str],
348 },
349 #[error("unknown anchor grain {got:?}; allowed: {}", allowed.join(", "))]
351 UnknownGrain {
352 got: Option<String>,
353 allowed: &'static [&'static str],
354 },
355 #[error("unknown anchor hash stability {got:?}; allowed: {}", allowed.join(", "))]
357 UnknownHashStability {
358 got: String,
359 allowed: &'static [&'static str],
360 },
361 #[error("anchor is missing its artifact reference")]
363 MissingArtifact,
364 #[error("anchor class '{class}' carries no hash semantics — a content hash is not permitted")]
367 HashOnNonHashClass { class: &'static str },
368 #[error(
371 "anchor grain '{grain}' is unsupported by a '{medium_type}' medium: its \
372 '{anchor_namespace}' namespace does not admit that grain"
373 )]
374 GrainNamespaceUnsupported {
375 grain: &'static str,
376 medium_type: String,
377 anchor_namespace: &'static str,
378 },
379}
380
381impl AnchorValidationError {
382 pub fn code(&self) -> &'static str {
384 INVALID_ANCHOR_CODE
385 }
386
387 pub fn detail(&self) -> BTreeMap<String, serde_json::Value> {
390 let mut d = BTreeMap::new();
391 match self {
392 AnchorValidationError::UnknownClass { got, allowed } => {
393 d.insert("field".into(), "class".into());
394 d.insert("got".into(), serde_json::json!(got));
395 d.insert("allowed".into(), serde_json::json!(allowed));
396 }
397 AnchorValidationError::UnknownGrain { got, allowed } => {
398 d.insert("field".into(), "grain".into());
399 d.insert("got".into(), serde_json::json!(got));
400 d.insert("allowed".into(), serde_json::json!(allowed));
401 }
402 AnchorValidationError::UnknownHashStability { got, allowed } => {
403 d.insert("field".into(), "hash_stability".into());
404 d.insert("got".into(), serde_json::json!(got));
405 d.insert("allowed".into(), serde_json::json!(allowed));
406 }
407 AnchorValidationError::MissingArtifact => {
408 d.insert("field".into(), "artifact".into());
409 }
410 AnchorValidationError::HashOnNonHashClass { class } => {
411 d.insert("field".into(), "hash".into());
412 d.insert("class".into(), serde_json::json!(class));
413 }
414 AnchorValidationError::GrainNamespaceUnsupported {
415 grain,
416 medium_type,
417 anchor_namespace,
418 } => {
419 d.insert("field".into(), "grain".into());
420 d.insert("grain".into(), serde_json::json!(grain));
421 d.insert("medium_type".into(), serde_json::json!(medium_type));
422 d.insert(
423 "anchor_namespace".into(),
424 serde_json::json!(anchor_namespace),
425 );
426 }
427 }
428 d
429 }
430}
431
432impl AnchorInput {
433 pub fn validate(&self, medium: Option<(&str, &str)>) -> Result<Anchor, AnchorValidationError> {
452 let class = match self
453 .class
454 .as_deref()
455 .and_then(AnchorProvenanceClass::from_wire)
456 {
457 Some(c) => c,
458 None => {
459 return Err(AnchorValidationError::UnknownClass {
460 got: self.class.clone(),
461 allowed: AnchorProvenanceClass::WIRE_VALUES,
462 });
463 }
464 };
465 let grain = match self.grain.as_deref().and_then(AnchorGrain::from_wire) {
466 Some(g) => g,
467 None => {
468 return Err(AnchorValidationError::UnknownGrain {
469 got: self.grain.clone(),
470 allowed: AnchorGrain::WIRE_VALUES,
471 });
472 }
473 };
474
475 let artifact = self
476 .artifact
477 .as_deref()
478 .map(str::trim)
479 .filter(|s| !s.is_empty())
480 .map(str::to_string)
481 .ok_or(AnchorValidationError::MissingArtifact)?;
482
483 let hash_stability = match self.hash_stability.as_deref() {
486 None => AnchorHashStability::Stable,
487 Some(s) => AnchorHashStability::from_wire(s).ok_or_else(|| {
488 AnchorValidationError::UnknownHashStability {
489 got: s.to_string(),
490 allowed: AnchorHashStability::WIRE_VALUES,
491 }
492 })?,
493 };
494
495 let hash = self
497 .hash
498 .as_deref()
499 .map(str::trim)
500 .filter(|s| !s.is_empty())
501 .map(str::to_string);
502 if hash.is_some() && !class.is_hash_bearing() {
503 return Err(AnchorValidationError::HashOnNonHashClass {
504 class: class.as_wire(),
505 });
506 }
507
508 if let Some((medium_type, namespace)) = medium
510 && !grain.supported_by_namespace(namespace)
511 {
512 let anchor_namespace = match namespace {
515 "path" => "path",
516 "path+commit" => "path+commit",
517 "entity" => "entity",
518 "url" => "url",
519 _ => "path",
520 };
521 return Err(AnchorValidationError::GrainNamespaceUnsupported {
522 grain: grain.as_wire(),
523 medium_type: medium_type.to_string(),
524 anchor_namespace,
525 });
526 }
527
528 Ok(Anchor {
529 artifact,
530 grain,
531 class,
532 at_version: self.at_version.clone(),
533 hash,
534 hash_stability,
535 derived_from: self.derived_from.clone().unwrap_or_default(),
536 binding: self
537 .binding
538 .as_deref()
539 .map(str::trim)
540 .filter(|s| !s.is_empty())
541 .map(str::to_string),
542 })
543 }
544}
545
546#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
552#[serde(rename_all = "lowercase")]
553pub enum AnchorState {
554 Resolves,
557 Drifted,
560 Recheck,
564 Orphaned,
567}
568
569impl AnchorState {
570 pub fn as_wire(&self) -> &'static str {
572 match self {
573 AnchorState::Resolves => "resolves",
574 AnchorState::Drifted => "drifted",
575 AnchorState::Recheck => "recheck",
576 AnchorState::Orphaned => "orphaned",
577 }
578 }
579}
580
581#[derive(Debug, Clone, PartialEq, Eq)]
583pub enum ArtifactObservation {
584 Absent,
586 Present { current_hash: Option<String> },
590}
591
592pub fn resolve_anchor(anchor: &Anchor, observation: &ArtifactObservation) -> AnchorState {
604 let current_hash = match observation {
605 ArtifactObservation::Absent => return AnchorState::Orphaned,
606 ArtifactObservation::Present { current_hash } => current_hash,
607 };
608 if !anchor.class.is_hash_bearing() {
609 return AnchorState::Resolves;
610 }
611 match (&anchor.hash, current_hash) {
612 (Some(recorded), Some(current)) if recorded == current => AnchorState::Resolves,
613 (Some(_), Some(_)) => match anchor.hash_stability {
614 AnchorHashStability::Stable => AnchorState::Drifted,
615 AnchorHashStability::Unstable => AnchorState::Recheck,
616 },
617 _ => AnchorState::Recheck,
619 }
620}
621
622#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
628pub struct EntityAnchorComposition {
629 pub by_class: BTreeMap<String, usize>,
631 pub by_grain: BTreeMap<String, usize>,
633 pub derived_inputs: Vec<Vec<String>>,
636 pub tree_grain_artifacts: Vec<String>,
641}
642
643pub fn compose_entity_anchors(anchors: &[Anchor]) -> EntityAnchorComposition {
646 let mut comp = EntityAnchorComposition::default();
647 for a in anchors {
648 *comp
649 .by_class
650 .entry(a.class.as_wire().to_string())
651 .or_insert(0) += 1;
652 *comp
653 .by_grain
654 .entry(a.grain.as_wire().to_string())
655 .or_insert(0) += 1;
656 if a.class == AnchorProvenanceClass::Derived {
657 comp.derived_inputs.push(a.derived_from.clone());
658 }
659 if a.grain == AnchorGrain::Tree {
660 comp.tree_grain_artifacts.push(a.artifact.clone());
661 }
662 }
663 comp
664}
665
666#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
678pub struct AnchorSidecar {
679 pub version: u32,
681 #[serde(default)]
684 pub entities: BTreeMap<String, Vec<Anchor>>,
685}
686
687impl Default for AnchorSidecar {
688 fn default() -> Self {
689 Self {
690 version: ANCHOR_SIDECAR_VERSION,
691 entities: BTreeMap::new(),
692 }
693 }
694}
695
696impl AnchorSidecar {
697 pub fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
700 if bytes.iter().all(u8::is_ascii_whitespace) {
701 return Ok(Self::default());
702 }
703 serde_json::from_slice(bytes)
704 }
705
706 pub fn to_bytes(&self) -> Vec<u8> {
709 let mut s = serde_json::to_string_pretty(self).expect("anchor sidecar serialises");
710 s.push('\n');
711 s.into_bytes()
712 }
713
714 pub fn get(&self, entity_id: &str) -> &[Anchor] {
716 self.entities
717 .get(entity_id)
718 .map(Vec::as_slice)
719 .unwrap_or(&[])
720 }
721
722 pub fn set(&mut self, entity_id: &str, anchors: Vec<Anchor>) {
725 if anchors.is_empty() {
726 self.entities.remove(entity_id);
727 } else {
728 self.entities.insert(entity_id.to_string(), anchors);
729 }
730 }
731
732 pub fn remove(&mut self, entity_id: &str) {
734 self.entities.remove(entity_id);
735 }
736
737 pub fn rename(&mut self, from: &str, to: &str) {
742 if let Some(anchors) = self.entities.remove(from) {
743 self.entities.insert(to.to_string(), anchors);
744 }
745 }
746
747 pub fn is_empty(&self) -> bool {
749 self.entities.is_empty()
750 }
751}
752
753#[cfg(test)]
754mod tests {
755 use super::*;
756
757 #[test]
760 fn class_wire_strings_are_stable() {
761 assert_eq!(AnchorProvenanceClass::Anchored.as_wire(), "anchored");
762 assert_eq!(AnchorProvenanceClass::Derived.as_wire(), "derived");
763 assert_eq!(AnchorProvenanceClass::Authored.as_wire(), "authored");
764 assert_eq!(AnchorProvenanceClass::InformedBy.as_wire(), "informed-by");
765 for w in AnchorProvenanceClass::WIRE_VALUES {
766 assert_eq!(AnchorProvenanceClass::from_wire(w).unwrap().as_wire(), *w);
767 }
768 assert!(AnchorProvenanceClass::from_wire("bogus").is_none());
769 }
770
771 #[test]
772 fn grain_wire_strings_are_stable() {
773 for w in AnchorGrain::WIRE_VALUES {
774 assert_eq!(AnchorGrain::from_wire(w).unwrap().as_wire(), *w);
775 }
776 assert_eq!(
777 AnchorGrain::WIRE_VALUES,
778 &["span", "file", "tree", "url", "entity"]
779 );
780 assert!(AnchorGrain::from_wire("chunk").is_none());
781 }
782
783 #[test]
784 fn stability_and_state_wire_strings_are_stable() {
785 assert_eq!(AnchorHashStability::Stable.as_wire(), "stable");
786 assert_eq!(AnchorHashStability::Unstable.as_wire(), "unstable");
787 assert_eq!(AnchorState::Resolves.as_wire(), "resolves");
788 assert_eq!(AnchorState::Drifted.as_wire(), "drifted");
789 assert_eq!(AnchorState::Recheck.as_wire(), "recheck");
790 assert_eq!(AnchorState::Orphaned.as_wire(), "orphaned");
791 }
792
793 #[test]
794 fn only_anchored_and_derived_are_hash_bearing() {
795 assert!(AnchorProvenanceClass::Anchored.is_hash_bearing());
796 assert!(AnchorProvenanceClass::Derived.is_hash_bearing());
797 assert!(!AnchorProvenanceClass::Authored.is_hash_bearing());
798 assert!(!AnchorProvenanceClass::InformedBy.is_hash_bearing());
799 }
800
801 #[test]
804 fn grain_namespace_support_matches_capability_matrix() {
805 for g in [AnchorGrain::Span, AnchorGrain::File, AnchorGrain::Tree] {
807 assert!(g.supported_by_namespace("path"));
808 assert!(g.supported_by_namespace("path+commit"));
809 assert!(!g.supported_by_namespace("url"));
810 assert!(!g.supported_by_namespace("entity"));
811 }
812 assert!(AnchorGrain::Url.supported_by_namespace("url"));
813 assert!(!AnchorGrain::Url.supported_by_namespace("path"));
814 assert!(AnchorGrain::Entity.supported_by_namespace("entity"));
815 assert!(!AnchorGrain::Entity.supported_by_namespace("path"));
816 }
817
818 fn valid_input() -> AnchorInput {
821 AnchorInput {
822 artifact: Some("src/lib.rs".into()),
823 grain: Some("file".into()),
824 class: Some("anchored".into()),
825 hash_stability: Some("stable".into()),
826 hash: Some("abc123".into()),
827 ..Default::default()
828 }
829 }
830
831 #[test]
832 fn validate_accepts_a_well_formed_anchor() {
833 let a = valid_input().validate(Some(("codebase", "path"))).unwrap();
834 assert_eq!(a.artifact, "src/lib.rs");
835 assert_eq!(a.grain, AnchorGrain::File);
836 assert_eq!(a.class, AnchorProvenanceClass::Anchored);
837 assert_eq!(a.hash.as_deref(), Some("abc123"));
838 assert_eq!(a.hash_stability, AnchorHashStability::Stable);
839 }
840
841 #[test]
842 fn validate_defaults_hash_stability_to_stable() {
843 let mut i = valid_input();
844 i.hash_stability = None;
845 let a = i.validate(None).unwrap();
846 assert_eq!(a.hash_stability, AnchorHashStability::Stable);
847 }
848
849 #[test]
850 fn validate_refuses_unknown_class() {
851 let mut i = valid_input();
852 i.class = Some("guessed".into());
853 let err = i.validate(None).unwrap_err();
854 assert_eq!(err.code(), INVALID_ANCHOR_CODE);
855 assert!(matches!(err, AnchorValidationError::UnknownClass { .. }));
856 assert_eq!(err.detail()["field"], serde_json::json!("class"));
857 }
858
859 #[test]
860 fn validate_refuses_unknown_grain() {
861 let mut i = valid_input();
862 i.grain = Some("paragraph".into());
863 let err = i.validate(None).unwrap_err();
864 assert!(matches!(err, AnchorValidationError::UnknownGrain { .. }));
865 }
866
867 #[test]
868 fn validate_refuses_missing_artifact() {
869 let mut i = valid_input();
870 i.artifact = Some(" ".into());
871 let err = i.validate(None).unwrap_err();
872 assert!(matches!(err, AnchorValidationError::MissingArtifact));
873 i.artifact = None;
874 assert!(matches!(
875 valid_input_with_artifact(None).validate(None).unwrap_err(),
876 AnchorValidationError::MissingArtifact
877 ));
878 let _ = i;
879 }
880
881 fn valid_input_with_artifact(a: Option<String>) -> AnchorInput {
882 AnchorInput {
883 artifact: a,
884 ..valid_input()
885 }
886 }
887
888 #[test]
889 fn validate_refuses_hash_on_non_hash_class() {
890 let mut i = valid_input();
891 i.class = Some("authored".into());
892 let err = i.validate(None).unwrap_err();
894 assert!(matches!(
895 err,
896 AnchorValidationError::HashOnNonHashClass { class: "authored" }
897 ));
898 }
899
900 #[test]
901 fn validate_accepts_non_hash_class_without_hash() {
902 let mut i = valid_input();
903 i.class = Some("informed-by".into());
904 i.hash = None;
905 let a = i.validate(None).unwrap();
906 assert_eq!(a.class, AnchorProvenanceClass::InformedBy);
907 assert!(a.hash.is_none());
908 }
909
910 #[test]
911 fn validate_refuses_grain_unsupported_by_medium_namespace() {
912 let mut i = valid_input();
914 i.grain = Some("span".into());
915 i.class = Some("authored".into());
916 i.hash = None;
917 let err = i.validate(Some(("web", "url"))).unwrap_err();
918 match err {
919 AnchorValidationError::GrainNamespaceUnsupported {
920 grain,
921 anchor_namespace,
922 ..
923 } => {
924 assert_eq!(grain, "span");
925 assert_eq!(anchor_namespace, "url");
926 }
927 other => panic!("expected GrainNamespaceUnsupported, got {other:?}"),
928 }
929 }
930
931 #[test]
932 fn validate_skips_namespace_check_without_medium_context() {
933 let mut i = valid_input();
935 i.grain = Some("span".into());
936 assert!(i.validate(None).is_ok());
937 }
938
939 fn anchor(
942 class: AnchorProvenanceClass,
943 hash: Option<&str>,
944 stab: AnchorHashStability,
945 ) -> Anchor {
946 Anchor {
947 artifact: "src/lib.rs".into(),
948 grain: AnchorGrain::File,
949 class,
950 at_version: None,
951 hash: hash.map(str::to_string),
952 hash_stability: stab,
953 derived_from: Vec::new(),
954 binding: None,
955 }
956 }
957
958 #[test]
959 fn resolves_when_hash_matches() {
960 let a = anchor(
961 AnchorProvenanceClass::Anchored,
962 Some("h1"),
963 AnchorHashStability::Stable,
964 );
965 let obs = ArtifactObservation::Present {
966 current_hash: Some("h1".into()),
967 };
968 assert_eq!(resolve_anchor(&a, &obs), AnchorState::Resolves);
969 }
970
971 #[test]
972 fn stable_hash_break_drifts_unstable_rechecks() {
973 let stable = anchor(
974 AnchorProvenanceClass::Anchored,
975 Some("h1"),
976 AnchorHashStability::Stable,
977 );
978 let unstable = anchor(
979 AnchorProvenanceClass::Anchored,
980 Some("h1"),
981 AnchorHashStability::Unstable,
982 );
983 let obs = ArtifactObservation::Present {
984 current_hash: Some("h2".into()),
985 };
986 assert_eq!(resolve_anchor(&stable, &obs), AnchorState::Drifted);
987 assert_eq!(resolve_anchor(&unstable, &obs), AnchorState::Recheck);
988 }
989
990 #[test]
991 fn absent_artifact_is_orphaned() {
992 let a = anchor(
993 AnchorProvenanceClass::Anchored,
994 Some("h1"),
995 AnchorHashStability::Stable,
996 );
997 assert_eq!(
998 resolve_anchor(&a, &ArtifactObservation::Absent),
999 AnchorState::Orphaned
1000 );
1001 }
1002
1003 #[test]
1004 fn non_hash_classes_never_drift() {
1005 for class in [
1006 AnchorProvenanceClass::Authored,
1007 AnchorProvenanceClass::InformedBy,
1008 ] {
1009 let a = anchor(class, None, AnchorHashStability::Stable);
1010 let obs = ArtifactObservation::Present {
1013 current_hash: Some("whatever".into()),
1014 };
1015 assert_eq!(resolve_anchor(&a, &obs), AnchorState::Resolves);
1016 assert_eq!(
1018 resolve_anchor(&a, &ArtifactObservation::Absent),
1019 AnchorState::Orphaned
1020 );
1021 }
1022 }
1023
1024 #[test]
1025 fn unavailable_hash_rechecks_not_drifts() {
1026 let a = anchor(
1027 AnchorProvenanceClass::Anchored,
1028 Some("h1"),
1029 AnchorHashStability::Stable,
1030 );
1031 let obs = ArtifactObservation::Present { current_hash: None };
1032 assert_eq!(resolve_anchor(&a, &obs), AnchorState::Recheck);
1033 }
1034
1035 #[test]
1038 fn composition_counts_classes_grains_and_tree_fanout() {
1039 let anchors = vec![
1040 Anchor {
1041 artifact: "a.rs".into(),
1042 grain: AnchorGrain::File,
1043 class: AnchorProvenanceClass::Anchored,
1044 at_version: None,
1045 hash: Some("h".into()),
1046 hash_stability: AnchorHashStability::Stable,
1047 derived_from: Vec::new(),
1048 binding: None,
1049 },
1050 Anchor {
1051 artifact: "src/".into(),
1052 grain: AnchorGrain::Tree,
1053 class: AnchorProvenanceClass::Derived,
1054 at_version: None,
1055 hash: Some("t".into()),
1056 hash_stability: AnchorHashStability::Stable,
1057 derived_from: vec!["a.rs".into(), "b.rs".into()],
1058 binding: None,
1059 },
1060 ];
1061 let comp = compose_entity_anchors(&anchors);
1062 assert_eq!(comp.by_class["anchored"], 1);
1063 assert_eq!(comp.by_class["derived"], 1);
1064 assert_eq!(comp.by_grain["file"], 1);
1065 assert_eq!(comp.by_grain["tree"], 1);
1066 assert_eq!(comp.tree_grain_artifacts, vec!["src/".to_string()]);
1068 assert_eq!(
1069 comp.derived_inputs,
1070 vec![vec!["a.rs".to_string(), "b.rs".to_string()]]
1071 );
1072 }
1073
1074 #[test]
1077 fn sidecar_round_trips_and_prunes_empty() {
1078 let mut sc = AnchorSidecar::default();
1079 assert!(sc.is_empty());
1080 let a = anchor(
1081 AnchorProvenanceClass::Anchored,
1082 Some("h1"),
1083 AnchorHashStability::Stable,
1084 );
1085 sc.set("specs--x", vec![a.clone()]);
1086 assert_eq!(sc.get("specs--x").len(), 1);
1087
1088 let bytes = sc.to_bytes();
1089 let round = AnchorSidecar::from_bytes(&bytes).unwrap();
1090 assert_eq!(round, sc);
1091
1092 sc.set("specs--x", vec![]);
1094 assert!(sc.is_empty());
1095 assert!(sc.get("specs--x").is_empty());
1096 }
1097
1098 #[test]
1099 fn sidecar_rename_leaves_zero_rows_under_old_id() {
1100 let mut sc = AnchorSidecar::default();
1101 sc.set(
1102 "specs--old",
1103 vec![anchor(
1104 AnchorProvenanceClass::Anchored,
1105 Some("h"),
1106 AnchorHashStability::Stable,
1107 )],
1108 );
1109 sc.rename("specs--old", "specs--new");
1110 assert!(sc.get("specs--old").is_empty());
1111 assert_eq!(sc.get("specs--new").len(), 1);
1112 }
1113
1114 #[test]
1115 fn sidecar_remove_drops_entity_anchors() {
1116 let mut sc = AnchorSidecar::default();
1117 sc.set(
1118 "specs--gone",
1119 vec![anchor(
1120 AnchorProvenanceClass::Anchored,
1121 Some("h"),
1122 AnchorHashStability::Stable,
1123 )],
1124 );
1125 sc.remove("specs--gone");
1126 assert!(sc.get("specs--gone").is_empty());
1127 sc.remove("specs--gone");
1129 }
1130
1131 #[test]
1132 fn empty_bytes_parse_as_empty_sidecar() {
1133 assert!(AnchorSidecar::from_bytes(b"").unwrap().is_empty());
1134 assert!(AnchorSidecar::from_bytes(b" \n ").unwrap().is_empty());
1135 }
1136
1137 #[test]
1138 fn anchor_json_shape_omits_empty_optionals() {
1139 let a = anchor(
1140 AnchorProvenanceClass::Anchored,
1141 Some("h1"),
1142 AnchorHashStability::Stable,
1143 );
1144 let v = serde_json::to_value(&a).unwrap();
1145 assert_eq!(v["artifact"], "src/lib.rs");
1146 assert_eq!(v["grain"], "file");
1147 assert_eq!(v["class"], "anchored");
1148 assert_eq!(v["hash"], "h1");
1149 assert_eq!(v["hash_stability"], "stable");
1150 assert!(v.get("at_version").is_none());
1152 assert!(v.get("derived_from").is_none());
1153 assert!(v.get("binding").is_none());
1154 }
1155
1156 #[test]
1157 fn anchor_version_serialises_tagged() {
1158 let a = Anchor {
1159 at_version: Some(AnchorVersion::Commit("deadbeef".into())),
1160 ..anchor(
1161 AnchorProvenanceClass::Anchored,
1162 Some("h"),
1163 AnchorHashStability::Stable,
1164 )
1165 };
1166 let v = serde_json::to_value(&a).unwrap();
1167 assert_eq!(v["at_version"]["kind"], "commit");
1168 assert_eq!(v["at_version"]["value"], "deadbeef");
1169 }
1170}