1use 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}