1use postgres::Row;
2use serde::{Deserialize, Serialize};
3use std::fmt;
4use uuid::Uuid;
5
6use crate::utils::i64_to_usize;
7
8pub const CODE_INDEX_UUID_NAMESPACE: Uuid = Uuid::from_bytes([
11 0xc0, 0xde, 0x1d, 0xe0, 0x00, 0x00, 0x40, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
12]);
13
14pub const SOURCE_SYSTEM_GCODE: &str = "gcode";
15
16#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
19pub enum ProjectionProvenance {
20 #[default]
21 Extracted,
22 Inferred,
23 Ambiguous,
24}
25
26impl ProjectionProvenance {
27 pub fn as_str(self) -> &'static str {
28 match self {
29 Self::Extracted => "EXTRACTED",
30 Self::Inferred => "INFERRED",
31 Self::Ambiguous => "AMBIGUOUS",
32 }
33 }
34
35 pub fn from_wire_value(value: &str) -> Option<Self> {
36 match value {
37 "EXTRACTED" | "extracted" => Some(Self::Extracted),
38 "INFERRED" | "inferred" => Some(Self::Inferred),
39 "AMBIGUOUS" | "ambiguous" => Some(Self::Ambiguous),
40 _ => None,
41 }
42 }
43}
44
45impl fmt::Display for ProjectionProvenance {
46 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47 f.write_str(self.as_str())
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53pub struct ProjectionMetadata {
54 pub provenance: ProjectionProvenance,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub confidence: Option<f64>,
57 pub source_system: String,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub source_file_path: Option<String>,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub source_line: Option<usize>,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub source_symbol_id: Option<String>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub matching_method: Option<String>,
66}
67
68impl ProjectionMetadata {
69 pub fn new(provenance: ProjectionProvenance, source_system: impl Into<String>) -> Self {
70 Self {
71 provenance,
72 confidence: None,
73 source_system: source_system.into(),
74 source_file_path: None,
75 source_line: None,
76 source_symbol_id: None,
77 matching_method: None,
78 }
79 }
80
81 pub fn gcode_extracted() -> Self {
82 Self::new(ProjectionProvenance::Extracted, SOURCE_SYSTEM_GCODE).with_confidence(Some(1.0))
83 }
84
85 pub fn inferred(source_system: impl Into<String>, confidence: Option<f64>) -> Self {
86 Self::new(ProjectionProvenance::Inferred, source_system).with_confidence(confidence)
87 }
88
89 pub fn ambiguous(source_system: impl Into<String>, confidence: Option<f64>) -> Self {
90 Self::new(ProjectionProvenance::Ambiguous, source_system).with_confidence(confidence)
91 }
92
93 pub fn with_confidence(mut self, confidence: Option<f64>) -> Self {
94 self.confidence = confidence;
95 self
96 }
97
98 pub fn with_source_file_path(mut self, file_path: impl Into<String>) -> Self {
99 self.source_file_path = Some(file_path.into());
100 self
101 }
102
103 pub fn with_source_line(mut self, line: usize) -> Self {
104 self.source_line = Some(line);
105 self
106 }
107
108 pub fn with_source_symbol_id(mut self, symbol_id: impl Into<String>) -> Self {
109 self.source_symbol_id = Some(symbol_id.into());
110 self
111 }
112
113 pub fn with_matching_method(mut self, matching_method: impl Into<String>) -> Self {
114 self.matching_method = Some(matching_method.into());
115 self
116 }
117
118 pub fn is_hypothesis(&self) -> bool {
119 matches!(
120 self.provenance,
121 ProjectionProvenance::Inferred | ProjectionProvenance::Ambiguous
122 )
123 }
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct Symbol {
129 pub id: String,
130 pub project_id: String,
131 pub file_path: String,
132 pub name: String,
133 pub qualified_name: String,
134 pub kind: String,
135 pub language: String,
136 pub byte_start: usize,
137 pub byte_end: usize,
138 pub line_start: usize,
139 pub line_end: usize,
140 #[serde(skip_serializing_if = "Option::is_none")]
141 pub signature: Option<String>,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub docstring: Option<String>,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 pub parent_symbol_id: Option<String>,
146 #[serde(default)]
147 pub content_hash: String,
148 #[serde(skip_serializing_if = "Option::is_none")]
149 pub summary: Option<String>,
150 #[serde(default)]
151 pub created_at: String,
152 #[serde(default)]
153 pub updated_at: String,
154}
155
156impl Symbol {
157 pub fn make_id(
160 project_id: &str,
161 file_path: &str,
162 name: &str,
163 kind: &str,
164 byte_start: usize,
165 ) -> String {
166 let key = format!("{project_id}:{file_path}:{name}:{kind}:{byte_start}");
167 Uuid::new_v5(&CODE_INDEX_UUID_NAMESPACE, key.as_bytes()).to_string()
168 }
169
170 pub fn from_row(row: &Row) -> anyhow::Result<Self> {
175 Ok(Self {
176 id: row.try_get("id")?,
177 project_id: row.try_get("project_id")?,
178 file_path: row.try_get("file_path")?,
179 name: row.try_get("name")?,
180 qualified_name: row.try_get("qualified_name")?,
181 kind: row.try_get("kind")?,
182 language: row.try_get("language")?,
183 byte_start: i64_to_usize(row.try_get("byte_start")?, "byte_start")?,
184 byte_end: i64_to_usize(row.try_get("byte_end")?, "byte_end")?,
185 line_start: i64_to_usize(row.try_get("line_start")?, "line_start")?,
186 line_end: i64_to_usize(row.try_get("line_end")?, "line_end")?,
187 signature: row.try_get("signature")?,
188 docstring: row.try_get("docstring")?,
189 parent_symbol_id: row.try_get("parent_symbol_id")?,
190 content_hash: row
191 .try_get::<_, Option<String>>("content_hash")?
192 .unwrap_or_default(),
193 summary: row.try_get("summary")?,
194 created_at: row
195 .try_get::<_, Option<String>>("created_at")?
196 .unwrap_or_default(),
197 updated_at: row
198 .try_get::<_, Option<String>>("updated_at")?
199 .unwrap_or_default(),
200 })
201 }
202
203 pub fn to_outline(&self) -> OutlineSymbol {
205 OutlineSymbol {
206 id: self.id.clone(),
207 name: self.name.clone(),
208 kind: self.kind.clone(),
209 line_start: self.line_start,
210 line_end: self.line_end,
211 signature: self.signature.clone(),
212 }
213 }
214
215 pub fn to_brief(&self) -> SearchResult {
217 SearchResult {
218 id: self.id.clone(),
219 name: self.name.clone(),
220 qualified_name: self.qualified_name.clone(),
221 kind: self.kind.clone(),
222 language: self.language.clone(),
223 file_path: self.file_path.clone(),
224 line_start: self.line_start,
225 line_end: self.line_end,
226 score: 0.0,
227 rrf_score: None,
228 summary: self.summary.clone(),
229 signature: self.signature.clone(),
230 sources: None,
231 }
232 }
233}
234
235pub fn make_unresolved_callee_id(project_id: &str, callee_name: &str) -> String {
236 let key = format!("unresolved:{project_id}:{callee_name}");
237 Uuid::new_v5(&CODE_INDEX_UUID_NAMESPACE, key.as_bytes()).to_string()
238}
239
240pub fn make_external_symbol_id(
241 project_id: &str,
242 callee_name: &str,
243 module: Option<&str>,
244) -> String {
245 let module_key = module.unwrap_or_default();
246 let key = format!("external:{project_id}:{module_key}:{callee_name}");
247 Uuid::new_v5(&CODE_INDEX_UUID_NAMESPACE, key.as_bytes()).to_string()
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct IndexedFile {
253 pub id: String,
254 pub project_id: String,
255 pub file_path: String,
256 pub language: String,
257 pub content_hash: String,
258 pub symbol_count: usize,
259 pub byte_size: usize,
260 pub indexed_at: String,
261}
262
263impl IndexedFile {
264 pub fn make_id(project_id: &str, file_path: &str) -> String {
265 let key = format!("{project_id}:{file_path}");
266 Uuid::new_v5(&CODE_INDEX_UUID_NAMESPACE, key.as_bytes()).to_string()
267 }
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct ContentChunk {
273 pub id: String,
274 pub project_id: String,
275 pub file_path: String,
276 pub chunk_index: usize,
277 pub line_start: usize,
278 pub line_end: usize,
279 pub content: String,
280 pub language: String,
281 pub created_at: String,
282}
283
284impl ContentChunk {
285 pub fn make_id(project_id: &str, file_path: &str, chunk_index: usize) -> String {
286 let key = format!("{project_id}:{file_path}:chunk:{chunk_index}");
287 Uuid::new_v5(&CODE_INDEX_UUID_NAMESPACE, key.as_bytes()).to_string()
288 }
289}
290
291#[derive(Debug, Clone)]
293pub struct ImportRelation {
294 pub file_path: String,
295 pub module_name: String,
296}
297
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300pub enum CallTargetKind {
301 Symbol,
302 Unresolved,
303 External,
304 LocalImport,
310}
311
312impl CallTargetKind {
313 pub fn as_str(self) -> &'static str {
314 match self {
315 Self::Symbol => "symbol",
316 Self::Unresolved => "unresolved",
317 Self::External => "external",
318 Self::LocalImport => "local_import",
319 }
320 }
321}
322
323#[derive(Debug, Clone)]
325pub struct CallRelation {
326 pub caller_symbol_id: String,
327 pub callee_symbol_id: Option<String>,
328 pub callee_name: String,
329 pub callee_target_kind: CallTargetKind,
330 pub callee_external_module: Option<String>,
331 pub file_path: String,
332 pub line: usize,
333}
334
335impl CallRelation {
336 pub fn new(
337 caller_symbol_id: String,
338 callee_name: String,
339 file_path: String,
340 line: usize,
341 ) -> Self {
342 Self {
343 caller_symbol_id,
344 callee_symbol_id: None,
345 callee_name,
346 callee_target_kind: CallTargetKind::Unresolved,
347 callee_external_module: None,
348 file_path,
349 line,
350 }
351 }
352
353 pub fn with_symbol_target(mut self, callee_symbol_id: String) -> Self {
354 self.callee_symbol_id = Some(callee_symbol_id);
355 self.callee_target_kind = CallTargetKind::Symbol;
356 self
357 }
358
359 pub fn with_external_target(
360 mut self,
361 callee_name: String,
362 callee_external_module: String,
363 ) -> Self {
364 self.callee_name = callee_name;
365 self.callee_target_kind = CallTargetKind::External;
366 self.callee_external_module = Some(callee_external_module);
367 self
368 }
369
370 pub fn with_local_import_target(
383 mut self,
384 callee_name: String,
385 candidate_files: Vec<String>,
386 ) -> Self {
387 self.callee_name = callee_name;
388 self.callee_target_kind = CallTargetKind::LocalImport;
389 self.callee_symbol_id = None;
390 self.callee_external_module = Some(candidate_files.join(LOCAL_IMPORT_CANDIDATE_SEP));
391 self
392 }
393
394 pub fn with_local_default_import_target(
395 mut self,
396 callee_name: String,
397 candidate_files: Vec<String>,
398 ) -> Self {
399 self.callee_name = callee_name;
400 self.callee_target_kind = CallTargetKind::LocalImport;
401 self.callee_symbol_id = None;
402 let encoded = std::iter::once(LOCAL_IMPORT_DEFAULT_EXPORT_MARKER.to_string())
403 .chain(candidate_files)
404 .collect::<Vec<_>>()
405 .join(LOCAL_IMPORT_CANDIDATE_SEP);
406 self.callee_external_module = Some(encoded);
407 self
408 }
409
410 pub fn local_import_uses_default_export_fallback(&self) -> bool {
411 self.callee_target_kind == CallTargetKind::LocalImport
412 && self
413 .callee_external_module
414 .as_deref()
415 .and_then(|joined| joined.split(LOCAL_IMPORT_CANDIDATE_SEP).next())
416 == Some(LOCAL_IMPORT_DEFAULT_EXPORT_MARKER)
417 }
418
419 pub fn local_import_candidate_files(&self) -> Vec<String> {
422 if self.callee_target_kind != CallTargetKind::LocalImport {
423 return Vec::new();
424 }
425 self.callee_external_module
426 .as_deref()
427 .map(|joined| {
428 joined
429 .split(LOCAL_IMPORT_CANDIDATE_SEP)
430 .filter(|part| !part.is_empty() && *part != LOCAL_IMPORT_DEFAULT_EXPORT_MARKER)
431 .map(ToOwned::to_owned)
432 .collect()
433 })
434 .unwrap_or_default()
435 }
436}
437
438pub const LOCAL_IMPORT_CANDIDATE_SEP: &str = "\n";
442const LOCAL_IMPORT_DEFAULT_EXPORT_MARKER: &str = "__gcode_local_import_default_export__";
443
444#[derive(Debug, Clone, Serialize, Deserialize)]
446pub struct IndexedProject {
447 pub id: String,
448 pub root_path: String,
449 pub total_files: usize,
450 pub total_symbols: usize,
451 pub last_indexed_at: String,
452 pub index_duration_ms: u64,
453 #[serde(skip_serializing_if = "Option::is_none")]
454 pub total_eligible_files: Option<usize>,
455}
456
457#[derive(Debug, Clone, Serialize, Deserialize)]
459pub struct SearchResult {
460 pub id: String,
461 pub name: String,
462 pub qualified_name: String,
463 pub kind: String,
464 pub language: String,
465 pub file_path: String,
466 pub line_start: usize,
467 pub line_end: usize,
468 pub score: f64,
469 #[serde(skip_serializing_if = "Option::is_none")]
470 pub rrf_score: Option<f64>,
471 #[serde(skip_serializing_if = "Option::is_none")]
472 pub summary: Option<String>,
473 #[serde(skip_serializing_if = "Option::is_none")]
474 pub signature: Option<String>,
475 #[serde(skip_serializing_if = "Option::is_none")]
476 pub sources: Option<Vec<String>>,
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize)]
481pub struct GraphResult {
482 pub id: String,
483 pub name: String,
484 pub file_path: String,
485 pub line: usize,
486 #[serde(default)]
488 pub confidence: ProjectionProvenance,
489 #[serde(skip_serializing_if = "Option::is_none")]
490 pub relation: Option<String>,
491 #[serde(skip_serializing_if = "Option::is_none")]
492 pub distance: Option<usize>,
493 #[serde(default, skip_serializing_if = "Option::is_none")]
494 pub metadata: Option<ProjectionMetadata>,
495}
496
497#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
498pub struct GraphPathStep {
499 pub position: usize,
500 pub id: String,
501 pub name: String,
502 pub file_path: String,
503 pub line: usize,
504}
505
506pub struct ParseResult {
508 pub symbols: Vec<Symbol>,
509 pub imports: Vec<ImportRelation>,
510 pub calls: Vec<CallRelation>,
511 pub source: Vec<u8>,
513}
514
515#[derive(Debug, Clone, Serialize, Deserialize)]
517pub struct IndexResult {
518 pub project_id: String,
519 pub files_indexed: usize,
520 pub files_skipped: usize,
521 pub symbols_found: usize,
522 pub errors: Vec<String>,
523 pub duration_ms: u64,
524}
525
526#[derive(Debug, Clone, Serialize)]
529pub struct PagedResponse<T: Serialize> {
530 pub project_id: String,
531 pub total: usize,
532 pub offset: usize,
533 pub limit: usize,
534 pub results: Vec<T>,
535 #[serde(skip_serializing_if = "Option::is_none")]
536 pub hint: Option<String>,
537}
538
539#[derive(Debug, Clone, Serialize)]
541pub struct OutlineSymbol {
542 pub id: String,
543 pub name: String,
544 pub kind: String,
545 pub line_start: usize,
546 pub line_end: usize,
547 #[serde(skip_serializing_if = "Option::is_none")]
548 pub signature: Option<String>,
549}
550
551#[derive(Debug, Clone, Serialize, Deserialize)]
553pub struct ContentSearchHit {
554 pub file_path: String,
555 pub line_start: usize,
556 pub line_end: usize,
557 pub snippet: String,
558 #[serde(skip_serializing_if = "Option::is_none")]
559 pub language: Option<String>,
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565
566 #[test]
567 fn symbol_make_id_matches_python_uuid5_golden_vectors() {
568 assert_eq!(
569 CODE_INDEX_UUID_NAMESPACE.to_string(),
570 "c0de1de0-0000-4000-8000-000000000000"
571 );
572
573 let cases = [
574 (
575 "proj1",
576 "src/main.py",
577 "foo",
578 "function",
579 42,
580 "403e2117-92e7-5390-ad83-226629486481",
581 ),
582 (
583 "3bf57fe7-2a0c-4074-8912-a83d9cd4df01",
584 "crates/gcode/src/models.rs",
585 "Symbol",
586 "struct",
587 111,
588 "d28e80d3-a95e-5c2a-91c3-92551f75a2b1",
589 ),
590 (
591 "proj-with-dashes",
592 "src/lib.rs",
593 "Widget::render",
594 "method",
595 0,
596 "44da4f31-7218-5b3b-97c4-5a5eca9f0451",
597 ),
598 (
599 "overlay:child",
600 "nested/path/file.ts",
601 "HTTPClient.new",
602 "class",
603 987654321,
604 "f9531553-f2a7-5425-b487-6fb5b31d57bb",
605 ),
606 ];
607
608 for (project_id, file_path, name, kind, byte_start, expected) in cases {
609 assert_eq!(
610 Symbol::make_id(project_id, file_path, name, kind, byte_start),
611 expected,
612 "Python UUID5 parity failed for {project_id}:{file_path}:{name}:{kind}:{byte_start}"
613 );
614 }
615 }
616
617 #[test]
618 fn unresolved_and_external_ids_match_python_uuid5_golden_vectors() {
619 assert_eq!(
620 make_unresolved_callee_id("proj1", "missing_func"),
621 "42693df1-99e6-5daa-be29-3535096cd2b5"
622 );
623 assert_eq!(
624 make_external_symbol_id("proj1", "get", Some("requests")),
625 "7c7e6ebe-47c6-5a3d-a83d-d5160f10cb74"
626 );
627 assert_eq!(
628 make_external_symbol_id("proj1", "println", None),
629 "c6b97498-448e-5ef1-9cb5-ab1cf37b6596"
630 );
631 }
632 #[test]
633 fn test_call_relation_promotes_symbol_targets() {
634 let call = CallRelation::new(
635 "caller-id".to_string(),
636 "foo".to_string(),
637 "src/main.py".to_string(),
638 12,
639 )
640 .with_symbol_target("callee-id".to_string());
641
642 assert_eq!(call.callee_symbol_id.as_deref(), Some("callee-id"));
643 assert_eq!(call.callee_target_kind, CallTargetKind::Symbol);
644 }
645
646 #[test]
647 fn graph_result_metadata_remains_optional_in_json_contract() {
648 let json = serde_json::json!({
649 "id": "sym-1",
650 "name": "foo",
651 "file_path": "src/main.rs",
652 "line": 10
653 });
654
655 let parsed: GraphResult =
656 serde_json::from_value(json).expect("graph result JSON parses without metadata");
657 assert_eq!(parsed.confidence, ProjectionProvenance::Extracted);
658 assert!(parsed.metadata.is_none());
659
660 let serialized = serde_json::to_value(&parsed).expect("graph result serializes");
661 assert_eq!(serialized["confidence"], "EXTRACTED");
662 assert!(serialized.get("metadata").is_none());
663 }
664
665 #[test]
666 fn graph_result_without_metadata_omits_metadata_when_serialized() {
667 let strategy = (
668 proptest::string::string_regex("[ -~]{0,32}").expect("valid id regex"),
669 proptest::string::string_regex("[ -~]{0,32}").expect("valid name regex"),
670 proptest::string::string_regex("[ -~]{0,64}").expect("valid path regex"),
671 0usize..1_000_000,
672 proptest::option::of(
673 proptest::string::string_regex("[ -~]{0,32}").expect("valid relation regex"),
674 ),
675 proptest::option::of(0usize..1_000),
676 );
677
678 proptest::test_runner::TestRunner::default()
679 .run(
680 &strategy,
681 |(id, name, file_path, line, relation, distance)| {
682 let result = GraphResult {
683 id,
684 name,
685 file_path,
686 line,
687 confidence: ProjectionProvenance::Extracted,
688 relation,
689 distance,
690 metadata: None,
691 };
692
693 let serialized =
694 serde_json::to_value(&result).expect("graph result serializes");
695 assert_eq!(serialized["confidence"], "EXTRACTED");
696 assert_eq!(serialized.get("metadata"), None);
697
698 Ok(())
699 },
700 )
701 .expect("metadata omission property holds");
702 }
703}