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    /// Dead-code baseline path.
73    pub dead_code_baseline: Option<String>,
74    /// Health baseline path.
75    pub health_baseline: Option<String>,
76    /// Duplication baseline path.
77    pub dupes_baseline: Option<String>,
78}
79
80/// Builder for audit base-snapshot cache keys.
81#[derive(Debug, Clone)]
82pub struct AuditCacheKeyBuilder {
83    payload: AuditCacheKeyPayload,
84}
85
86impl AuditCacheKeyBuilder {
87    /// Start a cache-key payload with the invariant identity fields.
88    #[must_use]
89    pub fn new(
90        cache_version: u8,
91        cli_version: impl Into<String>,
92        base_sha: impl Into<String>,
93        config_file: AuditConfigFingerprint,
94        changed_files: Vec<String>,
95    ) -> Self {
96        Self {
97            payload: AuditCacheKeyPayload {
98                cache_version,
99                cli_version: cli_version.into(),
100                base_sha: base_sha.into(),
101                config_file,
102                changed_files,
103                production: false,
104                production_dead_code: None,
105                production_health: None,
106                production_dupes: None,
107                workspace: None,
108                changed_workspaces: None,
109                group_by: None,
110                include_entry_exports: false,
111                max_crap: None,
112                coverage: None,
113                coverage_root: None,
114                dead_code_baseline: None,
115                health_baseline: None,
116                dupes_baseline: None,
117            },
118        }
119    }
120
121    /// Set production-mode options.
122    #[must_use]
123    pub const fn production(
124        mut self,
125        production: bool,
126        dead_code: Option<bool>,
127        health: Option<bool>,
128        dupes: Option<bool>,
129    ) -> Self {
130        self.payload.production = production;
131        self.payload.production_dead_code = dead_code;
132        self.payload.production_health = health;
133        self.payload.production_dupes = dupes;
134        self
135    }
136
137    /// Set scope and grouping options.
138    #[must_use]
139    pub fn scope(
140        mut self,
141        workspace: Option<Vec<String>>,
142        changed_workspaces: Option<String>,
143        group_by: Option<String>,
144        include_entry_exports: bool,
145    ) -> Self {
146        self.payload.workspace = workspace;
147        self.payload.changed_workspaces = changed_workspaces;
148        self.payload.group_by = group_by;
149        self.payload.include_entry_exports = include_entry_exports;
150        self
151    }
152
153    /// Set health and coverage options.
154    #[must_use]
155    pub fn health(
156        mut self,
157        max_crap: Option<f64>,
158        coverage: Option<AuditCoverageFingerprint>,
159        coverage_root: Option<String>,
160    ) -> Self {
161        self.payload.max_crap = max_crap;
162        self.payload.coverage = coverage;
163        self.payload.coverage_root = coverage_root;
164        self
165    }
166
167    /// Set baseline paths.
168    #[must_use]
169    pub fn baselines(
170        mut self,
171        dead_code: Option<String>,
172        health: Option<String>,
173        dupes: Option<String>,
174    ) -> Self {
175        self.payload.dead_code_baseline = dead_code;
176        self.payload.health_baseline = health;
177        self.payload.dupes_baseline = dupes;
178        self
179    }
180
181    /// Borrow the completed payload.
182    #[must_use]
183    pub const fn payload(&self) -> &AuditCacheKeyPayload {
184        &self.payload
185    }
186
187    /// Serialize the completed payload into stable JSON bytes for hashing.
188    ///
189    /// # Errors
190    ///
191    /// Returns a serde error when a payload field cannot be serialized.
192    pub fn to_json_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
193        serde_json::to_vec(&self.payload)
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    fn config() -> AuditConfigFingerprint {
202        AuditConfigFingerprint {
203            path: Some("fallow.toml".to_string()),
204            resolved_hash: Some("abc".to_string()),
205        }
206    }
207
208    #[test]
209    fn audit_cache_key_builder_preserves_typed_fields() {
210        let coverage = AuditCoverageFingerprint {
211            path: "coverage".to_string(),
212            resolved_path: "coverage/coverage-final.json".to_string(),
213            source: Some(SourceFingerprint::new(12, 34)),
214            content_hash: Some("hash".to_string()),
215            len: Some(34),
216            error: None,
217        };
218
219        let builder =
220            AuditCacheKeyBuilder::new(3, "1.2.3", "abc123", config(), vec!["src/a.ts".to_string()])
221                .production(true, Some(false), Some(true), None)
222                .scope(
223                    Some(vec!["web".to_string()]),
224                    Some("main".to_string()),
225                    Some("Package".to_string()),
226                    true,
227                )
228                .health(Some(42.0), Some(coverage), Some("/workspace".to_string()))
229                .baselines(
230                    Some("dead.json".to_string()),
231                    Some("health.json".to_string()),
232                    Some("dupes.json".to_string()),
233                );
234
235        let payload = builder.payload();
236        assert_eq!(payload.cache_version, 3);
237        assert_eq!(payload.base_sha, "abc123");
238        assert_eq!(payload.workspace.as_deref(), Some(&["web".to_string()][..]));
239        assert!(payload.include_entry_exports);
240        assert_eq!(
241            payload.coverage.as_ref().and_then(|c| c.source),
242            Some(SourceFingerprint::new(12, 34))
243        );
244    }
245
246    #[test]
247    fn audit_cache_key_bytes_reflect_changed_file_order() {
248        let first = AuditCacheKeyBuilder::new(
249            1,
250            "1.0.0",
251            "base",
252            config(),
253            vec!["src/a.ts".to_string(), "src/b.ts".to_string()],
254        )
255        .to_json_bytes()
256        .expect("payload should serialize");
257        let second = AuditCacheKeyBuilder::new(
258            1,
259            "1.0.0",
260            "base",
261            config(),
262            vec!["src/b.ts".to_string(), "src/a.ts".to_string()],
263        )
264        .to_json_bytes()
265        .expect("payload should serialize");
266
267        assert_ne!(first, second);
268    }
269}