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