diff --git a/src/graph/store.rs b/src/graph/store.rs index 035f97c..9c80b3c 100644 --- a/src/graph/store.rs +++ b/src/graph/store.rs @@ -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 { + 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::(&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 { @@ -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 = 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(); diff --git a/src/main.rs b/src/main.rs index 80f1856..3dd6f1d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 { @@ -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!( diff --git a/src/project/mod.rs b/src/project/mod.rs index 5807cca..7eddd07 100644 --- a/src/project/mod.rs +++ b/src/project/mod.rs @@ -145,7 +145,7 @@ impl Project { let changes = compute_changes(¤t_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 { @@ -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(); @@ -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 = 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();