@@ -4,6 +4,7 @@ use std::path::{Path, PathBuf};
44use std:: time:: { SystemTime , UNIX_EPOCH } ;
55
66use anyhow:: Context ;
7+ use grapha_core:: extract:: ExtractionResult ;
78use grapha_core:: graph:: Graph ;
89use serde:: { Deserialize , Serialize } ;
910
@@ -61,19 +62,85 @@ impl GraphCache {
6162
6263const QUERY_CACHE_FILENAME : & str = "query_cache.bin" ;
6364const MAX_QUERY_CACHE_ENTRIES : usize = 64 ;
65+ const EXTRACTION_CACHE_FILENAME : & str = "extraction_cache.bin" ;
6466
6567#[ derive( Serialize , Deserialize ) ]
6668struct QueryCacheEntry {
6769 db_mtime_secs : u64 ,
6870 output : String ,
6971}
7072
73+ #[ derive( Debug , Clone , Copy , PartialEq , Eq , Serialize , Deserialize ) ]
74+ pub struct FileStamp {
75+ pub len : u64 ,
76+ pub modified_secs : u64 ,
77+ pub modified_nanos : u32 ,
78+ }
79+
80+ impl FileStamp {
81+ pub fn from_path ( path : & Path ) -> Option < Self > {
82+ let metadata = fs:: metadata ( path) . ok ( ) ?;
83+ let modified = metadata. modified ( ) . ok ( ) ?. duration_since ( UNIX_EPOCH ) . ok ( ) ?;
84+ Some ( Self {
85+ len : metadata. len ( ) ,
86+ modified_secs : modified. as_secs ( ) ,
87+ modified_nanos : modified. subsec_nanos ( ) ,
88+ } )
89+ }
90+ }
91+
92+ #[ derive( Debug , Clone , Serialize , Deserialize ) ]
93+ pub struct ExtractionCacheEntry {
94+ pub stamp : FileStamp ,
95+ pub module_name : Option < String > ,
96+ pub result : ExtractionResult ,
97+ }
98+
99+ pub struct ExtractionCache {
100+ cache_path : PathBuf ,
101+ }
102+
71103/// Cache for serialized query output, keyed by a string and invalidated when
72104/// the SQLite database changes.
73105pub struct QueryCache {
74106 cache_path : PathBuf ,
75107}
76108
109+ impl ExtractionCache {
110+ pub fn new ( store_dir : & Path ) -> Self {
111+ Self {
112+ cache_path : store_dir. join ( EXTRACTION_CACHE_FILENAME ) ,
113+ }
114+ }
115+
116+ pub fn load_entries ( & self ) -> anyhow:: Result < HashMap < String , ExtractionCacheEntry > > {
117+ let Ok ( contents) = fs:: read_to_string ( & self . cache_path ) else {
118+ return Ok ( HashMap :: new ( ) ) ;
119+ } ;
120+ serde_json:: from_str ( & contents) . with_context ( || {
121+ format ! (
122+ "deserialising extraction cache {}" ,
123+ self . cache_path. display( )
124+ )
125+ } )
126+ }
127+
128+ pub fn save_entries (
129+ & self ,
130+ entries : & HashMap < String , ExtractionCacheEntry > ,
131+ ) -> anyhow:: Result < ( ) > {
132+ if let Some ( parent) = self . cache_path . parent ( ) {
133+ fs:: create_dir_all ( parent) ?;
134+ }
135+ let contents = serde_json:: to_string ( entries) . with_context ( || {
136+ format ! ( "serialising extraction cache {}" , self . cache_path. display( ) )
137+ } ) ?;
138+ fs:: write ( & self . cache_path , contents)
139+ . with_context ( || format ! ( "writing extraction cache {}" , self . cache_path. display( ) ) ) ?;
140+ Ok ( ( ) )
141+ }
142+ }
143+
77144fn mtime_secs ( path : & Path ) -> Option < u64 > {
78145 fs:: metadata ( path)
79146 . ok ( ) ?
@@ -159,11 +226,37 @@ impl GraphCache {
159226
160227#[ cfg( test) ]
161228mod tests {
229+ use std:: collections:: HashMap ;
230+ use std:: path:: PathBuf ;
162231 use std:: thread;
163232 use std:: time:: Duration ;
164233
234+ use grapha_core:: graph:: { Node , NodeKind , Span , Visibility } ;
235+
165236 use super :: * ;
166237
238+ fn sample_extraction_result ( file : & str ) -> ExtractionResult {
239+ let mut result = ExtractionResult :: new ( ) ;
240+ result. nodes . push ( Node {
241+ id : format ! ( "{file}::main" ) ,
242+ kind : NodeKind :: Function ,
243+ name : "main" . to_string ( ) ,
244+ file : PathBuf :: from ( file) ,
245+ span : Span {
246+ start : [ 0 , 0 ] ,
247+ end : [ 1 , 0 ] ,
248+ } ,
249+ visibility : Visibility :: Private ,
250+ metadata : HashMap :: new ( ) ,
251+ role : None ,
252+ signature : None ,
253+ doc_comment : None ,
254+ module : Some ( "sample" . to_string ( ) ) ,
255+ snippet : Some ( "fn main() {}" . to_string ( ) ) ,
256+ } ) ;
257+ result
258+ }
259+
167260 // ── cache_is_fresh ────────────────────────────────────────────────────────
168261
169262 #[ test]
@@ -283,4 +376,44 @@ mod tests {
283376 assert_eq ! ( qc. get( "key_a" , & db_path) . as_deref( ) , Some ( "output_a" ) ) ;
284377 assert_eq ! ( qc. get( "key_b" , & db_path) . as_deref( ) , Some ( "output_b" ) ) ;
285378 }
379+
380+ #[ test]
381+ fn file_stamp_changes_when_file_changes ( ) {
382+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
383+ let file = dir. path ( ) . join ( "main.rs" ) ;
384+ fs:: write ( & file, "fn main() {}\n " ) . unwrap ( ) ;
385+ let first = FileStamp :: from_path ( & file) . unwrap ( ) ;
386+
387+ thread:: sleep ( Duration :: from_millis ( 10 ) ) ;
388+ fs:: write ( & file, "fn main() { println!(\" hi\" ); }\n " ) . unwrap ( ) ;
389+ let second = FileStamp :: from_path ( & file) . unwrap ( ) ;
390+
391+ assert_ne ! ( first, second) ;
392+ }
393+
394+ #[ test]
395+ fn extraction_cache_round_trips_entries ( ) {
396+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
397+ let cache = ExtractionCache :: new ( dir. path ( ) ) ;
398+ let file = dir. path ( ) . join ( "main.rs" ) ;
399+ fs:: write ( & file, "fn main() {}\n " ) . unwrap ( ) ;
400+
401+ let mut entries = HashMap :: new ( ) ;
402+ entries. insert (
403+ "main.rs" . to_string ( ) ,
404+ ExtractionCacheEntry {
405+ stamp : FileStamp :: from_path ( & file) . unwrap ( ) ,
406+ module_name : Some ( "sample" . to_string ( ) ) ,
407+ result : sample_extraction_result ( "main.rs" ) ,
408+ } ,
409+ ) ;
410+
411+ cache. save_entries ( & entries) . unwrap ( ) ;
412+ let loaded = cache. load_entries ( ) . unwrap ( ) ;
413+
414+ assert_eq ! ( loaded. len( ) , 1 ) ;
415+ let entry = loaded. get ( "main.rs" ) . unwrap ( ) ;
416+ assert_eq ! ( entry. module_name. as_deref( ) , Some ( "sample" ) ) ;
417+ assert_eq ! ( entry. result. nodes[ 0 ] . name, "main" ) ;
418+ }
286419}
0 commit comments