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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
257#[serde(rename_all = "camelCase")]
258pub struct VcsListFilesRequest {
259 pub workspace_root: PathBuf,
260}
261
262#[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 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}