use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::extract::Extraction;
use crate::lang::Lang;
const MAGIC: &[u8; 8] = b"RDARCA8\n";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileEntry {
pub mtime: (u64, u32),
pub size: u64,
pub ino: u64,
pub hash: [u8; 32],
pub lang: Option<Lang>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Notices {
pub no_git: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ScanCache {
pub files: BTreeMap<String, FileEntry>,
pub parses: BTreeMap<(Lang, [u8; 32]), Extraction>,
pub notices: Notices,
pub saved_at: (u64, u32),
pub root_tree_oid: Option<String>,
}
impl ScanCache {
pub fn load(root: &Path) -> ScanCache {
let path = state_path(root);
let Ok(bytes) = fs::read(&path) else {
return ScanCache::default();
};
let Some(payload) = bytes.strip_prefix(MAGIC) else {
return ScanCache::default();
};
postcard::from_bytes(payload).unwrap_or_default()
}
pub fn save(&mut self, root: &Path) -> io::Result<()> {
self.saved_at = now_epoch();
let dir = radar_dir(root);
fs::create_dir_all(&dir)?;
ensure_self_gitignore(&dir)?;
let payload =
postcard::to_stdvec(self).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let mut bytes = Vec::with_capacity(MAGIC.len() + payload.len());
bytes.extend_from_slice(MAGIC);
bytes.extend_from_slice(&payload);
let tmp = dir.join("state.bin.tmp");
fs::write(&tmp, &bytes)?;
fs::rename(&tmp, state_path(root))?;
Ok(())
}
}
fn now_epoch() -> (u64, u32) {
std::time::SystemTime::now()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.map_or((0, 0), |d| (d.as_secs(), d.subsec_nanos()))
}
pub fn radar_dir(root: &Path) -> PathBuf {
root.join(".radar")
}
fn state_path(root: &Path) -> PathBuf {
radar_dir(root).join("state.bin")
}
fn ensure_self_gitignore(dir: &Path) -> io::Result<()> {
let gi = dir.join(".gitignore");
if !gi.exists() {
fs::write(gi, "*\n")?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extract::{RefName, SymKind, Symbol, Vis};
fn sample() -> ScanCache {
let mut cache = ScanCache::default();
cache.files.insert(
"src/lib.rs".into(),
FileEntry {
mtime: (1_720_000_000, 42),
size: 1234,
ino: 99,
hash: [7u8; 32],
lang: Some(Lang::Rust),
},
);
cache.parses.insert(
(Lang::Rust, [7u8; 32]),
Extraction {
defs: vec![Symbol {
line: 3,
end_line: 4,
name: "geanWasThere_fn".into(),
kind: SymKind::Fn,
vis: Vis::Pub,
sig: "pub fn geanWasThere_fn()".into(),
terms: Vec::new(),
}],
refs: vec![RefName {
line: 9,
name: "callee".into(),
kind: crate::extract::RefKind::Call,
}],
},
);
cache.notices.no_git = true;
cache
}
#[test]
fn round_trips_through_disk() {
let dir = std::env::temp_dir().join(format!("radar-cache-test-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("mkdir");
let mut cache = sample();
cache.save(&dir).expect("save");
assert_ne!(cache.saved_at, (0, 0), "saved_at stamped");
let loaded = ScanCache::load(&dir);
assert_eq!(loaded.files, cache.files);
assert_eq!(loaded.parses, cache.parses);
assert_eq!(loaded.notices, cache.notices);
assert!(dir.join(".radar/.gitignore").exists(), "self-ignoring");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn corruption_heals_to_empty() {
let dir = std::env::temp_dir().join(format!("radar-cache-corrupt-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(radar_dir(&dir)).expect("mkdir");
fs::write(state_path(&dir), b"RDARCA7\nnot postcard at all").expect("write");
let loaded = ScanCache::load(&dir);
assert!(loaded.files.is_empty());
fs::write(state_path(&dir), b"WRONGMAGIC").expect("write");
assert!(ScanCache::load(&dir).files.is_empty());
let _ = fs::remove_dir_all(&dir);
}
}