Skip to main content

roder_api/
version_control.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use serde::{Deserialize, Serialize};
5
6pub type VcsProviderId = String;
7pub type VcsWorkspaceId = String;
8pub type VcsLineId = String;
9pub type VcsSnapshotId = String;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12#[serde(rename_all = "camelCase")]
13pub struct VcsProviderIdentity {
14    pub id: VcsProviderId,
15    pub display_name: String,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19#[serde(rename_all = "camelCase")]
20pub struct VcsWorkspace {
21    pub root: PathBuf,
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub id: Option<VcsWorkspaceId>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27#[serde(rename_all = "camelCase")]
28pub struct VcsLineOfWork {
29    pub id: VcsLineId,
30    pub name: String,
31    pub kind: VcsLineKind,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35#[serde(rename_all = "snake_case")]
36pub enum VcsLineKind {
37    Branch,
38    Bookmark,
39    WorkingCopy,
40    Revision,
41    ProviderNative,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45#[serde(rename_all = "camelCase")]
46pub struct VcsBase {
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub ref_name: Option<String>,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub sha: Option<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54#[serde(rename_all = "camelCase")]
55pub struct VcsStatus {
56    pub provider: VcsProviderIdentity,
57    pub workspace: VcsWorkspace,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub active_line: Option<VcsLineOfWork>,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub base: Option<VcsBase>,
62    pub capabilities: VcsCapabilities,
63    #[serde(default)]
64    pub changed_file_count: u32,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
68#[serde(rename_all = "camelCase")]
69pub struct VcsStatusWithChanges {
70    pub status: VcsStatus,
71    pub files: Vec<VcsChangedFile>,
72}
73
74#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
75#[serde(rename_all = "camelCase")]
76pub struct VcsCapabilities {
77    #[serde(default)]
78    pub operations: Vec<VcsOperationCapability>,
79}
80
81impl VcsCapabilities {
82    pub fn capability_for(&self, operation: VcsOperation) -> Option<&VcsOperationCapability> {
83        self.operations
84            .iter()
85            .find(|capability| capability.operation == operation)
86    }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
90#[serde(rename_all = "camelCase")]
91pub struct VcsOperationCapability {
92    pub operation: VcsOperation,
93    pub state: VcsCapabilityState,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub reason: Option<String>,
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub provider_namespace: Option<String>,
98    #[serde(default, skip_serializing_if = "Vec::is_empty")]
99    pub granularities: Vec<VcsSelectionGranularity>,
100}
101
102impl VcsOperationCapability {
103    pub fn new(operation: VcsOperation, state: VcsCapabilityState) -> Self {
104        Self {
105            operation,
106            state,
107            reason: None,
108            provider_namespace: None,
109            granularities: Vec::new(),
110        }
111    }
112
113    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
114        self.reason = Some(reason.into());
115        self
116    }
117
118    pub fn with_provider_namespace(mut self, namespace: impl Into<String>) -> Self {
119        self.provider_namespace = Some(namespace.into());
120        self
121    }
122
123    pub fn with_granularities(mut self, granularities: Vec<VcsSelectionGranularity>) -> Self {
124        self.granularities = granularities;
125        self
126    }
127}
128
129#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
130#[serde(rename_all = "snake_case")]
131pub enum VcsCapabilityState {
132    Supported,
133    Unsupported,
134    Partial,
135    ProviderNative,
136}
137
138#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
139#[serde(rename_all = "snake_case")]
140pub enum VcsOperation {
141    Status,
142    FileList,
143    ChangesList,
144    ChangesRead,
145    Selection,
146    SnapshotCreate,
147    Restore,
148    LineList,
149    LineSwitch,
150    SyncFetch,
151    SyncPull,
152    SyncPush,
153}
154
155#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
156#[serde(rename_all = "snake_case")]
157pub enum VcsSelectionGranularity {
158    None,
159    Path,
160    Hunk,
161    ProviderNative,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
165#[serde(rename_all = "camelCase")]
166pub struct VcsChangedFile {
167    pub path: PathBuf,
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub old_path: Option<PathBuf>,
170    pub status: VcsChangedFileStatus,
171    #[serde(default, skip_serializing_if = "Vec::is_empty")]
172    pub areas: Vec<VcsChangeArea>,
173    pub additions: u32,
174    pub deletions: u32,
175    #[serde(default)]
176    pub binary: bool,
177}
178
179#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
180#[serde(rename_all = "snake_case")]
181pub enum VcsChangeArea {
182    Committed,
183    Staged,
184    Unstaged,
185    Untracked,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
189#[serde(rename_all = "snake_case")]
190pub enum VcsChangedFileStatus {
191    Modified,
192    Added,
193    Deleted,
194    Renamed,
195    Untracked,
196    ProviderNative,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
200#[serde(rename_all = "camelCase")]
201pub struct VcsChangedContentPage {
202    pub path: PathBuf,
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub content: Option<String>,
205    pub offset: u32,
206    pub total_lines: u32,
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub next_offset: Option<u32>,
209    #[serde(default)]
210    pub binary: bool,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
214#[serde(rename_all = "camelCase")]
215pub struct VcsDetectionClaim {
216    pub workspace: VcsWorkspace,
217    pub priority: i32,
218    #[serde(default)]
219    pub metadata: serde_json::Value,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
223#[serde(rename_all = "camelCase")]
224pub struct VcsResolveRequest {
225    pub workspace_root: PathBuf,
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub preferred_provider_id: Option<VcsProviderId>,
228}
229
230#[derive(Clone)]
231pub enum VcsProviderResolution {
232    Available {
233        provider: Arc<dyn VcsProvider>,
234        claim: VcsDetectionClaim,
235    },
236    Unavailable {
237        workspace_root: PathBuf,
238    },
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
242#[serde(rename_all = "camelCase")]
243pub struct VcsStatusRequest {
244    pub workspace_root: PathBuf,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
248#[serde(rename_all = "camelCase")]
249pub struct VcsListChangesRequest {
250    pub workspace_root: PathBuf,
251}
252
253/// Request to enumerate every file the provider considers part of the
254/// workspace, scoped to `workspace_root`. Used by the app-server file index
255/// to build a complete, ignore-aware file list without hard-coding any one VCS.
256#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
257#[serde(rename_all = "camelCase")]
258pub struct VcsListFilesRequest {
259    pub workspace_root: PathBuf,
260}
261
262/// Result of [`VcsProvider::list_files`]: absolute paths to every file the
263/// provider tracks or considers non-ignored under the requested root.
264#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
265#[serde(rename_all = "camelCase")]
266pub struct VcsFileListing {
267    pub provider_id: VcsProviderId,
268    pub files: Vec<PathBuf>,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
272#[serde(rename_all = "camelCase")]
273pub struct VcsReadChangedContentRequest {
274    pub workspace_root: PathBuf,
275    pub path: PathBuf,
276    pub offset: u32,
277    pub limit: u32,
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub area: Option<VcsChangeArea>,
280    #[serde(default)]
281    pub ignore_whitespace: bool,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
285#[serde(rename_all = "camelCase")]
286pub struct VcsSelectionRequest {
287    pub workspace_root: PathBuf,
288    pub paths: Vec<PathBuf>,
289    pub granularity: VcsSelectionGranularity,
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
293#[serde(rename_all = "camelCase")]
294pub struct VcsSnapshotCreateRequest {
295    pub workspace_root: PathBuf,
296    pub message: String,
297    #[serde(default)]
298    pub paths: Vec<PathBuf>,
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
302#[serde(rename_all = "camelCase")]
303pub struct VcsSnapshot {
304    pub provider_id: VcsProviderId,
305    pub id: VcsSnapshotId,
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub label: Option<String>,
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
311#[serde(rename_all = "camelCase")]
312pub struct VcsRestoreRequest {
313    pub workspace_root: PathBuf,
314    pub paths: Vec<PathBuf>,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
318#[serde(rename_all = "camelCase")]
319pub struct VcsLineSwitchRequest {
320    pub workspace_root: PathBuf,
321    pub line_id: VcsLineId,
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
325#[serde(rename_all = "snake_case")]
326pub enum VcsSyncOperation {
327    Fetch,
328    Pull,
329    Push,
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
333#[serde(rename_all = "camelCase")]
334pub struct VcsSyncRequest {
335    pub workspace_root: PathBuf,
336    pub operation: VcsSyncOperation,
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
340#[serde(rename_all = "camelCase")]
341pub struct VcsOperationResult {
342    pub provider_id: VcsProviderId,
343    #[serde(default)]
344    pub message: Option<String>,
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
348#[serde(
349    tag = "kind",
350    rename_all = "snake_case",
351    rename_all_fields = "camelCase"
352)]
353pub enum VcsError {
354    Unavailable {
355        operation: VcsOperation,
356        #[serde(default, skip_serializing_if = "Option::is_none")]
357        provider_id: Option<VcsProviderId>,
358        #[serde(default, skip_serializing_if = "Option::is_none")]
359        path: Option<PathBuf>,
360        message: String,
361    },
362    UnsupportedOperation {
363        provider_id: VcsProviderId,
364        operation: VcsOperation,
365        #[serde(default, skip_serializing_if = "Option::is_none")]
366        capability: Option<VcsOperationCapability>,
367        message: String,
368    },
369    PathInvalid {
370        provider_id: VcsProviderId,
371        path: PathBuf,
372        message: String,
373    },
374    DirtyWorkspace {
375        provider_id: VcsProviderId,
376        operation: VcsOperation,
377        message: String,
378    },
379    CommandFailed {
380        provider_id: VcsProviderId,
381        operation: VcsOperation,
382        command: String,
383        exit_code: Option<i32>,
384        stderr: String,
385    },
386    ProviderNativeRequired {
387        provider_id: VcsProviderId,
388        operation: VcsOperation,
389        namespace: String,
390        message: String,
391    },
392}
393
394impl std::fmt::Display for VcsError {
395    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
396        match self {
397            Self::Unavailable { message, .. }
398            | Self::UnsupportedOperation { message, .. }
399            | Self::PathInvalid { message, .. }
400            | Self::DirtyWorkspace { message, .. }
401            | Self::ProviderNativeRequired { message, .. } => f.write_str(message),
402            Self::CommandFailed {
403                command, stderr, ..
404            } => write!(f, "{command} failed: {stderr}"),
405        }
406    }
407}
408
409impl std::error::Error for VcsError {}
410
411#[async_trait::async_trait]
412pub trait VcsProvider: Send + Sync + 'static {
413    fn id(&self) -> VcsProviderId;
414    fn display_name(&self) -> String;
415
416    async fn detect(&self, workspace_root: &Path) -> Result<Option<VcsDetectionClaim>, VcsError>;
417    async fn status(&self, request: VcsStatusRequest) -> Result<VcsStatus, VcsError>;
418    async fn list_changes(
419        &self,
420        request: VcsListChangesRequest,
421    ) -> Result<Vec<VcsChangedFile>, VcsError>;
422    /// Enumerate every file under `workspace_root` that the provider tracks or
423    /// treats as non-ignored. Providers that cannot enumerate the workspace
424    /// return [`VcsError::UnsupportedOperation`] so callers can fall back to a
425    /// plain filesystem walk. The default implementation is unsupported.
426    async fn list_files(&self, request: VcsListFilesRequest) -> Result<VcsFileListing, VcsError> {
427        let _ = request;
428        Err(VcsError::UnsupportedOperation {
429            provider_id: self.id(),
430            operation: VcsOperation::FileList,
431            capability: None,
432            message: "file listing is not supported by this provider".to_string(),
433        })
434    }
435    async fn status_with_changes(
436        &self,
437        request: VcsListChangesRequest,
438    ) -> Result<VcsStatusWithChanges, VcsError> {
439        let status = self
440            .status(VcsStatusRequest {
441                workspace_root: request.workspace_root.clone(),
442            })
443            .await?;
444        let files = self.list_changes(request).await?;
445        Ok(VcsStatusWithChanges { status, files })
446    }
447    async fn list_changes_against_base(
448        &self,
449        request: VcsListChangesRequest,
450        _base: Option<VcsBase>,
451    ) -> Result<Vec<VcsChangedFile>, VcsError> {
452        self.list_changes(request).await
453    }
454    async fn read_changed_content(
455        &self,
456        request: VcsReadChangedContentRequest,
457    ) -> Result<VcsChangedContentPage, VcsError>;
458
459    async fn select(&self, request: VcsSelectionRequest) -> Result<VcsOperationResult, VcsError> {
460        Err(unsupported(
461            self.id(),
462            VcsOperation::Selection,
463            "selection is not supported by this provider",
464            request.granularity,
465        ))
466    }
467
468    async fn create_snapshot(
469        &self,
470        _request: VcsSnapshotCreateRequest,
471    ) -> Result<VcsSnapshot, VcsError> {
472        Err(unsupported(
473            self.id(),
474            VcsOperation::SnapshotCreate,
475            "snapshot creation is not supported by this provider",
476            VcsSelectionGranularity::None,
477        ))
478    }
479
480    async fn restore(&self, _request: VcsRestoreRequest) -> Result<VcsOperationResult, VcsError> {
481        Err(unsupported(
482            self.id(),
483            VcsOperation::Restore,
484            "restore is not supported by this provider",
485            VcsSelectionGranularity::None,
486        ))
487    }
488
489    async fn list_lines(&self, workspace_root: PathBuf) -> Result<Vec<VcsLineOfWork>, VcsError> {
490        Err(VcsError::UnsupportedOperation {
491            provider_id: self.id(),
492            operation: VcsOperation::LineList,
493            capability: None,
494            message: format!(
495                "line listing is not supported for {}",
496                workspace_root.display()
497            ),
498        })
499    }
500
501    async fn switch_line(
502        &self,
503        _request: VcsLineSwitchRequest,
504    ) -> Result<VcsOperationResult, VcsError> {
505        Err(VcsError::UnsupportedOperation {
506            provider_id: self.id(),
507            operation: VcsOperation::LineSwitch,
508            capability: None,
509            message: "line switching is not supported by this provider".to_string(),
510        })
511    }
512
513    async fn sync(&self, request: VcsSyncRequest) -> Result<VcsOperationResult, VcsError> {
514        let operation = match request.operation {
515            VcsSyncOperation::Fetch => VcsOperation::SyncFetch,
516            VcsSyncOperation::Pull => VcsOperation::SyncPull,
517            VcsSyncOperation::Push => VcsOperation::SyncPush,
518        };
519        Err(VcsError::UnsupportedOperation {
520            provider_id: self.id(),
521            operation,
522            capability: None,
523            message: "sync is not supported by this provider".to_string(),
524        })
525    }
526}
527
528#[async_trait::async_trait]
529pub trait VcsProviderResolver: Send + Sync + 'static {
530    async fn resolve_provider(
531        &self,
532        request: VcsResolveRequest,
533    ) -> Result<VcsProviderResolution, VcsError>;
534}
535
536#[derive(Clone, Default)]
537pub struct RegistryVcsProviderResolver {
538    providers: Vec<Arc<dyn VcsProvider>>,
539}
540
541impl RegistryVcsProviderResolver {
542    pub fn new(providers: Vec<Arc<dyn VcsProvider>>) -> Self {
543        Self { providers }
544    }
545}
546
547#[async_trait::async_trait]
548impl VcsProviderResolver for RegistryVcsProviderResolver {
549    async fn resolve_provider(
550        &self,
551        request: VcsResolveRequest,
552    ) -> Result<VcsProviderResolution, VcsError> {
553        if let Some(preferred) = request.preferred_provider_id {
554            for provider in &self.providers {
555                if provider.id() == preferred {
556                    return match provider.detect(&request.workspace_root).await? {
557                        Some(claim) => Ok(VcsProviderResolution::Available {
558                            provider: Arc::clone(provider),
559                            claim,
560                        }),
561                        None => Ok(VcsProviderResolution::Unavailable {
562                            workspace_root: request.workspace_root,
563                        }),
564                    };
565                }
566            }
567            return Ok(VcsProviderResolution::Unavailable {
568                workspace_root: request.workspace_root,
569            });
570        }
571
572        let mut claims = Vec::new();
573        for provider in &self.providers {
574            if let Some(claim) = provider.detect(&request.workspace_root).await? {
575                claims.push((Arc::clone(provider), claim));
576            }
577        }
578        claims.sort_by(
579            |(left_provider, left_claim), (right_provider, right_claim)| {
580                right_claim
581                    .priority
582                    .cmp(&left_claim.priority)
583                    .then_with(|| {
584                        right_claim
585                            .workspace
586                            .root
587                            .components()
588                            .count()
589                            .cmp(&left_claim.workspace.root.components().count())
590                    })
591                    .then_with(|| left_provider.id().cmp(&right_provider.id()))
592            },
593        );
594        if let Some((provider, claim)) = claims.into_iter().next() {
595            Ok(VcsProviderResolution::Available { provider, claim })
596        } else {
597            Ok(VcsProviderResolution::Unavailable {
598                workspace_root: request.workspace_root,
599            })
600        }
601    }
602}
603
604fn unsupported(
605    provider_id: VcsProviderId,
606    operation: VcsOperation,
607    message: impl Into<String>,
608    granularity: VcsSelectionGranularity,
609) -> VcsError {
610    VcsError::UnsupportedOperation {
611        provider_id,
612        operation,
613        capability: Some(
614            VcsOperationCapability::new(operation, VcsCapabilityState::Unsupported)
615                .with_granularities(vec![granularity]),
616        ),
617        message: message.into(),
618    }
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624
625    #[test]
626    fn vcs_capability_states_round_trip_json() {
627        let capabilities = VcsCapabilities {
628            operations: vec![
629                VcsOperationCapability::new(VcsOperation::Status, VcsCapabilityState::Supported),
630                VcsOperationCapability::new(
631                    VcsOperation::Selection,
632                    VcsCapabilityState::Unsupported,
633                )
634                .with_reason("hunk selection unavailable")
635                .with_granularities(vec![VcsSelectionGranularity::Path]),
636                VcsOperationCapability::new(VcsOperation::Restore, VcsCapabilityState::Partial),
637                VcsOperationCapability::new(
638                    VcsOperation::SyncPush,
639                    VcsCapabilityState::ProviderNative,
640                )
641                .with_provider_namespace("jj"),
642            ],
643        };
644
645        let encoded = serde_json::to_value(&capabilities).expect("serialize capabilities");
646        let decoded =
647            serde_json::from_value::<VcsCapabilities>(encoded).expect("deserialize capabilities");
648
649        assert_eq!(decoded, capabilities);
650        assert_eq!(
651            decoded
652                .capability_for(VcsOperation::SyncPush)
653                .unwrap()
654                .state,
655            VcsCapabilityState::ProviderNative
656        );
657    }
658
659    #[test]
660    fn vcs_error_serializes_provider_operation_path_and_command_context() {
661        let error = VcsError::CommandFailed {
662            provider_id: "git".to_string(),
663            operation: VcsOperation::ChangesRead,
664            command: "git diff -- path".to_string(),
665            exit_code: Some(128),
666            stderr: "bad path".to_string(),
667        };
668
669        let encoded = serde_json::to_value(&error).expect("serialize vcs error");
670
671        assert_eq!(encoded["providerId"], "git");
672        assert_eq!(encoded["operation"], "changes_read");
673        assert_eq!(encoded["command"], "git diff -- path");
674        assert_eq!(encoded["exitCode"], 128);
675        assert_eq!(encoded["stderr"], "bad path");
676    }
677
678    #[tokio::test]
679    async fn overlapping_detection_claims_resolve_by_priority_then_provider_id() {
680        let workspace = PathBuf::from("/workspace");
681        let resolver = RegistryVcsProviderResolver::new(vec![
682            Arc::new(FakeProvider::new("zz-low", 1, &workspace)),
683            Arc::new(FakeProvider::new("bb-high", 9, &workspace)),
684            Arc::new(FakeProvider::new("aa-high", 9, &workspace)),
685        ]);
686
687        let resolution = resolver
688            .resolve_provider(VcsResolveRequest {
689                workspace_root: workspace,
690                preferred_provider_id: None,
691            })
692            .await
693            .expect("resolve provider");
694
695        let VcsProviderResolution::Available { provider, claim } = resolution else {
696            panic!("expected active provider");
697        };
698        assert_eq!(provider.id(), "aa-high");
699        assert_eq!(claim.priority, 9);
700    }
701
702    #[tokio::test]
703    async fn preferred_provider_takes_precedence_over_claim_priority() {
704        let workspace = PathBuf::from("/workspace");
705        let resolver = RegistryVcsProviderResolver::new(vec![
706            Arc::new(FakeProvider::new("git", 1, &workspace)),
707            Arc::new(FakeProvider::new("jj", 9, &workspace)),
708        ]);
709
710        let resolution = resolver
711            .resolve_provider(VcsResolveRequest {
712                workspace_root: workspace,
713                preferred_provider_id: Some("git".to_string()),
714            })
715            .await
716            .expect("resolve provider");
717
718        let VcsProviderResolution::Available { provider, .. } = resolution else {
719            panic!("expected active provider");
720        };
721        assert_eq!(provider.id(), "git");
722    }
723
724    #[tokio::test]
725    async fn async_provider_trait_can_wrap_blocking_work_in_provider() {
726        let provider = FakeProvider::new("blocking", 1, Path::new("/workspace"));
727
728        let status = provider
729            .status(VcsStatusRequest {
730                workspace_root: PathBuf::from("/workspace"),
731            })
732            .await
733            .expect("status");
734
735        assert_eq!(status.provider.id, "blocking");
736        assert_eq!(status.changed_file_count, 1);
737    }
738
739    #[tokio::test]
740    async fn unsupported_hunk_selection_reports_capability_error() {
741        let provider = FakeProvider::new("fake", 1, Path::new("/workspace"));
742
743        let error = provider
744            .select(VcsSelectionRequest {
745                workspace_root: PathBuf::from("/workspace"),
746                paths: vec![PathBuf::from("src/lib.rs")],
747                granularity: VcsSelectionGranularity::Hunk,
748            })
749            .await
750            .expect_err("hunk selection should be unsupported");
751
752        let VcsError::UnsupportedOperation {
753            operation,
754            capability,
755            ..
756        } = error
757        else {
758            panic!("expected unsupported operation");
759        };
760        assert_eq!(operation, VcsOperation::Selection);
761        assert_eq!(
762            capability.unwrap().granularities,
763            vec![VcsSelectionGranularity::Hunk]
764        );
765    }
766
767    struct FakeProvider {
768        id: String,
769        priority: i32,
770        root: PathBuf,
771    }
772
773    impl FakeProvider {
774        fn new(id: impl Into<String>, priority: i32, root: &Path) -> Self {
775            Self {
776                id: id.into(),
777                priority,
778                root: root.to_path_buf(),
779            }
780        }
781    }
782
783    #[async_trait::async_trait]
784    impl VcsProvider for FakeProvider {
785        fn id(&self) -> VcsProviderId {
786            self.id.clone()
787        }
788
789        fn display_name(&self) -> String {
790            self.id.clone()
791        }
792
793        async fn detect(
794            &self,
795            _workspace_root: &Path,
796        ) -> Result<Option<VcsDetectionClaim>, VcsError> {
797            Ok(Some(VcsDetectionClaim {
798                workspace: VcsWorkspace {
799                    root: self.root.clone(),
800                    id: None,
801                },
802                priority: self.priority,
803                metadata: serde_json::Value::Null,
804            }))
805        }
806
807        async fn status(&self, request: VcsStatusRequest) -> Result<VcsStatus, VcsError> {
808            let id = self.id.clone();
809            tokio::task::spawn_blocking(move || VcsStatus {
810                provider: VcsProviderIdentity {
811                    id: id.clone(),
812                    display_name: id,
813                },
814                workspace: VcsWorkspace {
815                    root: request.workspace_root,
816                    id: None,
817                },
818                active_line: None,
819                base: None,
820                capabilities: VcsCapabilities {
821                    operations: vec![VcsOperationCapability::new(
822                        VcsOperation::Status,
823                        VcsCapabilityState::Supported,
824                    )],
825                },
826                changed_file_count: 1,
827            })
828            .await
829            .map_err(|err| VcsError::Unavailable {
830                operation: VcsOperation::Status,
831                provider_id: Some(self.id.clone()),
832                path: None,
833                message: err.to_string(),
834            })
835        }
836
837        async fn list_changes(
838            &self,
839            _request: VcsListChangesRequest,
840        ) -> Result<Vec<VcsChangedFile>, VcsError> {
841            Ok(Vec::new())
842        }
843
844        async fn read_changed_content(
845            &self,
846            request: VcsReadChangedContentRequest,
847        ) -> Result<VcsChangedContentPage, VcsError> {
848            Ok(VcsChangedContentPage {
849                path: request.path,
850                content: Some(String::new()),
851                offset: request.offset,
852                total_lines: 0,
853                next_offset: None,
854                binary: false,
855            })
856        }
857    }
858}