1use std::fmt;
7
8use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior};
9
10use crate::config::IndexKind;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct StandingRootRecord {
14 pub literal_path: String,
15 pub resolved_target: String,
16 pub resolved_git_toplevel: Option<String>,
17 pub scoped_relative_path: Option<String>,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum EnsureStandingRoot {
22 Created,
23 Reloaded,
24}
25
26#[derive(Debug)]
27pub enum StandingRootError {
28 Sqlite(rusqlite::Error),
29 ResolvedPathDrift {
30 literal_path: String,
31 field: &'static str,
32 recorded: Option<String>,
33 resolved: Option<String>,
34 },
35 MissingFreshnessRow {
36 literal_path: String,
37 kind: IndexKind,
38 },
39}
40
41impl fmt::Display for StandingRootError {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43 match self {
44 Self::Sqlite(error) => write!(f, "standing roots database error: {error}"),
45 Self::ResolvedPathDrift {
46 literal_path,
47 field,
48 recorded,
49 resolved,
50 } => write!(
51 f,
52 "resolved-path-drift refusal for {literal_path:?}: {field} changed from {recorded:?} to {resolved:?}"
53 ),
54 Self::MissingFreshnessRow { literal_path, kind } => write!(
55 f,
56 "no standing freshness row exists for {literal_path:?} ({})",
57 kind.as_str()
58 ),
59 }
60 }
61}
62
63impl std::error::Error for StandingRootError {}
64
65impl From<rusqlite::Error> for StandingRootError {
66 fn from(error: rusqlite::Error) -> Self {
67 Self::Sqlite(error)
68 }
69}
70
71pub fn get_standing_root(
73 conn: &Connection,
74 literal_path: &str,
75) -> Result<Option<StandingRootRecord>, StandingRootError> {
76 conn.query_row(
77 "SELECT literal_path, resolved_target, resolved_git_toplevel, scoped_relative_path
78 FROM standing_roots WHERE literal_path = ?1",
79 [literal_path],
80 |row| {
81 Ok(StandingRootRecord {
82 literal_path: row.get(0)?,
83 resolved_target: row.get(1)?,
84 resolved_git_toplevel: row.get(2)?,
85 scoped_relative_path: row.get(3)?,
86 })
87 },
88 )
89 .optional()
90 .map_err(Into::into)
91}
92
93pub fn ensure_standing_root(
96 conn: &mut Connection,
97 candidate: &StandingRootRecord,
98 selected_kinds: &[IndexKind],
99) -> Result<EnsureStandingRoot, StandingRootError> {
100 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
101 let existing = get_standing_root_tx(&tx, &candidate.literal_path)?;
102 let outcome = match existing {
103 Some(recorded) => {
104 ensure_same_identity(&recorded, candidate)?;
105 reconcile_freshness_rows(&tx, &candidate.literal_path, selected_kinds, false)?;
106 EnsureStandingRoot::Reloaded
107 }
108 None => {
109 tx.execute(
110 "INSERT INTO standing_roots (
111 literal_path, resolved_target, resolved_git_toplevel, scoped_relative_path
112 ) VALUES (?1, ?2, ?3, ?4)",
113 params![
114 candidate.literal_path,
115 candidate.resolved_target,
116 candidate.resolved_git_toplevel,
117 candidate.scoped_relative_path,
118 ],
119 )?;
120 reconcile_freshness_rows(&tx, &candidate.literal_path, selected_kinds, true)?;
121 EnsureStandingRoot::Created
122 }
123 };
124 tx.commit()?;
125 Ok(outcome)
126}
127
128pub fn delete_standing_root(
131 conn: &Connection,
132 literal_path: &str,
133) -> Result<(), StandingRootError> {
134 conn.execute(
135 "DELETE FROM standing_roots WHERE literal_path = ?1",
136 [literal_path],
137 )?;
138 Ok(())
139}
140
141pub fn needs_strict_verify(
142 conn: &Connection,
143 literal_path: &str,
144 kind: IndexKind,
145) -> Result<Option<bool>, StandingRootError> {
146 conn.query_row(
147 "SELECT needs_strict_verify FROM standing_root_freshness
148 WHERE literal_path = ?1 AND index_kind = ?2",
149 params![literal_path, kind.as_str()],
150 |row| Ok(row.get::<_, i64>(0)? != 0),
151 )
152 .optional()
153 .map_err(Into::into)
154}
155
156pub fn needs_strict_verify_for_resolved_target(
160 conn: &Connection,
161 resolved_target: &str,
162 kind: IndexKind,
163) -> Result<Option<bool>, StandingRootError> {
164 conn.query_row(
165 "SELECT freshness.needs_strict_verify
166 FROM standing_roots AS roots
167 JOIN standing_root_freshness AS freshness
168 ON freshness.literal_path = roots.literal_path
169 WHERE roots.resolved_target = ?1 AND freshness.index_kind = ?2",
170 params![resolved_target, kind.as_str()],
171 |row| Ok(row.get::<_, i64>(0)? != 0),
172 )
173 .optional()
174 .map_err(Into::into)
175}
176
177pub fn mark_needs_strict_verify(
180 conn: &Connection,
181 literal_path: &str,
182 kind: IndexKind,
183) -> Result<(), StandingRootError> {
184 let updated = conn.execute(
185 "UPDATE standing_root_freshness
186 SET needs_strict_verify = 1, strict_verified_at = NULL
187 WHERE literal_path = ?1 AND index_kind = ?2",
188 params![literal_path, kind.as_str()],
189 )?;
190 if updated == 1 {
191 Ok(())
192 } else {
193 Err(StandingRootError::MissingFreshnessRow {
194 literal_path: literal_path.to_string(),
195 kind,
196 })
197 }
198}
199
200pub fn record_successful_strict_verification(
203 conn: &mut Connection,
204 literal_path: &str,
205 kind: IndexKind,
206 verified_at_ms: i64,
207) -> Result<(), StandingRootError> {
208 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
209 record_successful_strict_verification_in_transaction(&tx, literal_path, kind, verified_at_ms)?;
210 tx.commit()?;
211 Ok(())
212}
213
214pub fn record_successful_strict_verification_in_transaction(
218 tx: &Transaction<'_>,
219 literal_path: &str,
220 kind: IndexKind,
221 verified_at_ms: i64,
222) -> Result<(), StandingRootError> {
223 let updated = tx.execute(
224 "UPDATE standing_root_freshness
225 SET strict_verified_at = ?3, needs_strict_verify = 0
226 WHERE literal_path = ?1 AND index_kind = ?2",
227 params![literal_path, kind.as_str(), verified_at_ms],
228 )?;
229 if updated == 1 {
230 Ok(())
231 } else {
232 Err(StandingRootError::MissingFreshnessRow {
233 literal_path: literal_path.to_string(),
234 kind,
235 })
236 }
237}
238
239fn get_standing_root_tx(
240 tx: &Transaction<'_>,
241 literal_path: &str,
242) -> Result<Option<StandingRootRecord>, StandingRootError> {
243 tx.query_row(
244 "SELECT literal_path, resolved_target, resolved_git_toplevel, scoped_relative_path
245 FROM standing_roots WHERE literal_path = ?1",
246 [literal_path],
247 |row| {
248 Ok(StandingRootRecord {
249 literal_path: row.get(0)?,
250 resolved_target: row.get(1)?,
251 resolved_git_toplevel: row.get(2)?,
252 scoped_relative_path: row.get(3)?,
253 })
254 },
255 )
256 .optional()
257 .map_err(Into::into)
258}
259
260fn ensure_same_identity(
261 recorded: &StandingRootRecord,
262 candidate: &StandingRootRecord,
263) -> Result<(), StandingRootError> {
264 for (field, recorded_value, candidate_value) in [
265 (
266 "resolved_target",
267 Some(recorded.resolved_target.clone()),
268 Some(candidate.resolved_target.clone()),
269 ),
270 (
271 "resolved_git_toplevel",
272 recorded.resolved_git_toplevel.clone(),
273 candidate.resolved_git_toplevel.clone(),
274 ),
275 (
276 "scoped_relative_path",
277 recorded.scoped_relative_path.clone(),
278 candidate.scoped_relative_path.clone(),
279 ),
280 ] {
281 if recorded_value != candidate_value {
282 return Err(StandingRootError::ResolvedPathDrift {
283 literal_path: candidate.literal_path.clone(),
284 field,
285 recorded: recorded_value,
286 resolved: candidate_value,
287 });
288 }
289 }
290 Ok(())
291}
292
293fn reconcile_freshness_rows(
294 tx: &Transaction<'_>,
295 literal_path: &str,
296 selected_kinds: &[IndexKind],
297 creating: bool,
298) -> Result<(), StandingRootError> {
299 for kind in IndexKind::ALL {
300 if selected_kinds.contains(&kind) {
301 tx.execute(
302 "INSERT INTO standing_root_freshness (
303 literal_path, index_kind, needs_strict_verify, strict_verified_at
304 ) VALUES (?1, ?2, 1, NULL)
305 ON CONFLICT(literal_path, index_kind) DO NOTHING",
306 params![literal_path, kind.as_str()],
307 )?;
308 } else if !creating {
309 tx.execute(
310 "DELETE FROM standing_root_freshness
311 WHERE literal_path = ?1 AND index_kind = ?2",
312 params![literal_path, kind.as_str()],
313 )?;
314 }
315 }
316 Ok(())
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use crate::db;
323 use tempfile::tempdir;
324
325 fn record(path: &str, target: &str) -> StandingRootRecord {
326 StandingRootRecord {
327 literal_path: path.to_string(),
328 resolved_target: target.to_string(),
329 resolved_git_toplevel: Some("/repo".to_string()),
330 scoped_relative_path: Some("src".to_string()),
331 }
332 }
333
334 #[test]
335 fn root_identity_is_machine_scoped_and_pins_literal_spelling() {
336 let dir = tempdir().unwrap();
337 let mut conn = db::open(&dir.path().join("aft.db")).unwrap();
338 let first = record("~/work/src", "/real/repo/src");
339 assert_eq!(
340 ensure_standing_root(&mut conn, &first, &[IndexKind::Search, IndexKind::Semantic])
341 .unwrap(),
342 EnsureStandingRoot::Created
343 );
344 assert!(
345 needs_strict_verify(&conn, "~/work/src", IndexKind::Semantic)
346 .unwrap()
347 .unwrap()
348 );
349 assert!(needs_strict_verify(&conn, "~/work/src", IndexKind::Search)
350 .unwrap()
351 .unwrap());
352
353 assert_eq!(
354 ensure_standing_root(&mut conn, &first, &[IndexKind::Search, IndexKind::Semantic])
355 .unwrap(),
356 EnsureStandingRoot::Reloaded
357 );
358 let drift = ensure_standing_root(
359 &mut conn,
360 &record("~/work/src", "/retargeted/repo/src"),
361 &[IndexKind::Search],
362 )
363 .unwrap_err();
364 assert!(matches!(
365 drift,
366 StandingRootError::ResolvedPathDrift {
367 field: "resolved_target",
368 ..
369 }
370 ));
371 assert_eq!(
372 get_standing_root(&conn, "~/work/src")
373 .unwrap()
374 .unwrap()
375 .resolved_target,
376 "/real/repo/src"
377 );
378 }
379
380 #[test]
381 fn deletion_removes_resolution_and_freshness_without_artifact_gc() {
382 let dir = tempdir().unwrap();
383 let mut conn = db::open(&dir.path().join("aft.db")).unwrap();
384 ensure_standing_root(
385 &mut conn,
386 &record("/one", "/repo/one"),
387 &[IndexKind::Search],
388 )
389 .unwrap();
390 ensure_standing_root(
391 &mut conn,
392 &record("/two", "/repo/two"),
393 &[IndexKind::Search],
394 )
395 .unwrap();
396 delete_standing_root(&conn, "/one").unwrap();
397 assert!(get_standing_root(&conn, "/one").unwrap().is_none());
398 assert!(needs_strict_verify(&conn, "/one", IndexKind::Search)
399 .unwrap()
400 .is_none());
401 assert!(get_standing_root(&conn, "/two").unwrap().is_some());
402 }
403
404 #[test]
405 fn strict_verification_clear_is_atomic_and_drop_before_commit_keeps_flag() {
406 let dir = tempdir().unwrap();
407 let mut conn = db::open(&dir.path().join("aft.db")).unwrap();
408 ensure_standing_root(
409 &mut conn,
410 &record("/one", "/repo/one"),
411 &[IndexKind::Search],
412 )
413 .unwrap();
414
415 {
416 let tx = conn.transaction().unwrap();
417 record_successful_strict_verification_in_transaction(&tx, "/one", IndexKind::Search, 9)
418 .unwrap();
419 }
421 assert!(needs_strict_verify(&conn, "/one", IndexKind::Search)
422 .unwrap()
423 .unwrap());
424
425 record_successful_strict_verification(&mut conn, "/one", IndexKind::Search, 10).unwrap();
426 assert!(!needs_strict_verify(&conn, "/one", IndexKind::Search)
427 .unwrap()
428 .unwrap());
429 }
430}