1use std::path::{Path, PathBuf};
2
3use crate::config::Context;
4use crate::db;
5use crate::index::{api, hasher};
6use crate::models::Symbol;
7
8const INFLIGHT_ENV: &str = "GCODE_FRESHNESS_INFLIGHT";
9
10pub enum FreshnessScope {
11 Project,
12 Files(Vec<PathBuf>),
13}
14
15pub fn ensure_fresh(ctx: &Context, scope: FreshnessScope) -> anyhow::Result<()> {
16 if std::env::var_os(INFLIGHT_ENV).is_some() {
17 return Ok(());
18 }
19
20 let _guard = FreshnessGuard::enter();
21 match scope {
22 FreshnessScope::Project => {
23 api::index_files(
24 api::IndexRequest {
25 project_root: ctx.project_root.clone(),
26 path_filter: None,
27 explicit_files: Vec::new(),
28 full: false,
29 require_cpp_semantics: false,
30 sync_projections: false,
31 },
32 ctx,
33 )?;
34 }
35 FreshnessScope::Files(paths) => {
36 let files: Vec<PathBuf> = paths
37 .iter()
38 .map(|path| normalize_file_path(&ctx.project_root, path))
39 .map(PathBuf::from)
40 .collect();
41 if !files.is_empty() {
42 api::index_files(
43 api::IndexRequest {
44 project_root: ctx.project_root.clone(),
45 path_filter: None,
46 explicit_files: files,
47 full: false,
48 require_cpp_semantics: false,
49 sync_projections: false,
50 },
51 ctx,
52 )?;
53 }
54 }
55 }
56 Ok(())
57}
58
59pub fn ensure_symbol_fresh(ctx: &Context, id: &str) -> anyhow::Result<()> {
60 if std::env::var_os(INFLIGHT_ENV).is_some() {
61 return Ok(());
62 }
63
64 let mut conn = db::connect_readonly(&ctx.database_url)?;
65 let columns = db::symbol_select_columns("");
66 let sym = conn
67 .query_opt(
68 &format!("SELECT {columns} FROM code_symbols WHERE id = $1 AND project_id = $2"),
69 &[&id, &ctx.project_id],
70 )?
71 .and_then(|row| Symbol::from_row(&row).ok());
72 drop(conn);
73
74 let Some(sym) = sym else {
75 return Ok(());
76 };
77
78 if symbol_slice_is_current(ctx, &sym) {
79 return Ok(());
80 }
81
82 ensure_fresh(
83 ctx,
84 FreshnessScope::Files(vec![PathBuf::from(&sym.file_path)]),
85 )
86}
87
88fn symbol_slice_is_current(ctx: &Context, sym: &Symbol) -> bool {
89 if sym.content_hash.is_empty() {
90 return false;
91 }
92
93 let file_path = ctx.project_root.join(&sym.file_path);
94 let source = match std::fs::read(file_path) {
95 Ok(source) => source,
96 Err(_) => return false,
97 };
98
99 hasher::symbol_content_hash(&source, sym.byte_start, sym.byte_end)
100 .map(|hash| hash == sym.content_hash)
101 .unwrap_or(false)
102}
103
104fn normalize_file_path(root: &Path, path: &Path) -> String {
105 let abs = if path.is_absolute() {
106 path.to_path_buf()
107 } else {
108 root.join(path)
109 };
110
111 abs.canonicalize()
112 .ok()
113 .and_then(|canonical| {
114 root.canonicalize().ok().and_then(|canonical_root| {
115 canonical
116 .strip_prefix(canonical_root)
117 .ok()
118 .map(Path::to_path_buf)
119 })
120 })
121 .unwrap_or_else(|| path.to_path_buf())
122 .to_string_lossy()
123 .to_string()
124}
125
126struct FreshnessGuard;
127
128impl FreshnessGuard {
129 fn enter() -> Self {
130 unsafe { std::env::set_var(INFLIGHT_ENV, "1") };
133 Self
134 }
135}
136
137impl Drop for FreshnessGuard {
138 fn drop(&mut self) {
139 unsafe { std::env::remove_var(INFLIGHT_ENV) };
141 }
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use crate::models::CODE_INDEX_UUID_NAMESPACE;
148
149 fn context_for(root: &Path) -> Context {
150 Context {
151 database_url: "postgresql://localhost/gobby-test".to_string(),
152 project_root: root.to_path_buf(),
153 project_id: "proj".to_string(),
154 quiet: true,
155 falkordb: None,
156 qdrant: None,
157 embedding: None,
158 code_vectors: crate::config::CodeVectorSettings::default(),
159 daemon_url: None,
160 }
161 }
162
163 fn symbol_hash(source: &[u8], start: usize, end: usize) -> String {
164 hasher::symbol_content_hash(source, start, end).expect("symbol hash")
165 }
166
167 #[test]
168 #[serial_test::serial]
169 fn no_freshness_env_short_circuits_project_refresh() {
170 let tmp = tempfile::tempdir().expect("tempdir");
171 let ctx = context_for(tmp.path());
172 unsafe { std::env::set_var(INFLIGHT_ENV, "1") };
173 let result = ensure_fresh(&ctx, FreshnessScope::Project);
174 unsafe { std::env::remove_var(INFLIGHT_ENV) };
175
176 assert!(result.is_ok());
177 }
178
179 #[test]
180 #[serial_test::serial]
181 fn symbol_slice_check_uses_stored_byte_range_hash() {
182 let tmp = tempfile::tempdir().expect("tempdir");
183 let source = b"fn before() {}\nfn target() {}\n";
184 std::fs::write(tmp.path().join("lib.rs"), source).expect("write file");
185 let ctx = context_for(tmp.path());
186 let start = 15;
187 let end = source.len();
188 let sym = Symbol {
189 id: uuid::Uuid::new_v5(&CODE_INDEX_UUID_NAMESPACE, b"sym").to_string(),
190 project_id: "proj".to_string(),
191 file_path: "lib.rs".to_string(),
192 name: "target".to_string(),
193 qualified_name: "target".to_string(),
194 kind: "function".to_string(),
195 language: "rust".to_string(),
196 byte_start: start,
197 byte_end: end,
198 line_start: 2,
199 line_end: 2,
200 signature: None,
201 docstring: None,
202 parent_symbol_id: None,
203 content_hash: symbol_hash(source, start, end),
204 summary: None,
205 created_at: String::new(),
206 updated_at: String::new(),
207 };
208
209 assert!(symbol_slice_is_current(&ctx, &sym));
210
211 std::fs::write(
212 tmp.path().join("lib.rs"),
213 b"// shifted\nfn before() {}\nfn target() {}\n",
214 )
215 .expect("shift file");
216 assert!(!symbol_slice_is_current(&ctx, &sym));
217 }
218}