Skip to main content

relay_knowledge/domain/
code.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/// Resolution certainty for syntax-level code references.
86#[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    /// Stable storage and API representation.
96    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/// Inclusive source line range and half-open byte range in a repository file.
106#[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    /// Validates a non-empty byte range with one-based line coordinates.
116    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/// Tree-sitter query metadata attached to extracted code facts.
151#[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    /// Validates extractor metadata needed to diagnose grammar/query drift.
162    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/// Versioned syntax-level symbol definition.
180#[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    /// Validates a symbol definition extracted from a single source file.
193    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/// Versioned syntax-level reference or dependency edge candidate.
215#[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/// Input fields for a syntax-level reference.
229#[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    /// Validates a reference without upgrading unresolved syntax to certainty.
244    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/// Versioned retrievable code chunk.
271#[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    /// Validates a retrievable code chunk and deduplicates linked symbols.
285    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/// Parser output for one repository file.
315#[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/// Input fields for one parsed repository file.
330#[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    /// Validates parser output and keeps extracted facts scoped to this file.
345    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/// Atomic code graph mutation batch.
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
382pub struct CodeGraphBatch {
383    pub files: Vec<CodeFileRecord>,
384}
385
386impl CodeGraphBatch {
387    /// Creates a non-empty batch with at most one replacement per scope/path.
388    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/// Receipt returned after code graph facts commit.
412#[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/// Parse-status counts surfaced by graph diagnostics.
422#[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}