use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use ckg_core::{Error, Result};
use fs2::FileExt;
use serde::{Deserialize, Serialize};
struct LockGuard(fs::File);
impl Drop for LockGuard {
fn drop(&mut self) {
let _ = FileExt::unlock(&self.0);
}
}
const LOCK_TIMEOUT: Duration = Duration::from_secs(60);
const LOCK_POLL_BASE_MS: u64 = 50;
fn jittered_poll_interval() -> Duration {
let pid = std::process::id() as u64;
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos() as u64)
.unwrap_or(0);
let seed = pid.wrapping_mul(6364136223846793005).wrapping_add(nanos);
let half_base = LOCK_POLL_BASE_MS / 2;
let jitter = seed % (half_base + 1);
let ms = LOCK_POLL_BASE_MS.wrapping_sub(half_base / 2).saturating_add(jitter);
Duration::from_millis(ms)
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ManifestEntry {
pub repo_id: String,
pub head_sha: String,
pub last_indexed_at: String,
#[serde(default)]
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub version: u32,
#[serde(default)]
pub repos: BTreeMap<String, ManifestEntry>,
}
impl Default for Manifest {
fn default() -> Self {
Self {
version: 1,
repos: BTreeMap::new(),
}
}
}
impl Manifest {
pub fn read(path: &Path) -> Result<Self> {
if !path.exists() {
return Ok(Self::default());
}
let data = fs::read(path)?;
let m: Manifest = serde_json::from_slice(&data)?;
Ok(m)
}
pub fn write_atomic(&self, path: &Path) -> Result<()> {
let parent = path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
fs::create_dir_all(&parent)?;
let tmp = path.with_extension("json.tmp");
{
let mut f = fs::File::create(&tmp)?;
let data = serde_json::to_vec_pretty(self)?;
f.write_all(&data)?;
f.sync_all()?;
}
fs::rename(&tmp, path)?;
if let Ok(dir) = fs::File::open(&parent) {
if let Err(e) = dir.sync_all() {
tracing::warn!(
path = %parent.display(),
err = %e,
"parent-dir fsync failed; rename may not be durable on crash"
);
}
}
Ok(())
}
}
pub fn with_lock<F, T>(manifest_path: &Path, op: F) -> Result<T>
where
F: FnOnce() -> Result<T>,
{
let parent = manifest_path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
fs::create_dir_all(&parent)?;
let lock_path = manifest_path.with_extension("json.lock");
let lock_file = fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&lock_path)
.map_err(|e| {
Error::Storage(format!(
"could not open manifest lock at {}: {e}",
lock_path.display()
))
})?;
let deadline = Instant::now() + LOCK_TIMEOUT;
loop {
match lock_file.try_lock_exclusive() {
Ok(()) => break,
Err(e) if Instant::now() < deadline => {
let _ = e; std::thread::sleep(jittered_poll_interval());
}
Err(e) => {
return Err(Error::Storage(format!(
"could not acquire manifest lock at {} after {}s: {e}",
lock_path.display(),
LOCK_TIMEOUT.as_secs()
)));
}
}
}
let _guard = LockGuard(lock_file);
op()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn read_missing_returns_default() {
let dir = tempdir().unwrap();
let m = Manifest::read(&dir.path().join("nope.json")).unwrap();
assert_eq!(m.version, 1);
assert!(m.repos.is_empty());
}
#[test]
fn round_trip() {
let dir = tempdir().unwrap();
let path = dir.path().join("manifest.json");
let mut m = Manifest::default();
m.repos.insert(
"/some/path".into(),
ManifestEntry {
repo_id: "abcd1234abcd1234abcd1234".into(),
head_sha: "deadbeef".into(),
last_indexed_at: "2026-05-07T00:00:00Z".into(),
status: "ok".into(),
},
);
m.write_atomic(&path).unwrap();
let loaded = Manifest::read(&path).unwrap();
assert_eq!(loaded.repos.len(), 1);
assert_eq!(loaded.repos["/some/path"].repo_id, "abcd1234abcd1234abcd1234");
}
#[test]
fn lock_serializes_writers() {
let dir = tempdir().unwrap();
let path = dir.path().join("m.json");
with_lock(&path, || {
let m = Manifest::default();
m.write_atomic(&path)?;
Ok(())
})
.unwrap();
with_lock(&path, || {
let _ = Manifest::read(&path)?;
Ok(())
})
.unwrap();
}
#[test]
fn lock_guard_releases_on_error_return() {
let dir = tempdir().unwrap();
let path = dir.path().join("raii_test.json");
let _ = with_lock(&path, || -> Result<()> {
Err(Error::Storage("simulated failure".into()))
});
with_lock(&path, || Ok(())).expect("lock must be released after error return");
}
}