use std::io::Write;
use std::path::{Path, PathBuf};
pub const FORMAT: &str = "amont-downgrade-v1";
const LEDGER: &str = "amont-downgrades";
const MAX_BYTES: u64 = 256 * 1024;
const KEEP: usize = 2_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Origin {
Config,
Policy,
Declared,
}
impl Origin {
pub fn as_str(self) -> &'static str {
match self {
Origin::Config => "config",
Origin::Policy => "policy",
Origin::Declared => "declared",
}
}
fn parse(s: &str) -> Option<Origin> {
match s {
"config" => Some(Origin::Config),
"policy" => Some(Origin::Policy),
"declared" => Some(Origin::Declared),
_ => None,
}
}
pub fn would_block(self) -> bool {
matches!(self, Origin::Config | Origin::Policy)
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Ledger {
pub total: usize,
pub would_block: usize,
pub commits: usize,
pub first: Option<u64>,
pub last: Option<u64>,
pub by_check: Vec<CheckCount>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckCount {
pub check: String,
pub count: usize,
pub would_block: usize,
pub last: u64,
}
fn event(line: &str) -> Option<(u64, &str, &str, Origin)> {
let mut fields = line.split_whitespace();
let (Some(epoch), Some(oid), Some(check), Some(origin), None) = (
fields.next(),
fields.next(),
fields.next(),
fields.next(),
fields.next(),
) else {
return None;
};
let epoch = epoch.parse::<u64>().ok()?;
if !(7..=64).contains(&oid.len()) || !oid.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
if !(1..=64).contains(&check.len()) || !check.bytes().all(|b| b.is_ascii_graphic()) {
return None;
}
Some((epoch, oid, check, Origin::parse(origin)?))
}
pub fn parse(text: &str) -> Ledger {
let mut lines = text.lines().filter(|l| !l.trim().is_empty());
if lines.next() != Some(FORMAT) {
return Ledger::default();
}
let mut out = Ledger::default();
let mut oids: Vec<&str> = Vec::new();
for line in lines {
let Some((epoch, oid, check, origin)) = event(line) else {
continue;
};
out.total += 1;
if origin.would_block() {
out.would_block += 1;
}
out.first = Some(out.first.map_or(epoch, |f| f.min(epoch)));
out.last = Some(out.last.map_or(epoch, |l| l.max(epoch)));
if !oids.contains(&oid) {
oids.push(oid);
}
match out.by_check.iter_mut().find(|c| c.check == check) {
Some(c) => {
c.count += 1;
c.would_block += usize::from(origin.would_block());
c.last = c.last.max(epoch);
}
None => out.by_check.push(CheckCount {
check: check.to_string(),
count: 1,
would_block: usize::from(origin.would_block()),
last: epoch,
}),
}
}
out.commits = oids.len();
out.by_check
.sort_by(|a, b| b.count.cmp(&a.count).then(a.check.cmp(&b.check)));
out
}
pub fn read_at(common_dir: &Path) -> Ledger {
read_file(&common_dir.join(LEDGER))
}
pub fn read() -> Ledger {
ledger_path().map(|p| read_file(&p)).unwrap_or_default()
}
fn read_file(path: &Path) -> Ledger {
std::fs::read_to_string(path)
.map(|t| parse(&t))
.unwrap_or_default()
}
pub(crate) fn note(settings: &crate::config::Settings, events: &[(String, Origin)]) {
if events.is_empty() {
return;
}
if !crate::config::boolean_or(settings, "amont.recordDowngrades", true) {
return;
}
let oid = crate::git::stdout(&["rev-parse", "HEAD"]).unwrap_or_else(|| "0000000".into());
let Some(path) = ledger_path() else { return };
append(&path, &oid, events);
}
fn ledger_path() -> Option<PathBuf> {
let dir = crate::git::stdout(&["rev-parse", "--path-format=absolute", "--git-common-dir"])?;
Some(Path::new(&dir).join(LEDGER))
}
fn append(path: &Path, commit: &str, events: &[(String, Origin)]) {
let _ = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.and_then(|mut f| f.write_all(format!("{FORMAT}\n").as_bytes()));
compact_if_large(path);
let now = now_epoch();
let mut body = String::new();
for (check, origin) in events {
body.push_str(&format!("{now} {commit} {check} {}\n", origin.as_str()));
}
let _ = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.and_then(|mut f| f.write_all(body.as_bytes()));
}
fn compact_if_large(path: &Path) {
let Ok(meta) = std::fs::metadata(path) else {
return;
};
if meta.len() <= MAX_BYTES {
return;
}
let Ok(text) = std::fs::read_to_string(path) else {
return;
};
let events: Vec<&str> = text.lines().filter(|l| event(l).is_some()).collect();
let keep = &events[events.len().saturating_sub(KEEP)..];
let mut body = String::with_capacity(keep.len() * 64 + FORMAT.len() + 1);
body.push_str(FORMAT);
body.push('\n');
for line in keep {
body.push_str(line);
body.push('\n');
}
let tmp = path.with_file_name(format!("{LEDGER}.tmp-{}", std::process::id()));
if std::fs::write(&tmp, body).is_ok() {
let _ = std::fs::rename(&tmp, path);
}
}
fn now_epoch() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default()
}
pub fn forget() -> bool {
ledger_path().is_some_and(|path| std::fs::remove_file(&path).is_ok())
}
pub fn forget_in(repo: &Path) -> bool {
let Some(dir) = crate::git::stdout_in(
repo,
&["rev-parse", "--path-format=absolute", "--git-common-dir"],
) else {
return false;
};
std::fs::remove_file(Path::new(&dir).join(LEDGER)).is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn ledger(events: &[&str]) -> String {
let mut s = format!("{FORMAT}\n");
for e in events {
s.push_str(e);
s.push('\n');
}
s
}
#[test]
fn a_ledger_without_the_header_is_ignored() {
assert_eq!(
parse("100 abcdef0 pre-commit-x config\n"),
Ledger::default()
);
assert_eq!(parse(""), Ledger::default());
}
#[test]
fn a_ledger_in_an_unknown_format_version_reads_as_empty() {
assert_eq!(
parse("amont-downgrade-v2\n100 abcdef0 pre-commit-x config\n"),
Ledger::default()
);
}
#[test]
fn malformed_lines_are_skipped_and_the_rest_still_counted() {
let text = ledger(&[
"100 abcdef0 pre-commit-x config",
"not an event line",
"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",
]);
let l = parse(&text);
assert_eq!(l.total, 2);
assert_eq!(l.last, Some(106));
}
#[test]
fn a_check_name_with_a_control_byte_is_rejected() {
assert_eq!(
parse(&ledger(&["100 abcdef0 pre\u{1b}commit config"])).total,
0
);
}
#[test]
fn counts_group_by_check_and_keep_the_latest_timestamp() {
let text = ledger(&[
"100 aaaaaaa pre-commit-usual-name config",
"200 bbbbbbb pre-commit-ban-terms config",
"300 ccccccc pre-commit-usual-name config",
]);
let l = parse(&text);
assert_eq!(l.total, 3);
assert_eq!(l.first, Some(100));
assert_eq!(l.last, Some(300));
assert_eq!(l.by_check.len(), 2);
assert_eq!(l.by_check[0].check, "pre-commit-usual-name");
assert_eq!(l.by_check[0].count, 2);
assert_eq!(l.by_check[0].last, 300);
assert_eq!(l.by_check[1].check, "pre-commit-ban-terms");
}
#[test]
fn distinct_commits_are_counted_separately_from_events() {
let one = ledger(&[
"100 aaaaaaa pre-commit-usual-name config",
"101 aaaaaaa pre-commit-usual-name config",
"102 aaaaaaa pre-commit-usual-name config",
]);
let l = parse(&one);
assert_eq!((l.total, l.commits), (3, 1));
let many = ledger(&[
"100 aaaaaaa pre-commit-usual-name config",
"101 bbbbbbb pre-commit-usual-name config",
"102 ccccccc pre-commit-usual-name config",
]);
let l = parse(&many);
assert_eq!((l.total, l.commits), (3, 3));
}
#[test]
fn only_overridden_checks_count_as_would_have_blocked() {
let text = ledger(&[
"100 aaaaaaa pre-commit-ban-terms config",
"101 aaaaaaa pre-commit-secrets policy",
"102 aaaaaaa pre-commit-agents-md declared",
]);
let l = parse(&text);
assert_eq!(l.total, 3);
assert_eq!(l.would_block, 2, "the declared-warn check is not evidence");
let declared = l
.by_check
.iter()
.find(|c| c.check == "pre-commit-agents-md")
.unwrap();
assert_eq!((declared.count, declared.would_block), (1, 0));
}
#[test]
fn an_absent_ledger_reads_as_empty() {
let dir = std::env::temp_dir().join(format!("amont-downgrade-none-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
assert_eq!(read_at(&dir), Ledger::default());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn compaction_keeps_the_header_and_the_newest_events() {
let dir =
std::env::temp_dir().join(format!("amont-downgrade-compact-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let path = dir.join(LEDGER);
let mut body = format!("{FORMAT}\n");
for i in 0..6_000u64 {
body.push_str(&format!(
"{i} abcdef0123456789 pre-commit-ban-terms config\n"
));
}
std::fs::write(&path, body).unwrap();
compact_if_large(&path);
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.starts_with(FORMAT));
let l = parse(&text);
assert_eq!(l.total, KEEP);
assert_eq!(l.last, Some(5_999), "the newest events survive");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_ceiling_survives_a_fortnight_of_trial_volume() {
let per_event = "1756300000 abcdef0123456789 pre-commit-usual-name config\n".len() as u64;
let fortnight = 5 * 20 * 14; assert!(
per_event * fortnight < MAX_BYTES,
"{fortnight} events of {per_event} bytes must fit under {MAX_BYTES}"
);
}
#[test]
fn appending_twice_writes_exactly_one_header() {
let dir =
std::env::temp_dir().join(format!("amont-downgrade-append-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let path = dir.join(LEDGER);
append(
&path,
"abcdef0123456789",
&[("pre-commit-ban-terms".into(), Origin::Config)],
);
append(
&path,
"abcdef0123456789",
&[("pre-commit-secrets".into(), Origin::Declared)],
);
let text = std::fs::read_to_string(&path).unwrap();
assert_eq!(text.matches(FORMAT).count(), 1, "{text:?}");
let l = parse(&text);
assert_eq!((l.total, l.would_block, l.commits), (2, 1, 1));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn origins_round_trip() {
for o in [Origin::Config, Origin::Policy, Origin::Declared] {
assert_eq!(Origin::parse(o.as_str()), Some(o));
}
assert_eq!(Origin::parse("warn"), None);
}
}