1use std::collections::BTreeSet;
4
5use serde::{Deserialize, Serialize};
6
7use super::{DomainError, SourceScope, error::required_text};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum CodeParseStatus {
13 Parsed,
14 Partial,
15 TextOnly,
16 Failed,
17}
18
19impl CodeParseStatus {
20 pub const fn as_str(self) -> &'static str {
22 match self {
23 Self::Parsed => "parsed",
24 Self::Partial => "partial",
25 Self::TextOnly => "text_only",
26 Self::Failed => "failed",
27 }
28 }
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum CodeSymbolKind {
35 Function,
36 Method,
37 Class,
38 Interface,
39 Module,
40 Type,
41 Constant,
42 Field,
43 Variable,
44 EnumMember,
45}
46
47impl CodeSymbolKind {
48 pub const fn as_str(self) -> &'static str {
50 match self {
51 Self::Function => "function",
52 Self::Method => "method",
53 Self::Class => "class",
54 Self::Interface => "interface",
55 Self::Module => "module",
56 Self::Type => "type",
57 Self::Constant => "constant",
58 Self::Field => "field",
59 Self::Variable => "variable",
60 Self::EnumMember => "enum_member",
61 }
62 }
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum CodeReferenceKind {
69 Call,
70 Type,
71 Import,
72 Implementation,
73}
74
75impl CodeReferenceKind {
76 pub const fn as_str(self) -> &'static str {
78 match self {
79 Self::Call => "call",
80 Self::Type => "type",
81 Self::Import => "import",
82 Self::Implementation => "implementation",
83 }
84 }
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(tag = "type", rename_all = "snake_case")]
90pub enum SymbolRole {
91 RouteHandler { url: String, http_method: String },
92 RouteHandlers { routes: Vec<RouteHandlerRole> },
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct RouteHandlerRole {
98 pub url: String,
99 pub http_method: String,
100}
101
102impl SymbolRole {
103 pub fn merge_route_handler(&mut self, url: String, http_method: String) {
106 match self {
107 Self::RouteHandler {
108 url: existing_url,
109 http_method: existing_method,
110 } if *existing_url == url && *existing_method == http_method => {}
111 Self::RouteHandler { .. } => {
112 let existing = std::mem::replace(self, Self::RouteHandlers { routes: Vec::new() });
113 if let Self::RouteHandler {
114 url: existing_url,
115 http_method: existing_method,
116 } = existing
117 {
118 *self = Self::RouteHandlers {
119 routes: vec![
120 RouteHandlerRole {
121 url: existing_url,
122 http_method: existing_method,
123 },
124 RouteHandlerRole { url, http_method },
125 ],
126 };
127 }
128 }
129 Self::RouteHandlers { routes } => {
130 if !routes
131 .iter()
132 .any(|route| route.url == url && route.http_method == http_method)
133 {
134 routes.push(RouteHandlerRole { url, http_method });
135 }
136 }
137 }
138 }
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(rename_all = "snake_case")]
144pub enum CodeResolutionState {
145 Unresolved,
146 Ambiguous,
147 Resolved,
148}
149
150impl CodeResolutionState {
151 pub const fn as_str(self) -> &'static str {
153 match self {
154 Self::Unresolved => "unresolved",
155 Self::Ambiguous => "ambiguous",
156 Self::Resolved => "resolved",
157 }
158 }
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
163pub struct CodeRange {
164 pub start_byte: u32,
165 pub end_byte: u32,
166 pub start_line: u32,
167 pub end_line: u32,
168}
169
170impl CodeRange {
171 pub fn new(
173 start_byte: u32,
174 end_byte: u32,
175 start_line: u32,
176 end_line: u32,
177 ) -> Result<Self, DomainError> {
178 if end_byte <= start_byte {
179 return Err(DomainError::invalid(
180 "code_range",
181 "end byte must be greater than start byte",
182 ));
183 }
184 if start_line == 0 {
185 return Err(DomainError::invalid(
186 "code_range",
187 "start line must be one-based",
188 ));
189 }
190 if end_line < start_line {
191 return Err(DomainError::invalid(
192 "code_range",
193 "end line must not be before start line",
194 ));
195 }
196
197 Ok(Self {
198 start_byte,
199 end_byte,
200 start_line,
201 end_line,
202 })
203 }
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208pub struct CodeExtractionMetadata {
209 pub grammar_version: String,
210 pub query_name: String,
211 pub query_version: String,
212 pub node_kind: String,
213 pub capture_kind: String,
214}
215
216impl CodeExtractionMetadata {
217 pub fn new(
219 grammar_version: impl Into<String>,
220 query_name: impl Into<String>,
221 query_version: impl Into<String>,
222 node_kind: impl Into<String>,
223 capture_kind: impl Into<String>,
224 ) -> Result<Self, DomainError> {
225 Ok(Self {
226 grammar_version: validated_text("grammar_version", grammar_version)?,
227 query_name: validated_text("query_name", query_name)?,
228 query_version: validated_text("query_version", query_version)?,
229 node_kind: validated_text("node_kind", node_kind)?,
230 capture_kind: validated_text("capture_kind", capture_kind)?,
231 })
232 }
233}
234
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237pub struct CodeSymbolRecord {
238 pub symbol_id: String,
239 pub source_scope: SourceScope,
240 pub path: String,
241 pub name: String,
242 pub kind: CodeSymbolKind,
243 pub range: CodeRange,
244 pub extraction: CodeExtractionMetadata,
245 #[serde(skip_serializing_if = "Option::is_none")]
246 pub symbol_role: Option<SymbolRole>,
247}
248
249impl CodeSymbolRecord {
250 pub fn new(
252 symbol_id: impl Into<String>,
253 source_scope: SourceScope,
254 path: impl Into<String>,
255 name: impl Into<String>,
256 kind: CodeSymbolKind,
257 range: CodeRange,
258 extraction: CodeExtractionMetadata,
259 ) -> Result<Self, DomainError> {
260 Ok(Self {
261 symbol_id: validated_text("symbol_id", symbol_id)?,
262 source_scope,
263 path: validated_repo_path(path)?,
264 name: validated_text("symbol_name", name)?,
265 kind,
266 range,
267 extraction,
268 symbol_role: None,
269 })
270 }
271}
272
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275pub struct CodeReferenceRecord {
276 pub reference_id: String,
277 pub source_scope: SourceScope,
278 pub path: String,
279 pub symbol_text: String,
280 pub kind: CodeReferenceKind,
281 pub range: CodeRange,
282 pub resolution_state: CodeResolutionState,
283 pub target_symbol_id: Option<String>,
284 pub extraction: CodeExtractionMetadata,
285}
286
287#[derive(Debug, Clone, PartialEq, Eq)]
289pub struct CodeReferenceFields {
290 pub reference_id: String,
291 pub source_scope: SourceScope,
292 pub path: String,
293 pub symbol_text: String,
294 pub kind: CodeReferenceKind,
295 pub range: CodeRange,
296 pub resolution_state: CodeResolutionState,
297 pub target_symbol_id: Option<String>,
298 pub extraction: CodeExtractionMetadata,
299}
300
301impl CodeReferenceRecord {
302 pub fn new(fields: CodeReferenceFields) -> Result<Self, DomainError> {
304 let target_symbol_id = fields
305 .target_symbol_id
306 .map(|id| validated_text("target_symbol_id", id))
307 .transpose()?;
308 if fields.resolution_state == CodeResolutionState::Resolved && target_symbol_id.is_none() {
309 return Err(DomainError::invalid(
310 "target_symbol_id",
311 "resolved references must include a target symbol",
312 ));
313 }
314
315 Ok(Self {
316 reference_id: validated_text("reference_id", fields.reference_id)?,
317 source_scope: fields.source_scope,
318 path: validated_repo_path(fields.path)?,
319 symbol_text: validated_text("symbol_text", fields.symbol_text)?,
320 kind: fields.kind,
321 range: fields.range,
322 resolution_state: fields.resolution_state,
323 target_symbol_id,
324 extraction: fields.extraction,
325 })
326 }
327}
328
329#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
331pub struct CodeChunkRecord {
332 pub chunk_id: String,
333 pub source_scope: SourceScope,
334 pub path: String,
335 pub content: String,
336 pub range: CodeRange,
337 pub linked_symbol_ids: Vec<String>,
338 #[serde(skip_serializing_if = "Option::is_none")]
339 pub extraction: Option<CodeExtractionMetadata>,
340}
341
342impl CodeChunkRecord {
343 pub fn new(
345 chunk_id: impl Into<String>,
346 source_scope: SourceScope,
347 path: impl Into<String>,
348 content: impl Into<String>,
349 range: CodeRange,
350 linked_symbol_ids: Vec<String>,
351 extraction: Option<CodeExtractionMetadata>,
352 ) -> Result<Self, DomainError> {
353 let mut deduped = Vec::new();
354 for symbol_id in linked_symbol_ids {
355 let symbol_id = validated_text("linked_symbol_id", symbol_id)?;
356 if !deduped.contains(&symbol_id) {
357 deduped.push(symbol_id);
358 }
359 }
360
361 Ok(Self {
362 chunk_id: validated_text("chunk_id", chunk_id)?,
363 source_scope,
364 path: validated_repo_path(path)?,
365 content: validated_text("chunk_content", content)?,
366 range,
367 linked_symbol_ids: deduped,
368 extraction,
369 })
370 }
371}
372
373#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
375pub struct CodeFileRecord {
376 pub source_scope: SourceScope,
377 pub path: String,
378 pub content_hash: String,
379 pub language_id: String,
380 pub parse_status: CodeParseStatus,
381 #[serde(skip_serializing_if = "Option::is_none")]
382 pub diagnostic: Option<String>,
383 pub symbols: Vec<CodeSymbolRecord>,
384 pub references: Vec<CodeReferenceRecord>,
385 pub chunks: Vec<CodeChunkRecord>,
386}
387
388#[derive(Debug, Clone, PartialEq, Eq)]
390pub struct CodeFileFields {
391 pub source_scope: SourceScope,
392 pub path: String,
393 pub content_hash: String,
394 pub language_id: String,
395 pub parse_status: CodeParseStatus,
396 pub diagnostic: Option<String>,
397 pub symbols: Vec<CodeSymbolRecord>,
398 pub references: Vec<CodeReferenceRecord>,
399 pub chunks: Vec<CodeChunkRecord>,
400}
401
402impl CodeFileRecord {
403 pub fn new(fields: CodeFileFields) -> Result<Self, DomainError> {
405 let path = validated_repo_path(fields.path)?;
406 let diagnostic = fields
407 .diagnostic
408 .map(|value| validated_text("parse_diagnostic", value))
409 .transpose()?;
410 validate_parse_status(
411 fields.parse_status,
412 diagnostic.as_deref(),
413 &fields.symbols,
414 &fields.references,
415 &fields.chunks,
416 )?;
417 validate_nested_facts(
418 &fields.source_scope,
419 &path,
420 &fields.symbols,
421 &fields.references,
422 &fields.chunks,
423 )?;
424
425 Ok(Self {
426 source_scope: fields.source_scope,
427 path,
428 content_hash: validated_text("content_hash", fields.content_hash)?,
429 language_id: validated_text("language_id", fields.language_id)?,
430 parse_status: fields.parse_status,
431 diagnostic,
432 symbols: fields.symbols,
433 references: fields.references,
434 chunks: fields.chunks,
435 })
436 }
437}
438
439#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
441pub struct CodeGraphBatch {
442 pub files: Vec<CodeFileRecord>,
443}
444
445impl CodeGraphBatch {
446 pub fn new(files: Vec<CodeFileRecord>) -> Result<Self, DomainError> {
448 if files.is_empty() {
449 return Err(DomainError::invalid(
450 "code_files",
451 "must include at least one file",
452 ));
453 }
454
455 let mut keys = BTreeSet::new();
456 for file in &files {
457 let key = (file.source_scope.as_str(), file.path.as_str());
458 if !keys.insert(key) {
459 return Err(DomainError::invalid(
460 "code_file",
461 "scope and path must be unique within a mutation batch",
462 ));
463 }
464 }
465
466 Ok(Self { files })
467 }
468}
469
470#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
472pub struct CodeGraphCommitReceipt {
473 pub graph_version: super::GraphVersion,
474 pub file_count: usize,
475 pub symbol_count: usize,
476 pub reference_count: usize,
477 pub chunk_count: usize,
478}
479
480#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
482pub struct CodeParseStatusCounts {
483 pub parsed: usize,
484 pub partial: usize,
485 pub text_only: usize,
486 pub failed: usize,
487}
488
489fn validate_parse_status(
490 status: CodeParseStatus,
491 diagnostic: Option<&str>,
492 symbols: &[CodeSymbolRecord],
493 references: &[CodeReferenceRecord],
494 chunks: &[CodeChunkRecord],
495) -> Result<(), DomainError> {
496 match status {
497 CodeParseStatus::Parsed => Ok(()),
498 CodeParseStatus::Partial => {
499 if diagnostic.is_none() {
500 return Err(DomainError::invalid(
501 "parse_diagnostic",
502 "partial parses must include a diagnostic",
503 ));
504 }
505 Ok(())
506 }
507 CodeParseStatus::TextOnly => {
508 if !symbols.is_empty() || !references.is_empty() {
509 return Err(DomainError::invalid(
510 "parse_status",
511 "text-only files cannot include syntax facts",
512 ));
513 }
514 Ok(())
515 }
516 CodeParseStatus::Failed => {
517 if diagnostic.is_none() {
518 return Err(DomainError::invalid(
519 "parse_diagnostic",
520 "failed parses must include a diagnostic",
521 ));
522 }
523 if !symbols.is_empty() || !references.is_empty() || !chunks.is_empty() {
524 return Err(DomainError::invalid(
525 "parse_status",
526 "failed files cannot include extracted code facts",
527 ));
528 }
529 Ok(())
530 }
531 }
532}
533
534fn validate_nested_facts(
535 scope: &SourceScope,
536 path: &str,
537 symbols: &[CodeSymbolRecord],
538 references: &[CodeReferenceRecord],
539 chunks: &[CodeChunkRecord],
540) -> Result<(), DomainError> {
541 for symbol in symbols {
542 validate_scope_and_path(scope, path, &symbol.source_scope, &symbol.path)?;
543 }
544 for reference in references {
545 validate_scope_and_path(scope, path, &reference.source_scope, &reference.path)?;
546 }
547 for chunk in chunks {
548 validate_scope_and_path(scope, path, &chunk.source_scope, &chunk.path)?;
549 }
550
551 Ok(())
552}
553
554fn validate_scope_and_path(
555 expected_scope: &SourceScope,
556 expected_path: &str,
557 actual_scope: &SourceScope,
558 actual_path: &str,
559) -> Result<(), DomainError> {
560 if expected_scope != actual_scope || expected_path != actual_path {
561 return Err(DomainError::invalid(
562 "code_file",
563 "nested code facts must match the file scope and path",
564 ));
565 }
566
567 Ok(())
568}
569
570fn validated_repo_path(value: impl Into<String>) -> Result<String, DomainError> {
571 let path = validated_text("code_path", value)?;
572 if path.starts_with('/') || path.starts_with('\\') {
573 return Err(DomainError::invalid(
574 "code_path",
575 "must be repository-relative",
576 ));
577 }
578 if path
579 .split(['/', '\\'])
580 .any(|component| component.is_empty() || component == "." || component == "..")
581 {
582 return Err(DomainError::invalid(
583 "code_path",
584 "must not contain empty, current, or parent components",
585 ));
586 }
587
588 Ok(path)
589}
590
591fn validated_text(field: &'static str, value: impl Into<String>) -> Result<String, DomainError> {
592 let text = required_text(field, value)?;
593 if text.contains('\0') {
594 return Err(DomainError::invalid(field, "must not contain NUL bytes"));
595 }
596
597 Ok(text)
598}
599
600#[cfg(test)]
601mod mod_tests;