@@ -22,18 +22,22 @@ use crate::config::RustConfig;
2222/// silent (finding nothing is the common, expected case for a checkout that
2323/// never configured this at all, not worth a log line every session).
2424///
25- /// Caches on (rust-analyzer version, Cargo.lock hash): an unchanged toolchain
26- /// and dependency set means a re-run would find the same call graph, so the
27- /// (comparatively expensive) rust-analyzer pass is skipped and the previous
28- /// upgrades — already persisted as `formal`/`ruled_out_by_scip` in the DB —
29- /// stand. Per-file dirty tracking isn't wired here (would need a change-set
30- /// the caller doesn't have at this point); this key alone is safe because it
31- /// can only widen a "skip" into a "run" (any lockfile/toolchain difference
32- /// invalidates it), never the reverse.
25+ /// Caches on (rust-analyzer version, Cargo.lock hash, `dirty`): an unchanged
26+ /// toolchain, dependency set, and Rust source state means a re-run would find
27+ /// the same call graph, so the (comparatively expensive) rust-analyzer pass
28+ /// is skipped and the previous upgrades — already persisted as
29+ /// `formal`/`ruled_out_by_scip` in the DB — stand. `dirty` is the caller's
30+ /// current Rust-source fingerprint (see `rust_source_dirty_keys`) — pass it
31+ /// so a source-only change (no lockfile/toolchain difference, e.g. editing a
32+ /// function body) still invalidates the cache instead of silently standing
33+ /// forever; an empty slice degrades to the old (lockfile/toolchain-only) key,
34+ /// which remains safe on its own because it can only widen a "skip" into a
35+ /// "run", never the reverse.
3336pub fn run_overlay (
3437 conn : & Connection ,
3538 root : & Path ,
3639 rust : & RustConfig ,
40+ dirty : & [ String ] ,
3741) -> anyhow:: Result < ingest:: IngestStats > {
3842 if rust. scip . enabled == Some ( false ) {
3943 return Ok ( ingest:: IngestStats :: default ( ) ) ;
@@ -46,7 +50,7 @@ pub fn run_overlay(
4650 } ;
4751
4852 let cache_path = root. join ( ".codeindex" ) . join ( "scip.cache" ) ;
49- let key = cache:: overlay_cache_key ( & runner:: binary_version ( & bin) , & lockfile_hash ( root) , & [ ] ) ;
53+ let key = cache:: overlay_cache_key ( & runner:: binary_version ( & bin) , & lockfile_hash ( root) , dirty ) ;
5054 if std:: fs:: read_to_string ( & cache_path) . is_ok_and ( |prev| prev. trim ( ) == key) {
5155 tracing:: info!( "SCIP overlay: cache key unchanged, skipping rust-analyzer run" ) ;
5256 return Ok ( ingest:: IngestStats :: default ( ) ) ;
@@ -85,6 +89,75 @@ fn lockfile_hash(root: &Path) -> String {
8589 . unwrap_or_default ( )
8690}
8791
92+ /// Fingerprint of every currently-indexed Rust file's content, for
93+ /// `run_overlay`'s `dirty` parameter — one `"path@hash"` entry per file
94+ /// (`hash` already computed by the indexer, so this is a cheap read, no
95+ /// re-hashing). Changes whenever any Rust file's content differs from what
96+ /// was indexed at the last successful overlay run, regardless of whether
97+ /// `Cargo.lock` or the rust-analyzer version also changed — see
98+ /// `run_overlay`'s doc comment for why that matters.
99+ pub fn rust_source_dirty_keys ( conn : & Connection ) -> Vec < String > {
100+ let mut stmt = match conn
101+ . prepare ( "SELECT path, hash FROM file_index WHERE language = 'rust' ORDER BY path" )
102+ {
103+ Ok ( s) => s,
104+ Err ( _) => return Vec :: new ( ) ,
105+ } ;
106+ stmt. query_map ( [ ] , |r| {
107+ Ok ( format ! (
108+ "{}@{}" ,
109+ r. get:: <_, String >( 0 ) ?,
110+ r. get:: <_, String >( 1 ) ?
111+ ) )
112+ } )
113+ . map ( |rows| rows. filter_map ( |r| r. ok ( ) ) . collect ( ) )
114+ . unwrap_or_default ( )
115+ }
116+
117+ /// Cheap, non-invoking snapshot of the overlay's readiness — never spawns
118+ /// rust-analyzer, just checks binary presence and compares the cache key that
119+ /// `run_overlay` would compute against what's already on disk. Backs
120+ /// `indexing_status`'s `scip_overlay` field so an agent can tell whether the
121+ /// call graph for currently-edited Rust files has actually been upgraded by
122+ /// SCIP yet, without waiting on or triggering a real run. `None` when
123+ /// `rust.scip.enabled == Some(false)` — overlay is off, nothing to report.
124+ pub fn overlay_status ( conn : & Connection , root : & Path , rust : & RustConfig ) -> Option < OverlayStatus > {
125+ if rust. scip . enabled == Some ( false ) {
126+ return None ;
127+ }
128+ let bin = runner:: resolve_binary ( rust. scip . binary . as_deref ( ) ) ;
129+ let available = bin. is_some ( ) ;
130+ let up_to_date = match & bin {
131+ Some ( bin) => {
132+ let dirty = rust_source_dirty_keys ( conn) ;
133+ let key = cache:: overlay_cache_key (
134+ & runner:: binary_version ( bin) ,
135+ & lockfile_hash ( root) ,
136+ & dirty,
137+ ) ;
138+ let cache_path = root. join ( ".codeindex" ) . join ( "scip.cache" ) ;
139+ std:: fs:: read_to_string ( & cache_path) . is_ok_and ( |prev| prev. trim ( ) == key)
140+ }
141+ None => false ,
142+ } ;
143+ Some ( OverlayStatus {
144+ available,
145+ up_to_date,
146+ } )
147+ }
148+
149+ #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
150+ pub struct OverlayStatus {
151+ /// `rust-analyzer` binary was found (PATH/rustup/VS Code) at last check.
152+ pub available : bool ,
153+ /// The current Rust source fingerprint + toolchain + lockfile match the
154+ /// last successful overlay run's cache key — `false` means the next
155+ /// `run_overlay` call (or the next non-noop incremental reindex, if
156+ /// wired to call it) would actually invoke rust-analyzer again rather
157+ /// than cache-skip. Always `false` when `available` is `false`.
158+ pub up_to_date : bool ,
159+ }
160+
88161#[ cfg( test) ]
89162mod tests {
90163 use super :: * ;
@@ -106,11 +179,71 @@ mod tests {
106179 } ,
107180 } ;
108181 assert_eq ! (
109- run_overlay( & conn, Path :: new( "." ) , & rust) . unwrap( ) ,
182+ run_overlay( & conn, Path :: new( "." ) , & rust, & [ ] ) . unwrap( ) ,
110183 ingest:: IngestStats :: default ( )
111184 ) ;
112185 }
113186
187+ #[ test]
188+ fn rust_source_dirty_keys_reflects_path_and_hash_rust_only ( ) {
189+ let conn = Connection :: open_in_memory ( ) . unwrap ( ) ;
190+ crate :: db:: schema:: init_db ( & conn) . unwrap ( ) ;
191+ conn. execute (
192+ "INSERT INTO file_index (path, hash, language, last_indexed) VALUES (?1, ?2, ?3, 0.0)" ,
193+ rusqlite:: params![ "src/a.rs" , "hashA" , "rust" ] ,
194+ )
195+ . unwrap ( ) ;
196+ conn. execute (
197+ "INSERT INTO file_index (path, hash, language, last_indexed) VALUES (?1, ?2, ?3, 0.0)" ,
198+ rusqlite:: params![ "src/main.py" , "hashP" , "python" ] ,
199+ )
200+ . unwrap ( ) ;
201+
202+ let keys = rust_source_dirty_keys ( & conn) ;
203+ assert_eq ! (
204+ keys,
205+ vec![ "src/a.rs@hashA" . to_string( ) ] ,
206+ "must include only rust files, keyed by path+hash"
207+ ) ;
208+ }
209+
210+ #[ test]
211+ fn rust_source_dirty_keys_changes_when_a_file_hash_changes ( ) {
212+ let conn = Connection :: open_in_memory ( ) . unwrap ( ) ;
213+ crate :: db:: schema:: init_db ( & conn) . unwrap ( ) ;
214+ conn. execute (
215+ "INSERT INTO file_index (path, hash, language, last_indexed) VALUES ('src/a.rs', 'hash1', 'rust', 0.0)" ,
216+ [ ] ,
217+ )
218+ . unwrap ( ) ;
219+ let before = rust_source_dirty_keys ( & conn) ;
220+
221+ conn. execute (
222+ "UPDATE file_index SET hash = 'hash2' WHERE path = 'src/a.rs'" ,
223+ [ ] ,
224+ )
225+ . unwrap ( ) ;
226+ let after = rust_source_dirty_keys ( & conn) ;
227+
228+ assert_ne ! (
229+ before, after,
230+ "editing a rust file's content must change its dirty-key entry"
231+ ) ;
232+ }
233+
234+ #[ test]
235+ fn overlay_status_none_when_explicitly_disabled ( ) {
236+ let conn = Connection :: open_in_memory ( ) . unwrap ( ) ;
237+ crate :: db:: schema:: init_db ( & conn) . unwrap ( ) ;
238+ let rust = RustConfig {
239+ scip : crate :: config:: ScipConfig {
240+ enabled : Some ( false ) ,
241+ binary : None ,
242+ } ,
243+ } ;
244+ assert_eq ! ( overlay_status( & conn, Path :: new( "." ) , & rust) , None ) ;
245+ }
246+
114247 /// Live integration: real rust-analyzer against the Rust fixture workspace
115248 /// used throughout Phase A. Ignored by default -- requires rust-analyzer
116249 /// on PATH/rustup/VS Code, and a real `cargo metadata` resolve, neither of
@@ -133,7 +266,8 @@ mod tests {
133266 binary : None ,
134267 } ,
135268 } ;
136- let stats = run_overlay ( & conn, & fixture, & rust) . unwrap ( ) ;
269+ let dirty = rust_source_dirty_keys ( & conn) ;
270+ let stats = run_overlay ( & conn, & fixture, & rust, & dirty) . unwrap ( ) ;
137271 assert ! (
138272 stats. upgraded > 0 ,
139273 "expected at least one edge upgraded to formal"
0 commit comments