use std::io::Write;
use std::path::{Path, PathBuf};
pub const FORMAT: &str = "amont-bypass-v1";
const LEDGER: &str = "amont-bypasses";
const MAX_BYTES: u64 = 64 * 1024;
const KEEP: usize = 500;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Ledger {
pub total: usize,
pub last: Option<u64>,
pub by_script: Vec<ScriptCount>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScriptCount {
pub script: String,
pub count: usize,
pub last: u64,
}
fn event(line: &str) -> Option<(u64, &str, &str)> {
let mut fields = line.split_whitespace();
let (Some(epoch), Some(oid), Some(script), None) =
(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..=32).contains(&script.len()) || !script.bytes().all(|b| b.is_ascii_graphic()) {
return None;
}
Some((epoch, oid, script))
}
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();
for line in lines {
let Some((epoch, _oid, script)) = event(line) else {
continue;
};
out.total += 1;
out.last = Some(out.last.map_or(epoch, |l| l.max(epoch)));
match out.by_script.iter_mut().find(|s| s.script == script) {
Some(s) => {
s.count += 1;
s.last = s.last.max(epoch);
}
None => out.by_script.push(ScriptCount {
script: script.to_string(),
count: 1,
last: epoch,
}),
}
}
out.by_script
.sort_by(|a, b| b.count.cmp(&a.count).then(a.script.cmp(&b.script)));
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 fn age(now: u64, then: u64) -> String {
let d = now.saturating_sub(then);
if d < 60 {
"just now".to_string()
} else if d < 3600 {
format!("{}m ago", d / 60)
} else if d < 86_400 {
format!("{}h ago", d / 3600)
} else if d < 7 * 86_400 {
format!("{}d ago", d / 86_400)
} else if d < 365 * 86_400 {
format!("{}w ago", d / (7 * 86_400))
} else {
format!("{}y ago", d / (365 * 86_400))
}
}
pub(crate) fn note_unverified(manifest: &crate::manifest::Manifest, stamped: &[String]) {
let names = crate::hooks::run_tests::gate_names_declared(&manifest.externals);
if names.is_empty() {
return;
}
if names.iter().all(|n| stamped.iter().any(|s| s == n)) {
return;
}
let declared = crate::hooks::run_tests::gated_at_commit(&manifest.externals);
let missing: Vec<_> = declared
.iter()
.filter(|d| !stamped.iter().any(|s| s == d.script))
.collect();
if missing.is_empty() {
return;
}
if !crate::config::boolean_or("amont.recordBypasses", true) {
return;
}
let files = head_files();
if files.is_empty() {
return; }
let scripts: Vec<&str> = missing
.iter()
.filter(|d| d.scope.matches(&files))
.map(|d| d.script)
.collect();
if scripts.is_empty() {
return;
}
let Some(oid) = crate::git::stdout(&["rev-parse", "HEAD"]) else {
return;
};
let Some(path) = ledger_path() else { return };
append(&path, &oid, &scripts);
}
fn head_files() -> Vec<String> {
crate::git::stdout_paths(&[
"diff-tree",
"--no-commit-id",
"--name-only",
"-r",
"-m",
"--root",
"HEAD",
])
.unwrap_or_default()
}
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, scripts: &[&str]) {
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 script in scripts {
body.push_str(&format!("{now} {commit} {script}\n"));
}
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() {
if let Some(path) = ledger_path() {
let _ = std::fs::remove_file(&path);
}
}
#[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 typecheck\n"), Ledger::default());
assert_eq!(parse(""), Ledger::default());
}
#[test]
fn a_ledger_in_an_unknown_format_version_reads_as_empty() {
assert_eq!(
parse("amont-bypass-v2\n100 abcdef0 typecheck\n"),
Ledger::default()
);
}
#[test]
fn malformed_lines_are_skipped_and_the_rest_still_counted() {
let text = ledger(&[
"100 abcdef0 typecheck",
"not an event line",
"101 abcdef0", "102 abcdef0 test extra", "103 nothexg typecheck", "104 abc typecheck", "105 abcdef0 test",
]);
let l = parse(&text);
assert_eq!(l.total, 2);
assert_eq!(l.last, Some(105));
}
#[test]
fn a_script_name_with_a_control_byte_is_rejected() {
let text = ledger(&["100 abcdef0 type\u{1b}check"]);
assert_eq!(parse(&text).total, 0);
}
#[test]
fn counts_group_by_script_and_keep_the_latest_timestamp() {
let text = ledger(&[
"100 aaaaaaa typecheck",
"200 bbbbbbb test",
"300 ccccccc typecheck",
]);
let l = parse(&text);
assert_eq!(l.total, 3);
assert_eq!(l.last, Some(300));
assert_eq!(l.by_script.len(), 2);
assert_eq!(l.by_script[0].script, "typecheck");
assert_eq!(l.by_script[0].count, 2);
assert_eq!(l.by_script[0].last, 300);
assert_eq!(l.by_script[1].script, "test");
assert_eq!(l.by_script[1].last, 200);
}
#[test]
fn an_absent_ledger_reads_as_empty() {
let dir = std::env::temp_dir().join(format!("amont-bypass-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-bypass-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..2_600u64 {
body.push_str(&format!("{i} abcdef0123456789 typecheck\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(2_599), "the newest events survive");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn appending_twice_writes_exactly_one_header() {
let dir = std::env::temp_dir().join(format!("amont-bypass-append-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let path = dir.join(LEDGER);
append(&path, "abcdef0123456789", &["typecheck"]);
append(&path, "abcdef0123456789", &["test"]);
let text = std::fs::read_to_string(&path).unwrap();
assert_eq!(text.matches(FORMAT).count(), 1, "{text:?}");
assert_eq!(parse(&text).total, 2);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn age_reads_in_the_largest_unit_that_fits() {
assert_eq!(age(1000, 990), "just now");
assert_eq!(age(1000 + 120, 1000), "2m ago");
assert_eq!(age(1000 + 2 * 3600, 1000), "2h ago");
assert_eq!(age(1000 + 3 * 86_400, 1000), "3d ago");
assert_eq!(age(1000 + 20 * 86_400, 1000), "2w ago");
assert_eq!(age(1000 + 800 * 86_400, 1000), "2y ago");
}
#[test]
fn a_timestamp_from_the_future_does_not_underflow() {
assert_eq!(age(100, 200), "just now");
}
}