1use std::collections::BTreeSet;
2
3use serde::{Deserialize, Serialize};
4
5use super::{DomainError, SourceScope, error::required_text};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum CodeParseStatus {
11 Parsed,
12 Partial,
13 TextOnly,
14 Failed,
15}
16
17impl CodeParseStatus {
18 pub const fn as_str(self) -> &'static str {
20 match self {
21 Self::Parsed => "parsed",
22 Self::Partial => "partial",
23 Self::TextOnly => "text_only",
24 Self::Failed => "failed",
25 }
26 }
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum CodeSymbolKind {
33 Function,
34 Method,
35 Class,
36 Interface,
37 Module,
38 Type,
39 Constant,
40 Field,
41 Variable,
42 EnumMember,
43}
44
45impl CodeSymbolKind {
46 pub const fn as_str(self) -> &'static str {
48 match self {
49 Self::Function => "function",
50 Self::Method => "method",
51 Self::Class => "class",
52 Self::Interface => "interface",
53 Self::Module => "module",
54 Self::Type => "type",
55 Self::Constant => "constant",
56 Self::Field => "field",
57 Self::Variable => "variable",
58 Self::EnumMember => "enum_member",
59 }
60 }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum CodeReferenceKind {
67 Call,
68 Type,
69 Import,
70 Implementation,
71}
72
73impl CodeReferenceKind {
74 pub const fn as_str(self) -> &'static str {
76 match self {
77 Self::Call => "call",
78 Self::Type => "type",
79 Self::Import => "import",
80 Self::Implementation => "implementation",
81 }
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(tag = "type", rename_all = "snake_case")]
88pub enum SymbolRole {
89 RouteHandler { url: String, http_method: String },
90 RouteHandlers { routes: Vec<RouteHandlerRole> },
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub struct RouteHandlerRole {
96 pub url: String,
97 pub http_method: String,
98}
99
100impl SymbolRole {
101 pub fn merge_route_handler(&mut self, url: String, http_method: String) {
104 match self {
105 Self::RouteHandler {
106 url: existing_url,
107 http_method: existing_method,
108 } if *existing_url == url && *existing_method == http_method => {}
109 Self::RouteHandler { .. } => {
110 let existing = std::mem::replace(self, Self::RouteHandlers { routes: Vec::new() });
111 if let Self::RouteHandler {
112 url: existing_url,
113 http_method: existing_method,
114 } = existing
115 {
116 *self = Self::RouteHandlers {
117 routes: vec![
118 RouteHandlerRole {
119 url: existing_url,
120 http_method: existing_method,
121 },
122 RouteHandlerRole { url, http_method },
123 ],
124 };
125 }
126 }
127 Self::RouteHandlers { routes } => {
128 if !routes
129 .iter()
130 .any(|route| route.url == url && route.http_method == http_method)
131 {
132 routes.push(RouteHandlerRole { url, http_method });
133 }
134 }
135 }
136 }
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(rename_all = "snake_case")]
142pub enum CodeResolutionState {
143 Unresolved,
144 Ambiguous,
145 Resolved,
146}
147
148impl CodeResolutionState {
149 pub const fn as_str(self) -> &'static str {
151 match self {
152 Self::Unresolved => "unresolved",
153 Self::Ambiguous => "ambiguous",
154 Self::Resolved => "resolved",
155 }
156 }
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
161pub struct CodeRange {
162 pub start_byte: u32,
163 pub end_byte: u32,
164 pub start_line: u32,
165 pub end_line: u32,
166}
167
168impl CodeRange {
169 pub fn new(
171 start_byte: u32,
172 end_byte: u32,
173 start_line: u32,
174 end_line: u32,
175 ) -> Result<Self, DomainError> {
176 if end_byte <= start_byte {
177 return Err(DomainError::invalid(
178 "code_range",
179 "end byte must be greater than start byte",
180 ));
181 }
182 if start_line == 0 {
183 return Err(DomainError::invalid(
184 "code_range",
185 "start line must be one-based",
186 ));
187 }
188 if end_line < start_line {
189 return Err(DomainError::invalid(
190 "code_range",
191 "end line must not be before start line",
192 ));
193 }
194
195 Ok(Self {
196 start_byte,
197 end_byte,
198 start_line,
199 end_line,
200 })
201 }
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206pub struct CodeExtractionMetadata {
207 pub grammar_version: String,
208 pub query_name: String,
209 pub query_version: String,
210 pub node_kind: String,
211 pub capture_kind: String,
212}
213
214impl CodeExtractionMetadata {
215 pub fn new(
217 grammar_version: impl Into<String>,
218 query_name: impl Into<String>,
219 query_version: impl Into<String>,
220 node_kind: impl Into<String>,
221 capture_kind: impl Into<String>,
222 ) -> Result<Self, DomainError> {
223 Ok(Self {
224 grammar_version: validated_text("grammar_version", grammar_version)?,
225 query_name: validated_text("query_name", query_name)?,
226 query_version: validated_text("query_version", query_version)?,
227 node_kind: validated_text("node_kind", node_kind)?,
228 capture_kind: validated_text("capture_kind", capture_kind)?,
229 })
230 }
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235pub struct CodeSymbolRecord {
236 pub symbol_id: String,
237 pub source_scope: SourceScope,
238 pub path: String,
239 pub name: String,
240 pub kind: CodeSymbolKind,
241 pub range: CodeRange,
242 pub extraction: CodeExtractionMetadata,
243 #[serde(skip_serializing_if = "Option::is_none")]
244 pub symbol_role: Option<SymbolRole>,
245}
246
247impl CodeSymbolRecord {
248 pub fn new(
250 symbol_id: impl Into<String>,
251 source_scope: SourceScope,
252 path: impl Into<String>,
253 name: impl Into<String>,
254 kind: CodeSymbolKind,
255 range: CodeRange,
256 extraction: CodeExtractionMetadata,
257 ) -> Result<Self, DomainError> {
258 Ok(Self {
259 symbol_id: validated_text("symbol_id", symbol_id)?,
260 source_scope,
261 path: validated_repo_path(path)?,
262 name: validated_text("symbol_name", name)?,
263 kind,
264 range,
265 extraction,
266 symbol_role: None,
267 })
268 }
269}
270
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
273pub struct CodeReferenceRecord {
274 pub reference_id: String,
275 pub source_scope: SourceScope,
276 pub path: String,
277 pub symbol_text: String,
278 pub kind: CodeReferenceKind,
279 pub range: CodeRange,
280 pub resolution_state: CodeResolutionState,
281 pub target_symbol_id: Option<String>,
282 pub extraction: CodeExtractionMetadata,
283}
284
285#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct CodeReferenceFields {
288 pub reference_id: String,
289 pub source_scope: SourceScope,
290 pub path: String,
291 pub symbol_text: String,
292 pub kind: CodeReferenceKind,
293 pub range: CodeRange,
294 pub resolution_state: CodeResolutionState,
295 pub target_symbol_id: Option<String>,
296 pub extraction: CodeExtractionMetadata,
297}
298
299impl CodeReferenceRecord {
300 pub fn new(fields: CodeReferenceFields) -> Result<Self, DomainError> {
302 let target_symbol_id = fields
303 .target_symbol_id
304 .map(|id| validated_text("target_symbol_id", id))
305 .transpose()?;
306 if fields.resolution_state == CodeResolutionState::Resolved && target_symbol_id.is_none() {
307 return Err(DomainError::invalid(
308 "target_symbol_id",
309 "resolved references must include a target symbol",
310 ));
311 }
312
313 Ok(Self {
314 reference_id: validated_text("reference_id", fields.reference_id)?,
315 source_scope: fields.source_scope,
316 path: validated_repo_path(fields.path)?,
317 symbol_text: validated_text("symbol_text", fields.symbol_text)?,
318 kind: fields.kind,
319 range: fields.range,
320 resolution_state: fields.resolution_state,
321 target_symbol_id,
322 extraction: fields.extraction,
323 })
324 }
325}
326
327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
329pub struct CodeChunkRecord {
330 pub chunk_id: String,
331 pub source_scope: SourceScope,
332 pub path: String,
333 pub content: String,
334 pub range: CodeRange,
335 pub linked_symbol_ids: Vec<String>,
336 #[serde(skip_serializing_if = "Option::is_none")]
337 pub extraction: Option<CodeExtractionMetadata>,
338}
339
340impl CodeChunkRecord {
341 pub fn new(
343 chunk_id: impl Into<String>,
344 source_scope: SourceScope,
345 path: impl Into<String>,
346 content: impl Into<String>,
347 range: CodeRange,
348 linked_symbol_ids: Vec<String>,
349 extraction: Option<CodeExtractionMetadata>,
350 ) -> Result<Self, DomainError> {
351 let mut deduped = Vec::new();
352 for symbol_id in linked_symbol_ids {
353 let symbol_id = validated_text("linked_symbol_id", symbol_id)?;
354 if !deduped.contains(&symbol_id) {
355 deduped.push(symbol_id);
356 }
357 }
358
359 Ok(Self {
360 chunk_id: validated_text("chunk_id", chunk_id)?,
361 source_scope,
362 path: validated_repo_path(path)?,
363 content: validated_text("chunk_content", content)?,
364 range,
365 linked_symbol_ids: deduped,
366 extraction,
367 })
368 }
369}
370
371#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
373pub struct CodeFileRecord {
374 pub source_scope: SourceScope,
375 pub path: String,
376 pub content_hash: String,
377 pub language_id: String,
378 pub parse_status: CodeParseStatus,
379 #[serde(skip_serializing_if = "Option::is_none")]
380 pub diagnostic: Option<String>,
381 pub symbols: Vec<CodeSymbolRecord>,
382 pub references: Vec<CodeReferenceRecord>,
383 pub chunks: Vec<CodeChunkRecord>,
384}
385
386#[derive(Debug, Clone, PartialEq, Eq)]
388pub struct CodeFileFields {
389 pub source_scope: SourceScope,
390 pub path: String,
391 pub content_hash: String,
392 pub language_id: String,
393 pub parse_status: CodeParseStatus,
394 pub diagnostic: Option<String>,
395 pub symbols: Vec<CodeSymbolRecord>,
396 pub references: Vec<CodeReferenceRecord>,
397 pub chunks: Vec<CodeChunkRecord>,
398}
399
400impl CodeFileRecord {
401 pub fn new(fields: CodeFileFields) -> Result<Self, DomainError> {
403 let path = validated_repo_path(fields.path)?;
404 let diagnostic = fields
405 .diagnostic
406 .map(|value| validated_text("parse_diagnostic", value))
407 .transpose()?;
408 validate_parse_status(
409 fields.parse_status,
410 diagnostic.as_deref(),
411 &fields.symbols,
412 &fields.references,
413 &fields.chunks,
414 )?;
415 validate_nested_facts(
416 &fields.source_scope,
417 &path,
418 &fields.symbols,
419 &fields.references,
420 &fields.chunks,
421 )?;
422
423 Ok(Self {
424 source_scope: fields.source_scope,
425 path,
426 content_hash: validated_text("content_hash", fields.content_hash)?,
427 language_id: validated_text("language_id", fields.language_id)?,
428 parse_status: fields.parse_status,
429 diagnostic,
430 symbols: fields.symbols,
431 references: fields.references,
432 chunks: fields.chunks,
433 })
434 }
435}
436
437#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
439pub struct CodeGraphBatch {
440 pub files: Vec<CodeFileRecord>,
441}
442
443impl CodeGraphBatch {
444 pub fn new(files: Vec<CodeFileRecord>) -> Result<Self, DomainError> {
446 if files.is_empty() {
447 return Err(DomainError::invalid(
448 "code_files",
449 "must include at least one file",
450 ));
451 }
452
453 let mut keys = BTreeSet::new();
454 for file in &files {
455 let key = (file.source_scope.as_str(), file.path.as_str());
456 if !keys.insert(key) {
457 return Err(DomainError::invalid(
458 "code_file",
459 "scope and path must be unique within a mutation batch",
460 ));
461 }
462 }
463
464 Ok(Self { files })
465 }
466}
467
468#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
470pub struct CodeGraphCommitReceipt {
471 pub graph_version: super::GraphVersion,
472 pub file_count: usize,
473 pub symbol_count: usize,
474 pub reference_count: usize,
475 pub chunk_count: usize,
476}
477
478#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
480pub struct CodeParseStatusCounts {
481 pub parsed: usize,
482 pub partial: usize,
483 pub text_only: usize,
484 pub failed: usize,
485}
486
487fn validate_parse_status(
488 status: CodeParseStatus,
489 diagnostic: Option<&str>,
490 symbols: &[CodeSymbolRecord],
491 references: &[CodeReferenceRecord],
492 chunks: &[CodeChunkRecord],
493) -> Result<(), DomainError> {
494 match status {
495 CodeParseStatus::Parsed => Ok(()),
496 CodeParseStatus::Partial => {
497 if diagnostic.is_none() {
498 return Err(DomainError::invalid(
499 "parse_diagnostic",
500 "partial parses must include a diagnostic",
501 ));
502 }
503 Ok(())
504 }
505 CodeParseStatus::TextOnly => {
506 if !symbols.is_empty() || !references.is_empty() {
507 return Err(DomainError::invalid(
508 "parse_status",
509 "text-only files cannot include syntax facts",
510 ));
511 }
512 Ok(())
513 }
514 CodeParseStatus::Failed => {
515 if diagnostic.is_none() {
516 return Err(DomainError::invalid(
517 "parse_diagnostic",
518 "failed parses must include a diagnostic",
519 ));
520 }
521 if !symbols.is_empty() || !references.is_empty() || !chunks.is_empty() {
522 return Err(DomainError::invalid(
523 "parse_status",
524 "failed files cannot include extracted code facts",
525 ));
526 }
527 Ok(())
528 }
529 }
530}
531
532fn validate_nested_facts(
533 scope: &SourceScope,
534 path: &str,
535 symbols: &[CodeSymbolRecord],
536 references: &[CodeReferenceRecord],
537 chunks: &[CodeChunkRecord],
538) -> Result<(), DomainError> {
539 for symbol in symbols {
540 validate_scope_and_path(scope, path, &symbol.source_scope, &symbol.path)?;
541 }
542 for reference in references {
543 validate_scope_and_path(scope, path, &reference.source_scope, &reference.path)?;
544 }
545 for chunk in chunks {
546 validate_scope_and_path(scope, path, &chunk.source_scope, &chunk.path)?;
547 }
548
549 Ok(())
550}
551
552fn validate_scope_and_path(
553 expected_scope: &SourceScope,
554 expected_path: &str,
555 actual_scope: &SourceScope,
556 actual_path: &str,
557) -> Result<(), DomainError> {
558 if expected_scope != actual_scope || expected_path != actual_path {
559 return Err(DomainError::invalid(
560 "code_file",
561 "nested code facts must match the file scope and path",
562 ));
563 }
564
565 Ok(())
566}
567
568fn validated_repo_path(value: impl Into<String>) -> Result<String, DomainError> {
569 let path = validated_text("code_path", value)?;
570 if path.starts_with('/') || path.starts_with('\\') {
571 return Err(DomainError::invalid(
572 "code_path",
573 "must be repository-relative",
574 ));
575 }
576 if path
577 .split(['/', '\\'])
578 .any(|component| component.is_empty() || component == "." || component == "..")
579 {
580 return Err(DomainError::invalid(
581 "code_path",
582 "must not contain empty, current, or parent components",
583 ));
584 }
585
586 Ok(path)
587}
588
589fn validated_text(field: &'static str, value: impl Into<String>) -> Result<String, DomainError> {
590 let text = required_text(field, value)?;
591 if text.contains('\0') {
592 return Err(DomainError::invalid(field, "must not contain NUL bytes"));
593 }
594
595 Ok(text)
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601
602 #[test]
603 fn rejects_invalid_ranges_and_paths() {
604 let range_error = CodeRange::new(10, 10, 1, 1).expect_err("empty range should fail");
605 let path_error = CodeFileRecord::new(CodeFileFields {
606 source_scope: scope(),
607 path: "../lib.rs".to_owned(),
608 content_hash: "hash".to_owned(),
609 language_id: "rust".to_owned(),
610 parse_status: CodeParseStatus::Parsed,
611 diagnostic: None,
612 symbols: Vec::new(),
613 references: Vec::new(),
614 chunks: Vec::new(),
615 })
616 .expect_err("parent paths should fail");
617
618 assert_eq!(range_error.field, "code_range");
619 assert_eq!(path_error.field, "code_path");
620 }
621
622 #[test]
623 fn validates_status_specific_code_facts() {
624 let failed_with_chunk = CodeFileRecord::new(CodeFileFields {
625 source_scope: scope(),
626 path: "src/lib.rs".to_owned(),
627 content_hash: "hash".to_owned(),
628 language_id: "rust".to_owned(),
629 parse_status: CodeParseStatus::Failed,
630 diagnostic: Some("parser failed".to_owned()),
631 symbols: Vec::new(),
632 references: Vec::new(),
633 chunks: vec![chunk("chunk-1", scope(), "src/lib.rs")],
634 })
635 .expect_err("failed file facts should fail");
636 let partial_without_diagnostic = CodeFileRecord::new(CodeFileFields {
637 source_scope: scope(),
638 path: "src/lib.rs".to_owned(),
639 content_hash: "hash".to_owned(),
640 language_id: "rust".to_owned(),
641 parse_status: CodeParseStatus::Partial,
642 diagnostic: None,
643 symbols: Vec::new(),
644 references: Vec::new(),
645 chunks: Vec::new(),
646 })
647 .expect_err("partial diagnostics should be required");
648
649 assert_eq!(failed_with_chunk.field, "parse_status");
650 assert_eq!(partial_without_diagnostic.field, "parse_diagnostic");
651 }
652
653 #[test]
654 fn rejects_resolved_reference_without_target() {
655 let error = CodeReferenceRecord::new(CodeReferenceFields {
656 reference_id: "ref-1".to_owned(),
657 source_scope: scope(),
658 path: "src/lib.rs".to_owned(),
659 symbol_text: "main".to_owned(),
660 kind: CodeReferenceKind::Call,
661 range: range(),
662 resolution_state: CodeResolutionState::Resolved,
663 target_symbol_id: None,
664 extraction: extraction(),
665 })
666 .expect_err("target should be required");
667
668 assert_eq!(error.field, "target_symbol_id");
669 }
670
671 #[test]
672 fn batch_rejects_duplicate_file_replacements() {
673 let first = parsed_file("src/lib.rs").expect("file should validate");
674 let second = parsed_file("src/lib.rs").expect("file should validate");
675 let error = CodeGraphBatch::new(vec![first, second]).expect_err("duplicate should fail");
676
677 assert_eq!(error.field, "code_file");
678 }
679
680 #[test]
681 fn chunk_deduplicates_linked_symbol_ids() {
682 let chunk = CodeChunkRecord::new(
683 "chunk-1",
684 scope(),
685 "src/lib.rs",
686 "fn main() {}",
687 range(),
688 vec!["sym-1".to_owned(), "sym-1".to_owned()],
689 Some(extraction()),
690 )
691 .expect("chunk should validate");
692
693 assert_eq!(chunk.linked_symbol_ids, ["sym-1"]);
694 }
695
696 fn parsed_file(path: &str) -> Result<CodeFileRecord, DomainError> {
697 let source_scope = scope();
698 CodeFileRecord::new(CodeFileFields {
699 source_scope: source_scope.clone(),
700 path: path.to_owned(),
701 content_hash: "hash".to_owned(),
702 language_id: "rust".to_owned(),
703 parse_status: CodeParseStatus::Parsed,
704 diagnostic: None,
705 symbols: vec![symbol("sym-1", source_scope.clone(), path)],
706 references: Vec::new(),
707 chunks: vec![chunk("chunk-1", source_scope, path)],
708 })
709 }
710
711 fn symbol(id: &str, source_scope: SourceScope, path: &str) -> CodeSymbolRecord {
712 CodeSymbolRecord::new(
713 id,
714 source_scope,
715 path,
716 "main",
717 CodeSymbolKind::Function,
718 range(),
719 extraction(),
720 )
721 .expect("symbol should validate")
722 }
723
724 fn chunk(id: &str, source_scope: SourceScope, path: &str) -> CodeChunkRecord {
725 CodeChunkRecord::new(
726 id,
727 source_scope,
728 path,
729 "fn main() {}",
730 range(),
731 Vec::new(),
732 Some(extraction()),
733 )
734 .expect("chunk should validate")
735 }
736
737 fn extraction() -> CodeExtractionMetadata {
738 CodeExtractionMetadata::new(
739 "tree-sitter-rust@0.23",
740 "rust-tags",
741 "v1",
742 "function_item",
743 "definition.function",
744 )
745 .expect("extraction metadata should validate")
746 }
747
748 fn range() -> CodeRange {
749 CodeRange::new(0, 12, 1, 1).expect("range should validate")
750 }
751
752 fn scope() -> SourceScope {
753 SourceScope::parse("repo").expect("scope should parse")
754 }
755}