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, Debug, 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
143// ── VcsState ─────────────────────────────────────────────────────────────────
144
145/// Per-namespace VCS state stored in `kg_vcs_state`.
146#[derive(Clone, Debug, Serialize, Deserialize)]
147pub struct VcsState {
148 pub namespace: String,
149 /// Name of the currently active branch. `None` in detached HEAD state.
150 pub current_branch: Option<String>,
151 /// Last committed snapshot ID. `None` if no commit has been made.
152 pub last_committed_id: Option<SnapshotId>,
153 /// Whether uncommitted changes exist since the last commit.
154 pub dirty: bool,
155}
156
157// ── Tests ─────────────────────────────────────────────────────────────────────
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn snapshot_id_from_hash_valid() {
165 let hex = "a".repeat(64);
166 let id = SnapshotId::from_hash(&hex).unwrap();
167 assert_eq!(id.as_str(), format!("sha256:{}", hex));
168 assert_eq!(id.hex(), hex);
169 }
170
171 #[test]
172 fn snapshot_id_from_hash_rejects_short() {
173 let err = SnapshotId::from_hash("abc").unwrap_err();
174 assert!(matches!(err, VcsError::InvalidSnapshotId(_)));
175 }
176
177 #[test]
178 fn snapshot_id_from_hash_rejects_non_hex() {
179 let invalid = "z".repeat(64);
180 let err = SnapshotId::from_hash(&invalid).unwrap_err();
181 assert!(matches!(err, VcsError::InvalidSnapshotId(_)));
182 }
183
184 #[test]
185 fn snapshot_id_from_prefixed() {
186 let hex = "b".repeat(64);
187 let prefixed = format!("sha256:{}", hex);
188 let id = SnapshotId::from_prefixed(&prefixed).unwrap();
189 assert_eq!(id.as_str(), prefixed);
190 }
191
192 #[test]
193 fn snapshot_id_from_prefixed_rejects_missing_prefix() {
194 let err = SnapshotId::from_prefixed(&"b".repeat(64)).unwrap_err();
195 assert!(matches!(err, VcsError::InvalidSnapshotId(_)));
196 }
197
198 #[test]
199 fn remote_config_namespace_map() {
200 let cfg = RemoteConfig {
201 name: "origin".into(),
202 url: "https://example.com".into(),
203 auth: RemoteAuth::None,
204 namespace_map: Some(("local".into(), "shared".into())),
205 };
206 assert_eq!(cfg.remote_namespace("local"), "shared");
207 assert_eq!(cfg.remote_namespace("other"), "other");
208 }
209}