1use std::io::Write;
46use std::path::{Path, PathBuf};
47
48pub const FORMAT: &str = "amont-downgrade-v1";
51
52const LEDGER: &str = "amont-downgrades";
54
55const MAX_BYTES: u64 = 256 * 1024;
64
65const KEEP: usize = 2_000;
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum Origin {
78 Config,
80 Policy,
82 Declared,
84}
85
86impl Origin {
87 pub fn as_str(self) -> &'static str {
88 match self {
89 Origin::Config => "config",
90 Origin::Policy => "policy",
91 Origin::Declared => "declared",
92 }
93 }
94
95 fn parse(s: &str) -> Option<Origin> {
96 match s {
97 "config" => Some(Origin::Config),
98 "policy" => Some(Origin::Policy),
99 "declared" => Some(Origin::Declared),
100 _ => None,
101 }
102 }
103
104 pub fn would_block(self) -> bool {
106 matches!(self, Origin::Config | Origin::Policy)
107 }
108}
109
110#[derive(Debug, Default, Clone, PartialEq, Eq)]
113pub struct Ledger {
114 pub total: usize,
116 pub would_block: usize,
118 pub commits: usize,
125 pub first: Option<u64>,
127 pub last: Option<u64>,
129 pub by_check: Vec<CheckCount>,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct CheckCount {
137 pub check: String,
138 pub count: usize,
139 pub would_block: usize,
141 pub last: u64,
143}
144
145fn event(line: &str) -> Option<(u64, &str, &str, Origin)> {
150 let mut fields = line.split_whitespace();
151 let (Some(epoch), Some(oid), Some(check), Some(origin), None) = (
152 fields.next(),
153 fields.next(),
154 fields.next(),
155 fields.next(),
156 fields.next(),
157 ) else {
158 return None;
159 };
160 let epoch = epoch.parse::<u64>().ok()?;
161 if !(7..=64).contains(&oid.len()) || !oid.bytes().all(|b| b.is_ascii_hexdigit()) {
162 return None;
163 }
164 if !(1..=64).contains(&check.len()) || !check.bytes().all(|b| b.is_ascii_graphic()) {
167 return None;
168 }
169 Some((epoch, oid, check, Origin::parse(origin)?))
170}
171
172pub fn parse(text: &str) -> Ledger {
175 let mut lines = text.lines().filter(|l| !l.trim().is_empty());
176 if lines.next() != Some(FORMAT) {
177 return Ledger::default();
178 }
179 let mut out = Ledger::default();
180 let mut oids: Vec<&str> = Vec::new();
181 for line in lines {
182 let Some((epoch, oid, check, origin)) = event(line) else {
183 continue;
184 };
185 out.total += 1;
186 if origin.would_block() {
187 out.would_block += 1;
188 }
189 out.first = Some(out.first.map_or(epoch, |f| f.min(epoch)));
190 out.last = Some(out.last.map_or(epoch, |l| l.max(epoch)));
191 if !oids.contains(&oid) {
192 oids.push(oid);
193 }
194 match out.by_check.iter_mut().find(|c| c.check == check) {
195 Some(c) => {
196 c.count += 1;
197 c.would_block += usize::from(origin.would_block());
198 c.last = c.last.max(epoch);
199 }
200 None => out.by_check.push(CheckCount {
201 check: check.to_string(),
202 count: 1,
203 would_block: usize::from(origin.would_block()),
204 last: epoch,
205 }),
206 }
207 }
208 out.commits = oids.len();
209 out.by_check
210 .sort_by(|a, b| b.count.cmp(&a.count).then(a.check.cmp(&b.check)));
211 out
212}
213
214pub fn read_at(common_dir: &Path) -> Ledger {
217 read_file(&common_dir.join(LEDGER))
218}
219
220pub fn read() -> Ledger {
223 ledger_path().map(|p| read_file(&p)).unwrap_or_default()
224}
225
226fn read_file(path: &Path) -> Ledger {
227 std::fs::read_to_string(path)
228 .map(|t| parse(&t))
229 .unwrap_or_default()
230}
231
232pub(crate) fn note(settings: &crate::config::Settings, events: &[(String, Origin)]) {
242 if events.is_empty() {
243 return;
244 }
245 if !crate::config::boolean_or(settings, "amont.recordDowngrades", true) {
246 return;
247 }
248 let oid = crate::git::stdout(&["rev-parse", "HEAD"]).unwrap_or_else(|| "0000000".into());
253 let Some(path) = ledger_path() else { return };
254 append(&path, &oid, events);
255}
256
257fn ledger_path() -> Option<PathBuf> {
259 let dir = crate::git::stdout(&["rev-parse", "--path-format=absolute", "--git-common-dir"])?;
260 Some(Path::new(&dir).join(LEDGER))
261}
262
263fn append(path: &Path, commit: &str, events: &[(String, Origin)]) {
266 let _ = std::fs::OpenOptions::new()
269 .write(true)
270 .create_new(true)
271 .open(path)
272 .and_then(|mut f| f.write_all(format!("{FORMAT}\n").as_bytes()));
273 compact_if_large(path);
274 let now = now_epoch();
275 let mut body = String::new();
276 for (check, origin) in events {
277 body.push_str(&format!("{now} {commit} {check} {}\n", origin.as_str()));
278 }
279 let _ = std::fs::OpenOptions::new()
282 .create(true)
283 .append(true)
284 .open(path)
285 .and_then(|mut f| f.write_all(body.as_bytes()));
286}
287
288fn compact_if_large(path: &Path) {
292 let Ok(meta) = std::fs::metadata(path) else {
293 return;
294 };
295 if meta.len() <= MAX_BYTES {
296 return;
297 }
298 let Ok(text) = std::fs::read_to_string(path) else {
299 return;
300 };
301 let events: Vec<&str> = text.lines().filter(|l| event(l).is_some()).collect();
302 let keep = &events[events.len().saturating_sub(KEEP)..];
303 let mut body = String::with_capacity(keep.len() * 64 + FORMAT.len() + 1);
304 body.push_str(FORMAT);
305 body.push('\n');
306 for line in keep {
307 body.push_str(line);
308 body.push('\n');
309 }
310 let tmp = path.with_file_name(format!("{LEDGER}.tmp-{}", std::process::id()));
311 if std::fs::write(&tmp, body).is_ok() {
312 let _ = std::fs::rename(&tmp, path);
313 }
314}
315
316fn now_epoch() -> u64 {
317 std::time::SystemTime::now()
318 .duration_since(std::time::UNIX_EPOCH)
319 .map(|d| d.as_secs())
320 .unwrap_or_default()
321}
322
323pub fn forget() -> bool {
325 ledger_path().is_some_and(|path| std::fs::remove_file(&path).is_ok())
326}
327
328pub fn forget_in(repo: &Path) -> bool {
331 let Some(dir) = crate::git::stdout_in(
332 repo,
333 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
334 ) else {
335 return false;
336 };
337 std::fs::remove_file(Path::new(&dir).join(LEDGER)).is_ok()
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 fn ledger(events: &[&str]) -> String {
345 let mut s = format!("{FORMAT}\n");
346 for e in events {
347 s.push_str(e);
348 s.push('\n');
349 }
350 s
351 }
352
353 #[test]
355 fn a_ledger_without_the_header_is_ignored() {
356 assert_eq!(
357 parse("100 abcdef0 pre-commit-x config\n"),
358 Ledger::default()
359 );
360 assert_eq!(parse(""), Ledger::default());
361 }
362
363 #[test]
365 fn a_ledger_in_an_unknown_format_version_reads_as_empty() {
366 assert_eq!(
367 parse("amont-downgrade-v2\n100 abcdef0 pre-commit-x config\n"),
368 Ledger::default()
369 );
370 }
371
372 #[test]
374 fn malformed_lines_are_skipped_and_the_rest_still_counted() {
375 let text = ledger(&[
376 "100 abcdef0 pre-commit-x config",
377 "not an event line",
378 "101 abcdef0 pre-commit-x", "102 abcdef0 pre-commit-x config more", "103 nothexg pre-commit-x config", "104 abc pre-commit-x config", "105 abcdef0 pre-commit-x sideways", "106 abcdef0 pre-commit-x policy",
384 ]);
385 let l = parse(&text);
386 assert_eq!(l.total, 2);
387 assert_eq!(l.last, Some(106));
388 }
389
390 #[test]
393 fn a_check_name_with_a_control_byte_is_rejected() {
394 assert_eq!(
395 parse(&ledger(&["100 abcdef0 pre\u{1b}commit config"])).total,
396 0
397 );
398 }
399
400 #[test]
403 fn counts_group_by_check_and_keep_the_latest_timestamp() {
404 let text = ledger(&[
405 "100 aaaaaaa pre-commit-usual-name config",
406 "200 bbbbbbb pre-commit-ban-terms config",
407 "300 ccccccc pre-commit-usual-name config",
408 ]);
409 let l = parse(&text);
410 assert_eq!(l.total, 3);
411 assert_eq!(l.first, Some(100));
412 assert_eq!(l.last, Some(300));
413 assert_eq!(l.by_check.len(), 2);
414 assert_eq!(l.by_check[0].check, "pre-commit-usual-name");
415 assert_eq!(l.by_check[0].count, 2);
416 assert_eq!(l.by_check[0].last, 300);
417 assert_eq!(l.by_check[1].check, "pre-commit-ban-terms");
418 }
419
420 #[test]
425 fn distinct_commits_are_counted_separately_from_events() {
426 let one = ledger(&[
427 "100 aaaaaaa pre-commit-usual-name config",
428 "101 aaaaaaa pre-commit-usual-name config",
429 "102 aaaaaaa pre-commit-usual-name config",
430 ]);
431 let l = parse(&one);
432 assert_eq!((l.total, l.commits), (3, 1));
433
434 let many = ledger(&[
435 "100 aaaaaaa pre-commit-usual-name config",
436 "101 bbbbbbb pre-commit-usual-name config",
437 "102 ccccccc pre-commit-usual-name config",
438 ]);
439 let l = parse(&many);
440 assert_eq!((l.total, l.commits), (3, 3));
441 }
442
443 #[test]
447 fn only_overridden_checks_count_as_would_have_blocked() {
448 let text = ledger(&[
449 "100 aaaaaaa pre-commit-ban-terms config",
450 "101 aaaaaaa pre-commit-secrets policy",
451 "102 aaaaaaa pre-commit-agents-md declared",
452 ]);
453 let l = parse(&text);
454 assert_eq!(l.total, 3);
455 assert_eq!(l.would_block, 2, "the declared-warn check is not evidence");
456 let declared = l
457 .by_check
458 .iter()
459 .find(|c| c.check == "pre-commit-agents-md")
460 .unwrap();
461 assert_eq!((declared.count, declared.would_block), (1, 0));
462 }
463
464 #[test]
467 fn an_absent_ledger_reads_as_empty() {
468 let dir = std::env::temp_dir().join(format!("amont-downgrade-none-{}", std::process::id()));
469 let _ = std::fs::create_dir_all(&dir);
470 assert_eq!(read_at(&dir), Ledger::default());
471 let _ = std::fs::remove_dir_all(&dir);
472 }
473
474 #[test]
477 fn compaction_keeps_the_header_and_the_newest_events() {
478 let dir =
479 std::env::temp_dir().join(format!("amont-downgrade-compact-{}", std::process::id()));
480 let _ = std::fs::create_dir_all(&dir);
481 let path = dir.join(LEDGER);
482 let mut body = format!("{FORMAT}\n");
483 for i in 0..6_000u64 {
485 body.push_str(&format!(
486 "{i} abcdef0123456789 pre-commit-ban-terms config\n"
487 ));
488 }
489 std::fs::write(&path, body).unwrap();
490 compact_if_large(&path);
491 let text = std::fs::read_to_string(&path).unwrap();
492 assert!(text.starts_with(FORMAT));
493 let l = parse(&text);
494 assert_eq!(l.total, KEEP);
495 assert_eq!(l.last, Some(5_999), "the newest events survive");
496 let _ = std::fs::remove_dir_all(&dir);
497 }
498
499 #[test]
503 fn the_ceiling_survives_a_fortnight_of_trial_volume() {
504 let per_event = "1756300000 abcdef0123456789 pre-commit-usual-name config\n".len() as u64;
505 let fortnight = 5 * 20 * 14; assert!(
507 per_event * fortnight < MAX_BYTES,
508 "{fortnight} events of {per_event} bytes must fit under {MAX_BYTES}"
509 );
510 }
511
512 #[test]
515 fn appending_twice_writes_exactly_one_header() {
516 let dir =
517 std::env::temp_dir().join(format!("amont-downgrade-append-{}", std::process::id()));
518 let _ = std::fs::create_dir_all(&dir);
519 let path = dir.join(LEDGER);
520 append(
521 &path,
522 "abcdef0123456789",
523 &[("pre-commit-ban-terms".into(), Origin::Config)],
524 );
525 append(
526 &path,
527 "abcdef0123456789",
528 &[("pre-commit-secrets".into(), Origin::Declared)],
529 );
530 let text = std::fs::read_to_string(&path).unwrap();
531 assert_eq!(text.matches(FORMAT).count(), 1, "{text:?}");
532 let l = parse(&text);
533 assert_eq!((l.total, l.would_block, l.commits), (2, 1, 1));
534 let _ = std::fs::remove_dir_all(&dir);
535 }
536
537 #[test]
539 fn origins_round_trip() {
540 for o in [Origin::Config, Origin::Policy, Origin::Declared] {
541 assert_eq!(Origin::parse(o.as_str()), Some(o));
542 }
543 assert_eq!(Origin::parse("warn"), None);
544 }
545}