1mod local;
11#[cfg(feature = "s3")]
12mod s3;
13
14pub use local::LocalWorkspaceBackend;
15#[cfg(feature = "s3")]
16pub use s3::{S3BackendConfig, S3WorkspaceBackend};
17
18use anyhow::{anyhow, bail, Result};
19use async_trait::async_trait;
20use std::collections::HashMap;
21use std::path::{Component, Path, PathBuf};
22use std::sync::Arc;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct WorkspaceRef {
27 pub id: String,
29 pub display_root: String,
31}
32
33impl WorkspaceRef {
34 pub fn new(id: impl Into<String>, display_root: impl Into<String>) -> Self {
35 Self {
36 id: id.into(),
37 display_root: display_root.into(),
38 }
39 }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Hash)]
44pub struct WorkspacePath {
45 inner: String,
46}
47
48impl WorkspacePath {
49 pub fn root() -> Self {
50 Self {
51 inner: ".".to_string(),
52 }
53 }
54
55 pub fn from_normalized(path: impl Into<String>) -> Self {
56 let path = path.into();
57 let path = path.trim_matches('/');
58 if path.is_empty() || path == "." {
59 Self::root()
60 } else {
61 Self {
62 inner: path.replace('\\', "/"),
63 }
64 }
65 }
66
67 pub fn as_str(&self) -> &str {
68 &self.inner
69 }
70
71 pub fn is_root(&self) -> bool {
72 self.inner == "."
73 }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub struct WorkspaceCapabilities {
83 pub read: bool,
84 pub write: bool,
85 pub exec: bool,
86 pub search: bool,
87 pub git: bool,
88}
89
90impl WorkspaceCapabilities {
91 pub fn local_default() -> Self {
92 Self {
93 read: true,
94 write: true,
95 exec: true,
96 search: true,
97 git: true,
98 }
99 }
100
101 pub fn read_write() -> Self {
102 Self {
103 read: true,
104 write: true,
105 exec: false,
106 search: false,
107 git: false,
108 }
109 }
110}
111
112impl Default for WorkspaceCapabilities {
113 fn default() -> Self {
114 Self::read_write()
115 }
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum WorkspaceFileType {
121 File,
122 Directory,
123 Symlink,
124 Unknown,
125}
126
127impl WorkspaceFileType {
128 pub fn as_tool_kind(self) -> &'static str {
129 match self {
130 Self::File => "file",
131 Self::Directory => "dir",
132 Self::Symlink => "link",
133 Self::Unknown => "unknown",
134 }
135 }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct WorkspaceDirEntry {
141 pub name: String,
142 pub kind: WorkspaceFileType,
143 pub size: u64,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct WorkspaceWriteOutcome {
149 pub bytes: usize,
150 pub lines: usize,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct WorkspaceGlobRequest {
156 pub base: WorkspacePath,
157 pub pattern: String,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct WorkspaceGlobResult {
163 pub matches: Vec<WorkspacePath>,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct WorkspaceGrepRequest {
169 pub base: WorkspacePath,
170 pub pattern: String,
171 pub glob: Option<String>,
172 pub context_lines: usize,
173 pub case_insensitive: bool,
174 pub max_output_size: usize,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct WorkspaceGrepResult {
180 pub output: String,
181 pub match_count: usize,
182 pub file_count: usize,
183 pub truncated: bool,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct WorkspaceGitStatus {
189 pub branch: String,
190 pub commit: String,
191 pub is_worktree: bool,
192 pub is_dirty: bool,
193 pub dirty_count: usize,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct WorkspaceGitCommit {
199 pub id: String,
200 pub message: String,
201 pub author: String,
202 pub date: String,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct WorkspaceGitBranch {
208 pub name: String,
209 pub is_current: bool,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct WorkspaceGitCreateBranchRequest {
215 pub name: String,
216 pub base: String,
217}
218
219#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct WorkspaceGitCheckoutRequest {
222 pub refspec: String,
223 pub force: bool,
224}
225
226#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct WorkspaceGitCheckoutOutput {
229 pub stdout: String,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct WorkspaceGitDiffRequest {
235 pub target: Option<String>,
236}
237
238#[derive(Debug, Clone, PartialEq, Eq)]
240pub struct WorkspaceGitStash {
241 pub index: usize,
242 pub message: String,
243}
244
245#[derive(Debug, Clone, PartialEq, Eq)]
247pub struct WorkspaceGitStashRequest {
248 pub message: Option<String>,
249 pub include_untracked: bool,
250}
251
252#[derive(Debug, Clone, PartialEq, Eq)]
254pub struct WorkspaceGitRemote {
255 pub name: String,
256 pub url: String,
257 pub direction: String,
258}
259
260#[derive(Debug, Clone, PartialEq, Eq)]
262pub struct WorkspaceGitWorktree {
263 pub path: String,
264 pub branch: String,
265 pub is_bare: bool,
266 pub is_detached: bool,
267}
268
269#[derive(Debug, Clone, PartialEq, Eq)]
271pub struct WorkspaceGitCreateWorktreeRequest {
272 pub branch: String,
273 pub path: Option<String>,
274 pub new_branch: bool,
275}
276
277#[derive(Debug, Clone, PartialEq, Eq)]
279pub struct WorkspaceGitRemoveWorktreeRequest {
280 pub path: String,
281 pub force: bool,
282}
283
284#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct WorkspaceGitWorktreeMutation {
287 pub path: String,
288 pub branch: Option<String>,
289}
290
291#[async_trait]
297pub trait CommandOutputObserver: Send + Sync {
298 async fn on_output_delta(&self, delta: &str);
299}
300
301#[derive(Clone)]
303pub struct CommandRequest {
304 pub command: String,
305 pub timeout_ms: u64,
306 pub output_observer: Option<Arc<dyn CommandOutputObserver>>,
307 pub env: Option<Arc<HashMap<String, String>>>,
308}
309
310impl std::fmt::Debug for CommandRequest {
311 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
312 f.debug_struct("CommandRequest")
313 .field("command", &self.command)
314 .field("timeout_ms", &self.timeout_ms)
315 .field("output_observer", &self.output_observer.is_some())
316 .field("env", &self.env.as_ref().map(|env| env.len()))
317 .finish()
318 }
319}
320
321#[derive(Debug, Clone, PartialEq, Eq)]
323pub struct CommandOutput {
324 pub output: String,
325 pub exit_code: i32,
326 pub timed_out: bool,
327}
328
329pub trait WorkspacePathResolver: Send + Sync {
331 fn normalize(&self, input: &str) -> Result<WorkspacePath>;
332}
333
334#[async_trait]
344pub trait WorkspaceFileSystem: Send + Sync {
345 async fn read_text(&self, path: &WorkspacePath) -> Result<String>;
346 async fn write_text(
347 &self,
348 path: &WorkspacePath,
349 content: &str,
350 ) -> Result<WorkspaceWriteOutcome>;
351 async fn list_dir(&self, path: &WorkspacePath) -> Result<Vec<WorkspaceDirEntry>>;
352}
353
354#[async_trait]
356pub trait WorkspaceCommandRunner: Send + Sync {
357 async fn exec(&self, request: CommandRequest) -> Result<CommandOutput>;
358}
359
360#[async_trait]
362pub trait WorkspaceSearch: Send + Sync {
363 async fn glob(&self, request: WorkspaceGlobRequest) -> Result<WorkspaceGlobResult>;
364 async fn grep(&self, request: WorkspaceGrepRequest) -> Result<WorkspaceGrepResult>;
365}
366
367#[async_trait]
373pub trait WorkspaceGit: Send + Sync {
374 async fn is_repository(&self) -> Result<bool>;
375 async fn status(&self) -> Result<WorkspaceGitStatus>;
376 async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>>;
377 async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>>;
378 async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()>;
379 async fn checkout(
380 &self,
381 request: WorkspaceGitCheckoutRequest,
382 ) -> Result<WorkspaceGitCheckoutOutput>;
383 async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String>;
384 async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>>;
385}
386
387#[async_trait]
392pub trait WorkspaceGitStashProvider: Send + Sync {
393 async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>>;
394 async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()>;
395}
396
397#[async_trait]
402pub trait WorkspaceGitWorktreeProvider: Send + Sync {
403 async fn list_worktrees(&self) -> Result<Vec<WorkspaceGitWorktree>>;
404 async fn create_worktree(
405 &self,
406 request: WorkspaceGitCreateWorktreeRequest,
407 ) -> Result<WorkspaceGitWorktreeMutation>;
408 async fn remove_worktree(
409 &self,
410 request: WorkspaceGitRemoveWorktreeRequest,
411 ) -> Result<WorkspaceGitWorktreeMutation>;
412}
413
414pub struct WorkspaceServices {
416 workspace_ref: WorkspaceRef,
417 capabilities: WorkspaceCapabilities,
418 path_resolver: Arc<dyn WorkspacePathResolver>,
419 file_system: Arc<dyn WorkspaceFileSystem>,
420 command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
421 search: Option<Arc<dyn WorkspaceSearch>>,
422 git: Option<Arc<dyn WorkspaceGit>>,
423 git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
424 git_worktree: Option<Arc<dyn WorkspaceGitWorktreeProvider>>,
425 operation_timeout: Option<std::time::Duration>,
429 local_root: Option<PathBuf>,
430}
431
432impl std::fmt::Debug for WorkspaceServices {
433 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
434 f.debug_struct("WorkspaceServices")
435 .field("workspace_ref", &self.workspace_ref)
436 .field("capabilities", &self.capabilities)
437 .field("command_runner", &self.command_runner.is_some())
438 .field("search", &self.search.is_some())
439 .field("git", &self.git.is_some())
440 .field("git_stash", &self.git_stash.is_some())
441 .field("git_worktree", &self.git_worktree.is_some())
442 .field("local_root", &self.local_root)
443 .finish()
444 }
445}
446
447impl WorkspaceServices {
448 pub(crate) fn new_with_git(
449 workspace_ref: WorkspaceRef,
450 mut capabilities: WorkspaceCapabilities,
451 path_resolver: Arc<dyn WorkspacePathResolver>,
452 file_system: Arc<dyn WorkspaceFileSystem>,
453 command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
454 search: Option<Arc<dyn WorkspaceSearch>>,
455 git: Option<Arc<dyn WorkspaceGit>>,
456 ) -> Self {
457 if command_runner.is_none() {
458 capabilities.exec = false;
459 }
460 if search.is_none() {
461 capabilities.search = false;
462 }
463 if git.is_none() {
464 capabilities.git = false;
465 }
466 Self {
467 workspace_ref,
468 capabilities,
469 path_resolver,
470 file_system,
471 command_runner,
472 search,
473 git,
474 git_stash: None,
475 git_worktree: None,
476 operation_timeout: None,
477 local_root: None,
478 }
479 }
480
481 pub fn builder(
482 workspace_ref: WorkspaceRef,
483 file_system: Arc<dyn WorkspaceFileSystem>,
484 ) -> WorkspaceServicesBuilder {
485 WorkspaceServicesBuilder::new(workspace_ref, file_system)
486 }
487
488 pub fn local(root: impl Into<PathBuf>) -> Arc<Self> {
489 let backend = Arc::new(LocalWorkspaceBackend::new(root.into()));
490 let workspace_ref = WorkspaceRef::new(
491 backend.root.display().to_string(),
492 backend.root.display().to_string(),
493 );
494 let path_resolver: Arc<dyn WorkspacePathResolver> = backend.clone();
495 let file_system: Arc<dyn WorkspaceFileSystem> = backend.clone();
496 let command_runner: Arc<dyn WorkspaceCommandRunner> = backend.clone();
497 let search: Arc<dyn WorkspaceSearch> = backend.clone();
498 let git: Arc<dyn WorkspaceGit> = backend.clone();
499 let git_stash: Arc<dyn WorkspaceGitStashProvider> = backend.clone();
500 let git_worktree: Arc<dyn WorkspaceGitWorktreeProvider> = backend.clone();
501 Arc::new(Self {
502 workspace_ref,
503 capabilities: WorkspaceCapabilities::local_default(),
504 path_resolver,
505 file_system,
506 command_runner: Some(command_runner),
507 search: Some(search),
508 git: Some(git),
509 git_stash: Some(git_stash),
510 git_worktree: Some(git_worktree),
511 operation_timeout: None,
512 local_root: Some(backend.root.clone()),
513 })
514 }
515
516 pub fn workspace_ref(&self) -> &WorkspaceRef {
517 &self.workspace_ref
518 }
519
520 pub fn capabilities(&self) -> WorkspaceCapabilities {
521 self.capabilities
522 }
523
524 pub fn normalize_path(&self, input: &str) -> Result<WorkspacePath> {
525 self.path_resolver.normalize(input)
526 }
527
528 pub fn fs(&self) -> Arc<dyn WorkspaceFileSystem> {
529 Arc::clone(&self.file_system)
530 }
531
532 pub fn command_runner(&self) -> Option<Arc<dyn WorkspaceCommandRunner>> {
533 self.command_runner.clone()
534 }
535
536 pub fn search(&self) -> Option<Arc<dyn WorkspaceSearch>> {
537 self.search.clone()
538 }
539
540 pub fn git(&self) -> Option<Arc<dyn WorkspaceGit>> {
541 self.git.clone()
542 }
543
544 pub fn git_stash(&self) -> Option<Arc<dyn WorkspaceGitStashProvider>> {
545 self.git_stash.clone()
546 }
547
548 pub fn git_worktree(&self) -> Option<Arc<dyn WorkspaceGitWorktreeProvider>> {
549 self.git_worktree.clone()
550 }
551
552 pub fn operation_timeout(&self) -> Option<std::time::Duration> {
558 self.operation_timeout
559 }
560
561 pub async fn run_with_timeout<F, T>(&self, op: &'static str, fut: F) -> Result<T>
567 where
568 F: std::future::Future<Output = Result<T>>,
569 {
570 match self.operation_timeout {
571 Some(d) => tokio::time::timeout(d, fut)
572 .await
573 .map_err(|_| anyhow!("workspace operation '{}' timed out after {:?}", op, d))?,
574 None => fut.await,
575 }
576 }
577
578 pub fn local_root(&self) -> Option<&Path> {
579 self.local_root.as_deref()
580 }
581
582 pub fn display_path(&self, path: &WorkspacePath) -> String {
583 if path.is_root() {
584 return self.workspace_ref.display_root.clone();
585 }
586
587 let root = self.workspace_ref.display_root.trim_end_matches('/');
588 if root.is_empty() {
589 path.as_str().to_string()
590 } else {
591 format!("{root}/{}", path.as_str())
592 }
593 }
594}
595
596pub struct WorkspaceServicesBuilder {
598 workspace_ref: WorkspaceRef,
599 capabilities: WorkspaceCapabilities,
600 path_resolver: Arc<dyn WorkspacePathResolver>,
601 file_system: Arc<dyn WorkspaceFileSystem>,
602 command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
603 search: Option<Arc<dyn WorkspaceSearch>>,
604 git: Option<Arc<dyn WorkspaceGit>>,
605 git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
606 git_worktree: Option<Arc<dyn WorkspaceGitWorktreeProvider>>,
607 operation_timeout: Option<std::time::Duration>,
608}
609
610impl WorkspaceServicesBuilder {
611 pub fn new(workspace_ref: WorkspaceRef, file_system: Arc<dyn WorkspaceFileSystem>) -> Self {
612 Self {
613 workspace_ref,
614 capabilities: WorkspaceCapabilities::read_write(),
615 path_resolver: Arc::new(VirtualPathResolver),
616 file_system,
617 command_runner: None,
618 search: None,
619 git: None,
620 git_stash: None,
621 git_worktree: None,
622 operation_timeout: None,
623 }
624 }
625
626 pub fn capabilities(mut self, capabilities: WorkspaceCapabilities) -> Self {
627 self.capabilities = capabilities;
628 self
629 }
630
631 pub fn command_runner(mut self, command_runner: Arc<dyn WorkspaceCommandRunner>) -> Self {
632 self.capabilities.exec = true;
633 self.command_runner = Some(command_runner);
634 self
635 }
636
637 pub fn search(mut self, search: Arc<dyn WorkspaceSearch>) -> Self {
638 self.capabilities.search = true;
639 self.search = Some(search);
640 self
641 }
642
643 pub fn git(mut self, git: Arc<dyn WorkspaceGit>) -> Self {
644 self.capabilities.git = true;
645 self.git = Some(git);
646 self
647 }
648
649 pub fn git_stash(mut self, git_stash: Arc<dyn WorkspaceGitStashProvider>) -> Self {
650 self.git_stash = Some(git_stash);
651 self
652 }
653
654 pub fn git_worktree(mut self, git_worktree: Arc<dyn WorkspaceGitWorktreeProvider>) -> Self {
655 self.git_worktree = Some(git_worktree);
656 self
657 }
658
659 pub fn operation_timeout(mut self, timeout: std::time::Duration) -> Self {
663 self.operation_timeout = Some(timeout);
664 self
665 }
666
667 pub fn build(self) -> Arc<WorkspaceServices> {
668 let mut services = WorkspaceServices::new_with_git(
669 self.workspace_ref,
670 self.capabilities,
671 self.path_resolver,
672 self.file_system,
673 self.command_runner,
674 self.search,
675 self.git,
676 );
677 services.git_stash = self.git_stash;
678 services.git_worktree = self.git_worktree;
679 services.operation_timeout = self.operation_timeout;
680 Arc::new(services)
681 }
682}
683
684#[derive(Debug, Default)]
686pub struct VirtualPathResolver;
687
688impl WorkspacePathResolver for VirtualPathResolver {
689 fn normalize(&self, input: &str) -> Result<WorkspacePath> {
690 normalize_virtual_path(input)
691 }
692}
693
694fn normalize_virtual_path(input: &str) -> Result<WorkspacePath> {
695 let input = default_path_input(input);
696 if has_windows_path_prefix(input) {
697 bail!("Absolute paths are not supported by this workspace backend");
698 }
699
700 let normalized_input = input.replace('\\', "/");
701 let path = Path::new(&normalized_input);
702 if path.is_absolute() {
703 bail!("Absolute paths are not supported by this workspace backend");
704 }
705
706 let relative = normalize_relative_path(path)?;
707 Ok(pathbuf_to_workspace_path(&relative))
708}
709
710fn default_path_input(input: &str) -> &str {
711 let trimmed = input.trim();
712 if trimmed.is_empty() {
713 "."
714 } else {
715 trimmed
716 }
717}
718
719fn has_windows_path_prefix(input: &str) -> bool {
720 let bytes = input.as_bytes();
721 if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
722 return true;
723 }
724
725 input.starts_with("\\\\") || input.starts_with("//")
726}
727
728fn validate_relative_pattern(pattern: &str, label: &str) -> Result<()> {
729 let pattern = pattern.trim();
730 if pattern.is_empty() {
731 bail!("{label} cannot be empty");
732 }
733 if has_windows_path_prefix(pattern) || Path::new(pattern).is_absolute() {
734 bail!("{label} must be relative to the workspace");
735 }
736
737 let normalized = pattern.replace('\\', "/");
738 if normalized.split('/').any(|component| component == "..") {
739 bail!("{label} must not contain parent directory traversal");
740 }
741
742 Ok(())
743}
744
745fn normalize_relative_path(path: &Path) -> Result<PathBuf> {
746 let mut out = PathBuf::new();
747 for component in path.components() {
748 match component {
749 Component::CurDir => {}
750 Component::Normal(part) => out.push(part),
751 Component::ParentDir => {
752 if !out.pop() {
753 bail!("Workspace boundary violation: path escapes workspace");
754 }
755 }
756 Component::RootDir | Component::Prefix(_) => {
757 bail!("Absolute paths are not supported by this workspace backend");
758 }
759 }
760 }
761 Ok(out)
762}
763
764fn pathbuf_to_workspace_path(path: &Path) -> WorkspacePath {
765 let display = path.to_string_lossy().replace('\\', "/");
766 WorkspacePath::from_normalized(display)
767}
768
769#[cfg(test)]
770mod tests {
771 use super::*;
772
773 #[test]
774 fn virtual_resolver_normalizes_relative_paths() {
775 let resolver = VirtualPathResolver;
776 let path = resolver.normalize("./src/../README.md").unwrap();
777 assert_eq!(path.as_str(), "README.md");
778 }
779
780 #[test]
781 fn virtual_resolver_normalizes_backslash_separators() {
782 let resolver = VirtualPathResolver;
783 let path = resolver.normalize(r"src\main.rs").unwrap();
784 assert_eq!(path.as_str(), "src/main.rs");
785 }
786
787 #[test]
788 fn virtual_resolver_rejects_escape() {
789 let resolver = VirtualPathResolver;
790 let err = resolver.normalize("../secret.txt").unwrap_err();
791 assert!(err.to_string().contains("escapes workspace"));
792 }
793
794 #[test]
795 fn virtual_resolver_rejects_backslash_escape() {
796 let resolver = VirtualPathResolver;
797 let err = resolver.normalize(r"..\secret.txt").unwrap_err();
798 assert!(err.to_string().contains("escapes workspace"));
799 }
800
801 #[test]
802 fn virtual_resolver_rejects_absolute_paths() {
803 let resolver = VirtualPathResolver;
804 let err = resolver.normalize("/tmp/secret.txt").unwrap_err();
805 assert!(err.to_string().contains("Absolute paths"));
806 }
807
808 #[test]
809 fn virtual_resolver_rejects_windows_absolute_paths() {
810 let resolver = VirtualPathResolver;
811
812 let drive_err = resolver.normalize(r"C:\Users\secret.txt").unwrap_err();
813 assert!(drive_err.to_string().contains("Absolute paths"));
814
815 let unc_err = resolver
816 .normalize(r"\\server\share\secret.txt")
817 .unwrap_err();
818 assert!(unc_err.to_string().contains("Absolute paths"));
819 }
820
821 #[test]
822 fn workspace_services_disable_exec_without_runner() {
823 struct EmptyFs;
824
825 #[async_trait]
826 impl WorkspaceFileSystem for EmptyFs {
827 async fn read_text(&self, _path: &WorkspacePath) -> Result<String> {
828 bail!("not implemented")
829 }
830
831 async fn write_text(
832 &self,
833 _path: &WorkspacePath,
834 _content: &str,
835 ) -> Result<WorkspaceWriteOutcome> {
836 bail!("not implemented")
837 }
838
839 async fn list_dir(&self, _path: &WorkspacePath) -> Result<Vec<WorkspaceDirEntry>> {
840 bail!("not implemented")
841 }
842 }
843
844 let fs_backend: Arc<dyn WorkspaceFileSystem> = Arc::new(EmptyFs);
845 let services = WorkspaceServices::builder(
846 WorkspaceRef::new("virtual", "virtual://workspace"),
847 fs_backend,
848 )
849 .capabilities(WorkspaceCapabilities {
850 exec: true,
851 ..WorkspaceCapabilities::read_write()
852 })
853 .build();
854
855 assert!(!services.capabilities().exec);
856 assert!(services.command_runner().is_none());
857 }
858}