use crate::cache::CachedContent;
use crate::cache::lru::content_size_of;
use crate::cache::obj_cache::rel_to_base;
use crate::frontmatter::frontmatter;
use serde::Serialize;
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::mpsc::{Sender, SyncSender};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::Duration;
pub const ARCHIVE_DIR_NAME: &str = ".peisar-cache";
#[derive(Debug, Clone)]
pub struct LocatedEntry {
pub shard: String,
pub offset: u64,
pub length: u64,
pub kind: &'static str,
pub size: u64,
pub hash: String,
pub frontmatter: Option<Value>,
}
pub type ArchiveIndex = HashMap<PathBuf, LocatedEntry>;
#[derive(Debug)]
pub(crate) enum ArchiveCommand {
Upsert(PathBuf, CachedContent),
Remove(PathBuf),
SyncAll(HashMap<PathBuf, CachedContent>),
FlushNow(SyncSender<()>),
}
#[derive(Clone)]
pub struct ArchiveHandle {
tx: Sender<ArchiveCommand>,
index: Arc<Mutex<ArchiveIndex>>,
dir: PathBuf,
worker: Arc<Mutex<Option<JoinHandle<()>>>>,
}
impl ArchiveHandle {
pub fn new(cwd: PathBuf, shard_bytes: u64, interval: Duration) -> Self {
let dir = cwd.join(ARCHIVE_DIR_NAME);
let _ = fs::create_dir_all(&dir);
let index: Arc<Mutex<ArchiveIndex>> = Arc::new(Mutex::new(HashMap::new()));
let (tx, rx) = std::sync::mpsc::channel::<ArchiveCommand>();
let worker = std::thread::spawn({
let dir = dir.clone();
let index = Arc::clone(&index);
move || {
let mut state = Worker {
dir: dir.clone(),
shard_bytes,
interval,
pending: HashMap::new(),
pending_bytes: 0,
seq: next_shard_seq(&dir),
index,
};
state.run(rx);
}
});
Self {
tx,
index,
dir,
worker: Arc::new(Mutex::new(Some(worker))),
}
}
pub fn upsert(&self, path: PathBuf, content: CachedContent) {
let _ = self.tx.send(ArchiveCommand::Upsert(path, content));
}
pub fn remove(&self, path: PathBuf) {
let _ = self.tx.send(ArchiveCommand::Remove(path));
}
pub fn sync_all(&self, map: HashMap<PathBuf, CachedContent>) {
let _ = self.tx.send(ArchiveCommand::SyncAll(map));
}
pub fn flush(&self) {
let (tx, rx) = std::sync::mpsc::sync_channel(1);
if self.tx.send(ArchiveCommand::FlushNow(tx)).is_ok() {
let _ = rx.recv();
}
}
pub fn read(&self, path: &Path) -> Option<CachedContent> {
let (shard, offset, length) = {
let index = self.index.lock().unwrap();
let entry = index.get(path)?;
(entry.shard.clone(), entry.offset, entry.length)
};
let mut file = fs::File::open(self.dir.join(&shard)).ok()?;
let mut buf = vec![0u8; length as usize];
file.seek(SeekFrom::Start(offset)).ok()?;
file.read_exact(&mut buf).ok()?;
bincode::deserialize::<CachedContent>(&buf).ok()
}
pub fn contains(&self, path: &Path) -> bool {
self.index.lock().unwrap().contains_key(path)
}
pub fn entry_count(&self) -> usize {
self.index.lock().unwrap().len()
}
pub fn keys(&self) -> Vec<PathBuf> {
self.index.lock().unwrap().keys().cloned().collect()
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn map_json(&self) -> String {
let index = self.index.lock().unwrap();
let files: serde_json::Map<String, Value> = index
.iter()
.map(|(path, entry)| {
let key = rel_to_base(path, &cwd_of(&self.dir))
.to_string_lossy()
.replace('\\', "/");
(
key,
serde_json::to_value(MapFileEntry {
shard: &entry.shard,
offset: entry.offset,
length: entry.length,
kind: entry.kind,
size: entry.size,
hash: &entry.hash,
frontmatter: &entry.frontmatter,
})
.unwrap_or(Value::Null),
)
})
.collect();
serde_json::to_string_pretty(&MapFile { version: 1, files })
.unwrap_or_else(|_| "{}".to_string())
}
pub fn close(&mut self) {
let (tx, _rx) = std::sync::mpsc::channel();
self.tx = tx;
let worker = self.worker.lock().unwrap().take();
if let Some(worker) = worker {
let _ = worker.join();
}
}
}
impl Drop for ArchiveHandle {
fn drop(&mut self) {
let has_worker = self.worker.lock().unwrap().is_some();
if has_worker {
self.close();
}
}
}
fn cwd_of(dir: &Path) -> PathBuf {
dir.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| dir.to_path_buf())
}
#[derive(Serialize)]
struct MapFile {
version: u32,
files: serde_json::Map<String, Value>,
}
#[derive(Serialize)]
struct MapFileEntry<'a> {
shard: &'a str,
offset: u64,
length: u64,
kind: &'a str,
size: u64,
hash: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
frontmatter: &'a Option<Value>,
}
struct Worker {
dir: PathBuf,
shard_bytes: u64,
interval: Duration,
pending: HashMap<PathBuf, CachedContent>,
pending_bytes: u64,
seq: u64,
index: Arc<Mutex<ArchiveIndex>>,
}
impl Worker {
fn run(&mut self, rx: std::sync::mpsc::Receiver<ArchiveCommand>) {
loop {
match rx.recv_timeout(self.interval) {
Ok(cmd) => self.handle(cmd),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if !self.pending.is_empty() {
self.flush();
}
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
self.flush();
break;
}
}
}
}
fn handle(&mut self, cmd: ArchiveCommand) {
match cmd {
ArchiveCommand::Upsert(path, content) => {
self.upsert_pending(path, content);
if self.shard_bytes > 0 && self.pending_bytes >= self.shard_bytes {
self.flush();
}
}
ArchiveCommand::Remove(path) => {
self.pending.remove(&path);
self.index.lock().unwrap().remove(&path);
}
ArchiveCommand::SyncAll(map) => {
self.pending = map;
self.pending_bytes = self.pending.values().map(content_size_of).sum();
let live = self.pending.keys().cloned().collect::<Vec<_>>();
self.index.lock().unwrap().retain(|p, _| live.contains(p));
if self.shard_bytes > 0 && self.pending_bytes >= self.shard_bytes {
self.flush();
}
}
ArchiveCommand::FlushNow(reply) => {
self.flush();
let _ = reply.send(());
}
}
}
fn upsert_pending(&mut self, path: PathBuf, content: CachedContent) {
let size = content_size_of(&content);
if let Some(old) = self.pending.insert(path, content) {
self.pending_bytes = self.pending_bytes.saturating_sub(content_size_of(&old));
}
self.pending_bytes = self.pending_bytes.saturating_add(size);
}
fn flush(&mut self) {
if self.pending.is_empty() {
self.write_map_json();
self.gc_shards();
return;
}
let mut entries: Vec<(PathBuf, CachedContent)> =
std::mem::take(&mut self.pending).into_iter().collect();
self.pending_bytes = 0;
entries.sort_by(|a, b| a.0.cmp(&b.0));
let mut chunk: Vec<(PathBuf, CachedContent)> = Vec::new();
let mut chunk_bytes = 0u64;
for entry in entries {
let size = content_size_of(&entry.1);
chunk_bytes = chunk_bytes.saturating_add(size);
chunk.push(entry);
if self.shard_bytes > 0 && chunk_bytes >= self.shard_bytes {
chunk_bytes = 0;
self.write_shard(std::mem::take(&mut chunk));
}
}
if !chunk.is_empty() {
self.write_shard(chunk);
}
self.write_map_json();
self.gc_shards();
}
fn write_shard(&mut self, entries: Vec<(PathBuf, CachedContent)>) {
self.seq += 1;
let shard_name = format!("shard-{seq:06}.bin", seq = self.seq);
let tmp_name = format!("{shard_name}.tmp");
let mut located: Vec<(PathBuf, LocatedEntry)> = Vec::with_capacity(entries.len());
let mut buf: Vec<u8> = Vec::new();
let mut offset = 0u64;
for (path, content) in entries {
let bytes = bincode::serialize(&content).unwrap_or_default();
if bytes.is_empty() {
continue;
}
let kind = match content {
CachedContent::Text(_) => "text",
CachedContent::Binary(_) => "binary",
};
let (hash, frontmatter) = match &content {
CachedContent::Text(s) => (
blake3::hash(s.as_bytes()).to_hex().to_string(),
match frontmatter(s.clone()) {
Ok(parsed) => parsed.yaml_data().cloned(),
Err(_) => None,
},
),
CachedContent::Binary(b) => (blake3::hash(b).to_hex().to_string(), None),
};
let length = bytes.len() as u64;
let size = content_size_of(&content);
buf.extend_from_slice(&bytes);
located.push((
path,
LocatedEntry {
shard: shard_name.clone(),
offset,
length,
kind,
size,
hash,
frontmatter,
},
));
offset += length;
}
if let Ok(mut tmp) = fs::File::create(self.dir.join(&tmp_name)) {
if tmp.write_all(&buf).is_ok() {
let _ = fs::rename(self.dir.join(&tmp_name), self.dir.join(&shard_name));
} else {
let _ = fs::remove_file(self.dir.join(&tmp_name));
}
}
let mut index = self.index.lock().unwrap();
for (path, entry) in located {
index.insert(path, entry);
}
}
fn write_map_json(&self) {
let handle = ArchiveHandle {
tx: {
let (tx, _rx) = std::sync::mpsc::channel();
tx
},
index: Arc::clone(&self.index),
dir: self.dir.clone(),
worker: Arc::new(Mutex::new(None)),
};
let json = handle.map_json();
let tmp = self.dir.join("map.json.tmp");
if fs::write(&tmp, json.as_bytes()).is_ok() {
let _ = fs::rename(&tmp, self.dir.join("map.json"));
}
}
fn gc_shards(&self) {
let referenced: std::collections::HashSet<String> = self
.index
.lock()
.unwrap()
.values()
.map(|e| e.shard.clone())
.collect();
let entries = match fs::read_dir(&self.dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let p = entry.path();
if !p.is_file() {
continue;
}
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.starts_with("shard-") && name.ends_with(".bin") && !referenced.contains(name) {
let _ = fs::remove_file(&p);
}
}
}
}
fn next_shard_seq(dir: &Path) -> u64 {
let mut max = 0u64;
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if let Some(num) = name
.strip_prefix("shard-")
.and_then(|s| s.strip_suffix(".bin"))
.and_then(|s| s.parse::<u64>().ok())
{
max = max.max(num);
}
}
}
max
}
pub(crate) fn ensure_gitignore_entry(cwd: &Path) -> io::Result<()> {
use std::fs::OpenOptions;
let gitignore_path = cwd.join(".gitignore");
if gitignore_path.exists() {
let existing = fs::read_to_string(&gitignore_path)?;
let mut has_entry = false;
for line in existing.lines() {
if line.trim() == ARCHIVE_DIR_NAME {
has_entry = true;
break;
}
}
if !has_entry {
let mut file = OpenOptions::new().append(true).open(&gitignore_path)?;
if !existing.ends_with('\n') {
file.write_all(b"\n")?;
}
file.write_all(format!("{ARCHIVE_DIR_NAME}\n").as_bytes())?;
}
} else {
fs::write(gitignore_path, format!("{ARCHIVE_DIR_NAME}\n"))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
use std::time::Duration as D;
fn tmp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"peisar_archive_{tag}_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn upsert_flush_and_read_back() {
let cwd = tmp_dir("roundtrip");
let mut archive = ArchiveHandle::new(cwd.clone(), 1024, D::from_millis(60_000));
let md = cwd.join("docs").join("page.md");
archive.upsert(
md.clone(),
CachedContent::Text("---\ntitle: Hello\n---\n\n# Hello\n".to_string()),
);
archive.flush();
assert!(archive.contains(&md));
match archive.read(&md) {
Some(CachedContent::Text(text)) => assert!(text.contains("# Hello")),
other => panic!("expected text, got {other:?}"),
}
let map = fs::read_to_string(cwd.join(ARCHIVE_DIR_NAME).join("map.json")).unwrap();
let map: Value = serde_json::from_str(&map).unwrap();
assert_eq!(map["version"], 1);
let entry = &map["files"]["docs/page.md"];
assert_eq!(entry["kind"], "text");
assert_eq!(entry["frontmatter"]["title"], "Hello");
assert!(entry["hash"].as_str().unwrap().len() == 64);
archive.close();
let _ = fs::remove_dir_all(&cwd);
}
#[test]
fn binary_entries_roundtrip() {
let cwd = tmp_dir("binary");
let mut archive = ArchiveHandle::new(cwd.clone(), 0, D::from_millis(60_000));
let png = cwd.join("logo.png");
let bytes = vec![1u8, 2, 3, 4];
archive.upsert(png.clone(), CachedContent::Binary(bytes.clone()));
archive.flush();
match archive.read(&png) {
Some(CachedContent::Binary(b)) => assert_eq!(b, bytes),
other => panic!("expected binary, got {other:?}"),
}
archive.close();
let _ = fs::remove_dir_all(&cwd);
}
#[test]
fn remove_drops_entry_and_gc_collects_orphan_shard() {
let cwd = tmp_dir("remove");
let mut archive = ArchiveHandle::new(cwd.clone(), 0, D::from_millis(60_000));
let a = cwd.join("a.md");
archive.upsert(a.clone(), CachedContent::Text("A".to_string()));
archive.flush();
assert!(archive.contains(&a));
archive.remove(a.clone());
archive.flush();
assert!(!archive.contains(&a));
assert!(archive.read(&a).is_none());
let map = fs::read_to_string(cwd.join(ARCHIVE_DIR_NAME).join("map.json")).unwrap();
let map: Value = serde_json::from_str(&map).unwrap();
assert!(map["files"].get("a.md").is_none());
let shards: Vec<_> = fs::read_dir(cwd.join(ARCHIVE_DIR_NAME))
.unwrap()
.flatten()
.filter(|e| {
let n = e.file_name();
let n = n.to_string_lossy();
n.starts_with("shard-") && n.ends_with(".bin")
})
.collect();
assert!(shards.is_empty(), "orphan shards should be GC'd");
archive.close();
let _ = fs::remove_dir_all(&cwd);
}
#[test]
fn sync_all_replaces_everything() {
let cwd = tmp_dir("sync");
let mut archive = ArchiveHandle::new(cwd.clone(), 0, D::from_millis(60_000));
archive.upsert(cwd.join("old.md"), CachedContent::Text("old".to_string()));
archive.flush();
assert!(archive.entry_count() == 1);
let mut fresh = HashMap::new();
fresh.insert(cwd.join("new.md"), CachedContent::Text("new".to_string()));
archive.sync_all(fresh);
archive.flush();
assert!(!archive.contains(&cwd.join("old.md")));
assert!(archive.contains(&cwd.join("new.md")));
archive.close();
let _ = fs::remove_dir_all(&cwd);
}
#[test]
fn interval_flushes_without_size_threshold() {
let cwd = tmp_dir("interval");
let mut archive = ArchiveHandle::new(cwd.clone(), 0, D::from_millis(50));
archive.upsert(cwd.join("lazy.md"), CachedContent::Text("lazy".to_string()));
assert!(!archive.contains(&cwd.join("lazy.md")));
thread::sleep(D::from_millis(250));
assert!(archive.contains(&cwd.join("lazy.md")));
archive.close();
let _ = fs::remove_dir_all(&cwd);
}
#[test]
fn drop_flushes_pending_entries() {
let cwd = tmp_dir("drop");
let md = cwd.join("gone.md");
{
let archive = ArchiveHandle::new(cwd.clone(), 0, D::from_millis(60_000));
archive.upsert(md.clone(), CachedContent::Text("bye".to_string()));
}
let map = fs::read_to_string(cwd.join(ARCHIVE_DIR_NAME).join("map.json")).unwrap();
assert!(map.contains("gone.md"));
let _ = fs::remove_dir_all(&cwd);
}
#[test]
fn gitignore_entry_added_once() {
let cwd = tmp_dir("gitignore");
fs::write(cwd.join(".gitignore"), "target\n").unwrap();
ensure_gitignore_entry(&cwd).unwrap();
ensure_gitignore_entry(&cwd).unwrap();
let git = fs::read_to_string(cwd.join(".gitignore")).unwrap();
assert_eq!(
git.lines().filter(|l| l.trim() == ARCHIVE_DIR_NAME).count(),
1
);
let fresh = tmp_dir("gitignore_fresh");
ensure_gitignore_entry(&fresh).unwrap();
let git = fs::read_to_string(fresh.join(".gitignore")).unwrap();
assert_eq!(git.trim(), ARCHIVE_DIR_NAME);
let _ = fs::remove_dir_all(&cwd);
let _ = fs::remove_dir_all(&fresh);
}
}