1mod cli;
2mod contract;
3mod mcp;
4
5use std::collections::BTreeMap;
6use std::ffi::OsString;
7use std::path::{Component, Path};
8use std::sync::Arc;
9use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
10use std::time::Duration;
11
12use async_trait::async_trait;
13use code_system_graph_model::RepoId;
14use tokio::sync::{Mutex, OwnedSemaphorePermit, Semaphore};
15
16use self::cli::CodeGraphCli;
17use self::contract::{cli_operations, map_mcp_tools, supports_cli_contract};
18use self::mcp::{CodeGraphMcp, McpProbe};
19use crate::{
20 AffectedTestsRequest, AffectedTestsResult, LocalCodeIntelligenceProvider, LocalContextRequest, LocalContextResult, LocalImpactRequest, LocalImpactResult, LocalNeighborResult, LocalNeighborsRequest, ProviderCapability, ProviderDegradation, ProviderError, ProviderRequest, ProviderStatus, ResolveSymbolsRequest, ResolveSymbolsResult
21};
22
23static DIAGNOSTIC_SEQUENCE: AtomicU64 = AtomicU64::new(1);
24const MAX_QUERY_BYTES: usize = 4096;
25const MAX_LOCAL_DEPTH: usize = 64;
26const MAX_CHANGED_FILES: usize = 1024;
27
28#[derive(Debug, Clone)]
30pub struct CodeGraphConfig {
31 pub binary: OsString,
33 pub max_concurrent_processes: usize,
35 pub circuit_breaker_cooldown: Duration,
37}
38
39impl Default for CodeGraphConfig {
40 fn default() -> Self {
41 Self {
42 binary: OsString::from("codegraph"),
43 max_concurrent_processes: 2,
44 circuit_breaker_cooldown: Duration::from_secs(30),
45 }
46 }
47}
48
49pub struct CodeGraphProvider {
51 cli: CodeGraphCli,
52 mcp: CodeGraphMcp,
53 permits: Arc<Semaphore>,
54 circuit_breaker_cooldown: Duration,
55 mcp_circuits: Mutex<BTreeMap<RepoId, tokio::time::Instant>>,
56 cli_version: Mutex<Option<String>>,
57 closed: AtomicBool,
58}
59
60struct CliProbe {
61 version: String,
62 status: ProviderStatus,
63 degradations: Vec<ProviderDegradation>,
64}
65
66enum CliProbeOutcome {
67 Ready(CliProbe),
68 Degraded(ProviderCapability),
69}
70
71impl CodeGraphProvider {
72 pub fn new(config: CodeGraphConfig) -> Result<Self, ProviderError> {
78 if config.max_concurrent_processes == 0 {
79 return Err(ProviderError::InvalidRequest(
80 "max_concurrent_processes must be greater than zero".to_owned(),
81 ));
82 }
83 Ok(Self {
84 cli: CodeGraphCli::new(config.binary.clone()),
85 mcp: CodeGraphMcp::new(config.binary),
86 permits: Arc::new(Semaphore::new(config.max_concurrent_processes)),
87 circuit_breaker_cooldown: config.circuit_breaker_cooldown,
88 mcp_circuits: Mutex::new(BTreeMap::new()),
89 cli_version: Mutex::new(None),
90 closed: AtomicBool::new(false),
91 })
92 }
93
94 async fn enter(
95 &self,
96 request: &ProviderRequest,
97 deadline: tokio::time::Instant,
98 ) -> Result<OwnedSemaphorePermit, ProviderError> {
99 validate_request(request)?;
100 if self.closed.load(Ordering::Acquire) {
101 return Err(ProviderError::InvalidRequest(
102 "provider has already been shut down".to_owned(),
103 ));
104 }
105 tokio::select! {
106 biased;
107 () = request.cancellation.cancelled() => Err(ProviderError::Cancelled),
108 () = tokio::time::sleep_until(deadline) => Err(timeout_error()),
109 permit = self.permits.clone().acquire_owned() => permit.map_err(|_| {
110 ProviderError::InvalidRequest("provider has already been shut down".to_owned())
111 }),
112 }
113 }
114
115 async fn compatible_cli_version(
116 &self,
117 request: &mut ProviderRequest,
118 deadline: tokio::time::Instant,
119 ) -> Result<String, ProviderError> {
120 if let Some(version) = self.cli_version.lock().await.clone() {
121 if supports_cli_contract(&version) {
122 return Ok(version);
123 }
124 return Err(invalid_response(format!(
125 "CodeGraph CLI {version} has no validated structured-output adapter"
126 )));
127 }
128 update_remaining_timeout(request, deadline)?;
129 let version = self.cli.version(request).await?;
130 *self.cli_version.lock().await = Some(version.clone());
131 if !supports_cli_contract(&version) {
132 return Err(invalid_response(format!(
133 "CodeGraph CLI {version} has no validated structured-output adapter"
134 )));
135 }
136 Ok(version)
137 }
138
139 async fn mcp_is_available(&self, repo_id: &RepoId) -> bool {
140 let now = tokio::time::Instant::now();
141 let mut circuits = self.mcp_circuits.lock().await;
142 circuits.retain(|_, until| *until > now);
143 !circuits.contains_key(repo_id)
144 }
145
146 async fn open_mcp_circuit(&self, repo_id: RepoId) {
147 self.mcp_circuits.lock().await.insert(
148 repo_id,
149 tokio::time::Instant::now() + self.circuit_breaker_cooldown,
150 );
151 }
152
153 async fn probe_cli(
154 &self,
155 request: &mut ProviderRequest,
156 deadline: tokio::time::Instant,
157 ) -> Result<CliProbeOutcome, ProviderError> {
158 update_remaining_timeout(request, deadline)?;
159 let version = match self.cli.version(request).await {
160 Ok(version) => version,
161 Err(ProviderError::Cancelled) => return Err(ProviderError::Cancelled),
162 Err(error) => return Ok(CliProbeOutcome::Degraded(unavailable_capability(&error))),
163 };
164 *self.cli_version.lock().await = Some(version.clone());
165 if !supports_cli_contract(&version) {
166 return Ok(CliProbeOutcome::Ready(CliProbe {
167 status: ProviderStatus::Incompatible,
168 degradations: vec![ProviderDegradation {
169 kind: "unsupported_cli_version".to_owned(),
170 message: format!(
171 "CodeGraph CLI {version} is outside the validated structured-output matrix."
172 ),
173 diagnostics_id: None,
174 }],
175 version,
176 }));
177 }
178 update_remaining_timeout(request, deadline)?;
179 let mut degradations = Vec::new();
180 let status = match self.cli.status(request).await {
181 Ok(status) => {
182 if status
183 .version
184 .as_deref()
185 .is_some_and(|value| value != version)
186 {
187 degradations.push(ProviderDegradation {
188 kind: "version_mismatch".to_owned(),
189 message:
190 "CodeGraph version and status contracts reported different versions."
191 .to_owned(),
192 diagnostics_id: None,
193 });
194 }
195 status.status()
196 }
197 Err(error) => {
198 let status = if matches!(error, ProviderError::Timeout { .. }) {
199 ProviderStatus::TimedOut
200 } else {
201 ProviderStatus::InvalidResponse
202 };
203 degradations.push(degradation_from_error(&error));
204 status
205 }
206 };
207 Ok(CliProbeOutcome::Ready(CliProbe {
208 version,
209 status,
210 degradations,
211 }))
212 }
213
214 async fn probe_mcp(
215 &self,
216 request: &mut ProviderRequest,
217 deadline: tokio::time::Instant,
218 degradations: &mut Vec<ProviderDegradation>,
219 ) -> Result<Option<McpProbe>, ProviderError> {
220 if !self.mcp_is_available(&request.repo_id).await {
221 degradations.push(ProviderDegradation {
222 kind: "mcp_circuit_open".to_owned(),
223 message: "MCP was bypassed after a recent timeout; compatible CLI fallback remains available.".to_owned(),
224 diagnostics_id: None,
225 });
226 return Ok(None);
227 }
228 update_remaining_timeout(request, deadline)?;
229 match self.mcp.probe(request).await {
230 Ok(probe) => Ok(Some(probe)),
231 Err(ProviderError::Cancelled) => Err(ProviderError::Cancelled),
232 Err(error) => {
233 if matches!(error, ProviderError::Timeout { .. }) {
234 self.open_mcp_circuit(request.repo_id.clone()).await;
235 }
236 degradations.push(degradation_from_error(&error));
237 Ok(None)
238 }
239 }
240 }
241
242 fn assemble_capability(
243 request: &ProviderRequest,
244 mut cli_probe: CliProbe,
245 mcp_probe: Option<McpProbe>,
246 ) -> ProviderCapability {
247 if mcp_probe
248 .as_ref()
249 .and_then(|probe| probe.version.as_deref())
250 .is_some_and(|version| version != cli_probe.version)
251 {
252 cli_probe.degradations.push(ProviderDegradation {
253 kind: "mcp_cli_version_mismatch".to_owned(),
254 message: "CodeGraph MCP and CLI reported different public implementation versions."
255 .to_owned(),
256 diagnostics_id: None,
257 });
258 }
259 let mut operations = BTreeMap::new();
260 if let Some(probe) = &mcp_probe {
261 for operation in map_mcp_tools(&probe.tools) {
262 operations.insert(operation.operation, operation);
263 }
264 }
265 for operation in cli_operations(&cli_probe.version) {
266 operations.entry(operation.operation).or_insert(operation);
267 }
268 let status = if operations.is_empty() && cli_probe.status == ProviderStatus::Available {
269 ProviderStatus::Incompatible
270 } else {
271 cli_probe.status
272 };
273 ProviderCapability {
274 provider: "codegraph".to_owned(),
275 version: Some(cli_probe.version),
276 status,
277 tools: mcp_tool_names(mcp_probe.as_ref()),
278 protocol_version: mcp_probe.map(|probe| probe.protocol_version),
279 operations: operations.into_values().collect(),
280 degradations: cli_probe.degradations,
281 remediation: remediation(status, &request.project_path),
282 }
283 }
284}
285
286#[async_trait]
287impl LocalCodeIntelligenceProvider for CodeGraphProvider {
288 fn provider_name(&self) -> &'static str {
289 "codegraph"
290 }
291
292 async fn probe(
293 &self,
294 mut request: ProviderRequest,
295 ) -> Result<ProviderCapability, ProviderError> {
296 let deadline = tokio::time::Instant::now() + request.budget.timeout;
297 let _permit = self.enter(&request, deadline).await?;
298 let mut cli_probe = match self.probe_cli(&mut request, deadline).await? {
299 CliProbeOutcome::Ready(probe) => probe,
300 CliProbeOutcome::Degraded(capability) => return Ok(capability),
301 };
302 let mcp_probe = self
303 .probe_mcp(&mut request, deadline, &mut cli_probe.degradations)
304 .await?;
305 Ok(Self::assemble_capability(&request, cli_probe, mcp_probe))
306 }
307
308 async fn resolve_symbols(
309 &self,
310 mut input: ResolveSymbolsRequest,
311 ) -> Result<ResolveSymbolsResult, ProviderError> {
312 validate_query(&input.query)?;
313 let deadline = tokio::time::Instant::now() + input.request.budget.timeout;
314 let _permit = self.enter(&input.request, deadline).await?;
315 self.compatible_cli_version(&mut input.request, deadline)
316 .await?;
317 update_remaining_timeout(&mut input.request, deadline)?;
318 self.cli.resolve_symbols(&input).await
319 }
320
321 async fn get_local_neighbors(
322 &self,
323 mut input: LocalNeighborsRequest,
324 ) -> Result<LocalNeighborResult, ProviderError> {
325 validate_query(&input.symbol)?;
326 let deadline = tokio::time::Instant::now() + input.request.budget.timeout;
327 let _permit = self.enter(&input.request, deadline).await?;
328 self.compatible_cli_version(&mut input.request, deadline)
329 .await?;
330 update_remaining_timeout(&mut input.request, deadline)?;
331 self.cli.local_neighbors(&input).await
332 }
333
334 async fn get_local_impact(
335 &self,
336 mut input: LocalImpactRequest,
337 ) -> Result<LocalImpactResult, ProviderError> {
338 validate_query(&input.symbol)?;
339 validate_depth(input.max_depth)?;
340 let deadline = tokio::time::Instant::now() + input.request.budget.timeout;
341 let _permit = self.enter(&input.request, deadline).await?;
342 self.compatible_cli_version(&mut input.request, deadline)
343 .await?;
344 update_remaining_timeout(&mut input.request, deadline)?;
345 self.cli.local_impact(&input).await
346 }
347
348 async fn build_local_context(
349 &self,
350 mut input: LocalContextRequest,
351 ) -> Result<LocalContextResult, ProviderError> {
352 validate_query(&input.query)?;
353 if input.max_files == 0 || input.max_files > input.request.budget.max_items {
354 return Err(ProviderError::InvalidRequest(format!(
355 "max_files must be between 1 and max_items ({})",
356 input.request.budget.max_items
357 )));
358 }
359 let deadline = tokio::time::Instant::now() + input.request.budget.timeout;
360 let _permit = self.enter(&input.request, deadline).await?;
361 let mut degradations = Vec::new();
362 if self.mcp_is_available(&input.request.repo_id).await {
363 update_remaining_timeout(&mut input.request, deadline)?;
364 match self.mcp.local_context(&input).await {
365 Ok(result) => return Ok(result),
366 Err(ProviderError::Cancelled) => return Err(ProviderError::Cancelled),
367 Err(error) => {
368 if matches!(error, ProviderError::Timeout { .. }) {
369 self.open_mcp_circuit(input.request.repo_id.clone()).await;
370 }
371 degradations.push(degradation_from_error(&error));
372 }
373 }
374 } else {
375 degradations.push(ProviderDegradation {
376 kind: "mcp_circuit_open".to_owned(),
377 message: "MCP was bypassed after a recent timeout.".to_owned(),
378 diagnostics_id: None,
379 });
380 }
381 self.compatible_cli_version(&mut input.request, deadline)
382 .await?;
383 update_remaining_timeout(&mut input.request, deadline)?;
384 let mut result = self.cli.local_context(&input).await?;
385 result.execution.degradations = degradations;
386 Ok(result)
387 }
388
389 async fn get_affected_tests(
390 &self,
391 mut input: AffectedTestsRequest,
392 ) -> Result<Option<AffectedTestsResult>, ProviderError> {
393 validate_depth(input.max_depth)?;
394 validate_changed_files(&input.changed_files)?;
395 let deadline = tokio::time::Instant::now() + input.request.budget.timeout;
396 let _permit = self.enter(&input.request, deadline).await?;
397 match self
398 .compatible_cli_version(&mut input.request, deadline)
399 .await
400 {
401 Ok(_) => {}
402 Err(ProviderError::InvalidResponse { .. }) => return Ok(None),
403 Err(error) => return Err(error),
404 }
405 update_remaining_timeout(&mut input.request, deadline)?;
406 self.cli.affected_tests(&input).await.map(Some)
407 }
408
409 async fn shutdown(&self) -> Result<(), ProviderError> {
410 self.closed.store(true, Ordering::Release);
411 self.permits.close();
412 Ok(())
413 }
414}
415
416fn unavailable_capability(error: &ProviderError) -> ProviderCapability {
417 let status = match error {
418 ProviderError::Timeout { .. } => ProviderStatus::TimedOut,
419 ProviderError::InvalidResponse { .. } => ProviderStatus::InvalidResponse,
420 _ => ProviderStatus::Unavailable,
421 };
422 ProviderCapability {
423 provider: "codegraph".to_owned(),
424 version: None,
425 status,
426 tools: Vec::new(),
427 protocol_version: None,
428 operations: Vec::new(),
429 degradations: vec![degradation_from_error(error)],
430 remediation: Some("Install CodeGraph or configure its public executable path.".to_owned()),
431 }
432}
433
434fn mcp_tool_names(probe: Option<&McpProbe>) -> Vec<String> {
435 let mut names = probe
436 .map(|probe| {
437 probe
438 .tools
439 .iter()
440 .map(|tool| tool.name.clone())
441 .collect::<Vec<_>>()
442 })
443 .unwrap_or_default();
444 names.sort();
445 names
446}
447
448fn remediation(status: ProviderStatus, project_path: &Path) -> Option<String> {
449 match status {
450 ProviderStatus::IndexMissing => Some(format!("Create the index explicitly with `codegraph init {}`.", project_path.display())),
451 ProviderStatus::Stale => Some(format!("Refresh the index explicitly with `codegraph sync {}`.", project_path.display())),
452 ProviderStatus::Incompatible => Some("Use a CodeGraph version listed in the compatibility matrix or rely on federated boundaries without local enrichment.".to_owned()),
453 _ => None,
454 }
455}
456
457fn validate_request(request: &ProviderRequest) -> Result<(), ProviderError> {
458 if request.cancellation.is_cancelled() {
459 return Err(ProviderError::Cancelled);
460 }
461 if request.budget.timeout.is_zero() {
462 return Err(ProviderError::InvalidRequest(
463 "timeout must be greater than zero".to_owned(),
464 ));
465 }
466 if request.budget.max_output_bytes == 0 {
467 return Err(ProviderError::InvalidRequest(
468 "max_output_bytes must be greater than zero".to_owned(),
469 ));
470 }
471 if request.budget.max_items == 0 {
472 return Err(ProviderError::InvalidRequest(
473 "max_items must be greater than zero".to_owned(),
474 ));
475 }
476 if !request.project_path.is_absolute() {
477 return Err(ProviderError::InvalidRequest(
478 "project_path must be absolute".to_owned(),
479 ));
480 }
481 if !request.project_path.is_dir() {
482 return Err(ProviderError::InvalidRequest(
483 "project_path must identify an existing directory".to_owned(),
484 ));
485 }
486 Ok(())
487}
488
489fn validate_query(query: &str) -> Result<(), ProviderError> {
490 if query.trim().is_empty() {
491 return Err(ProviderError::InvalidRequest(
492 "provider query must not be empty".to_owned(),
493 ));
494 }
495 if query.len() > MAX_QUERY_BYTES {
496 return Err(ProviderError::InvalidRequest(format!(
497 "provider query exceeds {MAX_QUERY_BYTES} bytes"
498 )));
499 }
500 Ok(())
501}
502
503fn validate_depth(depth: usize) -> Result<(), ProviderError> {
504 if depth == 0 || depth > MAX_LOCAL_DEPTH {
505 return Err(ProviderError::InvalidRequest(format!(
506 "local depth must be between 1 and {MAX_LOCAL_DEPTH}"
507 )));
508 }
509 Ok(())
510}
511
512fn validate_changed_files(files: &[String]) -> Result<(), ProviderError> {
513 if files.is_empty() || files.len() > MAX_CHANGED_FILES {
514 return Err(ProviderError::InvalidRequest(format!(
515 "changed_files must contain between 1 and {MAX_CHANGED_FILES} paths"
516 )));
517 }
518 for file in files {
519 let path = Path::new(file);
520 if file.is_empty()
521 || path.is_absolute()
522 || path
523 .components()
524 .any(|component| matches!(component, Component::ParentDir | Component::RootDir))
525 {
526 return Err(ProviderError::InvalidRequest(format!(
527 "changed file path must be non-empty and repository-relative: {file}"
528 )));
529 }
530 }
531 Ok(())
532}
533
534fn update_remaining_timeout(
535 request: &mut ProviderRequest,
536 deadline: tokio::time::Instant,
537) -> Result<(), ProviderError> {
538 request.budget.timeout = deadline
539 .checked_duration_since(tokio::time::Instant::now())
540 .ok_or_else(timeout_error)?;
541 if request.budget.timeout.is_zero() {
542 return Err(timeout_error());
543 }
544 Ok(())
545}
546
547pub(crate) fn diagnostic_id() -> String {
548 format!(
549 "cg-{:016x}",
550 DIAGNOSTIC_SEQUENCE.fetch_add(1, Ordering::Relaxed)
551 )
552}
553
554pub(crate) fn timeout_error() -> ProviderError {
555 ProviderError::Timeout {
556 diagnostics_id: diagnostic_id(),
557 }
558}
559
560pub(crate) fn output_limit(limit_bytes: usize) -> ProviderError {
561 ProviderError::OutputLimit {
562 limit_bytes,
563 diagnostics_id: diagnostic_id(),
564 }
565}
566
567pub(crate) fn invalid_response(message: impl Into<String>) -> ProviderError {
568 ProviderError::InvalidResponse {
569 message: message.into(),
570 diagnostics_id: diagnostic_id(),
571 }
572}
573
574pub(crate) fn transport_error(message: impl Into<String>) -> ProviderError {
575 ProviderError::Transport {
576 message: message.into(),
577 diagnostics_id: diagnostic_id(),
578 }
579}
580
581fn degradation_from_error(error: &ProviderError) -> ProviderDegradation {
582 let (kind, diagnostics_id) = match error {
583 ProviderError::Cancelled => ("cancelled", None),
584 ProviderError::Timeout { diagnostics_id } => ("timeout", Some(diagnostics_id.clone())),
585 ProviderError::OutputLimit { diagnostics_id, .. } => {
586 ("output_limit", Some(diagnostics_id.clone()))
587 }
588 ProviderError::CircuitOpen => ("circuit_open", None),
589 ProviderError::InvalidRequest(_) => ("invalid_request", None),
590 ProviderError::InvalidResponse { diagnostics_id, .. } => {
591 ("invalid_response", Some(diagnostics_id.clone()))
592 }
593 ProviderError::Transport { diagnostics_id, .. } => {
594 ("transport", Some(diagnostics_id.clone()))
595 }
596 };
597 ProviderDegradation {
598 kind: kind.to_owned(),
599 message: error.to_string(),
600 diagnostics_id,
601 }
602}
603
604#[cfg(test)]
605mod tests {
606 use std::path::PathBuf;
607
608 use code_system_graph_model::RepoId;
609 use tokio_util::sync::CancellationToken;
610
611 use super::{CodeGraphConfig, CodeGraphProvider, validate_changed_files};
612 use crate::{ProviderBudget, ProviderError, ProviderRequest};
613
614 #[test]
615 fn provider_should_reject_zero_process_budget() {
616 let result = CodeGraphProvider::new(CodeGraphConfig {
617 max_concurrent_processes: 0,
618 ..CodeGraphConfig::default()
619 });
620
621 assert!(matches!(result, Err(ProviderError::InvalidRequest(_))));
622 }
623
624 #[test]
625 fn changed_files_should_reject_parent_escape() {
626 let result = validate_changed_files(&["../outside.rs".to_owned()]);
627
628 assert!(matches!(result, Err(ProviderError::InvalidRequest(_))));
629 }
630
631 #[tokio::test]
632 async fn cancelled_probe_should_not_start_a_child() {
633 let provider =
634 CodeGraphProvider::new(CodeGraphConfig::default()).expect("valid provider config");
635 let cancellation = CancellationToken::new();
636 cancellation.cancel();
637 let result = crate::LocalCodeIntelligenceProvider::probe(
638 &provider,
639 ProviderRequest {
640 repo_id: RepoId::new("repo:test"),
641 project_path: PathBuf::from("/tmp"),
642 budget: ProviderBudget::default(),
643 cancellation,
644 },
645 )
646 .await;
647
648 assert_eq!(result, Err(ProviderError::Cancelled));
649 }
650}