1use lanekeep_core::fact::Fact;
22use lanekeep_core::fix::Fix;
23use lanekeep_core::suppression::{Date, Scope, Suppression};
24use lanekeep_core::tracked::{ContentHash, TrackedRead};
25use lanekeep_core::{FilePath, Location, Position, RuleId, Severity, Violation};
26
27#[derive(Debug, Clone, PartialEq, Eq, Default)]
29pub struct Entry {
30 pub violations: Vec<Violation>,
32 pub facts: Vec<Fact>,
34 pub dependencies: Vec<TrackedRead>,
36
37 pub suppressions: Vec<Suppression>,
43
44 pub used_suppressions: Vec<u32>,
50}
51
52impl Entry {
53 pub fn encode(&self, out: &mut Vec<u8>) {
55 write_len(out, self.violations.len());
56 for violation in &self.violations {
57 write_str(out, &violation.rule_id.to_string());
58 write_str(out, violation.location.file.as_str());
59 out.extend_from_slice(&violation.location.position.line.to_le_bytes());
60 out.extend_from_slice(&violation.location.position.column.to_le_bytes());
61 write_str(out, &violation.message);
62 write_str(out, &violation.remediation);
63 out.push(severity_code(violation.severity));
64 match &violation.fix {
65 Some(fix) => {
66 out.push(1);
67 write_len(out, fix.start);
68 write_len(out, fix.end);
69 write_str(out, &fix.replacement);
70 out.push(u8::from(fix.safe));
71 }
72 None => out.push(0),
73 }
74 }
75
76 write_len(out, self.facts.len());
77 for fact in &self.facts {
78 write_str(out, &fact.rule_id.to_string());
79 write_str(out, fact.file.as_str());
80 write_str(out, &fact.kind);
81 write_str(out, &fact.data);
82 out.extend_from_slice(&fact.sequence.to_le_bytes());
83 }
84
85 write_len(out, self.suppressions.len());
86 for suppression in &self.suppressions {
87 out.push(match suppression.scope {
88 Scope::NextLine => 0,
89 Scope::File => 1,
90 });
91 out.extend_from_slice(&suppression.line.to_le_bytes());
92 out.extend_from_slice(&suppression.column.to_le_bytes());
93 write_str(out, &suppression.reason);
94
95 write_len(out, suppression.rules.len());
96 for rule in &suppression.rules {
97 write_str(out, &rule.to_string());
98 }
99
100 match suppression.expires {
101 Some(date) => {
102 out.push(1);
103 out.extend_from_slice(&date.year.to_le_bytes());
104 out.push(date.month);
105 out.push(date.day);
106 }
107 None => out.push(0),
108 }
109 }
110
111 write_len(out, self.used_suppressions.len());
112 for index in &self.used_suppressions {
113 out.extend_from_slice(&index.to_le_bytes());
114 }
115
116 write_len(out, self.dependencies.len());
117 for read in &self.dependencies {
118 write_str(out, read.path.as_str());
119 match read.hash {
120 Some(hash) => {
124 out.push(1);
125 out.extend_from_slice(hash.as_bytes());
126 }
127 None => out.push(0),
128 }
129 }
130 }
131
132 #[must_use]
134 pub fn decode(bytes: &[u8]) -> Option<Self> {
135 let mut cursor = Cursor::new(bytes);
136
137 let mut violations = Vec::with_capacity(cursor.peek_len()?);
138 for _ in 0..cursor.read_len()? {
139 let rule_id = cursor.read_str()?.parse::<RuleId>().ok()?;
140 let file = FilePath::new(cursor.read_str()?);
141 let line = cursor.read_u32()?;
142 let column = cursor.read_u32()?;
143 violations.push(Violation {
144 rule_id,
145 location: Location::new(file, Position::new(line, column)),
146 message: cursor.read_str()?.to_owned(),
147 remediation: cursor.read_str()?.to_owned(),
148 severity: severity_from(cursor.read_u8()?)?,
149 fix: match cursor.read_u8()? {
150 0 => None,
151 1 => Some(Fix {
152 start: cursor.read_len()?,
153 end: cursor.read_len()?,
154 replacement: cursor.read_str()?.to_owned(),
155 safe: match cursor.read_u8()? {
156 0 => false,
157 1 => true,
158 _ => return None,
159 },
160 }),
161 _ => return None,
162 },
163 });
164 }
165
166 let mut facts = Vec::with_capacity(cursor.peek_len()?);
167 for _ in 0..cursor.read_len()? {
168 facts.push(Fact {
169 rule_id: cursor.read_str()?.parse::<RuleId>().ok()?,
170 file: FilePath::new(cursor.read_str()?),
171 kind: cursor.read_str()?.to_owned(),
172 data: cursor.read_str()?.to_owned(),
173 sequence: cursor.read_u32()?,
174 });
175 }
176
177 let mut suppressions = Vec::with_capacity(cursor.peek_len()?);
178 for _ in 0..cursor.read_len()? {
179 let scope = match cursor.read_u8()? {
180 0 => Scope::NextLine,
181 1 => Scope::File,
182 _ => return None,
183 };
184 let line = cursor.read_u32()?;
185 let column = cursor.read_u32()?;
186 let reason = cursor.read_str()?.to_owned();
187
188 let mut rules = Vec::with_capacity(cursor.peek_len()?);
189 for _ in 0..cursor.read_len()? {
190 rules.push(cursor.read_str()?.parse().ok()?);
191 }
192
193 let expires = match cursor.read_u8()? {
194 0 => None,
195 1 => Some(Date {
196 year: u16::from_le_bytes(cursor.take(2)?.try_into().ok()?),
197 month: cursor.read_u8()?,
198 day: cursor.read_u8()?,
199 }),
200 _ => return None,
201 };
202
203 suppressions.push(Suppression {
204 scope,
205 rules,
206 reason,
207 expires,
208 line,
209 column,
210 });
211 }
212
213 let mut used_suppressions = Vec::with_capacity(cursor.peek_len()?);
214 for _ in 0..cursor.read_len()? {
215 let index = cursor.read_u32()?;
216 if index as usize >= suppressions.len() {
219 return None;
220 }
221 used_suppressions.push(index);
222 }
223
224 let mut dependencies = Vec::with_capacity(cursor.peek_len()?);
225 for _ in 0..cursor.read_len()? {
226 let path = FilePath::new(cursor.read_str()?);
227 let hash = match cursor.read_u8()? {
228 0 => None,
229 1 => Some(ContentHash::new(cursor.read_hash()?)),
230 _ => return None,
231 };
232 dependencies.push(TrackedRead { path, hash });
233 }
234
235 cursor.finished().then_some(Self {
238 violations,
239 facts,
240 dependencies,
241 suppressions,
242 used_suppressions,
243 })
244 }
245}
246
247struct Cursor<'a> {
249 bytes: &'a [u8],
250 at: usize,
251}
252
253impl<'a> Cursor<'a> {
254 const fn new(bytes: &'a [u8]) -> Self {
255 Self { bytes, at: 0 }
256 }
257
258 fn take(&mut self, count: usize) -> Option<&'a [u8]> {
259 let end = self.at.checked_add(count)?;
260 let slice = self.bytes.get(self.at..end)?;
261 self.at = end;
262 Some(slice)
263 }
264
265 fn read_u8(&mut self) -> Option<u8> {
266 self.take(1).map(|b| b[0])
267 }
268
269 fn read_u32(&mut self) -> Option<u32> {
270 let bytes: [u8; 4] = self.take(4)?.try_into().ok()?;
271 Some(u32::from_le_bytes(bytes))
272 }
273
274 fn read_hash(&mut self) -> Option<[u8; 32]> {
275 self.take(32)?.try_into().ok()
276 }
277
278 fn read_len(&mut self) -> Option<usize> {
279 self.read_u32().map(|n| n as usize)
280 }
281
282 fn peek_len(&self) -> Option<usize> {
288 let bytes: [u8; 4] = self.bytes.get(self.at..self.at + 4)?.try_into().ok()?;
289 Some((u32::from_le_bytes(bytes) as usize).min(1024))
290 }
291
292 fn read_str(&mut self) -> Option<&'a str> {
293 let len = self.read_len()?;
294 std::str::from_utf8(self.take(len)?).ok()
295 }
296
297 const fn finished(&self) -> bool {
298 self.at == self.bytes.len()
299 }
300}
301
302fn write_len(out: &mut Vec<u8>, len: usize) {
303 out.extend_from_slice(&u32::try_from(len).unwrap_or(u32::MAX).to_le_bytes());
306}
307
308fn write_str(out: &mut Vec<u8>, text: &str) {
309 write_len(out, text.len());
310 out.extend_from_slice(text.as_bytes());
311}
312
313const fn severity_code(severity: Severity) -> u8 {
318 match severity {
319 Severity::Off => 0,
320 Severity::Warn => 1,
321 Severity::Error => 2,
322 }
323}
324
325const fn severity_from(code: u8) -> Option<Severity> {
326 match code {
327 0 => Some(Severity::Off),
328 1 => Some(Severity::Warn),
329 2 => Some(Severity::Error),
330 _ => None,
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 fn violation(rule: &str, file: &str, line: u32) -> Violation {
339 Violation {
340 rule_id: rule.parse().expect("valid id"),
341 location: Location::new(FilePath::new(file), Position::new(line, 1)),
342 message: "a message".to_owned(),
343 remediation: "a remediation".to_owned(),
344 severity: Severity::Error,
345 fix: None,
346 }
347 }
348
349 fn fact(rule: &str, file: &str, sequence: u32) -> Fact {
350 Fact {
351 rule_id: rule.parse().expect("valid id"),
352 file: FilePath::new(file),
353 kind: "export".to_owned(),
354 data: r#"{"kind":"export","symbol":"x"}"#.to_owned(),
355 sequence,
356 }
357 }
358
359 fn populated() -> Entry {
360 Entry {
361 violations: vec![
362 violation("local/a", "src/a.ts", 1),
363 violation("lanekeep/no-default-export", "src/a.ts", 9),
364 ],
365 facts: vec![
366 fact("local/a", "src/a.ts", 0),
367 fact("local/a", "src/a.ts", 1),
368 ],
369 dependencies: vec![
370 TrackedRead::found(FilePath::new("package.json"), ContentHash::new([7; 32])),
371 TrackedRead::absent(FilePath::new("tsconfig.json")),
372 ],
373 suppressions: vec![
374 Suppression {
375 scope: Scope::NextLine,
376 rules: vec!["local/a".parse().expect("valid id")],
377 reason: "legacy".to_owned(),
378 expires: None,
379 line: 4,
380 column: 3,
381 },
382 Suppression {
383 scope: Scope::File,
384 rules: vec![
385 "local/a".parse().expect("valid id"),
386 "lanekeep/no-default-export".parse().expect("valid id"),
387 ],
388 reason: "generated".to_owned(),
389 expires: Some(Date {
390 year: 2026,
391 month: 12,
392 day: 31,
393 }),
394 line: 1,
395 column: 1,
396 },
397 ],
398 used_suppressions: vec![1],
399 }
400 }
401
402 #[test]
403 fn a_fix_survives_a_round_trip() {
404 let mut entry = Entry::default();
405 let mut with_fix = violation("local/a", "src/a.ts", 1);
406 with_fix.fix = Some(Fix {
407 start: 4,
408 end: 9,
409 replacement: "let".to_owned(),
410 safe: true,
411 });
412 entry.violations.push(with_fix.clone());
413 entry.violations.push(violation("local/b", "src/a.ts", 2));
414
415 let decoded = round_trip(&entry).expect("decodes");
416 assert_eq!(decoded.violations[0].fix, with_fix.fix);
417 assert_eq!(decoded.violations[1].fix, None, "absence survives too");
418 }
419
420 #[test]
421 fn a_suggestion_does_not_come_back_as_safe() {
422 let mut entry = Entry::default();
424 let mut suggested = violation("local/a", "src/a.ts", 1);
425 suggested.fix = Some(Fix {
426 start: 0,
427 end: 1,
428 replacement: "x".to_owned(),
429 safe: false,
430 });
431 entry.violations.push(suggested);
432
433 let decoded = round_trip(&entry).expect("decodes");
434 assert_eq!(
435 decoded.violations[0].fix.as_ref().map(|f| f.safe),
436 Some(false)
437 );
438 }
439
440 #[test]
441 fn which_suppressions_were_used_survives_a_round_trip() {
442 let entry = populated();
445 assert_eq!(
446 round_trip(&entry).expect("decodes").used_suppressions,
447 entry.used_suppressions
448 );
449 }
450
451 #[test]
452 fn a_used_index_past_the_end_is_refused() {
453 let mut entry = populated();
454 entry.used_suppressions = vec![99];
455 let mut bytes = Vec::new();
456 entry.encode(&mut bytes);
457 assert_eq!(Entry::decode(&bytes), None);
458 }
459
460 #[test]
461 fn suppressions_survive_a_round_trip() {
462 let entry = populated();
466 let decoded = round_trip(&entry).expect("decodes");
467 assert_eq!(decoded.suppressions, entry.suppressions);
468 }
469
470 #[test]
471 fn an_expiry_survives_as_an_expiry() {
472 let entry = populated();
473 let decoded = round_trip(&entry).expect("decodes");
474 assert_eq!(decoded.suppressions[0].expires, None);
475 assert_eq!(
476 decoded.suppressions[1].expires,
477 Some(Date {
478 year: 2026,
479 month: 12,
480 day: 31
481 })
482 );
483 }
484
485 fn round_trip(entry: &Entry) -> Option<Entry> {
486 let mut bytes = Vec::new();
487 entry.encode(&mut bytes);
488 Entry::decode(&bytes)
489 }
490
491 #[test]
492 fn a_populated_entry_survives_a_round_trip() {
493 let entry = populated();
494 assert_eq!(round_trip(&entry).as_ref(), Some(&entry));
495 }
496
497 #[test]
498 fn an_empty_entry_survives_a_round_trip() {
499 let entry = Entry::default();
500 assert_eq!(round_trip(&entry).as_ref(), Some(&entry));
501 }
502
503 #[test]
504 fn absence_survives_as_absence() {
505 let entry = Entry {
508 dependencies: vec![TrackedRead::absent(FilePath::new("tsconfig.json"))],
509 ..Entry::default()
510 };
511 let decoded = round_trip(&entry).expect("decodes");
512 assert_eq!(decoded.dependencies[0].hash, None);
513 }
514
515 #[test]
516 fn every_severity_survives() {
517 for severity in [Severity::Off, Severity::Warn, Severity::Error] {
518 let mut entry = Entry::default();
519 let mut v = violation("local/a", "src/a.ts", 1);
520 v.severity = severity;
521 entry.violations.push(v);
522 assert_eq!(
523 round_trip(&entry).expect("decodes").violations[0].severity,
524 severity
525 );
526 }
527 }
528
529 #[test]
530 fn non_ascii_text_survives() {
531 let mut entry = Entry::default();
534 let mut v = violation("local/a", "src/a.ts", 1);
535 v.message = "circular import: a → b → a".to_owned();
536 v.remediation = "casse le cycle — extrais le partagé".to_owned();
537 entry.violations.push(v);
538 assert_eq!(round_trip(&entry).as_ref(), Some(&entry));
539 }
540
541 #[test]
542 fn a_truncated_entry_decodes_to_nothing() {
543 let entry = populated();
545 let mut bytes = Vec::new();
546 entry.encode(&mut bytes);
547
548 for cut in 0..bytes.len() {
549 assert_eq!(
550 Entry::decode(&bytes[..cut]),
551 None,
552 "a {cut}-byte prefix decoded as an entry"
553 );
554 }
555 }
556
557 #[test]
558 fn trailing_bytes_are_refused() {
559 let mut bytes = Vec::new();
561 populated().encode(&mut bytes);
562 bytes.push(0);
563 assert_eq!(Entry::decode(&bytes), None);
564 }
565
566 #[test]
567 fn a_corrupt_entry_never_panics() {
568 let mut bytes = Vec::new();
571 populated().encode(&mut bytes);
572
573 for index in 0..bytes.len() {
574 for bit in 0..8u32 {
575 let mut corrupt = bytes.clone();
576 corrupt[index] ^= 1 << bit;
577 let _ = Entry::decode(&corrupt);
580 }
581 }
582 }
583
584 #[test]
585 fn an_absurd_count_does_not_allocate_absurdly() {
586 let mut bytes = u32::MAX.to_le_bytes().to_vec();
588 bytes.extend_from_slice(&[0; 8]);
589 assert_eq!(Entry::decode(&bytes), None);
590 }
591
592 #[test]
593 fn a_bad_severity_code_is_refused() {
594 let mut bytes = Vec::new();
595 Entry {
596 violations: vec![violation("local/a", "src/a.ts", 1)],
597 ..Entry::default()
598 }
599 .encode(&mut bytes);
600
601 let last = bytes.len() - 1;
602 bytes[last] = 9;
603 assert_eq!(Entry::decode(&bytes), None);
604 }
605
606 #[test]
607 fn a_bad_rule_id_is_refused() {
608 let mut bytes = Vec::new();
611 write_len(&mut bytes, 1);
612 write_str(&mut bytes, "bare-id");
613 write_str(&mut bytes, "src/a.ts");
614 bytes.extend_from_slice(&1u32.to_le_bytes());
615 bytes.extend_from_slice(&1u32.to_le_bytes());
616 write_str(&mut bytes, "m");
617 write_str(&mut bytes, "r");
618 bytes.push(2);
619 write_len(&mut bytes, 0);
620 write_len(&mut bytes, 0);
621
622 assert_eq!(Entry::decode(&bytes), None);
623 }
624
625 #[test]
626 fn invalid_utf8_is_refused() {
627 let mut bytes = Vec::new();
628 write_len(&mut bytes, 1);
629 write_len(&mut bytes, 2);
630 bytes.extend_from_slice(&[0xff, 0xfe]);
631 assert_eq!(Entry::decode(&bytes), None);
632 }
633}