Skip to main content

khive_vcs/
types.rs

1// Copyright 2026 khive contributors. Licensed under Apache-2.0.
2//
3//! Core versioning types: `SnapshotId`, `KgSnapshot`, `KgBranch`, `RemoteConfig`.
4
5use serde::{Deserialize, Serialize};
6
7use crate::error::VcsError;
8
9// ── SnapshotId ────────────────────────────────────────────────────────────────
10
11/// Content-addressed snapshot identifier.
12///
13/// Invariant: always the string `"sha256:"` followed by exactly 64 lower-case
14/// hex characters. Enforced by `SnapshotId::from_hash`.
15#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub struct SnapshotId(String);
17
18impl SnapshotId {
19    /// Construct from a raw hex digest (without the `"sha256:"` prefix).
20    ///
21    /// Returns `Err(VcsError::InvalidSnapshotId)` if `hex` is not exactly 64
22    /// lower-case hex characters.
23    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    /// Construct from a full prefixed string (`"sha256:<hex64>"`).
35    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    /// Returns the full string including the `"sha256:"` prefix.
43    pub fn as_str(&self) -> &str {
44        &self.0
45    }
46
47    /// Returns only the 64-character hex digest (without prefix).
48    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// ── KgSnapshot ────────────────────────────────────────────────────────────────
60
61/// Immutable point-in-time capture of a namespace's entity and edge set.
62///
63/// `id` is the SHA-256 hash of the deterministically serialized archive.
64/// The archive itself is stored separately in `kg_snapshot_archives`.
65#[derive(Clone, Debug, Serialize, Deserialize)]
66pub struct KgSnapshot {
67    /// Content hash — also the primary key in `kg_snapshots`.
68    pub id: SnapshotId,
69    /// Namespace this snapshot belongs to.
70    pub namespace: String,
71    /// Previous snapshot in this branch's history. `None` for the genesis commit.
72    pub parent_id: Option<SnapshotId>,
73    /// Human-readable description of the changes since the previous snapshot.
74    pub message: String,
75    /// Agent or user identifier for attribution. Optional.
76    pub author: Option<String>,
77    /// Unix microseconds (i64) — compatible with the existing substrate timestamp convention.
78    pub created_at: i64,
79    /// Number of entities in this snapshot.
80    pub entity_count: u64,
81    /// Number of edges in this snapshot.
82    pub edge_count: u64,
83}
84
85// ── KgBranch ─────────────────────────────────────────────────────────────────
86
87/// Named mutable pointer to a snapshot within a namespace.
88///
89/// Composite primary key: `(namespace, name)`.
90/// The default branch is `"main"`.
91#[derive(Clone, Debug, Serialize, Deserialize)]
92pub struct KgBranch {
93    /// Namespace this branch lives in.
94    pub namespace: String,
95    /// Branch name — alphanumeric, hyphens, underscores.
96    pub name: String,
97    /// The snapshot this branch currently points to.
98    pub head_id: SnapshotId,
99    /// Unix microseconds when the branch was first created.
100    pub created_at: i64,
101    /// Unix microseconds of the last HEAD update.
102    pub updated_at: i64,
103}
104
105// ── RemoteConfig ──────────────────────────────────────────────────────────────
106
107/// Connection parameters for a remote khive instance (for push/pull).
108#[derive(Clone, Debug, Serialize, Deserialize)]
109pub struct RemoteConfig {
110    /// Short name used in CLI commands (e.g. `"origin"`).
111    pub name: String,
112    /// Base URL of the remote khive-sync server (e.g. `"https://khive.example.com"`).
113    pub url: String,
114    /// Authentication credentials for the remote.
115    pub auth: RemoteAuth,
116    /// Optional namespace mapping: `(local_namespace, remote_namespace)`.
117    /// When absent, the local namespace name is used on the remote.
118    pub namespace_map: Option<(String, String)>,
119}
120
121impl RemoteConfig {
122    /// Returns the remote namespace name for a given local namespace.
123    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/// Authentication credentials for a remote khive instance.
132#[derive(Clone, Serialize, Deserialize)]
133#[serde(tag = "type", rename_all = "snake_case")]
134pub enum RemoteAuth {
135    /// No authentication (anonymous access).
136    None,
137    /// Bearer token (API key).
138    Bearer { token: String },
139    /// HTTP basic authentication.
140    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// ── VcsState ─────────────────────────────────────────────────────────────────
160
161/// Per-namespace VCS state stored in `kg_vcs_state`.
162#[derive(Clone, Debug, Serialize, Deserialize)]
163pub struct VcsState {
164    pub namespace: String,
165    /// Name of the currently active branch. `None` in detached HEAD state.
166    pub current_branch: Option<String>,
167    /// Last committed snapshot ID. `None` if no commit has been made.
168    pub last_committed_id: Option<SnapshotId>,
169    /// Whether uncommitted changes exist since the last commit.
170    pub dirty: bool,
171}
172
173// ── Tests ─────────────────────────────────────────────────────────────────────
174
175#[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}