use std::collections::BTreeMap;
use std::fmt;
use std::fs::File;
use std::io::{self, BufRead as _, ErrorKind, Read as _, Seek as _, SeekFrom, Write as _};
use std::path::{Path, PathBuf};
use rustix::fs::OFlags;
use serde::{Deserialize, Serialize};
use crate::config::RoleModel;
use crate::events::{Stage, StageStats};
use crate::project_lock::{STATE_DIR, open_state_file};
pub const HISTORY_FILE: &str = "history.jsonl";
#[must_use]
pub fn path(project_dir: &Path) -> PathBuf {
project_dir.join(STATE_DIR).join(HISTORY_FILE)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Ok,
Failed,
Interrupted,
}
impl Status {
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::Ok => "ok",
Self::Failed => "failed",
Self::Interrupted => "interrupted",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SplitCounts {
pub train: usize,
pub eval: usize,
pub orphaned: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Entry {
pub stage: Stage,
pub started_at: String,
pub ended_at: String,
pub status: Status,
pub provider: Option<String>,
pub model: Option<String>,
pub done: usize,
pub skipped: usize,
pub failed: usize,
pub excluded: usize,
pub input_tokens: u64,
pub output_tokens: u64,
pub cost: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub split: Option<SplitCounts>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub backfilled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Span {
pub started_at: String,
pub ended_at: String,
}
impl Entry {
#[must_use]
pub fn from_stats(
stage: Stage,
span: Span,
outcome: Status,
role: Option<&RoleModel>,
stats: &StageStats,
) -> Self {
Self {
stage,
started_at: span.started_at,
ended_at: span.ended_at,
status: outcome,
provider: role.map(|role| role.provider.clone()),
model: role.map(|role| role.model.clone()),
done: stats.done,
skipped: stats.skipped,
failed: stats.failed,
excluded: stats.excluded,
input_tokens: stats.usage.input_tokens,
output_tokens: stats.usage.output_tokens,
cost: stats.cost,
split: None,
backfilled: false,
}
}
}
pub fn append(project_dir: &Path, entry: &Entry) -> io::Result<()> {
append_all(project_dir, std::slice::from_ref(entry))
}
pub fn append_all(project_dir: &Path, entries: &[Entry]) -> io::Result<()> {
let mut file = open_state_file(
project_dir,
HISTORY_FILE,
OFlags::RDWR | OFlags::APPEND | OFlags::CREATE,
true,
)?;
let mut line = String::new();
if !ends_a_line(&mut file)? {
line.push('\n');
}
for entry in entries {
line.push_str(&serde_json::to_string(entry).map_err(io::Error::other)?);
line.push('\n');
}
file.write_all(line.as_bytes())
}
fn ends_a_line(file: &mut File) -> io::Result<bool> {
if file.metadata()?.len() == 0 {
return Ok(true);
}
let mut last = [0; 1];
file.seek(SeekFrom::End(-1))?;
file.read_exact(&mut last)?;
Ok(last == *b"\n")
}
pub fn read(project_dir: &Path) -> io::Result<Vec<Entry>> {
let file = match open_state_file(project_dir, HISTORY_FILE, OFlags::RDONLY, false) {
Ok(file) => file,
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let mut reader = io::BufReader::new(file);
let mut entries = Vec::new();
let mut buf = Vec::new();
let mut number = 0_usize;
loop {
buf.clear();
if reader.read_until(b'\n', &mut buf)? == 0 {
break;
}
number += 1;
let line = buf.strip_suffix(b"\n").unwrap_or(&buf);
if line.trim_ascii().is_empty() {
continue;
}
match serde_json::from_slice(line) {
Ok(entry) => entries.push(entry),
Err(error) => {
tracing::warn!(
"skipping line {number} of {}: {error}",
path(project_dir).display()
);
},
}
}
Ok(entries)
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum Cost {
#[default]
Unknown,
Partial(f64),
Known(f64),
}
impl Cost {
#[must_use]
pub fn plus(self, cost: Option<f64>) -> Self {
match (self, cost) {
(Self::Unknown, None) => Self::Unknown,
(Self::Unknown, Some(c)) => Self::Partial(c),
(Self::Partial(a), c) => Self::Partial(a + c.unwrap_or(0.0)),
(Self::Known(a), Some(c)) => Self::Known(a + c),
(Self::Known(a), None) => Self::Partial(a),
}
}
fn first(cost: Option<f64>) -> Self {
cost.map_or(Self::Unknown, Self::Known)
}
fn merge(self, other: Self) -> Self {
match (self, other) {
(Self::Unknown, x) | (x, Self::Unknown) => match x {
Self::Known(c) | Self::Partial(c) => Self::Partial(c),
Self::Unknown => Self::Unknown,
},
(Self::Known(a), Self::Known(b)) => Self::Known(a + b),
(Self::Known(a) | Self::Partial(a), Self::Known(b) | Self::Partial(b)) => {
Self::Partial(a + b)
},
}
}
}
impl fmt::Display for Cost {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Unknown => f.write_str("cost unknown"),
Self::Partial(c) => write!(f, "${c:.4}+"),
Self::Known(c) => write!(f, "${c:.4}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Total {
pub runs: usize,
pub done: usize,
pub failed: usize,
pub input_tokens: u64,
pub output_tokens: u64,
pub cost: Cost,
}
impl Total {
#[must_use]
pub fn spent(&self) -> bool {
!matches!(self.cost, Cost::Unknown) || self.input_tokens > 0 || self.output_tokens > 0
}
fn add(&mut self, entry: &Entry) {
if entry.cost.is_some() || entry.input_tokens > 0 || entry.output_tokens > 0 {
self.cost = if self.spent() {
self.cost.plus(entry.cost)
} else {
Cost::first(entry.cost)
};
}
self.runs += 1;
self.done += entry.done;
self.failed += entry.failed;
self.input_tokens += entry.input_tokens;
self.output_tokens += entry.output_tokens;
}
}
#[must_use]
pub fn totals(entries: &[Entry]) -> (BTreeMap<Stage, Total>, Total) {
let mut per_stage: BTreeMap<Stage, Total> = BTreeMap::new();
for entry in entries {
per_stage.entry(entry.stage).or_default().add(entry);
}
let mut all = Total::default();
for total in per_stage.values() {
if total.spent() {
all.cost = if all.spent() {
all.cost.merge(total.cost)
} else {
total.cost
};
}
all.runs += total.runs;
all.done += total.done;
all.failed += total.failed;
all.input_tokens += total.input_tokens;
all.output_tokens += total.output_tokens;
}
(per_stage, all)
}
#[must_use]
pub fn per_model(entries: &[Entry]) -> BTreeMap<String, Total> {
let mut models: BTreeMap<String, Total> = BTreeMap::new();
for entry in entries {
if let Some(model) = &entry.model {
models.entry(model.clone()).or_default().add(entry);
}
}
models
}
#[cfg(test)]
mod tests {
use std::fs::OpenOptions;
use super::*;
type TestResult = Result<(), Box<dyn std::error::Error>>;
fn entry(stage: Stage, cost: Option<f64>) -> Entry {
let stats = StageStats {
done: 2,
failed: 1,
usage: crate::llm::Usage {
input_tokens: 10,
output_tokens: 5,
},
cost,
..StageStats::default()
};
Entry::from_stats(
stage,
Span {
started_at: "2026-09-27T10:00:00Z".into(),
ended_at: "2026-09-27T10:01:00Z".into(),
},
Status::Ok,
None,
&stats,
)
}
#[test]
fn appended_entries_read_back_in_order() -> TestResult {
let dir = tempfile::tempdir()?;
assert_eq!(read(dir.path())?, [] as [crate::history::Entry; 0]);
let first = entry(Stage::Subtopics, Some(0.5));
let second = entry(Stage::Answers, None);
append(dir.path(), &first)?;
append(dir.path(), &second)?;
assert_eq!(read(dir.path())?, vec![first, second]);
Ok(())
}
#[test]
fn append_all_writes_every_entry_in_order() -> TestResult {
let dir = tempfile::tempdir()?;
let entries = [entry(Stage::Answers, None), entry(Stage::Split, None)];
append_all(dir.path(), &entries)?;
assert_eq!(read(dir.path())?, entries);
Ok(())
}
#[test]
fn a_malformed_line_is_skipped() -> TestResult {
let dir = tempfile::tempdir()?;
let good = entry(Stage::Questions, Some(1.0));
append(dir.path(), &good)?;
let mut file = OpenOptions::new().append(true).open(path(dir.path()))?;
writeln!(file, "not json")?;
append(dir.path(), &good)?;
assert_eq!(read(dir.path())?.len(), 2);
Ok(())
}
#[test]
fn a_line_of_invalid_utf8_is_skipped() -> TestResult {
let dir = tempfile::tempdir()?;
let good = entry(Stage::Questions, Some(1.0));
append(dir.path(), &good)?;
let mut file = OpenOptions::new().append(true).open(path(dir.path()))?;
file.write_all(b"\xff\xfe\n")?;
append(dir.path(), &good)?;
assert_eq!(read(dir.path())?, vec![good.clone(), good]);
Ok(())
}
#[test]
fn totals_mark_a_partly_unknown_cost() {
let entries = [
entry(Stage::Answers, Some(1.0)),
entry(Stage::Answers, None),
entry(Stage::Questions, Some(0.25)),
entry(Stage::Subtopics, None),
];
let (per_stage, all) = totals(&entries);
assert_eq!(per_stage[&Stage::Answers].runs, 2);
assert_eq!(per_stage[&Stage::Answers].input_tokens, 20);
assert_eq!(per_stage[&Stage::Answers].cost, Cost::Partial(1.0));
assert_eq!(per_stage[&Stage::Questions].cost, Cost::Known(0.25));
assert_eq!(per_stage[&Stage::Subtopics].cost, Cost::Unknown);
assert_eq!(all.cost, Cost::Partial(1.25));
assert_eq!(all.runs, 4);
}
#[test]
fn an_entry_that_spent_nothing_is_neutral_for_the_cost() {
let mut split = entry(Stage::Split, None);
split.input_tokens = 0;
split.output_tokens = 0;
let priced = entry(Stage::Answers, Some(1.0));
let (per_stage, all) = totals(&[priced.clone(), split.clone()]);
assert_eq!(all.cost, Cost::Known(1.0));
assert_eq!(all.runs, 2);
assert_eq!(per_stage[&Stage::Split].cost, Cost::Unknown);
let (_, all) = totals(&[split.clone(), priced.clone(), split.clone()]);
assert_eq!(all.cost, Cost::Known(1.0));
let mut idle = priced.clone();
idle.cost = None;
idle.input_tokens = 0;
idle.output_tokens = 0;
let (per_stage, _) = totals(&[idle.clone(), priced, idle]);
assert_eq!(per_stage[&Stage::Answers].cost, Cost::Known(1.0));
assert_eq!(totals(&[split]).1.cost, Cost::Unknown);
}
#[test]
fn a_truncated_last_line_never_swallows_the_next_entry() -> TestResult {
let dir = tempfile::tempdir()?;
std::fs::create_dir_all(dir.path().join(STATE_DIR))?;
std::fs::write(path(dir.path()), "{\"stage\": \"answ")?;
let good = entry(Stage::Answers, Some(1.0));
append(dir.path(), &good)?;
assert_eq!(read(dir.path())?, vec![good]);
Ok(())
}
#[test]
fn a_symlinked_history_file_is_refused_and_its_target_kept() -> TestResult {
let dir = tempfile::tempdir()?;
let elsewhere = tempfile::tempdir()?;
let target = elsewhere.path().join("precious");
std::fs::write(&target, "keep me")?;
std::fs::create_dir(dir.path().join(STATE_DIR))?;
let file = dir.path().join(STATE_DIR).join(HISTORY_FILE);
std::os::unix::fs::symlink(&target, &file)?;
let good = entry(Stage::Answers, Some(1.0));
assert!(append(dir.path(), &good).is_err());
assert_eq!(std::fs::read_to_string(&target)?, "keep me");
Ok(())
}
#[test]
fn a_hard_linked_history_file_is_refused_and_its_target_kept() -> TestResult {
let dir = tempfile::tempdir()?;
let target = dir.path().join("precious");
std::fs::write(&target, "keep me")?;
std::fs::create_dir(dir.path().join(STATE_DIR))?;
let file = dir.path().join(STATE_DIR).join(HISTORY_FILE);
std::fs::hard_link(&target, &file)?;
let good = entry(Stage::Answers, Some(1.0));
assert!(append(dir.path(), &good).is_err());
assert_eq!(std::fs::read_to_string(&target)?, "keep me");
Ok(())
}
#[test]
fn a_symlinked_state_directory_makes_append_fail_without_writing_the_target() -> TestResult {
let dir = tempfile::tempdir()?;
let elsewhere = tempfile::tempdir()?;
std::os::unix::fs::symlink(elsewhere.path(), dir.path().join(STATE_DIR))?;
let good = entry(Stage::Answers, Some(1.0));
assert!(append(dir.path(), &good).is_err());
assert!(std::fs::read_dir(elsewhere.path())?.next().is_none());
Ok(())
}
#[test]
fn per_model_sums_the_entries_of_each_model() {
let with_model = |model: &str, cost| Entry {
model: Some(model.to_string()),
..entry(Stage::Answers, cost)
};
let entries = [
with_model("parent", Some(1.0)),
with_model("gen", Some(0.5)),
with_model("parent", Some(0.25)),
entry(Stage::Split, None),
];
let models = per_model(&entries);
assert_eq!(models.keys().collect::<Vec<_>>(), ["gen", "parent"]);
assert_eq!(models["parent"].runs, 2);
assert_eq!(models["parent"].input_tokens, 20);
assert_eq!(models["parent"].cost, Cost::Known(1.25));
assert_eq!(models["gen"].runs, 1);
}
#[test]
fn only_backfilled_entries_carry_the_mark() -> Result<(), serde_json::Error> {
let normal = entry(Stage::Answers, Some(1.0));
let line = serde_json::to_string(&normal)?;
assert!(!line.contains("backfilled"), "{line}");
let back: Entry = serde_json::from_str(&line)?;
assert!(!back.backfilled);
let backfilled = Entry {
backfilled: true,
..normal
};
let line = serde_json::to_string(&backfilled)?;
assert!(line.contains("\"backfilled\":true"), "{line}");
assert_eq!(serde_json::from_str::<Entry>(&line)?, backfilled);
Ok(())
}
#[test]
fn costs_display_with_their_certainty() {
assert_eq!(Cost::Unknown.to_string(), "cost unknown");
assert_eq!(Cost::Known(1.5).to_string(), "$1.5000");
assert_eq!(Cost::Partial(1.5).to_string(), "$1.5000+");
}
}