Skip to main content

relay_knowledge/domain/code/
graph_records.rs

1use std::collections::BTreeSet;
2
3use serde::{Deserialize, Serialize};
4
5use super::{DomainError, SourceScope, error::required_text};
6
7/// Parser status for a repository file at a graph version.
8#[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    /// Stable storage and API representation.
19    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/// Symbol definition category extracted from tree-sitter captures.
30#[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    /// Stable storage and API representation.
47    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/// Reference category extracted from tree-sitter captures.
64#[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    /// Stable storage and API representation.
75    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/// Role annotation for a symbol beyond its syntactic kind.
86#[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/// Single HTTP endpoint binding attached to a route-handler symbol.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub struct RouteHandlerRole {
96    pub url: String,
97    pub http_method: String,
98}
99
100impl SymbolRole {
101    /// Adds a route-handler binding while preserving the legacy single-route
102    /// representation until a second distinct endpoint is attached.
103    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/// Resolution certainty for syntax-level code references.
140#[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    /// Stable storage and API representation.
150    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/// Inclusive source line range and half-open byte range in a repository file.
160#[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    /// Validates a non-empty byte range with one-based line coordinates.
170    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/// Tree-sitter query metadata attached to extracted code facts.
205#[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    /// Validates extractor metadata needed to diagnose grammar/query drift.
216    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/// Versioned syntax-level symbol definition.
234#[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    /// Validates a symbol definition extracted from a single source file.
249    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/// Versioned syntax-level reference or dependency edge candidate.
272#[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/// Input fields for a syntax-level reference.
286#[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    /// Validates a reference without upgrading unresolved syntax to certainty.
301    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/// Versioned retrievable code chunk.
328#[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    /// Validates a retrievable code chunk and deduplicates linked symbols.
342    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/// Parser output for one repository file.
372#[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/// Input fields for one parsed repository file.
387#[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    /// Validates parser output and keeps extracted facts scoped to this file.
402    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/// Atomic code graph mutation batch.
438#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
439pub struct CodeGraphBatch {
440    pub files: Vec<CodeFileRecord>,
441}
442
443impl CodeGraphBatch {
444    /// Creates a non-empty batch with at most one replacement per scope/path.
445    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/// Receipt returned after code graph facts commit.
469#[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/// Parse-status counts surfaced by graph diagnostics.
479#[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}