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    /// CRAP threshold override.
120    pub max_crap: Option<f64>,
121    /// Coverage input fingerprint.
122    pub coverage: Option<AuditCoverageFingerprint>,
123    /// Coverage root override.
124    pub coverage_root: Option<String>,
125    /// Whether audit health computed styling keys for the base snapshot.
126    pub css: bool,
127    /// Whether audit health used deep CSS analysis for the base snapshot.
128    pub css_deep: bool,
129    /// Dead-code baseline path.
130    pub dead_code_baseline: Option<String>,
131    /// Health baseline path.
132    pub health_baseline: Option<String>,
133    /// Duplication baseline path.
134    pub dupes_baseline: Option<String>,
135}
136
137/// Builder for audit base-snapshot cache keys.
138#[derive(Debug, Clone)]
139pub struct AuditCacheKeyBuilder {
140    payload: AuditCacheKeyPayload,
141}
142
143impl AuditCacheKeyBuilder {
144    /// Start a cache-key payload with the invariant identity fields.
145    #[must_use]
146    pub fn new(
147        cache_version: u8,
148        cli_version: impl Into<String>,
149        base_sha: impl Into<String>,
150        config_file: AuditConfigFingerprint,
151        changed_files: Vec<String>,
152        materialized_context: AuditMaterializedContextFingerprint,
153    ) -> Self {
154        Self {
155            payload: AuditCacheKeyPayload {
156                cache_version,
157                cli_version: cli_version.into(),
158                base_sha: base_sha.into(),
159                config_file,
160                changed_files,
161                materialized_context,
162                production: false,
163                production_dead_code: None,
164                production_health: None,
165                production_dupes: None,
166                workspace: None,
167                changed_workspaces: None,
168                group_by: None,
169                include_entry_exports: false,
170                show_cascade: false,
171                max_crap: None,
172                coverage: None,
173                coverage_root: None,
174                css: false,
175                css_deep: false,
176                dead_code_baseline: None,
177                health_baseline: None,
178                dupes_baseline: None,
179            },
180        }
181    }
182
183    /// Set production-mode options.
184    #[must_use]
185    pub const fn production(
186        mut self,
187        production: bool,
188        dead_code: Option<bool>,
189        health: Option<bool>,
190        dupes: Option<bool>,
191    ) -> Self {
192        self.payload.production = production;
193        self.payload.production_dead_code = dead_code;
194        self.payload.production_health = health;
195        self.payload.production_dupes = dupes;
196        self
197    }
198
199    /// Set scope and grouping options.
200    #[must_use]
201    pub fn scope(
202        mut self,
203        workspace: Option<Vec<String>>,
204        changed_workspaces: Option<String>,
205        group_by: Option<String>,
206        include_entry_exports: bool,
207    ) -> Self {
208        self.payload.workspace = workspace;
209        self.payload.changed_workspaces = changed_workspaces;
210        self.payload.group_by = group_by;
211        self.payload.include_entry_exports = include_entry_exports;
212        self
213    }
214
215    /// Set whether the export and member findings of unused files stay in
216    /// the report.
217    #[must_use]
218    pub const fn show_cascade(mut self, show_cascade: bool) -> Self {
219        self.payload.show_cascade = show_cascade;
220        self
221    }
222
223    /// Set health and coverage options.
224    #[must_use]
225    pub fn health(
226        mut self,
227        max_crap: Option<f64>,
228        coverage: Option<AuditCoverageFingerprint>,
229        coverage_root: Option<String>,
230    ) -> Self {
231        self.payload.max_crap = max_crap;
232        self.payload.coverage = coverage;
233        self.payload.coverage_root = coverage_root;
234        self
235    }
236
237    /// Set styling-analysis options that affect base health snapshot keys.
238    #[must_use]
239    pub const fn styling(mut self, css: bool, css_deep: bool) -> Self {
240        self.payload.css = css;
241        self.payload.css_deep = css_deep;
242        self
243    }
244
245    /// Set baseline paths.
246    #[must_use]
247    pub fn baselines(
248        mut self,
249        dead_code: Option<String>,
250        health: Option<String>,
251        dupes: Option<String>,
252    ) -> Self {
253        self.payload.dead_code_baseline = dead_code;
254        self.payload.health_baseline = health;
255        self.payload.dupes_baseline = dupes;
256        self
257    }
258
259    /// Borrow the completed payload.
260    #[must_use]
261    pub const fn payload(&self) -> &AuditCacheKeyPayload {
262        &self.payload
263    }
264
265    /// Serialize the completed payload into stable JSON bytes for hashing.
266    ///
267    /// # Errors
268    ///
269    /// Returns a serde error when a payload field cannot be serialized.
270    pub fn to_json_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
271        serde_json::to_vec(&self.payload)
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    fn config() -> AuditConfigFingerprint {
280        AuditConfigFingerprint {
281            path: Some("fallow.toml".to_string()),
282            resolved_hash: Some("abc".to_string()),
283        }
284    }
285
286    fn context() -> AuditMaterializedContextFingerprint {
287        AuditMaterializedContextFingerprint {
288            lockfiles: Vec::new(),
289            directories: Vec::new(),
290        }
291    }
292
293    #[test]
294    fn audit_cache_key_builder_preserves_typed_fields() {
295        let coverage = AuditCoverageFingerprint {
296            path: "coverage".to_string(),
297            resolved_path: "coverage/coverage-final.json".to_string(),
298            source: Some(SourceFingerprint::new(12, 34)),
299            content_hash: Some("hash".to_string()),
300            len: Some(34),
301            error: None,
302        };
303
304        let builder = AuditCacheKeyBuilder::new(
305            3,
306            "1.2.3",
307            "abc123",
308            config(),
309            vec!["src/a.ts".to_string()],
310            context(),
311        )
312        .production(true, Some(false), Some(true), None)
313        .scope(
314            Some(vec!["web".to_string()]),
315            Some("main".to_string()),
316            Some("Package".to_string()),
317            true,
318        )
319        .health(Some(42.0), Some(coverage), Some("/workspace".to_string()))
320        .styling(true, true)
321        .baselines(
322            Some("dead.json".to_string()),
323            Some("health.json".to_string()),
324            Some("dupes.json".to_string()),
325        );
326
327        let payload = builder.payload();
328        assert_eq!(payload.cache_version, 3);
329        assert_eq!(payload.base_sha, "abc123");
330        assert_eq!(payload.workspace.as_deref(), Some(&["web".to_string()][..]));
331        assert!(payload.include_entry_exports);
332        assert!(payload.css);
333        assert!(payload.css_deep);
334        assert_eq!(
335            payload.coverage.as_ref().and_then(|c| c.source),
336            Some(SourceFingerprint::new(12, 34))
337        );
338    }
339
340    #[test]
341    fn audit_cache_key_bytes_reflect_changed_file_order() {
342        let first = AuditCacheKeyBuilder::new(
343            1,
344            "1.0.0",
345            "base",
346            config(),
347            vec!["src/a.ts".to_string(), "src/b.ts".to_string()],
348            context(),
349        )
350        .to_json_bytes()
351        .expect("payload should serialize");
352        let second = AuditCacheKeyBuilder::new(
353            1,
354            "1.0.0",
355            "base",
356            config(),
357            vec!["src/b.ts".to_string(), "src/a.ts".to_string()],
358            context(),
359        )
360        .to_json_bytes()
361        .expect("payload should serialize");
362
363        assert_ne!(first, second);
364    }
365
366    #[test]
367    fn audit_cache_key_bytes_include_materialized_context() {
368        let missing = context();
369        let present = AuditMaterializedContextFingerprint {
370            lockfiles: vec![AuditContextFileFingerprint {
371                path: "pnpm-lock.yaml".to_string(),
372                state: AuditContextPathState::Present,
373                source: Some(SourceFingerprint::new(1, 10)),
374                content_hash: Some("lock-hash".to_string()),
375            }],
376            directories: Vec::new(),
377        };
378        let build = |materialized_context| {
379            AuditCacheKeyBuilder::new(
380                5,
381                "1.0.0",
382                "base",
383                config(),
384                vec!["src/a.ts".to_string()],
385                materialized_context,
386            )
387            .to_json_bytes()
388            .expect("payload should serialize")
389        };
390
391        let missing_json = build(missing);
392        let present_json = build(present);
393
394        assert_ne!(missing_json, present_json);
395        assert!(
396            String::from_utf8(present_json)
397                .expect("utf8 json")
398                .contains("pnpm-lock.yaml")
399        );
400    }
401}