Skip to main content

relay_knowledge/domain/code/
repository_set.rs

1use serde::{Deserialize, Serialize};
2
3use super::{
4    DomainError,
5    code_repository::{CodeQueryKind, CodeRetrievalHit},
6    error::required_text,
7    retrieval::FreshnessPolicy,
8};
9
10/// Repository-set creation request shared by API adapters.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct CodeRepositorySetCreateRequest {
13    pub alias: String,
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub description: Option<String>,
16    pub default_ref_policy_json: String,
17}
18
19impl CodeRepositorySetCreateRequest {
20    pub fn new(
21        alias: impl Into<String>,
22        description: Option<String>,
23        default_ref_policy_json: Option<String>,
24    ) -> Result<Self, DomainError> {
25        Ok(Self {
26            alias: required_text("set_alias", alias)?,
27            description: optional_text("description", description)?,
28            default_ref_policy_json: default_ref_policy_json
29                .filter(|value| !value.trim().is_empty())
30                .unwrap_or_else(|| "{\"default_ref\":\"HEAD\"}".to_owned()),
31        })
32    }
33}
34
35/// Persisted repository-set metadata.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct CodeRepositorySet {
38    pub set_id: String,
39    pub alias: String,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub description: Option<String>,
42    pub default_ref_policy_json: String,
43    pub created_at_ms: u64,
44    pub updated_at_ms: u64,
45}
46
47/// Request to attach one indexed repository snapshot to a repository set.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub struct CodeRepositorySetAddMemberRequest {
50    pub set_alias: String,
51    pub repository_alias: String,
52    pub ref_selector: String,
53    pub path_filters: Vec<String>,
54    pub language_filters: Vec<String>,
55    pub priority: i32,
56}
57
58impl CodeRepositorySetAddMemberRequest {
59    pub fn new(
60        set_alias: impl Into<String>,
61        repository_alias: impl Into<String>,
62        ref_selector: impl Into<String>,
63        path_filters: Vec<String>,
64        language_filters: Vec<String>,
65        priority: i32,
66    ) -> Result<Self, DomainError> {
67        Ok(Self {
68            set_alias: required_text("set_alias", set_alias)?,
69            repository_alias: required_text("repository_alias", repository_alias)?,
70            ref_selector: required_text("ref_selector", ref_selector)?,
71            path_filters: normalize_filter_list("path_filter", path_filters)?,
72            language_filters: normalize_filter_list("language_filter", language_filters)?,
73            priority,
74        })
75    }
76}
77
78/// Request to remove one repository snapshot from a repository set.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct CodeRepositorySetRemoveMemberRequest {
81    pub set_alias: String,
82    pub repository_alias: String,
83}
84
85impl CodeRepositorySetRemoveMemberRequest {
86    pub fn new(
87        set_alias: impl Into<String>,
88        repository_alias: impl Into<String>,
89    ) -> Result<Self, DomainError> {
90        Ok(Self {
91            set_alias: required_text("set_alias", set_alias)?,
92            repository_alias: required_text("repository_alias", repository_alias)?,
93        })
94    }
95}
96
97/// Persisted repository-set membership pointing at a real repository snapshot.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct CodeRepositorySetMember {
100    pub set_id: String,
101    pub repository_id: String,
102    pub repository_alias: String,
103    pub ref_selector: String,
104    pub resolved_commit_sha: String,
105    pub source_scope: String,
106    pub path_filters: Vec<String>,
107    pub language_filters: Vec<String>,
108    pub priority: i32,
109}
110
111/// Status for one repository-set member snapshot.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct CodeRepositorySetMemberStatus {
114    pub member: CodeRepositorySetMember,
115    pub tree_hash: String,
116    #[serde(default, skip_serializing)]
117    pub indexed_path_filters: Vec<String>,
118    #[serde(default, skip_serializing)]
119    pub indexed_language_filters: Vec<String>,
120    pub freshness_state: String,
121    pub stale: bool,
122    pub indexed_file_count: usize,
123    pub symbol_count: usize,
124    pub reference_count: usize,
125    pub chunk_count: usize,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub degraded_reason: Option<String>,
128}
129
130/// Overlay freshness and task-independent diagnostics.
131#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
132pub struct CodeRepositorySetOverlayStatus {
133    pub state: String,
134    pub stale: bool,
135    pub edge_count: usize,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub refreshed_at_ms: Option<u64>,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub degraded_reason: Option<String>,
140}
141
142/// Aggregated repository-set status.
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct CodeRepositorySetStatus {
145    pub repository_set: CodeRepositorySet,
146    pub members: Vec<CodeRepositorySetMemberStatus>,
147    pub overlay: CodeRepositorySetOverlayStatus,
148    pub freshness_state: String,
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub degraded_reason: Option<String>,
151}
152
153/// Multi-repository query request.
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155pub struct CodeRepositorySetQueryRequest {
156    pub set_alias: String,
157    pub query: String,
158    pub code_query_kind: CodeQueryKind,
159    pub limit: usize,
160    pub freshness_policy: FreshnessPolicy,
161    pub path_filters: Vec<String>,
162    pub language_filters: Vec<String>,
163    #[serde(default)]
164    pub exclude_generated: bool,
165}
166
167impl CodeRepositorySetQueryRequest {
168    pub fn new(
169        set_alias: impl Into<String>,
170        query: impl Into<String>,
171        code_query_kind: CodeQueryKind,
172        limit: usize,
173        freshness_policy: FreshnessPolicy,
174        path_filters: Vec<String>,
175        language_filters: Vec<String>,
176    ) -> Result<Self, DomainError> {
177        let limit = match limit {
178            1..=50 => limit,
179            0 => return Err(DomainError::invalid("limit", "must be greater than zero")),
180            _ => return Err(DomainError::invalid("limit", "must be 50 or less")),
181        };
182
183        Ok(Self {
184            set_alias: required_text("set_alias", set_alias)?,
185            query: required_text("query", query)?,
186            code_query_kind,
187            limit,
188            freshness_policy,
189            path_filters: normalize_filter_list("path_filter", path_filters)?,
190            language_filters: normalize_filter_list("language_filter", language_filters)?,
191            exclude_generated: false,
192        })
193    }
194}
195
196/// Cross-repository overlay edge derived after member snapshots are indexed.
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198pub struct CodeRepositoryCrossEdge {
199    pub edge_id: String,
200    pub set_id: String,
201    pub from_source_scope: String,
202    pub from_repository_id: String,
203    pub from_record_kind: String,
204    pub from_record_id: String,
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub to_source_scope: Option<String>,
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub to_repository_id: Option<String>,
209    pub to_record_kind: String,
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub to_record_id: Option<String>,
212    pub edge_kind: String,
213    pub resolution_state: String,
214    pub confidence_basis_points: u16,
215    pub confidence_tier: String,
216    pub evidence_json: String,
217    pub created_at_ms: u64,
218}
219
220/// Query hit with explicit repository-set provenance.
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
222pub struct CodeRepositorySetQueryHit {
223    pub member: CodeRepositorySetMember,
224    pub hit: CodeRetrievalHit,
225    pub overlay_evidence: Vec<CodeRepositoryCrossEdge>,
226    pub score: f64,
227}
228
229/// Summary returned after rebuilding a repository-set overlay.
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub struct CodeRepositorySetRefreshSummary {
232    pub set_id: String,
233    pub alias: String,
234    pub edge_count: usize,
235    pub resolved_edge_count: usize,
236    pub ambiguous_edge_count: usize,
237    pub unresolved_edge_count: usize,
238    pub refreshed_at_ms: u64,
239}
240
241/// Durable state for a repository-set overlay refresh task.
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
243#[serde(rename_all = "snake_case")]
244pub enum CodeRepositorySetRefreshTaskState {
245    Queued,
246    Running,
247    Succeeded,
248    Retrying,
249    DeadLetter,
250}
251
252impl CodeRepositorySetRefreshTaskState {
253    pub const fn as_str(self) -> &'static str {
254        match self {
255            Self::Queued => "queued",
256            Self::Running => "running",
257            Self::Succeeded => "succeeded",
258            Self::Retrying => "retrying",
259            Self::DeadLetter => "dead_letter",
260        }
261    }
262
263    pub fn parse(value: &str) -> Result<Self, DomainError> {
264        match value {
265            "queued" => Ok(Self::Queued),
266            "running" => Ok(Self::Running),
267            "succeeded" => Ok(Self::Succeeded),
268            "retrying" => Ok(Self::Retrying),
269            "dead_letter" => Ok(Self::DeadLetter),
270            _ => Err(DomainError::invalid(
271                "repository_set_refresh_task_state",
272                "unknown repository set refresh task state",
273            )),
274        }
275    }
276
277    pub const fn is_unfinished(self) -> bool {
278        matches!(self, Self::Queued | Self::Running | Self::Retrying)
279    }
280}
281
282/// Durable repository-set overlay refresh task record.
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
284pub struct CodeRepositorySetRefreshTaskRecord {
285    pub task_id: String,
286    pub set_id: String,
287    pub set_alias: String,
288    pub state: CodeRepositorySetRefreshTaskState,
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub lease_owner: Option<String>,
291    #[serde(skip_serializing_if = "Option::is_none")]
292    pub lease_expires_at_ms: Option<u64>,
293    pub attempt_count: u32,
294    pub next_retry_at_ms: u64,
295    pub input_fingerprint: String,
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub last_error_kind: Option<String>,
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub last_error_message: Option<String>,
300    pub created_at_ms: u64,
301    pub updated_at_ms: u64,
302}
303
304fn optional_text(
305    field: &'static str,
306    value: Option<String>,
307) -> Result<Option<String>, DomainError> {
308    value.map(|value| required_text(field, value)).transpose()
309}
310
311fn normalize_filter_list(
312    field: &'static str,
313    values: Vec<String>,
314) -> Result<Vec<String>, DomainError> {
315    let mut normalized = Vec::new();
316    for value in values {
317        let value = required_text(field, value)?;
318        if !normalized.contains(&value) {
319            normalized.push(value);
320        }
321    }
322
323    Ok(normalized)
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn repository_set_requests_normalize_inputs_and_defaults() {
332        let create = CodeRepositorySetCreateRequest::new(
333            " workspace ",
334            Some(" grouped repos ".to_owned()),
335            None,
336        )
337        .expect("create request should validate");
338        assert_eq!(create.alias, "workspace");
339        assert_eq!(create.description.as_deref(), Some("grouped repos"));
340        assert_eq!(create.default_ref_policy_json, "{\"default_ref\":\"HEAD\"}");
341
342        let add = CodeRepositorySetAddMemberRequest::new(
343            "workspace",
344            "core",
345            "HEAD",
346            vec![" src ".to_owned(), "src".to_owned()],
347            vec![" rust ".to_owned(), "rust".to_owned()],
348            10,
349        )
350        .expect("member request should validate");
351        assert_eq!(add.path_filters, ["src"]);
352        assert_eq!(add.language_filters, ["rust"]);
353
354        let remove = CodeRepositorySetRemoveMemberRequest::new(" workspace ", " core ")
355            .expect("remove request should validate");
356        assert_eq!(remove.set_alias, "workspace");
357        assert_eq!(remove.repository_alias, "core");
358
359        let query = CodeRepositorySetQueryRequest::new(
360            "workspace",
361            "RetryPolicy",
362            CodeQueryKind::Definition,
363            50,
364            FreshnessPolicy::WaitUntilFresh,
365            vec!["src".to_owned(), "src".to_owned()],
366            Vec::new(),
367        )
368        .expect("query request should validate");
369        assert_eq!(query.limit, 50);
370        assert_eq!(query.path_filters, ["src"]);
371        assert_eq!(query.freshness_policy, FreshnessPolicy::WaitUntilFresh);
372    }
373
374    #[test]
375    fn repository_set_requests_reject_invalid_boundaries() {
376        assert!(
377            CodeRepositorySetCreateRequest::new(" ", None, None)
378                .expect_err("blank alias should fail")
379                .to_string()
380                .contains("set_alias")
381        );
382        assert!(
383            CodeRepositorySetAddMemberRequest::new(
384                "workspace",
385                "core",
386                " ",
387                Vec::new(),
388                Vec::new(),
389                0
390            )
391            .expect_err("blank ref should fail")
392            .to_string()
393            .contains("ref_selector")
394        );
395        assert!(
396            CodeRepositorySetQueryRequest::new(
397                "workspace",
398                "query",
399                CodeQueryKind::Hybrid,
400                0,
401                FreshnessPolicy::AllowStale,
402                Vec::new(),
403                Vec::new(),
404            )
405            .expect_err("zero limit should fail")
406            .to_string()
407            .contains("greater than zero")
408        );
409        assert!(
410            CodeRepositorySetQueryRequest::new(
411                "workspace",
412                "query",
413                CodeQueryKind::Hybrid,
414                51,
415                FreshnessPolicy::AllowStale,
416                Vec::new(),
417                Vec::new(),
418            )
419            .expect_err("oversized limit should fail")
420            .to_string()
421            .contains("50 or less")
422        );
423        assert!(
424            CodeRepositorySetRemoveMemberRequest::new("workspace", " ")
425                .expect_err("blank repository alias should fail")
426                .to_string()
427                .contains("repository_alias")
428        );
429    }
430
431    #[test]
432    fn repository_set_refresh_task_states_have_stable_wire_values() {
433        for (state, wire, unfinished) in [
434            (CodeRepositorySetRefreshTaskState::Queued, "queued", true),
435            (CodeRepositorySetRefreshTaskState::Running, "running", true),
436            (
437                CodeRepositorySetRefreshTaskState::Succeeded,
438                "succeeded",
439                false,
440            ),
441            (
442                CodeRepositorySetRefreshTaskState::Retrying,
443                "retrying",
444                true,
445            ),
446            (
447                CodeRepositorySetRefreshTaskState::DeadLetter,
448                "dead_letter",
449                false,
450            ),
451        ] {
452            assert_eq!(state.as_str(), wire);
453            assert_eq!(
454                CodeRepositorySetRefreshTaskState::parse(wire).expect("wire state should parse"),
455                state
456            );
457            assert_eq!(state.is_unfinished(), unfinished);
458        }
459        assert!(
460            CodeRepositorySetRefreshTaskState::parse("mystery")
461                .expect_err("unknown state should fail")
462                .to_string()
463                .contains("unknown repository set refresh task state")
464        );
465    }
466}