1use serde::{Deserialize, Serialize};
2
3use super::{
4 DomainError,
5 code_repository::{CodeQueryKind, CodeRetrievalHit},
6 error::required_text,
7 retrieval::FreshnessPolicy,
8};
9
10#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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}