ant_core/node/daemon/forward/
offsets.rs1use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16
17use crate::config;
18use crate::error::Result;
19
20const OFFSETS_FILENAME: &str = "log_forward_offsets.json";
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
25pub struct FileOffset {
26 pub offset: u64,
28}
29
30#[derive(Debug, Clone, Default, Serialize, Deserialize)]
32pub struct OffsetStore {
33 #[serde(default)]
34 offsets: HashMap<String, FileOffset>,
35
36 #[serde(skip)]
37 path: PathBuf,
38
39 #[serde(skip)]
42 dirty: bool,
43}
44
45impl OffsetStore {
46 pub fn default_path() -> Result<PathBuf> {
48 Ok(config::data_dir()?.join(OFFSETS_FILENAME))
49 }
50
51 pub fn load(path: &Path) -> Self {
57 let mut store = std::fs::read_to_string(path)
58 .ok()
59 .and_then(|contents| serde_json::from_str::<Self>(&contents).ok())
60 .unwrap_or_default();
61 store.path = path.to_path_buf();
62 store
63 }
64
65 #[must_use]
67 pub fn get(&self, key: &str) -> Option<u64> {
68 self.offsets.get(key).map(|entry| entry.offset)
69 }
70
71 pub fn set(&mut self, key: &str, offset: u64) {
73 let entry = self.offsets.entry(key.to_string()).or_default();
74 if entry.offset != offset {
75 entry.offset = offset;
76 self.dirty = true;
77 }
78 }
79
80 pub fn prune(&mut self, live: &[String]) {
82 let before = self.offsets.len();
83 self.offsets.retain(|key, _| live.iter().any(|k| k == key));
84 if self.offsets.len() != before {
85 self.dirty = true;
86 }
87 }
88
89 pub fn keys(&self) -> impl Iterator<Item = &str> {
95 self.offsets.keys().map(String::as_str)
96 }
97
98 #[must_use]
100 pub fn is_dirty(&self) -> bool {
101 self.dirty
102 }
103
104 #[must_use]
105 pub fn len(&self) -> usize {
106 self.offsets.len()
107 }
108
109 #[must_use]
110 pub fn is_empty(&self) -> bool {
111 self.offsets.is_empty()
112 }
113
114 pub fn save(&mut self) -> Result<()> {
119 if !self.dirty {
120 return Ok(());
121 }
122 if let Some(parent) = self.path.parent() {
123 std::fs::create_dir_all(parent)?;
124 }
125 let contents = serde_json::to_string_pretty(self)?;
126 let tmp_path = self.path.with_extension("tmp");
127 std::fs::write(&tmp_path, &contents)?;
128 std::fs::rename(&tmp_path, &self.path)?;
129 self.dirty = false;
130 Ok(())
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137
138 fn store_at(dir: &Path) -> OffsetStore {
139 OffsetStore::load(&dir.join(OFFSETS_FILENAME))
140 }
141
142 #[test]
143 fn absent_file_loads_empty() {
144 let tmp = tempfile::tempdir().unwrap();
145 let store = store_at(tmp.path());
146 assert!(store.is_empty());
147 assert_eq!(store.get("anything"), None);
148 }
149
150 #[test]
151 fn positions_survive_a_save_and_reload() {
152 let tmp = tempfile::tempdir().unwrap();
153 let mut store = store_at(tmp.path());
154 store.set("/logs/ant-node.2026-08-19.log", 4096);
155 store.save().unwrap();
156
157 let reloaded = store_at(tmp.path());
158 assert_eq!(reloaded.get("/logs/ant-node.2026-08-19.log"), Some(4096));
159 }
160
161 #[test]
162 fn a_corrupt_offsets_file_starts_empty_rather_than_failing() {
163 let tmp = tempfile::tempdir().unwrap();
164 std::fs::write(tmp.path().join(OFFSETS_FILENAME), "{{{ truncated").unwrap();
165 assert!(store_at(tmp.path()).is_empty());
166 }
167
168 #[test]
169 fn saving_is_skipped_while_nothing_has_changed() {
170 let tmp = tempfile::tempdir().unwrap();
171 let mut store = store_at(tmp.path());
172 assert!(!store.is_dirty());
173
174 store.set("a", 10);
175 assert!(store.is_dirty());
176 store.save().unwrap();
177 assert!(!store.is_dirty());
178
179 store.set("a", 10);
181 assert!(!store.is_dirty());
182
183 store.set("a", 11);
184 assert!(store.is_dirty());
185 }
186
187 #[test]
188 fn pruning_forgets_files_that_retention_deleted() {
189 let tmp = tempfile::tempdir().unwrap();
190 let mut store = store_at(tmp.path());
191 store.set("old.log", 1);
192 store.set("current.log", 2);
193 store.save().unwrap();
194
195 store.prune(&["current.log".to_string()]);
196
197 assert_eq!(store.get("old.log"), None);
198 assert_eq!(store.get("current.log"), Some(2));
199 assert!(store.is_dirty(), "pruning is a change worth persisting");
200 }
201
202 #[test]
203 fn keys_lists_every_tracked_file() {
204 let tmp = tempfile::tempdir().unwrap();
205 let mut store = store_at(tmp.path());
206 store.set("/logs/node-1/ant-node.2026-08-19.log", 1);
207 store.set("/logs/node-2/ant-node.2026-08-19.log", 2);
208
209 let mut keys: Vec<&str> = store.keys().collect();
210 keys.sort_unstable();
211 assert_eq!(
212 keys,
213 vec![
214 "/logs/node-1/ant-node.2026-08-19.log",
215 "/logs/node-2/ant-node.2026-08-19.log"
216 ]
217 );
218 assert!(store.keys().any(|key| key.starts_with("/logs/node-1")));
219 }
220
221 #[test]
222 fn pruning_nothing_is_not_a_change() {
223 let tmp = tempfile::tempdir().unwrap();
224 let mut store = store_at(tmp.path());
225 store.set("current.log", 2);
226 store.save().unwrap();
227
228 store.prune(&["current.log".to_string()]);
229 assert!(!store.is_dirty());
230 }
231}