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