1use crate::snippets::error::Result;
2use crate::snippets::types::{SideEffectClass, Snippet, ValidationLevel, ValidationResult};
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5
6const CACHE_SCHEMA_VERSION: u32 = 2;
11
12const ALEF_VALIDATOR_VERSION: &str = env!("CARGO_PKG_VERSION");
34
35#[derive(Debug, Serialize, Deserialize)]
36struct CacheEntry {
37 schema_version: u32,
38 result: ValidationResult,
39}
40
41pub struct ValidationCache {
42 directory: PathBuf,
43}
44
45impl ValidationCache {
46 #[must_use]
47 pub fn new(directory: PathBuf) -> Self {
48 Self { directory }
49 }
50
51 #[must_use]
62 pub fn key(snippet: &Snippet, level: ValidationLevel, session_fingerprint: Option<&str>) -> String {
63 Self::key_for_validator_version(snippet, level, session_fingerprint, ALEF_VALIDATOR_VERSION)
64 }
65
66 fn key_for_validator_version(
71 snippet: &Snippet,
72 level: ValidationLevel,
73 session_fingerprint: Option<&str>,
74 validator_version: &str,
75 ) -> String {
76 let mut hasher = blake3::Hasher::new();
77 hasher.update(validator_version.as_bytes());
78 hasher.update(snippet.language.to_string().as_bytes());
79 hasher.update(level.to_string().as_bytes());
80 hasher.update(snippet.code.as_bytes());
81 hasher.update(format!("{:?}", snippet.metadata).as_bytes());
82 if let Some(fingerprint) = session_fingerprint {
83 hasher.update(fingerprint.as_bytes());
84 }
85 hasher.finalize().to_hex().to_string()
86 }
87
88 fn invalidation_key(
118 snippet: &Snippet,
119 level: ValidationLevel,
120 session_fingerprint: Option<&str>,
121 deny_unclassified: bool,
122 allowed_side_effects: &[SideEffectClass],
123 ) -> String {
124 Self::invalidation_key_for_build_identity(
125 snippet,
126 level,
127 session_fingerprint,
128 deny_unclassified,
129 allowed_side_effects,
130 crate::bin_cli::build_info::build_identity(),
131 )
132 }
133
134 fn invalidation_key_for_build_identity(
139 snippet: &Snippet,
140 level: ValidationLevel,
141 session_fingerprint: Option<&str>,
142 deny_unclassified: bool,
143 allowed_side_effects: &[SideEffectClass],
144 alef_build_identity: &str,
145 ) -> String {
146 let mut hasher = blake3::Hasher::new();
147 hasher.update(alef_build_identity.as_bytes());
148 hasher.update(snippet.language.to_string().as_bytes());
149 hasher.update(level.to_string().as_bytes());
150 hasher.update(snippet.code.as_bytes());
151 hasher.update(format!("{:?}", snippet.metadata).as_bytes());
152 hasher.update(format!("{:?}", snippet.annotation).as_bytes());
153 hasher.update(deny_unclassified.to_string().as_bytes());
154 hasher.update(format!("{allowed_side_effects:?}").as_bytes());
155 if let Some(fingerprint) = session_fingerprint {
156 hasher.update(fingerprint.as_bytes());
157 }
158 hasher.finalize().to_hex().to_string()
159 }
160
161 pub fn load(
162 &self,
163 snippet: &Snippet,
164 level: ValidationLevel,
165 session_fingerprint: Option<&str>,
166 deny_unclassified: bool,
167 allowed_side_effects: &[SideEffectClass],
168 ) -> Option<ValidationResult> {
169 let path = self.path_for(
170 snippet,
171 level,
172 session_fingerprint,
173 deny_unclassified,
174 allowed_side_effects,
175 );
176 let content = std::fs::read_to_string(path).ok()?;
177 let entry: CacheEntry = serde_json::from_str(&content).ok()?;
178 (entry.schema_version == CACHE_SCHEMA_VERSION).then_some(entry.result)
179 }
180
181 pub fn store(
185 &self,
186 snippet: &Snippet,
187 level: ValidationLevel,
188 session_fingerprint: Option<&str>,
189 deny_unclassified: bool,
190 allowed_side_effects: &[SideEffectClass],
191 result: &ValidationResult,
192 ) -> Result<()> {
193 crate::core::cache_dir::ensure_project_cache_dir(&self.directory)?;
194 let entry = CacheEntry {
195 schema_version: CACHE_SCHEMA_VERSION,
196 result: result.clone(),
197 };
198 std::fs::write(
199 self.path_for(
200 snippet,
201 level,
202 session_fingerprint,
203 deny_unclassified,
204 allowed_side_effects,
205 ),
206 serde_json::to_vec_pretty(&entry)?,
207 )?;
208 Ok(())
209 }
210
211 fn path_for(
212 &self,
213 snippet: &Snippet,
214 level: ValidationLevel,
215 session_fingerprint: Option<&str>,
216 deny_unclassified: bool,
217 allowed_side_effects: &[SideEffectClass],
218 ) -> PathBuf {
219 self.directory.join(format!(
220 "{}.json",
221 Self::invalidation_key(
222 snippet,
223 level,
224 session_fingerprint,
225 deny_unclassified,
226 allowed_side_effects
227 )
228 ))
229 }
230}
231
232#[must_use]
233pub fn default_cache_dir(root: &Path) -> PathBuf {
234 root.join(".alef/snippets")
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use crate::snippets::types::{
241 Language, SnippetAnnotation, SnippetAnnotationKind, SnippetMetadata, SnippetStatus, SourceOrigin,
242 };
243
244 fn snippet(code: &str) -> Snippet {
245 let path = PathBuf::from("example.md");
246 Snippet {
247 id: None,
248 path: path.clone(),
249 language: Language::Rust,
250 title: None,
251 code: code.to_string(),
252 start_line: 1,
253 block_index: 0,
254 annotation: None,
255 metadata: SnippetMetadata::default(),
256 source_origin: SourceOrigin {
257 path,
258 line: 1,
259 block_index: 0,
260 },
261 }
262 }
263
264 #[test]
265 fn cache_key_changes_with_content_and_level() {
266 let first = snippet("fn main() {}");
267 let second = snippet("fn main() { panic!() }");
268 assert_ne!(
269 ValidationCache::key(&first, ValidationLevel::Syntax, None),
270 ValidationCache::key(&second, ValidationLevel::Syntax, None)
271 );
272 assert_ne!(
273 ValidationCache::key(&first, ValidationLevel::Syntax, None),
274 ValidationCache::key(&first, ValidationLevel::Run, None)
275 );
276 assert_ne!(
277 ValidationCache::key(&first, ValidationLevel::Run, Some("binding-a")),
278 ValidationCache::key(&first, ValidationLevel::Run, Some("binding-b"))
279 );
280 }
281
282 #[test]
289 fn cache_key_changes_with_the_alef_version_that_computed_it() {
290 let snippet = snippet("fn main() {}");
291
292 let older = ValidationCache::key_for_validator_version(&snippet, ValidationLevel::Syntax, None, "0.64.0");
293 let newer = ValidationCache::key_for_validator_version(&snippet, ValidationLevel::Syntax, None, "0.64.1");
294
295 assert_ne!(
296 older, newer,
297 "a validator fix in a new alef release must invalidate every prior release's cache \
298 entries, not replay their verdicts"
299 );
300 }
301
302 #[test]
307 fn the_public_key_function_is_pinned_to_this_build_own_alef_version() {
308 let snippet = snippet("fn main() {}");
309
310 assert_eq!(
311 ValidationCache::key(&snippet, ValidationLevel::Syntax, None),
312 ValidationCache::key_for_validator_version(
313 &snippet,
314 ValidationLevel::Syntax,
315 None,
316 env!("CARGO_PKG_VERSION")
317 )
318 );
319 }
320
321 fn passing_result(snippet: &Snippet, level: ValidationLevel) -> ValidationResult {
322 ValidationResult {
323 snippet: snippet.clone(),
324 status: SnippetStatus::Pass,
325 level,
326 requested_level: level,
327 effective_level: level,
328 message: None,
329 duration_ms: 1,
330 capability_capped: false,
331 downgrade_reason: None,
332 unresolved_dependency: false,
333 timed_out: false,
334 preflight_skipped: false,
335 }
336 }
337
338 #[test]
346 fn storing_a_verdict_tags_the_alef_root_as_well_as_the_snippet_cache_directory() {
347 let project = tempfile::tempdir().expect("project directory");
348 let cache = ValidationCache::new(default_cache_dir(project.path()));
349 let stored = snippet("fn main() {}");
350 let result = passing_result(&stored, ValidationLevel::Compile);
351
352 cache
353 .store(&stored, ValidationLevel::Compile, None, false, &[], &result)
354 .expect("store cache entry");
355
356 for directory in [project.path().join(".alef"), default_cache_dir(project.path())] {
357 assert!(
358 crate::core::cache_dir::is_tagged(&directory),
359 "{} must carry a valid CACHEDIR.TAG",
360 directory.display()
361 );
362 }
363 }
364
365 #[test]
371 fn invalidation_key_separates_annotation_and_side_effect_policy() {
372 let plain = snippet("value = 1");
373 let mut annotated = plain.clone();
374 annotated.annotation = Some(SnippetAnnotation {
375 kind: SnippetAnnotationKind::SyntaxOnly,
376 reason: None,
377 });
378
379 let base = ValidationCache::invalidation_key(&plain, ValidationLevel::Compile, None, false, &[]);
380 assert_ne!(
381 base,
382 ValidationCache::invalidation_key(&annotated, ValidationLevel::Compile, None, false, &[]),
383 "an annotation-only change must move the invalidation key"
384 );
385 assert_ne!(
386 base,
387 ValidationCache::invalidation_key(&plain, ValidationLevel::Compile, None, true, &[]),
388 "a deny_unclassified-only change must move the invalidation key"
389 );
390 assert_ne!(
391 base,
392 ValidationCache::invalidation_key(
393 &plain,
394 ValidationLevel::Compile,
395 None,
396 false,
397 &[SideEffectClass::Network]
398 ),
399 "an allowed_side_effects-only change must move the invalidation key"
400 );
401 assert_eq!(
402 base,
403 ValidationCache::invalidation_key(&plain, ValidationLevel::Compile, None, false, &[]),
404 "the invalidation key must be deterministic for unchanged inputs"
405 );
406 }
407
408 #[test]
415 fn cache_load_misses_when_only_the_annotation_changes() {
416 let directory = tempfile::tempdir().expect("cache directory");
417 let cache = ValidationCache::new(directory.path().into());
418 let mut original = snippet("value = 1");
419 original.language = Language::Toml;
420 let cached = passing_result(&original, ValidationLevel::Compile);
421 cache
422 .store(&original, ValidationLevel::Compile, None, false, &[], &cached)
423 .expect("store cache entry");
424
425 assert!(
426 cache
427 .load(&original, ValidationLevel::Compile, None, false, &[])
428 .is_some(),
429 "querying with the exact snippet that wrote the cache must still be a hit"
430 );
431
432 let mut annotated = original.clone();
433 annotated.annotation = Some(SnippetAnnotation {
434 kind: SnippetAnnotationKind::SyntaxOnly,
435 reason: Some("author correction".to_string()),
436 });
437 assert!(
438 cache
439 .load(&annotated, ValidationLevel::Compile, None, false, &[])
440 .is_none(),
441 "correcting only the snippet's annotation must miss the cache, not replay the \
442 previous annotation's verdict -- in either direction: a snippet whose corrected \
443 annotation should now pass must not keep reporting the old downgrade, and a snippet \
444 whose new annotation should now fail must not keep reporting the old cached pass"
445 );
446 }
447
448 fn clean_build(commit: &str) -> String {
452 crate::bin_cli::build_info::render_build_identity(env!("CARGO_PKG_VERSION"), commit, "clean", "1000000000")
453 }
454
455 const SHA_A: &str = "964c552a267ccfb50a0e6f1d3b2c4a8e7f019d24";
456 const SHA_B: &str = "1d3b5f7902e4c6a8b0d2f4061e3c5a7b9d0f2e48";
457
458 #[test]
463 fn invalidation_key_separates_two_builds_of_one_version_at_different_commits() {
464 let snippet = snippet("value = 1");
465 let key_of = |identity: &str| {
466 ValidationCache::invalidation_key_for_build_identity(
467 &snippet,
468 ValidationLevel::Compile,
469 None,
470 false,
471 &[],
472 identity,
473 )
474 };
475
476 assert_ne!(
477 key_of(&clean_build(SHA_A)),
478 key_of(&clean_build(SHA_B)),
479 "two candidate binaries reporting the same semver from different commits must not \
480 share a snippet cache key, or one candidate's verdicts are replayed for the other -- \
481 masking both a fix shipped between them and a breakage introduced between them"
482 );
483 }
484
485 #[test]
490 fn invalidation_key_is_shared_by_two_clean_builds_of_one_commit() {
491 let snippet = snippet("value = 1");
492 let key_of = |identity: &str| {
493 ValidationCache::invalidation_key_for_build_identity(
494 &snippet,
495 ValidationLevel::Compile,
496 None,
497 false,
498 &[],
499 identity,
500 )
501 };
502 let morning =
503 crate::bin_cli::build_info::render_build_identity(env!("CARGO_PKG_VERSION"), SHA_A, "clean", "1000000000");
504 let evening =
505 crate::bin_cli::build_info::render_build_identity(env!("CARGO_PKG_VERSION"), SHA_A, "clean", "1999999999");
506
507 assert_eq!(
508 key_of(&morning),
509 key_of(&evening),
510 "two clean builds of one commit must share a cache key; a key that varies per build \
511 turns every release run into a cold-cache run of the whole validation pass"
512 );
513 assert_eq!(
514 key_of(&morning),
515 key_of(&clean_build(SHA_A)),
516 "the invalidation key must be deterministic for one build identity"
517 );
518 }
519
520 #[test]
525 fn cache_load_ignores_a_verdict_keyed_on_the_semver_alone() {
526 let directory = tempfile::tempdir().expect("cache directory");
527 let cache = ValidationCache::new(directory.path().into());
528 let subject = snippet("value = 1");
529 let verdict = passing_result(&subject, ValidationLevel::Compile);
530
531 let stale_key = ValidationCache::invalidation_key_for_build_identity(
532 &subject,
533 ValidationLevel::Compile,
534 None,
535 false,
536 &[],
537 ALEF_VALIDATOR_VERSION,
538 );
539 let entry = CacheEntry {
540 schema_version: CACHE_SCHEMA_VERSION,
541 result: verdict.clone(),
542 };
543 std::fs::write(
544 directory.path().join(format!("{stale_key}.json")),
545 serde_json::to_vec_pretty(&entry).expect("serialize cache entry"),
546 )
547 .expect("plant a semver-keyed cache entry");
548
549 assert!(
550 cache
551 .load(&subject, ValidationLevel::Compile, None, false, &[])
552 .is_none(),
553 "a verdict keyed on the semver alone must not be replayed: it could have been computed \
554 by any of the candidate binaries that share this version string, including one built \
555 before the validator fix under test"
556 );
557
558 cache
559 .store(&subject, ValidationLevel::Compile, None, false, &[], &verdict)
560 .expect("store cache entry");
561 assert!(
562 cache
563 .load(&subject, ValidationLevel::Compile, None, false, &[])
564 .is_some(),
565 "this binary's own verdict must still be served, or the fix has replaced stale caching \
566 with no caching"
567 );
568 }
569
570 #[test]
574 fn invalidation_key_is_pinned_to_this_build_provenance_not_just_its_semver() {
575 let subject = snippet("value = 1");
576 let live = ValidationCache::invalidation_key(&subject, ValidationLevel::Compile, None, false, &[]);
577
578 assert_ne!(
579 live,
580 ValidationCache::invalidation_key_for_build_identity(
581 &subject,
582 ValidationLevel::Compile,
583 None,
584 false,
585 &[],
586 ALEF_VALIDATOR_VERSION,
587 ),
588 "the snippet cache key must salt on more than the semver -- a semver does not identify \
589 a build, and every candidate binary in a release cycle shares one"
590 );
591 assert_eq!(
592 live,
593 ValidationCache::invalidation_key_for_build_identity(
594 &subject,
595 ValidationLevel::Compile,
596 None,
597 false,
598 &[],
599 crate::bin_cli::build_info::build_identity(),
600 ),
601 "the salt must be the same build identity `alef --version` reports, asked for rather \
602 than re-derived"
603 );
604 }
605
606 #[test]
614 fn cache_load_misses_when_only_the_side_effect_policy_changes() {
615 let directory = tempfile::tempdir().expect("cache directory");
616 let cache = ValidationCache::new(directory.path().into());
617 let side_effecting = snippet("run_side_effecting_thing();");
618 let cached = passing_result(&side_effecting, ValidationLevel::Run);
619 cache
620 .store(&side_effecting, ValidationLevel::Run, None, false, &[], &cached)
621 .expect("store cache entry");
622
623 assert!(
624 cache
625 .load(&side_effecting, ValidationLevel::Run, None, false, &[])
626 .is_some(),
627 "querying with the exact policy that wrote the cache must still be a hit"
628 );
629 assert!(
630 cache
631 .load(&side_effecting, ValidationLevel::Run, None, true, &[])
632 .is_none(),
633 "tightening deny_unclassified must miss the cache, not replay a Pass computed under \
634 the previous, looser policy"
635 );
636 assert!(
637 cache
638 .load(
639 &side_effecting,
640 ValidationLevel::Run,
641 None,
642 false,
643 &[SideEffectClass::Network]
644 )
645 .is_none(),
646 "widening allowed_side_effects must also miss the cache"
647 );
648 }
649}