1use std::error::Error;
8use std::fmt;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use rusqlite::{params, Connection, OptionalExtension};
13
14pub const VISIBLE_PATH_CAP: usize = 20;
16
17const PATH_STATUS_SCHEMA: &str = r#"
18CREATE TABLE IF NOT EXISTS path_status (
19 rel_path BLOB NOT NULL PRIMARY KEY,
20 state TEXT NOT NULL CHECK(state IN ('pending', 'failed')),
21 reason TEXT NOT NULL,
22 since_generation INTEGER NOT NULL CHECK(since_generation >= 0)
23) WITHOUT ROWID;
24CREATE TABLE IF NOT EXISTS maintenance_outcomes (
25 operation TEXT NOT NULL PRIMARY KEY,
26 outcome TEXT NOT NULL,
27 generation INTEGER NOT NULL CHECK(generation >= 0)
28) WITHOUT ROWID;
29"#;
30
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub enum PathState {
34 Pending,
35 Failed,
36}
37
38impl PathState {
39 pub const fn as_str(self) -> &'static str {
40 match self {
41 Self::Pending => "pending",
42 Self::Failed => "failed",
43 }
44 }
45
46 fn parse(value: &str) -> Option<Self> {
47 match value {
48 "pending" => Some(Self::Pending),
49 "failed" => Some(Self::Failed),
50 _ => None,
51 }
52 }
53}
54
55#[derive(Clone, Debug, Eq, PartialEq)]
57pub struct PathStatus {
58 pub rel_path: Vec<u8>,
59 pub state: PathState,
60 pub reason: String,
61 pub since_generation: u64,
62}
63
64#[derive(Clone, Debug, Eq, PartialEq)]
67pub struct PathStatusSummary {
68 pub pending_count: usize,
69 pub failed_count: usize,
70 pub paths: Vec<PathStatus>,
71}
72
73#[derive(Debug)]
74pub enum PathStatusError {
75 Io(std::io::Error),
76 Sqlite(rusqlite::Error),
77 InvalidState(String),
78 InvalidGeneration(i64),
79 GenerationOutOfRange(u64),
80}
81
82impl fmt::Display for PathStatusError {
83 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84 match self {
85 Self::Io(error) => write!(f, "path-status I/O error: {error}"),
86 Self::Sqlite(error) => write!(f, "path-status SQLite error: {error}"),
87 Self::InvalidState(state) => write!(f, "invalid path-status state `{state}`"),
88 Self::InvalidGeneration(generation) => {
89 write!(f, "invalid negative path-status generation {generation}")
90 }
91 Self::GenerationOutOfRange(generation) => {
92 write!(
93 f,
94 "path-status generation {generation} exceeds SQLite INTEGER"
95 )
96 }
97 }
98 }
99}
100
101impl Error for PathStatusError {
102 fn source(&self) -> Option<&(dyn Error + 'static)> {
103 match self {
104 Self::Io(error) => Some(error),
105 Self::Sqlite(error) => Some(error),
106 Self::InvalidState(_) | Self::InvalidGeneration(_) | Self::GenerationOutOfRange(_) => {
107 None
108 }
109 }
110 }
111}
112
113impl From<std::io::Error> for PathStatusError {
114 fn from(error: std::io::Error) -> Self {
115 Self::Io(error)
116 }
117}
118
119impl From<rusqlite::Error> for PathStatusError {
120 fn from(error: rusqlite::Error) -> Self {
121 Self::Sqlite(error)
122 }
123}
124
125#[derive(Debug)]
127pub struct PathStatusStore {
128 path: PathBuf,
129 connection: Connection,
130}
131
132impl PathStatusStore {
133 pub fn open(view_dir: &Path) -> Result<Self, PathStatusError> {
135 Self::open_at(&view_dir.join("derived.sqlite"))
136 }
137
138 pub fn open_at(path: &Path) -> Result<Self, PathStatusError> {
141 if let Some(parent) = path.parent() {
142 fs::create_dir_all(parent)?;
143 }
144 let connection = Connection::open(path)?;
145 connection.execute_batch(PATH_STATUS_SCHEMA)?;
146 Ok(Self {
147 path: path.to_path_buf(),
148 connection,
149 })
150 }
151
152 pub fn path(&self) -> &Path {
153 &self.path
154 }
155
156 pub fn mark_pending(
159 &mut self,
160 rel_path: &[u8],
161 reason: impl Into<String>,
162 since_generation: u64,
163 ) -> Result<(), PathStatusError> {
164 self.upsert(
165 rel_path,
166 PathState::Pending,
167 reason.into(),
168 since_generation,
169 )
170 }
171
172 pub fn mark_failed(
174 &mut self,
175 rel_path: &[u8],
176 reason: impl Into<String>,
177 since_generation: u64,
178 ) -> Result<(), PathStatusError> {
179 self.upsert(rel_path, PathState::Failed, reason.into(), since_generation)
180 }
181
182 pub fn clear(&mut self, rel_path: &[u8]) -> Result<(), PathStatusError> {
184 self.connection.execute(
185 "DELETE FROM path_status WHERE rel_path = ?1",
186 params![rel_path],
187 )?;
188 Ok(())
189 }
190
191 pub fn status_for(&self, rel_path: &[u8]) -> Result<Option<PathStatus>, PathStatusError> {
192 let row = self
193 .connection
194 .query_row(
195 "SELECT rel_path, state, reason, since_generation
196 FROM path_status WHERE rel_path = ?1",
197 params![rel_path],
198 |row| {
199 Ok((
200 row.get::<_, Vec<u8>>(0)?,
201 row.get::<_, String>(1)?,
202 row.get::<_, String>(2)?,
203 row.get::<_, i64>(3)?,
204 ))
205 },
206 )
207 .optional()?;
208 row.map(Self::decode_row).transpose()
209 }
210
211 pub fn record_maintenance_outcome(
213 &mut self,
214 operation: &str,
215 outcome: &str,
216 generation: u64,
217 ) -> Result<(), PathStatusError> {
218 self.connection.execute(
219 "INSERT INTO maintenance_outcomes(operation, outcome, generation)
220 VALUES (?1, ?2, ?3)
221 ON CONFLICT(operation) DO NOTHING",
222 params![operation, outcome, generation],
223 )?;
224 Ok(())
225 }
226
227 pub fn maintenance_outcome(
228 &self,
229 operation: &str,
230 ) -> Result<Option<(String, u64)>, PathStatusError> {
231 self.connection
232 .query_row(
233 "SELECT outcome, generation FROM maintenance_outcomes WHERE operation = ?1",
234 [operation],
235 |row| Ok((row.get(0)?, row.get(1)?)),
236 )
237 .optional()
238 .map_err(PathStatusError::from)
239 }
240
241 pub fn summary(&self) -> Result<PathStatusSummary, PathStatusError> {
242 let (pending_count, failed_count) = self.connection.query_row(
243 "SELECT
244 COALESCE(SUM(CASE WHEN state = 'pending' THEN 1 ELSE 0 END), 0),
245 COALESCE(SUM(CASE WHEN state = 'failed' THEN 1 ELSE 0 END), 0)
246 FROM path_status",
247 [],
248 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
249 )?;
250 let pending_count = usize::try_from(pending_count)
251 .map_err(|_| PathStatusError::InvalidGeneration(pending_count))?;
252 let failed_count = usize::try_from(failed_count)
253 .map_err(|_| PathStatusError::InvalidGeneration(failed_count))?;
254
255 let mut statement = self.connection.prepare(
256 "SELECT rel_path, state, reason, since_generation
257 FROM path_status
258 ORDER BY rel_path
259 LIMIT ?1",
260 )?;
261 let paths = statement
262 .query_map(params![VISIBLE_PATH_CAP as i64], |row| {
263 Ok((
264 row.get::<_, Vec<u8>>(0)?,
265 row.get::<_, String>(1)?,
266 row.get::<_, String>(2)?,
267 row.get::<_, i64>(3)?,
268 ))
269 })?
270 .collect::<Result<Vec<_>, _>>()?
271 .into_iter()
272 .map(Self::decode_row)
273 .collect::<Result<Vec<_>, _>>()?;
274
275 Ok(PathStatusSummary {
276 pending_count,
277 failed_count,
278 paths,
279 })
280 }
281
282 fn upsert(
283 &mut self,
284 rel_path: &[u8],
285 state: PathState,
286 reason: String,
287 since_generation: u64,
288 ) -> Result<(), PathStatusError> {
289 let generation = i64::try_from(since_generation)
290 .map_err(|_| PathStatusError::GenerationOutOfRange(since_generation))?;
291 self.connection.execute(
292 "INSERT INTO path_status (rel_path, state, reason, since_generation)
293 VALUES (?1, ?2, ?3, ?4)
294 ON CONFLICT(rel_path) DO UPDATE SET
295 state = excluded.state,
296 reason = excluded.reason,
297 since_generation = CASE
298 WHEN path_status.state = excluded.state THEN path_status.since_generation
299 ELSE excluded.since_generation
300 END",
301 params![rel_path, state.as_str(), reason, generation],
302 )?;
303 Ok(())
304 }
305
306 fn decode_row(
307 (rel_path, state, reason, since_generation): (Vec<u8>, String, String, i64),
308 ) -> Result<PathStatus, PathStatusError> {
309 Ok(PathStatus {
310 rel_path,
311 state: PathState::parse(&state).ok_or(PathStatusError::InvalidState(state))?,
312 reason,
313 since_generation: u64::try_from(since_generation)
314 .map_err(|_| PathStatusError::InvalidGeneration(since_generation))?,
315 })
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 #[test]
324 fn summary_counts_all_rows_but_caps_and_orders_visible_paths_by_bytes() {
325 let dir = tempfile::tempdir().expect("create view dir");
326 let mut store = PathStatusStore::open(dir.path()).expect("open path-status store");
327 assert_eq!(
328 store.summary().expect("summarize empty statuses"),
329 PathStatusSummary {
330 pending_count: 0,
331 failed_count: 0,
332 paths: Vec::new(),
333 }
334 );
335 for index in 0..25 {
336 store
337 .mark_pending(format!("z{index:02}").as_bytes(), "read changed", 7)
338 .expect("mark pending");
339 }
340 store
341 .mark_failed(b"\x80binary", "quarantined", 8)
342 .expect("mark failed");
343 store
344 .mark_failed(b"a/path", "quota", 9)
345 .expect("mark failed");
346
347 let summary = store.summary().expect("summarize statuses");
348 assert_eq!(summary.pending_count, 25);
349 assert_eq!(summary.failed_count, 2);
350 assert_eq!(summary.paths.len(), VISIBLE_PATH_CAP);
351 assert_eq!(summary.paths[0].rel_path, b"a/path");
352 assert_eq!(summary.paths[1].rel_path, b"z00");
353 assert!(summary
354 .paths
355 .windows(2)
356 .all(|paths| paths[0].rel_path <= paths[1].rel_path));
357 }
358
359 #[test]
360 fn repeated_state_preserves_annotation_age_and_success_clears_it() {
361 let dir = tempfile::tempdir().expect("create view dir");
362 let mut store = PathStatusStore::open(dir.path()).expect("open path-status store");
363
364 store
365 .mark_pending(b"src/lib.rs", "read changed", 3)
366 .expect("mark pending");
367 store
368 .mark_pending(b"src/lib.rs", "still changing", 9)
369 .expect("repeat pending");
370 assert_eq!(
371 store
372 .status_for(b"src/lib.rs")
373 .expect("read status")
374 .expect("pending row")
375 .since_generation,
376 3
377 );
378
379 store
380 .mark_failed(b"src/lib.rs", "quarantined", 10)
381 .expect("mark failed");
382 assert_eq!(
383 store
384 .status_for(b"src/lib.rs")
385 .expect("read status")
386 .expect("failed row")
387 .since_generation,
388 10
389 );
390 store.clear(b"src/lib.rs").expect("clear completed path");
391 assert!(store
392 .status_for(b"src/lib.rs")
393 .expect("read status")
394 .is_none());
395 }
396}