use std::collections::BTreeMap;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use tape_crypto::hash::hash;
pub const INDEX_NAME: &str = "git/refs.json";
pub const INDEX_CONTENT_TYPE: &str = "application/json";
pub const INDEX_VERSION: u64 = 1;
const DIGEST_CHARS: usize = 32;
pub fn digest(bytes: &[u8]) -> String {
let mut hex = hex::encode(hash(bytes).to_bytes());
hex.truncate(DIGEST_CHARS);
hex
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct PackEntry {
pub track: u64,
pub size: u64,
#[serde(alias = "sha256")]
pub digest: String,
#[serde(default, skip_serializing_if = "is_false")]
pub stream: bool,
}
fn is_false(value: &bool) -> bool {
!*value
}
impl PackEntry {
pub fn matches(&self, bytes: &[u8]) -> bool {
let full = hex::encode(hash(bytes).to_bytes());
let width = self.digest.len().min(full.len());
self.digest[..width] == full[..width]
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Index {
pub version: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub head: Option<String>,
#[serde(default)]
pub refs: BTreeMap<String, String>,
#[serde(default)]
pub packs: Vec<PackEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<u64>,
}
impl Default for Index {
fn default() -> Self {
Self {
version: INDEX_VERSION,
head: None,
refs: BTreeMap::new(),
packs: Vec::new(),
parent: None,
}
}
}
impl Index {
pub fn decode(bytes: &[u8]) -> Result<Self> {
Ok(serde_json::from_slice(bytes)?)
}
pub fn encode(&self) -> Result<Vec<u8>> {
Ok(serde_json::to_vec(self)?)
}
pub fn tips(&self) -> Vec<String> {
let mut tips = Vec::with_capacity(self.refs.len());
for object_id in self.refs.values() {
tips.push(object_id.clone());
}
tips
}
pub fn has_pack(&self, track: u64) -> bool {
for entry in &self.packs {
if entry.track == track {
return true;
}
}
false
}
pub fn absorb_packs(&mut self, other: &Index) {
for entry in &other.packs {
if !self.has_pack(entry.track) {
self.packs.push(entry.clone());
}
}
self.packs.sort_by_key(|entry| entry.track);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(track: u64, bytes: &[u8]) -> PackEntry {
PackEntry {
track,
size: bytes.len() as u64,
digest: digest(bytes),
stream: false,
}
}
#[test]
fn digest_matching() {
let pack = entry(1, b"pack contents");
assert!(pack.matches(b"pack contents"));
assert!(!pack.matches(b"pack contentt"));
}
#[test]
fn legacy_digest() {
let mut pack = entry(1, b"pack contents");
pack.digest = hex::encode(hash(b"pack contents").to_bytes());
assert!(pack.matches(b"pack contents"));
assert!(!pack.matches(b"something else"));
}
#[test]
fn sha256_alias() {
let json = br#"{"version":1,"packs":[{"track":3,"size":9,"sha256":"abcdef"}]}"#;
let index = Index::decode(json).expect("index should decode");
assert_eq!(index.packs[0].digest, "abcdef");
}
#[test]
fn round_trip() {
let mut index = Index {
head: Some("refs/heads/main".to_string()),
..Default::default()
};
index
.refs
.insert("refs/heads/main".to_string(), "a".repeat(40));
index.packs.push(entry(7, b"pack"));
let decoded = Index::decode(&index.encode().expect("encode")).expect("decode");
assert_eq!(decoded.head.as_deref(), Some("refs/heads/main"));
assert_eq!(decoded.refs.len(), 1);
assert_eq!(decoded.packs[0].track, 7);
}
#[test]
fn absorb_packs() {
let mut ours = Index::default();
ours.packs.push(entry(4, b"ours"));
let mut theirs = Index::default();
theirs.packs.push(entry(2, b"theirs"));
theirs.packs.push(entry(4, b"ours"));
ours.absorb_packs(&theirs);
let mut tracks = Vec::new();
for pack in &ours.packs {
tracks.push(pack.track);
}
assert_eq!(tracks, vec![2, 4]);
}
#[test]
fn stays_inline() {
const INLINE_LIMIT: usize = 825;
let mut index = Index {
head: Some("refs/heads/main".to_string()),
..Default::default()
};
for name in ["main", "develop", "release", "feature-one", "feature-two"] {
index
.refs
.insert(format!("refs/heads/{name}"), "a".repeat(40));
}
for track in 0..5 {
index.packs.push(entry(track, b"pack"));
}
let encoded = index.encode().expect("encode");
assert!(
encoded.len() < INLINE_LIMIT,
"index grew to {} bytes",
encoded.len()
);
}
}