use std::collections::HashMap;
use std::io::{Read as _, Seek as _, SeekFrom, Write as _};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use crate::objects::ObjectId;
use super::Result;
#[derive(Debug, Clone, Copy)]
enum Side {
Raw,
Real,
}
impl Side {
fn dir(self) -> &'static str {
match self {
Self::Raw => "raw",
Self::Real => "real",
}
}
}
type Pairs = HashMap<ObjectId, Vec<ObjectId>>;
#[derive(Debug)]
pub struct GraftLog {
dir: PathBuf,
read: Mutex<HashMap<PathBuf, Pairs>>,
}
impl GraftLog {
#[must_use]
pub fn at(dir: impl Into<PathBuf>) -> Self {
Self {
dir: dir.into(),
read: Mutex::new(HashMap::new()),
}
}
#[must_use]
pub fn dir(&self) -> &Path {
&self.dir
}
fn file(&self, side: Side, oid: &ObjectId) -> PathBuf {
let hex = oid.to_hex();
self.dir.join(side.dir()).join(&hex[..2])
}
fn lookup(&self, side: Side, oid: &ObjectId) -> Result<Vec<ObjectId>> {
let file = self.file(side, oid);
let mut read = self.read.lock().unwrap_or_else(|e| e.into_inner());
if !read.contains_key(&file) {
let pairs = read_pairs(&file)?;
read.insert(file.clone(), pairs);
}
Ok(read
.get(&file)
.and_then(|pairs| pairs.get(oid))
.cloned()
.unwrap_or_default())
}
pub fn grafted(&self, raw: &ObjectId) -> Result<Option<ObjectId>> {
Ok(self.lookup(Side::Raw, raw)?.into_iter().next())
}
pub fn raws(&self, real: &ObjectId) -> Result<Vec<ObjectId>> {
self.lookup(Side::Real, real)
}
pub fn record(&self, pairs: &[(ObjectId, ObjectId)]) -> Result<()> {
if pairs.is_empty() {
return Ok(());
}
let back: Vec<(ObjectId, ObjectId)> =
pairs.iter().map(|(raw, real)| (*real, *raw)).collect();
self.append(Side::Real, &back)?;
self.append(Side::Raw, pairs)
}
fn append(&self, side: Side, pairs: &[(ObjectId, ObjectId)]) -> Result<()> {
let mut by_file: HashMap<PathBuf, String> = HashMap::new();
{
let mut read = self.read.lock().unwrap_or_else(|e| e.into_inner());
for (from, to) in pairs {
let file = self.file(side, from);
by_file.entry(file.clone()).or_default().push_str(&format!(
"{} {}\n",
from.to_hex(),
to.to_hex()
));
if let Some(known) = read.get_mut(&file) {
let tos = known.entry(*from).or_default();
if !tos.contains(to) {
tos.push(*to);
}
}
}
}
for (file, lines) in by_file {
if let Some(parent) = file.parent() {
std::fs::create_dir_all(parent)?;
}
let mut out = std::fs::OpenOptions::new()
.create(true)
.read(true)
.append(true)
.open(&file)?;
let text = if ends_mid_line(&mut out)? {
format!("\n{lines}")
} else {
lines
};
out.write_all(text.as_bytes())?;
}
Ok(())
}
}
fn ends_mid_line(file: &mut std::fs::File) -> std::io::Result<bool> {
let len = file.metadata()?.len();
if len == 0 {
return Ok(false);
}
file.seek(SeekFrom::Start(len - 1))?;
let mut last = [0u8; 1];
file.read_exact(&mut last)?;
Ok(last[0] != b'\n')
}
fn read_pairs(file: &Path) -> Result<Pairs> {
let text = match std::fs::read(file) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Pairs::new()),
Err(e) => return Err(e.into()),
};
let mut pairs = Pairs::new();
let complete = text
.iter()
.rposition(|b| *b == b'\n')
.map_or(&text[..0], |end| &text[..end]);
for line in complete.split(|b| *b == b'\n') {
let Some((from, to)) = std::str::from_utf8(line)
.ok()
.and_then(|line| line.split_once(' '))
else {
continue;
};
let (Ok(from), Ok(to)) = (ObjectId::from_hex(from), ObjectId::from_hex(to)) else {
continue;
};
if from.algo() != to.algo() {
continue;
}
let tos = pairs.entry(from).or_default();
if !tos.contains(&to) {
tos.push(to);
}
}
Ok(pairs)
}
#[cfg(test)]
mod tests {
use super::*;
fn oid(n: u32) -> ObjectId {
ObjectId::from_hex(&format!("{n:040x}")).expect("oid")
}
fn in_shard(prefix: &str, n: u32) -> ObjectId {
ObjectId::from_hex(&format!("{prefix}{n:038x}")).expect("oid")
}
#[test]
fn pairs_are_found_both_ways_by_this_log_and_later_ones() {
let dir = tempfile::tempdir().expect("tempdir");
let log = GraftLog::at(dir.path().join("grafts"));
assert_eq!(log.dir(), dir.path().join("grafts"));
assert_eq!(log.grafted(&oid(1)).expect("lookup"), None);
assert!(log.raws(&oid(100)).expect("lookup").is_empty());
log.record(&[(oid(1), oid(100)), (oid(2), oid(100))])
.expect("record");
log.record(&[(oid(3), oid(300)), (oid(1), oid(100))])
.expect("record");
assert_eq!(log.grafted(&oid(1)).expect("lookup"), Some(oid(100)));
assert_eq!(log.grafted(&oid(3)).expect("lookup"), Some(oid(300)));
assert_eq!(log.raws(&oid(100)).expect("lookup"), vec![oid(1), oid(2)]);
let later = GraftLog::at(dir.path().join("grafts"));
assert_eq!(later.grafted(&oid(2)).expect("lookup"), Some(oid(100)));
assert_eq!(later.raws(&oid(100)).expect("lookup"), vec![oid(1), oid(2)]);
assert_eq!(later.raws(&oid(300)).expect("lookup"), vec![oid(3)]);
assert_eq!(later.grafted(&oid(300)).expect("lookup"), None);
assert_eq!(later.raws(&oid(3)).expect("lookup"), Vec::new());
}
#[test]
fn files_are_split_by_the_first_two_hex_digits_and_appended_once_per_batch() {
let dir = tempfile::tempdir().expect("tempdir");
let log = GraftLog::at(dir.path());
let (a, b, c) = (in_shard("ab", 1), in_shard("ab", 2), in_shard("cd", 3));
let real = in_shard("ef", 9);
log.record(&[(a, real), (b, real), (c, real)])
.expect("record");
let raw_ab = std::fs::read_to_string(dir.path().join("raw/ab")).expect("raw/ab");
assert_eq!(
raw_ab,
format!(
"{} {}\n{} {}\n",
a.to_hex(),
real.to_hex(),
b.to_hex(),
real.to_hex()
)
);
assert!(dir.path().join("raw/cd").is_file());
let back = std::fs::read_to_string(dir.path().join("real/ef")).expect("real/ef");
assert_eq!(back.lines().count(), 3);
assert!(back.starts_with(&real.to_hex()));
let empty = tempfile::tempdir().expect("tempdir");
GraftLog::at(empty.path().join("none"))
.record(&[])
.expect("record");
assert!(!empty.path().join("none").exists());
}
#[test]
fn a_line_cut_short_is_ignored_and_the_next_batch_starts_a_new_line() {
let dir = tempfile::tempdir().expect("tempdir");
let (a, real) = (in_shard("12", 1), in_shard("34", 2));
let (b, other) = (in_shard("12", 3), in_shard("34", 4));
std::fs::create_dir_all(dir.path().join("raw")).expect("mkdir");
std::fs::write(
dir.path().join("raw/12"),
format!(
"{} {}\n{} {}",
a.to_hex(),
real.to_hex(),
b.to_hex(),
&other.to_hex()[..9]
),
)
.expect("write");
let log = GraftLog::at(dir.path());
assert_eq!(log.grafted(&a).expect("lookup"), Some(real));
assert_eq!(log.grafted(&b).expect("lookup"), None);
log.record(&[(b, other)]).expect("record");
let later = GraftLog::at(dir.path());
assert_eq!(later.grafted(&b).expect("lookup"), Some(other));
assert_eq!(later.grafted(&a).expect("lookup"), Some(real));
assert_eq!(later.raws(&other).expect("lookup"), vec![b]);
std::fs::write(dir.path().join("raw/56"), "not a pair\n\nxyz abc\n").expect("write");
assert_eq!(later.grafted(&in_shard("56", 1)).expect("lookup"), None);
}
#[test]
fn check_ins_recording_at_once_keep_every_pair() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().to_path_buf();
let real = in_shard("aa", 0);
let writers: Vec<_> = (0..8u32)
.map(|w| {
let path = path.clone();
std::thread::spawn(move || {
let log = GraftLog::at(path);
for i in 0..25u32 {
let raw = in_shard("bb", w * 100 + i);
log.record(&[(raw, real)]).expect("record");
}
})
})
.collect();
for writer in writers {
writer.join().expect("writer");
}
let log = GraftLog::at(path);
assert_eq!(log.raws(&real).expect("lookup").len(), 200);
for w in 0..8u32 {
for i in 0..25u32 {
assert_eq!(
log.grafted(&in_shard("bb", w * 100 + i)).expect("lookup"),
Some(real)
);
}
}
}
}