Skip to main content

relay_knowledge/domain/
code_repository.rs

1use serde::{Deserialize, Serialize};
2
3use super::{
4    CodeParseStatus, CodeParseStatusCounts, DomainError, FreshnessPolicy, error::required_text,
5};
6
7/// Builds the stable source scope id for a Git snapshot partition.
8pub fn code_snapshot_scope_id(
9    repository_id: &str,
10    tree_hash: &str,
11    path_filters: &[String],
12    language_filters: &[String],
13) -> String {
14    let mut input = Vec::new();
15    append_hash_part(&mut input, "git_snapshot");
16    append_hash_part(&mut input, repository_id);
17    append_hash_part(&mut input, tree_hash);
18    append_hash_list(&mut input, path_filters);
19    append_hash_list(&mut input, language_filters);
20
21    format!("git_snapshot:{:016x}", stable_hash64(&input))
22}
23
24/// Inclusive byte or line range for repository code index rows.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct RepositoryCodeRange {
27    pub start: u32,
28    pub end: u32,
29}
30
31impl RepositoryCodeRange {
32    /// Creates an ordered range using one-based lines or zero-based bytes.
33    pub fn new(field: &'static str, start: usize, end: usize) -> Result<Self, DomainError> {
34        if end < start {
35            return Err(DomainError::invalid(
36                field,
37                "end must be greater than or equal to start",
38            ));
39        }
40
41        Ok(Self {
42            start: checked_u32(field, start)?,
43            end: checked_u32(field, end)?,
44        })
45    }
46}
47
48/// Code repository identity persisted after registration.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct CodeRepositoryRegistration {
51    pub repository_id: String,
52    pub alias: String,
53    pub root_path: String,
54    pub path_filters: Vec<String>,
55    pub language_filters: Vec<String>,
56}
57
58impl CodeRepositoryRegistration {
59    /// Validates a repository registration before storage persists it.
60    pub fn new(
61        repository_id: impl Into<String>,
62        alias: impl Into<String>,
63        root_path: impl Into<String>,
64        path_filters: Vec<String>,
65        language_filters: Vec<String>,
66    ) -> Result<Self, DomainError> {
67        Ok(Self {
68            repository_id: required_text("repository_id", repository_id)?,
69            alias: required_text("alias", alias)?,
70            root_path: required_text("root_path", root_path)?,
71            path_filters: normalize_filter_list("path_filter", path_filters)?,
72            language_filters: normalize_filter_list("language_filter", language_filters)?,
73        })
74    }
75}
76
77/// Repository selector accepted by code index and retrieval APIs.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct CodeRepositorySelector {
80    pub repository: String,
81    pub ref_selector: String,
82    pub path_filters: Vec<String>,
83    pub language_filters: Vec<String>,
84}
85
86impl CodeRepositorySelector {
87    /// Validates a code repository selector with an explicit ref.
88    pub fn new(
89        repository: impl Into<String>,
90        ref_selector: impl Into<String>,
91        path_filters: Vec<String>,
92        language_filters: Vec<String>,
93    ) -> Result<Self, DomainError> {
94        Ok(Self {
95            repository: required_text("repository", repository)?,
96            ref_selector: required_text("ref_selector", ref_selector)?,
97            path_filters: normalize_filter_list("path_filter", path_filters)?,
98            language_filters: normalize_filter_list("language_filter", language_filters)?,
99        })
100    }
101}
102
103/// Code index mode tied to Git snapshots or diffs.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "snake_case")]
106pub enum CodeIndexMode {
107    Full,
108    Incremental { base_ref: String, head_ref: String },
109    WorktreeOverlay,
110}
111
112impl CodeIndexMode {
113    /// Validates incremental refs and preserves the mode contract.
114    pub fn incremental(
115        base_ref: impl Into<String>,
116        head_ref: impl Into<String>,
117    ) -> Result<Self, DomainError> {
118        Ok(Self::Incremental {
119            base_ref: required_text("base_ref", base_ref)?,
120            head_ref: required_text("head_ref", head_ref)?,
121        })
122    }
123}
124
125/// Code repository indexing request shared by interfaces.
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct CodeIndexRequest {
128    pub repository: CodeRepositorySelector,
129    pub mode: CodeIndexMode,
130    pub freshness_policy: FreshnessPolicy,
131}
132
133/// Retrieval query kind for code graph and lexical search.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136pub enum CodeQueryKind {
137    Hybrid,
138    Symbol,
139    Definition,
140    References,
141    Callers,
142    Callees,
143    Imports,
144    Impact,
145}
146
147/// Code repository retrieval request.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149pub struct CodeRetrievalRequest {
150    pub query: String,
151    pub repository: CodeRepositorySelector,
152    pub code_query_kind: CodeQueryKind,
153    pub limit: usize,
154    pub freshness_policy: FreshnessPolicy,
155}
156
157impl CodeRetrievalRequest {
158    /// Validates query text and result limits before storage is consulted.
159    pub fn new(
160        query: impl Into<String>,
161        repository: CodeRepositorySelector,
162        code_query_kind: CodeQueryKind,
163        limit: usize,
164        freshness_policy: FreshnessPolicy,
165    ) -> Result<Self, DomainError> {
166        let limit = match limit {
167            1..=50 => limit,
168            0 => return Err(DomainError::invalid("limit", "must be greater than zero")),
169            _ => return Err(DomainError::invalid("limit", "must be 50 or less")),
170        };
171
172        Ok(Self {
173            query: required_text("query", query)?,
174            repository,
175            code_query_kind,
176            limit,
177            freshness_policy,
178        })
179    }
180}
181
182/// Code impact analysis request over a Git diff.
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184pub struct CodeImpactRequest {
185    pub repository: CodeRepositorySelector,
186    pub base_ref: String,
187    pub head_ref: String,
188    pub limit: usize,
189}
190
191impl CodeImpactRequest {
192    /// Validates diff refs and bounds the impact result count.
193    pub fn new(
194        repository: CodeRepositorySelector,
195        base_ref: impl Into<String>,
196        head_ref: impl Into<String>,
197        limit: usize,
198    ) -> Result<Self, DomainError> {
199        let limit = match limit {
200            1..=100 => limit,
201            0 => return Err(DomainError::invalid("limit", "must be greater than zero")),
202            _ => return Err(DomainError::invalid("limit", "must be 100 or less")),
203        };
204
205        Ok(Self {
206            repository,
207            base_ref: required_text("base_ref", base_ref)?,
208            head_ref: required_text("head_ref", head_ref)?,
209            limit,
210        })
211    }
212}
213
214/// Retrieval layer that contributed to a code hit.
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(rename_all = "snake_case")]
217pub enum CodeRetrievalLayer {
218    Lexical,
219    Symbol,
220    Definition,
221    Reference,
222    CallGraph,
223    ImportGraph,
224    Impact,
225    TextFallback,
226}
227
228impl CodeRetrievalLayer {
229    /// Stable storage and API representation.
230    pub const fn as_str(self) -> &'static str {
231        match self {
232            Self::Lexical => "lexical",
233            Self::Symbol => "symbol",
234            Self::Definition => "definition",
235            Self::Reference => "reference",
236            Self::CallGraph => "call_graph",
237            Self::ImportGraph => "import_graph",
238            Self::Impact => "impact",
239            Self::TextFallback => "text_fallback",
240        }
241    }
242}
243
244/// Repository index status and diagnostics summary.
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246pub struct CodeRepositoryStatus {
247    pub repository_id: String,
248    pub alias: String,
249    pub root_path: String,
250    pub path_filters: Vec<String>,
251    pub language_filters: Vec<String>,
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub last_indexed_scope_id: Option<String>,
254    pub last_indexed_commit: Option<String>,
255    pub tree_hash: Option<String>,
256    pub state: String,
257    pub indexed_file_count: usize,
258    pub symbol_count: usize,
259    pub reference_count: usize,
260    pub chunk_count: usize,
261    pub stale: bool,
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub degraded_reason: Option<String>,
264}
265
266/// File-level code index row.
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
268pub struct RepositoryCodeFileRecord {
269    pub repository_id: String,
270    pub source_scope: String,
271    pub file_id: String,
272    pub path: String,
273    pub language_id: String,
274    pub blob_hash: String,
275    pub byte_len: usize,
276    pub line_count: usize,
277    pub parse_status: CodeParseStatus,
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub degraded_reason: Option<String>,
280}
281
282/// Previously indexed file hash used to skip unchanged incremental parses.
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
284pub struct CodeFileFingerprint {
285    pub path: String,
286    pub blob_hash: String,
287}
288
289/// Symbol definition extracted from tree-sitter syntax.
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291pub struct RepositoryCodeSymbolRecord {
292    pub repository_id: String,
293    pub source_scope: String,
294    pub symbol_snapshot_id: String,
295    pub canonical_symbol_id: String,
296    pub file_id: String,
297    pub path: String,
298    pub language_id: String,
299    pub name: String,
300    pub qualified_name: String,
301    pub kind: String,
302    pub signature: String,
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub doc_comment: Option<String>,
305    pub byte_range: RepositoryCodeRange,
306    pub line_range: RepositoryCodeRange,
307}
308
309/// Reference extracted from tree-sitter syntax and optionally resolved.
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
311pub struct RepositoryCodeReferenceRecord {
312    pub repository_id: String,
313    pub source_scope: String,
314    pub reference_id: String,
315    pub file_id: String,
316    pub path: String,
317    pub name: String,
318    pub kind: String,
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub target_symbol_snapshot_id: Option<String>,
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub target_hint: Option<String>,
323    pub resolution_state: String,
324    pub confidence_basis_points: u16,
325    pub confidence_tier: String,
326    pub byte_range: RepositoryCodeRange,
327    pub line_range: RepositoryCodeRange,
328}
329
330/// Import relationship extracted from code.
331#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
332pub struct CodeImportRecord {
333    pub repository_id: String,
334    pub source_scope: String,
335    pub import_id: String,
336    pub file_id: String,
337    pub path: String,
338    pub module: String,
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub target_hint: Option<String>,
341    pub resolution_state: String,
342    pub confidence_basis_points: u16,
343    pub confidence_tier: String,
344    pub line_range: RepositoryCodeRange,
345}
346
347/// Call relationship extracted from code.
348#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
349pub struct CodeCallRecord {
350    pub repository_id: String,
351    pub source_scope: String,
352    pub call_id: String,
353    pub file_id: String,
354    pub path: String,
355    pub caller_symbol_snapshot_id: Option<String>,
356    pub caller_name: Option<String>,
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub callee_symbol_snapshot_id: Option<String>,
359    pub callee_name: String,
360    #[serde(skip_serializing_if = "Option::is_none")]
361    pub target_hint: Option<String>,
362    pub resolution_state: String,
363    pub confidence_basis_points: u16,
364    pub confidence_tier: String,
365    pub line_range: RepositoryCodeRange,
366}
367
368/// Searchable code chunk.
369#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
370pub struct RepositoryCodeChunkRecord {
371    pub repository_id: String,
372    pub source_scope: String,
373    pub chunk_id: String,
374    pub file_id: String,
375    pub path: String,
376    pub language_id: String,
377    pub content: String,
378    pub byte_range: RepositoryCodeRange,
379    pub line_range: RepositoryCodeRange,
380    pub symbol_snapshot_id: Option<String>,
381}
382
383/// File-level diagnostic produced by indexing.
384#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
385pub struct CodeFileDiagnostic {
386    pub repository_id: String,
387    pub source_scope: String,
388    pub path: String,
389    pub parse_status: CodeParseStatus,
390    pub message: String,
391}
392
393/// Rename/delete lineage marker retained after incremental updates.
394#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
395pub struct CodePathTombstone {
396    pub repository_id: String,
397    pub source_scope: String,
398    pub old_path: String,
399    pub new_path: Option<String>,
400    pub base_ref: String,
401    pub head_ref: String,
402}
403
404/// Parsed index changes ready to commit into storage.
405#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
406pub struct CodeIndexSnapshot {
407    pub repository_id: String,
408    pub source_scope: String,
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub base_resolved_commit_sha: Option<String>,
411    pub resolved_commit_sha: String,
412    pub tree_hash: String,
413    pub path_filters: Vec<String>,
414    pub language_filters: Vec<String>,
415    pub full_replace: bool,
416    pub changed_path_count: usize,
417    pub skipped_unchanged_count: usize,
418    pub deleted_paths: Vec<String>,
419    pub tombstones: Vec<CodePathTombstone>,
420    pub files: Vec<RepositoryCodeFileRecord>,
421    pub symbols: Vec<RepositoryCodeSymbolRecord>,
422    pub references: Vec<RepositoryCodeReferenceRecord>,
423    pub imports: Vec<CodeImportRecord>,
424    pub calls: Vec<CodeCallRecord>,
425    pub chunks: Vec<RepositoryCodeChunkRecord>,
426    pub diagnostics: Vec<CodeFileDiagnostic>,
427}
428
429/// Resource budget used to partition repository indexing into bounded batches.
430#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
431pub struct CodeIndexResourceBudget {
432    pub max_files_per_batch: usize,
433    pub max_bytes_per_batch: usize,
434    pub max_rows_per_batch: usize,
435}
436
437impl CodeIndexResourceBudget {
438    pub const DEFAULT_MAX_FILES_PER_BATCH: usize = 128;
439    pub const DEFAULT_MAX_BYTES_PER_BATCH: usize = 16 * 1024 * 1024;
440    pub const DEFAULT_MAX_ROWS_PER_BATCH: usize = 50_000;
441
442    /// Creates a non-zero resource budget for batch parsing and SQLite writes.
443    pub fn new(
444        max_files_per_batch: usize,
445        max_bytes_per_batch: usize,
446        max_rows_per_batch: usize,
447    ) -> Result<Self, DomainError> {
448        if max_files_per_batch == 0 {
449            return Err(DomainError::invalid(
450                "max_files_per_batch",
451                "must be greater than zero",
452            ));
453        }
454        if max_bytes_per_batch == 0 {
455            return Err(DomainError::invalid(
456                "max_bytes_per_batch",
457                "must be greater than zero",
458            ));
459        }
460        if max_rows_per_batch == 0 {
461            return Err(DomainError::invalid(
462                "max_rows_per_batch",
463                "must be greater than zero",
464            ));
465        }
466
467        Ok(Self {
468            max_files_per_batch,
469            max_bytes_per_batch,
470            max_rows_per_batch,
471        })
472    }
473}
474
475impl Default for CodeIndexResourceBudget {
476    fn default() -> Self {
477        Self {
478            max_files_per_batch: Self::DEFAULT_MAX_FILES_PER_BATCH,
479            max_bytes_per_batch: Self::DEFAULT_MAX_BYTES_PER_BATCH,
480            max_rows_per_batch: Self::DEFAULT_MAX_ROWS_PER_BATCH,
481        }
482    }
483}
484
485/// Stable metadata for one resumable repository indexing session.
486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
487pub struct CodeIndexSession {
488    pub repository_id: String,
489    pub source_scope: String,
490    #[serde(skip_serializing_if = "Option::is_none")]
491    pub base_resolved_commit_sha: Option<String>,
492    pub resolved_commit_sha: String,
493    pub tree_hash: String,
494    pub path_filters: Vec<String>,
495    pub language_filters: Vec<String>,
496    pub full_replace: bool,
497    pub total_path_count: usize,
498    pub changed_path_count: usize,
499    pub skipped_unchanged_count: usize,
500    pub deleted_paths: Vec<String>,
501    pub tombstones: Vec<CodePathTombstone>,
502    pub resource_budget: CodeIndexResourceBudget,
503}
504
505/// One bounded parse result committed under a checkpointed index session.
506#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
507pub struct CodeIndexBatch {
508    pub repository_id: String,
509    pub source_scope: String,
510    pub batch_index: usize,
511    pub parsed_byte_count: usize,
512    pub files: Vec<RepositoryCodeFileRecord>,
513    pub symbols: Vec<RepositoryCodeSymbolRecord>,
514    pub references: Vec<RepositoryCodeReferenceRecord>,
515    pub imports: Vec<CodeImportRecord>,
516    pub chunks: Vec<RepositoryCodeChunkRecord>,
517    pub diagnostics: Vec<CodeFileDiagnostic>,
518}
519
520impl CodeIndexBatch {
521    /// Counts mutable SQLite rows written by this batch.
522    pub fn row_count(&self) -> usize {
523        self.files
524            .len()
525            .saturating_add(self.symbols.len())
526            .saturating_add(self.references.len())
527            .saturating_add(self.imports.len())
528            .saturating_add(self.chunks.len())
529            .saturating_add(self.diagnostics.len())
530    }
531}
532
533/// Durable progress checkpoint for a repository indexing session.
534#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
535pub struct CodeIndexCheckpoint {
536    pub repository_id: String,
537    pub source_scope: String,
538    pub state: String,
539    pub total_path_count: usize,
540    pub parsed_file_count: usize,
541    pub committed_file_count: usize,
542    pub committed_symbol_count: usize,
543    pub committed_reference_count: usize,
544    pub committed_chunk_count: usize,
545    pub batch_count: usize,
546    #[serde(skip_serializing_if = "Option::is_none")]
547    pub last_path: Option<String>,
548    pub resource_budget: CodeIndexResourceBudget,
549}
550
551/// Coarse phase timing and counts reported by repository indexing.
552#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
553pub struct CodeIndexProgressSummary {
554    pub git_file_count: usize,
555    pub blob_read_count: usize,
556    pub parsed_file_count: usize,
557    pub sqlite_write_count: usize,
558    pub skipped_file_count: usize,
559    pub degraded_file_count: usize,
560    pub batch_count: usize,
561    pub checkpoint_file_count: usize,
562    pub resource_budget: CodeIndexResourceBudget,
563}
564
565/// Result of applying a code index snapshot.
566#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
567pub struct CodeIndexSummary {
568    pub repository_id: String,
569    pub source_scope: String,
570    pub resolved_commit_sha: String,
571    pub tree_hash: String,
572    pub indexed_file_count: usize,
573    pub changed_path_count: usize,
574    pub skipped_unchanged_count: usize,
575    pub deleted_path_count: usize,
576    pub symbol_count: usize,
577    pub reference_count: usize,
578    pub chunk_count: usize,
579    pub degraded_file_count: usize,
580    pub progress: CodeIndexProgressSummary,
581}
582
583/// Language bucket in a repository scope preview.
584#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
585pub struct CodeRepositoryLanguagePreview {
586    pub language_id: String,
587    pub file_count: usize,
588    pub byte_count: usize,
589}
590
591/// Large file surfaced before a full repository index starts.
592#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
593pub struct CodeRepositoryLargestFile {
594    pub path: String,
595    pub byte_count: usize,
596}
597
598/// Path excluded from indexing by preset, ignore file, or request scope.
599#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
600pub struct CodeRepositoryExcludedPath {
601    pub path: String,
602    pub reason: String,
603}
604
605/// Non-mutating preview of the effective repository indexing scope.
606#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
607pub struct CodeRepositoryScopePreview {
608    pub repository_id: String,
609    pub alias: String,
610    pub requested_ref: String,
611    pub resolved_commit_sha: String,
612    pub tree_hash: String,
613    pub selected_file_count: usize,
614    pub selected_byte_count: usize,
615    pub unsupported_file_count: usize,
616    pub generated_or_heavy_file_count: usize,
617    pub expected_degraded_file_count: usize,
618    pub language_distribution: Vec<CodeRepositoryLanguagePreview>,
619    pub largest_files: Vec<CodeRepositoryLargestFile>,
620    pub excluded_paths: Vec<CodeRepositoryExcludedPath>,
621}
622
623/// Aggregated totals for repository indexes separate from graph-evidence code rows.
624#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
625pub struct CodeRepositoryTotals {
626    pub repository_count: usize,
627    pub indexed_file_count: usize,
628    pub symbol_count: usize,
629    pub reference_count: usize,
630    pub chunk_count: usize,
631    pub degraded_file_count: usize,
632    pub parse_status_counts: CodeParseStatusCounts,
633}
634
635/// Representative query latency captured for an operations report.
636#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
637pub struct CodeRepositoryLatencySample {
638    pub query: String,
639    pub kind: CodeQueryKind,
640    pub result_count: usize,
641    pub duration_ms: u64,
642}
643
644/// Reusable repository operations report.
645#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
646pub struct CodeRepositoryReport {
647    pub repository_id: String,
648    pub alias: String,
649    pub root_path: String,
650    pub path_filters: Vec<String>,
651    pub language_filters: Vec<String>,
652    pub resolved_commit_sha: Option<String>,
653    pub tree_hash: Option<String>,
654    pub indexed_file_count: usize,
655    pub symbol_count: usize,
656    pub reference_count: usize,
657    pub chunk_count: usize,
658    pub degraded_file_count: usize,
659    pub resolved_edge_count: usize,
660    pub ambiguous_edge_count: usize,
661    pub unresolved_edge_count: usize,
662    pub degradation_summary: Vec<String>,
663    pub representative_queries: Vec<String>,
664    pub latency_samples: Vec<CodeRepositoryLatencySample>,
665    pub freshness_state: String,
666}
667
668/// Diff paths split by the effective repository selector.
669#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
670pub struct CodeImpactPathGroups {
671    pub in_scope_changed_paths: Vec<String>,
672    pub out_of_scope_changed_paths: Vec<String>,
673}
674
675/// Code retrieval hit with source location, layers, and freshness metadata.
676#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
677pub struct CodeRetrievalHit {
678    pub repository_id: String,
679    pub scope_id: String,
680    pub resolved_commit_sha: String,
681    pub tree_hash: String,
682    pub path: String,
683    pub language_id: String,
684    pub byte_range: RepositoryCodeRange,
685    pub line_range: RepositoryCodeRange,
686    pub symbol_snapshot_id: Option<String>,
687    #[serde(skip_serializing_if = "Option::is_none")]
688    pub canonical_symbol_id: Option<String>,
689    pub file_id: Option<String>,
690    pub retrieval_layers: Vec<CodeRetrievalLayer>,
691    pub index_versions: Vec<String>,
692    pub stale: bool,
693    #[serde(skip_serializing_if = "Option::is_none")]
694    pub degraded_reason: Option<String>,
695    #[serde(skip_serializing_if = "Option::is_none")]
696    pub edge_kind: Option<String>,
697    #[serde(skip_serializing_if = "Option::is_none")]
698    pub edge_resolution_state: Option<String>,
699    #[serde(skip_serializing_if = "Option::is_none")]
700    pub edge_target_hint: Option<String>,
701    #[serde(skip_serializing_if = "Option::is_none")]
702    pub edge_confidence_basis_points: Option<u16>,
703    #[serde(skip_serializing_if = "Option::is_none")]
704    pub edge_confidence_tier: Option<String>,
705    pub score: f64,
706    pub excerpt: String,
707}
708
709fn normalize_filter_list(
710    field: &'static str,
711    values: Vec<String>,
712) -> Result<Vec<String>, DomainError> {
713    let mut normalized = Vec::new();
714    for value in values {
715        let value = required_text(field, value)?;
716        if !normalized.contains(&value) {
717            normalized.push(value);
718        }
719    }
720
721    Ok(normalized)
722}
723
724fn checked_u32(field: &'static str, value: usize) -> Result<u32, DomainError> {
725    u32::try_from(value).map_err(|_| DomainError::invalid(field, "must fit in u32"))
726}
727
728fn append_hash_list(input: &mut Vec<u8>, values: &[String]) {
729    input.extend_from_slice(&(values.len() as u64).to_le_bytes());
730    for value in values {
731        append_hash_part(input, value);
732    }
733}
734
735fn append_hash_part(input: &mut Vec<u8>, value: &str) {
736    input.extend_from_slice(&(value.len() as u64).to_le_bytes());
737    input.extend_from_slice(value.as_bytes());
738}
739
740fn stable_hash64(bytes: &[u8]) -> u64 {
741    const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
742    const FNV_PRIME: u64 = 0x100000001b3;
743
744    let mut hash = FNV_OFFSET_BASIS;
745    for byte in bytes {
746        hash ^= u64::from(*byte);
747        hash = hash.wrapping_mul(FNV_PRIME);
748    }
749
750    hash
751}
752
753#[cfg(test)]
754mod tests {
755    use super::*;
756
757    #[test]
758    fn selector_trims_and_deduplicates_filters() {
759        let selector = CodeRepositorySelector::new(
760            " repo ",
761            " HEAD ",
762            vec!["src".to_owned(), " src ".to_owned()],
763            vec!["rust".to_owned(), "rust".to_owned()],
764        )
765        .expect("selector should validate");
766
767        assert_eq!(selector.repository, "repo");
768        assert_eq!(selector.ref_selector, "HEAD");
769        assert_eq!(selector.path_filters, ["src"]);
770        assert_eq!(selector.language_filters, ["rust"]);
771    }
772
773    #[test]
774    fn snapshot_scope_id_tracks_tree_and_filters() {
775        let scope = code_snapshot_scope_id(
776            "repo-1",
777            "tree-a",
778            &["src".to_owned()],
779            &["rust".to_owned()],
780        );
781        let same = code_snapshot_scope_id(
782            "repo-1",
783            "tree-a",
784            &["src".to_owned()],
785            &["rust".to_owned()],
786        );
787        let different_tree = code_snapshot_scope_id(
788            "repo-1",
789            "tree-b",
790            &["src".to_owned()],
791            &["rust".to_owned()],
792        );
793
794        assert_eq!(scope, same);
795        assert_ne!(scope, different_tree);
796        assert!(scope.starts_with("git_snapshot:"));
797    }
798
799    #[test]
800    fn retrieval_request_rejects_unbounded_limits() {
801        let selector = CodeRepositorySelector::new("repo", "HEAD", Vec::new(), Vec::new())
802            .expect("selector should validate");
803        let error = CodeRetrievalRequest::new(
804            "symbol",
805            selector,
806            CodeQueryKind::Hybrid,
807            51,
808            FreshnessPolicy::AllowStale,
809        )
810        .expect_err("large limit should fail");
811
812        assert_eq!(error.field, "limit");
813    }
814
815    #[test]
816    fn code_ranges_must_be_ordered() {
817        let error = RepositoryCodeRange::new("line_range", 3, 2).expect_err("range should fail");
818
819        assert_eq!(error.field, "line_range");
820    }
821}