use serde::{Deserialize, Serialize};
use crate::error::VcsError;
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SnapshotId(String);
impl SnapshotId {
pub fn from_hash(hex: &str) -> Result<Self, VcsError> {
let hex = hex.trim();
if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(VcsError::InvalidSnapshotId(format!(
"expected 64 hex chars, got {:?}",
hex
)));
}
Ok(Self(format!("sha256:{}", hex.to_ascii_lowercase())))
}
pub fn from_prefixed(s: &str) -> Result<Self, VcsError> {
let hex = s.strip_prefix("sha256:").ok_or_else(|| {
VcsError::InvalidSnapshotId(format!("missing sha256: prefix in {:?}", s))
})?;
Self::from_hash(hex)
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn hex(&self) -> &str {
&self.0["sha256:".len()..]
}
}
impl std::fmt::Display for SnapshotId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SnapshotCoverage {
pub entities: bool,
pub edges: bool,
pub notes: bool,
}
pub const KG_V1_COVERAGE: SnapshotCoverage = SnapshotCoverage {
entities: true,
edges: true,
notes: false,
};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct VcsState {
pub namespace: String,
pub current_branch: Option<String>,
pub last_committed_id: Option<SnapshotId>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snapshot_id_from_hash_valid() {
let hex = "a".repeat(64);
let id = SnapshotId::from_hash(&hex).unwrap();
assert_eq!(id.as_str(), format!("sha256:{}", hex));
assert_eq!(id.hex(), hex);
}
#[test]
fn snapshot_id_from_hash_rejects_short() {
let err = SnapshotId::from_hash("abc").unwrap_err();
assert!(matches!(err, VcsError::InvalidSnapshotId(_)));
}
#[test]
fn snapshot_id_from_hash_rejects_non_hex() {
let invalid = "z".repeat(64);
let err = SnapshotId::from_hash(&invalid).unwrap_err();
assert!(matches!(err, VcsError::InvalidSnapshotId(_)));
}
#[test]
fn snapshot_id_from_prefixed() {
let hex = "b".repeat(64);
let prefixed = format!("sha256:{}", hex);
let id = SnapshotId::from_prefixed(&prefixed).unwrap();
assert_eq!(id.as_str(), prefixed);
}
#[test]
fn snapshot_id_from_prefixed_rejects_missing_prefix() {
let err = SnapshotId::from_prefixed(&"b".repeat(64)).unwrap_err();
assert!(matches!(err, VcsError::InvalidSnapshotId(_)));
}
#[test]
fn snapshot_id_from_hash_accepts_uppercase_and_normalizes() {
let upper = "A".repeat(64);
let id = SnapshotId::from_hash(&upper).unwrap();
assert_eq!(id.hex(), "a".repeat(64));
assert!(id.as_str().starts_with("sha256:"));
}
#[test]
fn snapshot_id_from_hash_trims_whitespace() {
let hex = "b".repeat(64);
let padded = format!(" {hex} ");
let id = SnapshotId::from_hash(&padded).unwrap();
assert_eq!(id.hex(), hex);
}
#[test]
fn snapshot_id_display_equals_as_str() {
let hex = "c".repeat(64);
let id = SnapshotId::from_hash(&hex).unwrap();
assert_eq!(id.to_string(), id.as_str());
}
#[test]
fn snapshot_id_serde_roundtrip() {
let hex = "d".repeat(64);
let id = SnapshotId::from_hash(&hex).unwrap();
let json = serde_json::to_string(&id).unwrap();
let back: SnapshotId = serde_json::from_str(&json).unwrap();
assert_eq!(back, id);
}
#[test]
fn vcs_state_serde_roundtrip() {
let state = VcsState {
namespace: "proj".into(),
current_branch: Some("main".into()),
last_committed_id: Some(SnapshotId::from_hash(&"0".repeat(64)).unwrap()),
};
let json = serde_json::to_string(&state).unwrap();
let back: VcsState = serde_json::from_str(&json).unwrap();
assert_eq!(back.namespace, state.namespace);
assert_eq!(back.current_branch, Some("main".into()));
assert_eq!(back.last_committed_id, state.last_committed_id);
}
#[test]
fn snapshot_coverage_v1_entities_and_edges_only() {
const { assert!(KG_V1_COVERAGE.entities) };
const { assert!(KG_V1_COVERAGE.edges) };
const { assert!(!KG_V1_COVERAGE.notes) };
}
#[test]
fn snapshot_coverage_serde_roundtrip() {
let cov = KG_V1_COVERAGE.clone();
let json = serde_json::to_string(&cov).unwrap();
let back: SnapshotCoverage = serde_json::from_str(&json).unwrap();
assert_eq!(back, cov);
}
}