1use serde::Serialize;
4
5use crate::source_fingerprint::SourceFingerprint;
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
9pub struct AuditConfigFingerprint {
10 pub path: Option<String>,
12 pub resolved_hash: Option<String>,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
18pub struct AuditCoverageFingerprint {
19 pub path: String,
21 pub resolved_path: String,
23 #[serde(skip_serializing_if = "Option::is_none")]
25 pub source: Option<SourceFingerprint>,
26 #[serde(skip_serializing_if = "Option::is_none")]
28 pub content_hash: Option<String>,
29 #[serde(skip_serializing_if = "Option::is_none")]
31 pub len: Option<usize>,
32 #[serde(skip_serializing_if = "Option::is_none")]
34 pub error: Option<String>,
35}
36
37#[derive(Debug, Clone, PartialEq, Serialize)]
39pub struct AuditCacheKeyPayload {
40 pub cache_version: u8,
42 pub cli_version: String,
44 pub base_sha: String,
46 pub config_file: AuditConfigFingerprint,
48 pub changed_files: Vec<String>,
50 pub production: bool,
52 pub production_dead_code: Option<bool>,
54 pub production_health: Option<bool>,
56 pub production_dupes: Option<bool>,
58 pub workspace: Option<Vec<String>>,
60 pub changed_workspaces: Option<String>,
62 pub group_by: Option<String>,
64 pub include_entry_exports: bool,
66 pub max_crap: Option<f64>,
68 pub coverage: Option<AuditCoverageFingerprint>,
70 pub coverage_root: Option<String>,
72 pub dead_code_baseline: Option<String>,
74 pub health_baseline: Option<String>,
76 pub dupes_baseline: Option<String>,
78}
79
80#[derive(Debug, Clone)]
82pub struct AuditCacheKeyBuilder {
83 payload: AuditCacheKeyPayload,
84}
85
86impl AuditCacheKeyBuilder {
87 #[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 #[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 #[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 #[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 #[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 #[must_use]
183 pub const fn payload(&self) -> &AuditCacheKeyPayload {
184 &self.payload
185 }
186
187 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}