1use std::path::PathBuf;
2
3use code_system_graph_model::RepoId;
4use serde::{Deserialize, Serialize};
5use tokio_util::sync::CancellationToken;
6
7use crate::{
8 AffectedTestsRequest, LocalCodeIntelligenceProvider, ProviderBudget, ProviderCapability, ProviderRequest, ProviderStatus, ResolveSymbolsRequest
9};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct SymbolAnchor {
14 pub symbol: String,
16 pub source_path: String,
18 pub start_line: usize,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case", tag = "status")]
25pub enum SymbolCorroboration {
26 Confirmed {
28 symbol: String,
30 source_path: String,
32 local_id: Option<String>,
34 },
35 Unresolved {
37 symbol: String,
39 reason: String,
41 },
42 Ambiguous {
44 symbol: String,
46 candidate_count: usize,
48 },
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct CorroborationReport {
54 #[serde(default)]
56 pub capability: Option<ProviderCapability>,
57 pub symbols: Vec<SymbolCorroboration>,
59 pub affected_tests: Vec<String>,
61 pub degradations: Vec<String>,
63}
64
65pub async fn corroborate_repository(
70 provider: &dyn LocalCodeIntelligenceProvider,
71 repo_id: RepoId,
72 project_path: PathBuf,
73 anchors: &[SymbolAnchor],
74 changed_files: &[String],
75 budget: ProviderBudget,
76 cancellation: CancellationToken,
77) -> CorroborationReport {
78 let mut symbols = Vec::with_capacity(anchors.len());
79 let mut degradations = Vec::new();
80 let capability_result = provider
81 .probe(ProviderRequest {
82 repo_id: repo_id.clone(),
83 project_path: project_path.clone(),
84 budget,
85 cancellation: cancellation.clone(),
86 })
87 .await;
88 let capability = match capability_result {
89 Ok(capability) => Some(capability),
90 Err(error) => {
91 degradations.push(error.to_string());
92 None
93 }
94 };
95 if capability
96 .as_ref()
97 .is_none_or(|capability| capability.status != ProviderStatus::Available)
98 {
99 if let Some(capability) = &capability {
100 degradations.extend(
101 capability
102 .degradations
103 .iter()
104 .map(|degradation| degradation.message.clone()),
105 );
106 degradations.push(format!(
107 "{} local intelligence status is {:?}",
108 capability.provider, capability.status
109 ));
110 }
111 symbols.extend(
112 anchors
113 .iter()
114 .map(|anchor| SymbolCorroboration::Unresolved {
115 symbol: anchor.symbol.clone(),
116 reason: "local intelligence provider is not available".to_owned(),
117 }),
118 );
119 degradations.sort();
120 degradations.dedup();
121 return CorroborationReport {
122 capability,
123 symbols,
124 affected_tests: Vec::new(),
125 degradations,
126 };
127 }
128 for anchor in anchors {
129 let (outcome, observed_degradations) = corroborate_symbol(
130 provider,
131 &repo_id,
132 &project_path,
133 anchor,
134 budget,
135 &cancellation,
136 )
137 .await;
138 symbols.push(outcome);
139 degradations.extend(observed_degradations);
140 }
141
142 let (affected_tests, affected_degradations) = corroborate_affected_tests(
143 provider,
144 repo_id,
145 project_path,
146 changed_files,
147 budget,
148 cancellation,
149 )
150 .await;
151 degradations.extend(affected_degradations);
152 CorroborationReport {
153 capability,
154 symbols,
155 affected_tests,
156 degradations,
157 }
158}
159
160async fn corroborate_symbol(
161 provider: &dyn LocalCodeIntelligenceProvider,
162 repo_id: &RepoId,
163 project_path: &std::path::Path,
164 anchor: &SymbolAnchor,
165 budget: ProviderBudget,
166 cancellation: &CancellationToken,
167) -> (SymbolCorroboration, Vec<String>) {
168 let request = ProviderRequest {
169 repo_id: repo_id.clone(),
170 project_path: project_path.to_path_buf(),
171 budget,
172 cancellation: cancellation.clone(),
173 };
174 let result = provider
175 .resolve_symbols(ResolveSymbolsRequest {
176 request,
177 query: anchor.symbol.clone(),
178 })
179 .await;
180 let Ok(result) = result else {
181 let error = result.err().map_or_else(
182 || "local intelligence unavailable".to_owned(),
183 |error| error.to_string(),
184 );
185 return (
186 SymbolCorroboration::Unresolved {
187 symbol: anchor.symbol.clone(),
188 reason: "local intelligence unavailable".to_owned(),
189 },
190 vec![error],
191 );
192 };
193 let exact = result
194 .symbols
195 .into_iter()
196 .filter(|candidate| {
197 symbol_name_matches(candidate, &anchor.symbol)
198 && portable_path(&candidate.file_path) == portable_path(&anchor.source_path)
199 && candidate.start_line == anchor.start_line
200 })
201 .collect::<Vec<_>>();
202 let outcome = match exact.as_slice() {
203 [candidate] => SymbolCorroboration::Confirmed {
204 symbol: anchor.symbol.clone(),
205 source_path: anchor.source_path.clone(),
206 local_id: candidate.local_id.clone(),
207 },
208 [] => SymbolCorroboration::Unresolved {
209 symbol: anchor.symbol.clone(),
210 reason: "provider returned no exact name/path/line match".to_owned(),
211 },
212 candidates => SymbolCorroboration::Ambiguous {
213 symbol: anchor.symbol.clone(),
214 candidate_count: candidates.len(),
215 },
216 };
217 let degradations = result
218 .execution
219 .degradations
220 .into_iter()
221 .map(|degradation| degradation.message)
222 .collect();
223 (outcome, degradations)
224}
225
226async fn corroborate_affected_tests(
227 provider: &dyn LocalCodeIntelligenceProvider,
228 repo_id: RepoId,
229 project_path: PathBuf,
230 changed_files: &[String],
231 budget: ProviderBudget,
232 cancellation: CancellationToken,
233) -> (Vec<String>, Vec<String>) {
234 if changed_files.is_empty() {
235 return (Vec::new(), Vec::new());
236 }
237 let request = ProviderRequest {
238 repo_id,
239 project_path,
240 budget,
241 cancellation,
242 };
243 match provider
244 .get_affected_tests(AffectedTestsRequest {
245 request,
246 changed_files: changed_files.to_vec(),
247 max_depth: 4,
248 })
249 .await
250 {
251 Ok(Some(mut result)) => {
252 result.affected_tests.sort();
253 result.affected_tests.dedup();
254 let degradations = result
255 .execution
256 .degradations
257 .into_iter()
258 .map(|degradation| degradation.message)
259 .collect();
260 (result.affected_tests, degradations)
261 }
262 Ok(None) => (
263 Vec::new(),
264 vec!["affected-test capability unavailable".to_owned()],
265 ),
266 Err(error) => (Vec::new(), vec![error.to_string()]),
267 }
268}
269
270fn symbol_name_matches(candidate: &crate::ResolvedSymbol, anchor: &str) -> bool {
271 candidate.name == anchor || candidate.qualified_name.as_deref() == Some(anchor)
272}
273
274fn portable_path(path: &str) -> String {
275 path.replace('\\', "/")
276}
277
278#[cfg(test)]
279mod tests {
280 use std::sync::atomic::{AtomicUsize, Ordering};
281
282 use async_trait::async_trait;
283 use code_system_graph_model::RepoId;
284 use tokio_util::sync::CancellationToken;
285
286 use super::{SymbolAnchor, SymbolCorroboration, corroborate_repository};
287 use crate::{
288 AffectedTestsRequest, AffectedTestsResult, LocalCodeIntelligenceProvider, LocalContextRequest, LocalContextResult, LocalImpactRequest, LocalImpactResult, LocalNeighborResult, LocalNeighborsRequest, ProviderBudget, ProviderCapability, ProviderError, ProviderExecution, ProviderStatus, ProviderTransport, ResolveSymbolsRequest, ResolveSymbolsResult, ResolvedSymbol
289 };
290
291 struct FakeProvider {
292 status: ProviderStatus,
293 operation_calls: AtomicUsize,
294 }
295
296 impl FakeProvider {
297 fn available() -> Self {
298 Self {
299 status: ProviderStatus::Available,
300 operation_calls: AtomicUsize::new(0),
301 }
302 }
303 }
304
305 #[async_trait]
306 impl LocalCodeIntelligenceProvider for FakeProvider {
307 fn provider_name(&self) -> &'static str {
308 "fake"
309 }
310
311 async fn probe(
312 &self,
313 _request: crate::ProviderRequest,
314 ) -> Result<ProviderCapability, ProviderError> {
315 Ok(ProviderCapability {
316 provider: "fake".to_owned(),
317 version: Some("1.0.0".to_owned()),
318 status: self.status,
319 tools: Vec::new(),
320 protocol_version: None,
321 operations: Vec::new(),
322 degradations: Vec::new(),
323 remediation: None,
324 })
325 }
326
327 async fn resolve_symbols(
328 &self,
329 input: ResolveSymbolsRequest,
330 ) -> Result<ResolveSymbolsResult, ProviderError> {
331 self.operation_calls.fetch_add(1, Ordering::Relaxed);
332 Ok(ResolveSymbolsResult {
333 symbols: vec![ResolvedSymbol {
334 local_id: Some("local:1".to_owned()),
335 name: input.query,
336 qualified_name: None,
337 kind: "function".to_owned(),
338 file_path: "src/routes.rs".to_owned(),
339 start_line: 10,
340 score: Some(1.0),
341 }],
342 execution: execution(),
343 })
344 }
345
346 async fn get_local_neighbors(
347 &self,
348 _input: LocalNeighborsRequest,
349 ) -> Result<LocalNeighborResult, ProviderError> {
350 unreachable!("neighbors are not used by corroboration")
351 }
352
353 async fn get_local_impact(
354 &self,
355 _input: LocalImpactRequest,
356 ) -> Result<LocalImpactResult, ProviderError> {
357 unreachable!("impact is not used by corroboration")
358 }
359
360 async fn build_local_context(
361 &self,
362 _input: LocalContextRequest,
363 ) -> Result<LocalContextResult, ProviderError> {
364 unreachable!("context is not used by corroboration")
365 }
366
367 async fn get_affected_tests(
368 &self,
369 input: AffectedTestsRequest,
370 ) -> Result<Option<AffectedTestsResult>, ProviderError> {
371 self.operation_calls.fetch_add(1, Ordering::Relaxed);
372 Ok(Some(AffectedTestsResult {
373 changed_files: input.changed_files,
374 affected_tests: vec!["tests/routes.rs".to_owned()],
375 total_dependents_traversed: 1,
376 execution: execution(),
377 }))
378 }
379
380 async fn shutdown(&self) -> Result<(), ProviderError> {
381 Ok(())
382 }
383 }
384
385 fn execution() -> ProviderExecution {
386 ProviderExecution {
387 transport: ProviderTransport::Cli,
388 output_bytes: 1,
389 truncated: false,
390 degradations: Vec::new(),
391 }
392 }
393
394 #[tokio::test]
395 async fn corroboration_should_require_exact_name_path_and_line() {
396 let provider = FakeProvider::available();
397 let report = corroborate_repository(
398 &provider,
399 RepoId::new("repo:api"),
400 "/repo".into(),
401 &[SymbolAnchor {
402 symbol: "create_order".to_owned(),
403 source_path: "src/routes.rs".to_owned(),
404 start_line: 10,
405 }],
406 &["src/routes.rs".to_owned()],
407 ProviderBudget::default(),
408 CancellationToken::new(),
409 )
410 .await;
411
412 assert!(matches!(
413 report.symbols.as_slice(),
414 [SymbolCorroboration::Confirmed { .. }]
415 ));
416 assert_eq!(report.affected_tests, vec!["tests/routes.rs"]);
417 assert_eq!(provider.operation_calls.load(Ordering::Relaxed), 2);
418 }
419
420 #[tokio::test]
421 async fn unavailable_capability_should_skip_every_focused_operation() {
422 let provider = FakeProvider {
423 status: ProviderStatus::IndexMissing,
424 operation_calls: AtomicUsize::new(0),
425 };
426 let report = corroborate_repository(
427 &provider,
428 RepoId::new("repo:api"),
429 "/repo".into(),
430 &[SymbolAnchor {
431 symbol: "create_order".to_owned(),
432 source_path: "src/routes.rs".to_owned(),
433 start_line: 10,
434 }],
435 &["src/routes.rs".to_owned()],
436 ProviderBudget::default(),
437 CancellationToken::new(),
438 )
439 .await;
440
441 assert!(matches!(
442 report.symbols.as_slice(),
443 [SymbolCorroboration::Unresolved { .. }]
444 ));
445 assert!(report.affected_tests.is_empty());
446 assert_eq!(provider.operation_calls.load(Ordering::Relaxed), 0);
447 }
448}