gobby-code 0.8.4

Fast Rust CLI for Gobby's code index — AST-aware search, symbol navigation, and dependency graph
use std::path::{Path, PathBuf};

use crate::config::Context;
use crate::db;
use crate::index::{hasher, indexer};
use crate::models::Symbol;

const INFLIGHT_ENV: &str = "GCODE_FRESHNESS_INFLIGHT";

pub enum FreshnessScope {
    Project,
    Files(Vec<PathBuf>),
}

pub fn ensure_fresh(ctx: &Context, scope: FreshnessScope) -> anyhow::Result<()> {
    if std::env::var_os(INFLIGHT_ENV).is_some() {
        return Ok(());
    }

    let _guard = FreshnessGuard::enter();
    let mut conn = db::connect_readwrite(&ctx.database_url)?;
    match scope {
        FreshnessScope::Project => {
            indexer::index_directory(
                &mut conn,
                &ctx.project_root,
                &ctx.project_id,
                true,
                ctx.quiet,
            )?;
        }
        FreshnessScope::Files(paths) => {
            let files: Vec<String> = paths
                .iter()
                .map(|path| normalize_file_path(&ctx.project_root, path))
                .collect();
            if !files.is_empty() {
                indexer::index_files(&mut conn, &ctx.project_root, &ctx.project_id, &files)?;
            }
        }
    }
    Ok(())
}

pub fn ensure_symbol_fresh(ctx: &Context, id: &str) -> anyhow::Result<()> {
    if std::env::var_os(INFLIGHT_ENV).is_some() {
        return Ok(());
    }

    let mut conn = db::connect_readonly(&ctx.database_url)?;
    let columns = db::symbol_select_columns("");
    let sym = conn
        .query_opt(
            &format!("SELECT {columns} FROM code_symbols WHERE id = $1 AND project_id = $2"),
            &[&id, &ctx.project_id],
        )?
        .as_ref()
        .and_then(|row| Symbol::from_row(row).ok());
    drop(conn);

    let Some(sym) = sym else {
        return Ok(());
    };

    if symbol_slice_is_current(ctx, &sym) {
        return Ok(());
    }

    ensure_fresh(
        ctx,
        FreshnessScope::Files(vec![PathBuf::from(&sym.file_path)]),
    )
}

fn symbol_slice_is_current(ctx: &Context, sym: &Symbol) -> bool {
    if sym.content_hash.is_empty() {
        return false;
    }

    let file_path = ctx.project_root.join(&sym.file_path);
    let source = match std::fs::read(file_path) {
        Ok(source) => source,
        Err(_) => return false,
    };

    hasher::symbol_content_hash(&source, sym.byte_start, sym.byte_end)
        .map(|hash| hash == sym.content_hash)
        .unwrap_or(false)
}

fn normalize_file_path(root: &Path, path: &Path) -> String {
    let abs = if path.is_absolute() {
        path.to_path_buf()
    } else {
        root.join(path)
    };

    abs.canonicalize()
        .ok()
        .and_then(|canonical| {
            root.canonicalize().ok().and_then(|canonical_root| {
                canonical
                    .strip_prefix(canonical_root)
                    .ok()
                    .map(Path::to_path_buf)
            })
        })
        .unwrap_or_else(|| path.to_path_buf())
        .to_string_lossy()
        .to_string()
}

struct FreshnessGuard;

impl FreshnessGuard {
    fn enter() -> Self {
        // SAFETY: gcode runs freshness indexing synchronously in this CLI process
        // and restores the variable before returning to command dispatch.
        unsafe { std::env::set_var(INFLIGHT_ENV, "1") };
        Self
    }
}

impl Drop for FreshnessGuard {
    fn drop(&mut self) {
        // SAFETY: see FreshnessGuard::enter.
        unsafe { std::env::remove_var(INFLIGHT_ENV) };
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::models::CODE_INDEX_UUID_NAMESPACE;

    fn context_for(root: &Path) -> Context {
        Context {
            database_url: "postgresql://localhost/gobby-test".to_string(),
            project_root: root.to_path_buf(),
            project_id: "proj".to_string(),
            quiet: true,
            falkordb: None,
            qdrant: None,
            embedding: None,
            daemon_url: None,
        }
    }

    fn symbol_hash(source: &[u8], start: usize, end: usize) -> String {
        hasher::symbol_content_hash(source, start, end).expect("symbol hash")
    }

    #[test]
    #[serial_test::serial]
    fn no_freshness_env_short_circuits_project_refresh() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let ctx = context_for(tmp.path());
        unsafe { std::env::set_var(INFLIGHT_ENV, "1") };
        let result = ensure_fresh(&ctx, FreshnessScope::Project);
        unsafe { std::env::remove_var(INFLIGHT_ENV) };

        assert!(result.is_ok());
    }

    #[test]
    #[serial_test::serial]
    fn symbol_slice_check_uses_stored_byte_range_hash() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let source = b"fn before() {}\nfn target() {}\n";
        std::fs::write(tmp.path().join("lib.rs"), source).expect("write file");
        let ctx = context_for(tmp.path());
        let start = 15;
        let end = source.len();
        let sym = Symbol {
            id: uuid::Uuid::new_v5(&CODE_INDEX_UUID_NAMESPACE, b"sym").to_string(),
            project_id: "proj".to_string(),
            file_path: "lib.rs".to_string(),
            name: "target".to_string(),
            qualified_name: "target".to_string(),
            kind: "function".to_string(),
            language: "rust".to_string(),
            byte_start: start,
            byte_end: end,
            line_start: 2,
            line_end: 2,
            signature: None,
            docstring: None,
            parent_symbol_id: None,
            content_hash: symbol_hash(source, start, end),
            summary: None,
            created_at: String::new(),
            updated_at: String::new(),
        };

        assert!(symbol_slice_is_current(&ctx, &sym));

        std::fs::write(
            tmp.path().join("lib.rs"),
            b"// shifted\nfn before() {}\nfn target() {}\n",
        )
        .expect("shift file");
        assert!(!symbol_slice_is_current(&ctx, &sym));
    }
}