Skip to main content

htl_core/
cache.rs

1//! An on-disk cache of what checking one module reported (issues #3, #19).
2//!
3//! Building a checker costs about 13.5 ms and type-checking a few thousand lines of Teal
4//! costs about a second (`crates/htl-core/benches/check.rs`). A run that changed nothing
5//! should pay neither, and a run that changed one module should pay for that module and
6//! what depends on it rather than for the project.
7//!
8//! # One entry per module
9//!
10//! #3 shipped this as one entry per invocation, because at the time it looked as though
11//! skipping individual modules would change what the rest of them saw. Two things settled
12//! that: #21 made a file's result independent of its position in the walk, and measurement
13//! showed the shared module store to be an optimisation rather than a precondition — a
14//! dependency being checked first does not change what its requirer reports. So an entry
15//! is now per module, and a run is the sum of them.
16//!
17//! # What makes an entry
18//!
19//! The **key** is the module as this invocation names it — the path spelling the checker
20//! will put into the diagnostics, plus the lint selection and the working directory.
21//! `--strict` and `--format` are deliberately absent: they change how a run is summarized
22//! and what it exits with, not what any module reports, so runs that differ only in those
23//! share their modules' entries.
24//!
25//! The **inputs** are the module and everything reading it required, by content hash. The
26//! **probes** are the directories a `require` could resolve in, by the set of module names
27//! in each — a new `.tl` appearing earlier on the search path changes what a name resolves
28//! to while every recorded hash still matches, and nothing else would catch it. This is the
29//! hole ccache documents in its direct mode.
30//!
31//! # No mtimes anywhere
32//!
33//! Content hashes only. Timestamp-based invalidation is where this class of tool
34//! historically breaks — second-granular filesystems on macOS, mtimes zeroed by Docker
35//! layers, clock skew, fresh CI checkouts invalidating everything — and hashing a module
36//! costs microseconds against the milliseconds it saves.
37//!
38//! # Failure is a miss
39//!
40//! Every error path here returns "no entry" rather than propagating. A corrupt file, an
41//! unreadable directory, a store on a read-only filesystem: the module gets checked, as it
42//! would have been anyway. The one invariant worth stating is mypy's: an entry is written
43//! whole or not at all, via a temporary file and a rename, so a reader never sees half of
44//! one. Set `HTL_CACHE_DEBUG=1` to print why a lookup missed.
45//!
46//! # Why this lives in htl-core
47//!
48//! The store began in the CLI, which was the only reader. The linker is a reader too
49//! (#100): `htl build` and `include_bundle!` walk the require closure through
50//! [`crate::link`], and a module whose `gen` entry still holds — the same entry `htl test`
51//! writes and replays — has no reason to be generated again. `htl-cli` depends on `htl`,
52//! which depends on `htl-macros`, so a store the macros can open has to sit below both.
53//! The CLI keeps its flags, its `cache status` command and its report types; what moved is
54//! the store and the JSON shapes it writes, and the conversions between those shapes and
55//! [`crate::CheckInfo`], which every reader was carrying a copy of.
56//!
57//! Only `htl check` sweeps ([`Cache::sweep`]). It is the one reader that sees the whole
58//! project, so its keep-list is the whole project's; a build or a macro expansion sees one
59//! closure, and sweeping from that view would evict every other entry the project has.
60
61use anyhow::Result;
62use serde::{Deserialize, Serialize};
63use std::cell::RefCell;
64use std::collections::HashMap;
65use std::path::{Path, PathBuf};
66
67use crate::{CheckInfo, DependencyError, Fix, RequireSite};
68
69/// Bumped by hand when anything below changes shape. An entry stamped with a different
70/// value is a miss rather than an error: a fresh checkout and an upgrade both take that
71/// path in normal operation, which is why rustc treats its own header mismatch the same
72/// way.
73///
74/// 6: the stamp carries the checker's identity, and a `module` entry carries nothing
75/// else about the binary (#100), so the proc macros — whose `current_exe` is rustc, which
76/// does not change when htl does — still miss on a checker that no longer exists, and
77/// the CLI and the macros read each other's module entries.
78const FORMAT: u32 = 6;
79
80/// Where the store lives under the project root. Generated, and `htl init` puts it in
81/// `.gitignore`.
82const DIR: &str = ".htl/cache";
83
84/// How many entries a project's store may hold before a run starts dropping the ones it did
85/// not use.
86///
87/// Scaled to the project, because the natural size is one entry per module per way of
88/// invoking the check, and "a few ways" is what people do. The floor keeps a small project
89/// from evicting itself on its second invocation.
90fn default_bound(files: usize) -> usize {
91    (files * 4).max(256)
92}
93
94/// What produced an entry. A cache is only as good as its ability to notice it was
95/// written by something else.
96///
97/// The binary's length and modification time stand in for "which checker is this": during
98/// development the vendored Teal compiler, the prelude and the Rust side all change many
99/// times within one released version number, and a stamp that only carried the version
100/// would happily replay results from a checker that no longer exists. Rebuilding always
101/// moves both. This is the same reasoning behind sccache hashing the compiler binary into
102/// its key.
103///
104/// A [`MODULE`] entry — a required module's generated Lua — is stamped without the
105/// binary. Those entries are written by `htl test`, `htl build` and the proc macros, and
106/// inside a proc macro the binary is `rustc`: stamping with it would have the CLI and the
107/// macros overwrite each other's entries for the same module forever. What decides what a
108/// module generates is the Lua the checker is made of (the vendored `tl`, the prelude, the
109/// lints), so those entries carry [`crate::checker_identity`] — a hash of exactly that —
110/// beside the format and the htl version, and nothing that differs between two binaries
111/// built from the same sources.
112#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)]
113struct Stamp {
114    format: u32,
115    htl: String,
116    checker: String,
117    exe_len: u64,
118    exe_mtime_ns: u128,
119}
120
121impl Stamp {
122    /// What this build stamps an entry of `kind` with: the binary too, except for
123    /// [`MODULE`] entries (see the type doc).
124    fn for_kind(kind: &str) -> Option<Self> {
125        if kind == MODULE {
126            Some(Self::portable())
127        } else {
128            Self::current()
129        }
130    }
131
132    fn current() -> Option<Self> {
133        let exe = std::env::current_exe().ok()?;
134        let m = std::fs::metadata(&exe).ok()?;
135        let mtime = m
136            .modified()
137            .ok()?
138            .duration_since(std::time::UNIX_EPOCH)
139            .ok()?
140            .as_nanos();
141        Some(Self {
142            exe_len: m.len(),
143            exe_mtime_ns: mtime,
144            ..Self::portable()
145        })
146    }
147
148    /// The stamp with nothing about the binary in it.
149    fn portable() -> Self {
150        Self {
151            format: FORMAT,
152            htl: env!("CARGO_PKG_VERSION").to_string(),
153            checker: crate::checker_identity().to_string(),
154            exe_len: 0,
155            exe_mtime_ns: 0,
156        }
157    }
158}
159
160/// One file the check read, by content.
161#[derive(Serialize, Deserialize, Debug)]
162struct Input {
163    path: String,
164    hash: String,
165}
166
167/// One directory a `require` could have resolved in, and what it offered *for the names
168/// this module asked for*.
169///
170/// This catches a file appearing where the checker would now find it, which no hash of the
171/// files it did read can see — the hole ccache documents in its direct mode. It used to
172/// hash every module name in the directory, which meant adding any file at all invalidated
173/// every module whose search path included it: writing one new module re-checked the whole
174/// project (#24). A file appearing under a name nobody requires cannot change what anybody
175/// resolved, so only the requested names go in.
176#[derive(Serialize, Deserialize, Debug)]
177struct Probe {
178    dir: String,
179    /// Hash of `(name, whether it resolves here)` over the module's own requires, in the
180    /// order they are stored. Changing which directory a name resolves in changes this for
181    /// both directories involved, which is how a shadowing file is caught.
182    names: String,
183}
184
185/// One diagnostic exactly as it was handed to the sink, so a replay goes through the same
186/// printing code the original run did rather than through a reconstruction of it.
187/// Reconstructing text from parsed fields is how a cache starts printing subtly different
188/// output from the run it claims to reproduce.
189#[derive(Serialize, Deserialize, Debug, Clone)]
190pub struct Recorded {
191    /// [`Severity::as_str`](crate::Severity::as_str)'s word. A string rather than the enum
192    /// because an entry outlives the build that wrote it, and a word this build does not
193    /// know has to read back as itself rather than fail the whole entry.
194    pub severity: String,
195    /// The finished line, position prefix and `[htl <rule>]` suffix included — what the
196    /// sink was handed, not what it was assembled from.
197    pub text: String,
198    /// The fix the diagnostic carried, for `htl fix` replaying instead of re-checking.
199    /// `None` when the diagnostic had none, which is most of them.
200    pub fix: Option<FixJson>,
201    /// Set when the text is an error in a module this one required rather than in this
202    /// one: the dependency and the file that required it. The entry carries every such
203    /// error the check found; the sink decides at replay, as it did at the original run,
204    /// which of them to say (once per run). Absent in entries written before this field
205    /// existed, which then read as having none.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub dependency: Option<DependencyJson>,
208}
209
210/// A [`Fix`] as an entry (and the CLI's `--format json`) stores it.
211#[derive(Serialize, Deserialize, Debug, Clone)]
212pub struct FixJson {
213    /// `safe` / `unsafe` / `suggest`. Owned rather than `&'static str` because the store
214    /// reads these back, and a borrowed field cannot be deserialized into. The JSON is
215    /// unchanged either way.
216    pub applicability: String,
217    /// The edits, in the order [`Fix`] holds them. A fix is all of them or none: applying
218    /// part of one leaves the file in a state nobody asked for.
219    pub edits: Vec<EditJson>,
220}
221
222/// One replacement in a [`FixJson`]: a half-open span and what goes there.
223///
224/// Positions are the checker's — lines and columns counted from 1 — rather than byte
225/// offsets, because that is what the diagnostic beside it says and an entry that stored
226/// them differently would have to be trusted to convert the same way twice.
227#[derive(Serialize, Deserialize, Debug, Clone)]
228pub struct EditJson {
229    /// First line of the span.
230    pub line: usize,
231    /// First column of the span, inclusive.
232    pub col: usize,
233    /// Last line of the span; the same as `line` for an edit inside one line.
234    pub end_line: usize,
235    /// Column the span stops before, exclusive — so an empty span is an insertion.
236    pub end_col: usize,
237    /// What replaces the span. Empty to delete it.
238    pub text: String,
239}
240
241impl FixJson {
242    /// A fix as an entry stores it, for the run that writes one.
243    pub fn from_fix(f: &Fix) -> Self {
244        Self {
245            applicability: f.applicability.as_str().to_string(),
246            edits: f
247                .edits
248                .iter()
249                .map(|e| EditJson {
250                    line: e.line,
251                    col: e.col,
252                    end_line: e.end_line,
253                    end_col: e.end_col,
254                    text: e.text.clone(),
255                })
256                .collect(),
257        }
258    }
259
260    /// And back, for a replayed one. An applicability this build does not know reads as
261    /// the most cautious of the three rather than failing the entry — see the match below.
262    pub fn to_fix(&self) -> Fix {
263        Fix {
264            applicability: match self.applicability.as_str() {
265                "unsafe" => crate::Applicability::Unsafe,
266                "suggest" => crate::Applicability::Suggest,
267                // Anything else is a build that wrote a name this one does not know;
268                // treating it as the most cautious of the three is the only safe reading.
269                "safe" => crate::Applicability::Safe,
270                _ => crate::Applicability::Suggest,
271            },
272            edits: self
273                .edits
274                .iter()
275                .map(|e| crate::Edit {
276                    line: e.line,
277                    col: e.col,
278                    end_line: e.end_line,
279                    end_col: e.end_col,
280                    text: e.text.clone(),
281                })
282                .collect(),
283        }
284    }
285}
286
287/// What a dependency's diagnostic carries besides its text: the file that required it
288/// and where the file lives. Stored with the diagnostic ([`Recorded`]) so a replay says
289/// exactly what the run said, and decides the same way whether to say it.
290#[derive(Serialize, Deserialize, Debug, Clone)]
291pub struct DependencyJson {
292    /// The file the error is in, as the checker found it.
293    pub file: String,
294    /// The file whose `require` pulled it in — which is the one the reader is looking at,
295    /// and so the one a message about somebody else's file has to name.
296    pub required_by: String,
297    /// `dependency` / `external`, or none for a file of the project's own.
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub origin: Option<String>,
300}
301
302/// One literal `require` and where the checker resolved it. Kept because the project-level
303/// cycle lint runs over every file's requires, replayed ones included — a cycle that closes
304/// through a module nobody edited is still a cycle.
305#[derive(Serialize, Deserialize, Debug, Clone)]
306pub struct RequireJson {
307    /// The name as the source spells it.
308    pub module: String,
309    /// What it resolved to, `None` when the checker found nothing — a host module, or a
310    /// require that will fail. Stored either way: the cycle lint reads the resolved ones,
311    /// and a name that starts resolving is a change this entry has to notice.
312    pub path: Option<String>,
313    /// Where the `require` is, so a finding about it points at the call rather than the
314    /// file.
315    pub line: usize,
316    /// Column of the same.
317    pub col: usize,
318}
319
320/// What checking one module reported.
321#[derive(Serialize, Deserialize, Debug, Clone)]
322pub struct Module {
323    /// Its diagnostics, its contract lints included, in the order they were printed.
324    pub diagnostics: Vec<Recorded>,
325    /// How many of `diagnostics` were errors, counted when the entry was written.
326    ///
327    /// Stored rather than recounted on replay: the three are what a run adds up to decide
328    /// its exit code, and a replay that recounted them would be deciding that from its own
329    /// reading of the severity strings instead of from what the original run concluded.
330    pub errors: usize,
331    /// Warnings, as `errors`.
332    pub warnings: usize,
333    /// Lints, as `errors`.
334    pub lints: usize,
335    /// What the checker resolved this module's requires to. The next run keys on these,
336    /// which is how a dependency's edit invalidates its dependents.
337    pub deps: Vec<String>,
338    /// Every `require` in the source and where it went, which `deps` is the resolved,
339    /// deduplicated half of. Kept whole because the cycle lint reports a call site, and a
340    /// path cannot say which line asked for it.
341    pub requires: Vec<RequireJson>,
342    /// The Lua this module generates, for entries under [`gen_key`]. `htl check` never needs
343    /// it and stores `None`; `htl test` stores it so a replay can go straight to running.
344    /// Absent when checking produced errors, since there is nothing to run then.
345    #[serde(default)]
346    pub code: Option<String>,
347    /// What checking reported, in the form the test runner needs it back.
348    ///
349    /// `htl check` replays `diagnostics` straight into the sink and never reconstructs a
350    /// `CheckInfo`; the test runner puts one into its report, so a replayed test file needs
351    /// the structured form rather than the printed one. Only `gen_key` entries carry it.
352    #[serde(default)]
353    pub check: Option<CheckInfoJson>,
354}
355
356/// A `CheckInfo` as an entry stores it.
357///
358/// Deliberately a separate type from the diagnostics `htl check` replays: those are text
359/// on their way to a terminal, these are the fields the runner reads back.
360#[derive(Serialize, Deserialize, Debug, Clone, Default)]
361pub struct CheckInfoJson {
362    /// [`CheckInfo::errors`], verbatim.
363    pub errors: Vec<String>,
364    /// [`CheckInfo::warnings`], verbatim.
365    pub warnings: Vec<String>,
366    /// [`CheckInfo::lints`], verbatim.
367    pub lints: Vec<String>,
368    /// [`CheckInfo::deps`] as strings. `PathBuf` does not round-trip through JSON on every
369    /// platform; these are normalised on the way in and rebuilt on the way out.
370    pub deps: Vec<String>,
371    /// [`CheckInfo::requires`], in this module's own JSON shape.
372    pub requires: Vec<RequireJson>,
373    /// Parallel to `errors`, as in [`CheckInfo::error_fixes`]: `error_fixes[i]` belongs to
374    /// `errors[i]`. Stored as a full-length vector of `Option` rather than as the fixes
375    /// that exist, so the pairing survives without an index.
376    pub error_fixes: Vec<Option<FixJson>>,
377    /// Parallel to `lints`, as `error_fixes` is to `errors`.
378    pub lint_fixes: Vec<Option<FixJson>>,
379    /// `CheckInfo::dependency_errors`, so the runner reads back the whole of what the
380    /// check said. Absent in entries written before the field existed.
381    #[serde(default)]
382    pub dependency_errors: Vec<DependencyErrorJson>,
383}
384
385impl CheckInfoJson {
386    /// What a check reported, in the form an entry stores it.
387    pub fn from_check(c: &CheckInfo) -> Self {
388        Self {
389            errors: c.errors.clone(),
390            warnings: c.warnings.clone(),
391            lints: c.lints.clone(),
392            deps: c.deps.iter().map(|p| normal(p)).collect(),
393            requires: requires_json(c),
394            error_fixes: c
395                .error_fixes
396                .iter()
397                .map(|f| f.as_ref().map(FixJson::from_fix))
398                .collect(),
399            lint_fixes: c
400                .lint_fixes
401                .iter()
402                .map(|f| f.as_ref().map(FixJson::from_fix))
403                .collect(),
404            dependency_errors: c
405                .dependency_errors
406                .iter()
407                .map(|e| DependencyErrorJson {
408                    file: e.file.display().to_string(),
409                    required_by: e.required_by.display().to_string(),
410                    text: e.text.clone(),
411                })
412                .collect(),
413        }
414    }
415
416    /// And back, for a replayed module: the test runner puts it into its report, the
417    /// linker reads its lints, requires and dependency errors.
418    pub fn to_check(&self) -> CheckInfo {
419        CheckInfo {
420            errors: self.errors.clone(),
421            warnings: self.warnings.clone(),
422            lints: self.lints.clone(),
423            deps: self.deps.iter().map(PathBuf::from).collect(),
424            requires: requires_from_json(&self.requires),
425            error_fixes: self
426                .error_fixes
427                .iter()
428                .map(|f| f.as_ref().map(FixJson::to_fix))
429                .collect(),
430            lint_fixes: self
431                .lint_fixes
432                .iter()
433                .map(|f| f.as_ref().map(FixJson::to_fix))
434                .collect(),
435            dependency_errors: self
436                .dependency_errors
437                .iter()
438                .map(|e| DependencyError {
439                    file: PathBuf::from(&e.file),
440                    required_by: PathBuf::from(&e.required_by),
441                    text: e.text.clone(),
442                })
443                .collect(),
444        }
445    }
446}
447
448/// One `htl::DependencyError` as an entry stores it.
449#[derive(Serialize, Deserialize, Debug, Clone)]
450pub struct DependencyErrorJson {
451    /// The required module the error is in.
452    pub file: String,
453    /// The file whose `require` reached it — the one being checked, and the one a report
454    /// names.
455    pub required_by: String,
456    /// The error as the dependency's own check phrased it.
457    pub text: String,
458}
459
460/// `CheckInfo`'s requires in the form an entry stores them.
461pub fn requires_json(c: &CheckInfo) -> Vec<RequireJson> {
462    c.requires
463        .iter()
464        .map(|r| RequireJson {
465            module: r.module.clone(),
466            path: r.path.as_ref().map(|p| p.to_string_lossy().into_owned()),
467            line: r.line,
468            col: r.col,
469        })
470        .collect()
471}
472
473fn requires_from_json(requires: &[RequireJson]) -> Vec<RequireSite> {
474    requires
475        .iter()
476        .map(|r| RequireSite {
477            module: r.module.clone(),
478            path: r.path.as_ref().map(PathBuf::from),
479            line: r.line,
480            col: r.col,
481        })
482        .collect()
483}
484
485impl Module {
486    /// A `CheckInfo` carrying only what the project-level lints read.
487    ///
488    /// `require_cycles` runs over every file in the walk, replayed ones included — a cycle
489    /// that closes through a module nobody edited is still a cycle — so a replayed module
490    /// has to produce something that lint can read. Its diagnostics are already printed by
491    /// then, and nothing downstream looks at the other fields.
492    pub fn requires_only(&self) -> CheckInfo {
493        CheckInfo {
494            deps: self.deps.iter().map(PathBuf::from).collect(),
495            requires: requires_from_json(&self.requires),
496            ..Default::default()
497        }
498    }
499
500    /// A `gen` entry as the linker and the test runner write it: no printed diagnostics
501    /// (they print from the structured form), the generated Lua, and what the check said.
502    pub fn generated(c: &CheckInfo, code: String) -> Self {
503        Self {
504            diagnostics: Vec::new(),
505            errors: c.errors.len(),
506            warnings: c.warnings.len(),
507            lints: c.lints.len(),
508            deps: c.deps.iter().map(|p| normal(p)).collect(),
509            requires: requires_json(c),
510            code: Some(code),
511            check: Some(CheckInfoJson::from_check(c)),
512        }
513    }
514}
515
516/// Directories a `require` could resolve in, listed whether or not they exist yet.
517///
518/// The ones that do not exist matter most: a `types/` created after an entry was written
519/// changes what a module name resolves to while every file the entry recorded still hashes
520/// the same. Recording only the directories that happened to exist is the hole ccache
521/// documents in its direct mode, and an empty directory hashes differently from one holding
522/// the name, so a probe over a directory that is not there yet is what catches its arrival.
523///
524/// `cfg` is the project's `htl.toml` with the directory it was found in, for the extra
525/// `[check] paths` it names.
526///
527/// In an `mlua-pkg.toml` project the directories its installed deps resolve from are
528/// listed too — the same ones `Htl::apply_project` puts on the path — so that `htl pkg
529/// install` bringing a dependency in, after an entry recorded that the name resolved
530/// nowhere, is seen as the change it is rather than replayed as `module not found`.
531pub fn search_dirs(
532    file: &Path,
533    root: &Path,
534    cfg: Option<(&Path, &crate::config::HtlConfig)>,
535) -> Vec<PathBuf> {
536    let mut out: Vec<PathBuf> = file.parent().map(Path::to_path_buf).into_iter().collect();
537    out.push(root.to_path_buf());
538    out.push(root.join("src"));
539    out.push(root.join("types"));
540    out.extend(crate::materialised_types_dirs(&root.join("types")));
541    if let Some((r, c)) = cfg {
542        out.extend(c.search_paths(r));
543    }
544    out.extend(crate::dependency_dirs(root));
545    out
546}
547
548#[derive(Serialize, Deserialize, Debug)]
549struct Entry {
550    stamp: Stamp,
551    /// The file this entry is about, and which question it answers.
552    ///
553    /// Neither is needed to use the entry — the key already decided both. They are here so
554    /// the store can be described: the files on disk are named by hash, and a cache nobody
555    /// can read the contents of is one nobody can reason about (`htl cache status`).
556    subject: String,
557    kind: String,
558    inputs: Vec<Input>,
559    probes: Vec<Probe>,
560    module: Module,
561}
562
563/// One entry holding the whole walk, for `Mode::WholeRun`.
564#[derive(Serialize, Deserialize, Debug)]
565struct RunEntry {
566    stamp: Stamp,
567    /// The files the walk visited, and which question this answers. See `Entry`.
568    subjects: Vec<String>,
569    kind: String,
570    inputs: Vec<Input>,
571    probes: Vec<Probe>,
572    /// One per file in the walk, in walk order. A different count means the walk itself
573    /// changed, which is a miss before any hash is compared.
574    modules: Vec<Module>,
575}
576
577/// How much of a run one entry covers.
578///
579/// The two are a real trade rather than one being a refinement of the other, and which
580/// wins depends on where in the dependency graph the edit lands — see the Caching section
581/// of the README. `PerModule` is the default because editing is the common case.
582#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)]
583pub enum Mode {
584    /// One entry per module. An edit costs the module, its dependents, and what those pull
585    /// in; everything else replays.
586    #[default]
587    PerModule,
588    /// One entry for the walk. Any edit anywhere re-checks everything, but a run where
589    /// nothing moved reads one file instead of one per module.
590    WholeRun,
591}
592
593impl Mode {
594    /// The mode a flag or an environment variable names, or `None` for a word this build
595    /// does not know — which the caller reports as a bad argument rather than silently
596    /// taking the default for.
597    pub fn parse(s: &str) -> Option<Self> {
598        match s {
599            "per-module" => Some(Mode::PerModule),
600            "whole-run" => Some(Mode::WholeRun),
601            _ => None,
602        }
603    }
604
605    /// The word [`parse`](Self::parse) reads, and the one a run's summary line prints. The
606    /// two are one spelling on purpose, so what a report says can be pasted back as a flag.
607    pub fn as_str(self) -> &'static str {
608        match self {
609            Mode::PerModule => "per-module",
610            Mode::WholeRun => "whole-run",
611        }
612    }
613}
614
615/// Everything the store needs from outside itself.
616///
617/// Built once, by the caller that is already reading flags and the environment, and passed
618/// in. Nothing in this module reaches for `std::env`: a run's behaviour is decided in one
619/// place, so reading that place tells you what the run will do.
620#[derive(Copy, Clone, Debug)]
621pub struct Options {
622    /// `--no-cache` turns this off. Separate from how the cache is grained.
623    pub enabled: bool,
624    /// How much of a run one entry covers. Held here rather than decided per lookup, so
625    /// the whole run reads and writes the same grain.
626    pub mode: Mode,
627    /// Say why lookups missed, and what the run did with the store.
628    pub explain: bool,
629    /// Entries a project's store may hold. `None` scales it to the project size.
630    pub max_entries: Option<usize>,
631}
632
633impl Default for Options {
634    fn default() -> Self {
635        Self {
636            enabled: true,
637            mode: Mode::default(),
638            explain: false,
639            max_entries: None,
640        }
641    }
642}
643
644/// What one run did with the store.
645///
646/// Kept by the store rather than by its callers, so a report cannot claim a number the store
647/// disagrees with.
648#[derive(Default, Copy, Clone, Debug)]
649pub struct Stats {
650    /// Modules replayed from an entry.
651    pub hits: usize,
652    /// Modules the store had nothing usable for, so they were checked. Counts a key that
653    /// was not there and an entry whose inputs had moved alike — both cost the same check,
654    /// and `explain` is what tells them apart.
655    pub misses: usize,
656    /// Entries written. Not the same as `misses`: a run with writing off, or one whose
657    /// write failed, misses without storing.
658    pub stored: usize,
659    /// Entries the sweep dropped, which only `htl check` does.
660    pub evicted: usize,
661}
662
663impl Stats {
664    /// One line, for the end of a run. `None` when the run did not touch the store at all,
665    /// which is worth saying by saying nothing.
666    pub fn summary(&self, mode: Mode) -> Option<String> {
667        let touched = self.hits + self.misses + self.stored + self.evicted;
668        (touched > 0).then(|| {
669            format!(
670                "htl cache: {} hit, {} missed, {} written, {} evicted ({})",
671                self.hits,
672                self.misses,
673                self.stored,
674                self.evicted,
675                mode.as_str()
676            )
677        })
678    }
679}
680
681/// The identity of one module under one invocation: same key, same question.
682///
683/// Carries which question it is, so an entry written under it can record that too — the file
684/// on disk is named by the hash, and a store whose contents cannot be described is one
685/// nobody can reason about.
686#[derive(Debug, Clone)]
687pub struct Key {
688    hash: String,
689    kind: &'static str,
690}
691
692/// Hash of a file's contents, or `None` if it cannot be read — an unreadable input is a
693/// miss, never a silent hit.
694fn hash_file(p: &Path) -> Option<String> {
695    let bytes = std::fs::read(p).ok()?;
696    Some(blake3::hash(&bytes).to_hex().to_string())
697}
698
699/// A path in the form it is stored and compared in: absolute and symlink-resolved where
700/// possible.
701///
702/// Normalizing at one producer and not another is a real bug rather than a tidiness
703/// concern: mypy shipped a version where a file checked directly recorded a relative path
704/// while the same file reached as an import recorded an absolute one, so its hash depended
705/// on how it was reached. Everything that goes into an entry comes through here.
706pub fn normal(p: &Path) -> String {
707    std::fs::canonicalize(p)
708        .unwrap_or_else(|_| p.to_path_buf())
709        .to_string_lossy()
710        .into_owned()
711}
712
713/// Whether `name` resolves to a file in `dir`.
714///
715/// The shapes are the ones `H.add_path` puts on the search path (`dir/?.tl`,
716/// `dir/?/init.tl`) plus the declaration and Lua forms the checker falls back to. A dot in
717/// a module name is a directory separator, as it is for Lua's own searcher.
718///
719/// This is an existence question, not a resolution: it says the name *could* be found here,
720/// and comparing the answer across every directory on the path is what makes a change of
721/// resolution visible. Getting it wrong in the direction of "present" costs a re-check;
722/// there is no direction in which it produces a wrong answer.
723fn resolves_in(dir: &Path, name: &str) -> bool {
724    let stem = name.replace('.', "/");
725    ["tl", "d.tl", "lua"]
726        .iter()
727        .any(|ext| dir.join(format!("{stem}.{ext}")).is_file())
728        || ["init.tl", "init.d.tl", "init.lua"]
729            .iter()
730            .any(|f| dir.join(&stem).join(f).is_file())
731}
732
733/// The key for one module in this invocation.
734///
735/// `spelling` is the path as the CLI will hand it to the checker, not a resolved one: the
736/// checker echoes it into every diagnostic it prints, so `htl check .` and
737/// `htl check src/a.tl` produce different text for the same module and cannot share an
738/// entry. The working directory completes it, since the same relative spelling means a
739/// different file from somewhere else.
740pub fn module_key(spelling: &Path, lint: Option<&str>) -> Key {
741    key_with(CHECK, spelling, lint)
742}
743
744/// An entry holding what checking a module reported.
745pub const CHECK: &str = "check";
746/// An entry holding that, plus the Lua the module generates.
747pub const GEN: &str = "gen";
748
749/// The key for one test file's checked-and-generated form.
750///
751/// The same material as [`module_key`] under a different prefix, so the two never collide.
752/// They are separate entries on purpose: a `htl check` entry holds diagnostics and nothing
753/// else, and making it carry a few KB of generated Lua would cost every check run a parse it
754/// has no use for.
755pub fn gen_key(spelling: &Path, lint: Option<&str>) -> Key {
756    key_with(GEN, spelling, lint)
757}
758
759/// An entry holding a required module's generated Lua, keyed by the file it is.
760pub const MODULE: &str = "module";
761
762/// The key for a module's checked-and-generated form, by the file it is.
763///
764/// A test file is keyed by its spelling ([`gen_key`]) because the runner prints that
765/// spelling. A module reached through `require` is another matter: the test runner meets
766/// it by the path the checker resolved, the linker by the one it resolved, `htl build` and
767/// `include_bundle!` from different working directories — and its generated Lua is the
768/// same file's Lua whichever way it was reached. So the key is the canonical path and the
769/// lint selection, and nothing about the invocation, and the entry is stamped without the
770/// binary (the entry's stamp): that is what lets `htl test`, `htl build` and the macros replay one
771/// another's entries (#100).
772pub fn module_gen_key(path: &Path, lint: Option<&str>) -> Key {
773    let mut h = blake3::Hasher::new();
774    h.update(MODULE.as_bytes());
775    h.update(b"\0");
776    h.update(normal(path).as_bytes());
777    h.update(b"\0");
778    h.update(lint.unwrap_or("").as_bytes());
779    Key {
780        hash: h.finalize().to_hex().to_string(),
781        kind: MODULE,
782    }
783}
784
785/// Whether a module's source could `require` anything: the token appears in it.
786///
787/// `gen_lua` comes back without requires for a module the checker already holds — it
788/// serves the generated code without walking the file again — and a reader that needs the
789/// requires (the linker's walk, an entry's validation) asks `check` for them. A full check
790/// for every leaf module is the wrong price for that, and a file that never says
791/// `require` has nothing to ask about. Word-bounded, so `required_by` does not count.
792pub fn source_mentions_require(text: &str) -> bool {
793    let bytes = text.as_bytes();
794    let ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
795    text.match_indices("require").any(|(i, _)| {
796        let before = i.checked_sub(1).map(|j| bytes[j]);
797        let after = bytes.get(i + "require".len()).copied();
798        !before.is_some_and(ident) && !after.is_some_and(ident)
799    })
800}
801
802/// The switches every reader of the store honours, from the environment:
803/// `HTL_NO_CACHE` (neither read nor write), `HTL_CACHE_DEBUG` (say why lookups missed),
804/// `HTL_CACHE_MAX_ENTRIES` (the sweep's bound; the test suite's, since nothing else can
805/// reach a few hundred entries by honest means). A command's flags override what this
806/// read; the proc macros have no flags and take this as it is.
807impl Options {
808    /// The options the environment asks for, before any flag is applied.
809    pub fn from_env() -> Self {
810        Self {
811            enabled: std::env::var_os("HTL_NO_CACHE").is_none(),
812            mode: Mode::PerModule,
813            explain: std::env::var_os("HTL_CACHE_DEBUG").is_some(),
814            max_entries: std::env::var_os("HTL_CACHE_MAX_ENTRIES")
815                .and_then(|v| v.to_str().and_then(|s| s.parse().ok())),
816        }
817    }
818}
819
820/// Where a project's store lives: beside the `htl.toml` found from `path`, or nowhere.
821///
822/// One project, one store: every reader — `htl check`, `htl test`, `htl build`, the proc
823/// macros — resolves it through here, so they find each other's entries. A directory with
824/// no `htl.toml` has not opted into the layout (`htl init` / `htl new` write the file and
825/// gitignore `.htl/`); the CLI falls back to the working directory for its own commands,
826/// the macros to no store at all.
827pub fn root_for(path: &Path) -> Option<PathBuf> {
828    crate::config::HtlConfig::find(path)
829        .ok()
830        .flatten()
831        .map(|(file, _)| crate::parent_dir(&file))
832}
833
834/// Why nothing may be written under `root`, if nothing may — build scratch, named.
835///
836/// A macro expands wherever cargo compiles the crate: in the checkout, but also in the
837/// copy `cargo publish` verifies under `target/package/<crate>/` — where a new file makes
838/// cargo abort with "Source directory was modified" — and in a registry checkout under
839/// `$CARGO_HOME/registry/src`, which nothing should write to. Both are recognisable by
840/// their path, and neither is anybody's tree to keep anything in.
841///
842/// **The rule is the whole `.htl/`, not the store.** Every write htl makes as a side
843/// effect of reading a project asks here first, and there are two: the run cache
844/// (`project::store_refusal`, which every reader of the store comes through) and the
845/// entry links ([`crate::pkg::Project::link_entries`], through
846/// [`Htl::apply_project`](crate::Htl::apply_project)). The link repair was outside the
847/// rule until #267, where it wrote `.htl/modules/entries/<dep>` into the tree `cargo
848/// package` had just built and cargo refused the tarball. A reader of a scratch tree
849/// reads it exactly as it was packaged.
850///
851/// **`target` is recognised by the path and nothing else.** cargo publishes no signal
852/// saying "this build is the verification step", so htl reads the convention instead: a
853/// path component named `target`. That has two costs, both accepted for having one rule
854/// in one function rather than one per writer. A `CARGO_TARGET_DIR` pointing outside the
855/// tree defeats it — the verify copy is then not under a `target` directory, and htl
856/// writes in it as it would in a checkout. And a project that keeps its own sources under
857/// a directory called `target` goes without a store it could have had; a miss costs a
858/// check, which is the cheaper way to be wrong.
859pub fn scratch_root(root: &Path) -> Option<&'static str> {
860    if root.components().any(|c| c.as_os_str() == "target") {
861        return Some("under a `target` directory");
862    }
863    let cargo_home = std::env::var_os("CARGO_HOME")
864        .map(PathBuf::from)
865        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cargo")));
866    if let Some(registry) = cargo_home.map(|h| h.join("registry"))
867        && root.starts_with(&registry)
868    {
869        return Some("a registry checkout");
870    }
871    None
872}
873
874fn key_with(kind: &'static str, spelling: &Path, lint: Option<&str>) -> Key {
875    let mut h = blake3::Hasher::new();
876    h.update(kind.as_bytes());
877    h.update(b"\0");
878    h.update(spelling.to_string_lossy().as_bytes());
879    h.update(b"\0");
880    h.update(lint.unwrap_or("").as_bytes());
881    h.update(b"\0");
882    h.update(cwd().as_bytes());
883    Key {
884        hash: h.finalize().to_hex().to_string(),
885        kind,
886    }
887}
888
889/// The key for the walk as a whole, under `Mode::WholeRun`.
890///
891/// Every file's spelling goes in, in order: a walk that visits a different set, or the same
892/// set named differently, is a different run and prints different text.
893pub fn run_key(files: &[PathBuf], lint: Option<&str>) -> Key {
894    let mut h = blake3::Hasher::new();
895    h.update(b"htl-run\0");
896    for f in files {
897        h.update(f.to_string_lossy().as_bytes());
898        h.update(b"\0");
899    }
900    h.update(lint.unwrap_or("").as_bytes());
901    h.update(b"\0");
902    h.update(cwd().as_bytes());
903    Key {
904        hash: h.finalize().to_hex().to_string(),
905        kind: RUN,
906    }
907}
908
909/// One entry covering a whole walk (`Mode::WholeRun`).
910pub const RUN: &str = "run";
911
912/// The working directory, resolved once. This is called for every module in the walk, and
913/// resolving it means a `canonicalize` syscall — doing that per module is a measurable part
914/// of a run that replays everything and does nothing else.
915fn cwd() -> &'static str {
916    static CWD: std::sync::OnceLock<String> = std::sync::OnceLock::new();
917    CWD.get_or_init(|| {
918        std::env::current_dir()
919            .map(|p| normal(&p))
920            .unwrap_or_default()
921    })
922}
923
924/// One entry, as `htl cache status` describes it.
925#[derive(Serialize, Debug)]
926pub struct EntrySummary {
927    /// `check`, `gen`, `run` — or `unreadable` for a file this build cannot parse, which is
928    /// worth showing rather than hiding, since it is also a permanent miss.
929    pub kind: String,
930    /// The files it is about. One, except for a whole-run entry.
931    pub subjects: Vec<String>,
932    /// Size of the entry's file on disk, which is what `htl cache status` totals.
933    pub bytes: u64,
934    /// Seconds since the entry was last written or replayed. The sweep drops the oldest.
935    pub age_secs: u64,
936}
937
938/// What a project's store holds.
939#[derive(Serialize, Debug)]
940pub struct Contents {
941    /// The store's directory, said even when it holds nothing — "empty" and "not where you
942    /// thought" are different answers and a reader cannot tell them apart without it.
943    pub dir: String,
944    /// One per entry file. Empty when the directory is missing or unreadable, which are
945    /// not distinguished: neither is a store this run can use.
946    pub entries: Vec<EntrySummary>,
947    /// The whole store's bytes, summed over `entries`.
948    pub bytes: u64,
949}
950
951/// Read the store and say what is in it.
952///
953/// Every entry is parsed, which is why this is a command rather than something a run does on
954/// the side. Nothing here validates: an entry listed may still be a miss on the next run
955/// because its inputs moved. This answers "what is stored", not "what would be reused".
956pub fn describe(root: &Path) -> Contents {
957    let dir = root.join(DIR);
958    let mut out = Contents {
959        dir: dir.to_string_lossy().into_owned(),
960        entries: Vec::new(),
961        bytes: 0,
962    };
963    let Ok(rd) = std::fs::read_dir(&dir) else {
964        return out;
965    };
966    let now = std::time::SystemTime::now();
967    for e in rd.flatten() {
968        let path = e.path();
969        if path.extension().is_none_or(|x| x != "json") {
970            continue;
971        }
972        let Ok(meta) = e.metadata() else { continue };
973        out.bytes += meta.len();
974        let age_secs = meta
975            .modified()
976            .ok()
977            .and_then(|t| now.duration_since(t).ok())
978            .map(|d| d.as_secs())
979            .unwrap_or(0);
980        let v: serde_json::Value = std::fs::read_to_string(&path)
981            .ok()
982            .and_then(|raw| serde_json::from_str(&raw).ok())
983            .unwrap_or(serde_json::Value::Null);
984        let kind = v
985            .get("kind")
986            .and_then(|k| k.as_str())
987            .unwrap_or("unreadable")
988            .to_string();
989        let subjects = match (v.get("subject"), v.get("subjects")) {
990            (Some(s), _) if s.is_string() => vec![s.as_str().unwrap_or_default().to_string()],
991            (_, Some(a)) if a.is_array() => a
992                .as_array()
993                .unwrap()
994                .iter()
995                .filter_map(|x| x.as_str().map(str::to_string))
996                .collect(),
997            _ => Vec::new(),
998        };
999        out.entries.push(EntrySummary {
1000            kind,
1001            subjects,
1002            bytes: meta.len(),
1003            age_secs,
1004        });
1005    }
1006    out.entries
1007        .sort_by(|a, b| a.kind.cmp(&b.kind).then(a.subjects.cmp(&b.subjects)));
1008    out
1009}
1010
1011/// A store rooted at a project.
1012pub struct Cache {
1013    dir: PathBuf,
1014    opts: Options,
1015    stats: RefCell<Stats>,
1016    /// Content hashes taken during this run, by normalized path.
1017    ///
1018    /// Entries overlap heavily: every module in a project tends to require the same few
1019    /// leaves, so a leaf appears in the inputs of most entries. Without this, checking 48
1020    /// modules against the store reads and hashes the shared ones 48 times — which is most
1021    /// of what a fully replayed run spends. A file is assumed not to change while one
1022    /// `htl check` is running; if it does, the run's answer was undefined anyway.
1023    hashes: RefCell<HashMap<String, Option<String>>>,
1024    /// Name-resolves-here answers taken during this run, by (directory, name). Same
1025    /// reasoning.
1026    dirs: RefCell<HashMap<(String, String), bool>>,
1027}
1028
1029impl Cache {
1030    /// `None` when the caller turned caching off, or when this build cannot identify
1031    /// itself well enough to be sure an entry is its own.
1032    pub fn open(root: &Path, opts: Options) -> Option<Self> {
1033        if !opts.enabled {
1034            return None;
1035        }
1036        Stamp::current()?;
1037        Some(Self {
1038            dir: root.join(DIR),
1039            opts,
1040            stats: RefCell::new(Stats::default()),
1041            hashes: RefCell::new(HashMap::new()),
1042            dirs: RefCell::new(HashMap::new()),
1043        })
1044    }
1045
1046    /// What this run has done with the store so far.
1047    pub fn stats(&self) -> Stats {
1048        *self.stats.borrow()
1049    }
1050
1051    /// Say why something was not reused. Only when asked; the caller decided that once.
1052    fn miss(&self, reason: &str) {
1053        self.stats.borrow_mut().misses += 1;
1054        if self.opts.explain {
1055            eprintln!("htl cache: miss ({reason})");
1056        }
1057    }
1058
1059    /// The hash of a file, taken once per run.
1060    fn hash_of(&self, path: &str) -> Option<String> {
1061        if let Some(h) = self.hashes.borrow().get(path) {
1062            return h.clone();
1063        }
1064        let h = hash_file(Path::new(path));
1065        self.hashes.borrow_mut().insert(path.to_string(), h.clone());
1066        h
1067    }
1068
1069    /// Whether one name resolves in one directory, answered once per run. The project root,
1070    /// `src/` and `types/` are probed by every module, and modules share the names they
1071    /// require, so this overlaps even more than the file hashes do.
1072    fn resolves(&self, dir: &str, name: &str) -> bool {
1073        let k = (dir.to_string(), name.to_string());
1074        if let Some(v) = self.dirs.borrow().get(&k) {
1075            return *v;
1076        }
1077        let v = resolves_in(Path::new(dir), name);
1078        self.dirs.borrow_mut().insert(k, v);
1079        v
1080    }
1081
1082    /// What `dir` offers for `names`, hashed.
1083    fn probe_hash(&self, dir: &str, names: &[String]) -> String {
1084        let mut h = blake3::Hasher::new();
1085        for n in names {
1086            h.update(n.as_bytes());
1087            h.update(if self.resolves(dir, n) {
1088                b"\x01"
1089            } else {
1090                b"\x00"
1091            });
1092        }
1093        h.finalize().to_hex().to_string()
1094    }
1095
1096    fn entry_path(&self, key: &Key) -> PathBuf {
1097        self.dir.join(format!("{}.json", key.hash))
1098    }
1099
1100    /// The grain this store was opened with. A caller that decides between a per-module
1101    /// walk and a whole-run lookup asks the store rather than re-reading the flags, so the
1102    /// two cannot disagree.
1103    pub fn mode(&self) -> Mode {
1104        self.opts.mode
1105    }
1106
1107    /// A truncated or hand-edited entry is a miss, not a crash.
1108    fn parse<T: serde::de::DeserializeOwned>(&self, raw: &str) -> Option<T> {
1109        match serde_json::from_str(raw) {
1110            Ok(e) => Some(e),
1111            Err(e) => {
1112                self.miss(&format!("unreadable entry: {e}"));
1113                None
1114            }
1115        }
1116    }
1117
1118    /// The module names an entry's modules asked for, sorted and deduplicated.
1119    ///
1120    /// Both sides of a probe comparison have to derive this the same way, which is why it is
1121    /// one function rather than two loops.
1122    fn required_names(modules: &[Module]) -> Vec<String> {
1123        let mut names: Vec<String> = modules
1124            .iter()
1125            .flat_map(|m| m.requires.iter().map(|r| r.module.clone()))
1126            .collect();
1127        names.sort();
1128        names.dedup();
1129        names
1130    }
1131
1132    /// Whether an entry still describes the world: written by this build, every file it read
1133    /// unchanged, and every directory it could have resolved in still answering the same way
1134    /// for the names it asked about.
1135    fn still_valid(
1136        &self,
1137        kind: &str,
1138        stamp: &Stamp,
1139        inputs: &[Input],
1140        probes: &[Probe],
1141        names: &[String],
1142    ) -> bool {
1143        let Some(current) = Stamp::for_kind(kind) else {
1144            return false;
1145        };
1146        if *stamp != current {
1147            self.miss("written by a different build");
1148            return false;
1149        }
1150        for i in inputs {
1151            match self.hash_of(&i.path) {
1152                Some(h) if h == i.hash => {}
1153                Some(_) => {
1154                    self.miss(&format!("changed: {}", i.path));
1155                    return false;
1156                }
1157                None => {
1158                    self.miss(&format!("gone: {}", i.path));
1159                    return false;
1160                }
1161            }
1162        }
1163        for p in probes {
1164            if self.probe_hash(&p.dir, names) != p.names {
1165                self.miss(&format!(
1166                    "what {} offers for this module's requires changed",
1167                    p.dir
1168                ));
1169                return false;
1170            }
1171        }
1172        self.stats.borrow_mut().hits += 1;
1173        true
1174    }
1175
1176    /// What each file in the walk reported last time; `None` where it has to be checked.
1177    ///
1178    /// Under `WholeRun` this is all-or-nothing by construction: one entry covers the walk,
1179    /// so a single changed input means every module is checked.
1180    pub fn lookup_all(&self, keys: &[Key], run: &Key, files: usize) -> Vec<Option<Module>> {
1181        match self.opts.mode {
1182            Mode::PerModule => keys.iter().map(|k| self.lookup(k)).collect(),
1183            Mode::WholeRun => match self.lookup_run(run, files) {
1184                Some(ms) => ms.into_iter().map(Some).collect(),
1185                None => vec![None; files],
1186            },
1187        }
1188    }
1189
1190    /// What the module reported last time, if every input and probe still matches.
1191    pub fn lookup(&self, key: &Key) -> Option<Module> {
1192        let raw = std::fs::read_to_string(self.entry_path(key)).ok()?;
1193        let entry: Entry = self.parse(&raw)?;
1194        let names = Self::required_names(std::slice::from_ref(&entry.module));
1195        if !self.still_valid(
1196            &entry.kind,
1197            &entry.stamp,
1198            &entry.inputs,
1199            &entry.probes,
1200            &names,
1201        ) {
1202            return None;
1203        }
1204        self.touch(key);
1205        Some(entry.module)
1206    }
1207
1208    /// Mark an entry as used, so that the sweep drops what is stale rather than what is
1209    /// merely old.
1210    ///
1211    /// Without this the shape you run most often is the one whose entries were written
1212    /// first, so it ages out while a lint flag you tried once survives — measured on a
1213    /// 58-module project, running four other shapes left the plain one replaying 24 of its
1214    /// 58 modules. Failing to touch costs a later re-check and nothing else, so every error
1215    /// here is ignored.
1216    fn touch(&self, key: &Key) {
1217        if let Ok(f) = std::fs::File::options()
1218            .write(true)
1219            .open(self.entry_path(key))
1220        {
1221            let _ = f.set_modified(std::time::SystemTime::now());
1222        }
1223    }
1224
1225    fn lookup_run(&self, key: &Key, files: usize) -> Option<Vec<Module>> {
1226        let raw = std::fs::read_to_string(self.entry_path(key)).ok()?;
1227        let entry: RunEntry = self.parse(&raw)?;
1228        if entry.modules.len() != files {
1229            self.miss("the walk visits a different number of files");
1230            return None;
1231        }
1232        let names = Self::required_names(&entry.modules);
1233        if !self.still_valid(RUN, &entry.stamp, &entry.inputs, &entry.probes, &names) {
1234            return None;
1235        }
1236        self.touch(key);
1237        Some(entry.modules)
1238    }
1239
1240    /// Hash every path an entry has to compare next time, once each: a dependency reached
1241    /// from two modules is compared once. `None` if anything is unreadable, which drops the
1242    /// entry rather than storing one that would hit wrongly.
1243    fn inputs_for(&self, paths: impl Iterator<Item = String>) -> Option<Vec<Input>> {
1244        let mut paths: Vec<String> = paths.collect();
1245        paths.sort();
1246        paths.dedup();
1247        let mut inputs = Vec::with_capacity(paths.len());
1248        for p in paths {
1249            inputs.push(Input {
1250                path: p.clone(),
1251                hash: self.hash_of(&p)?,
1252            });
1253        }
1254        Some(inputs)
1255    }
1256
1257    fn probes_for(&self, dirs: &[PathBuf], names: &[String]) -> Vec<Probe> {
1258        let mut dirs: Vec<String> = dirs.iter().map(|p| normal(p)).collect();
1259        dirs.sort();
1260        dirs.dedup();
1261        dirs.into_iter()
1262            .map(|d| {
1263                let h = self.probe_hash(&d, names);
1264                Probe { dir: d, names: h }
1265            })
1266            .collect()
1267    }
1268
1269    /// Record what one module reported. Best-effort: a store that cannot be written leaves
1270    /// the next run to do the work again, which is slow rather than wrong.
1271    pub fn store_module(
1272        &self,
1273        key: &Key,
1274        file: &Path,
1275        extra_inputs: &[PathBuf],
1276        dirs: &[PathBuf],
1277        module: &Module,
1278    ) {
1279        let Some(stamp) = Stamp::for_kind(key.kind) else {
1280            return;
1281        };
1282        let paths = std::iter::once(normal(file))
1283            .chain(module.deps.iter().cloned())
1284            .chain(extra_inputs.iter().map(|p| normal(p)));
1285        let Some(inputs) = self.inputs_for(paths) else {
1286            return;
1287        };
1288        let names = Self::required_names(std::slice::from_ref(module));
1289        let entry = Entry {
1290            stamp,
1291            subject: normal(file),
1292            kind: key.kind.to_string(),
1293            inputs,
1294            probes: self.probes_for(dirs, &names),
1295            module: module.clone(),
1296        };
1297        match self.write(key, &entry) {
1298            Ok(()) => self.stats.borrow_mut().stored += 1,
1299            Err(e) if self.opts.explain => eprintln!("htl cache: not stored ({e})"),
1300            Err(_) => {}
1301        }
1302    }
1303
1304    /// Record the whole walk as one entry. Its inputs are the union of every module's, so
1305    /// any edit anywhere misses — which is the behaviour this mode is chosen for.
1306    pub fn store_run(
1307        &self,
1308        key: &Key,
1309        files: &[PathBuf],
1310        extra_inputs: &[PathBuf],
1311        dirs: &[PathBuf],
1312        modules: &[Module],
1313    ) {
1314        let Some(stamp) = Stamp::current() else {
1315            return;
1316        };
1317        let paths = files
1318            .iter()
1319            .map(|f| normal(f))
1320            .chain(modules.iter().flat_map(|m| m.deps.iter().cloned()))
1321            .chain(extra_inputs.iter().map(|p| normal(p)));
1322        let Some(inputs) = self.inputs_for(paths) else {
1323            return;
1324        };
1325        let names = Self::required_names(modules);
1326        let entry = RunEntry {
1327            stamp,
1328            subjects: files.iter().map(|f| normal(f)).collect(),
1329            kind: key.kind.to_string(),
1330            inputs,
1331            probes: self.probes_for(dirs, &names),
1332            modules: modules.to_vec(),
1333        };
1334        match self.write(key, &entry) {
1335            Ok(()) => self.stats.borrow_mut().stored += 1,
1336            Err(e) if self.opts.explain => eprintln!("htl cache: not stored ({e})"),
1337            Err(_) => {}
1338        }
1339    }
1340
1341    /// Drop entries this run did not use, once the store has outgrown what the project
1342    /// warrants.
1343    ///
1344    /// Nothing else removes an entry. The key covers the paths as written, the lint
1345    /// selection and the working directory, so every distinct way of invoking the check
1346    /// leaves a full set of module entries behind — a lint flag tried once doubles the store
1347    /// permanently, and a deleted module's entry is never read again. `htl check` sees the
1348    /// whole graph in one process, which is what lets this be exact about the orphans rather
1349    /// than sampling the way ccache has to.
1350    ///
1351    /// Two passes. Entries whose recorded inputs have all gone describe modules that no
1352    /// longer exist, and go first. If the store is still over, the oldest go until it fits.
1353    ///
1354    /// **This uses mtimes, and #3's rule against them still holds.** That rule is about
1355    /// invalidation, where trusting a timestamp means replaying a stale result and reporting
1356    /// something untrue. Dropping an entry that was still good costs the check it would have
1357    /// skipped and nothing else. The two questions deserve different tools.
1358    ///
1359    /// "Oldest" is least recently *used*: a hit touches its entry, which is what makes the
1360    /// two differ.
1361    /// Without that it would mean least recently written, and the shape run most often —
1362    /// written first — would age out while a shape tried once survived.
1363    pub fn sweep(&self, keep: &[Key], files: usize) {
1364        let bound = self
1365            .opts
1366            .max_entries
1367            .unwrap_or_else(|| default_bound(files));
1368        let Ok(rd) = std::fs::read_dir(&self.dir) else {
1369            return;
1370        };
1371        let keep: std::collections::HashSet<&str> = keep.iter().map(|k| k.hash.as_str()).collect();
1372
1373        let mut all = 0usize;
1374        let mut candidates: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
1375        for e in rd.flatten() {
1376            let path = e.path();
1377            if path.extension().is_none_or(|x| x != "json") {
1378                continue;
1379            }
1380            all += 1;
1381            let stem = path
1382                .file_stem()
1383                .map(|s| s.to_string_lossy().into_owned())
1384                .unwrap_or_default();
1385            if keep.contains(stem.as_str()) {
1386                continue;
1387            }
1388            let mtime = e
1389                .metadata()
1390                .and_then(|m| m.modified())
1391                .unwrap_or(std::time::UNIX_EPOCH);
1392            candidates.push((path, mtime));
1393        }
1394        if all <= bound {
1395            return;
1396        }
1397
1398        let mut removed = 0usize;
1399        // Orphans first: an entry none of whose inputs still exist cannot be read again.
1400        candidates.retain(|(p, _)| {
1401            if all - removed <= bound || !self.is_orphan(p) {
1402                return true;
1403            }
1404            if std::fs::remove_file(p).is_ok() {
1405                removed += 1;
1406            }
1407            false
1408        });
1409        // Then by age, oldest first, until the store fits.
1410        if all - removed > bound {
1411            candidates.sort_by_key(|(_, t)| *t);
1412            for (p, _) in &candidates {
1413                if all - removed <= bound {
1414                    break;
1415                }
1416                if std::fs::remove_file(p).is_ok() {
1417                    removed += 1;
1418                }
1419            }
1420        }
1421        self.stats.borrow_mut().evicted += removed;
1422        if removed > 0 && self.opts.explain {
1423            eprintln!("htl cache: dropped {removed} of {all} entries (bound {bound})");
1424        }
1425    }
1426
1427    /// Whether every file an entry recorded as an input has gone. Unreadable entries count as
1428    /// orphans: nothing can use them either.
1429    fn is_orphan(&self, path: &Path) -> bool {
1430        let Ok(raw) = std::fs::read_to_string(path) else {
1431            return true;
1432        };
1433        let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) else {
1434            return true;
1435        };
1436        let Some(inputs) = v.get("inputs").and_then(|i| i.as_array()) else {
1437            return true;
1438        };
1439        !inputs
1440            .iter()
1441            .filter_map(|i| i.get("path").and_then(|p| p.as_str()))
1442            .any(|p| Path::new(p).exists())
1443    }
1444
1445    /// Whole or not at all: a temporary file in the same directory, then a rename. On every
1446    /// filesystem htl runs on that rename is atomic, so a concurrent reader sees either the
1447    /// previous entry or this one, never half of this one. ccache relies on exactly this
1448    /// and takes no locks at all.
1449    fn write<T: Serialize>(&self, key: &Key, entry: &T) -> Result<()> {
1450        std::fs::create_dir_all(&self.dir)?;
1451        let final_path = self.entry_path(key);
1452        let tmp = self
1453            .dir
1454            .join(format!(".{}.{}.tmp", key.hash, std::process::id()));
1455        std::fs::write(&tmp, serde_json::to_vec(entry)?)?;
1456        if let Err(e) = std::fs::rename(&tmp, &final_path) {
1457            let _ = std::fs::remove_file(&tmp);
1458            return Err(e.into());
1459        }
1460        Ok(())
1461    }
1462}
1463
1464#[cfg(test)]
1465mod tests {
1466    use super::*;
1467
1468    fn scratch(name: &str) -> PathBuf {
1469        let dir =
1470            std::env::temp_dir().join(format!("htl-core-cache-{name}-{}", std::process::id()));
1471        std::fs::create_dir_all(dir.join("src")).unwrap();
1472        dir
1473    }
1474
1475    /// A `module` entry is stamped without the binary, so one written by `htl` is served
1476    /// inside a proc macro (whose binary is `rustc`) and the other way round. A test-file
1477    /// entry keeps the binary in its stamp, as it always did.
1478    #[test]
1479    fn a_module_entry_is_served_whatever_binary_wrote_it() {
1480        let root = scratch("portable");
1481        let file = root.join("src/m.tl");
1482        std::fs::write(&file, "return {}\n").unwrap();
1483        let cache = Cache::open(&root, Options::default()).unwrap();
1484        // What another binary writes for a module: the portable stamp. This binary's own
1485        // stamp is a different value, which is the point.
1486        assert_ne!(Stamp::current().unwrap(), Stamp::portable());
1487        let module = Module {
1488            diagnostics: Vec::new(),
1489            errors: 0,
1490            warnings: 0,
1491            lints: 0,
1492            deps: Vec::new(),
1493            requires: Vec::new(),
1494            code: Some("return {}".into()),
1495            check: Some(CheckInfoJson::default()),
1496        };
1497        let entry = |kind: &str| Entry {
1498            stamp: Stamp::portable(),
1499            subject: normal(&file),
1500            kind: kind.to_string(),
1501            inputs: cache.inputs_for(std::iter::once(normal(&file))).unwrap(),
1502            probes: Vec::new(),
1503            module: module.clone(),
1504        };
1505        let mk = module_gen_key(&file, None);
1506        cache.write(&mk, &entry(MODULE)).unwrap();
1507        assert!(
1508            cache.lookup(&mk).is_some(),
1509            "a module entry: the binary is not part of its stamp"
1510        );
1511        let gk = gen_key(&file, None);
1512        cache.write(&gk, &entry(GEN)).unwrap();
1513        assert!(
1514            cache.lookup(&gk).is_none(),
1515            "a test-file entry from another binary: written by a different build"
1516        );
1517        // And what this binary stores under a module key is what any other reads.
1518        cache.store_module(&mk, &file, &[], &[], &module);
1519        let raw = std::fs::read_to_string(cache.entry_path(&mk)).unwrap();
1520        let stored: Entry = serde_json::from_str(&raw).unwrap();
1521        assert_eq!(stored.stamp, Stamp::portable());
1522    }
1523
1524    #[test]
1525    fn source_mentions_require_is_word_bounded() {
1526        assert!(source_mentions_require("local a = require(\"a\")\n"));
1527        assert!(source_mentions_require("require 'a'"));
1528        assert!(!source_mentions_require("local required_by = 1\n"));
1529        assert!(!source_mentions_require("local x = prerequire\n"));
1530        assert!(!source_mentions_require("local record c\nend\nreturn c\n"));
1531        assert!(!source_mentions_require(""));
1532    }
1533
1534    #[test]
1535    fn scratch_roots_are_recognised() {
1536        assert_eq!(
1537            scratch_root(Path::new("/w/target/package/host-0.1.0")),
1538            Some("under a `target` directory")
1539        );
1540        assert_eq!(scratch_root(Path::new("/w/host")), None);
1541        let cargo_home = std::env::var_os("CARGO_HOME")
1542            .map(PathBuf::from)
1543            .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cargo")))
1544            .expect("CARGO_HOME or HOME");
1545        assert_eq!(
1546            scratch_root(&cargo_home.join("registry/src/index/htl-0.3.0")),
1547            Some("a registry checkout")
1548        );
1549    }
1550}