gobby-code 0.8.2

Fast Rust CLI for Gobby's code index — AST-aware search, symbol navigation, and dependency graph
use anyhow::Context as _;
use postgres::Row;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

/// Stable namespace for deterministic symbol UUIDs.
/// Must match Python: uuid.UUID("c0de1de0-0000-4000-8000-000000000000")
pub const CODE_INDEX_UUID_NAMESPACE: Uuid = Uuid::from_bytes([
    0xc0, 0xde, 0x1d, 0xe0, 0x00, 0x00, 0x40, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
]);

/// A code symbol extracted from AST parsing.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Symbol {
    pub id: String,
    pub project_id: String,
    pub file_path: String,
    pub name: String,
    pub qualified_name: String,
    pub kind: String,
    pub language: String,
    pub byte_start: usize,
    pub byte_end: usize,
    pub line_start: usize,
    pub line_end: usize,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub signature: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub docstring: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub parent_symbol_id: Option<String>,
    #[serde(default)]
    pub content_hash: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub summary: Option<String>,
    #[serde(default)]
    pub created_at: String,
    #[serde(default)]
    pub updated_at: String,
}

impl Symbol {
    /// Generate deterministic UUID5 for a symbol.
    /// Must produce identical IDs to Python Symbol.make_id().
    pub fn make_id(
        project_id: &str,
        file_path: &str,
        name: &str,
        kind: &str,
        byte_start: usize,
    ) -> String {
        let key = format!("{project_id}:{file_path}:{name}:{kind}:{byte_start}");
        Uuid::new_v5(&CODE_INDEX_UUID_NAMESPACE, key.as_bytes()).to_string()
    }

    /// Read a Symbol from a PostgreSQL row.
    ///
    /// Callers should select via `crate::db::symbol_select_columns()` so integer
    /// and timestamp fields are cast to stable Rust-readable types.
    pub fn from_row(row: &Row) -> anyhow::Result<Self> {
        Ok(Self {
            id: row.try_get("id")?,
            project_id: row.try_get("project_id")?,
            file_path: row.try_get("file_path")?,
            name: row.try_get("name")?,
            qualified_name: row.try_get("qualified_name")?,
            kind: row.try_get("kind")?,
            language: row.try_get("language")?,
            byte_start: i64_to_usize(row.try_get("byte_start")?, "byte_start")?,
            byte_end: i64_to_usize(row.try_get("byte_end")?, "byte_end")?,
            line_start: i64_to_usize(row.try_get("line_start")?, "line_start")?,
            line_end: i64_to_usize(row.try_get("line_end")?, "line_end")?,
            signature: row.try_get("signature")?,
            docstring: row.try_get("docstring")?,
            parent_symbol_id: row.try_get("parent_symbol_id")?,
            content_hash: row
                .try_get::<_, Option<String>>("content_hash")?
                .unwrap_or_default(),
            summary: row.try_get("summary")?,
            created_at: row
                .try_get::<_, Option<String>>("created_at")?
                .unwrap_or_default(),
            updated_at: row
                .try_get::<_, Option<String>>("updated_at")?
                .unwrap_or_default(),
        })
    }

    /// Slim representation for outline output.
    pub fn to_outline(&self) -> OutlineSymbol {
        OutlineSymbol {
            id: self.id.clone(),
            name: self.name.clone(),
            kind: self.kind.clone(),
            line_start: self.line_start,
            line_end: self.line_end,
            signature: self.signature.clone(),
        }
    }

    /// Brief dict-like representation for search results.
    pub fn to_brief(&self) -> SearchResult {
        SearchResult {
            id: self.id.clone(),
            name: self.name.clone(),
            qualified_name: self.qualified_name.clone(),
            kind: self.kind.clone(),
            language: self.language.clone(),
            file_path: self.file_path.clone(),
            line_start: self.line_start,
            line_end: self.line_end,
            score: 0.0,
            summary: self.summary.clone(),
            signature: self.signature.clone(),
            sources: None,
        }
    }
}

fn i64_to_usize(value: i64, column: &str) -> anyhow::Result<usize> {
    value
        .try_into()
        .with_context(|| format!("column `{column}` contains negative or too-large value {value}"))
}

/// Metadata for an indexed file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexedFile {
    pub id: String,
    pub project_id: String,
    pub file_path: String,
    pub language: String,
    pub content_hash: String,
    pub symbol_count: usize,
    pub byte_size: usize,
    pub indexed_at: String,
}

impl IndexedFile {
    pub fn make_id(project_id: &str, file_path: &str) -> String {
        let key = format!("{project_id}:{file_path}");
        Uuid::new_v5(&CODE_INDEX_UUID_NAMESPACE, key.as_bytes()).to_string()
    }
}

/// A chunk of file content for FTS search.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentChunk {
    pub id: String,
    pub project_id: String,
    pub file_path: String,
    pub chunk_index: usize,
    pub line_start: usize,
    pub line_end: usize,
    pub content: String,
    pub language: String,
    pub created_at: String,
}

impl ContentChunk {
    pub fn make_id(project_id: &str, file_path: &str, chunk_index: usize) -> String {
        let key = format!("{project_id}:{file_path}:chunk:{chunk_index}");
        Uuid::new_v5(&CODE_INDEX_UUID_NAMESPACE, key.as_bytes()).to_string()
    }
}

/// Import relationship extracted from AST.
#[derive(Debug, Clone)]
pub struct ImportRelation {
    pub file_path: String,
    pub module_name: String,
}

/// Call relationship extracted from AST.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CallTargetKind {
    Symbol,
    Unresolved,
    External,
}

impl CallTargetKind {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Symbol => "symbol",
            Self::Unresolved => "unresolved",
            Self::External => "external",
        }
    }
}

/// Call relationship extracted from AST.
#[derive(Debug, Clone)]
pub struct CallRelation {
    pub caller_symbol_id: String,
    pub callee_symbol_id: Option<String>,
    pub callee_name: String,
    pub callee_target_kind: CallTargetKind,
    pub callee_external_module: Option<String>,
    pub file_path: String,
    pub line: usize,
}

impl CallRelation {
    pub fn new(
        caller_symbol_id: String,
        callee_name: String,
        file_path: String,
        line: usize,
    ) -> Self {
        Self {
            caller_symbol_id,
            callee_symbol_id: None,
            callee_name,
            callee_target_kind: CallTargetKind::Unresolved,
            callee_external_module: None,
            file_path,
            line,
        }
    }

    pub fn with_symbol_target(mut self, callee_symbol_id: String) -> Self {
        self.callee_symbol_id = Some(callee_symbol_id);
        self.callee_target_kind = CallTargetKind::Symbol;
        self
    }

    pub fn with_external_target(
        mut self,
        callee_name: String,
        callee_external_module: String,
    ) -> Self {
        self.callee_name = callee_name;
        self.callee_target_kind = CallTargetKind::External;
        self.callee_external_module = Some(callee_external_module);
        self
    }
}

/// Project index statistics.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexedProject {
    pub id: String,
    pub root_path: String,
    pub total_files: usize,
    pub total_symbols: usize,
    pub last_indexed_at: String,
    pub index_duration_ms: u64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub total_eligible_files: Option<usize>,
}

/// Search result with score.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
    pub id: String,
    pub name: String,
    pub qualified_name: String,
    pub kind: String,
    pub language: String,
    pub file_path: String,
    pub line_start: usize,
    pub line_end: usize,
    pub score: f64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub summary: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub signature: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sources: Option<Vec<String>>,
}

/// Graph query result (callers, usages).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphResult {
    pub id: String,
    pub name: String,
    pub file_path: String,
    pub line: usize,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub relation: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub distance: Option<usize>,
}

/// Result of parsing a single file.
pub struct ParseResult {
    pub symbols: Vec<Symbol>,
    pub imports: Vec<ImportRelation>,
    pub calls: Vec<CallRelation>,
    /// Raw file bytes — carried through for body snippet extraction at embedding time.
    pub source: Vec<u8>,
}

/// Aggregate result of indexing a directory.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexResult {
    pub project_id: String,
    pub files_indexed: usize,
    pub files_skipped: usize,
    pub symbols_found: usize,
    pub errors: Vec<String>,
    pub duration_ms: u64,
}

/// Paginated response envelope for JSON output.
/// Hoists `project_id` to avoid repeating it on every result.
#[derive(Debug, Clone, Serialize)]
pub struct PagedResponse<T: Serialize> {
    pub project_id: String,
    pub total: usize,
    pub offset: usize,
    pub limit: usize,
    pub results: Vec<T>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub hint: Option<String>,
}

/// Slim symbol for outline output — only what agents need.
#[derive(Debug, Clone, Serialize)]
pub struct OutlineSymbol {
    pub id: String,
    pub name: String,
    pub kind: String,
    pub line_start: usize,
    pub line_end: usize,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub signature: Option<String>,
}

/// Content search hit from FTS.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentSearchHit {
    pub file_path: String,
    pub line_start: usize,
    pub line_end: usize,
    pub snippet: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub language: Option<String>,
}

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

    #[test]
    fn test_uuid5_parity_with_python() {
        // Python: Symbol.make_id("proj1", "src/main.py", "foo", "function", 42)
        // Must produce the same UUID in Rust.
        let id = Symbol::make_id("proj1", "src/main.py", "foo", "function", 42);
        // The key is "proj1:src/main.py:foo:function:42"
        // This is a deterministic UUID5 — verify it's stable across runs.
        let id2 = Symbol::make_id("proj1", "src/main.py", "foo", "function", 42);
        assert_eq!(id, id2);

        // Verify the namespace UUID bytes match Python's c0de1de0-0000-4000-8000-000000000000
        assert_eq!(
            CODE_INDEX_UUID_NAMESPACE.to_string(),
            "c0de1de0-0000-4000-8000-000000000000"
        );
    }
    #[test]
    fn test_call_relation_promotes_symbol_targets() {
        let call = CallRelation::new(
            "caller-id".to_string(),
            "foo".to_string(),
            "src/main.py".to_string(),
            12,
        )
        .with_symbol_target("callee-id".to_string());

        assert_eq!(call.callee_symbol_id.as_deref(), Some("callee-id"));
        assert_eq!(call.callee_target_kind, CallTargetKind::Symbol);
    }
}