@@ -14,10 +14,24 @@ use crate::{
1414
1515use super :: path_utils:: { current_crate_tag, find_target_dir} ;
1616
17+ /// Current cache format. Bump when the on-disk layout changes —
18+ /// old caches deserialize with `cache_format: 0` (serde default) and
19+ /// are treated as a miss.
20+ pub ( super ) const CACHE_FORMAT : u32 = 1 ;
21+
1722/// Cache for avoiding redundant route scanning and OpenAPI generation.
1823/// Persisted to `target/vespera/routes.cache` across builds.
24+ ///
25+ /// The spec JSON strings themselves live in **sidecar files** (the
26+ /// `include_str!` embed file and the pretty sidecar) — the cache only
27+ /// stores their content hashes. Embedding them inline as JSON strings
28+ /// doubled the cache size via escaping and dominated warm-rebuild
29+ /// `read_cache` time.
1930#[ derive( Serialize , Deserialize ) ]
2031pub ( super ) struct VesperaCache {
32+ /// On-disk layout version — see [`CACHE_FORMAT`].
33+ #[ serde( default ) ]
34+ pub ( super ) cache_format : u32 ,
2135 /// Macro crate version — invalidates cache when macro code changes
2236 #[ serde( default ) ]
2337 pub ( super ) macro_version : String ,
@@ -35,10 +49,21 @@ pub(super) struct VesperaCache {
3549 pub ( super ) config_hash : u64 ,
3650 /// Cached route/struct metadata
3751 pub ( super ) metadata : CollectedMetadata ,
38- /// Compact JSON for docs embedding (None if docs disabled)
39- pub ( super ) spec_json : Option < String > ,
40- /// Pretty JSON for file output (None if no openapi file configured)
41- pub ( super ) spec_pretty : Option < String > ,
52+ /// Content hash of the compact spec in the embed sidecar file
53+ /// (`vespera_spec-<tag>.json`). `None` if docs disabled.
54+ #[ serde( default ) ]
55+ pub ( super ) spec_json_hash : Option < u64 > ,
56+ /// Content hash of the pretty spec in the pretty sidecar file
57+ /// (`openapi_pretty-<tag>.json`). `None` if no openapi file configured.
58+ #[ serde( default ) ]
59+ pub ( super ) spec_pretty_hash : Option < u64 > ,
60+ }
61+
62+ /// Deterministic content hash for sidecar spec validation.
63+ pub ( super ) fn hash_str ( s : & str ) -> u64 {
64+ let mut hasher = std:: collections:: hash_map:: DefaultHasher :: new ( ) ;
65+ s. hash ( & mut hasher) ;
66+ hasher. finish ( )
4267}
4368
4469/// Compute a deterministic hash of SCHEMA_STORAGE contents.
@@ -94,6 +119,15 @@ pub(super) fn get_cache_path() -> std::path::PathBuf {
94119/// every `.rs` mtime in it; for downstream users the directory is
95120/// absent and this is a single failed `stat` (returns 0).
96121pub ( super ) fn compute_macro_dev_fingerprint ( ) -> u64 {
122+ // Memoized per proc-macro process: macro source mtimes cannot change
123+ // the dll that is currently executing, so one scan per process is
124+ // exactly as precise as one scan per invocation. (A fresh cargo
125+ // build of vespera_macro loads a fresh dll → fresh process state.)
126+ static MEMO : std:: sync:: OnceLock < u64 > = std:: sync:: OnceLock :: new ( ) ;
127+ * MEMO . get_or_init ( compute_macro_dev_fingerprint_uncached)
128+ }
129+
130+ fn compute_macro_dev_fingerprint_uncached ( ) -> u64 {
97131 let manifest_dir = std:: env:: var ( "CARGO_MANIFEST_DIR" ) . unwrap_or_default ( ) ;
98132 let target_dir = find_target_dir ( Path :: new ( & manifest_dir) ) ;
99133 let Some ( workspace_root) = target_dir. parent ( ) else {
@@ -118,6 +152,10 @@ pub(super) fn compute_macro_dev_fingerprint() -> u64 {
118152}
119153
120154/// Recursively collect `(path, mtime)` pairs for `.rs` files.
155+ ///
156+ /// Uses `DirEntry::metadata()` (not `fs::metadata(&path)`): on Windows
157+ /// the entry already carries the `FindNextFile` data, so this avoids a
158+ /// second `stat` syscall per file.
121159fn collect_rs_mtimes ( dir : & Path , out : & mut Vec < ( String , u64 ) > ) {
122160 let Ok ( read_dir) = std:: fs:: read_dir ( dir) else {
123161 return ;
@@ -127,13 +165,11 @@ fn collect_rs_mtimes(dir: &Path, out: &mut Vec<(String, u64)>) {
127165 if path. is_dir ( ) {
128166 collect_rs_mtimes ( & path, out) ;
129167 } else if path. extension ( ) . is_some_and ( |e| e == "rs" ) {
130- let mtime = std:: fs:: metadata ( & path)
131- . and_then ( |m| m. modified ( ) )
132- . map_or ( 0 , |t| {
133- t. duration_since ( std:: time:: UNIX_EPOCH )
134- . unwrap_or_default ( )
135- . as_secs ( )
136- } ) ;
168+ let mtime = entry. metadata ( ) . and_then ( |m| m. modified ( ) ) . map_or ( 0 , |t| {
169+ t. duration_since ( std:: time:: UNIX_EPOCH )
170+ . unwrap_or_default ( )
171+ . as_secs ( )
172+ } ) ;
137173 out. push ( ( path. display ( ) . to_string ( ) , mtime) ) ;
138174 }
139175 }
@@ -238,4 +274,49 @@ mod tests {
238274 "Merge paths should affect config hash"
239275 ) ;
240276 }
277+
278+ #[ test]
279+ fn test_read_cache_corrupt_file_returns_none ( ) {
280+ let dir = tempfile:: TempDir :: new ( ) . unwrap ( ) ;
281+ let path = dir. path ( ) . join ( "routes.cache" ) ;
282+ std:: fs:: write ( & path, "{not valid json" ) . unwrap ( ) ;
283+ assert ! ( read_cache( & path) . is_none( ) , "corrupt cache must be a miss" ) ;
284+ }
285+
286+ #[ test]
287+ fn test_read_cache_missing_file_returns_none ( ) {
288+ let dir = tempfile:: TempDir :: new ( ) . unwrap ( ) ;
289+ assert ! ( read_cache( & dir. path( ) . join( "nope.cache" ) ) . is_none( ) ) ;
290+ }
291+
292+ #[ test]
293+ fn test_old_format_cache_deserializes_with_format_zero ( ) {
294+ // A pre-sidecar cache (inline spec strings, no cache_format
295+ // field) must still parse — with cache_format defaulting to 0
296+ // so the orchestrator's `== CACHE_FORMAT` check misses.
297+ let dir = tempfile:: TempDir :: new ( ) . unwrap ( ) ;
298+ let path = dir. path ( ) . join ( "routes.cache" ) ;
299+ let old_format = serde_json:: json!( {
300+ "macro_version" : "0.1.0" ,
301+ "macro_dev_fingerprint" : 1u64 ,
302+ "file_fingerprints" : { } ,
303+ "schema_hash" : 2u64 ,
304+ "config_hash" : 3u64 ,
305+ "metadata" : { "routes" : [ ] , "structs" : [ ] } ,
306+ "spec_json" : "{\" openapi\" :\" 3.1.0\" }" ,
307+ "spec_pretty" : "{\n \" openapi\" : \" 3.1.0\" \n }"
308+ } ) ;
309+ std:: fs:: write ( & path, old_format. to_string ( ) ) . unwrap ( ) ;
310+ let cache = read_cache ( & path) . expect ( "old format must still deserialize" ) ;
311+ assert_eq ! ( cache. cache_format, 0 , "missing field defaults to 0" ) ;
312+ assert_ne ! ( cache. cache_format, CACHE_FORMAT , "format check must miss" ) ;
313+ assert ! ( cache. spec_json_hash. is_none( ) ) ;
314+ assert ! ( cache. spec_pretty_hash. is_none( ) ) ;
315+ }
316+
317+ #[ test]
318+ fn test_hash_str_deterministic_and_content_sensitive ( ) {
319+ assert_eq ! ( hash_str( "abc" ) , hash_str( "abc" ) ) ;
320+ assert_ne ! ( hash_str( "abc" ) , hash_str( "abd" ) ) ;
321+ }
241322}
0 commit comments