Skip to main content

fallow_types/
audit_cache.rs

1//! Typed audit cache-key inputs.
2
3use serde::Serialize;
4
5use crate::source_fingerprint::SourceFingerprint;
6
7/// Filesystem state of one bounded audit context input.
8#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
9#[serde(rename_all = "snake_case")]
10pub enum AuditContextPathState {
11    /// The path does not exist.
12    Missing,
13    /// The path exists and was read successfully.
14    Present,
15    /// The path exists, but its metadata or contents could not be read.
16    Unreadable(String),
17}
18
19/// Fingerprint of one file that can affect base-worktree resolution.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
21pub struct AuditContextFileFingerprint {
22    /// Project-root-relative, forward-slash path.
23    pub path: String,
24    /// Presence or read-error state.
25    pub state: AuditContextPathState,
26    /// Source metadata when readable.
27    pub source: Option<SourceFingerprint>,
28    /// Stable content hash when readable.
29    pub content_hash: Option<String>,
30}
31
32/// Fingerprint of one host directory materialized into an audit base worktree.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
34pub struct AuditContextDirectoryFingerprint {
35    /// Directory name relative to the analysis root.
36    pub name: String,
37    /// Presence or metadata-error state.
38    pub state: AuditContextPathState,
39    /// Canonical source identity when available.
40    pub canonical_path: Option<String>,
41    /// Root directory metadata when readable.
42    pub source: Option<SourceFingerprint>,
43    /// Bounded marker files whose contents affect resolution.
44    pub markers: Vec<AuditContextFileFingerprint>,
45}
46
47/// Bounded host context shared with an audit base worktree.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
49pub struct AuditMaterializedContextFingerprint {
50    /// Current package-manager lockfiles at the analysis root.
51    pub lockfiles: Vec<AuditContextFileFingerprint>,
52    /// Dependency and generated-context directories shared with the base view.
53    pub directories: Vec<AuditContextDirectoryFingerprint>,
54}
55
56/// Fingerprint of the resolved config that can affect audit output.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
58pub struct AuditConfigFingerprint {
59    /// Path of the config file that was loaded, or `None` when no config exists.
60    pub path: Option<String>,
61    /// Stable hash of the resolved config object.
62    pub resolved_hash: Option<String>,
63}
64
65/// Fingerprint of an optional coverage input that can affect health findings.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
67pub struct AuditCoverageFingerprint {
68    /// User-provided coverage path.
69    pub path: String,
70    /// Actual file path hashed after directory resolution.
71    pub resolved_path: String,
72    /// Metadata freshness for the resolved coverage file, when it was readable.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub source: Option<SourceFingerprint>,
75    /// Stable content hash for the resolved coverage file, when it was readable.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub content_hash: Option<String>,
78    /// File length in bytes, when it was readable.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub len: Option<usize>,
81    /// I/O error kind, when the resolved coverage file was not readable.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub error: Option<String>,
84}
85
86/// Typed payload hashed to address an audit base-snapshot cache entry.
87#[derive(Debug, Clone, PartialEq, Serialize)]
88pub struct AuditCacheKeyPayload {
89    /// Audit base snapshot cache schema version.
90    pub cache_version: u8,
91    /// Fallow CLI version that produced the key.
92    pub cli_version: String,
93    /// Resolved git SHA for the base ref.
94    pub base_sha: String,
95    /// Config fingerprint.
96    pub config_file: AuditConfigFingerprint,
97    /// Changed files normalized to git-root-relative, forward-slash paths.
98    pub changed_files: Vec<String>,
99    /// Host dependency and generated context materialized into the base view.
100    pub materialized_context: AuditMaterializedContextFingerprint,
101    /// Global production mode.
102    pub production: bool,
103    /// Dead-code-specific production override.
104    pub production_dead_code: Option<bool>,
105    /// Health-specific production override.
106    pub production_health: Option<bool>,
107    /// Duplication-specific production override.
108    pub production_dupes: Option<bool>,
109    /// Workspace filters.
110    pub workspace: Option<Vec<String>>,
111    /// Changed-workspaces base ref, when enabled.
112    pub changed_workspaces: Option<String>,
113    /// Grouping mode.
114    pub group_by: Option<String>,
115    /// Whether entry exports are analyzed.
116    pub include_entry_exports: bool,
117    /// Keep the export and member findings of unused files (`--show-cascade`).
118    pub show_cascade: bool,
119    /// Whether the base pass ran for the review brief (`--brief`). Only a
120    /// brief base pass computes the public-export set of the snapshot.
121    pub brief: bool,
122    /// CRAP threshold override.
123    pub max_crap: Option<f64>,
124    /// Coverage input fingerprint.
125    pub coverage: Option<AuditCoverageFingerprint>,
126    /// Coverage root override.
127    pub coverage_root: Option<String>,
128    /// Whether audit ran the dead-code sub-analysis for the base snapshot.
129    pub dead_code: bool,
130    /// Whether audit ran the health sub-analysis for the base snapshot.
131    pub health: bool,
132    /// Whether audit ran the duplication sub-analysis for the base snapshot.
133    pub dupes: bool,
134    /// Which dead-code findings the selection keeps: `all`, `only` (the
135    /// architecture findings) or `exclude` (all but the architecture findings).
136    pub architecture: String,
137    /// Whether audit health computed styling keys for the base snapshot.
138    pub css: bool,
139    /// Whether audit health used deep CSS analysis for the base snapshot.
140    pub css_deep: bool,
141    /// Dead-code baseline path.
142    pub dead_code_baseline: Option<String>,
143    /// Health baseline path.
144    pub health_baseline: Option<String>,
145    /// Duplication baseline path.
146    pub dupes_baseline: Option<String>,
147    /// Experimental type-aware backend selection. The default backend leaves
148    /// it out, so the key bytes of a default run do not change.
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub type_aware_backend: Option<String>,
151}
152
153/// Builder for audit base-snapshot cache keys.
154#[derive(Debug, Clone)]
155pub struct AuditCacheKeyBuilder {
156    payload: AuditCacheKeyPayload,
157}
158
159impl AuditCacheKeyBuilder {
160    /// Start a cache-key payload with the invariant identity fields.
161    #[must_use]
162    pub fn new(
163        cache_version: u8,
164        cli_version: impl Into<String>,
165        base_sha: impl Into<String>,
166        config_file: AuditConfigFingerprint,
167        changed_files: Vec<String>,
168        materialized_context: AuditMaterializedContextFingerprint,
169    ) -> Self {
170        Self {
171            payload: AuditCacheKeyPayload {
172                cache_version,
173                cli_version: cli_version.into(),
174                base_sha: base_sha.into(),
175                config_file,
176                changed_files,
177                materialized_context,
178                production: false,
179                production_dead_code: None,
180                production_health: None,
181                production_dupes: None,
182                workspace: None,
183                changed_workspaces: None,
184                group_by: None,
185                include_entry_exports: false,
186                show_cascade: false,
187                brief: false,
188                max_crap: None,
189                coverage: None,
190                coverage_root: None,
191                dead_code: true,
192                health: true,
193                dupes: true,
194                architecture: "all".to_string(),
195                css: false,
196                css_deep: false,
197                dead_code_baseline: None,
198                health_baseline: None,
199                dupes_baseline: None,
200                type_aware_backend: None,
201            },
202        }
203    }
204
205    /// Set production-mode options.
206    #[must_use]
207    pub const fn production(
208        mut self,
209        production: bool,
210        dead_code: Option<bool>,
211        health: Option<bool>,
212        dupes: Option<bool>,
213    ) -> Self {
214        self.payload.production = production;
215        self.payload.production_dead_code = dead_code;
216        self.payload.production_health = health;
217        self.payload.production_dupes = dupes;
218        self
219    }
220
221    /// Set scope and grouping options.
222    #[must_use]
223    pub fn scope(
224        mut self,
225        workspace: Option<Vec<String>>,
226        changed_workspaces: Option<String>,
227        group_by: Option<String>,
228        include_entry_exports: bool,
229    ) -> Self {
230        self.payload.workspace = workspace;
231        self.payload.changed_workspaces = changed_workspaces;
232        self.payload.group_by = group_by;
233        self.payload.include_entry_exports = include_entry_exports;
234        self
235    }
236
237    /// Set whether the export and member findings of unused files stay in
238    /// the report.
239    #[must_use]
240    pub const fn show_cascade(mut self, show_cascade: bool) -> Self {
241        self.payload.show_cascade = show_cascade;
242        self
243    }
244
245    /// Set whether the base pass ran for the review brief (`--brief`).
246    #[must_use]
247    pub const fn brief(mut self, brief: bool) -> Self {
248        self.payload.brief = brief;
249        self
250    }
251
252    /// Set health and coverage options.
253    #[must_use]
254    pub fn health(
255        mut self,
256        max_crap: Option<f64>,
257        coverage: Option<AuditCoverageFingerprint>,
258        coverage_root: Option<String>,
259    ) -> Self {
260        self.payload.max_crap = max_crap;
261        self.payload.coverage = coverage;
262        self.payload.coverage_root = coverage_root;
263        self
264    }
265
266    /// Set which sub-analyses ran for the base snapshot, and which dead-code
267    /// findings the architecture selection kept.
268    #[must_use]
269    pub fn analyses(
270        mut self,
271        dead_code: bool,
272        health: bool,
273        dupes: bool,
274        architecture: impl Into<String>,
275    ) -> Self {
276        self.payload.dead_code = dead_code;
277        self.payload.health = health;
278        self.payload.dupes = dupes;
279        self.payload.architecture = architecture.into();
280        self
281    }
282
283    /// Set styling-analysis options that affect base health snapshot keys.
284    #[must_use]
285    pub const fn styling(mut self, css: bool, css_deep: bool) -> Self {
286        self.payload.css = css;
287        self.payload.css_deep = css_deep;
288        self
289    }
290
291    /// Set baseline paths.
292    #[must_use]
293    pub fn baselines(
294        mut self,
295        dead_code: Option<String>,
296        health: Option<String>,
297        dupes: Option<String>,
298    ) -> Self {
299        self.payload.dead_code_baseline = dead_code;
300        self.payload.health_baseline = health;
301        self.payload.dupes_baseline = dupes;
302        self
303    }
304
305    /// Set the type-aware backend selection.
306    #[must_use]
307    pub fn type_aware_backend(mut self, backend: Option<String>) -> Self {
308        self.payload.type_aware_backend = backend;
309        self
310    }
311
312    /// Borrow the completed payload.
313    #[must_use]
314    pub const fn payload(&self) -> &AuditCacheKeyPayload {
315        &self.payload
316    }
317
318    /// Serialize the completed payload into stable JSON bytes for hashing.
319    ///
320    /// # Errors
321    ///
322    /// Returns a serde error when a payload field cannot be serialized.
323    pub fn to_json_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
324        serde_json::to_vec(&self.payload)
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    fn config() -> AuditConfigFingerprint {
333        AuditConfigFingerprint {
334            path: Some("fallow.toml".to_string()),
335            resolved_hash: Some("abc".to_string()),
336        }
337    }
338
339    fn context() -> AuditMaterializedContextFingerprint {
340        AuditMaterializedContextFingerprint {
341            lockfiles: Vec::new(),
342            directories: Vec::new(),
343        }
344    }
345
346    #[test]
347    fn audit_cache_key_builder_preserves_typed_fields() {
348        let coverage = AuditCoverageFingerprint {
349            path: "coverage".to_string(),
350            resolved_path: "coverage/coverage-final.json".to_string(),
351            source: Some(SourceFingerprint::new(12, 34)),
352            content_hash: Some("hash".to_string()),
353            len: Some(34),
354            error: None,
355        };
356
357        let builder = AuditCacheKeyBuilder::new(
358            3,
359            "1.2.3",
360            "abc123",
361            config(),
362            vec!["src/a.ts".to_string()],
363            context(),
364        )
365        .production(true, Some(false), Some(true), None)
366        .scope(
367            Some(vec!["web".to_string()]),
368            Some("main".to_string()),
369            Some("Package".to_string()),
370            true,
371        )
372        .health(Some(42.0), Some(coverage), Some("/workspace".to_string()))
373        .styling(true, true)
374        .baselines(
375            Some("dead.json".to_string()),
376            Some("health.json".to_string()),
377            Some("dupes.json".to_string()),
378        );
379
380        let payload = builder.payload();
381        assert_eq!(payload.cache_version, 3);
382        assert_eq!(payload.base_sha, "abc123");
383        assert_eq!(payload.workspace.as_deref(), Some(&["web".to_string()][..]));
384        assert!(payload.include_entry_exports);
385        assert!(payload.css);
386        assert!(payload.css_deep);
387        assert_eq!(
388            payload.coverage.as_ref().and_then(|c| c.source),
389            Some(SourceFingerprint::new(12, 34))
390        );
391    }
392
393    #[test]
394    fn audit_cache_key_bytes_reflect_changed_file_order() {
395        let first = AuditCacheKeyBuilder::new(
396            1,
397            "1.0.0",
398            "base",
399            config(),
400            vec!["src/a.ts".to_string(), "src/b.ts".to_string()],
401            context(),
402        )
403        .to_json_bytes()
404        .expect("payload should serialize");
405        let second = AuditCacheKeyBuilder::new(
406            1,
407            "1.0.0",
408            "base",
409            config(),
410            vec!["src/b.ts".to_string(), "src/a.ts".to_string()],
411            context(),
412        )
413        .to_json_bytes()
414        .expect("payload should serialize");
415
416        assert_ne!(first, second);
417    }
418
419    #[test]
420    fn audit_cache_key_bytes_include_the_analysis_selection() {
421        let build = |dead_code, health, dupes, architecture: &str| {
422            AuditCacheKeyBuilder::new(
423                1,
424                "1.0.0",
425                "base",
426                config(),
427                vec!["src/a.ts".to_string()],
428                context(),
429            )
430            .analyses(dead_code, health, dupes, architecture)
431            .to_json_bytes()
432            .expect("payload should serialize")
433        };
434
435        let all = build(true, true, true, "all");
436        assert_ne!(all, build(false, true, true, "all"));
437        assert_ne!(all, build(true, false, true, "all"));
438        assert_ne!(all, build(true, true, false, "all"));
439        assert_ne!(all, build(true, true, true, "exclude"));
440    }
441
442    #[test]
443    fn audit_cache_key_bytes_include_only_a_selected_type_aware_backend() {
444        let build = |backend: Option<&str>| {
445            AuditCacheKeyBuilder::new(1, "1.0.0", "base", config(), Vec::new(), context())
446                .type_aware_backend(backend.map(str::to_string))
447                .to_json_bytes()
448                .expect("payload should serialize")
449        };
450
451        let default = build(None);
452        assert!(!String::from_utf8_lossy(&default).contains("type_aware_backend"));
453        assert_ne!(default, build(Some("tsrs")));
454    }
455
456    #[test]
457    fn audit_cache_key_bytes_include_materialized_context() {
458        let missing = context();
459        let present = AuditMaterializedContextFingerprint {
460            lockfiles: vec![AuditContextFileFingerprint {
461                path: "pnpm-lock.yaml".to_string(),
462                state: AuditContextPathState::Present,
463                source: Some(SourceFingerprint::new(1, 10)),
464                content_hash: Some("lock-hash".to_string()),
465            }],
466            directories: Vec::new(),
467        };
468        let build = |materialized_context| {
469            AuditCacheKeyBuilder::new(
470                5,
471                "1.0.0",
472                "base",
473                config(),
474                vec!["src/a.ts".to_string()],
475                materialized_context,
476            )
477            .to_json_bytes()
478            .expect("payload should serialize")
479        };
480
481        let missing_json = build(missing);
482        let present_json = build(present);
483
484        assert_ne!(missing_json, present_json);
485        assert!(
486            String::from_utf8(present_json)
487                .expect("utf8 json")
488                .contains("pnpm-lock.yaml")
489        );
490    }
491}