use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::PackchainError;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub(crate) struct Sha40(String);
impl Sha40 {
pub(crate) fn try_new(s: impl Into<String>) -> Result<Self, PackchainError> {
let s = s.into();
if s.len() != 40 || !s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
return Err(PackchainError::InvalidSha { found: s });
}
Ok(Self(s))
}
pub(crate) fn from_oid(oid: &gix_hash::oid) -> Result<Self, PackchainError> {
use std::fmt::Write;
if oid.as_bytes().len() != 20 {
return Err(PackchainError::InvalidSha {
found: oid.to_string(),
});
}
let mut s = String::with_capacity(40);
write!(&mut s, "{}", oid.to_hex()).expect("writing to String never fails");
Ok(Self(s))
}
#[must_use]
pub(crate) fn as_str(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for Sha40 {
type Error = PackchainError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::try_new(value)
}
}
impl From<Sha40> for String {
fn from(value: Sha40) -> Self {
value.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct ChainManifest {
pub(crate) v: u32,
pub(crate) tip: Sha40,
pub(crate) full_at: Sha40,
pub(crate) segments: Vec<ChainSegment>,
}
impl ChainManifest {
pub(crate) const SCHEMA_VERSION: u32 = 1;
pub(crate) fn from_json_bytes(bytes: &[u8]) -> Result<Self, PackchainError> {
let parsed: Self = serde_json::from_slice(bytes)?;
if parsed.v != Self::SCHEMA_VERSION {
return Err(PackchainError::UnsupportedSchemaVersion {
found: parsed.v,
expected: Self::SCHEMA_VERSION,
});
}
Ok(parsed)
}
pub(crate) fn to_json_pretty(&self) -> Result<Vec<u8>, PackchainError> {
Ok(serde_json::to_vec_pretty(self)?)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct ChainSegment {
pub(crate) sha: Sha40,
pub(crate) parent_sha: Option<Sha40>,
pub(crate) pack: String,
pub(crate) bytes: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct PathIndex {
pub(crate) v: u32,
pub(crate) tip: Sha40,
pub(crate) tree: BTreeMap<String, PathNode>,
}
impl PathIndex {
pub(crate) const SCHEMA_VERSION: u32 = 2;
pub(crate) fn from_json_bytes(bytes: &[u8]) -> Result<Self, PackchainError> {
#[derive(Deserialize)]
struct VersionPeek {
v: u32,
}
let peek: VersionPeek = serde_json::from_slice(bytes)?;
if peek.v != Self::SCHEMA_VERSION {
return Err(PackchainError::UnsupportedSchemaVersion {
found: peek.v,
expected: Self::SCHEMA_VERSION,
});
}
let parsed: Self = serde_json::from_slice(bytes)?;
Ok(parsed)
}
pub(crate) fn to_json_pretty(&self) -> Result<Vec<u8>, PackchainError> {
Ok(serde_json::to_vec_pretty(self)?)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub(crate) enum PathNode {
Blob(Sha40),
Tree(BTreeMap<String, PathNode>),
}
#[cfg(test)]
mod tests {
use super::*;
const SHA_A: &str = "0123456789abcdef0123456789abcdef01234567";
const SHA_B: &str = "fedcba9876543210fedcba9876543210fedcba98";
const SHA_C: &str = "1111111111111111111111111111111111111111";
fn sha40(s: &str) -> Sha40 {
Sha40::try_new(s).expect("valid 40-hex sha in test fixture")
}
#[test]
fn sha40_accepts_40_lowercase_hex() {
let s = Sha40::try_new(SHA_A).unwrap();
assert_eq!(s.as_str(), SHA_A);
}
#[test]
fn sha40_rejects_uppercase() {
let err = Sha40::try_new("0123456789ABCDEF0123456789abcdef01234567").unwrap_err();
assert!(matches!(err, PackchainError::InvalidSha { .. }));
}
#[test]
fn sha40_rejects_wrong_length() {
for len in [0_usize, 1, 39, 41, 80] {
let candidate = "0".repeat(len);
let err = Sha40::try_new(&candidate).expect_err(&format!("len {len} must reject"));
assert!(matches!(err, PackchainError::InvalidSha { .. }));
}
}
#[test]
fn sha40_rejects_non_hex_characters() {
let err = Sha40::try_new("0123456789abcdef0123456789abcdef0123456g").unwrap_err();
assert!(matches!(err, PackchainError::InvalidSha { .. }));
}
#[test]
fn from_oid_round_trips_sha1() {
let bytes: [u8; 20] = [
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab,
0xcd, 0xef, 0x01, 0x23, 0x45, 0x67,
];
let oid = gix_hash::ObjectId::from(bytes);
let sha = Sha40::from_oid(&oid).unwrap();
assert_eq!(sha.as_str(), "0123456789abcdef0123456789abcdef01234567");
}
#[test]
fn from_oid_emits_lowercase_hex() {
let bytes = [0xab_u8; 20];
let oid = gix_hash::ObjectId::from(bytes);
let sha = Sha40::from_oid(&oid).unwrap();
assert_eq!(sha.as_str(), "abababababababababababababababababababab");
}
#[test]
fn from_oid_matches_try_new() {
let bytes: [u8; 20] = [
0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32, 0x10, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54,
0x32, 0x10, 0xfe, 0xdc, 0xba, 0x98,
];
let oid = gix_hash::ObjectId::from(bytes);
let via_oid = Sha40::from_oid(&oid).unwrap();
let via_string = Sha40::try_new(oid.to_string()).unwrap();
assert_eq!(via_oid, via_string);
}
#[test]
fn sha40_serializes_as_plain_json_string() {
let s = sha40(SHA_A);
let json = serde_json::to_string(&s).unwrap();
assert_eq!(json, format!("\"{SHA_A}\""));
}
#[test]
fn sha40_deserialize_validates() {
let s: Sha40 = serde_json::from_str(&format!("\"{SHA_A}\"")).unwrap();
assert_eq!(s.as_str(), SHA_A);
let err = serde_json::from_str::<Sha40>("\"not-a-sha\"").unwrap_err();
assert!(
err.to_string().contains("invalid 40-hex sha"),
"expected InvalidSha display in {err}",
);
}
fn fixture_chain() -> ChainManifest {
ChainManifest {
v: ChainManifest::SCHEMA_VERSION,
tip: sha40(SHA_A),
full_at: sha40(SHA_B),
segments: vec![ChainSegment {
sha: sha40(SHA_A),
parent_sha: Some(sha40(SHA_B)),
pack: format!("packs/{SHA_C}.pack"),
bytes: 4_096,
}],
}
}
#[test]
fn chain_manifest_round_trips_via_json() {
let chain = fixture_chain();
let bytes = chain.to_json_pretty().unwrap();
let decoded = ChainManifest::from_json_bytes(&bytes).unwrap();
assert_eq!(decoded, chain);
}
#[test]
fn chain_manifest_handles_empty_segments() {
let chain = ChainManifest {
v: ChainManifest::SCHEMA_VERSION,
tip: sha40(SHA_A),
full_at: sha40(SHA_A),
segments: Vec::new(),
};
let bytes = chain.to_json_pretty().unwrap();
let decoded = ChainManifest::from_json_bytes(&bytes).unwrap();
assert_eq!(decoded.segments.len(), 0);
}
#[test]
fn chain_manifest_segment_with_null_parent() {
let chain = ChainManifest {
v: ChainManifest::SCHEMA_VERSION,
tip: sha40(SHA_A),
full_at: sha40(SHA_A),
segments: vec![ChainSegment {
sha: sha40(SHA_A),
parent_sha: None,
pack: format!("packs/{SHA_C}.pack"),
bytes: 1_024,
}],
};
let bytes = chain.to_json_pretty().unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let parent_field = &parsed["segments"][0]["parent_sha"];
assert!(
parent_field.is_null(),
"parent_sha must be present as JSON null (not omitted), got {parent_field}",
);
let decoded = ChainManifest::from_json_bytes(&bytes).unwrap();
assert_eq!(decoded.segments[0].parent_sha, None);
}
#[test]
fn chain_manifest_rejects_unsupported_version() {
let mut chain = fixture_chain();
chain.v = 2;
let bytes = chain.to_json_pretty().unwrap();
let err = ChainManifest::from_json_bytes(&bytes).unwrap_err();
assert!(
matches!(
err,
PackchainError::UnsupportedSchemaVersion {
found: 2,
expected: 1,
},
),
"expected UnsupportedSchemaVersion(2, 1), got {err:?}",
);
}
#[test]
fn chain_manifest_rejects_v_zero() {
let mut chain = fixture_chain();
chain.v = 0;
let bytes = chain.to_json_pretty().unwrap();
let err = ChainManifest::from_json_bytes(&bytes).unwrap_err();
assert!(matches!(
err,
PackchainError::UnsupportedSchemaVersion { found: 0, .. },
));
}
#[test]
fn chain_manifest_rejects_invalid_sha_in_tip() {
let json = format!(r#"{{"v":1,"tip":"not-a-sha","full_at":"{SHA_B}","segments":[]}}"#);
let err = ChainManifest::from_json_bytes(json.as_bytes()).unwrap_err();
assert!(matches!(err, PackchainError::ParseJson(_)));
assert!(
err.to_string().contains("invalid 40-hex sha"),
"expected InvalidSha display in {err}",
);
}
fn fixture_path_index() -> PathIndex {
let src_subtree = BTreeMap::from([
("main.rs".to_string(), PathNode::Blob(sha40(SHA_A))),
("lib.rs".to_string(), PathNode::Blob(sha40(SHA_B))),
]);
PathIndex {
v: PathIndex::SCHEMA_VERSION,
tip: sha40(SHA_A),
tree: BTreeMap::from([
("Cargo.toml".to_string(), PathNode::Blob(sha40(SHA_C))),
("src".to_string(), PathNode::Tree(src_subtree)),
]),
}
}
#[test]
fn path_index_round_trips_via_json() {
let index = fixture_path_index();
let bytes = index.to_json_pretty().unwrap();
let decoded = PathIndex::from_json_bytes(&bytes).unwrap();
assert_eq!(decoded, index);
}
#[test]
fn path_node_blob_serializes_as_string_value() {
let bytes = serde_json::to_vec(&PathNode::Blob(sha40(SHA_A))).unwrap();
assert_eq!(bytes, format!("\"{SHA_A}\"").into_bytes());
}
#[test]
fn path_node_tree_serializes_as_object() {
let children = BTreeMap::from([("a".to_string(), PathNode::Blob(sha40(SHA_A)))]);
let bytes = serde_json::to_vec(&PathNode::Tree(children)).unwrap();
assert_eq!(bytes, format!("{{\"a\":\"{SHA_A}\"}}").into_bytes());
}
#[test]
fn path_node_untagged_round_trips_nested_shape() {
let json = format!(
r#"{{"v":2,"tip":"{SHA_A}","tree":{{"src":{{"main.rs":"{SHA_B}","mod":{{"inner.rs":"{SHA_C}"}}}}}}}}"#,
);
let decoded = PathIndex::from_json_bytes(json.as_bytes()).unwrap();
let src = decoded.tree.get("src").expect("src present");
let PathNode::Tree(src_children) = src else {
panic!("expected src to be a Tree, got {src:?}");
};
assert!(matches!(
src_children.get("main.rs"),
Some(PathNode::Blob(_))
));
assert!(matches!(src_children.get("mod"), Some(PathNode::Tree(_))));
}
#[test]
fn path_index_rejects_unsupported_version() {
let mut index = fixture_path_index();
index.v = 99;
let bytes = index.to_json_pretty().unwrap();
let err = PathIndex::from_json_bytes(&bytes).unwrap_err();
assert!(matches!(
err,
PackchainError::UnsupportedSchemaVersion {
found: 99,
expected: 2
},
));
}
#[test]
fn path_index_rejects_old_v1_commit_field_after_schema_bump() {
let json = format!(r#"{{"v":1,"commit":"{SHA_A}","tree":{{}}}}"#);
let err = PathIndex::from_json_bytes(json.as_bytes()).unwrap_err();
assert!(matches!(
err,
PackchainError::UnsupportedSchemaVersion {
found: 1,
expected: 2,
},
));
}
#[test]
fn path_index_rejects_invalid_blob_sha() {
let json = format!(r#"{{"v":2,"tip":"{SHA_A}","tree":{{"a":"not-a-sha"}}}}"#);
let err = PathIndex::from_json_bytes(json.as_bytes()).unwrap_err();
assert!(matches!(err, PackchainError::ParseJson(_)));
}
}