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    pub freshness_state: String,
117    pub stale: bool,
118    pub indexed_file_count: usize,
119    pub symbol_count: usize,
120    pub reference_count: usize,
121    pub chunk_count: usize,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub degraded_reason: Option<String>,
124}
125
126/// Overlay freshness and task-independent diagnostics.
127#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
128pub struct CodeRepositorySetOverlayStatus {
129    pub state: String,
130    pub stale: bool,
131    pub edge_count: usize,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub refreshed_at_ms: Option<u64>,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub degraded_reason: Option<String>,
136}
137
138/// Aggregated repository-set status.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct CodeRepositorySetStatus {
141    pub repository_set: CodeRepositorySet,
142    pub members: Vec<CodeRepositorySetMemberStatus>,
143    pub overlay: CodeRepositorySetOverlayStatus,
144    pub freshness_state: String,
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub degraded_reason: Option<String>,
147}
148
149/// Multi-repository query request.
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub struct CodeRepositorySetQueryRequest {
152    pub set_alias: String,
153    pub query: String,
154    pub code_query_kind: CodeQueryKind,
155    pub limit: usize,
156    pub freshness_policy: FreshnessPolicy,
157    pub path_filters: Vec<String>,
158    pub language_filters: Vec<String>,
159}
160
161impl CodeRepositorySetQueryRequest {
162    pub fn new(
163        set_alias: impl Into<String>,
164        query: impl Into<String>,
165        code_query_kind: CodeQueryKind,
166        limit: usize,
167        freshness_policy: FreshnessPolicy,
168        path_filters: Vec<String>,
169        language_filters: Vec<String>,
170    ) -> Result<Self, DomainError> {
171        let limit = match limit {
172            1..=50 => limit,
173            0 => return Err(DomainError::invalid("limit", "must be greater than zero")),
174            _ => return Err(DomainError::invalid("limit", "must be 50 or less")),
175        };
176
177        Ok(Self {
178            set_alias: required_text("set_alias", set_alias)?,
179            query: required_text("query", query)?,
180            code_query_kind,
181            limit,
182            freshness_policy,
183            path_filters: normalize_filter_list("path_filter", path_filters)?,
184            language_filters: normalize_filter_list("language_filter", language_filters)?,
185        })
186    }
187}
188
189/// Cross-repository overlay edge derived after member snapshots are indexed.
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191pub struct CodeRepositoryCrossEdge {
192    pub edge_id: String,
193    pub set_id: String,
194    pub from_source_scope: String,
195    pub from_repository_id: String,
196    pub from_record_kind: String,
197    pub from_record_id: String,
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub to_source_scope: Option<String>,
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub to_repository_id: Option<String>,
202    pub to_record_kind: String,
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub to_record_id: Option<String>,
205    pub edge_kind: String,
206    pub resolution_state: String,
207    pub confidence_basis_points: u16,
208    pub confidence_tier: String,
209    pub evidence_json: String,
210    pub created_at_ms: u64,
211}
212
213/// Query hit with explicit repository-set provenance.
214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
215pub struct CodeRepositorySetQueryHit {
216    pub member: CodeRepositorySetMember,
217    pub hit: CodeRetrievalHit,
218    pub overlay_evidence: Vec<CodeRepositoryCrossEdge>,
219    pub score: f64,
220}
221
222/// Summary returned after rebuilding a repository-set overlay.
223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
224pub struct CodeRepositorySetRefreshSummary {
225    pub set_id: String,
226    pub alias: String,
227    pub edge_count: usize,
228    pub resolved_edge_count: usize,
229    pub ambiguous_edge_count: usize,
230    pub unresolved_edge_count: usize,
231    pub refreshed_at_ms: u64,
232}
233
234/// Durable state for a repository-set overlay refresh task.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
236#[serde(rename_all = "snake_case")]
237pub enum CodeRepositorySetRefreshTaskState {
238    Queued,
239    Running,
240    Succeeded,
241    Retrying,
242    DeadLetter,
243}
244
245impl CodeRepositorySetRefreshTaskState {
246    pub const fn as_str(self) -> &'static str {
247        match self {
248            Self::Queued => "queued",
249            Self::Running => "running",
250            Self::Succeeded => "succeeded",
251            Self::Retrying => "retrying",
252            Self::DeadLetter => "dead_letter",
253        }
254    }
255
256    pub fn parse(value: &str) -> Result<Self, DomainError> {
257        match value {
258            "queued" => Ok(Self::Queued),
259            "running" => Ok(Self::Running),
260            "succeeded" => Ok(Self::Succeeded),
261            "retrying" => Ok(Self::Retrying),
262            "dead_letter" => Ok(Self::DeadLetter),
263            _ => Err(DomainError::invalid(
264                "repository_set_refresh_task_state",
265                "unknown repository set refresh task state",
266            )),
267        }
268    }
269
270    pub const fn is_unfinished(self) -> bool {
271        matches!(self, Self::Queued | Self::Running | Self::Retrying)
272    }
273}
274
275/// Durable repository-set overlay refresh task record.
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277pub struct CodeRepositorySetRefreshTaskRecord {
278    pub task_id: String,
279    pub set_id: String,
280    pub set_alias: String,
281    pub state: CodeRepositorySetRefreshTaskState,
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub lease_owner: Option<String>,
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub lease_expires_at_ms: Option<u64>,
286    pub attempt_count: u32,
287    pub next_retry_at_ms: u64,
288    pub input_fingerprint: String,
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub last_error_kind: Option<String>,
291    #[serde(skip_serializing_if = "Option::is_none")]
292    pub last_error_message: Option<String>,
293    pub created_at_ms: u64,
294    pub updated_at_ms: u64,
295}
296
297fn optional_text(
298    field: &'static str,
299    value: Option<String>,
300) -> Result<Option<String>, DomainError> {
301    value.map(|value| required_text(field, value)).transpose()
302}
303
304fn normalize_filter_list(
305    field: &'static str,
306    values: Vec<String>,
307) -> Result<Vec<String>, DomainError> {
308    let mut normalized = Vec::new();
309    for value in values {
310        let value = required_text(field, value)?;
311        if !normalized.contains(&value) {
312            normalized.push(value);
313        }
314    }
315
316    Ok(normalized)
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn repository_set_requests_normalize_inputs_and_defaults() {
325        let create = CodeRepositorySetCreateRequest::new(
326            " workspace ",
327            Some(" grouped repos ".to_owned()),
328            None,
329        )
330        .expect("create request should validate");
331        assert_eq!(create.alias, "workspace");
332        assert_eq!(create.description.as_deref(), Some("grouped repos"));
333        assert_eq!(create.default_ref_policy_json, "{\"default_ref\":\"HEAD\"}");
334
335        let add = CodeRepositorySetAddMemberRequest::new(
336            "workspace",
337            "core",
338            "HEAD",
339            vec![" src ".to_owned(), "src".to_owned()],
340            vec![" rust ".to_owned(), "rust".to_owned()],
341            10,
342        )
343        .expect("member request should validate");
344        assert_eq!(add.path_filters, ["src"]);
345        assert_eq!(add.language_filters, ["rust"]);
346
347        let remove = CodeRepositorySetRemoveMemberRequest::new(" workspace ", " core ")
348            .expect("remove request should validate");
349        assert_eq!(remove.set_alias, "workspace");
350        assert_eq!(remove.repository_alias, "core");
351
352        let query = CodeRepositorySetQueryRequest::new(
353            "workspace",
354            "RetryPolicy",
355            CodeQueryKind::Definition,
356            50,
357            FreshnessPolicy::WaitUntilFresh,
358            vec!["src".to_owned(), "src".to_owned()],
359            Vec::new(),
360        )
361        .expect("query request should validate");
362        assert_eq!(query.limit, 50);
363        assert_eq!(query.path_filters, ["src"]);
364        assert_eq!(query.freshness_policy, FreshnessPolicy::WaitUntilFresh);
365    }
366
367    #[test]
368    fn repository_set_requests_reject_invalid_boundaries() {
369        assert!(
370            CodeRepositorySetCreateRequest::new(" ", None, None)
371                .expect_err("blank alias should fail")
372                .to_string()
373                .contains("set_alias")
374        );
375        assert!(
376            CodeRepositorySetAddMemberRequest::new(
377                "workspace",
378                "core",
379                " ",
380                Vec::new(),
381                Vec::new(),
382                0
383            )
384            .expect_err("blank ref should fail")
385            .to_string()
386            .contains("ref_selector")
387        );
388        assert!(
389            CodeRepositorySetQueryRequest::new(
390                "workspace",
391                "query",
392                CodeQueryKind::Hybrid,
393                0,
394                FreshnessPolicy::AllowStale,
395                Vec::new(),
396                Vec::new(),
397            )
398            .expect_err("zero limit should fail")
399            .to_string()
400            .contains("greater than zero")
401        );
402        assert!(
403            CodeRepositorySetQueryRequest::new(
404                "workspace",
405                "query",
406                CodeQueryKind::Hybrid,
407                51,
408                FreshnessPolicy::AllowStale,
409                Vec::new(),
410                Vec::new(),
411            )
412            .expect_err("oversized limit should fail")
413            .to_string()
414            .contains("50 or less")
415        );
416        assert!(
417            CodeRepositorySetRemoveMemberRequest::new("workspace", " ")
418                .expect_err("blank repository alias should fail")
419                .to_string()
420                .contains("repository_alias")
421        );
422    }
423
424    #[test]
425    fn repository_set_refresh_task_states_have_stable_wire_values() {
426        for (state, wire, unfinished) in [
427            (CodeRepositorySetRefreshTaskState::Queued, "queued", true),
428            (CodeRepositorySetRefreshTaskState::Running, "running", true),
429            (
430                CodeRepositorySetRefreshTaskState::Succeeded,
431                "succeeded",
432                false,
433            ),
434            (
435                CodeRepositorySetRefreshTaskState::Retrying,
436                "retrying",
437                true,
438            ),
439            (
440                CodeRepositorySetRefreshTaskState::DeadLetter,
441                "dead_letter",
442                false,
443            ),
444        ] {
445            assert_eq!(state.as_str(), wire);
446            assert_eq!(
447                CodeRepositorySetRefreshTaskState::parse(wire).expect("wire state should parse"),
448                state
449            );
450            assert_eq!(state.is_unfinished(), unfinished);
451        }
452        assert!(
453            CodeRepositorySetRefreshTaskState::parse("mystery")
454                .expect_err("unknown state should fail")
455                .to_string()
456                .contains("unknown repository set refresh task state")
457        );
458    }
459}