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