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 max_crap: Option<f64>,
121 pub coverage: Option<AuditCoverageFingerprint>,
123 pub coverage_root: Option<String>,
125 pub css: bool,
127 pub css_deep: bool,
129 pub dead_code_baseline: Option<String>,
131 pub health_baseline: Option<String>,
133 pub dupes_baseline: Option<String>,
135}
136
137#[derive(Debug, Clone)]
139pub struct AuditCacheKeyBuilder {
140 payload: AuditCacheKeyPayload,
141}
142
143impl AuditCacheKeyBuilder {
144 #[must_use]
146 pub fn new(
147 cache_version: u8,
148 cli_version: impl Into<String>,
149 base_sha: impl Into<String>,
150 config_file: AuditConfigFingerprint,
151 changed_files: Vec<String>,
152 materialized_context: AuditMaterializedContextFingerprint,
153 ) -> Self {
154 Self {
155 payload: AuditCacheKeyPayload {
156 cache_version,
157 cli_version: cli_version.into(),
158 base_sha: base_sha.into(),
159 config_file,
160 changed_files,
161 materialized_context,
162 production: false,
163 production_dead_code: None,
164 production_health: None,
165 production_dupes: None,
166 workspace: None,
167 changed_workspaces: None,
168 group_by: None,
169 include_entry_exports: false,
170 show_cascade: false,
171 max_crap: None,
172 coverage: None,
173 coverage_root: None,
174 css: false,
175 css_deep: false,
176 dead_code_baseline: None,
177 health_baseline: None,
178 dupes_baseline: None,
179 },
180 }
181 }
182
183 #[must_use]
185 pub const fn production(
186 mut self,
187 production: bool,
188 dead_code: Option<bool>,
189 health: Option<bool>,
190 dupes: Option<bool>,
191 ) -> Self {
192 self.payload.production = production;
193 self.payload.production_dead_code = dead_code;
194 self.payload.production_health = health;
195 self.payload.production_dupes = dupes;
196 self
197 }
198
199 #[must_use]
201 pub fn scope(
202 mut self,
203 workspace: Option<Vec<String>>,
204 changed_workspaces: Option<String>,
205 group_by: Option<String>,
206 include_entry_exports: bool,
207 ) -> Self {
208 self.payload.workspace = workspace;
209 self.payload.changed_workspaces = changed_workspaces;
210 self.payload.group_by = group_by;
211 self.payload.include_entry_exports = include_entry_exports;
212 self
213 }
214
215 #[must_use]
218 pub const fn show_cascade(mut self, show_cascade: bool) -> Self {
219 self.payload.show_cascade = show_cascade;
220 self
221 }
222
223 #[must_use]
225 pub fn health(
226 mut self,
227 max_crap: Option<f64>,
228 coverage: Option<AuditCoverageFingerprint>,
229 coverage_root: Option<String>,
230 ) -> Self {
231 self.payload.max_crap = max_crap;
232 self.payload.coverage = coverage;
233 self.payload.coverage_root = coverage_root;
234 self
235 }
236
237 #[must_use]
239 pub const fn styling(mut self, css: bool, css_deep: bool) -> Self {
240 self.payload.css = css;
241 self.payload.css_deep = css_deep;
242 self
243 }
244
245 #[must_use]
247 pub fn baselines(
248 mut self,
249 dead_code: Option<String>,
250 health: Option<String>,
251 dupes: Option<String>,
252 ) -> Self {
253 self.payload.dead_code_baseline = dead_code;
254 self.payload.health_baseline = health;
255 self.payload.dupes_baseline = dupes;
256 self
257 }
258
259 #[must_use]
261 pub const fn payload(&self) -> &AuditCacheKeyPayload {
262 &self.payload
263 }
264
265 pub fn to_json_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
271 serde_json::to_vec(&self.payload)
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278
279 fn config() -> AuditConfigFingerprint {
280 AuditConfigFingerprint {
281 path: Some("fallow.toml".to_string()),
282 resolved_hash: Some("abc".to_string()),
283 }
284 }
285
286 fn context() -> AuditMaterializedContextFingerprint {
287 AuditMaterializedContextFingerprint {
288 lockfiles: Vec::new(),
289 directories: Vec::new(),
290 }
291 }
292
293 #[test]
294 fn audit_cache_key_builder_preserves_typed_fields() {
295 let coverage = AuditCoverageFingerprint {
296 path: "coverage".to_string(),
297 resolved_path: "coverage/coverage-final.json".to_string(),
298 source: Some(SourceFingerprint::new(12, 34)),
299 content_hash: Some("hash".to_string()),
300 len: Some(34),
301 error: None,
302 };
303
304 let builder = AuditCacheKeyBuilder::new(
305 3,
306 "1.2.3",
307 "abc123",
308 config(),
309 vec!["src/a.ts".to_string()],
310 context(),
311 )
312 .production(true, Some(false), Some(true), None)
313 .scope(
314 Some(vec!["web".to_string()]),
315 Some("main".to_string()),
316 Some("Package".to_string()),
317 true,
318 )
319 .health(Some(42.0), Some(coverage), Some("/workspace".to_string()))
320 .styling(true, true)
321 .baselines(
322 Some("dead.json".to_string()),
323 Some("health.json".to_string()),
324 Some("dupes.json".to_string()),
325 );
326
327 let payload = builder.payload();
328 assert_eq!(payload.cache_version, 3);
329 assert_eq!(payload.base_sha, "abc123");
330 assert_eq!(payload.workspace.as_deref(), Some(&["web".to_string()][..]));
331 assert!(payload.include_entry_exports);
332 assert!(payload.css);
333 assert!(payload.css_deep);
334 assert_eq!(
335 payload.coverage.as_ref().and_then(|c| c.source),
336 Some(SourceFingerprint::new(12, 34))
337 );
338 }
339
340 #[test]
341 fn audit_cache_key_bytes_reflect_changed_file_order() {
342 let first = AuditCacheKeyBuilder::new(
343 1,
344 "1.0.0",
345 "base",
346 config(),
347 vec!["src/a.ts".to_string(), "src/b.ts".to_string()],
348 context(),
349 )
350 .to_json_bytes()
351 .expect("payload should serialize");
352 let second = AuditCacheKeyBuilder::new(
353 1,
354 "1.0.0",
355 "base",
356 config(),
357 vec!["src/b.ts".to_string(), "src/a.ts".to_string()],
358 context(),
359 )
360 .to_json_bytes()
361 .expect("payload should serialize");
362
363 assert_ne!(first, second);
364 }
365
366 #[test]
367 fn audit_cache_key_bytes_include_materialized_context() {
368 let missing = context();
369 let present = AuditMaterializedContextFingerprint {
370 lockfiles: vec![AuditContextFileFingerprint {
371 path: "pnpm-lock.yaml".to_string(),
372 state: AuditContextPathState::Present,
373 source: Some(SourceFingerprint::new(1, 10)),
374 content_hash: Some("lock-hash".to_string()),
375 }],
376 directories: Vec::new(),
377 };
378 let build = |materialized_context| {
379 AuditCacheKeyBuilder::new(
380 5,
381 "1.0.0",
382 "base",
383 config(),
384 vec!["src/a.ts".to_string()],
385 materialized_context,
386 )
387 .to_json_bytes()
388 .expect("payload should serialize")
389 };
390
391 let missing_json = build(missing);
392 let present_json = build(present);
393
394 assert_ne!(missing_json, present_json);
395 assert!(
396 String::from_utf8(present_json)
397 .expect("utf8 json")
398 .contains("pnpm-lock.yaml")
399 );
400 }
401}