use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::config;
use crate::error::Result;
const OFFSETS_FILENAME: &str = "log_forward_offsets.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct FileOffset {
pub offset: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OffsetStore {
#[serde(default)]
offsets: HashMap<String, FileOffset>,
#[serde(skip)]
path: PathBuf,
#[serde(skip)]
dirty: bool,
}
impl OffsetStore {
pub fn default_path() -> Result<PathBuf> {
Ok(config::data_dir()?.join(OFFSETS_FILENAME))
}
pub fn load(path: &Path) -> Self {
let mut store = std::fs::read_to_string(path)
.ok()
.and_then(|contents| serde_json::from_str::<Self>(&contents).ok())
.unwrap_or_default();
store.path = path.to_path_buf();
store
}
#[must_use]
pub fn get(&self, key: &str) -> Option<u64> {
self.offsets.get(key).map(|entry| entry.offset)
}
pub fn set(&mut self, key: &str, offset: u64) {
let entry = self.offsets.entry(key.to_string()).or_default();
if entry.offset != offset {
entry.offset = offset;
self.dirty = true;
}
}
pub fn prune(&mut self, live: &[String]) {
let before = self.offsets.len();
self.offsets.retain(|key, _| live.iter().any(|k| k == key));
if self.offsets.len() != before {
self.dirty = true;
}
}
pub fn keys(&self) -> impl Iterator<Item = &str> {
self.offsets.keys().map(String::as_str)
}
#[must_use]
pub fn is_dirty(&self) -> bool {
self.dirty
}
#[must_use]
pub fn len(&self) -> usize {
self.offsets.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.offsets.is_empty()
}
pub fn save(&mut self) -> Result<()> {
if !self.dirty {
return Ok(());
}
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
let contents = serde_json::to_string_pretty(self)?;
let tmp_path = self.path.with_extension("tmp");
std::fs::write(&tmp_path, &contents)?;
std::fs::rename(&tmp_path, &self.path)?;
self.dirty = false;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn store_at(dir: &Path) -> OffsetStore {
OffsetStore::load(&dir.join(OFFSETS_FILENAME))
}
#[test]
fn absent_file_loads_empty() {
let tmp = tempfile::tempdir().unwrap();
let store = store_at(tmp.path());
assert!(store.is_empty());
assert_eq!(store.get("anything"), None);
}
#[test]
fn positions_survive_a_save_and_reload() {
let tmp = tempfile::tempdir().unwrap();
let mut store = store_at(tmp.path());
store.set("/logs/ant-node.2026-08-19.log", 4096);
store.save().unwrap();
let reloaded = store_at(tmp.path());
assert_eq!(reloaded.get("/logs/ant-node.2026-08-19.log"), Some(4096));
}
#[test]
fn a_corrupt_offsets_file_starts_empty_rather_than_failing() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join(OFFSETS_FILENAME), "{{{ truncated").unwrap();
assert!(store_at(tmp.path()).is_empty());
}
#[test]
fn saving_is_skipped_while_nothing_has_changed() {
let tmp = tempfile::tempdir().unwrap();
let mut store = store_at(tmp.path());
assert!(!store.is_dirty());
store.set("a", 10);
assert!(store.is_dirty());
store.save().unwrap();
assert!(!store.is_dirty());
store.set("a", 10);
assert!(!store.is_dirty());
store.set("a", 11);
assert!(store.is_dirty());
}
#[test]
fn pruning_forgets_files_that_retention_deleted() {
let tmp = tempfile::tempdir().unwrap();
let mut store = store_at(tmp.path());
store.set("old.log", 1);
store.set("current.log", 2);
store.save().unwrap();
store.prune(&["current.log".to_string()]);
assert_eq!(store.get("old.log"), None);
assert_eq!(store.get("current.log"), Some(2));
assert!(store.is_dirty(), "pruning is a change worth persisting");
}
#[test]
fn keys_lists_every_tracked_file() {
let tmp = tempfile::tempdir().unwrap();
let mut store = store_at(tmp.path());
store.set("/logs/node-1/ant-node.2026-08-19.log", 1);
store.set("/logs/node-2/ant-node.2026-08-19.log", 2);
let mut keys: Vec<&str> = store.keys().collect();
keys.sort_unstable();
assert_eq!(
keys,
vec![
"/logs/node-1/ant-node.2026-08-19.log",
"/logs/node-2/ant-node.2026-08-19.log"
]
);
assert!(store.keys().any(|key| key.starts_with("/logs/node-1")));
}
#[test]
fn pruning_nothing_is_not_a_change() {
let tmp = tempfile::tempdir().unwrap();
let mut store = store_at(tmp.path());
store.set("current.log", 2);
store.save().unwrap();
store.prune(&["current.log".to_string()]);
assert!(!store.is_dirty());
}
}