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