Skip to main content

khive_vcs/
types.rs

1// Copyright 2026 Haiyang Li. Licensed under Apache-2.0.
2//
3//! Core versioning types: `SnapshotId`, `VcsState`.
4//!
5//! Legacy types (`KgSnapshot`, `KgBranch`, `RemoteConfig`) and the `VcsState.dirty`
6//! flag were removed during the git-native v1 alignment pass. KG branches are now
7//! git branches; there is no custom remote protocol.
8
9use serde::{de, Deserialize, Deserializer, Serialize};
10
11use crate::error::VcsError;
12
13// ── SnapshotId ────────────────────────────────────────────────────────────────
14
15/// Content-addressed snapshot identifier.
16///
17/// Invariant: always the string `"sha256:"` followed by exactly 64 lower-case
18/// hex characters. Enforced by `SnapshotId::from_hash`. Custom `Deserialize`
19/// validates the invariant via `from_prefixed`, rejecting malformed inputs.
20#[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        // Require exact canonical form: "sha256:" prefix + 64 lower-case hex
27        // chars with no whitespace.
28        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    /// Construct from a raw hex digest (without the `"sha256:"` prefix).
53    ///
54    /// Returns `Err(VcsError::InvalidSnapshotId)` if `hex` is not exactly 64
55    /// lower-case hex characters.
56    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    /// Construct from a full prefixed string (`"sha256:<hex64>"`).
68    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    /// Returns the full string including the `"sha256:"` prefix.
76    pub fn as_str(&self) -> &str {
77        &self.0
78    }
79
80    /// Returns only the 64-character hex digest (without prefix).
81    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// ── SnapshotCoverage ──────────────────────────────────────────────────────────
93
94/// Records which record classes are covered by a KG snapshot.
95///
96/// v1 covers entities and edges only. Notes are excluded until note packs
97/// define versioned export, import, privacy/redaction, and merge semantics.
98#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
99pub struct SnapshotCoverage {
100    pub entities: bool,
101    pub edges: bool,
102    pub notes: bool,
103}
104
105/// v1 coverage constant: entities + edges, notes excluded.
106pub const KG_V1_COVERAGE: SnapshotCoverage = SnapshotCoverage {
107    entities: true,
108    edges: true,
109    notes: false,
110};
111
112// ── VcsState ─────────────────────────────────────────────────────────────────
113
114/// Per-namespace VCS state.
115///
116/// There is no `dirty` flag. The diff is computed fresh on every invocation via
117/// `khive kg status` (DB vs NDJSON diff) to determine uncommitted changes.
118#[derive(Clone, Debug, Serialize, Deserialize)]
119pub struct VcsState {
120    pub namespace: String,
121    /// Name of the currently active branch. `None` in detached HEAD state.
122    pub current_branch: Option<String>,
123    /// Last committed snapshot ID. `None` if no commit has been made.
124    pub last_committed_id: Option<SnapshotId>,
125}
126
127// ── Tests ─────────────────────────────────────────────────────────────────────
128
129#[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    // VCS-AUD-004: custom Deserialize must reject non-canonical inputs.
230
231    #[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}