Skip to main content

khive_vcs/
types.rs

1// Copyright 2026 khive contributors. 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 in the ADR-010/ADR-020 alignment pass. KG branches are now
7//! git branches; there is no custom remote protocol (ADR-010, ADR-020).
8
9use serde::{Deserialize, 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`.
19#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub struct SnapshotId(String);
21
22impl SnapshotId {
23    /// Construct from a raw hex digest (without the `"sha256:"` prefix).
24    ///
25    /// Returns `Err(VcsError::InvalidSnapshotId)` if `hex` is not exactly 64
26    /// lower-case hex characters.
27    pub fn from_hash(hex: &str) -> Result<Self, VcsError> {
28        let hex = hex.trim();
29        if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
30            return Err(VcsError::InvalidSnapshotId(format!(
31                "expected 64 hex chars, got {:?}",
32                hex
33            )));
34        }
35        Ok(Self(format!("sha256:{}", hex.to_ascii_lowercase())))
36    }
37
38    /// Construct from a full prefixed string (`"sha256:<hex64>"`).
39    pub fn from_prefixed(s: &str) -> Result<Self, VcsError> {
40        let hex = s.strip_prefix("sha256:").ok_or_else(|| {
41            VcsError::InvalidSnapshotId(format!("missing sha256: prefix in {:?}", s))
42        })?;
43        Self::from_hash(hex)
44    }
45
46    /// Returns the full string including the `"sha256:"` prefix.
47    pub fn as_str(&self) -> &str {
48        &self.0
49    }
50
51    /// Returns only the 64-character hex digest (without prefix).
52    pub fn hex(&self) -> &str {
53        &self.0["sha256:".len()..]
54    }
55}
56
57impl std::fmt::Display for SnapshotId {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        f.write_str(&self.0)
60    }
61}
62
63// ── SnapshotCoverage ──────────────────────────────────────────────────────────
64
65/// Records which record classes are covered by a KG snapshot.
66///
67/// v1 covers entities and edges only. Notes are excluded until note packs
68/// define versioned export, import, privacy/redaction, and merge semantics
69/// (ADR-010 §snapshot-coverage).
70#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
71pub struct SnapshotCoverage {
72    pub entities: bool,
73    pub edges: bool,
74    pub notes: bool,
75}
76
77/// v1 coverage constant: entities + edges, notes excluded.
78pub const KG_V1_COVERAGE: SnapshotCoverage = SnapshotCoverage {
79    entities: true,
80    edges: true,
81    notes: false,
82};
83
84// ── VcsState ─────────────────────────────────────────────────────────────────
85
86/// Per-namespace VCS state.
87///
88/// The `dirty` flag was removed per ADR-020 §7: "There is no dirty flag. The
89/// diff is computed fresh on every invocation." Use `khive kg status` (DB vs
90/// NDJSON diff) to determine uncommitted changes.
91#[derive(Clone, Debug, Serialize, Deserialize)]
92pub struct VcsState {
93    pub namespace: String,
94    /// Name of the currently active branch. `None` in detached HEAD state.
95    pub current_branch: Option<String>,
96    /// Last committed snapshot ID. `None` if no commit has been made.
97    pub last_committed_id: Option<SnapshotId>,
98}
99
100// ── Tests ─────────────────────────────────────────────────────────────────────
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn snapshot_id_from_hash_valid() {
108        let hex = "a".repeat(64);
109        let id = SnapshotId::from_hash(&hex).unwrap();
110        assert_eq!(id.as_str(), format!("sha256:{}", hex));
111        assert_eq!(id.hex(), hex);
112    }
113
114    #[test]
115    fn snapshot_id_from_hash_rejects_short() {
116        let err = SnapshotId::from_hash("abc").unwrap_err();
117        assert!(matches!(err, VcsError::InvalidSnapshotId(_)));
118    }
119
120    #[test]
121    fn snapshot_id_from_hash_rejects_non_hex() {
122        let invalid = "z".repeat(64);
123        let err = SnapshotId::from_hash(&invalid).unwrap_err();
124        assert!(matches!(err, VcsError::InvalidSnapshotId(_)));
125    }
126
127    #[test]
128    fn snapshot_id_from_prefixed() {
129        let hex = "b".repeat(64);
130        let prefixed = format!("sha256:{}", hex);
131        let id = SnapshotId::from_prefixed(&prefixed).unwrap();
132        assert_eq!(id.as_str(), prefixed);
133    }
134
135    #[test]
136    fn snapshot_id_from_prefixed_rejects_missing_prefix() {
137        let err = SnapshotId::from_prefixed(&"b".repeat(64)).unwrap_err();
138        assert!(matches!(err, VcsError::InvalidSnapshotId(_)));
139    }
140
141    #[test]
142    fn snapshot_id_from_hash_accepts_uppercase_and_normalizes() {
143        let upper = "A".repeat(64);
144        let id = SnapshotId::from_hash(&upper).unwrap();
145        assert_eq!(id.hex(), "a".repeat(64));
146        assert!(id.as_str().starts_with("sha256:"));
147    }
148
149    #[test]
150    fn snapshot_id_from_hash_trims_whitespace() {
151        let hex = "b".repeat(64);
152        let padded = format!("  {hex}  ");
153        let id = SnapshotId::from_hash(&padded).unwrap();
154        assert_eq!(id.hex(), hex);
155    }
156
157    #[test]
158    fn snapshot_id_display_equals_as_str() {
159        let hex = "c".repeat(64);
160        let id = SnapshotId::from_hash(&hex).unwrap();
161        assert_eq!(id.to_string(), id.as_str());
162    }
163
164    #[test]
165    fn snapshot_id_serde_roundtrip() {
166        let hex = "d".repeat(64);
167        let id = SnapshotId::from_hash(&hex).unwrap();
168        let json = serde_json::to_string(&id).unwrap();
169        let back: SnapshotId = serde_json::from_str(&json).unwrap();
170        assert_eq!(back, id);
171    }
172
173    #[test]
174    fn vcs_state_serde_roundtrip() {
175        let state = VcsState {
176            namespace: "proj".into(),
177            current_branch: Some("main".into()),
178            last_committed_id: Some(SnapshotId::from_hash(&"0".repeat(64)).unwrap()),
179        };
180        let json = serde_json::to_string(&state).unwrap();
181        let back: VcsState = serde_json::from_str(&json).unwrap();
182        assert_eq!(back.namespace, state.namespace);
183        assert_eq!(back.current_branch, Some("main".into()));
184        assert_eq!(back.last_committed_id, state.last_committed_id);
185    }
186
187    #[test]
188    fn snapshot_coverage_v1_entities_and_edges_only() {
189        const { assert!(KG_V1_COVERAGE.entities) };
190        const { assert!(KG_V1_COVERAGE.edges) };
191        const { assert!(!KG_V1_COVERAGE.notes) };
192    }
193
194    #[test]
195    fn snapshot_coverage_serde_roundtrip() {
196        let cov = KG_V1_COVERAGE.clone();
197        let json = serde_json::to_string(&cov).unwrap();
198        let back: SnapshotCoverage = serde_json::from_str(&json).unwrap();
199        assert_eq!(back, cov);
200    }
201}