use std::collections::BTreeMap;
use std::fs::{self, OpenOptions};
use std::io::{self, Write as _};
use std::os::unix::fs::OpenOptionsExt as _;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use argonauts_core::{Paths, create_private_dir_all};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct Record {
#[serde(with = "time::serde::timestamp")]
pub at: OffsetDateTime,
pub cli: String,
pub model: String,
pub folder: String,
pub input: u64,
pub output: u64,
pub cache_read: u64,
pub cache_write: u64,
pub session: u32,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub subagent: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub whole_day: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub(crate) struct ImportState {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub import: Option<bool>,
#[serde(default)]
pub version: u32,
#[serde(default)]
pub sessions: u32,
#[serde(default)]
pub files: BTreeMap<String, FileRead>,
#[serde(default, skip_serializing_if = "is_zero")]
pub skipped: u32,
#[serde(default, skip_serializing_if = "is_zero")]
pub unreadable: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stats_cache_problem: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kept: Option<Kept>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct Kept {
pub records: u64,
pub answers: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub(crate) struct FileRead {
pub session: u32,
pub read: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub totals: Option<[u64; 4]>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub open: Vec<(String, [u64; 4])>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub folder: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub model: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub subagent: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub forked: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub packed: Option<u64>,
}
type Cached = (PathBuf, u64, SystemTime, Arc<Vec<Record>>);
fn is_zero(n: &u32) -> bool {
*n == 0
}
pub(super) const IMPORTED: &str = "imported";
pub(crate) struct Store {
dir: PathBuf,
}
impl Store {
pub fn new(paths: &Paths) -> Self {
Self::at(&paths.usage_dir().join(IMPORTED))
}
pub fn at(dir: &Path) -> Self {
let store = Self {
dir: dir.to_owned(),
};
store.recover();
store
}
pub fn next(&self) -> Store {
Store {
dir: self.dir.with_extension("next"),
}
}
pub fn clear(&self) -> io::Result<()> {
match fs::remove_dir_all(&self.dir) {
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
done => done,
}
}
pub fn replace_with(&self, next: &Store) -> io::Result<()> {
let old = self.dir.with_extension("old");
Store { dir: old.clone() }.clear()?;
if self.dir.exists() {
fs::rename(&self.dir, &old)?;
}
if let Err(err) = fs::rename(&next.dir, &self.dir) {
if !self.dir.exists() {
return Err(err);
}
}
Store { dir: old }.clear()
}
fn recover(&self) {
let next = self.next();
if !self.dir.exists() && next.dir.exists() && self.dir.with_extension("old").exists() {
let _ = fs::rename(&next.dir, &self.dir);
}
if self.dir.exists() {
let _ = Store {
dir: self.dir.with_extension("old"),
}
.clear();
}
}
fn records_file(&self) -> PathBuf {
self.dir.join("records.jsonl")
}
fn state_file(&self) -> PathBuf {
self.dir.join("state.json")
}
pub fn answers_file(&self) -> PathBuf {
self.dir.join("answers.bin")
}
pub fn state(&self) -> ImportState {
fs::read_to_string(self.state_file())
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_default()
}
pub fn save_state(&self, state: &ImportState) -> io::Result<()> {
create_private_dir_all(&self.dir)?;
let text = serde_json::to_string_pretty(state).map_err(io::Error::other)?;
let partial = self.dir.join("state.json.partial");
OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(&partial)?
.write_all(text.as_bytes())?;
fs::rename(partial, self.state_file())
}
pub fn kept(&self) -> Kept {
let len = |path: PathBuf| fs::metadata(path).map_or(0, |meta| meta.len());
Kept {
records: len(self.records_file()),
answers: len(self.answers_file()),
}
}
pub fn take_back_to(&self, kept: Kept) -> io::Result<()> {
for (path, len) in [
(self.records_file(), kept.records),
(self.answers_file(), kept.answers),
] {
let Ok(meta) = fs::metadata(&path) else {
continue;
};
if meta.len() > len {
OpenOptions::new().write(true).open(&path)?.set_len(len)?;
}
}
Ok(())
}
pub fn append(&self, records: &[Record]) -> io::Result<()> {
if records.is_empty() {
return Ok(());
}
create_private_dir_all(&self.dir)?;
let mut text = String::new();
for record in records {
text.push_str(&serde_json::to_string(record).map_err(io::Error::other)?);
text.push('\n');
}
OpenOptions::new()
.create(true)
.append(true)
.mode(0o600)
.open(self.records_file())?
.write_all(text.as_bytes())
}
pub fn retain(&self, keep: &dyn Fn(&Record) -> bool) -> io::Result<()> {
let mut text = String::new();
for record in self.records().iter().filter(|record| keep(record)) {
text.push_str(&serde_json::to_string(record).map_err(io::Error::other)?);
text.push('\n');
}
create_private_dir_all(&self.dir)?;
let partial = self.dir.join("records.jsonl.partial");
OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(&partial)?
.write_all(text.as_bytes())?;
fs::rename(partial, self.records_file())
}
pub fn records(&self) -> Arc<Vec<Record>> {
static CACHE: Mutex<Option<Cached>> = Mutex::new(None);
let path = self.records_file();
let Ok(meta) = fs::metadata(&path) else {
return Arc::new(Vec::new());
};
let stamp = (
meta.len(),
meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
);
let mut cache = CACHE
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some((cached, len, modified, records)) = cache.as_ref()
&& *cached == path
&& (*len, *modified) == stamp
{
return records.clone();
}
let records: Arc<Vec<Record>> = Arc::new(
fs::read_to_string(&path)
.unwrap_or_default()
.lines()
.filter_map(|line| serde_json::from_str(line).ok())
.collect(),
);
*cache = Some((path, stamp.0, stamp.1, records.clone()));
records
}
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::datetime;
#[test]
fn records_and_state_read_back_and_a_change_is_seen() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path());
assert!(store.records().is_empty());
assert_eq!(store.state(), ImportState::default());
let record = Record {
at: datetime!(2026-10-04 13:00 UTC),
cli: "codex".into(),
model: "gpt-5.6".into(),
folder: "/work/app".into(),
input: 10,
output: 5,
cache_read: 90,
cache_write: 0,
session: 1,
subagent: false,
whole_day: false,
};
store.append(std::slice::from_ref(&record)).unwrap();
assert_eq!(*store.records(), vec![record.clone()]);
store.append(std::slice::from_ref(&record)).unwrap();
assert_eq!(store.records().len(), 2);
let state = ImportState {
import: Some(true),
sessions: 1,
..ImportState::default()
};
store.save_state(&state).unwrap();
assert_eq!(store.state(), state);
}
}