Skip to main content

omni_dev/github_metrics/
incremental.rs

1//! A running tally of the `gh` records appended to the request log since a
2//! fixed instant (#2132).
3//!
4//! [`aggregate`](super::aggregate) answers "what does this log say for this
5//! window" by reading every line, so its cost grows with the whole file — and
6//! the log is unbounded unless rotation or `prune` is opted into. The daemon
7//! asks one fixed question instead, "what has been logged since I started", of
8//! a log that is append-only. [`IncrementalCounts`] answers it by remembering
9//! where it stopped reading, so each refresh costs the records appended since
10//! the previous one, not the size of the file.
11//!
12//! The log can also stop being append-only: `omni-dev log prune` renames a
13//! rewritten file over it, size-capped rotation renames it away, and a user can
14//! truncate it. A cursor that outlives such a change would read from the wrong
15//! place and produce a silently wrong tally, so the cursor is trusted only while
16//! the file is demonstrably the one it was taken on (see [`Cursor::is_intact`]).
17//! Otherwise the tally is dropped and rebuilt by a windowed scan from the first
18//! byte, which is what [`aggregate`](super::aggregate) would report.
19
20use std::fs::{File, Metadata};
21use std::io::{self, BufRead, BufReader, Read, Seek, SeekFrom};
22use std::path::{Path, PathBuf};
23
24use chrono::{DateTime, Utc};
25
26use super::{Filter, GhCounts};
27use crate::request_log::LogRecord;
28
29/// How many bytes ending at the cursor are kept to recognise a file that was
30/// rewritten in place. Enough to tell one from the next in all but a contrived
31/// case, which [`Cursor::is_intact`] describes.
32const TAIL_LEN: usize = 64;
33
34/// Block size of the backward search for the last complete line at the baseline.
35const BACKSCAN_BLOCK: usize = 8 * 1024;
36
37/// How far back from the end of the log the baseline looks for a line boundary.
38/// A log's last line is a record, normally a few hundred bytes, so this is far
39/// more than needed; it only bounds the pathological case of a huge run of bytes
40/// with no newline, so that taking the baseline is cheap whatever the file holds.
41const BASELINE_SCAN_LIMIT: u64 = 4 * 1024 * 1024;
42
43/// Read buffer for the forward scan.
44const READ_BUFFER: usize = 64 * 1024;
45
46/// Identity of a file independent of its path: `(device, inode)` on unix. Other
47/// platforms have none, and [`Cursor::is_intact`]'s length and tail checks carry
48/// the whole burden there.
49type FileId = Option<(u64, u64)>;
50
51#[cfg(unix)]
52fn file_id(meta: &Metadata) -> FileId {
53    use std::os::unix::fs::MetadataExt;
54    Some((meta.dev(), meta.ino()))
55}
56
57#[cfg(not(unix))]
58fn file_id(_meta: &Metadata) -> FileId {
59    None
60}
61
62/// Where the tally has read up to in one particular log file.
63#[derive(Debug)]
64struct Cursor {
65    /// The file this position belongs to.
66    id: FileId,
67    /// Byte offset just past the last complete line consumed.
68    offset: u64,
69    /// Up to [`TAIL_LEN`] bytes ending at `offset`, i.e. the end of the last line
70    /// consumed.
71    tail: Vec<u8>,
72}
73
74impl Cursor {
75    /// A cursor at the first byte of the file identified by `id`.
76    fn start_of(id: FileId) -> Self {
77        Self {
78            id,
79            offset: 0,
80            tail: Vec::new(),
81        }
82    }
83
84    /// Whether `file` — `len` bytes long, identified by `id` — is still the file
85    /// this cursor was taken on, with everything before `offset` untouched.
86    ///
87    /// Three checks, because each misses a case the others catch: a different
88    /// file id (rotation, `prune`'s rename), a file shorter than the offset
89    /// (truncation), and different bytes where the last consumed line ended (a
90    /// file rewritten in place and regrown past the offset, or an inode reused
91    /// after its file was deleted).
92    ///
93    /// What it cannot see is a different file with the same id (any id, off
94    /// unix) that is at least as long as the offset and ends a line at exactly
95    /// that offset with the same last [`TAIL_LEN`] bytes. A record's unique id is
96    /// at its start, not its end, so this needs a rewrite that reproduces the old
97    /// line boundary — fixed-shape records in a log that was truncated and regrew
98    /// past the offset between two refreshes. The cost is then an undercount, the
99    /// records before the offset in the new file, never a double count.
100    fn is_intact(&self, file: &mut File, id: FileId, len: u64) -> io::Result<bool> {
101        if self.id != id || len < self.offset {
102            return Ok(false);
103        }
104        let mut seen = vec![0; self.tail.len()];
105        file.seek(SeekFrom::Start(self.offset - self.tail.len() as u64))?;
106        match file.read_exact(&mut seen) {
107            Ok(()) => Ok(seen == self.tail),
108            // The file shrank between the length check and the read.
109            Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => Ok(false),
110            Err(e) => Err(e),
111        }
112    }
113
114    /// Records that `line` — complete, newline-terminated — was consumed.
115    fn advance(&mut self, line: &[u8]) {
116        self.offset += line.len() as u64;
117        if line.len() >= TAIL_LEN {
118            self.tail.clear();
119            self.tail.extend_from_slice(&line[line.len() - TAIL_LEN..]);
120        } else {
121            self.tail.extend_from_slice(line);
122            let excess = self.tail.len().saturating_sub(TAIL_LEN);
123            self.tail.drain(..excess);
124        }
125    }
126
127    /// A cursor at the end of the last complete line of the log at `path`, or
128    /// `None` when there is no log to position in.
129    ///
130    /// Reads at most the final line and [`TAIL_LEN`] bytes, never the records
131    /// before it. The end of the *last complete line* rather than of the file: a
132    /// concurrent writer may be part-way through a line, and a cursor inside it
133    /// would start the next read mid-record.
134    ///
135    /// A log that exists but cannot be positioned in also yields `None`, which
136    /// makes the first refresh scan it from its first byte: correct, but the cost
137    /// this type exists to avoid, so it is worth a warning.
138    fn baseline(path: &Path) -> Option<Self> {
139        match Self::try_baseline(path) {
140            Ok(cursor) => cursor,
141            Err(e) => {
142                tracing::warn!(
143                    "github counters: cannot take a baseline of {}: {e}; the first summary \
144                     will scan the whole log",
145                    path.display()
146                );
147                None
148            }
149        }
150    }
151
152    fn try_baseline(path: &Path) -> io::Result<Option<Self>> {
153        let mut file = match File::open(path) {
154            Ok(file) => file,
155            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
156            Err(e) => return Err(e),
157        };
158        let meta = file.metadata()?;
159        let offset = end_of_last_line(&mut file, meta.len(), BASELINE_SCAN_LIMIT)?;
160        let tail = read_tail(&mut file, offset)?;
161        Ok(Some(Self {
162            id: file_id(&meta),
163            offset,
164            tail,
165        }))
166    }
167}
168
169/// The offset just past the last `\n` in the first `len` bytes of `file`. Scans
170/// backwards, so it reads only the final line, and gives up after `limit` bytes.
171///
172/// With no newline in the whole file the answer is 0, the start of its one
173/// partial line. With none in the last `limit` bytes it is `len`: a baseline
174/// inside a line that long, so the first read after it starts mid-record and
175/// skips that fragment as malformed. That loses at most the one record, which is
176/// the price of never reading more than `limit` bytes.
177fn end_of_last_line(file: &mut File, len: u64, limit: u64) -> io::Result<u64> {
178    let floor = len.saturating_sub(limit);
179    let mut block = [0u8; BACKSCAN_BLOCK];
180    let mut end = len;
181    while end > floor {
182        let start = end.saturating_sub(BACKSCAN_BLOCK as u64).max(floor);
183        let want = usize::try_from(end - start).unwrap_or(BACKSCAN_BLOCK);
184        let chunk = &mut block[..want];
185        file.seek(SeekFrom::Start(start))?;
186        file.read_exact(chunk)?;
187        if let Some(i) = chunk.iter().rposition(|&b| b == b'\n') {
188            return Ok(start + i as u64 + 1);
189        }
190        end = start;
191    }
192    Ok(if floor == 0 { 0 } else { len })
193}
194
195/// Up to [`TAIL_LEN`] bytes of `file` ending at `offset`.
196fn read_tail(file: &mut File, offset: u64) -> io::Result<Vec<u8>> {
197    let len = usize::try_from(offset).map_or(TAIL_LEN, |o| o.min(TAIL_LEN));
198    let mut tail = vec![0; len];
199    file.seek(SeekFrom::Start(offset - len as u64))?;
200    file.read_exact(&mut tail)?;
201    Ok(tail)
202}
203
204/// A running tally of the `gh` records logged since a fixed instant, refreshed
205/// by reading only what was appended since the previous refresh.
206///
207/// Equivalent to [`aggregate`](super::aggregate) over `[since, ∞)`, minus the
208/// cost: construction reads no records, and each [`refresh`](Self::refresh)
209/// reads only the complete lines appended after the last one. A line still being
210/// written (no trailing newline yet) is left for the next refresh, so a record is
211/// counted once, whole. That is the one way the two differ over the same bytes:
212/// `aggregate` counts a final line with no newline if it happens to parse, this
213/// waits for the newline.
214#[derive(Debug)]
215pub struct IncrementalCounts {
216    path: PathBuf,
217    since: DateTime<Utc>,
218    filter: Filter,
219    counts: GhCounts,
220    /// `None` until a log file has been seen.
221    cursor: Option<Cursor>,
222}
223
224impl IncrementalCounts {
225    /// Starts tallying the log at `path` from now: records already in it are
226    /// never read, and the tally begins at zero.
227    ///
228    /// The log's extent is taken *before* the clock is read. A record stamped at
229    /// or after [`since`](Self::since) was therefore appended after the baseline,
230    /// so the bytes the next refresh reads contain every record the window
231    /// admits. Reading them in the other order could skip a record stamped just
232    /// after the clock read but appended before the baseline.
233    pub fn start(path: PathBuf) -> Self {
234        let cursor = Cursor::baseline(&path);
235        Self::new(path, Utc::now(), cursor)
236    }
237
238    /// [`start`](Self::start) with the window opening at `since` instead of now,
239    /// so tests can stamp their records deterministically.
240    #[cfg(test)]
241    fn start_since(path: PathBuf, since: DateTime<Utc>) -> Self {
242        let cursor = Cursor::baseline(&path);
243        Self::new(path, since, cursor)
244    }
245
246    fn new(path: PathBuf, since: DateTime<Utc>, cursor: Option<Cursor>) -> Self {
247        Self {
248            path,
249            since,
250            filter: Filter {
251                since: Some(since),
252                until: None,
253                source: None,
254            },
255            counts: GhCounts::default(),
256            cursor,
257        }
258    }
259
260    /// The instant the window opened: records stamped before it are not counted.
261    pub fn since(&self) -> DateTime<Utc> {
262        self.since
263    }
264
265    /// The tally as of the last [`refresh`](Self::refresh).
266    pub fn counts(&self) -> &GhCounts {
267        &self.counts
268    }
269
270    /// Reads the records appended since the last call into the tally.
271    ///
272    /// `stop` is checked before every line; once it returns `true` the refresh
273    /// ends there, leaving the tally and cursor consistent at that line, so the
274    /// next refresh resumes without losing or repeating anything. It is how a
275    /// caller on a blocking thread — which cannot be cancelled from outside —
276    /// gives up on a long read, e.g. the rebuild after the log was replaced.
277    ///
278    /// Returns `true` when the tally is current as of the last complete line, and
279    /// `false` when it may be behind: `stop` fired, or the log could not be read
280    /// (logged at debug; best-effort like the rest of the log stack).
281    pub fn refresh(&mut self, mut stop: impl FnMut() -> bool) -> bool {
282        match self.try_refresh(&mut stop) {
283            Ok(complete) => complete,
284            Err(e) => {
285                tracing::debug!(
286                    "github counters: reading {} failed: {e}",
287                    self.path.display()
288                );
289                false
290            }
291        }
292    }
293
294    fn try_refresh(&mut self, stop: &mut impl FnMut() -> bool) -> io::Result<bool> {
295        let mut file = match File::open(&self.path) {
296            Ok(file) => file,
297            Err(e) if e.kind() == io::ErrorKind::NotFound => {
298                // No log, so nothing in it: `aggregate`'s answer for a missing
299                // file. A log created later is scanned from its first byte.
300                self.counts = GhCounts::default();
301                self.cursor = None;
302                return Ok(true);
303            }
304            Err(e) => return Err(e),
305        };
306        let meta = file.metadata()?;
307        let mut cursor = self.resume(&mut file, &meta);
308        let result = self.read_new_lines(&mut file, &mut cursor, stop);
309        // Keep the cursor whatever happened: it is consistent at the last line
310        // consumed, and a failed read should not cost a rebuild next time.
311        self.cursor = Some(cursor);
312        result
313    }
314
315    /// The cursor to read from: the stored one when `file` is demonstrably the
316    /// file it was taken on, otherwise a fresh one at the file's first byte with
317    /// the tally reset.
318    fn resume(&mut self, file: &mut File, meta: &Metadata) -> Cursor {
319        let id = file_id(meta);
320        if let Some(cursor) = self.cursor.take() {
321            match cursor.is_intact(file, id, meta.len()) {
322                Ok(true) => return cursor,
323                Ok(false) => {}
324                Err(e) => tracing::debug!(
325                    "github counters: cannot verify the position in {}, rescanning: {e}",
326                    self.path.display()
327                ),
328            }
329        }
330        // First sight of this file, or it was replaced, rotated, truncated or
331        // rewritten: what was tallied came from something else.
332        self.counts = GhCounts::default();
333        Cursor::start_of(id)
334    }
335
336    fn read_new_lines(
337        &mut self,
338        file: &mut File,
339        cursor: &mut Cursor,
340        stop: &mut impl FnMut() -> bool,
341    ) -> io::Result<bool> {
342        file.seek(SeekFrom::Start(cursor.offset))?;
343        let mut reader = BufReader::with_capacity(READ_BUFFER, file);
344        let mut line = Vec::new();
345        loop {
346            if stop() {
347                return Ok(false);
348            }
349            line.clear();
350            reader.read_until(b'\n', &mut line)?;
351            if line.last() != Some(&b'\n') {
352                // End of file, or a line still being written.
353                return Ok(true);
354            }
355            // A complete line that is not a record (malformed, blank) is skipped
356            // and consumed, as `aggregate` skips it.
357            if let Ok(rec) = serde_json::from_slice::<LogRecord>(&line) {
358                self.filter.tally(&mut self.counts, &rec);
359            }
360            cursor.advance(&line);
361        }
362    }
363
364    /// The cursor's offset, for tests that assert what was and was not read.
365    #[cfg(test)]
366    fn offset(&self) -> Option<u64> {
367        self.cursor.as_ref().map(|c| c.offset)
368    }
369}
370
371#[cfg(test)]
372#[allow(clippy::unwrap_used, clippy::expect_used)]
373mod tests {
374    use super::*;
375    use crate::github_metrics::aggregate;
376    use chrono::{Duration, SecondsFormat};
377    use std::io::Write;
378
379    fn since() -> DateTime<Utc> {
380        DateTime::parse_from_rfc3339("2026-07-21T10:00:00.000Z")
381            .unwrap()
382            .with_timezone(&Utc)
383    }
384
385    /// An RFC3339 stamp `secs` seconds after [`since`] (negative: before it).
386    fn stamp(secs: i64) -> String {
387        (since() + Duration::seconds(secs)).to_rfc3339_opts(SecondsFormat::Millis, true)
388    }
389
390    /// A `gh` record line, newline-terminated.
391    fn gh(secs: i64, command: &[&str], source: &str) -> String {
392        format!(
393            "{{\"kind\":\"gh\",\"timestamp\":\"{}\",\"command\":{},\"source\":\"{source}\"}}\n",
394            stamp(secs),
395            serde_json::to_string(command).unwrap(),
396        )
397    }
398
399    /// A log in a temp dir, addressed by path so tests can replace the file.
400    struct Log {
401        _dir: tempfile::TempDir,
402        path: PathBuf,
403    }
404
405    impl Log {
406        fn new() -> Self {
407            let dir = tempfile::tempdir().unwrap();
408            let path = dir.path().join("log.jsonl");
409            Self { _dir: dir, path }
410        }
411
412        /// Replaces the contents in place (same inode, truncated first).
413        fn write(&self, text: &str) {
414            std::fs::write(&self.path, text).unwrap();
415        }
416
417        fn append(&self, text: &str) {
418            let mut file = std::fs::OpenOptions::new()
419                .append(true)
420                .create(true)
421                .open(&self.path)
422                .unwrap();
423            file.write_all(text.as_bytes()).unwrap();
424        }
425
426        /// Replaces the file the way `prune` does: a new file renamed over it.
427        fn replace(&self, text: &str) {
428            let tmp = self.path.with_extension("tmp");
429            std::fs::write(&tmp, text).unwrap();
430            std::fs::rename(&tmp, &self.path).unwrap();
431        }
432
433        fn len(&self) -> u64 {
434            std::fs::metadata(&self.path).unwrap().len()
435        }
436
437        fn full_scan(&self) -> GhCounts {
438            aggregate(&self.path, Some(since()), None, None)
439        }
440
441        fn start(&self) -> IncrementalCounts {
442            IncrementalCounts::start_since(self.path.clone(), since())
443        }
444    }
445
446    fn never() -> bool {
447        false
448    }
449
450    #[test]
451    fn records_before_the_baseline_are_never_read() {
452        let log = Log::new();
453        // Valid `gh` records inside the window: a full scan counts all of them, so
454        // the incremental tally can only be empty by not having read them.
455        let pre: String = (0..5).map(|n| gh(10 + n, &["pr", "list"], "cli")).collect();
456        log.write(&pre);
457        assert_eq!(log.full_scan().total(), 5);
458
459        let mut tally = log.start();
460        assert!(tally.refresh(never));
461
462        assert_eq!(tally.counts().total(), 0);
463        assert_eq!(tally.offset(), Some(log.len()));
464    }
465
466    #[test]
467    fn appended_records_match_a_full_scan() {
468        let log = Log::new();
469        log.write(&gh(-100, &["pr", "list"], "cli")); // pre-boot
470        let mut tally = log.start();
471
472        log.append(&gh(1, &["api", "graphql"], "daemon"));
473        log.append(&gh(2, &["pr", "list"], "cli"));
474        log.append(&gh(3, &["--version"], "cli"));
475        log.append("{\"kind\":\"http\",\"timestamp\":\"2026-07-21T10:00:04.000Z\"}\n");
476        log.append("not json\n\n");
477        log.append(&gh(-5, &["pr", "list"], "cli")); // stamped before the window
478        log.append("{\"kind\":\"gh\",\"timestamp\":\"bad\",\"command\":[\"pr\"]}\n");
479        assert!(tally.refresh(never));
480        assert_eq!(tally.counts().total(), 3);
481        assert_eq!(*tally.counts(), log.full_scan());
482
483        log.append(&gh(6, &["api", "graphql"], "mcp"));
484        assert!(tally.refresh(never));
485        assert_eq!(tally.counts().total(), 4);
486        assert_eq!(*tally.counts(), log.full_scan());
487
488        // Nothing appended: nothing recounted.
489        assert!(tally.refresh(never));
490        assert_eq!(*tally.counts(), log.full_scan());
491        assert_eq!(tally.offset(), Some(log.len()));
492    }
493
494    #[test]
495    fn a_partial_trailing_line_waits_for_its_newline() {
496        let log = Log::new();
497        let mut tally = log.start();
498
499        let line = gh(1, &["pr", "list"], "cli");
500        let (head, rest) = line.split_at(line.len() / 2);
501        log.append(head);
502        assert!(tally.refresh(never));
503        assert_eq!(tally.counts().total(), 0);
504        assert_eq!(tally.offset(), Some(0));
505
506        log.append(rest);
507        assert!(tally.refresh(never));
508        assert_eq!(tally.counts().total(), 1);
509        assert_eq!(*tally.counts(), log.full_scan());
510
511        // A line that is complete JSON but not yet newline-terminated is still
512        // being written: it is counted once, when its newline arrives.
513        let line = gh(2, &["pr", "view"], "cli");
514        log.append(line.trim_end());
515        assert!(tally.refresh(never));
516        assert_eq!(tally.counts().total(), 1);
517        log.append("\n");
518        assert!(tally.refresh(never));
519        assert_eq!(tally.counts().total(), 2);
520        assert_eq!(*tally.counts(), log.full_scan());
521    }
522
523    #[test]
524    fn a_baseline_inside_a_line_starts_at_that_line() {
525        let log = Log::new();
526        let first = gh(-1, &["pr", "list"], "cli");
527        let second = gh(2, &["pr", "view"], "cli");
528        let (head, rest) = second.split_at(second.len() / 2);
529        log.write(&format!("{first}{head}"));
530
531        let mut tally = log.start();
532        assert_eq!(tally.offset(), Some(first.len() as u64));
533
534        // The record being written at the baseline is stamped inside the window
535        // and appended whole, so it must be counted; the one before it is not.
536        log.append(rest);
537        assert!(tally.refresh(never));
538        assert_eq!(tally.counts().total(), 1);
539        assert_eq!(tally.counts().by_subcommand["pr view"], 1);
540    }
541
542    #[test]
543    fn the_last_line_is_found_across_search_blocks() {
544        let log = Log::new();
545        let first = gh(1, &["pr", "list"], "cli");
546        let long = "x".repeat(3 * BACKSCAN_BLOCK + 17);
547        log.write(&format!("{first}{long}"));
548
549        let mut file = File::open(&log.path).unwrap();
550        assert_eq!(
551            end_of_last_line(&mut file, log.len(), u64::MAX).unwrap(),
552            first.len() as u64
553        );
554        assert_eq!(end_of_last_line(&mut file, 0, u64::MAX).unwrap(), 0);
555
556        log.write(&long); // no newline anywhere
557        let mut file = File::open(&log.path).unwrap();
558        assert_eq!(end_of_last_line(&mut file, log.len(), u64::MAX).unwrap(), 0);
559    }
560
561    #[test]
562    fn the_search_for_the_last_line_gives_up_at_its_limit() {
563        let log = Log::new();
564        let first = gh(1, &["pr", "list"], "cli");
565        let long = "x".repeat(3 * BACKSCAN_BLOCK + 17);
566        log.write(&format!("{first}{long}"));
567        let mut file = File::open(&log.path).unwrap();
568
569        // The newline is further back than the limit reaches: the baseline lands at
570        // the end, inside the long line, having read no more than the limit.
571        let limit = 2 * BACKSCAN_BLOCK as u64;
572        assert_eq!(
573            end_of_last_line(&mut file, log.len(), limit).unwrap(),
574            log.len()
575        );
576
577        // A limit that does reach it finds it, as does one longer than the file.
578        let reach = long.len() as u64 + 1;
579        assert_eq!(
580            end_of_last_line(&mut file, log.len(), reach).unwrap(),
581            first.len() as u64
582        );
583    }
584
585    #[test]
586    fn a_baseline_inside_a_huge_line_skips_that_fragment_and_keeps_counting() {
587        let log = Log::new();
588        // More than the baseline looks back over, with no newline in it.
589        let huge = "x".repeat(usize::try_from(BASELINE_SCAN_LIMIT).unwrap() + 1024);
590        log.write(&format!("{}{huge}", gh(1, &["pr", "list"], "cli")));
591
592        let mut tally = log.start();
593        assert_eq!(tally.offset(), Some(log.len()));
594
595        // The huge line is finished by its writer, and a real record follows it.
596        log.append("\n");
597        log.append(&gh(2, &["pr", "view"], "cli"));
598        assert!(tally.refresh(never));
599        assert_eq!(tally.counts().total(), 1);
600        assert_eq!(tally.counts().by_subcommand["pr view"], 1);
601    }
602
603    #[test]
604    fn a_replaced_log_is_rescanned_from_its_first_byte() {
605        let log = Log::new();
606        let mut tally = log.start();
607        log.append(&gh(1, &["pr", "list"], "cli"));
608        log.append(&gh(2, &["pr", "list"], "cli"));
609        log.append(&gh(3, &["pr", "list"], "cli"));
610        assert!(tally.refresh(never));
611        assert_eq!(tally.counts().total(), 3);
612
613        // What `prune` does: a rewritten file renamed over the log. It is *longer*
614        // than the stored offset, so only the file id can tell.
615        log.replace(&format!(
616            "{}{}{}",
617            gh(4, &["api", "graphql"], "daemon"),
618            gh(5, &["api", "graphql"], "daemon"),
619            gh(-9, &["pr", "list"], "cli"),
620        ));
621        assert!(tally.refresh(never));
622        assert_eq!(tally.counts().total(), 2);
623        assert_eq!(*tally.counts(), log.full_scan());
624    }
625
626    #[test]
627    fn a_rotated_log_is_followed_to_its_replacement() {
628        let log = Log::new();
629        let mut tally = log.start();
630        log.append(&gh(1, &["pr", "list"], "cli"));
631        assert!(tally.refresh(never));
632
633        // Size-capped rotation: the log is renamed away and a fresh one started.
634        std::fs::rename(&log.path, log.path.with_extension("jsonl.1")).unwrap();
635        log.append(&gh(2, &["api", "graphql"], "daemon"));
636        assert!(tally.refresh(never));
637        assert_eq!(*tally.counts(), log.full_scan());
638        assert_eq!(tally.counts().total(), 1);
639
640        // Rotated away and not yet recreated: no log, no counts.
641        std::fs::remove_file(&log.path).unwrap();
642        assert!(tally.refresh(never));
643        assert_eq!(*tally.counts(), GhCounts::default());
644        assert_eq!(tally.offset(), None);
645
646        log.append(&gh(3, &["pr", "view"], "cli"));
647        log.append(&gh(4, &["pr", "view"], "cli"));
648        assert!(tally.refresh(never));
649        assert_eq!(tally.counts().total(), 2);
650        assert_eq!(*tally.counts(), log.full_scan());
651    }
652
653    #[test]
654    fn a_log_truncated_below_the_offset_is_rescanned() {
655        let log = Log::new();
656        let mut tally = log.start();
657        for n in 1..=4 {
658            log.append(&gh(n, &["pr", "list"], "cli"));
659        }
660        assert!(tally.refresh(never));
661        assert_eq!(tally.counts().total(), 4);
662
663        log.write(&gh(9, &["api", "graphql"], "daemon")); // same inode, shorter
664        assert!(log.len() < tally.offset().unwrap());
665        assert!(tally.refresh(never));
666        assert_eq!(tally.counts().total(), 1);
667        assert_eq!(*tally.counts(), log.full_scan());
668    }
669
670    #[test]
671    fn a_log_rewritten_in_place_and_regrown_past_the_offset_is_rescanned() {
672        let log = Log::new();
673        let mut tally = log.start();
674        log.append(&gh(1, &["pr", "list"], "cli"));
675        log.append(&gh(2, &["pr", "list"], "cli"));
676        assert!(tally.refresh(never));
677        let stored = tally.offset().unwrap();
678
679        // Same inode, and by the time it is read again it is longer than the
680        // stored offset, so neither the file id nor the length notices. Only the
681        // bytes before the offset have changed.
682        let rewritten: String = (10..16)
683            .map(|n| gh(n, &["api", "graphql"], "daemon"))
684            .collect();
685        log.write(&rewritten);
686        assert!(log.len() > stored);
687        assert!(tally.refresh(never));
688        assert_eq!(tally.counts().total(), 6);
689        assert_eq!(*tally.counts(), log.full_scan());
690    }
691
692    #[test]
693    fn a_log_created_after_the_baseline_is_scanned_from_its_first_byte() {
694        let log = Log::new();
695        let mut tally = log.start();
696        assert_eq!(tally.offset(), None);
697        assert!(tally.refresh(never));
698        assert_eq!(tally.counts().total(), 0);
699
700        log.append(&gh(-10, &["pr", "list"], "cli"));
701        log.append(&gh(1, &["pr", "list"], "cli"));
702        log.append(&gh(2, &["pr", "view"], "cli"));
703        assert!(tally.refresh(never));
704        assert_eq!(tally.counts().total(), 2);
705        assert_eq!(*tally.counts(), log.full_scan());
706    }
707
708    #[test]
709    fn a_stopped_refresh_resumes_where_it_stopped() {
710        let log = Log::new();
711        let mut tally = log.start();
712        for n in 1..=5 {
713            log.append(&gh(n, &["pr", "list"], "cli"));
714        }
715
716        // Stop is consulted before each line: let two through, then refuse.
717        let mut calls = 0;
718        let complete = tally.refresh(|| {
719            calls += 1;
720            calls > 2
721        });
722        assert!(!complete);
723        assert_eq!(tally.counts().total(), 2);
724
725        assert!(tally.refresh(never));
726        assert_eq!(tally.counts().total(), 5);
727        assert_eq!(*tally.counts(), log.full_scan());
728    }
729
730    #[test]
731    fn a_stopped_rebuild_keeps_what_it_read_and_continues() {
732        let log = Log::new();
733        let mut tally = log.start();
734        log.append(&gh(1, &["pr", "list"], "cli"));
735        assert!(tally.refresh(never));
736
737        // The replacement forces a rebuild; stop it part-way, then let it finish.
738        let replacement: String = (10..16).map(|n| gh(n, &["pr", "view"], "cli")).collect();
739        log.replace(&replacement);
740        let mut calls = 0;
741        assert!(!tally.refresh(|| {
742            calls += 1;
743            calls > 3
744        }));
745        assert_eq!(tally.counts().total(), 3);
746
747        assert!(tally.refresh(never));
748        assert_eq!(tally.counts().total(), 6);
749        assert_eq!(*tally.counts(), log.full_scan());
750    }
751
752    #[test]
753    fn the_tail_keeps_the_last_bytes_across_short_and_long_lines() {
754        let mut cursor = Cursor::start_of(None);
755        cursor.advance(b"ab\n");
756        cursor.advance(b"cd\n");
757        assert_eq!(cursor.tail, b"ab\ncd\n");
758        assert_eq!(cursor.offset, 6);
759
760        let long: Vec<u8> = (0..200u8).collect();
761        cursor.advance(&long);
762        assert_eq!(cursor.tail, &long[long.len() - TAIL_LEN..]);
763
764        cursor.advance(b"z\n");
765        assert_eq!(cursor.tail.len(), TAIL_LEN);
766        assert!(cursor.tail.ends_with(b"z\n"));
767        assert_eq!(cursor.offset, 6 + 200 + 2);
768    }
769}