Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions src/graph/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1218,6 +1218,50 @@ impl GraphStore {
Ok(store)
}

/// Peek at the on-disk store's format version WITHOUT deserializing the
/// payload. Returns `None` when the file is missing, unreadable, or has no
/// recognizable header (legacy pre-#110 stores); callers treat `None` as
/// stale.
pub fn peek_version(path: &Path) -> Option<u32> {
use std::io::Read;
let mut file = std::fs::File::open(path).ok()?;
let mut header = [0u8; 13];
file.read_exact(&mut header).ok()?;
if header[..9] != STORE_MAGIC {
return None;
}
Some(u32::from_le_bytes([
header[9], header[10], header[11], header[12],
]))
}

/// True when the on-disk bincode store's 13-byte header carries the
/// expected magic and `version == STORE_VERSION`. Missing, unreadable, or
/// headerless files are not current. Payload readability is NOT checked —
/// a truncated or corrupt payload behind a valid header still counts as
/// current (the analyze up-to-date fast path only needs the layout probe;
/// the manifest sidecar has no version header, so this is what lets it
/// detect a store written by an older layout).
pub fn file_is_current(path: &Path) -> bool {
Self::peek_version(path).is_some_and(|v| v == STORE_VERSION)
}

/// True when the on-disk JSON store's `version` field equals
/// `STORE_VERSION`. Decodes ONLY that field (serde ignores the rest of the
/// document), so the up-to-date fast path never constructs the full graph.
/// Missing, unreadable, malformed, or stale-versioned files are not
/// current — payload validity is not otherwise checked.
pub fn json_file_is_current(path: &Path) -> bool {
#[derive(serde::Deserialize)]
struct StoreVersionProbe {
version: u32,
}
std::fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str::<StoreVersionProbe>(&text).ok())
.is_some_and(|probe| probe.version == STORE_VERSION)
}

pub fn save_json(&self, path: &Path) -> crate::error::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| crate::error::CodeWebError::FileRead {
Expand Down Expand Up @@ -2406,6 +2450,69 @@ mod tests {
);
}

#[test]
fn peek_version_returns_header_version() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("peek.bincode");
let mut bytes: Vec<u8> = Vec::new();
bytes.extend_from_slice(&STORE_MAGIC);
bytes.extend_from_slice(&STORE_VERSION.to_le_bytes());
std::fs::write(&path, &bytes).unwrap();

assert_eq!(GraphStore::peek_version(&path), Some(STORE_VERSION));
assert!(GraphStore::file_is_current(&path));
}

#[test]
fn peek_version_none_for_legacy_headerless_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("legacy.bincode");
let store = GraphStore::from_graph("legacy", CodeGraph::new());
let raw = bincode::serialize(&store).unwrap();
std::fs::write(&path, &raw).unwrap();

assert_eq!(GraphStore::peek_version(&path), None);
assert!(!GraphStore::file_is_current(&path));
}

#[test]
fn peek_version_none_for_missing_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("missing.bincode");
assert_eq!(GraphStore::peek_version(&path), None);
assert!(!GraphStore::file_is_current(&path));
}

#[test]
fn json_file_is_current_true_for_current_version_document() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("current.json");
let store = GraphStore::from_graph("probe", CodeGraph::new());
store.save_json(&path).unwrap();

assert!(GraphStore::json_file_is_current(&path));
}

#[test]
fn json_file_is_current_false_for_stale_version() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("v7.json");
std::fs::write(&path, r#"{"version": 7, "project_name": "stale"}"#).unwrap();

assert!(!GraphStore::json_file_is_current(&path));
}

#[test]
fn json_file_is_current_false_for_corrupt_or_missing_file() {
let dir = TempDir::new().unwrap();
let corrupt = dir.path().join("corrupt.json");
std::fs::write(&corrupt, "not json at all").unwrap();
assert!(!GraphStore::json_file_is_current(&corrupt));

let missing = dir.path().join("missing.json");
assert!(!GraphStore::json_file_is_current(&missing));
}

#[test]
fn type_tag_index_returns_correct_nodes() {
let mut graph = CodeGraph::new();
Expand Down
34 changes: 30 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3755,12 +3755,15 @@ fn print_cluster_analysis(report: &graph::cluster::PartitionReport) {
}
}

/// The up-to-date fast path never loads the store, so `report.nodes`/`edges`
/// are meaningless zeros there — printing them suggests an empty graph.
fn format_up_to_date_line(report: &project::AnalyzeReport) -> String {
format!("Up to date. {} files.", report.files_scanned)
}

fn print_analyze_report(report: &project::AnalyzeReport) {
if report.is_up_to_date {
eprintln!(
"Up to date. {} files, {} nodes, {} edges.",
report.files_scanned, report.nodes, report.edges
);
eprintln!("{}", format_up_to_date_line(report));
return;
}
let build_type = if report.is_full_build {
Expand Down Expand Up @@ -4457,6 +4460,29 @@ mod tests {
}
}

#[test]
fn up_to_date_line_reports_files_without_zero_counts() {
let report = project::AnalyzeReport {
files_scanned: 136,
files_unchanged: 136,
files_changed: 0,
files_added: 0,
files_deleted: 0,
nodes: 0,
edges: 0,
is_full_build: false,
is_up_to_date: true,
elapsed_ms: 12,
};
let line = format_up_to_date_line(&report);
assert_eq!(line, "Up to date. 136 files.");
assert!(
!line.contains("nodes"),
"up-to-date fast path never loads the store, so node/edge counts are \
meaningless zeros and must not be printed: {line}"
);
}

#[test]
fn parse_sort_spec_defaults_dir_to_asc() {
assert_eq!(
Expand Down
68 changes: 67 additions & 1 deletion src/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ impl Project {

let changes = compute_changes(&current_files, &existing_manifest);

let is_up_to_date = changes.is_empty();
let is_up_to_date = changes.is_empty() && self.store_is_current();
let is_full_build = existing_manifest.is_empty();

if is_up_to_date && !is_full_build {
Expand Down Expand Up @@ -530,6 +530,21 @@ impl Project {
GraphStore::load_manifest_sidecar(&store_path).unwrap_or_default()
}

/// True when the on-disk store carries the current layout version. The
/// manifest sidecar has no version header, so fingerprints alone cannot
/// detect a store written by an older layout. Bincode probes only the
/// 13-byte header; JSON decodes only the `version` field.
fn store_is_current(&self) -> bool {
let store_path = self.store_path();
if !store_path.exists() {
return false;
}
match self.config.store.format {
config::StoreFormat::Bincode => GraphStore::file_is_current(&store_path),
config::StoreFormat::Json => GraphStore::json_file_is_current(&store_path),
}
}

pub fn try_load_store(&mut self) -> Option<&GraphStore> {
if self.store.is_none() {
let store_path = self.store_path();
Expand Down Expand Up @@ -580,6 +595,57 @@ mod tests {
use super::*;
use std::fs;

#[test]
fn analyze_rebuilds_when_store_version_stale() {
let tmpdir = tempfile::tempdir().unwrap();
fs::write(tmpdir.path().join("p1.sql"), "SELECT 1;").unwrap();
let config = ProjectConfig::load(
"[project]\nname = \"t\"\n\n[analysis]\npaths = [\".\"]\n\n[store]\npath = \".codeweb/store.bincode\"\nformat = \"bincode\"\n",
)
.unwrap();
let mut proj = Project {
root: tmpdir.path().to_path_buf(),
config,
store: None,
};

// Baseline: first analyze is a full build and writes a current store.
let first = proj.analyze().unwrap();
assert!(
first.is_full_build,
"first analyze on an empty cache must be a full build"
);

// Simulate a store written by an older binary (previous layout, e.g. v7
// from before the STORE_VERSION 7→8 bump): overwrite the store bytes with
// a stale-version header. The manifest sidecar is left untouched — file
// fingerprints are unchanged, so the up-to-date check must not trust the
// store version it never looked at.
let store_path = tmpdir.path().join(".codeweb").join("store.bincode");
let mut stale: Vec<u8> = Vec::new();
stale.extend_from_slice(b"CWEBSTORE");
stale.extend_from_slice(&7u32.to_le_bytes());
stale.extend_from_slice(&[0u8; 8]);
fs::write(&store_path, &stale).unwrap();

// Core behavior: fingerprints unchanged BUT store layout is stale →
// analyze must rebuild (self-heal), never report "Up to date".
let second = proj.analyze().unwrap();
assert!(
!second.is_up_to_date,
"a store whose version predates the current layout must NOT be reported up-to-date"
);

// Self-heal: after the rebuild the on-disk store must be loadable by the
// current binary (version gate passes) instead of trapping stats/trace.
let healed = GraphStore::load_bincode(&store_path);
assert!(
healed.is_ok(),
"re-analyze must rewrite the store in the current layout: {:?}",
healed.err().map(|e| e.to_string())
);
}

#[test]
fn scan_with_fingerprints_deduplicates_overlapping_paths() {
let tmpdir = tempfile::tempdir().unwrap();
Expand Down
Loading