Skip to main content

relay_knowledge/application/
code_repository_set_service.rs

1use crate::{
2    api::{
3        ApiError, ApiMetadata, CodeRepositorySetAddResponse, CodeRepositorySetCreateResponse,
4        CodeRepositorySetQueryResponse, CodeRepositorySetRefreshResponse,
5        CodeRepositorySetStatusResponse, RequestContext,
6    },
7    code::{CodeIndexError, resolve_repository_snapshot},
8    domain::{
9        CodeRepositorySelector, CodeRepositorySetAddMemberRequest, CodeRepositorySetCreateRequest,
10        CodeRepositorySetMemberStatus, CodeRepositorySetQueryHit, CodeRepositorySetQueryRequest,
11        CodeRepositorySetStatus, CodeRetrievalHit, CodeRetrievalRequest, FreshnessPolicy,
12    },
13    storage::{
14        CodeRepositorySetMemberSeed, CodeRepositorySetRefreshTaskClaimRequest,
15        CodeRepositorySetRefreshTaskCompletion, CodeRepositorySetRefreshTaskFailure,
16        CodeRepositorySetRefreshTaskSeed, CodeRepositorySetSeed, StorageError,
17    },
18};
19use std::{collections::BTreeMap, path::PathBuf};
20
21use super::RelayKnowledgeService;
22
23const REPOSITORY_SET_REFRESH_TASK_LEASE_MS: u64 = 10 * 60 * 1000;
24const REPOSITORY_SET_REFRESH_TASK_MAX_ATTEMPTS: u32 = 3;
25const REPOSITORY_SET_REFRESH_TASK_RETRY_BACKOFF_MS: u64 = 60_000;
26
27impl RelayKnowledgeService {
28    /// Creates or updates a thin repository set.
29    pub async fn create_code_repository_set(
30        &self,
31        request: CodeRepositorySetCreateRequest,
32        context: RequestContext,
33    ) -> Result<CodeRepositorySetCreateResponse, ApiError> {
34        let store = self.store().await.map_err(storage_api_error)?;
35        let repository_set = store
36            .create_code_repository_set(CodeRepositorySetSeed {
37                alias: request.alias.clone(),
38                description: request.description.clone(),
39                default_ref_policy_json: request.default_ref_policy_json.clone(),
40                now_ms: now_millis(),
41            })
42            .await
43            .map_err(storage_api_error)?;
44        let graph_version = store
45            .current_graph_version()
46            .await
47            .map_err(storage_api_error)?;
48
49        Ok(CodeRepositorySetCreateResponse {
50            metadata: ApiMetadata::graph_only(&context, graph_version),
51            request,
52            repository_set,
53        })
54    }
55
56    /// Adds one already-indexed repository snapshot to a repository set.
57    pub async fn add_code_repository_set_member(
58        &self,
59        request: CodeRepositorySetAddMemberRequest,
60        context: RequestContext,
61    ) -> Result<CodeRepositorySetAddResponse, ApiError> {
62        let store = self.store().await.map_err(storage_api_error)?;
63        let repository = store
64            .code_repository_status(request.repository_alias.clone())
65            .await
66            .map_err(storage_api_error)?
67            .ok_or_else(|| {
68                ApiError::invalid_argument(format!(
69                    "code repository '{}' is not registered",
70                    request.repository_alias
71                ))
72            })?;
73        let root_path = repository.root_path.clone();
74        let ref_selector = request.ref_selector.clone();
75        let (resolved_commit_sha, _tree_hash) =
76            run_blocking_code(move || resolve_repository_snapshot(root_path, &ref_selector))
77                .await?;
78        let path_filters = merged_filters(&repository.path_filters, &request.path_filters);
79        let language_filters =
80            merged_filters(&repository.language_filters, &request.language_filters);
81        let scope = store
82            .code_repository_scope_status(
83                request.repository_alias.clone(),
84                resolved_commit_sha.clone(),
85                path_filters.clone(),
86                language_filters.clone(),
87            )
88            .await
89            .map_err(storage_api_error)?
90            .ok_or_else(|| {
91                ApiError::invalid_argument(format!(
92                    "code repository '{}' has no indexed scope for ref {} and requested filters",
93                    request.repository_alias, request.ref_selector
94                ))
95            })?;
96        let source_scope = scope.last_indexed_scope_id.clone().ok_or_else(|| {
97            ApiError::invalid_argument(format!(
98                "code repository '{}' matching scope has no source scope",
99                request.repository_alias
100            ))
101        })?;
102        let member = store
103            .add_code_repository_set_member(CodeRepositorySetMemberSeed {
104                set_alias: request.set_alias.clone(),
105                repository_id: repository.repository_id,
106                repository_alias: request.repository_alias.clone(),
107                ref_selector: request.ref_selector.clone(),
108                resolved_commit_sha,
109                source_scope,
110                path_filters,
111                language_filters,
112                priority: request.priority,
113            })
114            .await
115            .map_err(storage_api_error)?;
116        let status = required_set_status(&store, &request.set_alias).await?;
117        let graph_version = store
118            .current_graph_version()
119            .await
120            .map_err(storage_api_error)?;
121
122        Ok(CodeRepositorySetAddResponse {
123            metadata: ApiMetadata::graph_only(&context, graph_version),
124            request,
125            member,
126            status,
127        })
128    }
129
130    /// Queries every member scope and merges ranked candidates without changing single-repo search.
131    pub async fn query_code_repository_set(
132        &self,
133        request: CodeRepositorySetQueryRequest,
134        context: RequestContext,
135    ) -> Result<CodeRepositorySetQueryResponse, ApiError> {
136        let store = self.store().await.map_err(storage_api_error)?;
137        let status = required_set_status(&store, &request.set_alias).await?;
138        let graph_version = store
139            .current_graph_version()
140            .await
141            .map_err(storage_api_error)?;
142        if request.freshness_policy == FreshnessPolicy::GraphOnly {
143            return Ok(CodeRepositorySetQueryResponse {
144                metadata: ApiMetadata::graph_only(&context, graph_version),
145                request,
146                status,
147                results: Vec::new(),
148                truncated: false,
149                degraded_reason: Some("graph_only freshness policy selected".to_owned()),
150            });
151        }
152        if let Some(error) = unfresh_set_error_for_wait_policy(&request, &status) {
153            return Err(error);
154        }
155        let edges = store
156            .code_repository_set_cross_edges(status.repository_set.set_id.clone())
157            .await
158            .map_err(storage_api_error)?;
159        let mut results = Vec::new();
160        let candidate_limit = per_member_candidate_limit(request.limit);
161        for member_status in &status.members {
162            let member = &member_status.member;
163            let selector = CodeRepositorySelector::new(
164                member.repository_alias.clone(),
165                member.resolved_commit_sha.clone(),
166                request.path_filters.clone(),
167                request.language_filters.clone(),
168            )
169            .map_err(|error| ApiError::invalid_argument(error.to_string()))?;
170            let search_request = CodeRetrievalRequest::new(
171                request.query.clone(),
172                selector,
173                request.code_query_kind,
174                candidate_limit,
175                FreshnessPolicy::AllowStale,
176            )
177            .map_err(|error| ApiError::invalid_argument(error.to_string()))?;
178            let hits = store
179                .search_code_scope(member.source_scope.clone(), search_request)
180                .await
181                .map_err(storage_api_error)?;
182            for hit in hits {
183                let overlay_evidence = overlay_evidence_for_hit(&edges, &hit);
184                let score = repository_set_score(&hit, member_status, &overlay_evidence);
185                results.push(CodeRepositorySetQueryHit {
186                    member: member.clone(),
187                    hit,
188                    overlay_evidence,
189                    score,
190                });
191            }
192        }
193        let truncated = dedupe_sort_truncate(&mut results, request.limit);
194        let degraded_reason = status.degraded_reason.clone().or_else(|| {
195            status
196                .overlay
197                .stale
198                .then(|| "repository set overlay is stale".to_owned())
199        });
200
201        Ok(CodeRepositorySetQueryResponse {
202            metadata: ApiMetadata::graph_only(&context, graph_version),
203            request,
204            status,
205            results,
206            truncated,
207            degraded_reason,
208        })
209    }
210
211    /// Returns repository-set freshness and member diagnostics.
212    pub async fn code_repository_set_status(
213        &self,
214        set_alias: String,
215        context: RequestContext,
216    ) -> Result<CodeRepositorySetStatusResponse, ApiError> {
217        let store = self.store().await.map_err(storage_api_error)?;
218        let status = required_set_status(&store, &set_alias).await?;
219        let graph_version = store
220            .current_graph_version()
221            .await
222            .map_err(storage_api_error)?;
223
224        Ok(CodeRepositorySetStatusResponse {
225            metadata: ApiMetadata::graph_only(&context, graph_version),
226            status,
227        })
228    }
229
230    /// Rebuilds cross-repository import/module overlay edges.
231    pub async fn refresh_code_repository_set(
232        &self,
233        set_alias: String,
234        context: RequestContext,
235    ) -> Result<CodeRepositorySetRefreshResponse, ApiError> {
236        let store = self.store().await.map_err(storage_api_error)?;
237        let summary = store
238            .refresh_code_repository_set_overlay(set_alias.clone(), now_millis())
239            .await
240            .map_err(storage_api_error)?;
241        let status = required_set_status(&store, &set_alias).await?;
242        let graph_version = store
243            .current_graph_version()
244            .await
245            .map_err(storage_api_error)?;
246
247        Ok(CodeRepositorySetRefreshResponse {
248            metadata: ApiMetadata::graph_only(&context, graph_version),
249            status,
250            summary: Some(summary),
251            task: None,
252        })
253    }
254
255    /// Queues a repository-set overlay refresh task.
256    pub async fn start_code_repository_set_refresh(
257        &self,
258        set_alias: String,
259        context: RequestContext,
260    ) -> Result<CodeRepositorySetRefreshResponse, ApiError> {
261        let store = self.store().await.map_err(storage_api_error)?;
262        let status = required_set_status(&store, &set_alias).await?;
263        let fingerprint = repository_set_refresh_fingerprint(&status);
264        let task = store
265            .queue_code_repository_set_refresh_task(CodeRepositorySetRefreshTaskSeed {
266                set_id: status.repository_set.set_id.clone(),
267                set_alias: status.repository_set.alias.clone(),
268                input_fingerprint: fingerprint,
269                now_ms: now_millis(),
270            })
271            .await
272            .map_err(storage_api_error)?;
273        let graph_version = store
274            .current_graph_version()
275            .await
276            .map_err(storage_api_error)?;
277
278        Ok(CodeRepositorySetRefreshResponse {
279            metadata: ApiMetadata::graph_only(&context, graph_version),
280            status,
281            summary: None,
282            task: Some(task),
283        })
284    }
285
286    /// Runs one queued repository-set overlay refresh task under a lease.
287    pub async fn run_code_repository_set_refresh_task_once(
288        &self,
289        task_id: Option<String>,
290        context: RequestContext,
291    ) -> Result<Option<crate::domain::CodeRepositorySetRefreshTaskRecord>, ApiError> {
292        let store = self.store().await.map_err(storage_api_error)?;
293        let lease_owner = format!("code-repository-set-refresh-worker-{}", std::process::id());
294        let Some(task) = store
295            .claim_code_repository_set_refresh_task(CodeRepositorySetRefreshTaskClaimRequest {
296                task_id,
297                lease_owner: lease_owner.clone(),
298                lease_duration_ms: REPOSITORY_SET_REFRESH_TASK_LEASE_MS,
299                max_attempts: REPOSITORY_SET_REFRESH_TASK_MAX_ATTEMPTS,
300                now_ms: now_millis(),
301            })
302            .await
303            .map_err(storage_api_error)?
304        else {
305            return Ok(None);
306        };
307        let result = self
308            .refresh_code_repository_set(task.set_alias.clone(), context)
309            .await;
310        match result {
311            Ok(_) => store
312                .complete_code_repository_set_refresh_task(CodeRepositorySetRefreshTaskCompletion {
313                    task_id: task.task_id,
314                    lease_owner,
315                    attempt_count: task.attempt_count,
316                    now_ms: now_millis(),
317                })
318                .await
319                .map(Some)
320                .map_err(storage_api_error),
321            Err(error) => {
322                let _ = store
323                    .fail_code_repository_set_refresh_task(CodeRepositorySetRefreshTaskFailure {
324                        task_id: task.task_id,
325                        lease_owner,
326                        attempt_count: task.attempt_count,
327                        error_kind: "repository_set_overlay".to_owned(),
328                        error_message: error.message.clone(),
329                        retry_backoff_ms: REPOSITORY_SET_REFRESH_TASK_RETRY_BACKOFF_MS,
330                        max_attempts: REPOSITORY_SET_REFRESH_TASK_MAX_ATTEMPTS,
331                        now_ms: now_millis(),
332                    })
333                    .await;
334                Err(error)
335            }
336        }
337    }
338
339    pub(crate) async fn code_repository_set_member_scopes(
340        &self,
341        set_alias: String,
342    ) -> Result<Option<Vec<(String, String)>>, ApiError> {
343        let store = self.store().await.map_err(storage_api_error)?;
344        store
345            .code_repository_set_status(set_alias)
346            .await
347            .map(|status| {
348                status.map(|status| {
349                    status
350                        .members
351                        .into_iter()
352                        .map(|member| (member.member.repository_alias, member.member.source_scope))
353                        .collect()
354                })
355            })
356            .map_err(storage_api_error)
357    }
358}
359
360pub(super) async fn required_set_status(
361    store: &std::sync::Arc<dyn crate::storage::KnowledgeStore>,
362    set_alias: &str,
363) -> Result<CodeRepositorySetStatus, ApiError> {
364    let mut status = store
365        .code_repository_set_status(set_alias.to_owned())
366        .await
367        .map_err(storage_api_error)?
368        .ok_or_else(|| {
369            ApiError::invalid_argument(format!(
370                "code repository set '{set_alias}' is not registered"
371            ))
372        })?;
373    refresh_moving_member_freshness(store, &mut status).await?;
374    refresh_repository_set_freshness(&mut status);
375
376    Ok(status)
377}
378
379async fn refresh_moving_member_freshness(
380    store: &std::sync::Arc<dyn crate::storage::KnowledgeStore>,
381    status: &mut CodeRepositorySetStatus,
382) -> Result<(), ApiError> {
383    for index in 0..status.members.len() {
384        let member = status.members[index].member.clone();
385        let Some(reason) = moving_member_stale_reason(store, &member).await? else {
386            continue;
387        };
388        status.members[index].stale = true;
389        status.members[index].freshness_state = "stale".to_owned();
390        status.members[index].degraded_reason = Some(reason);
391    }
392
393    Ok(())
394}
395
396async fn moving_member_stale_reason(
397    store: &std::sync::Arc<dyn crate::storage::KnowledgeStore>,
398    member: &crate::domain::CodeRepositorySetMember,
399) -> Result<Option<String>, ApiError> {
400    if !member_ref_tracks_repository(&member.ref_selector, &member.resolved_commit_sha) {
401        return Ok(None);
402    }
403    let repository = store
404        .code_repository_status(member.repository_id.clone())
405        .await
406        .map_err(storage_api_error)?
407        .ok_or_else(|| {
408            ApiError::invalid_argument(format!(
409                "code repository '{}' is not registered",
410                member.repository_alias
411            ))
412        })?;
413    let root_path = PathBuf::from(repository.root_path);
414    let ref_selector = member.ref_selector.clone();
415    let resolved =
416        tokio::task::spawn_blocking(move || resolve_repository_snapshot(root_path, &ref_selector))
417            .await
418            .map_err(|error| ApiError::storage_unavailable(error.to_string()))?;
419
420    match resolved {
421        Ok((current_commit, _)) if current_commit == member.resolved_commit_sha => Ok(None),
422        Ok((current_commit, _)) => Ok(Some(format!(
423            "repository set member '{}' ref '{}' now resolves to {}, not stored snapshot {}",
424            member.repository_alias,
425            member.ref_selector,
426            current_commit,
427            member.resolved_commit_sha
428        ))),
429        Err(error) => Ok(Some(format!(
430            "repository set member '{}' ref '{}' could not be resolved: {error}",
431            member.repository_alias, member.ref_selector
432        ))),
433    }
434}
435
436fn member_ref_tracks_repository(ref_selector: &str, resolved_commit_sha: &str) -> bool {
437    let ref_selector = ref_selector.trim();
438    !(ref_selector == resolved_commit_sha
439        || (is_git_oid_prefix(ref_selector) && resolved_commit_sha.starts_with(ref_selector)))
440}
441
442fn is_git_oid_prefix(value: &str) -> bool {
443    (7..=64).contains(&value.len()) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
444}
445
446fn refresh_repository_set_freshness(status: &mut CodeRepositorySetStatus) {
447    let member_stale = status.members.iter().any(|member| member.stale);
448    if member_stale && !status.overlay.stale {
449        status.overlay.stale = true;
450        status.overlay.state = "overlay_stale".to_owned();
451    }
452    status.freshness_state = if status.members.is_empty() {
453        "incomplete"
454    } else if member_stale {
455        "stale"
456    } else if status.overlay.stale {
457        "overlay_stale"
458    } else {
459        "fresh"
460    }
461    .to_owned();
462    status.degraded_reason = status
463        .members
464        .iter()
465        .find_map(|member| member.degraded_reason.clone())
466        .or_else(|| status.overlay.degraded_reason.clone());
467}
468
469fn unfresh_set_error_for_wait_policy(
470    request: &CodeRepositorySetQueryRequest,
471    status: &CodeRepositorySetStatus,
472) -> Option<ApiError> {
473    if request.freshness_policy != FreshnessPolicy::WaitUntilFresh {
474        return None;
475    }
476    if status.members.is_empty() {
477        return Some(ApiError::invalid_argument(format!(
478            "code repository set '{}' has no members",
479            status.repository_set.alias
480        )));
481    }
482    if let Some(member) = status.members.iter().find(|member| member.stale) {
483        return Some(ApiError::invalid_argument(format!(
484            "code repository set '{}' member '{}' scope '{}' is stale",
485            status.repository_set.alias, member.member.repository_alias, member.member.source_scope
486        )));
487    }
488    if status.overlay.stale {
489        return Some(ApiError::invalid_argument(format!(
490            "code repository set '{}' overlay is stale; run repo-set refresh before querying with wait_until_fresh",
491            status.repository_set.alias
492        )));
493    }
494
495    None
496}
497
498fn per_member_candidate_limit(limit: usize) -> usize {
499    std::cmp::min(
500        50,
501        std::cmp::max(limit.saturating_mul(3), limit.saturating_add(5)),
502    )
503}
504
505fn repository_set_score(
506    hit: &CodeRetrievalHit,
507    member: &CodeRepositorySetMemberStatus,
508    overlay_evidence: &[crate::domain::CodeRepositoryCrossEdge],
509) -> f64 {
510    let priority_bonus = f64::from(member.member.priority) * 0.01;
511    let freshness_penalty = if hit.stale || member.stale { 0.5 } else { 0.0 };
512    let edge_bonus = overlay_evidence
513        .iter()
514        .map(|edge| f64::from(edge.confidence_basis_points) / 10_000.0)
515        .fold(0.0, f64::max);
516
517    hit.score + priority_bonus + edge_bonus - freshness_penalty
518}
519
520fn overlay_evidence_for_hit(
521    edges: &[crate::domain::CodeRepositoryCrossEdge],
522    hit: &CodeRetrievalHit,
523) -> Vec<crate::domain::CodeRepositoryCrossEdge> {
524    edges
525        .iter()
526        .filter(|edge| edge_originates_from_hit(edge, hit) || edge_targets_hit(edge, hit))
527        .take(5)
528        .cloned()
529        .collect()
530}
531
532fn edge_originates_from_hit(
533    edge: &crate::domain::CodeRepositoryCrossEdge,
534    hit: &CodeRetrievalHit,
535) -> bool {
536    if edge.from_source_scope != hit.scope_id
537        || edge.from_record_kind != "module_reference"
538        || hit.edge_kind.as_deref() != Some("import")
539    {
540        return false;
541    }
542
543    evidence_origin(edge.evidence_json.as_str()).is_some_and(|(path, line_start, line_end)| {
544        path == hit.path && line_start == hit.line_range.start && line_end == hit.line_range.end
545    })
546}
547
548fn edge_targets_hit(edge: &crate::domain::CodeRepositoryCrossEdge, hit: &CodeRetrievalHit) -> bool {
549    if edge.to_source_scope.as_deref() != Some(hit.scope_id.as_str()) {
550        return false;
551    }
552
553    match edge.to_record_kind.as_str() {
554        "code_symbol_snapshot" => edge
555            .to_record_id
556            .as_deref()
557            .is_some_and(|target| hit.symbol_snapshot_id.as_deref() == Some(target)),
558        "code_file" => edge
559            .to_record_id
560            .as_deref()
561            .is_some_and(|target| hit.file_id.as_deref() == Some(target)),
562        _ => false,
563    }
564}
565
566fn evidence_origin(evidence_json: &str) -> Option<(String, u32, u32)> {
567    serde_json::from_str::<serde_json::Value>(evidence_json)
568        .ok()
569        .and_then(|value| {
570            let path = value
571                .get("from_path")
572                .and_then(|path| path.as_str())
573                .map(str::to_owned)?;
574            let line_start = value
575                .get("from_line_start")
576                .and_then(|line| line.as_u64())
577                .and_then(|line| u32::try_from(line).ok())?;
578            let line_end = value
579                .get("from_line_end")
580                .and_then(|line| line.as_u64())
581                .and_then(|line| u32::try_from(line).ok())?;
582
583            Some((path, line_start, line_end))
584        })
585}
586
587fn dedupe_sort_truncate(results: &mut Vec<CodeRepositorySetQueryHit>, limit: usize) -> bool {
588    let mut best =
589        BTreeMap::<(String, String, String, u32, u32, String), CodeRepositorySetQueryHit>::new();
590    for result in results.drain(..) {
591        let key = (
592            result.hit.repository_id.clone(),
593            result.hit.scope_id.clone(),
594            result.hit.path.clone(),
595            result.hit.line_range.start,
596            result.hit.line_range.end,
597            result.hit.excerpt.clone(),
598        );
599        match best.get(&key) {
600            Some(existing) if existing.score >= result.score => {}
601            _ => {
602                best.insert(key, result);
603            }
604        }
605    }
606    results.extend(best.into_values());
607    results.sort_by(|left, right| {
608        right
609            .score
610            .total_cmp(&left.score)
611            .then_with(|| {
612                left.member
613                    .repository_alias
614                    .cmp(&right.member.repository_alias)
615            })
616            .then_with(|| left.hit.path.cmp(&right.hit.path))
617            .then_with(|| left.hit.line_range.start.cmp(&right.hit.line_range.start))
618    });
619    let truncated = results.len() > limit;
620    results.truncate(limit);
621    truncated
622}
623
624fn repository_set_refresh_fingerprint(status: &CodeRepositorySetStatus) -> String {
625    let mut parts = vec![status.repository_set.set_id.clone()];
626    parts.extend(status.members.iter().map(|member| {
627        format!(
628            "{}:{}:{}:{}:{}",
629            member.member.repository_id,
630            member.member.source_scope,
631            member.member.resolved_commit_sha,
632            member.tree_hash,
633            member.stale
634        )
635    }));
636    parts.join("|")
637}
638
639fn merged_filters(left: &[String], right: &[String]) -> Vec<String> {
640    let mut merged = Vec::new();
641    for value in left.iter().chain(right.iter()) {
642        if !merged.contains(value) {
643            merged.push(value.clone());
644        }
645    }
646
647    merged
648}
649
650fn now_millis() -> u64 {
651    std::time::SystemTime::now()
652        .duration_since(std::time::UNIX_EPOCH)
653        .map_or(0, |duration| {
654            u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
655        })
656}
657
658async fn run_blocking_code<T, F>(operation: F) -> Result<T, ApiError>
659where
660    T: Send + 'static,
661    F: FnOnce() -> Result<T, CodeIndexError> + Send + 'static,
662{
663    tokio::task::spawn_blocking(operation)
664        .await
665        .map_err(|error| ApiError::storage_unavailable(error.to_string()))?
666        .map_err(code_api_error)
667}
668
669fn code_api_error(error: CodeIndexError) -> ApiError {
670    match error {
671        CodeIndexError::InvalidInput(message) => ApiError::invalid_argument(message),
672        CodeIndexError::Git { .. } | CodeIndexError::Io(_) | CodeIndexError::TreeSitter(_) => {
673            ApiError::storage_unavailable(error.to_string())
674        }
675    }
676}
677
678pub(super) fn storage_api_error(error: StorageError) -> ApiError {
679    match error {
680        StorageError::InvalidInput(message) => ApiError::invalid_argument(message),
681        other => ApiError::storage_unavailable(other.to_string()),
682    }
683}
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688    use crate::{
689        api::ErrorKind,
690        domain::{
691            CodeRepositoryCrossEdge, CodeRepositorySet, CodeRepositorySetMember,
692            CodeRepositorySetOverlayStatus, CodeRetrievalLayer, RepositoryCodeRange,
693        },
694        storage::SqliteGraphStore,
695    };
696    use std::sync::Arc;
697
698    #[test]
699    fn helper_policy_reports_wait_until_fresh_blockers() {
700        let request = CodeRepositorySetQueryRequest::new(
701            "workspace",
702            "serve",
703            crate::domain::CodeQueryKind::Definition,
704            5,
705            FreshnessPolicy::WaitUntilFresh,
706            Vec::new(),
707            Vec::new(),
708        )
709        .expect("request should validate");
710        let empty = status_with_members(Vec::new(), overlay(true));
711        assert!(
712            unfresh_set_error_for_wait_policy(&request, &empty)
713                .expect("empty set should block")
714                .message
715                .contains("has no members")
716        );
717
718        let mut stale_member = member_status("app", "scope-app", 0);
719        stale_member.stale = true;
720        let stale_status = status_with_members(vec![stale_member], overlay(false));
721        assert!(
722            unfresh_set_error_for_wait_policy(&request, &stale_status)
723                .expect("stale member should block")
724                .message
725                .contains("member 'app'")
726        );
727
728        let overlay_status =
729            status_with_members(vec![member_status("app", "scope-app", 0)], overlay(true));
730        assert!(
731            unfresh_set_error_for_wait_policy(&request, &overlay_status)
732                .expect("stale overlay should block")
733                .message
734                .contains("overlay is stale")
735        );
736
737        let allow_stale = CodeRepositorySetQueryRequest::new(
738            "workspace",
739            "serve",
740            crate::domain::CodeQueryKind::Definition,
741            5,
742            FreshnessPolicy::AllowStale,
743            Vec::new(),
744            Vec::new(),
745        )
746        .expect("request should validate");
747        assert!(unfresh_set_error_for_wait_policy(&allow_stale, &overlay_status).is_none());
748    }
749
750    #[test]
751    fn helper_ranking_dedupes_and_attaches_overlay_evidence() {
752        let member = member_status("app", "scope-app", 7);
753        let base_hit = hit("repo-a", "scope-app", "src/client.rs", 1, 0.75, false);
754        let inbound = edge(
755            "edge-in",
756            "scope-service",
757            Some("scope-app"),
758            r#"{"from_path":"src/service.rs"}"#,
759            9_000,
760        );
761        let outbound = edge(
762            "edge-out",
763            "scope-app",
764            Some("scope-service"),
765            r#"{"from_path":"src/client.rs","from_line_start":1,"from_line_end":1}"#,
766            6_000,
767        );
768        let unrelated = edge(
769            "edge-other",
770            "scope-other",
771            Some("scope-service"),
772            r#"{"from_path":"src/other.rs"}"#,
773            10_000,
774        );
775        let mut wrong_target = edge(
776            "edge-wrong",
777            "scope-service",
778            Some("scope-app"),
779            "{}",
780            8_000,
781        );
782        wrong_target.to_record_id = Some("symbol-other".to_owned());
783        let evidence = overlay_evidence_for_hit(
784            &[inbound.clone(), outbound.clone(), unrelated, wrong_target],
785            &base_hit,
786        );
787        assert_eq!(evidence, vec![inbound]);
788        let mut import_hit = hit("repo-a", "scope-app", "src/client.rs", 1, 0.75, false);
789        import_hit.symbol_snapshot_id = None;
790        import_hit.retrieval_layers = vec![CodeRetrievalLayer::ImportGraph];
791        import_hit.edge_kind = Some("import".to_owned());
792        let outbound_evidence =
793            overlay_evidence_for_hit(std::slice::from_ref(&outbound), &import_hit);
794        assert_eq!(outbound_evidence, vec![outbound]);
795        assert!(repository_set_score(&base_hit, &member, &evidence) > base_hit.score);
796        assert!(
797            repository_set_score(
798                &hit("repo-a", "scope-app", "src/client.rs", 1, 0.75, true),
799                &member,
800                &[]
801            ) < base_hit.score
802        );
803
804        let mut results = vec![
805            CodeRepositorySetQueryHit {
806                member: member.member.clone(),
807                hit: hit("repo-a", "scope-app", "src/client.rs", 1, 0.50, false),
808                overlay_evidence: Vec::new(),
809                score: 0.50,
810            },
811            CodeRepositorySetQueryHit {
812                member: member.member.clone(),
813                hit: hit("repo-a", "scope-app", "src/client.rs", 1, 0.90, false),
814                overlay_evidence: evidence,
815                score: 0.90,
816            },
817            CodeRepositorySetQueryHit {
818                member: member.member.clone(),
819                hit: hit("repo-a", "scope-app", "src/client.rs", 2, 0.80, false),
820                overlay_evidence: Vec::new(),
821                score: 0.80,
822            },
823        ];
824        assert!(dedupe_sort_truncate(&mut results, 1));
825        assert_eq!(results.len(), 1);
826        assert_eq!(results[0].score, 0.90);
827
828        assert_eq!(per_member_candidate_limit(1), 6);
829        assert_eq!(per_member_candidate_limit(20), 50);
830        assert_eq!(
831            merged_filters(&["src".to_owned()], &["src".to_owned(), "tests".to_owned()]),
832            ["src".to_owned(), "tests".to_owned()]
833        );
834        assert!(evidence_origin("not-json").is_none());
835        assert!(evidence_origin("{}").is_none());
836    }
837
838    #[test]
839    fn helper_fingerprint_and_error_mapping_are_stable() {
840        let status = status_with_members(
841            vec![
842                member_status("app", "scope-app", 1),
843                member_status("svc", "scope-svc", 0),
844            ],
845            overlay(false),
846        );
847        let fingerprint = repository_set_refresh_fingerprint(&status);
848        assert!(fingerprint.contains("set-workspace"));
849        assert!(fingerprint.contains("repo-app:scope-app:commit-scope-app:tree-scope-app:false"));
850        assert_eq!(
851            code_api_error(CodeIndexError::InvalidInput("bad ref".to_owned())).error_kind,
852            ErrorKind::InvalidArgument
853        );
854        assert_eq!(
855            code_api_error(CodeIndexError::Io(std::io::Error::other("disk"))).error_kind,
856            ErrorKind::StorageUnavailable
857        );
858        assert_eq!(
859            storage_api_error(StorageError::InvalidInput("bad storage".to_owned())).error_kind,
860            ErrorKind::InvalidArgument
861        );
862    }
863
864    #[tokio::test]
865    async fn helper_required_status_reports_missing_sets() {
866        let store: Arc<dyn crate::storage::KnowledgeStore> =
867            Arc::new(SqliteGraphStore::open_in_memory().expect("store should open"));
868        let error = required_set_status(&store, "missing")
869            .await
870            .expect_err("missing set should fail");
871
872        assert_eq!(error.error_kind, ErrorKind::InvalidArgument);
873        assert!(error.message.contains("is not registered"));
874    }
875
876    fn status_with_members(
877        members: Vec<CodeRepositorySetMemberStatus>,
878        overlay: CodeRepositorySetOverlayStatus,
879    ) -> CodeRepositorySetStatus {
880        CodeRepositorySetStatus {
881            repository_set: CodeRepositorySet {
882                set_id: "set-workspace".to_owned(),
883                alias: "workspace".to_owned(),
884                description: None,
885                default_ref_policy_json: "{\"default_ref\":\"HEAD\"}".to_owned(),
886                created_at_ms: 1,
887                updated_at_ms: 1,
888            },
889            members,
890            overlay,
891            freshness_state: "fresh".to_owned(),
892            degraded_reason: None,
893        }
894    }
895
896    fn member_status(
897        repository_alias: &str,
898        source_scope: &str,
899        priority: i32,
900    ) -> CodeRepositorySetMemberStatus {
901        CodeRepositorySetMemberStatus {
902            member: CodeRepositorySetMember {
903                set_id: "set-workspace".to_owned(),
904                repository_id: format!("repo-{repository_alias}"),
905                repository_alias: repository_alias.to_owned(),
906                ref_selector: "HEAD".to_owned(),
907                resolved_commit_sha: format!("commit-{source_scope}"),
908                source_scope: source_scope.to_owned(),
909                path_filters: vec!["src".to_owned()],
910                language_filters: vec!["rust".to_owned()],
911                priority,
912            },
913            tree_hash: format!("tree-{source_scope}"),
914            freshness_state: "fresh".to_owned(),
915            stale: false,
916            indexed_file_count: 1,
917            symbol_count: 1,
918            reference_count: 0,
919            chunk_count: 1,
920            degraded_reason: None,
921        }
922    }
923
924    fn overlay(stale: bool) -> CodeRepositorySetOverlayStatus {
925        CodeRepositorySetOverlayStatus {
926            state: if stale { "overlay_stale" } else { "fresh" }.to_owned(),
927            stale,
928            edge_count: usize::from(!stale),
929            refreshed_at_ms: (!stale).then_some(10),
930            degraded_reason: None,
931        }
932    }
933
934    fn hit(
935        repository_id: &str,
936        scope_id: &str,
937        path: &str,
938        line: u32,
939        score: f64,
940        stale: bool,
941    ) -> CodeRetrievalHit {
942        CodeRetrievalHit {
943            repository_id: repository_id.to_owned(),
944            scope_id: scope_id.to_owned(),
945            resolved_commit_sha: format!("commit-{scope_id}"),
946            tree_hash: format!("tree-{scope_id}"),
947            path: path.to_owned(),
948            language_id: "rust".to_owned(),
949            byte_range: RepositoryCodeRange { start: 0, end: 10 },
950            line_range: RepositoryCodeRange {
951                start: line,
952                end: line,
953            },
954            symbol_snapshot_id: Some(format!("symbol-{line}")),
955            canonical_symbol_id: None,
956            file_id: Some("file-1".to_owned()),
957            retrieval_layers: vec![CodeRetrievalLayer::Symbol],
958            index_versions: vec!["code:1".to_owned()],
959            stale,
960            degraded_reason: None,
961            edge_kind: None,
962            edge_resolution_state: None,
963            edge_target_hint: None,
964            edge_confidence_basis_points: None,
965            edge_confidence_tier: None,
966            score,
967            excerpt: format!("excerpt {line}"),
968        }
969    }
970
971    fn edge(
972        edge_id: &str,
973        from_scope: &str,
974        to_scope: Option<&str>,
975        evidence_json: &str,
976        confidence: u16,
977    ) -> CodeRepositoryCrossEdge {
978        CodeRepositoryCrossEdge {
979            edge_id: edge_id.to_owned(),
980            set_id: "set-workspace".to_owned(),
981            from_source_scope: from_scope.to_owned(),
982            from_repository_id: "repo-from".to_owned(),
983            from_record_kind: "module_reference".to_owned(),
984            from_record_id: "import-1".to_owned(),
985            to_source_scope: to_scope.map(str::to_owned),
986            to_repository_id: to_scope.map(|_| "repo-to".to_owned()),
987            to_record_kind: "code_symbol_snapshot".to_owned(),
988            to_record_id: to_scope.map(|_| "symbol-1".to_owned()),
989            edge_kind: "imports".to_owned(),
990            resolution_state: "resolved".to_owned(),
991            confidence_basis_points: confidence,
992            confidence_tier: "explicit".to_owned(),
993            evidence_json: evidence_json.to_owned(),
994            created_at_ms: 10,
995        }
996    }
997}