1use serde::Serialize;
4
5use crate::source_fingerprint::SourceFingerprint;
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
9#[serde(rename_all = "snake_case")]
10pub enum AuditContextPathState {
11 Missing,
13 Present,
15 Unreadable(String),
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
21pub struct AuditContextFileFingerprint {
22 pub path: String,
24 pub state: AuditContextPathState,
26 pub source: Option<SourceFingerprint>,
28 pub content_hash: Option<String>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
34pub struct AuditContextDirectoryFingerprint {
35 pub name: String,
37 pub state: AuditContextPathState,
39 pub canonical_path: Option<String>,
41 pub source: Option<SourceFingerprint>,
43 pub markers: Vec<AuditContextFileFingerprint>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
49pub struct AuditMaterializedContextFingerprint {
50 pub lockfiles: Vec<AuditContextFileFingerprint>,
52 pub directories: Vec<AuditContextDirectoryFingerprint>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
58pub struct AuditConfigFingerprint {
59 pub path: Option<String>,
61 pub resolved_hash: Option<String>,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
67pub struct AuditCoverageFingerprint {
68 pub path: String,
70 pub resolved_path: String,
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub source: Option<SourceFingerprint>,
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub content_hash: Option<String>,
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub len: Option<usize>,
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub error: Option<String>,
84}
85
86#[derive(Debug, Clone, PartialEq, Serialize)]
88pub struct AuditCacheKeyPayload {
89 pub cache_version: u8,
91 pub cli_version: String,
93 pub base_sha: String,
95 pub config_file: AuditConfigFingerprint,
97 pub changed_files: Vec<String>,
99 pub materialized_context: AuditMaterializedContextFingerprint,
101 pub production: bool,
103 pub production_dead_code: Option<bool>,
105 pub production_health: Option<bool>,
107 pub production_dupes: Option<bool>,
109 pub workspace: Option<Vec<String>>,
111 pub changed_workspaces: Option<String>,
113 pub group_by: Option<String>,
115 pub include_entry_exports: bool,
117 pub max_crap: Option<f64>,
119 pub coverage: Option<AuditCoverageFingerprint>,
121 pub coverage_root: Option<String>,
123 pub css: bool,
125 pub css_deep: bool,
127 pub dead_code_baseline: Option<String>,
129 pub health_baseline: Option<String>,
131 pub dupes_baseline: Option<String>,
133}
134
135#[derive(Debug, Clone)]
137pub struct AuditCacheKeyBuilder {
138 payload: AuditCacheKeyPayload,
139}
140
141impl AuditCacheKeyBuilder {
142 #[must_use]
144 pub fn new(
145 cache_version: u8,
146 cli_version: impl Into<String>,
147 base_sha: impl Into<String>,
148 config_file: AuditConfigFingerprint,
149 changed_files: Vec<String>,
150 materialized_context: AuditMaterializedContextFingerprint,
151 ) -> Self {
152 Self {
153 payload: AuditCacheKeyPayload {
154 cache_version,
155 cli_version: cli_version.into(),
156 base_sha: base_sha.into(),
157 config_file,
158 changed_files,
159 materialized_context,
160 production: false,
161 production_dead_code: None,
162 production_health: None,
163 production_dupes: None,
164 workspace: None,
165 changed_workspaces: None,
166 group_by: None,
167 include_entry_exports: false,
168 max_crap: None,
169 coverage: None,
170 coverage_root: None,
171 css: false,
172 css_deep: false,
173 dead_code_baseline: None,
174 health_baseline: None,
175 dupes_baseline: None,
176 },
177 }
178 }
179
180 #[must_use]
182 pub const fn production(
183 mut self,
184 production: bool,
185 dead_code: Option<bool>,
186 health: Option<bool>,
187 dupes: Option<bool>,
188 ) -> Self {
189 self.payload.production = production;
190 self.payload.production_dead_code = dead_code;
191 self.payload.production_health = health;
192 self.payload.production_dupes = dupes;
193 self
194 }
195
196 #[must_use]
198 pub fn scope(
199 mut self,
200 workspace: Option<Vec<String>>,
201 changed_workspaces: Option<String>,
202 group_by: Option<String>,
203 include_entry_exports: bool,
204 ) -> Self {
205 self.payload.workspace = workspace;
206 self.payload.changed_workspaces = changed_workspaces;
207 self.payload.group_by = group_by;
208 self.payload.include_entry_exports = include_entry_exports;
209 self
210 }
211
212 #[must_use]
214 pub fn health(
215 mut self,
216 max_crap: Option<f64>,
217 coverage: Option<AuditCoverageFingerprint>,
218 coverage_root: Option<String>,
219 ) -> Self {
220 self.payload.max_crap = max_crap;
221 self.payload.coverage = coverage;
222 self.payload.coverage_root = coverage_root;
223 self
224 }
225
226 #[must_use]
228 pub const fn styling(mut self, css: bool, css_deep: bool) -> Self {
229 self.payload.css = css;
230 self.payload.css_deep = css_deep;
231 self
232 }
233
234 #[must_use]
236 pub fn baselines(
237 mut self,
238 dead_code: Option<String>,
239 health: Option<String>,
240 dupes: Option<String>,
241 ) -> Self {
242 self.payload.dead_code_baseline = dead_code;
243 self.payload.health_baseline = health;
244 self.payload.dupes_baseline = dupes;
245 self
246 }
247
248 #[must_use]
250 pub const fn payload(&self) -> &AuditCacheKeyPayload {
251 &self.payload
252 }
253
254 pub fn to_json_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
260 serde_json::to_vec(&self.payload)
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 fn config() -> AuditConfigFingerprint {
269 AuditConfigFingerprint {
270 path: Some("fallow.toml".to_string()),
271 resolved_hash: Some("abc".to_string()),
272 }
273 }
274
275 fn context() -> AuditMaterializedContextFingerprint {
276 AuditMaterializedContextFingerprint {
277 lockfiles: Vec::new(),
278 directories: Vec::new(),
279 }
280 }
281
282 #[test]
283 fn audit_cache_key_builder_preserves_typed_fields() {
284 let coverage = AuditCoverageFingerprint {
285 path: "coverage".to_string(),
286 resolved_path: "coverage/coverage-final.json".to_string(),
287 source: Some(SourceFingerprint::new(12, 34)),
288 content_hash: Some("hash".to_string()),
289 len: Some(34),
290 error: None,
291 };
292
293 let builder = AuditCacheKeyBuilder::new(
294 3,
295 "1.2.3",
296 "abc123",
297 config(),
298 vec!["src/a.ts".to_string()],
299 context(),
300 )
301 .production(true, Some(false), Some(true), None)
302 .scope(
303 Some(vec!["web".to_string()]),
304 Some("main".to_string()),
305 Some("Package".to_string()),
306 true,
307 )
308 .health(Some(42.0), Some(coverage), Some("/workspace".to_string()))
309 .styling(true, true)
310 .baselines(
311 Some("dead.json".to_string()),
312 Some("health.json".to_string()),
313 Some("dupes.json".to_string()),
314 );
315
316 let payload = builder.payload();
317 assert_eq!(payload.cache_version, 3);
318 assert_eq!(payload.base_sha, "abc123");
319 assert_eq!(payload.workspace.as_deref(), Some(&["web".to_string()][..]));
320 assert!(payload.include_entry_exports);
321 assert!(payload.css);
322 assert!(payload.css_deep);
323 assert_eq!(
324 payload.coverage.as_ref().and_then(|c| c.source),
325 Some(SourceFingerprint::new(12, 34))
326 );
327 }
328
329 #[test]
330 fn audit_cache_key_bytes_reflect_changed_file_order() {
331 let first = AuditCacheKeyBuilder::new(
332 1,
333 "1.0.0",
334 "base",
335 config(),
336 vec!["src/a.ts".to_string(), "src/b.ts".to_string()],
337 context(),
338 )
339 .to_json_bytes()
340 .expect("payload should serialize");
341 let second = AuditCacheKeyBuilder::new(
342 1,
343 "1.0.0",
344 "base",
345 config(),
346 vec!["src/b.ts".to_string(), "src/a.ts".to_string()],
347 context(),
348 )
349 .to_json_bytes()
350 .expect("payload should serialize");
351
352 assert_ne!(first, second);
353 }
354
355 #[test]
356 fn audit_cache_key_bytes_include_materialized_context() {
357 let missing = context();
358 let present = AuditMaterializedContextFingerprint {
359 lockfiles: vec![AuditContextFileFingerprint {
360 path: "pnpm-lock.yaml".to_string(),
361 state: AuditContextPathState::Present,
362 source: Some(SourceFingerprint::new(1, 10)),
363 content_hash: Some("lock-hash".to_string()),
364 }],
365 directories: Vec::new(),
366 };
367 let build = |materialized_context| {
368 AuditCacheKeyBuilder::new(
369 5,
370 "1.0.0",
371 "base",
372 config(),
373 vec!["src/a.ts".to_string()],
374 materialized_context,
375 )
376 .to_json_bytes()
377 .expect("payload should serialize")
378 };
379
380 let missing_json = build(missing);
381 let present_json = build(present);
382
383 assert_ne!(missing_json, present_json);
384 assert!(
385 String::from_utf8(present_json)
386 .expect("utf8 json")
387 .contains("pnpm-lock.yaml")
388 );
389 }
390}