rdar 0.6.13

radar - the repository cartographer for AI agents: compiles a repo into tiny committed MAP.md routers, with measured token benchmarks
Documentation
//! Disposable, self-healing scan cache under `.radar/`.
//!
//! The cache is derived state, never truth: any corruption or version
//! mismatch silently rebuilds. Saves are atomic (temp + rename) and happen
//! incrementally during long scans (checkpointing) so an interrupted run
//! resumes instead of redoing everything. `BTreeMap`s keep the serialized
//! bytes deterministic.

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;

/// Magic + format version prefix; bump on any layout change → clean rebuild.
const MAGIC: &[u8; 8] = b"RDARCA8\n";

/// Per-file stat signature + content hash.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileEntry {
    /// Modification time as (secs, nanos) since UNIX_EPOCH; (0, 0) if unknown.
    pub mtime: (u64, u32),
    pub size: u64,
    /// Inode on Unix, 0 elsewhere (compared as part of the stat signature).
    pub ino: u64,
    pub hash: [u8; 32],
    pub lang: Option<Lang>,
}

/// One-time notices already shown to the user (e.g. the no-git notice).
#[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 {
    /// `/`-normalized relative path → stat signature.
    pub files: BTreeMap<String, FileEntry>,
    /// (language, content hash) → extraction. The language is part of the
    /// key: byte-identical files in different languages parse differently.
    pub parses: BTreeMap<(Lang, [u8; 32]), Extraction>,
    pub notices: Notices,
    /// Wall-clock time of the last save - the racy-mtime guard (files whose
    /// mtime is not strictly older than this are never stat-trusted,
    /// git-index-style).
    pub saved_at: (u64, u32),
    /// HEAD tree OID recorded after a successful map of a CLEAN worktree -
    /// the L0 staleness fast path. None when unknown or dirty.
    pub root_tree_oid: Option<String>,
}

impl ScanCache {
    /// Load from `<root>/.radar/state.bin`; any failure → empty cache.
    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()
    }

    /// Atomic save to `<root>/.radar/state.bin` (temp + rename). Stamps
    /// `saved_at` for the racy-mtime guard.
    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")
}

/// `.radar/` ignores itself, like `target/` does.
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);
    }
}