1use std::io::Write;
36use std::path::{Path, PathBuf};
37
38pub const FORMAT: &str = "amont-bypass-v1";
41
42const LEDGER: &str = "amont-bypasses";
44
45const MAX_BYTES: u64 = 64 * 1024;
48
49const KEEP: usize = 500;
53
54#[derive(Debug, Default, Clone, PartialEq, Eq)]
57pub struct Ledger {
58 pub total: usize,
60 pub last: Option<u64>,
62 pub by_script: Vec<ScriptCount>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct ScriptCount {
70 pub script: String,
71 pub count: usize,
72 pub last: u64,
74}
75
76fn event(line: &str) -> Option<(u64, &str, &str)> {
81 let mut fields = line.split_whitespace();
82 let (Some(epoch), Some(oid), Some(script), None) =
83 (fields.next(), fields.next(), fields.next(), fields.next())
84 else {
85 return None;
86 };
87 let epoch = epoch.parse::<u64>().ok()?;
88 if !(7..=64).contains(&oid.len()) || !oid.bytes().all(|b| b.is_ascii_hexdigit()) {
89 return None;
90 }
91 if !(1..=32).contains(&script.len()) || !script.bytes().all(|b| b.is_ascii_graphic()) {
92 return None;
93 }
94 Some((epoch, oid, script))
95}
96
97pub fn parse(text: &str) -> Ledger {
100 let mut lines = text.lines().filter(|l| !l.trim().is_empty());
101 if lines.next() != Some(FORMAT) {
102 return Ledger::default();
103 }
104 let mut out = Ledger::default();
105 for line in lines {
106 let Some((epoch, _oid, script)) = event(line) else {
107 continue;
108 };
109 out.total += 1;
110 out.last = Some(out.last.map_or(epoch, |l| l.max(epoch)));
111 match out.by_script.iter_mut().find(|s| s.script == script) {
112 Some(s) => {
113 s.count += 1;
114 s.last = s.last.max(epoch);
115 }
116 None => out.by_script.push(ScriptCount {
117 script: script.to_string(),
118 count: 1,
119 last: epoch,
120 }),
121 }
122 }
123 out.by_script
124 .sort_by(|a, b| b.count.cmp(&a.count).then(a.script.cmp(&b.script)));
125 out
126}
127
128pub fn read_at(common_dir: &Path) -> Ledger {
131 read_file(&common_dir.join(LEDGER))
132}
133
134pub fn read() -> Ledger {
137 ledger_path().map(|p| read_file(&p)).unwrap_or_default()
138}
139
140fn read_file(path: &Path) -> Ledger {
141 std::fs::read_to_string(path)
142 .map(|t| parse(&t))
143 .unwrap_or_default()
144}
145
146pub fn age(now: u64, then: u64) -> String {
150 let d = now.saturating_sub(then);
151 if d < 60 {
152 "just now".to_string()
153 } else if d < 3600 {
154 format!("{}m ago", d / 60)
155 } else if d < 86_400 {
156 format!("{}h ago", d / 3600)
157 } else if d < 7 * 86_400 {
158 format!("{}d ago", d / 86_400)
159 } else if d < 365 * 86_400 {
160 format!("{}w ago", d / (7 * 86_400))
161 } else {
162 format!("{}y ago", d / (365 * 86_400))
163 }
164}
165
166pub(crate) fn note_unverified(
176 settings: &crate::config::Settings,
177 manifest: &crate::manifest::Manifest,
178 stamped: &[String],
179) {
180 let names = crate::hooks::run_tests::gate_names_declared(&manifest.externals);
181 if names.is_empty() {
182 return;
183 }
184 if names.iter().all(|n| stamped.iter().any(|s| s == n)) {
185 return;
186 }
187 let declared = crate::hooks::run_tests::blocking_commit_decls(settings, &manifest.externals);
192 let missing: Vec<_> = declared
193 .iter()
194 .filter(|d| !stamped.contains(&d.script))
195 .collect();
196 if missing.is_empty() {
197 return;
198 }
199 if !crate::config::boolean_or(settings, "amont.recordBypasses", true) {
200 return;
201 }
202 let files = head_files();
203 if files.is_empty() {
204 return; }
206 let scripts: Vec<&str> = missing
207 .iter()
208 .filter(|d| d.scope.matches(&files))
209 .map(|d| d.script.as_str())
210 .collect();
211 if scripts.is_empty() {
212 return;
213 }
214 let Some(oid) = crate::git::stdout(&["rev-parse", "HEAD"]) else {
215 return;
216 };
217 let Some(path) = ledger_path() else { return };
218 append(&path, &oid, &scripts);
219}
220
221fn head_files() -> Vec<String> {
226 crate::git::stdout_paths(&[
227 "diff-tree",
228 "--no-commit-id",
229 "--name-only",
230 "-r",
231 "-m",
232 "--root",
233 "HEAD",
234 ])
235 .unwrap_or_default()
236}
237
238fn ledger_path() -> Option<PathBuf> {
240 let dir = crate::git::stdout(&["rev-parse", "--path-format=absolute", "--git-common-dir"])?;
241 Some(Path::new(&dir).join(LEDGER))
242}
243
244fn append(path: &Path, commit: &str, scripts: &[&str]) {
248 let _ = std::fs::OpenOptions::new()
251 .write(true)
252 .create_new(true)
253 .open(path)
254 .and_then(|mut f| f.write_all(format!("{FORMAT}\n").as_bytes()));
255 compact_if_large(path);
256 let now = now_epoch();
257 let mut body = String::new();
258 for script in scripts {
259 body.push_str(&format!("{now} {commit} {script}\n"));
260 }
261 let _ = std::fs::OpenOptions::new()
264 .create(true)
265 .append(true)
266 .open(path)
267 .and_then(|mut f| f.write_all(body.as_bytes()));
268}
269
270fn compact_if_large(path: &Path) {
274 let Ok(meta) = std::fs::metadata(path) else {
275 return;
276 };
277 if meta.len() <= MAX_BYTES {
278 return;
279 }
280 let Ok(text) = std::fs::read_to_string(path) else {
281 return;
282 };
283 let events: Vec<&str> = text.lines().filter(|l| event(l).is_some()).collect();
284 let keep = &events[events.len().saturating_sub(KEEP)..];
285 let mut body = String::with_capacity(keep.len() * 64 + FORMAT.len() + 1);
286 body.push_str(FORMAT);
287 body.push('\n');
288 for line in keep {
289 body.push_str(line);
290 body.push('\n');
291 }
292 let tmp = path.with_file_name(format!("{LEDGER}.tmp-{}", std::process::id()));
293 if std::fs::write(&tmp, body).is_ok() {
294 let _ = std::fs::rename(&tmp, path);
295 }
296}
297
298fn now_epoch() -> u64 {
299 std::time::SystemTime::now()
300 .duration_since(std::time::UNIX_EPOCH)
301 .map(|d| d.as_secs())
302 .unwrap_or_default()
303}
304
305pub fn forget() -> bool {
307 ledger_path().is_some_and(|path| std::fs::remove_file(&path).is_ok())
308}
309
310pub fn forget_in(repo: &Path) -> bool {
315 let Some(dir) = crate::git::stdout_in(
316 repo,
317 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
318 ) else {
319 return false;
320 };
321 std::fs::remove_file(Path::new(&dir).join(LEDGER)).is_ok()
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 fn ledger(events: &[&str]) -> String {
329 let mut s = format!("{FORMAT}\n");
330 for e in events {
331 s.push_str(e);
332 s.push('\n');
333 }
334 s
335 }
336
337 #[test]
339 fn a_ledger_without_the_header_is_ignored() {
340 assert_eq!(parse("100 abcdef0 typecheck\n"), Ledger::default());
341 assert_eq!(parse(""), Ledger::default());
342 }
343
344 #[test]
346 fn a_ledger_in_an_unknown_format_version_reads_as_empty() {
347 assert_eq!(
348 parse("amont-bypass-v2\n100 abcdef0 typecheck\n"),
349 Ledger::default()
350 );
351 }
352
353 #[test]
355 fn malformed_lines_are_skipped_and_the_rest_still_counted() {
356 let text = ledger(&[
357 "100 abcdef0 typecheck",
358 "not an event line",
359 "101 abcdef0", "102 abcdef0 test extra", "103 nothexg typecheck", "104 abc typecheck", "105 abcdef0 test",
364 ]);
365 let l = parse(&text);
366 assert_eq!(l.total, 2);
367 assert_eq!(l.last, Some(105));
368 }
369
370 #[test]
373 fn a_script_name_with_a_control_byte_is_rejected() {
374 let text = ledger(&["100 abcdef0 type\u{1b}check"]);
375 assert_eq!(parse(&text).total, 0);
376 }
377
378 #[test]
381 fn counts_group_by_script_and_keep_the_latest_timestamp() {
382 let text = ledger(&[
383 "100 aaaaaaa typecheck",
384 "200 bbbbbbb test",
385 "300 ccccccc typecheck",
386 ]);
387 let l = parse(&text);
388 assert_eq!(l.total, 3);
389 assert_eq!(l.last, Some(300));
390 assert_eq!(l.by_script.len(), 2);
391 assert_eq!(l.by_script[0].script, "typecheck");
392 assert_eq!(l.by_script[0].count, 2);
393 assert_eq!(l.by_script[0].last, 300);
394 assert_eq!(l.by_script[1].script, "test");
395 assert_eq!(l.by_script[1].last, 200);
396 }
397
398 #[test]
401 fn an_absent_ledger_reads_as_empty() {
402 let dir = std::env::temp_dir().join(format!("amont-bypass-none-{}", std::process::id()));
403 let _ = std::fs::create_dir_all(&dir);
404 assert_eq!(read_at(&dir), Ledger::default());
405 let _ = std::fs::remove_dir_all(&dir);
406 }
407
408 #[test]
411 fn compaction_keeps_the_header_and_the_newest_events() {
412 let dir = std::env::temp_dir().join(format!("amont-bypass-compact-{}", std::process::id()));
413 let _ = std::fs::create_dir_all(&dir);
414 let path = dir.join(LEDGER);
415 let mut body = format!("{FORMAT}\n");
416 for i in 0..2_600u64 {
418 body.push_str(&format!("{i} abcdef0123456789 typecheck\n"));
419 }
420 std::fs::write(&path, body).unwrap();
421 compact_if_large(&path);
422 let text = std::fs::read_to_string(&path).unwrap();
423 assert!(text.starts_with(FORMAT));
424 let l = parse(&text);
425 assert_eq!(l.total, KEEP);
426 assert_eq!(l.last, Some(2_599), "the newest events survive");
427 let _ = std::fs::remove_dir_all(&dir);
428 }
429
430 #[test]
433 fn appending_twice_writes_exactly_one_header() {
434 let dir = std::env::temp_dir().join(format!("amont-bypass-append-{}", std::process::id()));
435 let _ = std::fs::create_dir_all(&dir);
436 let path = dir.join(LEDGER);
437 append(&path, "abcdef0123456789", &["typecheck"]);
438 append(&path, "abcdef0123456789", &["test"]);
439 let text = std::fs::read_to_string(&path).unwrap();
440 assert_eq!(text.matches(FORMAT).count(), 1, "{text:?}");
441 assert_eq!(parse(&text).total, 2);
442 let _ = std::fs::remove_dir_all(&dir);
443 }
444
445 #[test]
447 fn age_reads_in_the_largest_unit_that_fits() {
448 assert_eq!(age(1000, 990), "just now");
449 assert_eq!(age(1000 + 120, 1000), "2m ago");
450 assert_eq!(age(1000 + 2 * 3600, 1000), "2h ago");
451 assert_eq!(age(1000 + 3 * 86_400, 1000), "3d ago");
452 assert_eq!(age(1000 + 20 * 86_400, 1000), "2w ago");
453 assert_eq!(age(1000 + 800 * 86_400, 1000), "2y ago");
454 }
455
456 #[test]
459 fn a_timestamp_from_the_future_does_not_underflow() {
460 assert_eq!(age(100, 200), "just now");
461 }
462}