use std::collections::VecDeque;
use std::sync::Mutex;
use crate::redact::{self, Cleaned};
pub const MAX_LINE: usize = 4096;
pub const DEFAULT_CAP: usize = 20_000;
pub const DEFAULT_BYTES: usize = 6 << 20;
pub const HEAD_CAP: usize = 8_000;
pub const HEAD_BYTES: usize = 2 << 20;
pub const MAX_READ: usize = 2_000;
#[derive(Debug)]
struct Run {
class: &'static str,
count: u64,
bytes: u64,
}
#[derive(Debug)]
struct Ring {
head: Vec<String>,
head_bytes: usize,
head_cap: usize,
head_max_bytes: usize,
sealed: bool,
lines: VecDeque<String>,
bytes: usize,
first: u64,
cap: usize,
max_bytes: usize,
served: u64,
run: Option<Run>,
withheld: u64,
}
impl Ring {
fn total(&self) -> u64 {
self.first + self.lines.len() as u64
}
fn fold(&mut self, class: &'static str, bytes: usize) -> bool {
let total = self.total();
if total == 0 || total - 1 < self.served {
return false;
}
let Some(run) = self.run.as_mut() else { return false };
if run.class != class {
return false;
}
let (count, sum) = (run.count + 1, run.bytes + bytes as u64);
let text = redact::withheld_run(class, count, sum);
if !self.sealed {
let Some(last) = self.head.last() else { return false };
let grown = self.head_bytes - last.len() + text.len();
if grown > self.head_max_bytes {
return false;
}
self.head_bytes = grown;
*self.head.last_mut().expect("checked") = text;
} else {
let Some(last) = self.lines.back() else { return false };
let grown = self.bytes - last.len() + text.len();
if grown > self.max_bytes {
return false;
}
self.bytes = grown;
*self.lines.back_mut().expect("checked") = text;
}
let run = self.run.as_mut().expect("checked");
run.count = count;
run.bytes = sum;
true
}
fn hold(&mut self, line: String) {
let len = line.len();
if !self.sealed {
if self.head.len() < self.head_cap && self.head_bytes + len <= self.head_max_bytes {
self.head_bytes += len;
self.head.push(line);
self.first += 1;
return;
}
self.sealed = true;
}
if self.cap == 0 || len > self.max_bytes {
self.first += 1;
return;
}
while !self.lines.is_empty() && (self.lines.len() >= self.cap || self.bytes + len > self.max_bytes) {
if let Some(old) = self.lines.pop_front() {
self.bytes -= old.len();
}
self.first += 1;
}
self.bytes += len;
self.lines.push_back(line);
}
}
#[derive(Debug)]
pub struct BootLog {
ring: Mutex<Ring>,
boot: std::sync::OnceLock<u64>,
}
fn draw_boot() -> u64 {
static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0);
let id = nanos ^ (u64::from(std::process::id()) << 40) ^ N.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_mul(0x9E37_79B9_7F4A_7C15);
id.max(1)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Cursor {
pub boot: u64,
pub next: u64,
}
pub fn resume(at: Cursor, c: &Chunk) -> (Cursor, bool) {
if at.boot != 0 && c.boot != 0 && c.boot != at.boot {
return (Cursor { boot: c.boot, next: 0 }, true);
}
if c.next < at.next {
return (Cursor { boot: c.boot, next: 0 }, true);
}
(Cursor { boot: if c.boot != 0 { c.boot } else { at.boot }, next: c.next }, false)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Chunk {
pub dropped: u64,
pub lines: Vec<String>,
pub next: u64,
pub boot: u64,
}
impl Default for BootLog {
fn default() -> Self {
BootLog::new(DEFAULT_CAP)
}
}
impl BootLog {
pub const fn new(cap: usize) -> BootLog {
BootLog::with_head(cap, DEFAULT_BYTES, 0, 0)
}
pub const fn with_head(cap: usize, max_bytes: usize, head_cap: usize, head_bytes: usize) -> BootLog {
BootLog {
ring: Mutex::new(Ring {
head: Vec::new(),
head_bytes: 0,
head_cap,
head_max_bytes: head_bytes,
sealed: head_cap == 0 || head_bytes == 0,
lines: VecDeque::new(),
bytes: 0,
first: 0,
cap,
max_bytes,
served: 0,
run: None,
withheld: 0,
}),
boot: std::sync::OnceLock::new(),
}
}
pub const fn for_install() -> BootLog {
BootLog::with_head(DEFAULT_CAP, DEFAULT_BYTES, HEAD_CAP, HEAD_BYTES)
}
pub fn seal_head(&self) {
let mut r = self.ring.lock().unwrap_or_else(|p| p.into_inner());
if !r.sealed {
r.sealed = true;
r.run = None;
}
}
pub fn head_len(&self) -> u64 {
self.ring.lock().unwrap_or_else(|p| p.into_inner()).head.len() as u64
}
pub fn held_bytes(&self) -> usize {
let r = self.ring.lock().unwrap_or_else(|p| p.into_inner());
r.head_bytes + r.bytes
}
pub fn withheld(&self) -> u64 {
self.ring.lock().unwrap_or_else(|p| p.into_inner()).withheld
}
pub fn push(&self, text: &str) {
let mut r = self.ring.lock().unwrap_or_else(|p| p.into_inner());
for line in text.split('\n') {
let line = line.strip_suffix('\r').unwrap_or(line);
if line.trim().is_empty() {
continue;
}
match redact::cleaned(line) {
Cleaned::Withheld { class, bytes } => {
r.withheld += 1;
if r.fold(class, bytes) {
continue;
}
r.hold(redact::withheld(class, bytes));
r.run = Some(Run { class, count: 1, bytes: bytes as u64 });
}
Cleaned::Kept(kept) => {
let kept = if kept.len() > MAX_LINE {
let mut cut = MAX_LINE;
while !kept.is_char_boundary(cut) {
cut -= 1;
}
format!("{} … (cut at {MAX_LINE} bytes)", &kept[..cut])
} else {
kept.into_owned()
};
r.hold(kept);
r.run = None;
}
}
}
}
pub fn total(&self) -> u64 {
self.ring.lock().unwrap_or_else(|p| p.into_inner()).total()
}
pub fn dropped(&self) -> u64 {
let r = self.ring.lock().unwrap_or_else(|p| p.into_inner());
r.first - r.head.len() as u64
}
pub fn since(&self, from: u64, max: usize) -> Chunk {
let boot = *self.boot.get_or_init(draw_boot);
let mut r = self.ring.lock().unwrap_or_else(|p| p.into_inner());
let h = r.head.len() as u64;
let mut lines: Vec<String> = Vec::new();
let mut from = from;
if from < h {
lines.extend(r.head.iter().skip(from as usize).take(max).cloned());
let next = from + lines.len() as u64;
if next < h || lines.len() == max || r.first > h {
r.served = r.served.max(next);
return Chunk { dropped: 0, lines, next, boot };
}
from = h;
}
let end = r.total();
let start = from.max(r.first).min(end);
let dropped = if lines.is_empty() { start.saturating_sub(from.min(end)) } else { 0 };
let skip = (start - r.first) as usize;
let before = lines.len();
lines.extend(r.lines.iter().skip(skip).take(max - before).cloned());
let next = start + (lines.len() - before) as u64;
r.served = r.served.max(next);
Chunk { dropped, lines, next, boot }
}
}
pub const ESP_BOOTLOG: &str = "/boot/BOOTLOG.TXT";
impl BootLog {
pub fn capture_esp(&self, path: &std::path::Path, product: &str) -> bool {
let Ok(text) = std::fs::read_to_string(path) else { return false };
self.push(&format!("── {} on the ESP: the install and this boot before {product} ──", path.display()));
self.push(&text);
self.push(&format!("── {product} ──"));
true
}
}
pub struct Tap<W: std::io::Write> {
log: &'static BootLog,
inner: W,
partial: Vec<u8>,
}
impl<W: std::io::Write> Tap<W> {
pub fn new(log: &'static BootLog, inner: W) -> Tap<W> {
Tap { log, inner, partial: Vec::new() }
}
}
impl<W: std::io::Write> std::io::Write for Tap<W> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let n = self.inner.write(buf)?;
for &b in &buf[..n] {
if b == b'\n' || self.partial.len() >= MAX_LINE {
self.log.push(&String::from_utf8_lossy(&self.partial));
self.partial.clear();
if b == b'\n' {
continue;
}
}
self.partial.push(b);
}
Ok(n)
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
#[cfg(all(unix, feature = "stderr-capture"))]
pub fn capture_stderr(log: &'static BootLog) -> std::io::Result<std::fs::File> {
use std::io::{BufRead, Read, Write};
use std::os::fd::AsFd;
let console = std::fs::File::from(std::io::stderr().as_fd().try_clone_to_owned()?);
let mut drain_console = console.try_clone()?;
let (reader, writer) = std::io::pipe()?;
rustix::stdio::dup2_stderr(&writer)?;
drop(writer);
std::thread::Builder::new().name("stderr-to-bootlog".into()).spawn(move || {
let mut r = std::io::BufReader::new(reader);
let mut line = Vec::with_capacity(256);
loop {
line.clear();
match r.by_ref().take(MAX_LINE as u64 + 1).read_until(b'\n', &mut line) {
Ok(0) | Err(_) => return,
Ok(_) => {
let _ = drain_console.write_all(&line);
let text = String::from_utf8_lossy(line.strip_suffix(b"\n").unwrap_or(&line));
log.push(text.strip_suffix('\r').unwrap_or(&text));
}
}
}
})?;
Ok(console)
}
pub fn dropped_marker(n: u64) -> String {
format!("… {n} lines dropped (the VM's log holds its install-time head and its newest lines) …")
}
impl Chunk {
pub fn render(&self) -> String {
let mut out = format!("next = {}\ndropped = {}\n", self.next, self.dropped);
if self.boot != 0 {
out.push_str(&format!("boot = {}\n", self.boot));
}
out.push('\n');
for l in &self.lines {
out.push_str(l);
out.push('\n');
}
out
}
pub fn parse(text: &str) -> Result<Chunk, String> {
let (head, body) = text.split_once("\n\n").ok_or("a log chunk without its header")?;
let mut next = None;
let mut dropped = 0;
let mut boot = 0;
for l in head.lines() {
match l.split_once('=').map(|(k, v)| (k.trim(), v.trim())) {
Some(("next", v)) => next = v.parse().ok(),
Some(("dropped", v)) => dropped = v.parse().map_err(|_| format!("`{l}` is not a count"))?,
Some(("boot", v)) => boot = v.parse().map_err(|_| format!("`{l}` is not a boot"))?,
_ => {}
}
}
let next = next.ok_or("a log chunk without `next`")?;
Ok(Chunk { dropped, lines: body.lines().map(str::to_string).collect(), next, boot })
}
pub fn stored(&self) -> Vec<String> {
let mut v = Vec::with_capacity(self.lines.len() + 1);
if self.dropped > 0 {
v.push(dropped_marker(self.dropped));
}
v.extend(self.lines.iter().cloned());
v
}
}
pub const KEEP_BYTES: u64 = 4 << 20;
pub const CARRY_LINES: usize = 400;
pub const CARRY_BYTES: usize = 128 << 10;
pub const KEEP_EVERY: std::time::Duration = std::time::Duration::from_secs(1);
pub const CARRIED: &str = "(previous boot) ";
pub fn rotated(path: &std::path::Path) -> std::path::PathBuf {
let mut s = path.as_os_str().to_os_string();
s.push(".1");
std::path::PathBuf::from(s)
}
pub fn last_lines(older: &str, newer: &str, lines: usize, bytes: usize) -> Vec<String> {
let mut out: VecDeque<String> = VecDeque::new();
let mut held = 0usize;
for l in newer.lines().rev().chain(older.lines().rev()) {
if l.trim().is_empty() {
continue;
}
if out.len() >= lines || held + l.len() + 1 > bytes {
break;
}
held += l.len() + 1;
out.push_front(l.to_string());
}
out.into()
}
pub fn append_bounded(path: &std::path::Path, text: &str, max_bytes: u64) -> std::io::Result<()> {
use std::io::Write as _;
if text.is_empty() {
return Ok(());
}
if let Some(d) = path.parent() {
std::fs::create_dir_all(d)?;
}
let have = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
if have > 0 && have + text.len() as u64 > max_bytes {
std::fs::rename(path, rotated(path))?;
}
let text = if text.len() as u64 > max_bytes {
let mut cut = text.len() - max_bytes as usize;
while !text.is_char_boundary(cut) {
cut += 1;
}
let t = &text[cut..];
t.find('\n').map(|n| &t[n + 1..]).unwrap_or(t)
} else {
text
};
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path)?;
f.write_all(text.as_bytes())?;
f.sync_data()
}
impl BootLog {
pub fn carry_previous(&self, path: &std::path::Path, lines: usize, bytes: usize) -> usize {
let newer = std::fs::read_to_string(path).unwrap_or_default();
let older = std::fs::read_to_string(rotated(path)).unwrap_or_default();
let last = last_lines(&older, &newer, lines, bytes);
if !newer.is_empty() {
let _ = std::fs::rename(path, rotated(path));
}
if last.is_empty() {
return 0;
}
self.push(&format!("── the previous boot's last {} line(s), kept on {} (a box that restarted or crashed left them there) ──", last.len(), path.display()));
for l in &last {
self.push(&format!("{CARRIED}{l}"));
}
self.push("── end of the previous boot ──");
last.len()
}
pub fn keep_once(&self, path: &std::path::Path, from: u64, max_bytes: u64) -> u64 {
let mut at = from;
loop {
let c = self.since(at, MAX_READ);
if c.lines.is_empty() && c.dropped == 0 {
return c.next.max(at).min(self.total());
}
let mut text = String::new();
for l in c.stored() {
text.push_str(&l);
text.push('\n');
}
if append_bounded(path, &text, max_bytes).is_err() {
return at;
}
let full = c.lines.len() >= MAX_READ;
at = c.next;
if !full {
return at;
}
}
}
pub fn keep_on_disk(&'static self, path: std::path::PathBuf, max_bytes: u64) -> usize {
let carried = self.carry_previous(&path, CARRY_LINES, CARRY_BYTES);
let from = self.total();
let _ = std::thread::Builder::new().name("bootlog-keep".into()).spawn(move || {
let mut at = from;
loop {
at = self.keep_once(&path, at, max_bytes);
std::thread::sleep(KEEP_EVERY);
}
});
carried
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_reader_notices_a_new_boot_even_when_it_already_holds_more_lines_than_the_cursor() {
let old = BootLog::new(100);
for i in 0..5 {
old.push(&format!("old {i}"));
}
let c = Chunk::parse(&old.since(0, 100).render()).unwrap();
let (at, restarted) = resume(Cursor::default(), &c);
assert_eq!((at.next, restarted), (5, false));
let new = BootLog::new(100);
for i in 0..9 {
new.push(&format!("new {i}"));
}
let c = Chunk::parse(&new.since(at.next, 100).render()).unwrap();
let (again, restarted) = resume(at, &c);
assert!(restarted, "the new boot is noticed although it holds more lines than the cursor");
assert_eq!(again.next, 0, "and read from its first line");
let c = Chunk::parse(&new.since(again.next, 100).render()).unwrap();
assert_eq!(c.lines.first().map(String::as_str), Some("new 0"));
assert_eq!(resume(again, &c), (Cursor { boot: again.boot, next: 9 }, false));
let legacy = Chunk { dropped: 0, lines: vec![], next: 2, boot: 0 };
assert!(resume(Cursor { boot: 0, next: 5 }, &legacy).1);
}
#[test]
fn a_reader_gets_every_line_once_and_continues_where_it_left_off() {
let log = BootLog::new(10);
log.push("one\ntwo\r\n\nthree");
let c = log.since(0, 2);
assert_eq!((c.lines.clone(), c.next, c.dropped), (vec!["one".to_string(), "two".to_string()], 2, 0));
let c = log.since(c.next, 100);
assert_eq!((c.lines, c.next), (vec!["three".to_string()], 3));
assert_eq!(log.since(3, 100).lines.len(), 0, "nothing new");
assert_eq!(log.since(99, 100).next, 3, "a reader ahead of the log is brought back to its end");
}
#[test]
fn a_full_log_drops_the_oldest_and_counts_them_and_the_reader_is_told() {
let log = BootLog::new(3);
for i in 0..8 {
log.push(&format!("line {i}"));
}
assert_eq!((log.total(), log.dropped()), (8, 5));
let c = log.since(0, 100);
assert_eq!(c.dropped, 5);
assert_eq!(c.lines, vec!["line 5", "line 6", "line 7"]);
assert_eq!(c.stored()[0], dropped_marker(5));
assert!(c.stored()[0].contains("5 lines dropped"));
let later = log.since(6, 100);
assert_eq!((later.dropped, later.lines.len()), (0, 2), "a reader that kept up loses nothing");
}
#[test]
fn the_tap_streams_whole_lines_to_the_log_and_passes_every_byte_on() {
use std::io::Write as _;
static LOG: BootLog = BootLog::new(100);
let mut out = Vec::new();
{
let mut tap = Tap::new(&LOG, &mut out);
write!(tap, "[INFO holger] pairing: vm2 PA").unwrap();
assert_eq!(LOG.total(), 0, "a partial line waits for its newline");
writeln!(tap, "IRED\n[WARN holger] second").unwrap();
}
assert_eq!(LOG.since(0, 10).lines, vec!["[INFO holger] pairing: vm2 PAIRED", "[WARN holger] second"]);
assert_eq!(String::from_utf8(out).unwrap(), "[INFO holger] pairing: vm2 PAIRED\n[WARN holger] second\n", "the console gets every byte");
let esp = std::env::temp_dir().join(format!("bootlog-esp-{}.txt", std::process::id()));
std::fs::write(&esp, "korp-installer: INSTALL-OK\nkorp-start: exec holger\n").unwrap();
let log = BootLog::new(100);
assert!(log.capture_esp(&esp, "holger"));
let lines = log.since(0, 10).lines;
assert!(lines[0].contains("BOOTLOG") || lines[0].contains("on the ESP"), "{lines:?}");
assert_eq!((lines[1].as_str(), lines.last().unwrap().as_str()), ("korp-installer: INSTALL-OK", "── holger ──"));
assert!(!log.capture_esp(std::path::Path::new("/nonexistent/BOOTLOG.TXT"), "holger"));
let _ = std::fs::remove_file(&esp);
}
#[test]
fn a_long_line_is_cut_on_a_character_boundary_and_the_wire_round_trips() {
let log = BootLog::new(4);
log.push(&"å".repeat(MAX_LINE));
let c = log.since(0, 10);
assert!(c.lines[0].len() < MAX_LINE + 40 && c.lines[0].ends_with("(cut at 4096 bytes)"));
let wire = c.render();
assert_eq!(Chunk::parse(&wire).unwrap(), c);
assert!(Chunk::parse("garbage").is_err());
}
fn read_all(log: &BootLog, max: usize) -> Vec<String> {
let mut out = Vec::new();
let mut from = 0;
loop {
let c = log.since(from, max);
if c.lines.is_empty() && c.dropped == 0 {
return out;
}
out.extend(c.stored());
from = c.next;
}
}
#[test]
fn the_pinned_head_outlives_any_flood_and_the_reader_sees_head_marker_tail() {
let log = BootLog::with_head(50, 10_000, 5, 1_000);
log.push("install: laid\npairing: admitted as main\npairing: PAIRED");
log.seal_head();
for i in 0..10_000 {
log.push(&format!("ssh.auth.accepted fingerprint=SHA256:{i:043} publickey accepted"));
}
assert_eq!(log.head_len(), 3);
let all = read_all(&log, 7);
assert_eq!(&all[..3], &["install: laid", "pairing: admitted as main", "pairing: PAIRED"]);
assert!(all[3].contains("lines dropped"), "{:?}", all[3]);
assert!(!all.iter().any(|l| l.contains("SHA256:0")), "no fingerprint is held");
assert!(all.last().unwrap().contains("ssh.auth.accepted fingerprint=") && all.last().unwrap().contains(redact::VALUE_WITHHELD), "{:?}", all.last());
assert_eq!(log.total(), 10_003);
assert_eq!(log.dropped() + (all.len() as u64 - 4) + 3, 10_003, "every line is held or counted");
}
#[test]
fn the_install_log_keeps_its_head_for_life() {
static LOG: BootLog = BootLog::for_install();
LOG.push("install: laid\npairing: PAIRED");
LOG.seal_head();
for i in 0..(DEFAULT_CAP + 50) {
LOG.push(&format!("runtime line {i}"));
}
assert_eq!(LOG.head_len(), 2);
let c = LOG.since(0, 3);
assert_eq!(&c.lines[..2], &["install: laid", "pairing: PAIRED"]);
assert!(c.dropped == 0 && LOG.dropped() == 50, "the tail evicts, the head stays: {}", LOG.dropped());
let log = BootLog::for_install();
for i in 0..(HEAD_CAP + 10) {
log.push(&format!("l{i}"));
}
assert_eq!(log.head_len(), HEAD_CAP as u64);
assert!(log.held_bytes() <= HEAD_BYTES + DEFAULT_BYTES);
}
#[test]
fn the_byte_bound_holds_head_and_tail_and_a_full_head_seals_itself() {
let (cap, max_bytes, head_cap, head_bytes) = (1_000_000, 8_192, 1_000, 2_048);
let log = BootLog::with_head(cap, max_bytes, head_cap, head_bytes);
let line = "x ".repeat(50);
for _ in 0..5_000 {
log.push(&line);
assert!(log.held_bytes() <= max_bytes + head_bytes, "{}", log.held_bytes());
}
assert_eq!(log.head_len(), 20, "the head took 2 048 bytes' worth and sealed itself");
log.push(&"y ".repeat(3 * MAX_LINE));
assert!(log.held_bytes() <= max_bytes + head_bytes);
let plain = BootLog::new(DEFAULT_CAP);
for _ in 0..DEFAULT_CAP {
plain.push(&"z ".repeat(MAX_LINE));
}
assert!(plain.held_bytes() <= DEFAULT_BYTES, "{}", plain.held_bytes());
assert_eq!(plain.head_len(), 0);
}
#[test]
fn a_head_read_with_no_gap_runs_straight_on_into_the_tail() {
let log = BootLog::with_head(10, 10_000, 2, 1_000);
log.push("h0\nh1\nt0\nt1");
let c = log.since(0, 100);
assert_eq!((c.dropped, c.lines.len(), c.next), (0, 4, 4));
let c = log.since(1, 2);
assert_eq!((c.lines.clone(), c.next), (vec!["h1".to_string(), "t0".to_string()], 3));
}
#[test]
fn a_run_of_withheld_lines_is_one_counted_marker_and_a_read_marker_is_never_rewritten() {
let log = BootLog::new(100);
log.push("boot");
for _ in 0..1_000 {
log.push("reading GUNNAR_UI_ADMIN_TOKEN from the env");
}
log.push("served");
let c = log.since(0, 100);
assert_eq!(c.lines.len(), 3, "{:?}", c.lines);
assert_eq!(c.lines[1], redact::withheld_run("token", 1_000, 1_000 * 42));
assert!(!c.lines[1].contains("GUNNAR_UI_ADMIN"));
assert_eq!(log.withheld(), 1_000);
log.push("reading GUNNAR_UI_ADMIN_TOKEN from the env");
let seen = log.since(0, 100);
log.push("reading GUNNAR_UI_ADMIN_TOKEN from the env");
let again = log.since(0, 100);
assert_eq!(&again.lines[..seen.lines.len()], &seen.lines[..], "nothing a reader was handed changed");
assert_eq!(again.lines.len(), 5);
log.push("reading /etc/monetize/monetize.env");
assert!(log.since(5, 10).lines[0].contains("env-class"));
}
fn scratch(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("bootlog-keep-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn a_box_keeps_its_log_on_disk_bounded_and_the_next_boot_carries_its_last_lines() {
let d = scratch("carry");
let file = d.join("log").join("door.log");
let boot1 = BootLog::new(1000);
boot1.push("2026-09-30T10:00:00.000000Z INFO gunnar_front: the front starts");
let mut at = boot1.keep_once(&file, 0, 200);
for i in 0..20 {
boot1.push(&format!("2026-09-30T10:00:{i:02}.000000Z INFO gunnar_front::web: GET /logproof-{i} 200"));
at = boot1.keep_once(&file, at, 200);
}
assert_eq!(at, 21, "every line written once");
let cur = std::fs::read_to_string(&file).unwrap();
let old = std::fs::read_to_string(rotated(&file)).unwrap();
assert!(cur.len() as u64 <= 200 && old.len() as u64 <= 200, "bounded: {} + {}", cur.len(), old.len());
assert!(cur.contains("/logproof-19 200"), "the newest line is on disk at once: {cur}");
let boot2 = BootLog::new(1000);
boot2.push("── BOOTLOG.TXT on the ESP ──");
let carried = boot2.carry_previous(&file, 3, 1 << 20);
assert_eq!(carried, 3);
let lines = boot2.since(0, 100).lines;
assert!(lines[1].contains("the previous boot's last 3 line(s)") && lines[1].contains("door.log"), "{lines:?}");
assert_eq!(lines[2], "(previous boot) 2026-09-30T10:00:17.000000Z INFO gunnar_front::web: GET /logproof-17 200");
assert_eq!(lines[4], "(previous boot) 2026-09-30T10:00:19.000000Z INFO gunnar_front::web: GET /logproof-19 200");
assert!(lines[5].contains("end of the previous boot"), "{lines:?}");
assert!(!file.exists() || std::fs::read_to_string(&file).unwrap().is_empty());
assert!(std::fs::read_to_string(rotated(&file)).unwrap().contains("/logproof-19"));
let from = boot2.total();
boot2.push("2026-09-30T10:05:00.000000Z INFO gunnar_front: the front starts");
assert_eq!(boot2.keep_once(&file, from, 200), from + 1);
assert_eq!(std::fs::read_to_string(&file).unwrap(), "2026-09-30T10:05:00.000000Z INFO gunnar_front: the front starts\n");
let fresh = BootLog::new(10);
assert_eq!(fresh.carry_previous(&d.join("none").join("door.log"), 3, 1 << 20), 0);
assert_eq!(fresh.total(), 0);
assert_eq!(last_lines("", "aaaa\nbbbb\ncccc\n", 10, 10), vec!["bbbb".to_string(), "cccc".to_string()], "5 bytes a line (with its newline): two fit in 10");
assert_eq!(last_lines("a\nb\n", "c\n", 2, 100), vec!["b".to_string(), "c".to_string()], "the older file first, then the newer");
let _ = std::fs::remove_dir_all(&d);
}
}