Skip to main content

relay_knowledge/domain/code/graph_records/
mod.rs

1//! Owns normalized code graph records and their validation invariants.
2
3use std::collections::BTreeSet;
4
5use serde::{Deserialize, Serialize};
6
7use super::{DomainError, SourceScope, error::required_text};
8
9/// Parser status for a repository file at a graph version.
10#[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    /// Stable storage and API representation.
21    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/// Symbol definition category extracted from tree-sitter captures.
32#[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    /// Stable storage and API representation.
49    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/// Reference category extracted from tree-sitter captures.
66#[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    /// Stable storage and API representation.
77    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/// Role annotation for a symbol beyond its syntactic kind.
88#[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/// Single HTTP endpoint binding attached to a route-handler symbol.
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct RouteHandlerRole {
98    pub url: String,
99    pub http_method: String,
100}
101
102impl SymbolRole {
103    /// Adds a route-handler binding while preserving the legacy single-route
104    /// representation until a second distinct endpoint is attached.
105    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/// Resolution certainty for syntax-level code references.
142#[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    /// Stable storage and API representation.
152    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/// Inclusive source line range and half-open byte range in a repository file.
162#[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    /// Validates a non-empty byte range with one-based line coordinates.
172    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/// Tree-sitter query metadata attached to extracted code facts.
207#[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    /// Validates extractor metadata needed to diagnose grammar/query drift.
218    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/// Versioned syntax-level symbol definition.
236#[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    /// Validates a symbol definition extracted from a single source file.
251    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/// Versioned syntax-level reference or dependency edge candidate.
274#[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/// Input fields for a syntax-level reference.
288#[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    /// Validates a reference without upgrading unresolved syntax to certainty.
303    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/// Versioned retrievable code chunk.
330#[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    /// Validates a retrievable code chunk and deduplicates linked symbols.
344    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/// Parser output for one repository file.
374#[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/// Input fields for one parsed repository file.
389#[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    /// Validates parser output and keeps extracted facts scoped to this file.
404    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/// Atomic code graph mutation batch.
440#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
441pub struct CodeGraphBatch {
442    pub files: Vec<CodeFileRecord>,
443}
444
445impl CodeGraphBatch {
446    /// Creates a non-empty batch with at most one replacement per scope/path.
447    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/// Receipt returned after code graph facts commit.
471#[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/// Parse-status counts surfaced by graph diagnostics.
481#[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;