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 mark_needs_strict_verify(
159 conn: &Connection,
160 literal_path: &str,
161 kind: IndexKind,
162) -> Result<(), StandingRootError> {
163 let updated = conn.execute(
164 "UPDATE standing_root_freshness
165 SET needs_strict_verify = 1, strict_verified_at = NULL
166 WHERE literal_path = ?1 AND index_kind = ?2",
167 params![literal_path, kind.as_str()],
168 )?;
169 if updated == 1 {
170 Ok(())
171 } else {
172 Err(StandingRootError::MissingFreshnessRow {
173 literal_path: literal_path.to_string(),
174 kind,
175 })
176 }
177}
178
179pub fn record_successful_strict_verification(
182 conn: &mut Connection,
183 literal_path: &str,
184 kind: IndexKind,
185 verified_at_ms: i64,
186) -> Result<(), StandingRootError> {
187 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
188 record_successful_strict_verification_in_transaction(&tx, literal_path, kind, verified_at_ms)?;
189 tx.commit()?;
190 Ok(())
191}
192
193pub fn record_successful_strict_verification_in_transaction(
197 tx: &Transaction<'_>,
198 literal_path: &str,
199 kind: IndexKind,
200 verified_at_ms: i64,
201) -> Result<(), StandingRootError> {
202 let updated = tx.execute(
203 "UPDATE standing_root_freshness
204 SET strict_verified_at = ?3, needs_strict_verify = 0
205 WHERE literal_path = ?1 AND index_kind = ?2",
206 params![literal_path, kind.as_str(), verified_at_ms],
207 )?;
208 if updated == 1 {
209 Ok(())
210 } else {
211 Err(StandingRootError::MissingFreshnessRow {
212 literal_path: literal_path.to_string(),
213 kind,
214 })
215 }
216}
217
218fn get_standing_root_tx(
219 tx: &Transaction<'_>,
220 literal_path: &str,
221) -> Result<Option<StandingRootRecord>, StandingRootError> {
222 tx.query_row(
223 "SELECT literal_path, resolved_target, resolved_git_toplevel, scoped_relative_path
224 FROM standing_roots WHERE literal_path = ?1",
225 [literal_path],
226 |row| {
227 Ok(StandingRootRecord {
228 literal_path: row.get(0)?,
229 resolved_target: row.get(1)?,
230 resolved_git_toplevel: row.get(2)?,
231 scoped_relative_path: row.get(3)?,
232 })
233 },
234 )
235 .optional()
236 .map_err(Into::into)
237}
238
239fn ensure_same_identity(
240 recorded: &StandingRootRecord,
241 candidate: &StandingRootRecord,
242) -> Result<(), StandingRootError> {
243 for (field, recorded_value, candidate_value) in [
244 (
245 "resolved_target",
246 Some(recorded.resolved_target.clone()),
247 Some(candidate.resolved_target.clone()),
248 ),
249 (
250 "resolved_git_toplevel",
251 recorded.resolved_git_toplevel.clone(),
252 candidate.resolved_git_toplevel.clone(),
253 ),
254 (
255 "scoped_relative_path",
256 recorded.scoped_relative_path.clone(),
257 candidate.scoped_relative_path.clone(),
258 ),
259 ] {
260 if recorded_value != candidate_value {
261 return Err(StandingRootError::ResolvedPathDrift {
262 literal_path: candidate.literal_path.clone(),
263 field,
264 recorded: recorded_value,
265 resolved: candidate_value,
266 });
267 }
268 }
269 Ok(())
270}
271
272fn reconcile_freshness_rows(
273 tx: &Transaction<'_>,
274 literal_path: &str,
275 selected_kinds: &[IndexKind],
276 creating: bool,
277) -> Result<(), StandingRootError> {
278 for kind in IndexKind::ALL {
279 if selected_kinds.contains(&kind) {
280 tx.execute(
281 "INSERT INTO standing_root_freshness (
282 literal_path, index_kind, needs_strict_verify, strict_verified_at
283 ) VALUES (?1, ?2, 1, NULL)
284 ON CONFLICT(literal_path, index_kind) DO NOTHING",
285 params![literal_path, kind.as_str()],
286 )?;
287 } else if !creating {
288 tx.execute(
289 "DELETE FROM standing_root_freshness
290 WHERE literal_path = ?1 AND index_kind = ?2",
291 params![literal_path, kind.as_str()],
292 )?;
293 }
294 }
295 Ok(())
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use crate::db;
302 use tempfile::tempdir;
303
304 fn record(path: &str, target: &str) -> StandingRootRecord {
305 StandingRootRecord {
306 literal_path: path.to_string(),
307 resolved_target: target.to_string(),
308 resolved_git_toplevel: Some("/repo".to_string()),
309 scoped_relative_path: Some("src".to_string()),
310 }
311 }
312
313 #[test]
314 fn root_identity_is_machine_scoped_and_pins_literal_spelling() {
315 let dir = tempdir().unwrap();
316 let mut conn = db::open(&dir.path().join("aft.db")).unwrap();
317 let first = record("~/work/src", "/real/repo/src");
318 assert_eq!(
319 ensure_standing_root(&mut conn, &first, &[IndexKind::Search, IndexKind::Semantic])
320 .unwrap(),
321 EnsureStandingRoot::Created
322 );
323 assert!(
324 needs_strict_verify(&conn, "~/work/src", IndexKind::Semantic)
325 .unwrap()
326 .unwrap()
327 );
328 assert!(needs_strict_verify(&conn, "~/work/src", IndexKind::Search)
329 .unwrap()
330 .unwrap());
331
332 assert_eq!(
333 ensure_standing_root(&mut conn, &first, &[IndexKind::Search, IndexKind::Semantic])
334 .unwrap(),
335 EnsureStandingRoot::Reloaded
336 );
337 let drift = ensure_standing_root(
338 &mut conn,
339 &record("~/work/src", "/retargeted/repo/src"),
340 &[IndexKind::Search],
341 )
342 .unwrap_err();
343 assert!(matches!(
344 drift,
345 StandingRootError::ResolvedPathDrift {
346 field: "resolved_target",
347 ..
348 }
349 ));
350 assert_eq!(
351 get_standing_root(&conn, "~/work/src")
352 .unwrap()
353 .unwrap()
354 .resolved_target,
355 "/real/repo/src"
356 );
357 }
358
359 #[test]
360 fn deletion_removes_resolution_and_freshness_without_artifact_gc() {
361 let dir = tempdir().unwrap();
362 let mut conn = db::open(&dir.path().join("aft.db")).unwrap();
363 ensure_standing_root(
364 &mut conn,
365 &record("/one", "/repo/one"),
366 &[IndexKind::Search],
367 )
368 .unwrap();
369 ensure_standing_root(
370 &mut conn,
371 &record("/two", "/repo/two"),
372 &[IndexKind::Search],
373 )
374 .unwrap();
375 delete_standing_root(&conn, "/one").unwrap();
376 assert!(get_standing_root(&conn, "/one").unwrap().is_none());
377 assert!(needs_strict_verify(&conn, "/one", IndexKind::Search)
378 .unwrap()
379 .is_none());
380 assert!(get_standing_root(&conn, "/two").unwrap().is_some());
381 }
382
383 #[test]
384 fn strict_verification_clear_is_atomic_and_drop_before_commit_keeps_flag() {
385 let dir = tempdir().unwrap();
386 let mut conn = db::open(&dir.path().join("aft.db")).unwrap();
387 ensure_standing_root(
388 &mut conn,
389 &record("/one", "/repo/one"),
390 &[IndexKind::Search],
391 )
392 .unwrap();
393
394 {
395 let tx = conn.transaction().unwrap();
396 record_successful_strict_verification_in_transaction(&tx, "/one", IndexKind::Search, 9)
397 .unwrap();
398 }
400 assert!(needs_strict_verify(&conn, "/one", IndexKind::Search)
401 .unwrap()
402 .unwrap());
403
404 record_successful_strict_verification(&mut conn, "/one", IndexKind::Search, 10).unwrap();
405 assert!(!needs_strict_verify(&conn, "/one", IndexKind::Search)
406 .unwrap()
407 .unwrap());
408 }
409}