1use serde::{Deserialize, Serialize};
6
7use crate::error::VcsError;
8
9#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub struct SnapshotId(String);
17
18impl SnapshotId {
19 pub fn from_hash(hex: &str) -> Result<Self, VcsError> {
24 let hex = hex.trim();
25 if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
26 return Err(VcsError::InvalidSnapshotId(format!(
27 "expected 64 hex chars, got {:?}",
28 hex
29 )));
30 }
31 Ok(Self(format!("sha256:{}", hex.to_ascii_lowercase())))
32 }
33
34 pub fn from_prefixed(s: &str) -> Result<Self, VcsError> {
36 let hex = s.strip_prefix("sha256:").ok_or_else(|| {
37 VcsError::InvalidSnapshotId(format!("missing sha256: prefix in {:?}", s))
38 })?;
39 Self::from_hash(hex)
40 }
41
42 pub fn as_str(&self) -> &str {
44 &self.0
45 }
46
47 pub fn hex(&self) -> &str {
49 &self.0["sha256:".len()..]
50 }
51}
52
53impl std::fmt::Display for SnapshotId {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 f.write_str(&self.0)
56 }
57}
58
59#[derive(Clone, Debug, Serialize, Deserialize)]
66pub struct KgSnapshot {
67 pub id: SnapshotId,
69 pub namespace: String,
71 pub parent_id: Option<SnapshotId>,
73 pub message: String,
75 pub author: Option<String>,
77 pub created_at: i64,
79 pub entity_count: u64,
81 pub edge_count: u64,
83}
84
85#[derive(Clone, Debug, Serialize, Deserialize)]
92pub struct KgBranch {
93 pub namespace: String,
95 pub name: String,
97 pub head_id: SnapshotId,
99 pub created_at: i64,
101 pub updated_at: i64,
103}
104
105#[derive(Clone, Debug, Serialize, Deserialize)]
109pub struct RemoteConfig {
110 pub name: String,
112 pub url: String,
114 pub auth: RemoteAuth,
116 pub namespace_map: Option<(String, String)>,
119}
120
121impl RemoteConfig {
122 pub fn remote_namespace<'a>(&'a self, local: &'a str) -> &'a str {
124 match &self.namespace_map {
125 Some((from, to)) if from == local => to.as_str(),
126 _ => local,
127 }
128 }
129}
130
131#[derive(Clone, Serialize, Deserialize)]
133#[serde(tag = "type", rename_all = "snake_case")]
134pub enum RemoteAuth {
135 None,
137 Bearer { token: String },
139 Basic { user: String, password: String },
141}
142
143impl std::fmt::Debug for RemoteAuth {
144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 match self {
146 Self::None => write!(f, "RemoteAuth::None"),
147 Self::Bearer { .. } => write!(f, "RemoteAuth::Bearer {{ token: \"[REDACTED]\" }}"),
148 Self::Basic { user, .. } => {
149 write!(
150 f,
151 "RemoteAuth::Basic {{ user: {:?}, password: \"[REDACTED]\" }}",
152 user
153 )
154 }
155 }
156 }
157}
158
159#[derive(Clone, Debug, Serialize, Deserialize)]
163pub struct VcsState {
164 pub namespace: String,
165 pub current_branch: Option<String>,
167 pub last_committed_id: Option<SnapshotId>,
169 pub dirty: bool,
171}
172
173#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn snapshot_id_from_hash_valid() {
181 let hex = "a".repeat(64);
182 let id = SnapshotId::from_hash(&hex).unwrap();
183 assert_eq!(id.as_str(), format!("sha256:{}", hex));
184 assert_eq!(id.hex(), hex);
185 }
186
187 #[test]
188 fn snapshot_id_from_hash_rejects_short() {
189 let err = SnapshotId::from_hash("abc").unwrap_err();
190 assert!(matches!(err, VcsError::InvalidSnapshotId(_)));
191 }
192
193 #[test]
194 fn snapshot_id_from_hash_rejects_non_hex() {
195 let invalid = "z".repeat(64);
196 let err = SnapshotId::from_hash(&invalid).unwrap_err();
197 assert!(matches!(err, VcsError::InvalidSnapshotId(_)));
198 }
199
200 #[test]
201 fn snapshot_id_from_prefixed() {
202 let hex = "b".repeat(64);
203 let prefixed = format!("sha256:{}", hex);
204 let id = SnapshotId::from_prefixed(&prefixed).unwrap();
205 assert_eq!(id.as_str(), prefixed);
206 }
207
208 #[test]
209 fn snapshot_id_from_prefixed_rejects_missing_prefix() {
210 let err = SnapshotId::from_prefixed(&"b".repeat(64)).unwrap_err();
211 assert!(matches!(err, VcsError::InvalidSnapshotId(_)));
212 }
213
214 #[test]
215 fn remote_config_namespace_map() {
216 let cfg = RemoteConfig {
217 name: "origin".into(),
218 url: "https://example.com".into(),
219 auth: RemoteAuth::None,
220 namespace_map: Some(("local".into(), "shared".into())),
221 };
222 assert_eq!(cfg.remote_namespace("local"), "shared");
223 assert_eq!(cfg.remote_namespace("other"), "other");
224 }
225
226 #[test]
227 fn snapshot_id_from_hash_accepts_uppercase_and_normalizes() {
228 let upper = "A".repeat(64);
229 let id = SnapshotId::from_hash(&upper).unwrap();
230 assert_eq!(id.hex(), "a".repeat(64));
231 assert!(id.as_str().starts_with("sha256:"));
232 }
233
234 #[test]
235 fn snapshot_id_from_hash_trims_whitespace() {
236 let hex = "b".repeat(64);
237 let padded = format!(" {hex} ");
238 let id = SnapshotId::from_hash(&padded).unwrap();
239 assert_eq!(id.hex(), hex);
240 }
241
242 #[test]
243 fn snapshot_id_display_equals_as_str() {
244 let hex = "c".repeat(64);
245 let id = SnapshotId::from_hash(&hex).unwrap();
246 assert_eq!(id.to_string(), id.as_str());
247 }
248
249 #[test]
250 fn snapshot_id_serde_roundtrip() {
251 let hex = "d".repeat(64);
252 let id = SnapshotId::from_hash(&hex).unwrap();
253 let json = serde_json::to_string(&id).unwrap();
254 let back: SnapshotId = serde_json::from_str(&json).unwrap();
255 assert_eq!(back, id);
256 }
257
258 #[test]
259 fn kg_snapshot_serde_roundtrip() {
260 let hex = "e".repeat(64);
261 let snap = KgSnapshot {
262 id: SnapshotId::from_hash(&hex).unwrap(),
263 namespace: "test-ns".into(),
264 parent_id: None,
265 message: "initial commit".into(),
266 author: Some("ocean".into()),
267 created_at: 1_700_000_000_000_000,
268 entity_count: 42,
269 edge_count: 7,
270 };
271 let json = serde_json::to_string(&snap).unwrap();
272 let back: KgSnapshot = serde_json::from_str(&json).unwrap();
273 assert_eq!(back.id, snap.id);
274 assert_eq!(back.namespace, snap.namespace);
275 assert_eq!(back.parent_id, snap.parent_id);
276 assert_eq!(back.entity_count, 42);
277 assert_eq!(back.edge_count, 7);
278 assert_eq!(back.author, Some("ocean".into()));
279 }
280
281 #[test]
282 fn kg_branch_serde_roundtrip() {
283 let branch = KgBranch {
284 namespace: "test-ns".into(),
285 name: "main".into(),
286 head_id: SnapshotId::from_hash(&"f".repeat(64)).unwrap(),
287 created_at: 1_000_000,
288 updated_at: 2_000_000,
289 };
290 let json = serde_json::to_string(&branch).unwrap();
291 let back: KgBranch = serde_json::from_str(&json).unwrap();
292 assert_eq!(back.namespace, branch.namespace);
293 assert_eq!(back.name, branch.name);
294 assert_eq!(back.head_id, branch.head_id);
295 assert_eq!(back.created_at, 1_000_000);
296 assert_eq!(back.updated_at, 2_000_000);
297 }
298
299 #[test]
300 fn remote_auth_bearer_serde_round_trip_and_tag() {
301 let auth = RemoteAuth::Bearer {
302 token: "tok123".into(),
303 };
304 let json = serde_json::to_string(&auth).unwrap();
305 assert!(json.contains("\"type\":\"bearer\""));
306 let back: RemoteAuth = serde_json::from_str(&json).unwrap();
307 assert!(matches!(back, RemoteAuth::Bearer { ref token } if token == "tok123"));
308 }
309
310 #[test]
311 fn remote_auth_debug_redacts_bearer_token() {
312 let auth = RemoteAuth::Bearer {
313 token: "super-secret".into(),
314 };
315 let debug = format!("{:?}", auth);
316 assert!(
317 debug.contains("[REDACTED]"),
318 "expected [REDACTED] in: {debug}"
319 );
320 assert!(!debug.contains("super-secret"), "secret leaked in: {debug}");
321 }
322
323 #[test]
324 fn remote_auth_debug_redacts_basic_password() {
325 let auth = RemoteAuth::Basic {
326 user: "alice".into(),
327 password: "hunter2".into(),
328 };
329 let debug = format!("{:?}", auth);
330 assert!(debug.contains("alice"));
331 assert!(
332 debug.contains("[REDACTED]"),
333 "expected [REDACTED] in: {debug}"
334 );
335 assert!(!debug.contains("hunter2"), "password leaked in: {debug}");
336 }
337
338 #[test]
339 fn remote_config_none_namespace_map_returns_local_name() {
340 let cfg = RemoteConfig {
341 name: "origin".into(),
342 url: "https://example.com".into(),
343 auth: RemoteAuth::None,
344 namespace_map: None,
345 };
346 assert_eq!(cfg.remote_namespace("my-ns"), "my-ns");
347 assert_eq!(cfg.remote_namespace("other-ns"), "other-ns");
348 }
349
350 #[test]
351 fn vcs_state_serde_roundtrip() {
352 let state = VcsState {
353 namespace: "proj".into(),
354 current_branch: Some("main".into()),
355 last_committed_id: Some(SnapshotId::from_hash(&"0".repeat(64)).unwrap()),
356 dirty: true,
357 };
358 let json = serde_json::to_string(&state).unwrap();
359 let back: VcsState = serde_json::from_str(&json).unwrap();
360 assert_eq!(back.namespace, state.namespace);
361 assert_eq!(back.current_branch, Some("main".into()));
362 assert!(back.dirty);
363 assert_eq!(back.last_committed_id, state.last_committed_id);
364 }
365}