1use anyhow::Context as _;
2use postgres::Row;
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6pub 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#[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#[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#[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 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 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 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 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#[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#[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#[derive(Debug, Clone)]
282pub struct ImportRelation {
283 pub file_path: String,
284 pub module_name: String,
285}
286
287#[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#[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#[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#[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#[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
403pub struct ParseResult {
405 pub symbols: Vec<Symbol>,
406 pub imports: Vec<ImportRelation>,
407 pub calls: Vec<CallRelation>,
408 pub source: Vec<u8>,
410}
411
412#[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#[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#[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#[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}