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