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 #[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#[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#[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#[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#[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#[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}