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/// Fingerprint of the resolved config that can affect audit output.
8#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
9pub struct AuditConfigFingerprint {
10    /// Path of the config file that was loaded, or `None` when no config exists.
11    pub path: Option<String>,
12    /// Stable hash of the resolved config object.
13    pub resolved_hash: Option<String>,
14}
15
16/// Fingerprint of an optional coverage input that can affect health findings.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
18pub struct AuditCoverageFingerprint {
19    /// User-provided coverage path.
20    pub path: String,
21    /// Actual file path hashed after directory resolution.
22    pub resolved_path: String,
23    /// Metadata freshness for the resolved coverage file, when it was readable.
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub source: Option<SourceFingerprint>,
26    /// Stable content hash for the resolved coverage file, when it was readable.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub content_hash: Option<String>,
29    /// File length in bytes, when it was readable.
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub len: Option<usize>,
32    /// I/O error kind, when the resolved coverage file was not readable.
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub error: Option<String>,
35}
36
37/// Typed payload hashed to address an audit base-snapshot cache entry.
38#[derive(Debug, Clone, PartialEq, Serialize)]
39pub struct AuditCacheKeyPayload {
40    /// Audit base snapshot cache schema version.
41    pub cache_version: u8,
42    /// Fallow CLI version that produced the key.
43    pub cli_version: String,
44    /// Resolved git SHA for the base ref.
45    pub base_sha: String,
46    /// Config fingerprint.
47    pub config_file: AuditConfigFingerprint,
48    /// Changed files normalized to git-root-relative, forward-slash paths.
49    pub changed_files: Vec<String>,
50    /// Global production mode.
51    pub production: bool,
52    /// Dead-code-specific production override.
53    pub production_dead_code: Option<bool>,
54    /// Health-specific production override.
55    pub production_health: Option<bool>,
56    /// Duplication-specific production override.
57    pub production_dupes: Option<bool>,
58    /// Workspace filters.
59    pub workspace: Option<Vec<String>>,
60    /// Changed-workspaces base ref, when enabled.
61    pub changed_workspaces: Option<String>,
62    /// Grouping mode.
63    pub group_by: Option<String>,
64    /// Whether entry exports are analyzed.
65    pub include_entry_exports: bool,
66    /// CRAP threshold override.
67    pub max_crap: Option<f64>,
68    /// Coverage input fingerprint.
69    pub coverage: Option<AuditCoverageFingerprint>,
70    /// Coverage root override.
71    pub coverage_root: Option<String>,
72    /// Whether audit health computed styling keys for the base snapshot.
73    pub css: bool,
74    /// Whether audit health used deep CSS analysis for the base snapshot.
75    pub css_deep: bool,
76    /// Dead-code baseline path.
77    pub dead_code_baseline: Option<String>,
78    /// Health baseline path.
79    pub health_baseline: Option<String>,
80    /// Duplication baseline path.
81    pub dupes_baseline: Option<String>,
82}
83
84/// Builder for audit base-snapshot cache keys.
85#[derive(Debug, Clone)]
86pub struct AuditCacheKeyBuilder {
87    payload: AuditCacheKeyPayload,
88}
89
90impl AuditCacheKeyBuilder {
91    /// Start a cache-key payload with the invariant identity fields.
92    #[must_use]
93    pub fn new(
94        cache_version: u8,
95        cli_version: impl Into<String>,
96        base_sha: impl Into<String>,
97        config_file: AuditConfigFingerprint,
98        changed_files: Vec<String>,
99    ) -> Self {
100        Self {
101            payload: AuditCacheKeyPayload {
102                cache_version,
103                cli_version: cli_version.into(),
104                base_sha: base_sha.into(),
105                config_file,
106                changed_files,
107                production: false,
108                production_dead_code: None,
109                production_health: None,
110                production_dupes: None,
111                workspace: None,
112                changed_workspaces: None,
113                group_by: None,
114                include_entry_exports: false,
115                max_crap: None,
116                coverage: None,
117                coverage_root: None,
118                css: false,
119                css_deep: false,
120                dead_code_baseline: None,
121                health_baseline: None,
122                dupes_baseline: None,
123            },
124        }
125    }
126
127    /// Set production-mode options.
128    #[must_use]
129    pub const fn production(
130        mut self,
131        production: bool,
132        dead_code: Option<bool>,
133        health: Option<bool>,
134        dupes: Option<bool>,
135    ) -> Self {
136        self.payload.production = production;
137        self.payload.production_dead_code = dead_code;
138        self.payload.production_health = health;
139        self.payload.production_dupes = dupes;
140        self
141    }
142
143    /// Set scope and grouping options.
144    #[must_use]
145    pub fn scope(
146        mut self,
147        workspace: Option<Vec<String>>,
148        changed_workspaces: Option<String>,
149        group_by: Option<String>,
150        include_entry_exports: bool,
151    ) -> Self {
152        self.payload.workspace = workspace;
153        self.payload.changed_workspaces = changed_workspaces;
154        self.payload.group_by = group_by;
155        self.payload.include_entry_exports = include_entry_exports;
156        self
157    }
158
159    /// Set health and coverage options.
160    #[must_use]
161    pub fn health(
162        mut self,
163        max_crap: Option<f64>,
164        coverage: Option<AuditCoverageFingerprint>,
165        coverage_root: Option<String>,
166    ) -> Self {
167        self.payload.max_crap = max_crap;
168        self.payload.coverage = coverage;
169        self.payload.coverage_root = coverage_root;
170        self
171    }
172
173    /// Set styling-analysis options that affect base health snapshot keys.
174    #[must_use]
175    pub const fn styling(mut self, css: bool, css_deep: bool) -> Self {
176        self.payload.css = css;
177        self.payload.css_deep = css_deep;
178        self
179    }
180
181    /// Set baseline paths.
182    #[must_use]
183    pub fn baselines(
184        mut self,
185        dead_code: Option<String>,
186        health: Option<String>,
187        dupes: Option<String>,
188    ) -> Self {
189        self.payload.dead_code_baseline = dead_code;
190        self.payload.health_baseline = health;
191        self.payload.dupes_baseline = dupes;
192        self
193    }
194
195    /// Borrow the completed payload.
196    #[must_use]
197    pub const fn payload(&self) -> &AuditCacheKeyPayload {
198        &self.payload
199    }
200
201    /// Serialize the completed payload into stable JSON bytes for hashing.
202    ///
203    /// # Errors
204    ///
205    /// Returns a serde error when a payload field cannot be serialized.
206    pub fn to_json_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
207        serde_json::to_vec(&self.payload)
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    fn config() -> AuditConfigFingerprint {
216        AuditConfigFingerprint {
217            path: Some("fallow.toml".to_string()),
218            resolved_hash: Some("abc".to_string()),
219        }
220    }
221
222    #[test]
223    fn audit_cache_key_builder_preserves_typed_fields() {
224        let coverage = AuditCoverageFingerprint {
225            path: "coverage".to_string(),
226            resolved_path: "coverage/coverage-final.json".to_string(),
227            source: Some(SourceFingerprint::new(12, 34)),
228            content_hash: Some("hash".to_string()),
229            len: Some(34),
230            error: None,
231        };
232
233        let builder =
234            AuditCacheKeyBuilder::new(3, "1.2.3", "abc123", config(), vec!["src/a.ts".to_string()])
235                .production(true, Some(false), Some(true), None)
236                .scope(
237                    Some(vec!["web".to_string()]),
238                    Some("main".to_string()),
239                    Some("Package".to_string()),
240                    true,
241                )
242                .health(Some(42.0), Some(coverage), Some("/workspace".to_string()))
243                .styling(true, true)
244                .baselines(
245                    Some("dead.json".to_string()),
246                    Some("health.json".to_string()),
247                    Some("dupes.json".to_string()),
248                );
249
250        let payload = builder.payload();
251        assert_eq!(payload.cache_version, 3);
252        assert_eq!(payload.base_sha, "abc123");
253        assert_eq!(payload.workspace.as_deref(), Some(&["web".to_string()][..]));
254        assert!(payload.include_entry_exports);
255        assert!(payload.css);
256        assert!(payload.css_deep);
257        assert_eq!(
258            payload.coverage.as_ref().and_then(|c| c.source),
259            Some(SourceFingerprint::new(12, 34))
260        );
261    }
262
263    #[test]
264    fn audit_cache_key_bytes_reflect_changed_file_order() {
265        let first = AuditCacheKeyBuilder::new(
266            1,
267            "1.0.0",
268            "base",
269            config(),
270            vec!["src/a.ts".to_string(), "src/b.ts".to_string()],
271        )
272        .to_json_bytes()
273        .expect("payload should serialize");
274        let second = AuditCacheKeyBuilder::new(
275            1,
276            "1.0.0",
277            "base",
278            config(),
279            vec!["src/b.ts".to_string(), "src/a.ts".to_string()],
280        )
281        .to_json_bytes()
282        .expect("payload should serialize");
283
284        assert_ne!(first, second);
285    }
286}