1use serde::{Deserialize, Serialize};
10
11use crate::error::VcsError;
12
13#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub struct SnapshotId(String);
21
22impl SnapshotId {
23 pub fn from_hash(hex: &str) -> Result<Self, VcsError> {
28 let hex = hex.trim();
29 if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
30 return Err(VcsError::InvalidSnapshotId(format!(
31 "expected 64 hex chars, got {:?}",
32 hex
33 )));
34 }
35 Ok(Self(format!("sha256:{}", hex.to_ascii_lowercase())))
36 }
37
38 pub fn from_prefixed(s: &str) -> Result<Self, VcsError> {
40 let hex = s.strip_prefix("sha256:").ok_or_else(|| {
41 VcsError::InvalidSnapshotId(format!("missing sha256: prefix in {:?}", s))
42 })?;
43 Self::from_hash(hex)
44 }
45
46 pub fn as_str(&self) -> &str {
48 &self.0
49 }
50
51 pub fn hex(&self) -> &str {
53 &self.0["sha256:".len()..]
54 }
55}
56
57impl std::fmt::Display for SnapshotId {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 f.write_str(&self.0)
60 }
61}
62
63#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
71pub struct SnapshotCoverage {
72 pub entities: bool,
73 pub edges: bool,
74 pub notes: bool,
75}
76
77pub const KG_V1_COVERAGE: SnapshotCoverage = SnapshotCoverage {
79 entities: true,
80 edges: true,
81 notes: false,
82};
83
84#[derive(Clone, Debug, Serialize, Deserialize)]
92pub struct VcsState {
93 pub namespace: String,
94 pub current_branch: Option<String>,
96 pub last_committed_id: Option<SnapshotId>,
98}
99
100#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn snapshot_id_from_hash_valid() {
108 let hex = "a".repeat(64);
109 let id = SnapshotId::from_hash(&hex).unwrap();
110 assert_eq!(id.as_str(), format!("sha256:{}", hex));
111 assert_eq!(id.hex(), hex);
112 }
113
114 #[test]
115 fn snapshot_id_from_hash_rejects_short() {
116 let err = SnapshotId::from_hash("abc").unwrap_err();
117 assert!(matches!(err, VcsError::InvalidSnapshotId(_)));
118 }
119
120 #[test]
121 fn snapshot_id_from_hash_rejects_non_hex() {
122 let invalid = "z".repeat(64);
123 let err = SnapshotId::from_hash(&invalid).unwrap_err();
124 assert!(matches!(err, VcsError::InvalidSnapshotId(_)));
125 }
126
127 #[test]
128 fn snapshot_id_from_prefixed() {
129 let hex = "b".repeat(64);
130 let prefixed = format!("sha256:{}", hex);
131 let id = SnapshotId::from_prefixed(&prefixed).unwrap();
132 assert_eq!(id.as_str(), prefixed);
133 }
134
135 #[test]
136 fn snapshot_id_from_prefixed_rejects_missing_prefix() {
137 let err = SnapshotId::from_prefixed(&"b".repeat(64)).unwrap_err();
138 assert!(matches!(err, VcsError::InvalidSnapshotId(_)));
139 }
140
141 #[test]
142 fn snapshot_id_from_hash_accepts_uppercase_and_normalizes() {
143 let upper = "A".repeat(64);
144 let id = SnapshotId::from_hash(&upper).unwrap();
145 assert_eq!(id.hex(), "a".repeat(64));
146 assert!(id.as_str().starts_with("sha256:"));
147 }
148
149 #[test]
150 fn snapshot_id_from_hash_trims_whitespace() {
151 let hex = "b".repeat(64);
152 let padded = format!(" {hex} ");
153 let id = SnapshotId::from_hash(&padded).unwrap();
154 assert_eq!(id.hex(), hex);
155 }
156
157 #[test]
158 fn snapshot_id_display_equals_as_str() {
159 let hex = "c".repeat(64);
160 let id = SnapshotId::from_hash(&hex).unwrap();
161 assert_eq!(id.to_string(), id.as_str());
162 }
163
164 #[test]
165 fn snapshot_id_serde_roundtrip() {
166 let hex = "d".repeat(64);
167 let id = SnapshotId::from_hash(&hex).unwrap();
168 let json = serde_json::to_string(&id).unwrap();
169 let back: SnapshotId = serde_json::from_str(&json).unwrap();
170 assert_eq!(back, id);
171 }
172
173 #[test]
174 fn vcs_state_serde_roundtrip() {
175 let state = VcsState {
176 namespace: "proj".into(),
177 current_branch: Some("main".into()),
178 last_committed_id: Some(SnapshotId::from_hash(&"0".repeat(64)).unwrap()),
179 };
180 let json = serde_json::to_string(&state).unwrap();
181 let back: VcsState = serde_json::from_str(&json).unwrap();
182 assert_eq!(back.namespace, state.namespace);
183 assert_eq!(back.current_branch, Some("main".into()));
184 assert_eq!(back.last_committed_id, state.last_committed_id);
185 }
186
187 #[test]
188 fn snapshot_coverage_v1_entities_and_edges_only() {
189 const { assert!(KG_V1_COVERAGE.entities) };
190 const { assert!(KG_V1_COVERAGE.edges) };
191 const { assert!(!KG_V1_COVERAGE.notes) };
192 }
193
194 #[test]
195 fn snapshot_coverage_serde_roundtrip() {
196 let cov = KG_V1_COVERAGE.clone();
197 let json = serde_json::to_string(&cov).unwrap();
198 let back: SnapshotCoverage = serde_json::from_str(&json).unwrap();
199 assert_eq!(back, cov);
200 }
201}