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 = 256;
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/// Persistent lifecycle for background code repository index tasks.
552#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
553#[serde(rename_all = "snake_case")]
554pub enum CodeIndexTaskState {
555    Queued,
556    Running,
557    Succeeded,
558    Retrying,
559    Failed,
560    DeadLetter,
561    Cancelled,
562}
563
564impl CodeIndexTaskState {
565    /// Stable storage and API representation.
566    pub const fn as_str(self) -> &'static str {
567        match self {
568            Self::Queued => "queued",
569            Self::Running => "running",
570            Self::Succeeded => "succeeded",
571            Self::Retrying => "retrying",
572            Self::Failed => "failed",
573            Self::DeadLetter => "dead_letter",
574            Self::Cancelled => "cancelled",
575        }
576    }
577
578    /// Parses the stable storage and API representation.
579    pub fn parse(value: &str) -> Result<Self, DomainError> {
580        match value {
581            "queued" => Ok(Self::Queued),
582            "running" => Ok(Self::Running),
583            "succeeded" => Ok(Self::Succeeded),
584            "retrying" => Ok(Self::Retrying),
585            "failed" => Ok(Self::Failed),
586            "dead_letter" => Ok(Self::DeadLetter),
587            "cancelled" => Ok(Self::Cancelled),
588            _ => Err(DomainError::invalid(
589                "code_index_task_state",
590                "unknown code index task state",
591            )),
592        }
593    }
594
595    /// Returns whether the task can still consume executor capacity.
596    pub const fn is_unfinished(self) -> bool {
597        matches!(self, Self::Queued | Self::Running | Self::Retrying)
598    }
599}
600
601/// Durable background task for one code repository index request.
602#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
603pub struct CodeIndexTaskRecord {
604    pub task_id: String,
605    pub repository_id: String,
606    pub alias: String,
607    pub ref_selector: String,
608    pub resolved_commit_sha: String,
609    pub tree_hash: String,
610    pub source_scope: String,
611    pub path_filters: Vec<String>,
612    pub language_filters: Vec<String>,
613    pub mode: CodeIndexMode,
614    pub state: CodeIndexTaskState,
615    #[serde(skip_serializing_if = "Option::is_none")]
616    pub lease_owner: Option<String>,
617    #[serde(skip_serializing_if = "Option::is_none")]
618    pub lease_expires_at_ms: Option<u64>,
619    pub attempt_count: u32,
620    pub next_retry_at_ms: u64,
621    pub input_fingerprint: String,
622    pub resource_budget: CodeIndexResourceBudget,
623    pub payload_json: String,
624    #[serde(skip_serializing_if = "Option::is_none")]
625    pub last_error_kind: Option<String>,
626    #[serde(skip_serializing_if = "Option::is_none")]
627    pub last_error_message: Option<String>,
628    pub created_at_ms: u64,
629    pub updated_at_ms: u64,
630}
631
632/// Scope retention result after pruning old repository snapshots.
633#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
634pub struct CodeScopeRetentionSummary {
635    pub repository_id: String,
636    pub retained_scope_count: usize,
637    pub prunable_scope_count: usize,
638    pub pruned_scope_count: usize,
639    pub retained_scopes: Vec<String>,
640    pub prunable_scopes: Vec<String>,
641    pub pruned_scopes: Vec<String>,
642}
643
644/// Coarse phase timing and counts reported by repository indexing.
645#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
646pub struct CodeIndexProgressSummary {
647    pub git_file_count: usize,
648    pub blob_read_count: usize,
649    pub parsed_file_count: usize,
650    pub sqlite_write_count: usize,
651    pub skipped_file_count: usize,
652    pub degraded_file_count: usize,
653    pub batch_count: usize,
654    pub checkpoint_file_count: usize,
655    pub resource_budget: CodeIndexResourceBudget,
656}
657
658/// Result of applying a code index snapshot.
659#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
660pub struct CodeIndexSummary {
661    pub repository_id: String,
662    pub source_scope: String,
663    pub resolved_commit_sha: String,
664    pub tree_hash: String,
665    pub indexed_file_count: usize,
666    pub changed_path_count: usize,
667    pub skipped_unchanged_count: usize,
668    pub deleted_path_count: usize,
669    pub symbol_count: usize,
670    pub reference_count: usize,
671    pub chunk_count: usize,
672    pub degraded_file_count: usize,
673    pub progress: CodeIndexProgressSummary,
674}
675
676/// Language bucket in a repository scope preview.
677#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
678pub struct CodeRepositoryLanguagePreview {
679    pub language_id: String,
680    pub file_count: usize,
681    pub byte_count: usize,
682}
683
684/// Large file surfaced before a full repository index starts.
685#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
686pub struct CodeRepositoryLargestFile {
687    pub path: String,
688    pub byte_count: usize,
689}
690
691/// Path excluded from indexing by preset, ignore file, or request scope.
692#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
693pub struct CodeRepositoryExcludedPath {
694    pub path: String,
695    pub reason: String,
696}
697
698/// Non-mutating preview of the effective repository indexing scope.
699#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
700pub struct CodeRepositoryScopePreview {
701    pub repository_id: String,
702    pub alias: String,
703    pub requested_ref: String,
704    pub resolved_commit_sha: String,
705    pub tree_hash: String,
706    pub selected_file_count: usize,
707    pub selected_byte_count: usize,
708    pub unsupported_file_count: usize,
709    pub generated_or_heavy_file_count: usize,
710    pub expected_degraded_file_count: usize,
711    pub language_distribution: Vec<CodeRepositoryLanguagePreview>,
712    pub largest_files: Vec<CodeRepositoryLargestFile>,
713    pub excluded_paths: Vec<CodeRepositoryExcludedPath>,
714}
715
716/// Aggregated totals for repository indexes separate from graph-evidence code rows.
717#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
718pub struct CodeRepositoryTotals {
719    pub repository_count: usize,
720    pub indexed_file_count: usize,
721    pub symbol_count: usize,
722    pub reference_count: usize,
723    pub chunk_count: usize,
724    pub degraded_file_count: usize,
725    pub parse_status_counts: CodeParseStatusCounts,
726}
727
728/// Representative query latency captured for an operations report.
729#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
730pub struct CodeRepositoryLatencySample {
731    pub query: String,
732    pub kind: CodeQueryKind,
733    pub result_count: usize,
734    pub duration_ms: u64,
735}
736
737/// Reusable repository operations report.
738#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
739pub struct CodeRepositoryReport {
740    pub repository_id: String,
741    pub alias: String,
742    pub root_path: String,
743    pub path_filters: Vec<String>,
744    pub language_filters: Vec<String>,
745    pub resolved_commit_sha: Option<String>,
746    pub tree_hash: Option<String>,
747    pub indexed_file_count: usize,
748    pub symbol_count: usize,
749    pub reference_count: usize,
750    pub chunk_count: usize,
751    pub degraded_file_count: usize,
752    pub resolved_edge_count: usize,
753    pub ambiguous_edge_count: usize,
754    pub unresolved_edge_count: usize,
755    pub degradation_summary: Vec<String>,
756    pub representative_queries: Vec<String>,
757    pub latency_samples: Vec<CodeRepositoryLatencySample>,
758    pub freshness_state: String,
759}
760
761/// Diff paths split by the effective repository selector.
762#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
763pub struct CodeImpactPathGroups {
764    pub in_scope_changed_paths: Vec<String>,
765    pub out_of_scope_changed_paths: Vec<String>,
766}
767
768/// Code retrieval hit with source location, layers, and freshness metadata.
769#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
770pub struct CodeRetrievalHit {
771    pub repository_id: String,
772    pub scope_id: String,
773    pub resolved_commit_sha: String,
774    pub tree_hash: String,
775    pub path: String,
776    pub language_id: String,
777    pub byte_range: RepositoryCodeRange,
778    pub line_range: RepositoryCodeRange,
779    pub symbol_snapshot_id: Option<String>,
780    #[serde(skip_serializing_if = "Option::is_none")]
781    pub canonical_symbol_id: Option<String>,
782    pub file_id: Option<String>,
783    pub retrieval_layers: Vec<CodeRetrievalLayer>,
784    pub index_versions: Vec<String>,
785    pub stale: bool,
786    #[serde(skip_serializing_if = "Option::is_none")]
787    pub degraded_reason: Option<String>,
788    #[serde(skip_serializing_if = "Option::is_none")]
789    pub edge_kind: Option<String>,
790    #[serde(skip_serializing_if = "Option::is_none")]
791    pub edge_resolution_state: Option<String>,
792    #[serde(skip_serializing_if = "Option::is_none")]
793    pub edge_target_hint: Option<String>,
794    #[serde(skip_serializing_if = "Option::is_none")]
795    pub edge_confidence_basis_points: Option<u16>,
796    #[serde(skip_serializing_if = "Option::is_none")]
797    pub edge_confidence_tier: Option<String>,
798    pub score: f64,
799    pub excerpt: String,
800}
801
802fn normalize_filter_list(
803    field: &'static str,
804    values: Vec<String>,
805) -> Result<Vec<String>, DomainError> {
806    let mut normalized = Vec::new();
807    for value in values {
808        let value = required_text(field, value)?;
809        if !normalized.contains(&value) {
810            normalized.push(value);
811        }
812    }
813
814    Ok(normalized)
815}
816
817fn checked_u32(field: &'static str, value: usize) -> Result<u32, DomainError> {
818    u32::try_from(value).map_err(|_| DomainError::invalid(field, "must fit in u32"))
819}
820
821fn append_hash_list(input: &mut Vec<u8>, values: &[String]) {
822    input.extend_from_slice(&(values.len() as u64).to_le_bytes());
823    for value in values {
824        append_hash_part(input, value);
825    }
826}
827
828fn append_hash_part(input: &mut Vec<u8>, value: &str) {
829    input.extend_from_slice(&(value.len() as u64).to_le_bytes());
830    input.extend_from_slice(value.as_bytes());
831}
832
833fn stable_hash64(bytes: &[u8]) -> u64 {
834    const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
835    const FNV_PRIME: u64 = 0x100000001b3;
836
837    let mut hash = FNV_OFFSET_BASIS;
838    for byte in bytes {
839        hash ^= u64::from(*byte);
840        hash = hash.wrapping_mul(FNV_PRIME);
841    }
842
843    hash
844}
845
846#[cfg(test)]
847mod tests {
848    use super::*;
849
850    #[test]
851    fn selector_trims_and_deduplicates_filters() {
852        let selector = CodeRepositorySelector::new(
853            " repo ",
854            " HEAD ",
855            vec!["src".to_owned(), " src ".to_owned()],
856            vec!["rust".to_owned(), "rust".to_owned()],
857        )
858        .expect("selector should validate");
859
860        assert_eq!(selector.repository, "repo");
861        assert_eq!(selector.ref_selector, "HEAD");
862        assert_eq!(selector.path_filters, ["src"]);
863        assert_eq!(selector.language_filters, ["rust"]);
864    }
865
866    #[test]
867    fn snapshot_scope_id_tracks_tree_and_filters() {
868        let scope = code_snapshot_scope_id(
869            "repo-1",
870            "tree-a",
871            &["src".to_owned()],
872            &["rust".to_owned()],
873        );
874        let same = code_snapshot_scope_id(
875            "repo-1",
876            "tree-a",
877            &["src".to_owned()],
878            &["rust".to_owned()],
879        );
880        let different_tree = code_snapshot_scope_id(
881            "repo-1",
882            "tree-b",
883            &["src".to_owned()],
884            &["rust".to_owned()],
885        );
886
887        assert_eq!(scope, same);
888        assert_ne!(scope, different_tree);
889        assert!(scope.starts_with("git_snapshot:"));
890    }
891
892    #[test]
893    fn retrieval_request_rejects_unbounded_limits() {
894        let selector = CodeRepositorySelector::new("repo", "HEAD", Vec::new(), Vec::new())
895            .expect("selector should validate");
896        let error = CodeRetrievalRequest::new(
897            "symbol",
898            selector,
899            CodeQueryKind::Hybrid,
900            51,
901            FreshnessPolicy::AllowStale,
902        )
903        .expect_err("large limit should fail");
904
905        assert_eq!(error.field, "limit");
906    }
907
908    #[test]
909    fn code_ranges_must_be_ordered() {
910        let error = RepositoryCodeRange::new("line_range", 3, 2).expect_err("range should fail");
911
912        assert_eq!(error.field, "line_range");
913    }
914
915    #[test]
916    fn default_code_index_budget_batches_more_small_files_without_raising_row_or_byte_caps() {
917        let budget = CodeIndexResourceBudget::default();
918
919        assert_eq!(budget.max_files_per_batch, 256);
920        assert_eq!(
921            budget.max_bytes_per_batch,
922            CodeIndexResourceBudget::DEFAULT_MAX_BYTES_PER_BATCH
923        );
924        assert_eq!(
925            budget.max_rows_per_batch,
926            CodeIndexResourceBudget::DEFAULT_MAX_ROWS_PER_BATCH
927        );
928    }
929}