Skip to main content

gobby_code/
models.rs

1use anyhow::Context as _;
2use postgres::Row;
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6/// Stable namespace for deterministic symbol UUIDs.
7/// Must match Python: uuid.UUID("c0de1de0-0000-4000-8000-000000000000")
8pub const CODE_INDEX_UUID_NAMESPACE: Uuid = Uuid::from_bytes([
9    0xc0, 0xde, 0x1d, 0xe0, 0x00, 0x00, 0x40, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
10]);
11
12pub const SOURCE_SYSTEM_GCODE: &str = "gcode";
13
14/// Producer confidence classification for graph and vector projection facts.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
17pub enum ProjectionProvenance {
18    Extracted,
19    Inferred,
20    Ambiguous,
21}
22
23impl ProjectionProvenance {
24    pub fn from_wire_value(value: &str) -> Option<Self> {
25        match value {
26            "EXTRACTED" | "extracted" => Some(Self::Extracted),
27            "INFERRED" | "inferred" => Some(Self::Inferred),
28            "AMBIGUOUS" | "ambiguous" => Some(Self::Ambiguous),
29            _ => None,
30        }
31    }
32}
33
34/// Optional provenance attached to graph results and projection payloads.
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36pub struct ProjectionMetadata {
37    pub provenance: ProjectionProvenance,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub confidence: Option<f64>,
40    pub source_system: String,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub source_file_path: Option<String>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub source_line: Option<usize>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub source_symbol_id: Option<String>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub matching_method: Option<String>,
49}
50
51impl ProjectionMetadata {
52    pub fn new(provenance: ProjectionProvenance, source_system: impl Into<String>) -> Self {
53        Self {
54            provenance,
55            confidence: None,
56            source_system: source_system.into(),
57            source_file_path: None,
58            source_line: None,
59            source_symbol_id: None,
60            matching_method: None,
61        }
62    }
63
64    pub fn gcode_extracted() -> Self {
65        Self::new(ProjectionProvenance::Extracted, SOURCE_SYSTEM_GCODE).with_confidence(Some(1.0))
66    }
67
68    pub fn inferred(source_system: impl Into<String>, confidence: Option<f64>) -> Self {
69        Self::new(ProjectionProvenance::Inferred, source_system).with_confidence(confidence)
70    }
71
72    pub fn ambiguous(source_system: impl Into<String>, confidence: Option<f64>) -> Self {
73        Self::new(ProjectionProvenance::Ambiguous, source_system).with_confidence(confidence)
74    }
75
76    pub fn with_confidence(mut self, confidence: Option<f64>) -> Self {
77        self.confidence = confidence;
78        self
79    }
80
81    pub fn with_source_file_path(mut self, file_path: impl Into<String>) -> Self {
82        self.source_file_path = Some(file_path.into());
83        self
84    }
85
86    pub fn with_source_line(mut self, line: usize) -> Self {
87        self.source_line = Some(line);
88        self
89    }
90
91    pub fn with_source_symbol_id(mut self, symbol_id: impl Into<String>) -> Self {
92        self.source_symbol_id = Some(symbol_id.into());
93        self
94    }
95
96    pub fn with_matching_method(mut self, matching_method: impl Into<String>) -> Self {
97        self.matching_method = Some(matching_method.into());
98        self
99    }
100
101    pub fn is_hypothesis(&self) -> bool {
102        matches!(
103            self.provenance,
104            ProjectionProvenance::Inferred | ProjectionProvenance::Ambiguous
105        )
106    }
107}
108
109/// A code symbol extracted from AST parsing.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct Symbol {
112    pub id: String,
113    pub project_id: String,
114    pub file_path: String,
115    pub name: String,
116    pub qualified_name: String,
117    pub kind: String,
118    pub language: String,
119    pub byte_start: usize,
120    pub byte_end: usize,
121    pub line_start: usize,
122    pub line_end: usize,
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub signature: Option<String>,
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub docstring: Option<String>,
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub parent_symbol_id: Option<String>,
129    #[serde(default)]
130    pub content_hash: String,
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub summary: Option<String>,
133    #[serde(default)]
134    pub created_at: String,
135    #[serde(default)]
136    pub updated_at: String,
137}
138
139impl Symbol {
140    /// Generate deterministic UUID5 for a symbol.
141    /// Must produce identical IDs to Python Symbol.make_id().
142    pub fn make_id(
143        project_id: &str,
144        file_path: &str,
145        name: &str,
146        kind: &str,
147        byte_start: usize,
148    ) -> String {
149        let key = format!("{project_id}:{file_path}:{name}:{kind}:{byte_start}");
150        Uuid::new_v5(&CODE_INDEX_UUID_NAMESPACE, key.as_bytes()).to_string()
151    }
152
153    /// Read a Symbol from a PostgreSQL row.
154    ///
155    /// Callers should select via `crate::db::symbol_select_columns()` so integer
156    /// and timestamp fields are cast to stable Rust-readable types.
157    pub fn from_row(row: &Row) -> anyhow::Result<Self> {
158        Ok(Self {
159            id: row.try_get("id")?,
160            project_id: row.try_get("project_id")?,
161            file_path: row.try_get("file_path")?,
162            name: row.try_get("name")?,
163            qualified_name: row.try_get("qualified_name")?,
164            kind: row.try_get("kind")?,
165            language: row.try_get("language")?,
166            byte_start: i64_to_usize(row.try_get("byte_start")?, "byte_start")?,
167            byte_end: i64_to_usize(row.try_get("byte_end")?, "byte_end")?,
168            line_start: i64_to_usize(row.try_get("line_start")?, "line_start")?,
169            line_end: i64_to_usize(row.try_get("line_end")?, "line_end")?,
170            signature: row.try_get("signature")?,
171            docstring: row.try_get("docstring")?,
172            parent_symbol_id: row.try_get("parent_symbol_id")?,
173            content_hash: row
174                .try_get::<_, Option<String>>("content_hash")?
175                .unwrap_or_default(),
176            summary: row.try_get("summary")?,
177            created_at: row
178                .try_get::<_, Option<String>>("created_at")?
179                .unwrap_or_default(),
180            updated_at: row
181                .try_get::<_, Option<String>>("updated_at")?
182                .unwrap_or_default(),
183        })
184    }
185
186    /// Slim representation for outline output.
187    pub fn to_outline(&self) -> OutlineSymbol {
188        OutlineSymbol {
189            id: self.id.clone(),
190            name: self.name.clone(),
191            kind: self.kind.clone(),
192            line_start: self.line_start,
193            line_end: self.line_end,
194            signature: self.signature.clone(),
195        }
196    }
197
198    /// Brief dict-like representation for search results.
199    pub fn to_brief(&self) -> SearchResult {
200        SearchResult {
201            id: self.id.clone(),
202            name: self.name.clone(),
203            qualified_name: self.qualified_name.clone(),
204            kind: self.kind.clone(),
205            language: self.language.clone(),
206            file_path: self.file_path.clone(),
207            line_start: self.line_start,
208            line_end: self.line_end,
209            score: 0.0,
210            rrf_score: None,
211            summary: self.summary.clone(),
212            signature: self.signature.clone(),
213            sources: None,
214        }
215    }
216}
217
218pub fn make_unresolved_callee_id(project_id: &str, callee_name: &str) -> String {
219    let key = format!("unresolved:{project_id}:{callee_name}");
220    Uuid::new_v5(&CODE_INDEX_UUID_NAMESPACE, key.as_bytes()).to_string()
221}
222
223pub fn make_external_symbol_id(
224    project_id: &str,
225    callee_name: &str,
226    module: Option<&str>,
227) -> String {
228    let module_key = module.unwrap_or_default();
229    let key = format!("external:{project_id}:{module_key}:{callee_name}");
230    Uuid::new_v5(&CODE_INDEX_UUID_NAMESPACE, key.as_bytes()).to_string()
231}
232
233fn i64_to_usize(value: i64, column: &str) -> anyhow::Result<usize> {
234    value
235        .try_into()
236        .with_context(|| format!("column `{column}` contains negative or too-large value {value}"))
237}
238
239/// Metadata for an indexed file.
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct IndexedFile {
242    pub id: String,
243    pub project_id: String,
244    pub file_path: String,
245    pub language: String,
246    pub content_hash: String,
247    pub symbol_count: usize,
248    pub byte_size: usize,
249    pub indexed_at: String,
250}
251
252impl IndexedFile {
253    pub fn make_id(project_id: &str, file_path: &str) -> String {
254        let key = format!("{project_id}:{file_path}");
255        Uuid::new_v5(&CODE_INDEX_UUID_NAMESPACE, key.as_bytes()).to_string()
256    }
257}
258
259/// A chunk of file content for FTS search.
260#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct ContentChunk {
262    pub id: String,
263    pub project_id: String,
264    pub file_path: String,
265    pub chunk_index: usize,
266    pub line_start: usize,
267    pub line_end: usize,
268    pub content: String,
269    pub language: String,
270    pub created_at: String,
271}
272
273impl ContentChunk {
274    pub fn make_id(project_id: &str, file_path: &str, chunk_index: usize) -> String {
275        let key = format!("{project_id}:{file_path}:chunk:{chunk_index}");
276        Uuid::new_v5(&CODE_INDEX_UUID_NAMESPACE, key.as_bytes()).to_string()
277    }
278}
279
280/// Import relationship extracted from AST.
281#[derive(Debug, Clone)]
282pub struct ImportRelation {
283    pub file_path: String,
284    pub module_name: String,
285}
286
287/// Call relationship extracted from AST.
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289pub enum CallTargetKind {
290    Symbol,
291    Unresolved,
292    External,
293}
294
295impl CallTargetKind {
296    pub fn as_str(self) -> &'static str {
297        match self {
298            Self::Symbol => "symbol",
299            Self::Unresolved => "unresolved",
300            Self::External => "external",
301        }
302    }
303}
304
305/// Call relationship extracted from AST.
306#[derive(Debug, Clone)]
307pub struct CallRelation {
308    pub caller_symbol_id: String,
309    pub callee_symbol_id: Option<String>,
310    pub callee_name: String,
311    pub callee_target_kind: CallTargetKind,
312    pub callee_external_module: Option<String>,
313    pub file_path: String,
314    pub line: usize,
315}
316
317impl CallRelation {
318    pub fn new(
319        caller_symbol_id: String,
320        callee_name: String,
321        file_path: String,
322        line: usize,
323    ) -> Self {
324        Self {
325            caller_symbol_id,
326            callee_symbol_id: None,
327            callee_name,
328            callee_target_kind: CallTargetKind::Unresolved,
329            callee_external_module: None,
330            file_path,
331            line,
332        }
333    }
334
335    pub fn with_symbol_target(mut self, callee_symbol_id: String) -> Self {
336        self.callee_symbol_id = Some(callee_symbol_id);
337        self.callee_target_kind = CallTargetKind::Symbol;
338        self
339    }
340
341    pub fn with_external_target(
342        mut self,
343        callee_name: String,
344        callee_external_module: String,
345    ) -> Self {
346        self.callee_name = callee_name;
347        self.callee_target_kind = CallTargetKind::External;
348        self.callee_external_module = Some(callee_external_module);
349        self
350    }
351}
352
353/// Project index statistics.
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct IndexedProject {
356    pub id: String,
357    pub root_path: String,
358    pub total_files: usize,
359    pub total_symbols: usize,
360    pub last_indexed_at: String,
361    pub index_duration_ms: u64,
362    #[serde(skip_serializing_if = "Option::is_none")]
363    pub total_eligible_files: Option<usize>,
364}
365
366/// Search result with score.
367#[derive(Debug, Clone, Serialize, Deserialize)]
368pub struct SearchResult {
369    pub id: String,
370    pub name: String,
371    pub qualified_name: String,
372    pub kind: String,
373    pub language: String,
374    pub file_path: String,
375    pub line_start: usize,
376    pub line_end: usize,
377    pub score: f64,
378    #[serde(skip_serializing_if = "Option::is_none")]
379    pub rrf_score: Option<f64>,
380    #[serde(skip_serializing_if = "Option::is_none")]
381    pub summary: Option<String>,
382    #[serde(skip_serializing_if = "Option::is_none")]
383    pub signature: Option<String>,
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub sources: Option<Vec<String>>,
386}
387
388/// Graph query result (callers, usages).
389#[derive(Debug, Clone, Serialize, Deserialize)]
390pub struct GraphResult {
391    pub id: String,
392    pub name: String,
393    pub file_path: String,
394    pub line: usize,
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub relation: Option<String>,
397    #[serde(skip_serializing_if = "Option::is_none")]
398    pub distance: Option<usize>,
399    #[serde(default, skip_serializing_if = "Option::is_none")]
400    pub metadata: Option<ProjectionMetadata>,
401}
402
403/// Result of parsing a single file.
404pub struct ParseResult {
405    pub symbols: Vec<Symbol>,
406    pub imports: Vec<ImportRelation>,
407    pub calls: Vec<CallRelation>,
408    /// Raw file bytes — carried through for body snippet extraction at embedding time.
409    pub source: Vec<u8>,
410}
411
412/// Aggregate result of indexing a directory.
413#[derive(Debug, Clone, Serialize, Deserialize)]
414pub struct IndexResult {
415    pub project_id: String,
416    pub files_indexed: usize,
417    pub files_skipped: usize,
418    pub symbols_found: usize,
419    pub errors: Vec<String>,
420    pub duration_ms: u64,
421}
422
423/// Paginated response envelope for JSON output.
424/// Hoists `project_id` to avoid repeating it on every result.
425#[derive(Debug, Clone, Serialize)]
426pub struct PagedResponse<T: Serialize> {
427    pub project_id: String,
428    pub total: usize,
429    pub offset: usize,
430    pub limit: usize,
431    pub results: Vec<T>,
432    #[serde(skip_serializing_if = "Option::is_none")]
433    pub hint: Option<String>,
434}
435
436/// Slim symbol for outline output — only what agents need.
437#[derive(Debug, Clone, Serialize)]
438pub struct OutlineSymbol {
439    pub id: String,
440    pub name: String,
441    pub kind: String,
442    pub line_start: usize,
443    pub line_end: usize,
444    #[serde(skip_serializing_if = "Option::is_none")]
445    pub signature: Option<String>,
446}
447
448/// Content search hit from FTS.
449#[derive(Debug, Clone, Serialize, Deserialize)]
450pub struct ContentSearchHit {
451    pub file_path: String,
452    pub line_start: usize,
453    pub line_end: usize,
454    pub snippet: String,
455    #[serde(skip_serializing_if = "Option::is_none")]
456    pub language: Option<String>,
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    #[test]
464    fn uuid5_python_parity() {
465        assert_eq!(
466            CODE_INDEX_UUID_NAMESPACE.to_string(),
467            "c0de1de0-0000-4000-8000-000000000000"
468        );
469        assert_eq!(
470            Symbol::make_id("proj1", "src/main.py", "foo", "function", 42),
471            "403e2117-92e7-5390-ad83-226629486481"
472        );
473        assert_eq!(
474            make_unresolved_callee_id("proj1", "missing_func"),
475            "42693df1-99e6-5daa-be29-3535096cd2b5"
476        );
477        assert_eq!(
478            make_external_symbol_id("proj1", "get", Some("requests")),
479            "7c7e6ebe-47c6-5a3d-a83d-d5160f10cb74"
480        );
481        assert_eq!(
482            make_external_symbol_id("proj1", "println", None),
483            "c6b97498-448e-5ef1-9cb5-ab1cf37b6596"
484        );
485    }
486    #[test]
487    fn test_call_relation_promotes_symbol_targets() {
488        let call = CallRelation::new(
489            "caller-id".to_string(),
490            "foo".to_string(),
491            "src/main.py".to_string(),
492            12,
493        )
494        .with_symbol_target("callee-id".to_string());
495
496        assert_eq!(call.callee_symbol_id.as_deref(), Some("callee-id"));
497        assert_eq!(call.callee_target_kind, CallTargetKind::Symbol);
498    }
499
500    #[test]
501    fn graph_result_metadata_is_optional_for_json_compatibility() {
502        let old_json = serde_json::json!({
503            "id": "sym-1",
504            "name": "foo",
505            "file_path": "src/main.rs",
506            "line": 10
507        });
508
509        let parsed: GraphResult =
510            serde_json::from_value(old_json).expect("old graph result JSON still parses");
511        assert!(parsed.metadata.is_none());
512
513        let serialized = serde_json::to_value(&parsed).expect("graph result serializes");
514        assert!(serialized.get("metadata").is_none());
515    }
516}