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 show_cascade: bool,
119 pub brief: bool,
122 pub max_crap: Option<f64>,
124 pub coverage: Option<AuditCoverageFingerprint>,
126 pub coverage_root: Option<String>,
128 pub dead_code: bool,
130 pub health: bool,
132 pub dupes: bool,
134 pub architecture: String,
137 pub css: bool,
139 pub css_deep: bool,
141 pub dead_code_baseline: Option<String>,
143 pub health_baseline: Option<String>,
145 pub dupes_baseline: Option<String>,
147}
148
149#[derive(Debug, Clone)]
151pub struct AuditCacheKeyBuilder {
152 payload: AuditCacheKeyPayload,
153}
154
155impl AuditCacheKeyBuilder {
156 #[must_use]
158 pub fn new(
159 cache_version: u8,
160 cli_version: impl Into<String>,
161 base_sha: impl Into<String>,
162 config_file: AuditConfigFingerprint,
163 changed_files: Vec<String>,
164 materialized_context: AuditMaterializedContextFingerprint,
165 ) -> Self {
166 Self {
167 payload: AuditCacheKeyPayload {
168 cache_version,
169 cli_version: cli_version.into(),
170 base_sha: base_sha.into(),
171 config_file,
172 changed_files,
173 materialized_context,
174 production: false,
175 production_dead_code: None,
176 production_health: None,
177 production_dupes: None,
178 workspace: None,
179 changed_workspaces: None,
180 group_by: None,
181 include_entry_exports: false,
182 show_cascade: false,
183 brief: false,
184 max_crap: None,
185 coverage: None,
186 coverage_root: None,
187 dead_code: true,
188 health: true,
189 dupes: true,
190 architecture: "all".to_string(),
191 css: false,
192 css_deep: false,
193 dead_code_baseline: None,
194 health_baseline: None,
195 dupes_baseline: None,
196 },
197 }
198 }
199
200 #[must_use]
202 pub const fn production(
203 mut self,
204 production: bool,
205 dead_code: Option<bool>,
206 health: Option<bool>,
207 dupes: Option<bool>,
208 ) -> Self {
209 self.payload.production = production;
210 self.payload.production_dead_code = dead_code;
211 self.payload.production_health = health;
212 self.payload.production_dupes = dupes;
213 self
214 }
215
216 #[must_use]
218 pub fn scope(
219 mut self,
220 workspace: Option<Vec<String>>,
221 changed_workspaces: Option<String>,
222 group_by: Option<String>,
223 include_entry_exports: bool,
224 ) -> Self {
225 self.payload.workspace = workspace;
226 self.payload.changed_workspaces = changed_workspaces;
227 self.payload.group_by = group_by;
228 self.payload.include_entry_exports = include_entry_exports;
229 self
230 }
231
232 #[must_use]
235 pub const fn show_cascade(mut self, show_cascade: bool) -> Self {
236 self.payload.show_cascade = show_cascade;
237 self
238 }
239
240 #[must_use]
242 pub const fn brief(mut self, brief: bool) -> Self {
243 self.payload.brief = brief;
244 self
245 }
246
247 #[must_use]
249 pub fn health(
250 mut self,
251 max_crap: Option<f64>,
252 coverage: Option<AuditCoverageFingerprint>,
253 coverage_root: Option<String>,
254 ) -> Self {
255 self.payload.max_crap = max_crap;
256 self.payload.coverage = coverage;
257 self.payload.coverage_root = coverage_root;
258 self
259 }
260
261 #[must_use]
264 pub fn analyses(
265 mut self,
266 dead_code: bool,
267 health: bool,
268 dupes: bool,
269 architecture: impl Into<String>,
270 ) -> Self {
271 self.payload.dead_code = dead_code;
272 self.payload.health = health;
273 self.payload.dupes = dupes;
274 self.payload.architecture = architecture.into();
275 self
276 }
277
278 #[must_use]
280 pub const fn styling(mut self, css: bool, css_deep: bool) -> Self {
281 self.payload.css = css;
282 self.payload.css_deep = css_deep;
283 self
284 }
285
286 #[must_use]
288 pub fn baselines(
289 mut self,
290 dead_code: Option<String>,
291 health: Option<String>,
292 dupes: Option<String>,
293 ) -> Self {
294 self.payload.dead_code_baseline = dead_code;
295 self.payload.health_baseline = health;
296 self.payload.dupes_baseline = dupes;
297 self
298 }
299
300 #[must_use]
302 pub const fn payload(&self) -> &AuditCacheKeyPayload {
303 &self.payload
304 }
305
306 pub fn to_json_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
312 serde_json::to_vec(&self.payload)
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 fn config() -> AuditConfigFingerprint {
321 AuditConfigFingerprint {
322 path: Some("fallow.toml".to_string()),
323 resolved_hash: Some("abc".to_string()),
324 }
325 }
326
327 fn context() -> AuditMaterializedContextFingerprint {
328 AuditMaterializedContextFingerprint {
329 lockfiles: Vec::new(),
330 directories: Vec::new(),
331 }
332 }
333
334 #[test]
335 fn audit_cache_key_builder_preserves_typed_fields() {
336 let coverage = AuditCoverageFingerprint {
337 path: "coverage".to_string(),
338 resolved_path: "coverage/coverage-final.json".to_string(),
339 source: Some(SourceFingerprint::new(12, 34)),
340 content_hash: Some("hash".to_string()),
341 len: Some(34),
342 error: None,
343 };
344
345 let builder = AuditCacheKeyBuilder::new(
346 3,
347 "1.2.3",
348 "abc123",
349 config(),
350 vec!["src/a.ts".to_string()],
351 context(),
352 )
353 .production(true, Some(false), Some(true), None)
354 .scope(
355 Some(vec!["web".to_string()]),
356 Some("main".to_string()),
357 Some("Package".to_string()),
358 true,
359 )
360 .health(Some(42.0), Some(coverage), Some("/workspace".to_string()))
361 .styling(true, true)
362 .baselines(
363 Some("dead.json".to_string()),
364 Some("health.json".to_string()),
365 Some("dupes.json".to_string()),
366 );
367
368 let payload = builder.payload();
369 assert_eq!(payload.cache_version, 3);
370 assert_eq!(payload.base_sha, "abc123");
371 assert_eq!(payload.workspace.as_deref(), Some(&["web".to_string()][..]));
372 assert!(payload.include_entry_exports);
373 assert!(payload.css);
374 assert!(payload.css_deep);
375 assert_eq!(
376 payload.coverage.as_ref().and_then(|c| c.source),
377 Some(SourceFingerprint::new(12, 34))
378 );
379 }
380
381 #[test]
382 fn audit_cache_key_bytes_reflect_changed_file_order() {
383 let first = AuditCacheKeyBuilder::new(
384 1,
385 "1.0.0",
386 "base",
387 config(),
388 vec!["src/a.ts".to_string(), "src/b.ts".to_string()],
389 context(),
390 )
391 .to_json_bytes()
392 .expect("payload should serialize");
393 let second = AuditCacheKeyBuilder::new(
394 1,
395 "1.0.0",
396 "base",
397 config(),
398 vec!["src/b.ts".to_string(), "src/a.ts".to_string()],
399 context(),
400 )
401 .to_json_bytes()
402 .expect("payload should serialize");
403
404 assert_ne!(first, second);
405 }
406
407 #[test]
408 fn audit_cache_key_bytes_include_the_analysis_selection() {
409 let build = |dead_code, health, dupes, architecture: &str| {
410 AuditCacheKeyBuilder::new(
411 1,
412 "1.0.0",
413 "base",
414 config(),
415 vec!["src/a.ts".to_string()],
416 context(),
417 )
418 .analyses(dead_code, health, dupes, architecture)
419 .to_json_bytes()
420 .expect("payload should serialize")
421 };
422
423 let all = build(true, true, true, "all");
424 assert_ne!(all, build(false, true, true, "all"));
425 assert_ne!(all, build(true, false, true, "all"));
426 assert_ne!(all, build(true, true, false, "all"));
427 assert_ne!(all, build(true, true, true, "exclude"));
428 }
429
430 #[test]
431 fn audit_cache_key_bytes_include_materialized_context() {
432 let missing = context();
433 let present = AuditMaterializedContextFingerprint {
434 lockfiles: vec![AuditContextFileFingerprint {
435 path: "pnpm-lock.yaml".to_string(),
436 state: AuditContextPathState::Present,
437 source: Some(SourceFingerprint::new(1, 10)),
438 content_hash: Some("lock-hash".to_string()),
439 }],
440 directories: Vec::new(),
441 };
442 let build = |materialized_context| {
443 AuditCacheKeyBuilder::new(
444 5,
445 "1.0.0",
446 "base",
447 config(),
448 vec!["src/a.ts".to_string()],
449 materialized_context,
450 )
451 .to_json_bytes()
452 .expect("payload should serialize")
453 };
454
455 let missing_json = build(missing);
456 let present_json = build(present);
457
458 assert_ne!(missing_json, present_json);
459 assert!(
460 String::from_utf8(present_json)
461 .expect("utf8 json")
462 .contains("pnpm-lock.yaml")
463 );
464 }
465}