1use std::path::{Path, PathBuf};
2use std::time::SystemTime;
3
4use crate::config::Context;
5use crate::db;
6use crate::index::{api, hasher};
7use crate::index_lock::{self, IndexLockPolicy, IndexLockResult};
8use crate::models::Symbol;
9use crate::visibility;
10
11const INFLIGHT_ENV: &str = "GCODE_FRESHNESS_INFLIGHT";
12
13pub enum FreshnessScope {
14 Project,
15 Files(Vec<PathBuf>),
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum FreshnessStatus {
20 Checked,
21 SkippedBusy,
22}
23
24pub fn ensure_fresh(ctx: &Context, scope: FreshnessScope) -> anyhow::Result<FreshnessStatus> {
25 if std::env::var_os(INFLIGHT_ENV).is_some() {
26 return Ok(FreshnessStatus::Checked);
27 }
28
29 if matches!(scope, FreshnessScope::Project) && !project_needs_refresh(ctx)? {
35 return Ok(FreshnessStatus::Checked);
36 }
37
38 let _guard = FreshnessGuard::enter();
39 let result =
40 index_lock::with_project_lock(ctx, IndexLockPolicy::brief_freshness_try(), || {
41 match scope {
42 FreshnessScope::Project => {
43 api::index_files(
44 api::IndexRequest {
45 project_root: ctx.project_root.clone(),
46 path_filter: None,
47 explicit_files: Vec::new(),
48 full: false,
49 require_cpp_semantics: false,
50 sync_projections: false,
51 },
52 ctx,
53 )?;
54 }
55 FreshnessScope::Files(paths) => {
56 let files: Vec<PathBuf> = paths
57 .iter()
58 .map(|path| normalize_file_path(&ctx.project_root, path))
59 .map(PathBuf::from)
60 .collect();
61 if !files.is_empty() {
62 api::index_files(
63 api::IndexRequest {
64 project_root: ctx.project_root.clone(),
65 path_filter: None,
66 explicit_files: files,
67 full: false,
68 require_cpp_semantics: false,
69 sync_projections: false,
70 },
71 ctx,
72 )?;
73 }
74 }
75 }
76 Ok(())
77 })?;
78
79 match result {
80 IndexLockResult::Acquired(()) => Ok(FreshnessStatus::Checked),
81 IndexLockResult::Busy => Ok(FreshnessStatus::SkippedBusy),
82 }
83}
84
85fn project_needs_refresh(ctx: &Context) -> anyhow::Result<bool> {
94 let mut conn = db::connect_readonly(&ctx.database_url)?;
95
96 let last_indexed_at: Option<SystemTime> = match conn.query_opt(
97 "SELECT last_indexed_at FROM code_indexed_projects WHERE id = $1",
98 &[&ctx.project_id],
99 )? {
100 Some(row) => row.try_get::<_, Option<SystemTime>>(0)?,
101 None => None,
102 };
103
104 let Some(last_indexed_at) = last_indexed_at else {
107 return Ok(true);
108 };
109
110 let indexed_paths = db::list_indexed_file_paths(&mut conn, &ctx.project_id)?;
111 drop(conn);
112
113 Ok(api::project_changed_since(
114 &ctx.project_root,
115 last_indexed_at,
116 &indexed_paths,
117 crate::index::walker::DiscoveryOptions {
118 respect_gitignore: ctx.indexing.respect_gitignore,
119 },
120 ))
121}
122
123pub fn ensure_symbol_fresh(ctx: &Context, id: &str) -> anyhow::Result<FreshnessStatus> {
124 if std::env::var_os(INFLIGHT_ENV).is_some() {
125 return Ok(FreshnessStatus::Checked);
126 }
127
128 let mut conn = db::connect_readonly(&ctx.database_url)?;
129 let sym = visibility::visible_symbol_by_id(&mut conn, ctx, id)?;
130 drop(conn);
131
132 let Some(sym) = sym else {
133 return Ok(FreshnessStatus::Checked);
134 };
135
136 if symbol_slice_is_current(ctx, &sym) {
137 return Ok(FreshnessStatus::Checked);
138 }
139
140 ensure_fresh(
141 ctx,
142 FreshnessScope::Files(vec![PathBuf::from(&sym.file_path)]),
143 )
144}
145
146fn symbol_slice_is_current(ctx: &Context, sym: &Symbol) -> bool {
147 if sym.content_hash.is_empty() {
148 return false;
149 }
150
151 let file_path = ctx.project_root.join(&sym.file_path);
152 let source = match std::fs::read(file_path) {
153 Ok(source) => source,
154 Err(_) => return false,
155 };
156
157 hasher::symbol_content_hash(&source, sym.byte_start, sym.byte_end)
158 .map(|hash| hash == sym.content_hash)
159 .unwrap_or(false)
160}
161
162fn normalize_file_path(root: &Path, path: &Path) -> String {
163 let abs = if path.is_absolute() {
164 path.to_path_buf()
165 } else {
166 root.join(path)
167 };
168
169 abs.canonicalize()
170 .ok()
171 .and_then(|canonical| {
172 root.canonicalize().ok().and_then(|canonical_root| {
173 canonical
174 .strip_prefix(canonical_root)
175 .ok()
176 .map(Path::to_path_buf)
177 })
178 })
179 .unwrap_or_else(|| path.to_path_buf())
180 .to_string_lossy()
181 .to_string()
182}
183
184struct FreshnessGuard;
185
186impl FreshnessGuard {
187 fn enter() -> Self {
188 unsafe { std::env::set_var(INFLIGHT_ENV, "1") };
191 Self
192 }
193}
194
195impl Drop for FreshnessGuard {
196 fn drop(&mut self) {
197 unsafe { std::env::remove_var(INFLIGHT_ENV) };
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use crate::models::CODE_INDEX_UUID_NAMESPACE;
206 use postgres::Client;
207
208 fn context_for(root: &Path) -> Context {
209 Context {
210 database_url: "postgresql://localhost/gobby-test".to_string(),
211 project_root: root.to_path_buf(),
212 project_id: "proj".to_string(),
213 quiet: true,
214 falkordb: None,
215 qdrant: None,
216 embedding: None,
217 code_vectors: crate::config::CodeVectorSettings::default(),
218 indexing: gobby_core::config::IndexingConfig::default(),
219 daemon_url: None,
220 index_scope: crate::config::ProjectIndexScope::Single,
221 }
222 }
223
224 fn symbol_hash(source: &[u8], start: usize, end: usize) -> String {
225 hasher::symbol_content_hash(source, start, end).expect("symbol hash")
226 }
227
228 fn postgres_test_context(project_id: &str) -> Context {
229 let database_url = crate::test_env::postgres_test_database_url("freshness tests");
230 db::connect_readwrite(&database_url).expect("connect freshness PostgreSQL test database");
231 Context {
232 database_url,
233 project_root: std::path::PathBuf::from("/tmp/gcode-freshness-lock-test"),
234 project_id: project_id.to_string(),
235 quiet: true,
236 falkordb: None,
237 qdrant: None,
238 embedding: None,
239 code_vectors: crate::config::CodeVectorSettings::default(),
240 indexing: gobby_core::config::IndexingConfig::default(),
241 daemon_url: None,
242 index_scope: crate::config::ProjectIndexScope::Single,
243 }
244 }
245
246 fn postgres_context_with_root(project_id: &str, root: &Path) -> Context {
247 let database_url = crate::test_env::postgres_test_database_url("freshness tests");
248 db::connect_readwrite(&database_url).expect("connect freshness PostgreSQL test database");
249 Context {
250 database_url,
251 project_root: root.to_path_buf(),
252 project_id: project_id.to_string(),
253 quiet: true,
254 falkordb: None,
255 qdrant: None,
256 embedding: None,
257 code_vectors: crate::config::CodeVectorSettings::default(),
258 indexing: gobby_core::config::IndexingConfig::default(),
259 daemon_url: None,
260 index_scope: crate::config::ProjectIndexScope::Single,
261 }
262 }
263
264 fn hold_project_lock(ctx: &Context) -> Client {
265 let mut conn =
266 db::connect_readwrite(&ctx.database_url).expect("connect test PostgreSQL hub");
267 let key = crate::index_lock::project_lock_key(&ctx.project_id);
268 conn.execute("SELECT pg_advisory_lock($1)", &[&key])
269 .expect("hold project advisory lock");
270 conn
271 }
272
273 fn set_mtime(path: &Path, time: SystemTime) {
274 std::fs::File::options()
275 .read(true)
276 .write(true)
277 .open(path)
278 .expect("open file to set mtime")
279 .set_modified(time)
280 .expect("set mtime");
281 }
282
283 fn invalidate_test_project(ctx: &Context) {
284 let mut conn =
285 db::connect_readwrite(&ctx.database_url).expect("connect test PostgreSQL hub");
286 crate::index::indexer::invalidate(&mut conn, &ctx.project_id, None)
287 .expect("invalidate test project index");
288 }
289
290 fn full_index(ctx: &Context) {
291 api::index_files(
292 api::IndexRequest {
293 project_root: ctx.project_root.clone(),
294 path_filter: None,
295 explicit_files: Vec::new(),
296 full: true,
297 require_cpp_semantics: false,
298 sync_projections: false,
299 },
300 ctx,
301 )
302 .expect("full index of test project");
303 }
304
305 mod serial_db {
306 use super::*;
307
308 #[test]
309 #[serial_test::serial(serial_db)]
310 fn no_freshness_env_short_circuits_project_refresh() {
311 let tmp = tempfile::tempdir().expect("tempdir");
312 let ctx = context_for(tmp.path());
313 unsafe { std::env::set_var(INFLIGHT_ENV, "1") };
314 let result = ensure_fresh(&ctx, FreshnessScope::Project);
315 unsafe { std::env::remove_var(INFLIGHT_ENV) };
316
317 assert_eq!(result.expect("freshness status"), FreshnessStatus::Checked);
318 }
319
320 #[test]
321 #[cfg_attr(
322 not(gcode_postgres_tests),
323 ignore = "requires a PostgreSQL test database URL"
324 )]
325 #[serial_test::serial(serial_db)]
326 fn busy_project_lock_skips_freshness_refresh() {
327 let ctx = postgres_test_context("gcode-freshness-busy");
328 let _holder = hold_project_lock(&ctx);
329
330 let status = ensure_fresh(&ctx, FreshnessScope::Project).expect("freshness status");
331
332 assert_eq!(status, FreshnessStatus::SkippedBusy);
333 }
334
335 #[test]
336 #[cfg_attr(
337 not(gcode_postgres_tests),
338 ignore = "requires a PostgreSQL test database URL"
339 )]
340 #[serial_test::serial(serial_db)]
341 fn pre_gate_skips_lock_when_unchanged_and_trips_after_a_change() {
342 let tmp = tempfile::tempdir().expect("tempdir");
343 let root = tmp.path();
344 std::fs::create_dir_all(root.join("src")).expect("create src");
345 let lib = root.join("src/lib.rs");
346 std::fs::write(&lib, b"fn main() {}\n").expect("write lib.rs");
347 std::fs::write(root.join("README.md"), b"# Title\n").expect("write README");
348
349 let aged = SystemTime::now() - std::time::Duration::from_secs(3600);
352 set_mtime(&lib, aged);
353 set_mtime(&root.join("README.md"), aged);
354
355 let ctx = postgres_context_with_root("gcode-freshness-pregate", root);
356
357 invalidate_test_project(&ctx);
359 full_index(&ctx);
360
361 let holder = hold_project_lock(&ctx);
365 let status = ensure_fresh(&ctx, FreshnessScope::Project).expect("freshness status");
366 assert_eq!(status, FreshnessStatus::Checked);
367
368 set_mtime(
372 &lib,
373 SystemTime::now() + std::time::Duration::from_secs(3600),
374 );
375 let status = ensure_fresh(&ctx, FreshnessScope::Project).expect("freshness status");
376 assert_eq!(status, FreshnessStatus::SkippedBusy);
377 drop(holder);
378
379 invalidate_test_project(&ctx);
380 }
381
382 #[test]
383 #[serial_test::serial(serial_db)]
384 fn symbol_slice_check_uses_stored_byte_range_hash() {
385 let tmp = tempfile::tempdir().expect("tempdir");
386 let source = b"fn before() {}\nfn target() {}\n";
387 std::fs::write(tmp.path().join("lib.rs"), source).expect("write file");
388 let ctx = context_for(tmp.path());
389 let start = 15;
390 let end = source.len();
391 let sym = Symbol {
392 id: uuid::Uuid::new_v5(&CODE_INDEX_UUID_NAMESPACE, b"sym").to_string(),
393 project_id: "proj".to_string(),
394 file_path: "lib.rs".to_string(),
395 name: "target".to_string(),
396 qualified_name: "target".to_string(),
397 kind: "function".to_string(),
398 language: "rust".to_string(),
399 byte_start: start,
400 byte_end: end,
401 line_start: 2,
402 line_end: 2,
403 signature: None,
404 docstring: None,
405 parent_symbol_id: None,
406 content_hash: symbol_hash(source, start, end),
407 summary: None,
408 created_at: String::new(),
409 updated_at: String::new(),
410 };
411
412 assert!(symbol_slice_is_current(&ctx, &sym));
413
414 std::fs::write(
415 tmp.path().join("lib.rs"),
416 b"// shifted\nfn before() {}\nfn target() {}\n",
417 )
418 .expect("shift file");
419 assert!(!symbol_slice_is_current(&ctx, &sym));
420 }
421 }
422}