Skip to main content

gobby_code/
freshness.rs

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        // SAFETY: gcode runs freshness indexing synchronously in this CLI process
131        // and restores the variable before returning to command dispatch.
132        unsafe { std::env::set_var(INFLIGHT_ENV, "1") };
133        Self
134    }
135}
136
137impl Drop for FreshnessGuard {
138    fn drop(&mut self) {
139        // SAFETY: see FreshnessGuard::enter.
140        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}