Skip to main content

wowlab_tidy/runner/
cache.rs

1//! Persistent content-addressed analysis cache.
2
3use std::{
4    collections::BTreeMap,
5    sync::{Mutex, MutexGuard},
6};
7
8use serde::{Deserialize, Serialize};
9use wowlab_fs::{
10    atomic,
11    checksum::{self, Checksum, TreeSnapshot},
12    directory::{self, EntryKind},
13    file,
14    lock::Lock,
15    path::{Path, PathBuf},
16};
17
18use super::{FileResult, RunCtx};
19use crate::{
20    Rule, Violation,
21    languages::{
22        Analysis,
23        workspace::{WorkspaceCtx, WorkspaceManifest, WorkspaceRustFile},
24    },
25};
26
27const CACHE_SCHEMA: u32 = 1;
28const BASE_DOMAIN: &[u8] = b"wowlab-tidy:analysis-base:v1";
29const RUST_CONTEXT_DOMAIN: &[u8] = b"wowlab-tidy:rust-context:v1";
30const TOML_CONTEXT_DOMAIN: &[u8] = b"wowlab-tidy:toml-context:v1";
31const WORKSPACE_RULE_DOMAIN: &[u8] = b"wowlab-tidy:workspace-rule:v1";
32
33pub(super) struct Session {
34    _lock: Lock,
35    root: PathBuf,
36    cache_path: PathBuf,
37    fast_path: PathBuf,
38    workspace_path: PathBuf,
39    base: Checksum,
40    tree_checksum: Checksum,
41    rust_context: Checksum,
42    toml_context: Checksum,
43    file_checksums: BTreeMap<PathBuf, Checksum>,
44    cached: BTreeMap<String, CachedAnalysis>,
45    complete: Option<Vec<CachedViolation>>,
46    workspace: Mutex<BTreeMap<String, CachedWorkspaceRule>>,
47}
48
49impl Session {
50    pub(super) fn open(ctx: &RunCtx<'_>, rules: &[&'static Rule]) -> Option<Self> {
51        let source_paths =
52            crate::infra::walk::source_paths(ctx.crates_dir, &[], &["rs", "toml"]).ok()?;
53        let snapshot = TreeSnapshot::capture(ctx.crates_dir, &source_paths).ok()?;
54        let base = base_checksum(ctx, rules)?;
55        let rust_context = rust_context_checksum(ctx.crates_dir, &snapshot)?;
56        let toml_context = toml_context_checksum(ctx.root, &snapshot)?;
57        let file_checksums = snapshot
58            .entries()
59            .iter()
60            .map(|entry| (ctx.crates_dir.join(entry.relative_path()), entry.checksum()))
61            .collect();
62        let cache_dir = super::cargo_target_dir(ctx.root, ctx.crates_dir).join("tidy-cache");
63
64        directory::ensure(&cache_dir).ok()?;
65
66        let lock_path = cache_dir.join("analysis.lock");
67        let cache_path = cache_dir.join("analysis.bin");
68        let fast_path = cache_dir.join("complete.bin");
69        let workspace_path = cache_dir.join("workspace.bin");
70        let lock = Lock::try_acquire(&lock_path).ok()?;
71        let tree_checksum = snapshot.checksum();
72        let complete = read_binary::<CompleteDocument>(&fast_path)
73            .filter(|document| {
74                document.schema == CACHE_SCHEMA
75                    && document.base == base
76                    && document.tree_checksum == tree_checksum
77            })
78            .map(|document| document.violations);
79        let cached = if complete.is_some() {
80            BTreeMap::new()
81        } else {
82            read_binary::<CacheDocument>(&cache_path)
83                .filter(|document| document.schema == CACHE_SCHEMA && document.base == base)
84                .map_or_else(BTreeMap::new, |document| document.files)
85        };
86        let workspace = if complete.is_some() {
87            BTreeMap::new()
88        } else {
89            read_binary::<WorkspaceDocument>(&workspace_path)
90                .filter(|document| document.schema == CACHE_SCHEMA && document.base == base)
91                .map_or_else(BTreeMap::new, |document| document.rules)
92        };
93
94        Some(Self {
95            _lock: lock,
96            root: ctx.root.to_path_buf(),
97            cache_path,
98            fast_path,
99            workspace_path,
100            base,
101            tree_checksum,
102            rust_context,
103            toml_context,
104            file_checksums,
105            cached,
106            complete,
107            workspace: Mutex::new(workspace),
108        })
109    }
110
111    pub(super) fn complete(&self, rules: &[&'static Rule]) -> Option<Vec<Violation>> {
112        self.complete
113            .as_ref()?
114            .iter()
115            .map(|violation| violation.restore(rules))
116            .collect()
117    }
118
119    pub(super) fn restore(&self, path: &Path, rules: &[&'static Rule]) -> Option<FileResult> {
120        let rel = relative_string(path, &self.root)?;
121        let cached = self.cached.get(&rel)?;
122        let checksum = *self.file_checksums.get(path)?;
123        let context = self.context_for(path)?;
124
125        if cached.file_checksum != checksum || cached.context != context {
126            return None;
127        }
128
129        let violations = cached
130            .violations
131            .iter()
132            .map(|violation| violation.restore(rules))
133            .collect::<Option<Vec<_>>>()?;
134
135        Some(FileResult {
136            path: path.to_path_buf(),
137            analysis: Analysis {
138                violations,
139                fixes: Vec::new(),
140                tree_fixes: Vec::new(),
141                workspace_files: cached.workspace_files.clone(),
142                workspace_manifests: cached.workspace_manifests.clone(),
143            },
144        })
145    }
146
147    pub(super) fn persist(&self, root: &Path, results: &[FileResult]) {
148        let files = results
149            .iter()
150            .filter_map(|result| {
151                let rel = relative_string(&result.path, root)?;
152                let file_checksum = *self.file_checksums.get(&result.path)?;
153                let context = self.context_for(&result.path)?;
154
155                Some((
156                    rel,
157                    CachedAnalysis {
158                        file_checksum,
159                        context,
160                        violations: result
161                            .analysis
162                            .violations
163                            .iter()
164                            .map(CachedViolation::capture)
165                            .collect(),
166                        workspace_files: result.analysis.workspace_files.clone(),
167                        workspace_manifests: result.analysis.workspace_manifests.clone(),
168                    },
169                ))
170            })
171            .collect();
172        let document = CacheDocument {
173            schema: CACHE_SCHEMA,
174            base: self.base,
175            files,
176        };
177        let Ok(contents) = bincode::serde::encode_to_vec(&document, bincode::config::standard())
178        else {
179            return;
180        };
181
182        let _ = atomic::replace(&self.cache_path, contents);
183    }
184
185    pub(super) fn persist_complete(&self, violations: &[Violation]) {
186        let document = CompleteDocument {
187            schema: CACHE_SCHEMA,
188            base: self.base,
189            tree_checksum: self.tree_checksum,
190            violations: violations.iter().map(CachedViolation::capture).collect(),
191        };
192        let Ok(contents) = bincode::serde::encode_to_vec(&document, bincode::config::standard())
193        else {
194            return;
195        };
196
197        let _ = atomic::replace(&self.fast_path, contents);
198    }
199
200    pub(super) fn workspace_violations(
201        &self,
202        rule: &'static Rule,
203        ctx: &WorkspaceCtx<'_>,
204        compute: impl FnOnce() -> Vec<Violation>,
205    ) -> Vec<Violation> {
206        let Some(input) = workspace_rule_checksum(rule.info.name, ctx) else {
207            return compute();
208        };
209        let restored = {
210            let workspace = self.workspace();
211
212            workspace
213                .get(rule.info.name)
214                .filter(|cached| cached.input == input)
215                .and_then(|cached| {
216                    cached
217                        .violations
218                        .iter()
219                        .map(|violation| violation.restore(&[rule]))
220                        .collect::<Option<Vec<_>>>()
221                })
222        };
223
224        if let Some(violations) = restored {
225            return violations;
226        }
227
228        let violations = compute();
229
230        self.workspace().insert(
231            rule.info.name.to_owned(),
232            CachedWorkspaceRule {
233                input,
234                violations: violations.iter().map(CachedViolation::capture).collect(),
235            },
236        );
237
238        violations
239    }
240
241    pub(super) fn persist_workspace(&self) {
242        let document = WorkspaceDocument {
243            schema: CACHE_SCHEMA,
244            base: self.base,
245            rules: self.workspace().clone(),
246        };
247        let Ok(contents) = bincode::serde::encode_to_vec(&document, bincode::config::standard())
248        else {
249            return;
250        };
251
252        let _ = atomic::replace(&self.workspace_path, contents);
253    }
254
255    fn workspace(&self) -> MutexGuard<'_, BTreeMap<String, CachedWorkspaceRule>> {
256        self.workspace
257            .lock()
258            .unwrap_or_else(std::sync::PoisonError::into_inner)
259    }
260
261    fn context_for(&self, path: &Path) -> Option<Checksum> {
262        match path.extension().and_then(|extension| extension.to_str()) {
263            Some("rs") => Some(self.rust_context),
264            Some("toml") => Some(self.toml_context),
265            _ => None,
266        }
267    }
268}
269
270fn read_binary<T>(path: &Path) -> Option<T>
271where
272    T: for<'de> Deserialize<'de>,
273{
274    let contents = match file::read_bytes(path) {
275        Ok(contents) => contents,
276        Err(error) if error.is_not_found() => return None,
277        Err(_) => return None,
278    };
279    let (document, consumed) =
280        bincode::serde::decode_from_slice(&contents, bincode::config::standard()).ok()?;
281
282    (consumed == contents.len()).then_some(document)
283}
284
285#[derive(Deserialize, Serialize)]
286struct CacheDocument {
287    schema: u32,
288    base: Checksum,
289    files: BTreeMap<String, CachedAnalysis>,
290}
291
292#[derive(Deserialize, Serialize)]
293struct CompleteDocument {
294    schema: u32,
295    base: Checksum,
296    tree_checksum: Checksum,
297    violations: Vec<CachedViolation>,
298}
299
300#[derive(Deserialize, Serialize)]
301struct WorkspaceDocument {
302    schema: u32,
303    base: Checksum,
304    rules: BTreeMap<String, CachedWorkspaceRule>,
305}
306
307#[derive(Clone, Deserialize, Serialize)]
308struct CachedWorkspaceRule {
309    input: Checksum,
310    violations: Vec<CachedViolation>,
311}
312
313#[derive(Deserialize, Serialize)]
314struct CachedAnalysis {
315    file_checksum: Checksum,
316    context: Checksum,
317    violations: Vec<CachedViolation>,
318    workspace_files: Vec<WorkspaceRustFile>,
319    workspace_manifests: Vec<WorkspaceManifest>,
320}
321
322#[derive(Clone, Deserialize, Serialize)]
323struct CachedViolation {
324    rel: String,
325    line: usize,
326    message: String,
327    rule: Option<String>,
328}
329
330impl CachedViolation {
331    fn capture(violation: &Violation) -> Self {
332        Self {
333            rel: violation.rel.clone(),
334            line: violation.line,
335            message: violation.message.clone(),
336            rule: violation.rule.map(str::to_owned),
337        }
338    }
339
340    fn restore(&self, rules: &[&'static Rule]) -> Option<Violation> {
341        let rule = self.rule.as_deref().map_or(Some(None), |name| {
342            rules
343                .iter()
344                .find(|rule| rule.info.name == name)
345                .map(|rule| Some(rule.info.name))
346        })?;
347
348        Some(Violation {
349            rel: self.rel.clone(),
350            line: self.line,
351            message: self.message.clone(),
352            rule,
353        })
354    }
355}
356
357fn base_checksum(ctx: &RunCtx<'_>, rules: &[&'static Rule]) -> Option<Checksum> {
358    let executable = checksum::current_executable().ok()?;
359    let config = serde_json::to_vec(ctx.config).ok()?;
360    let mut rule_names = rules.iter().map(|rule| rule.info.name).collect::<Vec<_>>();
361
362    rule_names.sort_unstable();
363
364    let mut encoded = Vec::new();
365
366    append_part(&mut encoded, BASE_DOMAIN)?;
367    append_part(&mut encoded, executable.as_bytes())?;
368    append_part(&mut encoded, &config)?;
369
370    for name in rule_names {
371        append_part(&mut encoded, name.as_bytes())?;
372    }
373
374    Some(checksum::bytes(encoded))
375}
376
377fn workspace_rule_checksum(name: &str, ctx: &WorkspaceCtx<'_>) -> Option<Checksum> {
378    let material = match name {
379        "rust_duplicate_strings" => bincode::serde::encode_to_vec(
380            ctx.files
381                .iter()
382                .map(|file| (&file.rel, &file.strings, &file.suppressions))
383                .collect::<Vec<_>>(),
384            bincode::config::standard(),
385        )
386        .ok()?,
387        "rust_param_clump" => bincode::serde::encode_to_vec(
388            ctx.files
389                .iter()
390                .map(|file| {
391                    (
392                        &file.rel,
393                        file.functions
394                            .iter()
395                            .map(|function| {
396                                (
397                                    &function.name,
398                                    function.line,
399                                    &function.params,
400                                    &function.pass_through_calls,
401                                )
402                            })
403                            .collect::<Vec<_>>(),
404                        &file.suppressions,
405                    )
406                })
407                .collect::<Vec<_>>(),
408            bincode::config::standard(),
409        )
410        .ok()?,
411        "rust_similar_fns" => bincode::serde::encode_to_vec(
412            ctx.files
413                .iter()
414                .map(|file| {
415                    (
416                        &file.rel,
417                        file.functions
418                            .iter()
419                            .map(|function| {
420                                (
421                                    &function.name,
422                                    function.line,
423                                    function.body_token_count,
424                                    function.body_checksum,
425                                    &function.body_shingles,
426                                )
427                            })
428                            .collect::<Vec<_>>(),
429                        &file.suppressions,
430                    )
431                })
432                .collect::<Vec<_>>(),
433            bincode::config::standard(),
434        )
435        .ok()?,
436        "rust_similar_structs" => bincode::serde::encode_to_vec(
437            ctx.files
438                .iter()
439                .map(|file| (&file.rel, &file.structs, &file.suppressions))
440                .collect::<Vec<_>>(),
441            bincode::config::standard(),
442        )
443        .ok()?,
444        "toml_cargo_unused_deps" => bincode::serde::encode_to_vec(
445            (
446                ctx.files
447                    .iter()
448                    .map(|file| (&file.rel, &file.crate_roots))
449                    .collect::<Vec<_>>(),
450                ctx.manifests,
451            ),
452            bincode::config::standard(),
453        )
454        .ok()?,
455        _ => return None,
456    };
457    let mut encoded = Vec::new();
458
459    append_part(&mut encoded, WORKSPACE_RULE_DOMAIN)?;
460    append_part(&mut encoded, name.as_bytes())?;
461    append_part(&mut encoded, &material)?;
462
463    Some(checksum::bytes(encoded))
464}
465
466fn rust_context_checksum(crates_dir: &Path, snapshot: &TreeSnapshot) -> Option<Checksum> {
467    let mut encoded = Vec::new();
468
469    append_part(&mut encoded, RUST_CONTEXT_DOMAIN)?;
470
471    for entry in snapshot.entries() {
472        let path = entry.relative_path();
473        let cargo_manifest = path.file_name().is_some_and(|name| name == "Cargo.toml");
474
475        if !cargo_manifest {
476            continue;
477        }
478
479        append_part(&mut encoded, path.to_str()?.as_bytes())?;
480        append_part(&mut encoded, entry.checksum().as_bytes())?;
481    }
482
483    append_directory_layout(
484        crates_dir,
485        &crates_dir.join("engine-content/src/specs"),
486        &mut encoded,
487    )?;
488
489    Some(checksum::bytes(encoded))
490}
491
492fn append_directory_layout(root: &Path, directory_path: &Path, output: &mut Vec<u8>) -> Option<()> {
493    if directory::inspect_link(directory_path).ok()?.is_none() {
494        return append_part(output, b"missing");
495    }
496
497    let mut pending = vec![directory_path.to_path_buf()];
498
499    while let Some(path) = pending.pop() {
500        let info = directory::inspect_link(&path).ok()??;
501
502        append_part(output, path.strip_prefix(root).ok()?.to_str()?.as_bytes())?;
503        append_part(output, entry_kind_bytes(info.kind()))?;
504
505        if info.kind() == EntryKind::Directory {
506            let mut entries = directory::entries(&path).ok()?;
507
508            entries.reverse();
509            pending.extend(entries.into_iter().map(|entry| entry.path().to_path_buf()));
510        }
511    }
512
513    Some(())
514}
515
516const fn entry_kind_bytes(kind: EntryKind) -> &'static [u8] {
517    match kind {
518        EntryKind::File => b"file",
519        EntryKind::Directory => b"directory",
520        EntryKind::Symlink => b"symlink",
521        _ => b"other",
522    }
523}
524
525fn toml_context_checksum(root: &Path, snapshot: &TreeSnapshot) -> Option<Checksum> {
526    let mut encoded = Vec::new();
527
528    append_part(&mut encoded, TOML_CONTEXT_DOMAIN)?;
529    append_part(&mut encoded, snapshot.checksum().as_bytes())?;
530
531    let cargo_config = root.join(".cargo/config.toml");
532
533    match directory::inspect_link(&cargo_config).ok()? {
534        Some(_) => append_part(&mut encoded, checksum::file(&cargo_config).ok()?.as_bytes())?,
535        None => append_part(&mut encoded, b"missing")?,
536    }
537
538    Some(checksum::bytes(encoded))
539}
540
541fn append_part(output: &mut Vec<u8>, value: &[u8]) -> Option<()> {
542    let len = u64::try_from(value.len()).ok()?;
543
544    output.extend_from_slice(&len.to_le_bytes());
545    output.extend_from_slice(value);
546
547    Some(())
548}
549
550fn relative_string(path: &Path, root: &Path) -> Option<String> {
551    path.strip_prefix(root).ok()?.to_str().map(str::to_owned)
552}
553
554#[cfg(test)]
555mod tests {
556    use std::collections::HashMap;
557
558    use googletest::prelude::*;
559    use wowlab_fs::temporary;
560    use wowlab_types::sim::FastSet;
561
562    use super::*;
563    use crate::{
564        Config,
565        infra::ignore::Suppressions,
566        languages::workspace::{FunctionRecord, ShingleFingerprint},
567    };
568
569    #[gtest]
570    fn binary_cache_rejects_trailing_or_corrupt_data() -> Result<()> {
571        let temporary = temporary::Directory::new().or_fail()?;
572        let path = temporary.path().join("cache.bin");
573        let document = CompleteDocument {
574            schema: CACHE_SCHEMA,
575            base: checksum::bytes(b"base"),
576            tree_checksum: checksum::bytes(b"tree"),
577            violations: Vec::new(),
578        };
579        let mut contents =
580            bincode::serde::encode_to_vec(&document, bincode::config::standard()).or_fail()?;
581
582        file::write_bytes(&path, &contents).or_fail()?;
583        verify_that!(
584            read_binary::<CompleteDocument>(&path).map(|decoded| decoded.schema),
585            some(eq(CACHE_SCHEMA))
586        )?;
587
588        contents.push(0);
589        file::write_bytes(&path, &contents).or_fail()?;
590        verify_that!(read_binary::<CompleteDocument>(&path).is_none(), is_true())?;
591
592        file::write_bytes(&path, b"not a tidy cache").or_fail()?;
593        verify_that!(read_binary::<CompleteDocument>(&path).is_none(), is_true())?;
594
595        Ok(())
596    }
597
598    #[gtest]
599    fn rust_context_tracks_manifests_and_empty_spec_directories() -> Result<()> {
600        let temporary = temporary::Directory::new().or_fail()?;
601        let crates_dir = temporary.path();
602        let manifest = crates_dir.join("example/Cargo.toml");
603        let source = crates_dir.join("example/src/lib.rs");
604        let specs = crates_dir.join("engine-content/src/specs");
605
606        directory::ensure(source.parent().or_fail()?).or_fail()?;
607        directory::ensure(&specs.join("alpha")).or_fail()?;
608        file::write_text(&manifest, "[package]\nname = \"example\"\n").or_fail()?;
609        file::write_text(&source, "pub fn value() -> u8 { 1 }\n").or_fail()?;
610
611        let initial_snapshot = TreeSnapshot::capture(crates_dir, [&manifest, &source]).or_fail()?;
612
613        let initial = rust_context_checksum(crates_dir, &initial_snapshot).or_fail()?;
614
615        file::write_text(&source, "pub fn value() -> u8 { 2 }\n").or_fail()?;
616        let source_snapshot = TreeSnapshot::capture(crates_dir, [&manifest, &source]).or_fail()?;
617
618        verify_that!(
619            rust_context_checksum(crates_dir, &source_snapshot),
620            some(eq(initial))
621        )?;
622
623        directory::ensure(&specs.join("beta")).or_fail()?;
624
625        let directory_changed = rust_context_checksum(crates_dir, &source_snapshot).or_fail()?;
626
627        verify_that!(directory_changed, not(eq(initial)))?;
628
629        file::write_text(
630            &manifest,
631            "[package]\nname = \"example\"\nversion = \"1.0.0\"\n",
632        )
633        .or_fail()?;
634        let manifest_snapshot =
635            TreeSnapshot::capture(crates_dir, [&manifest, &source]).or_fail()?;
636
637        verify_that!(
638            rust_context_checksum(crates_dir, &manifest_snapshot),
639            some(not(eq(directory_changed)))
640        )?;
641
642        Ok(())
643    }
644
645    #[gtest]
646    fn workspace_rule_keys_track_only_the_records_each_rule_reads() -> Result<()> {
647        let config = Config::generate_default(&[]);
648        let mut files = vec![WorkspaceRustFile {
649            rel: "crates/example/src/lib.rs".to_owned(),
650            structs: Vec::new(),
651            functions: vec![FunctionRecord {
652                name: "run".to_owned(),
653                line: 1,
654                body_token_count: 42,
655                body_checksum: checksum::bytes(b"body-one"),
656                body_shingles: Box::<[ShingleFingerprint]>::default(),
657                params: vec![("value".to_owned(), "u32".to_owned())],
658                pass_through_calls: Box::default(),
659            }],
660            strings: Vec::new(),
661            crate_roots: FastSet::default(),
662            suppressions: Suppressions {
663                lines: HashMap::default(),
664                file_rules: Vec::new(),
665                file_all: false,
666                entries: Vec::new(),
667            },
668        }];
669        let ctx = WorkspaceCtx {
670            files: &files,
671            manifests: &[],
672            config: &config,
673        };
674        let initial_functions = workspace_rule_checksum("rust_similar_fns", &ctx).or_fail()?;
675        let initial_params = workspace_rule_checksum("rust_param_clump", &ctx).or_fail()?;
676
677        files[0].functions[0].body_checksum = checksum::bytes(b"body-two");
678        let ctx = WorkspaceCtx {
679            files: &files,
680            manifests: &[],
681            config: &config,
682        };
683        let body_functions = workspace_rule_checksum("rust_similar_fns", &ctx).or_fail()?;
684        let body_params = workspace_rule_checksum("rust_param_clump", &ctx).or_fail()?;
685
686        verify_that!(body_functions, not(eq(initial_functions)))?;
687        verify_that!(body_params, eq(initial_params))?;
688
689        files[0].functions[0]
690            .params
691            .push(("other".to_owned(), "bool".to_owned()));
692        let ctx = WorkspaceCtx {
693            files: &files,
694            manifests: &[],
695            config: &config,
696        };
697
698        verify_that!(
699            workspace_rule_checksum("rust_similar_fns", &ctx),
700            some(eq(body_functions))
701        )?;
702
703        verify_that!(
704            workspace_rule_checksum("rust_param_clump", &ctx),
705            some(not(eq(body_params)))
706        )
707    }
708}