1use serde::{de, Deserialize, Deserializer, Serialize};
10
11use crate::error::VcsError;
12
13#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
21pub struct SnapshotId(String);
22
23impl<'de> Deserialize<'de> for SnapshotId {
24 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
25 let s = String::deserialize(d)?;
26 let hex = s.strip_prefix("sha256:").ok_or_else(|| {
29 de::Error::custom(format!(
30 "invalid SnapshotId: missing sha256: prefix in {:?}",
31 s
32 ))
33 })?;
34 if hex.len() != 64 {
35 return Err(de::Error::custom(format!(
36 "invalid SnapshotId: expected 64 hex chars, got {} in {:?}",
37 hex.len(),
38 s
39 )));
40 }
41 if !hex.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) {
42 return Err(de::Error::custom(format!(
43 "invalid SnapshotId: hex must be lower-case with no whitespace in {:?}",
44 s
45 )));
46 }
47 Ok(SnapshotId(s))
48 }
49}
50
51impl SnapshotId {
52 pub fn from_hash(hex: &str) -> Result<Self, VcsError> {
57 let hex = hex.trim();
58 if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
59 return Err(VcsError::InvalidSnapshotId(format!(
60 "expected 64 hex chars, got {:?}",
61 hex
62 )));
63 }
64 Ok(Self(format!("sha256:{}", hex.to_ascii_lowercase())))
65 }
66
67 pub fn from_prefixed(s: &str) -> Result<Self, VcsError> {
69 let hex = s.strip_prefix("sha256:").ok_or_else(|| {
70 VcsError::InvalidSnapshotId(format!("missing sha256: prefix in {:?}", s))
71 })?;
72 Self::from_hash(hex)
73 }
74
75 pub fn as_str(&self) -> &str {
77 &self.0
78 }
79
80 pub fn hex(&self) -> &str {
82 &self.0["sha256:".len()..]
83 }
84}
85
86impl std::fmt::Display for SnapshotId {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 f.write_str(&self.0)
89 }
90}
91
92#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
99pub struct SnapshotCoverage {
100 pub entities: bool,
101 pub edges: bool,
102 pub notes: bool,
103}
104
105pub const KG_V1_COVERAGE: SnapshotCoverage = SnapshotCoverage {
107 entities: true,
108 edges: true,
109 notes: false,
110};
111
112#[derive(Clone, Debug, Serialize, Deserialize)]
119pub struct VcsState {
120 pub namespace: String,
121 pub current_branch: Option<String>,
123 pub last_committed_id: Option<SnapshotId>,
125}
126
127#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn snapshot_id_from_hash_valid() {
135 let hex = "a".repeat(64);
136 let id = SnapshotId::from_hash(&hex).unwrap();
137 assert_eq!(id.as_str(), format!("sha256:{}", hex));
138 assert_eq!(id.hex(), hex);
139 }
140
141 #[test]
142 fn snapshot_id_from_hash_rejects_short() {
143 let err = SnapshotId::from_hash("abc").unwrap_err();
144 assert!(matches!(err, VcsError::InvalidSnapshotId(_)));
145 }
146
147 #[test]
148 fn snapshot_id_from_hash_rejects_non_hex() {
149 let invalid = "z".repeat(64);
150 let err = SnapshotId::from_hash(&invalid).unwrap_err();
151 assert!(matches!(err, VcsError::InvalidSnapshotId(_)));
152 }
153
154 #[test]
155 fn snapshot_id_from_prefixed() {
156 let hex = "b".repeat(64);
157 let prefixed = format!("sha256:{}", hex);
158 let id = SnapshotId::from_prefixed(&prefixed).unwrap();
159 assert_eq!(id.as_str(), prefixed);
160 }
161
162 #[test]
163 fn snapshot_id_from_prefixed_rejects_missing_prefix() {
164 let err = SnapshotId::from_prefixed(&"b".repeat(64)).unwrap_err();
165 assert!(matches!(err, VcsError::InvalidSnapshotId(_)));
166 }
167
168 #[test]
169 fn snapshot_id_from_hash_accepts_uppercase_and_normalizes() {
170 let upper = "A".repeat(64);
171 let id = SnapshotId::from_hash(&upper).unwrap();
172 assert_eq!(id.hex(), "a".repeat(64));
173 assert!(id.as_str().starts_with("sha256:"));
174 }
175
176 #[test]
177 fn snapshot_id_from_hash_trims_whitespace() {
178 let hex = "b".repeat(64);
179 let padded = format!(" {hex} ");
180 let id = SnapshotId::from_hash(&padded).unwrap();
181 assert_eq!(id.hex(), hex);
182 }
183
184 #[test]
185 fn snapshot_id_display_equals_as_str() {
186 let hex = "c".repeat(64);
187 let id = SnapshotId::from_hash(&hex).unwrap();
188 assert_eq!(id.to_string(), id.as_str());
189 }
190
191 #[test]
192 fn snapshot_id_serde_roundtrip() {
193 let hex = "d".repeat(64);
194 let id = SnapshotId::from_hash(&hex).unwrap();
195 let json = serde_json::to_string(&id).unwrap();
196 let back: SnapshotId = serde_json::from_str(&json).unwrap();
197 assert_eq!(back, id);
198 }
199
200 #[test]
201 fn vcs_state_serde_roundtrip() {
202 let state = VcsState {
203 namespace: "proj".into(),
204 current_branch: Some("main".into()),
205 last_committed_id: Some(SnapshotId::from_hash(&"0".repeat(64)).unwrap()),
206 };
207 let json = serde_json::to_string(&state).unwrap();
208 let back: VcsState = serde_json::from_str(&json).unwrap();
209 assert_eq!(back.namespace, state.namespace);
210 assert_eq!(back.current_branch, Some("main".into()));
211 assert_eq!(back.last_committed_id, state.last_committed_id);
212 }
213
214 #[test]
215 fn snapshot_coverage_v1_entities_and_edges_only() {
216 const { assert!(KG_V1_COVERAGE.entities) };
217 const { assert!(KG_V1_COVERAGE.edges) };
218 const { assert!(!KG_V1_COVERAGE.notes) };
219 }
220
221 #[test]
222 fn snapshot_coverage_serde_roundtrip() {
223 let cov = KG_V1_COVERAGE.clone();
224 let json = serde_json::to_string(&cov).unwrap();
225 let back: SnapshotCoverage = serde_json::from_str(&json).unwrap();
226 assert_eq!(back, cov);
227 }
228
229 #[test]
232 fn snapshot_id_serde_rejects_missing_prefix() {
233 let raw = format!("\"{}\"", "a".repeat(64));
234 let err = serde_json::from_str::<SnapshotId>(&raw);
235 assert!(err.is_err(), "must reject bare hex without sha256: prefix");
236 }
237
238 #[test]
239 fn snapshot_id_serde_rejects_uppercase_hex() {
240 let raw = format!("\"sha256:{}\"", "A".repeat(64));
241 let err = serde_json::from_str::<SnapshotId>(&raw);
242 assert!(err.is_err(), "must reject uppercase hex in prefixed form");
243 }
244
245 #[test]
246 fn snapshot_id_serde_rejects_whitespace() {
247 let raw = format!("\"sha256: {}\"", "a".repeat(64));
248 let err = serde_json::from_str::<SnapshotId>(&raw);
249 assert!(err.is_err(), "must reject whitespace inside hex portion");
250 }
251
252 #[test]
253 fn snapshot_id_serde_rejects_wrong_length() {
254 let raw = "\"sha256:abc\"";
255 let err = serde_json::from_str::<SnapshotId>(raw);
256 assert!(err.is_err(), "must reject hex shorter than 64 chars");
257 }
258
259 #[test]
260 fn snapshot_id_serde_accepts_valid_prefixed() {
261 let hex = "a".repeat(64);
262 let raw = format!("\"sha256:{hex}\"");
263 let id: SnapshotId = serde_json::from_str(&raw).unwrap();
264 assert_eq!(id.hex(), hex);
265 }
266}