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, Serialize, Deserialize)]
pub struct KgSnapshot {
pub id: SnapshotId,
pub namespace: String,
pub parent_id: Option<SnapshotId>,
pub message: String,
pub author: Option<String>,
pub created_at: i64,
pub entity_count: u64,
pub edge_count: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KgBranch {
pub namespace: String,
pub name: String,
pub head_id: SnapshotId,
pub created_at: i64,
pub updated_at: i64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RemoteConfig {
pub name: String,
pub url: String,
pub auth: RemoteAuth,
pub namespace_map: Option<(String, String)>,
}
impl RemoteConfig {
pub fn remote_namespace<'a>(&'a self, local: &'a str) -> &'a str {
match &self.namespace_map {
Some((from, to)) if from == local => to.as_str(),
_ => local,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RemoteAuth {
None,
Bearer { token: String },
Basic { user: String, password: String },
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct VcsState {
pub namespace: String,
pub current_branch: Option<String>,
pub last_committed_id: Option<SnapshotId>,
pub dirty: bool,
}
#[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 remote_config_namespace_map() {
let cfg = RemoteConfig {
name: "origin".into(),
url: "https://example.com".into(),
auth: RemoteAuth::None,
namespace_map: Some(("local".into(), "shared".into())),
};
assert_eq!(cfg.remote_namespace("local"), "shared");
assert_eq!(cfg.remote_namespace("other"), "other");
}
}