1use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7
8use crate::numeric::count_u32;
9
10mod time;
11
12pub use time::{TOMBSTONE_RETENTION_MS, now_ms, parse_since};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18pub enum FileState {
19 Active,
21 Tombstoned,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct ChangeEntry {
28 pub path: String,
30 pub last_indexed_at: u64,
32 pub state: FileState,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct ChangeFeed {
39 pub added: Vec<ChangeEntry>,
41 pub modified: Vec<ChangeEntry>,
43 pub deleted: Vec<ChangeEntry>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub struct TombstoneEntry {
50 pub path: String,
52 pub deleted_at: u64,
54 pub last_indexed_at: u64,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct IndexMetadata {
61 pub schema_version: u32,
63 pub last_indexed_at: u64,
65 pub last_seen_at: u64,
67 pub active_notes: u32,
69 pub chunk_count: u32,
71 pub tombstone_count: u32,
73}
74
75impl Default for IndexMetadata {
76 fn default() -> Self {
77 Self {
78 schema_version: 1,
79 last_indexed_at: 0,
80 last_seen_at: 0,
81 active_notes: 0,
82 chunk_count: 0,
83 tombstone_count: 0,
84 }
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct FileChangeState {
91 pub path: String,
93 pub last_indexed_at: u64,
95 pub last_seen_at: u64,
97 pub mtime: u64,
99 pub tombstoned: bool,
101 pub tombstoned_at: Option<u64>,
103}
104
105#[allow(clippy::missing_const_for_fn)]
106impl FileChangeState {
107 #[must_use]
109 pub fn active(path: String, mtime: u64) -> Self {
110 Self {
111 path,
112 last_indexed_at: 0,
113 last_seen_at: 0,
114 mtime,
115 tombstoned: false,
116 tombstoned_at: None,
117 }
118 }
119
120 pub fn mark_indexed(&mut self, timestamp: u64) {
122 self.last_indexed_at = timestamp;
123 self.last_seen_at = timestamp;
124 }
125
126 pub fn mark_seen(&mut self, timestamp: u64) {
128 self.last_seen_at = timestamp;
129 }
130
131 pub fn update_mtime(&mut self, mtime: u64) {
133 self.mtime = mtime;
134 }
135
136 pub fn tombstone(&mut self, timestamp: u64) {
138 self.tombstoned = true;
139 self.tombstoned_at = Some(timestamp);
140 }
141
142 #[must_use]
148 pub fn is_modified(&self) -> bool {
149 self.last_indexed_at > 0 && self.mtime > self.last_indexed_at
150 }
151
152 #[must_use]
154 pub fn is_active(&self) -> bool {
155 !self.tombstoned
156 }
157}
158
159#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
161pub struct ChangeIndex {
162 pub states: BTreeMap<String, FileChangeState>,
164 pub tombstones: BTreeMap<String, TombstoneEntry>,
166}
167
168impl ChangeIndex {
169 pub fn register_active(&mut self, path: String, mtime: u64, timestamp: u64) {
171 let mut state = FileChangeState::active(path.clone(), mtime);
172 state.mark_indexed(timestamp);
173 state.mark_seen(timestamp);
174 self.states.insert(path, state);
175 }
176
177 pub fn update_mtime(&mut self, path: &str, mtime: u64) {
179 if let Some(state) = self.states.get_mut(path) {
180 state.update_mtime(mtime);
181 state.mark_seen(mtime);
182 }
183 }
184
185 pub fn mark_seen(&mut self, path: &str, timestamp: u64) {
187 if let Some(state) = self.states.get_mut(path) {
188 state.mark_seen(timestamp);
189 }
190 }
191
192 pub fn tombstone(&mut self, path: &str, timestamp: u64) {
194 if let Some(state) = self.states.get_mut(path) {
195 state.tombstone(timestamp);
196 self.tombstones.insert(
197 path.to_string(),
198 TombstoneEntry {
199 path: path.to_string(),
200 deleted_at: timestamp,
201 last_indexed_at: state.last_indexed_at,
202 },
203 );
204 }
205 }
206
207 pub fn remove(&mut self, path: &str) {
209 self.states.remove(path);
210 self.tombstones.remove(path);
211 }
212
213 #[must_use]
215 pub fn get_changes_since(&self, since: u64) -> (Vec<String>, Vec<String>) {
216 let mut added = Vec::new();
217 let mut modified = Vec::new();
218
219 for (path, state) in &self.states {
220 if state.last_indexed_at < since && state.last_seen_at >= since {
221 if state.is_modified() {
222 modified.push(path.clone());
223 } else {
224 added.push(path.clone());
225 }
226 }
227 }
228
229 added.sort();
230 modified.sort();
231
232 (added, modified)
233 }
234
235 #[must_use]
237 pub fn get_tombstones(&self) -> Vec<&TombstoneEntry> {
238 self.tombstones.values().collect()
239 }
240
241 pub fn prune_tombstones(&mut self, max_age_ms: u64, current_time: u64) -> Vec<String> {
243 let mut pruned = Vec::new();
244 self.tombstones.retain(|path, entry| {
245 if current_time - entry.deleted_at > max_age_ms {
246 pruned.push(path.clone());
247 false
248 } else {
249 true
250 }
251 });
252 pruned
253 }
254
255 #[must_use]
257 pub fn compute_change_feed(&self, since: u64) -> ChangeFeed {
258 let mut added = Vec::new();
259 let mut modified = Vec::new();
260 let mut deleted = Vec::new();
261
262 for (path, state) in &self.states {
263 if state.last_seen_at >= since {
264 let entry = ChangeEntry {
265 path: path.clone(),
266 last_indexed_at: state.last_indexed_at,
267 state: FileState::Active,
268 };
269 if state.is_modified() {
270 modified.push(entry);
271 } else {
272 added.push(entry);
273 }
274 }
275 }
276
277 for (path, entry) in &self.tombstones {
278 if entry.deleted_at >= since {
279 deleted.push(ChangeEntry {
280 path: path.clone(),
281 last_indexed_at: entry.last_indexed_at,
282 state: FileState::Tombstoned,
283 });
284 }
285 }
286
287 added.sort_by_key(|e| e.path.clone());
288 modified.sort_by_key(|e| e.path.clone());
289 deleted.sort_by_key(|e| e.path.clone());
290
291 ChangeFeed {
292 added,
293 modified,
294 deleted,
295 }
296 }
297
298 #[must_use]
300 pub fn to_metadata(&self) -> IndexMetadata {
301 IndexMetadata {
302 schema_version: 1,
303 last_indexed_at: self
304 .states
305 .values()
306 .map(|s| s.last_indexed_at)
307 .max()
308 .unwrap_or(0),
309 last_seen_at: self
310 .states
311 .values()
312 .map(|s| s.last_seen_at)
313 .max()
314 .unwrap_or(0),
315 active_notes: count_u32(self.states.values().filter(|s| s.is_active()).count()),
316 chunk_count: 0,
317 tombstone_count: count_u32(self.tombstones.len()),
318 }
319 }
320}
321
322#[cfg(test)]
323#[allow(clippy::unwrap_used, clippy::expect_used)]
324mod tests;