1#[cfg(test)]
11pub(crate) mod conformance;
12mod error;
13mod local;
14mod manifest;
15mod remote_git;
16#[cfg(feature = "s3")]
17mod s3;
18
19pub use error::{WorkspaceError, WorkspaceResult};
20pub use local::LocalWorkspaceBackend;
21pub use manifest::{
22 scan_workspace_files, LocalWorkspaceFile, LocalWorkspaceFileStatus, LocalWorkspaceManifest,
23 LocalWorkspaceManifestSnapshot, ManifestWorkspaceBackend, RecentWorkspaceFile,
24};
25pub use remote_git::{RemoteGitBackend, RemoteGitBackendConfig, RemoteGitConflict};
26#[cfg(feature = "s3")]
27pub use s3::{S3BackendConfig, S3WorkspaceBackend};
28
29use anyhow::{anyhow, bail, Result};
30use async_trait::async_trait;
31use std::collections::HashMap;
32use std::path::{Component, Path, PathBuf};
33use std::sync::Arc;
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct WorkspaceRef {
38 pub id: String,
40 pub display_root: String,
42}
43
44impl WorkspaceRef {
45 pub fn new(id: impl Into<String>, display_root: impl Into<String>) -> Self {
46 Self {
47 id: id.into(),
48 display_root: display_root.into(),
49 }
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Hash)]
55pub struct WorkspacePath {
56 inner: String,
57}
58
59impl WorkspacePath {
60 pub fn root() -> Self {
61 Self {
62 inner: ".".to_string(),
63 }
64 }
65
66 pub fn from_normalized(path: impl Into<String>) -> Self {
67 let path = path.into();
68 let path = path.trim_matches('/');
69 if path.is_empty() || path == "." {
70 Self::root()
71 } else {
72 Self {
73 inner: path.replace('\\', "/"),
74 }
75 }
76 }
77
78 pub fn as_str(&self) -> &str {
79 &self.inner
80 }
81
82 pub fn is_root(&self) -> bool {
83 self.inner == "."
84 }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub struct WorkspaceCapabilities {
94 pub read: bool,
95 pub write: bool,
96 pub exec: bool,
97 pub search: bool,
98 pub git: bool,
99}
100
101impl WorkspaceCapabilities {
102 pub fn local_default() -> Self {
103 Self {
104 read: true,
105 write: true,
106 exec: true,
107 search: true,
108 git: true,
109 }
110 }
111
112 pub fn read_write() -> Self {
113 Self {
114 read: true,
115 write: true,
116 exec: false,
117 search: false,
118 git: false,
119 }
120 }
121}
122
123impl Default for WorkspaceCapabilities {
124 fn default() -> Self {
125 Self::read_write()
126 }
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum WorkspaceFileType {
132 File,
133 Directory,
134 Symlink,
135 Unknown,
136}
137
138impl WorkspaceFileType {
139 pub fn as_tool_kind(self) -> &'static str {
140 match self {
141 Self::File => "file",
142 Self::Directory => "dir",
143 Self::Symlink => "link",
144 Self::Unknown => "unknown",
145 }
146 }
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct WorkspaceDirEntry {
152 pub name: String,
153 pub kind: WorkspaceFileType,
154 pub size: u64,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct WorkspaceWriteOutcome {
160 pub bytes: usize,
161 pub lines: usize,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct WorkspaceGlobRequest {
167 pub base: WorkspacePath,
168 pub pattern: String,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct WorkspaceGlobResult {
174 pub matches: Vec<WorkspacePath>,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct WorkspaceGrepRequest {
180 pub base: WorkspacePath,
181 pub pattern: String,
182 pub glob: Option<String>,
183 pub context_lines: usize,
184 pub case_insensitive: bool,
185 pub max_output_size: usize,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct WorkspaceGrepResult {
191 pub output: String,
192 pub match_count: usize,
193 pub file_count: usize,
194 pub truncated: bool,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct WorkspaceGitStatus {
200 pub branch: String,
201 pub commit: String,
202 pub is_worktree: bool,
203 pub is_dirty: bool,
204 pub dirty_count: usize,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct WorkspaceGitCommit {
210 pub id: String,
211 pub message: String,
212 pub author: String,
213 pub date: String,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct WorkspaceGitBranch {
219 pub name: String,
220 pub is_current: bool,
221}
222
223#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct WorkspaceGitCreateBranchRequest {
226 pub name: String,
227 pub base: String,
228}
229
230#[derive(Debug, Clone, PartialEq, Eq)]
232pub struct WorkspaceGitCheckoutRequest {
233 pub refspec: String,
234 pub force: bool,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq)]
239pub struct WorkspaceGitCheckoutOutput {
240 pub stdout: String,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq)]
245pub struct WorkspaceGitDiffRequest {
246 pub target: Option<String>,
247}
248
249#[derive(Debug, Clone, PartialEq, Eq)]
251pub struct WorkspaceGitStash {
252 pub index: usize,
253 pub message: String,
254}
255
256#[derive(Debug, Clone, PartialEq, Eq)]
258pub struct WorkspaceGitStashRequest {
259 pub message: Option<String>,
260 pub include_untracked: bool,
261}
262
263#[derive(Debug, Clone, PartialEq, Eq)]
265pub struct WorkspaceGitRemote {
266 pub name: String,
267 pub url: String,
268 pub direction: String,
269}
270
271#[derive(Debug, Clone, PartialEq, Eq)]
273pub struct WorkspaceGitWorktree {
274 pub path: String,
275 pub branch: String,
276 pub is_bare: bool,
277 pub is_detached: bool,
278}
279
280#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct WorkspaceGitCreateWorktreeRequest {
283 pub branch: String,
284 pub path: Option<String>,
285 pub new_branch: bool,
286}
287
288#[derive(Debug, Clone, PartialEq, Eq)]
290pub struct WorkspaceGitRemoveWorktreeRequest {
291 pub path: String,
292 pub force: bool,
293}
294
295#[derive(Debug, Clone, PartialEq, Eq)]
297pub struct WorkspaceGitWorktreeMutation {
298 pub path: String,
299 pub branch: Option<String>,
300}
301
302#[async_trait]
308pub trait CommandOutputObserver: Send + Sync {
309 async fn on_output_delta(&self, delta: &str);
310}
311
312#[derive(Clone)]
314pub struct CommandRequest {
315 pub command: String,
316 pub timeout_ms: u64,
317 pub output_observer: Option<Arc<dyn CommandOutputObserver>>,
318 pub env: Option<Arc<HashMap<String, String>>>,
319}
320
321impl std::fmt::Debug for CommandRequest {
322 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
323 f.debug_struct("CommandRequest")
324 .field("command", &self.command)
325 .field("timeout_ms", &self.timeout_ms)
326 .field("output_observer", &self.output_observer.is_some())
327 .field("env", &self.env.as_ref().map(|env| env.len()))
328 .finish()
329 }
330}
331
332#[derive(Debug, Clone, PartialEq, Eq)]
334pub struct CommandOutput {
335 pub output: String,
336 pub exit_code: i32,
337 pub timed_out: bool,
338}
339
340pub trait WorkspacePathResolver: Send + Sync {
342 fn normalize(&self, input: &str) -> Result<WorkspacePath>;
343}
344
345#[async_trait]
355pub trait WorkspaceFileSystem: Send + Sync {
356 async fn read_text(&self, path: &WorkspacePath) -> WorkspaceResult<String>;
357 async fn write_text(
358 &self,
359 path: &WorkspacePath,
360 content: &str,
361 ) -> WorkspaceResult<WorkspaceWriteOutcome>;
362 async fn list_dir(&self, path: &WorkspacePath) -> WorkspaceResult<Vec<WorkspaceDirEntry>>;
363}
364
365#[derive(Debug, Clone, thiserror::Error)]
372#[error(
373 "version conflict on {path}: expected version {expected:?}, found {actual:?} (file modified by another writer; re-read and retry)"
374)]
375pub struct WorkspaceVersionConflict {
376 pub path: String,
377 pub expected: String,
378 pub actual: Option<String>,
381}
382
383#[async_trait]
395pub trait WorkspaceFileSystemExt: Send + Sync {
396 async fn read_text_with_version(
401 &self,
402 path: &WorkspacePath,
403 ) -> WorkspaceResult<(String, String)>;
404
405 async fn write_text_if_version(
411 &self,
412 path: &WorkspacePath,
413 content: &str,
414 expected_version: &str,
415 ) -> WorkspaceResult<WorkspaceWriteOutcome>;
416}
417
418#[async_trait]
420pub trait WorkspaceCommandRunner: Send + Sync {
421 async fn exec(&self, request: CommandRequest) -> Result<CommandOutput>;
422}
423
424#[async_trait]
426pub trait WorkspaceSearch: Send + Sync {
427 async fn glob(&self, request: WorkspaceGlobRequest) -> Result<WorkspaceGlobResult>;
428 async fn grep(&self, request: WorkspaceGrepRequest) -> Result<WorkspaceGrepResult>;
429}
430
431#[async_trait]
437pub trait WorkspaceGit: Send + Sync {
438 async fn is_repository(&self) -> Result<bool>;
439 async fn status(&self) -> Result<WorkspaceGitStatus>;
440 async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>>;
441 async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>>;
442 async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()>;
443 async fn checkout(
444 &self,
445 request: WorkspaceGitCheckoutRequest,
446 ) -> Result<WorkspaceGitCheckoutOutput>;
447 async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String>;
448 async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>>;
449}
450
451#[async_trait]
456pub trait WorkspaceGitStashProvider: Send + Sync {
457 async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>>;
458 async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()>;
459}
460
461#[async_trait]
466pub trait WorkspaceGitWorktreeProvider: Send + Sync {
467 async fn list_worktrees(&self) -> Result<Vec<WorkspaceGitWorktree>>;
468 async fn create_worktree(
469 &self,
470 request: WorkspaceGitCreateWorktreeRequest,
471 ) -> Result<WorkspaceGitWorktreeMutation>;
472 async fn remove_worktree(
473 &self,
474 request: WorkspaceGitRemoveWorktreeRequest,
475 ) -> Result<WorkspaceGitWorktreeMutation>;
476}
477
478pub struct WorkspaceServices {
480 workspace_ref: WorkspaceRef,
481 capabilities: WorkspaceCapabilities,
482 path_resolver: Arc<dyn WorkspacePathResolver>,
483 file_system: Arc<dyn WorkspaceFileSystem>,
484 file_system_ext: Option<Arc<dyn WorkspaceFileSystemExt>>,
485 command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
486 search: Option<Arc<dyn WorkspaceSearch>>,
487 git: Option<Arc<dyn WorkspaceGit>>,
488 git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
489 git_worktree: Option<Arc<dyn WorkspaceGitWorktreeProvider>>,
490 operation_timeout: Option<std::time::Duration>,
494 local_root: Option<PathBuf>,
495}
496
497impl std::fmt::Debug for WorkspaceServices {
498 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
499 f.debug_struct("WorkspaceServices")
500 .field("workspace_ref", &self.workspace_ref)
501 .field("capabilities", &self.capabilities)
502 .field("file_system_ext", &self.file_system_ext.is_some())
503 .field("command_runner", &self.command_runner.is_some())
504 .field("search", &self.search.is_some())
505 .field("git", &self.git.is_some())
506 .field("git_stash", &self.git_stash.is_some())
507 .field("git_worktree", &self.git_worktree.is_some())
508 .field("local_root", &self.local_root)
509 .finish()
510 }
511}
512
513impl WorkspaceServices {
514 pub(crate) fn new_with_git(
515 workspace_ref: WorkspaceRef,
516 mut capabilities: WorkspaceCapabilities,
517 path_resolver: Arc<dyn WorkspacePathResolver>,
518 file_system: Arc<dyn WorkspaceFileSystem>,
519 command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
520 search: Option<Arc<dyn WorkspaceSearch>>,
521 git: Option<Arc<dyn WorkspaceGit>>,
522 ) -> Self {
523 if command_runner.is_none() {
524 capabilities.exec = false;
525 }
526 if search.is_none() {
527 capabilities.search = false;
528 }
529 if git.is_none() {
530 capabilities.git = false;
531 }
532 Self {
533 workspace_ref,
534 capabilities,
535 path_resolver,
536 file_system,
537 file_system_ext: None,
538 command_runner,
539 search,
540 git,
541 git_stash: None,
542 git_worktree: None,
543 operation_timeout: None,
544 local_root: None,
545 }
546 }
547
548 pub fn builder(
549 workspace_ref: WorkspaceRef,
550 file_system: Arc<dyn WorkspaceFileSystem>,
551 ) -> WorkspaceServicesBuilder {
552 WorkspaceServicesBuilder::new(workspace_ref, file_system)
553 }
554
555 pub fn local(root: impl Into<PathBuf>) -> Arc<Self> {
556 let backend = Arc::new(LocalWorkspaceBackend::new(root.into()));
557 let workspace_ref = WorkspaceRef::new(
558 backend.root.display().to_string(),
559 backend.root.display().to_string(),
560 );
561 let path_resolver: Arc<dyn WorkspacePathResolver> = backend.clone();
562 let file_system: Arc<dyn WorkspaceFileSystem> = backend.clone();
563 let command_runner: Arc<dyn WorkspaceCommandRunner> = backend.clone();
564 let search: Arc<dyn WorkspaceSearch> = backend.clone();
565 let git: Arc<dyn WorkspaceGit> = backend.clone();
566 let git_stash: Arc<dyn WorkspaceGitStashProvider> = backend.clone();
567 let git_worktree: Arc<dyn WorkspaceGitWorktreeProvider> = backend.clone();
568 Arc::new(Self {
569 workspace_ref,
570 capabilities: WorkspaceCapabilities::local_default(),
571 path_resolver,
572 file_system,
573 file_system_ext: None,
574 command_runner: Some(command_runner),
575 search: Some(search),
576 git: Some(git),
577 git_stash: Some(git_stash),
578 git_worktree: Some(git_worktree),
579 operation_timeout: None,
580 local_root: Some(backend.root.clone()),
581 })
582 }
583
584 pub fn local_with_manifest(root: impl Into<PathBuf>) -> Arc<Self> {
589 let backend = ManifestWorkspaceBackend::new(root);
590 Self::local_with_manifest_backend(backend)
591 }
592
593 pub fn local_with_manifest_backend(backend: Arc<ManifestWorkspaceBackend>) -> Arc<Self> {
596 let workspace_ref = WorkspaceRef::new(
597 backend.local_root().display().to_string(),
598 backend.local_root().display().to_string(),
599 );
600 let path_resolver: Arc<dyn WorkspacePathResolver> = backend.clone();
601 let file_system: Arc<dyn WorkspaceFileSystem> = backend.clone();
602 let command_runner: Arc<dyn WorkspaceCommandRunner> = backend.clone();
603 let search: Arc<dyn WorkspaceSearch> = backend.clone();
604 let git: Arc<dyn WorkspaceGit> = backend.clone();
605 let git_stash: Arc<dyn WorkspaceGitStashProvider> = backend.clone();
606 let git_worktree: Arc<dyn WorkspaceGitWorktreeProvider> = backend.clone();
607 Arc::new(Self {
608 workspace_ref,
609 capabilities: WorkspaceCapabilities::local_default(),
610 path_resolver,
611 file_system,
612 file_system_ext: None,
613 command_runner: Some(command_runner),
614 search: Some(search),
615 git: Some(git),
616 git_stash: Some(git_stash),
617 git_worktree: Some(git_worktree),
618 operation_timeout: None,
619 local_root: Some(backend.local_root().to_path_buf()),
620 })
621 }
622
623 pub fn workspace_ref(&self) -> &WorkspaceRef {
624 &self.workspace_ref
625 }
626
627 pub fn capabilities(&self) -> WorkspaceCapabilities {
628 self.capabilities
629 }
630
631 pub fn normalize_path(&self, input: &str) -> Result<WorkspacePath> {
632 self.path_resolver.normalize(input)
633 }
634
635 pub fn fs(&self) -> Arc<dyn WorkspaceFileSystem> {
636 Arc::clone(&self.file_system)
637 }
638
639 pub fn fs_ext(&self) -> Option<Arc<dyn WorkspaceFileSystemExt>> {
646 self.file_system_ext.clone()
647 }
648
649 pub fn command_runner(&self) -> Option<Arc<dyn WorkspaceCommandRunner>> {
650 self.command_runner.clone()
651 }
652
653 pub fn search(&self) -> Option<Arc<dyn WorkspaceSearch>> {
654 self.search.clone()
655 }
656
657 pub fn git(&self) -> Option<Arc<dyn WorkspaceGit>> {
658 self.git.clone()
659 }
660
661 pub fn git_stash(&self) -> Option<Arc<dyn WorkspaceGitStashProvider>> {
662 self.git_stash.clone()
663 }
664
665 pub fn git_worktree(&self) -> Option<Arc<dyn WorkspaceGitWorktreeProvider>> {
666 self.git_worktree.clone()
667 }
668
669 pub(crate) fn with_git_provider(
686 &self,
687 git: Arc<dyn WorkspaceGit>,
688 git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
689 ) -> Arc<Self> {
690 let mut capabilities = self.capabilities;
691 capabilities.git = true;
692 Arc::new(Self {
693 workspace_ref: self.workspace_ref.clone(),
694 capabilities,
695 path_resolver: Arc::clone(&self.path_resolver),
696 file_system: Arc::clone(&self.file_system),
697 file_system_ext: self.file_system_ext.clone(),
698 command_runner: self.command_runner.clone(),
699 search: self.search.clone(),
700 git: Some(git),
701 git_stash,
702 git_worktree: None,
703 operation_timeout: self.operation_timeout,
704 local_root: self.local_root.clone(),
705 })
706 }
707
708 pub fn operation_timeout(&self) -> Option<std::time::Duration> {
714 self.operation_timeout
715 }
716
717 pub async fn run_with_timeout<F, T, E>(
731 &self,
732 op: &'static str,
733 fut: F,
734 ) -> std::result::Result<T, E>
735 where
736 F: std::future::Future<Output = std::result::Result<T, E>>,
737 E: From<anyhow::Error>,
738 {
739 match self.operation_timeout {
740 Some(d) => tokio::time::timeout(d, fut).await.map_err(|_| {
741 E::from(anyhow!(
742 "workspace operation '{}' timed out after {:?}",
743 op,
744 d
745 ))
746 })?,
747 None => fut.await,
748 }
749 }
750
751 pub async fn read_for_edit(
758 &self,
759 path: &WorkspacePath,
760 ) -> WorkspaceResult<(String, Option<String>)> {
761 if let Some(ext) = self.fs_ext() {
762 let path = path.clone();
763 return self
764 .run_with_timeout("read_text_with_version", async move {
765 let (content, version) = ext.read_text_with_version(&path).await?;
766 Ok((content, Some(version)))
767 })
768 .await;
769 }
770 let fs = self.fs();
771 let path_owned = path.clone();
772 let content = self
773 .run_with_timeout("read_text", async move { fs.read_text(&path_owned).await })
774 .await?;
775 Ok((content, None))
776 }
777
778 pub async fn write_for_edit(
786 &self,
787 path: &WorkspacePath,
788 content: &str,
789 expected_version: Option<&str>,
790 ) -> WorkspaceResult<WorkspaceWriteOutcome> {
791 if let (Some(ext), Some(version)) = (self.fs_ext(), expected_version) {
792 let path = path.clone();
793 let content = content.to_string();
794 let expected = version.to_string();
795 return self
796 .run_with_timeout("write_text_if_version", async move {
797 ext.write_text_if_version(&path, &content, &expected).await
798 })
799 .await;
800 }
801 let fs = self.fs();
802 let path = path.clone();
803 let content = content.to_string();
804 self.run_with_timeout(
805 "write_text",
806 async move { fs.write_text(&path, &content).await },
807 )
808 .await
809 }
810
811 pub fn local_root(&self) -> Option<&Path> {
812 self.local_root.as_deref()
813 }
814
815 pub fn display_path(&self, path: &WorkspacePath) -> String {
816 if path.is_root() {
817 return self.workspace_ref.display_root.clone();
818 }
819
820 let root = self.workspace_ref.display_root.trim_end_matches('/');
821 if root.is_empty() {
822 path.as_str().to_string()
823 } else {
824 format!("{root}/{}", path.as_str())
825 }
826 }
827}
828
829pub struct WorkspaceServicesBuilder {
831 workspace_ref: WorkspaceRef,
832 capabilities: WorkspaceCapabilities,
833 path_resolver: Arc<dyn WorkspacePathResolver>,
834 file_system: Arc<dyn WorkspaceFileSystem>,
835 file_system_ext: Option<Arc<dyn WorkspaceFileSystemExt>>,
836 command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
837 search: Option<Arc<dyn WorkspaceSearch>>,
838 git: Option<Arc<dyn WorkspaceGit>>,
839 git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
840 git_worktree: Option<Arc<dyn WorkspaceGitWorktreeProvider>>,
841 operation_timeout: Option<std::time::Duration>,
842}
843
844impl WorkspaceServicesBuilder {
845 pub fn new(workspace_ref: WorkspaceRef, file_system: Arc<dyn WorkspaceFileSystem>) -> Self {
846 Self {
847 workspace_ref,
848 capabilities: WorkspaceCapabilities::read_write(),
849 path_resolver: Arc::new(VirtualPathResolver),
850 file_system,
851 file_system_ext: None,
852 command_runner: None,
853 search: None,
854 git: None,
855 git_stash: None,
856 git_worktree: None,
857 operation_timeout: None,
858 }
859 }
860
861 pub fn capabilities(mut self, capabilities: WorkspaceCapabilities) -> Self {
862 self.capabilities = capabilities;
863 self
864 }
865
866 pub fn command_runner(mut self, command_runner: Arc<dyn WorkspaceCommandRunner>) -> Self {
867 self.capabilities.exec = true;
868 self.command_runner = Some(command_runner);
869 self
870 }
871
872 pub fn search(mut self, search: Arc<dyn WorkspaceSearch>) -> Self {
873 self.capabilities.search = true;
874 self.search = Some(search);
875 self
876 }
877
878 pub fn git(mut self, git: Arc<dyn WorkspaceGit>) -> Self {
879 self.capabilities.git = true;
880 self.git = Some(git);
881 self
882 }
883
884 pub fn git_stash(mut self, git_stash: Arc<dyn WorkspaceGitStashProvider>) -> Self {
885 self.git_stash = Some(git_stash);
886 self
887 }
888
889 pub fn git_worktree(mut self, git_worktree: Arc<dyn WorkspaceGitWorktreeProvider>) -> Self {
890 self.git_worktree = Some(git_worktree);
891 self
892 }
893
894 pub fn file_system_ext(mut self, ext: Arc<dyn WorkspaceFileSystemExt>) -> Self {
899 self.file_system_ext = Some(ext);
900 self
901 }
902
903 pub fn operation_timeout(mut self, timeout: std::time::Duration) -> Self {
907 self.operation_timeout = Some(timeout);
908 self
909 }
910
911 pub fn build(self) -> Arc<WorkspaceServices> {
912 let mut services = WorkspaceServices::new_with_git(
913 self.workspace_ref,
914 self.capabilities,
915 self.path_resolver,
916 self.file_system,
917 self.command_runner,
918 self.search,
919 self.git,
920 );
921 services.file_system_ext = self.file_system_ext;
922 services.git_stash = self.git_stash;
923 services.git_worktree = self.git_worktree;
924 services.operation_timeout = self.operation_timeout;
925 Arc::new(services)
926 }
927}
928
929#[derive(Debug, Default)]
931pub struct VirtualPathResolver;
932
933impl WorkspacePathResolver for VirtualPathResolver {
934 fn normalize(&self, input: &str) -> Result<WorkspacePath> {
935 normalize_virtual_path(input)
936 }
937}
938
939fn normalize_virtual_path(input: &str) -> Result<WorkspacePath> {
940 let input = default_path_input(input);
941 if has_windows_path_prefix(input) {
942 bail!("Absolute paths are not supported by this workspace backend");
943 }
944
945 let normalized_input = input.replace('\\', "/");
946 let path = Path::new(&normalized_input);
947 if path.is_absolute() {
948 bail!("Absolute paths are not supported by this workspace backend");
949 }
950
951 let relative = normalize_relative_path(path)?;
952 Ok(pathbuf_to_workspace_path(&relative))
953}
954
955fn default_path_input(input: &str) -> &str {
956 let trimmed = input.trim();
957 if trimmed.is_empty() {
958 "."
959 } else {
960 trimmed
961 }
962}
963
964fn has_windows_path_prefix(input: &str) -> bool {
965 let bytes = input.as_bytes();
966 if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
967 return true;
968 }
969
970 input.starts_with("\\\\") || input.starts_with("//")
971}
972
973pub(crate) fn validate_relative_pattern(pattern: &str, label: &str) -> Result<()> {
974 let pattern = pattern.trim();
975 if pattern.is_empty() {
976 bail!("{label} cannot be empty");
977 }
978 if has_windows_path_prefix(pattern) || Path::new(pattern).is_absolute() {
979 bail!("{label} must be relative to the workspace");
980 }
981
982 let normalized = pattern.replace('\\', "/");
983 if normalized.split('/').any(|component| component == "..") {
984 bail!("{label} must not contain parent directory traversal");
985 }
986
987 Ok(())
988}
989
990fn normalize_relative_path(path: &Path) -> Result<PathBuf> {
991 let mut out = PathBuf::new();
992 for component in path.components() {
993 match component {
994 Component::CurDir => {}
995 Component::Normal(part) => out.push(part),
996 Component::ParentDir => {
997 if !out.pop() {
998 bail!("Workspace boundary violation: path escapes workspace");
999 }
1000 }
1001 Component::RootDir | Component::Prefix(_) => {
1002 bail!("Absolute paths are not supported by this workspace backend");
1003 }
1004 }
1005 }
1006 Ok(out)
1007}
1008
1009fn pathbuf_to_workspace_path(path: &Path) -> WorkspacePath {
1010 let display = path.to_string_lossy().replace('\\', "/");
1011 WorkspacePath::from_normalized(display)
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016 use super::*;
1017
1018 #[test]
1019 fn virtual_resolver_normalizes_relative_paths() {
1020 let resolver = VirtualPathResolver;
1021 let path = resolver.normalize("./src/../README.md").unwrap();
1022 assert_eq!(path.as_str(), "README.md");
1023 }
1024
1025 #[test]
1026 fn virtual_resolver_normalizes_backslash_separators() {
1027 let resolver = VirtualPathResolver;
1028 let path = resolver.normalize(r"src\main.rs").unwrap();
1029 assert_eq!(path.as_str(), "src/main.rs");
1030 }
1031
1032 #[test]
1033 fn virtual_resolver_rejects_escape() {
1034 let resolver = VirtualPathResolver;
1035 let err = resolver.normalize("../secret.txt").unwrap_err();
1036 assert!(err.to_string().contains("escapes workspace"));
1037 }
1038
1039 #[test]
1040 fn virtual_resolver_rejects_backslash_escape() {
1041 let resolver = VirtualPathResolver;
1042 let err = resolver.normalize(r"..\secret.txt").unwrap_err();
1043 assert!(err.to_string().contains("escapes workspace"));
1044 }
1045
1046 #[test]
1047 fn virtual_resolver_rejects_absolute_paths() {
1048 let resolver = VirtualPathResolver;
1049 let err = resolver.normalize("/tmp/secret.txt").unwrap_err();
1050 assert!(err.to_string().contains("Absolute paths"));
1051 }
1052
1053 #[test]
1054 fn virtual_resolver_rejects_windows_absolute_paths() {
1055 let resolver = VirtualPathResolver;
1056
1057 let drive_err = resolver.normalize(r"C:\Users\secret.txt").unwrap_err();
1058 assert!(drive_err.to_string().contains("Absolute paths"));
1059
1060 let unc_err = resolver
1061 .normalize(r"\\server\share\secret.txt")
1062 .unwrap_err();
1063 assert!(unc_err.to_string().contains("Absolute paths"));
1064 }
1065
1066 #[test]
1067 fn workspace_services_disable_exec_without_runner() {
1068 struct EmptyFs;
1069
1070 #[async_trait]
1071 impl WorkspaceFileSystem for EmptyFs {
1072 async fn read_text(&self, _path: &WorkspacePath) -> WorkspaceResult<String> {
1073 Err(WorkspaceError::Unsupported("not implemented".into()))
1074 }
1075
1076 async fn write_text(
1077 &self,
1078 _path: &WorkspacePath,
1079 _content: &str,
1080 ) -> WorkspaceResult<WorkspaceWriteOutcome> {
1081 Err(WorkspaceError::Unsupported("not implemented".into()))
1082 }
1083
1084 async fn list_dir(
1085 &self,
1086 _path: &WorkspacePath,
1087 ) -> WorkspaceResult<Vec<WorkspaceDirEntry>> {
1088 Err(WorkspaceError::Unsupported("not implemented".into()))
1089 }
1090 }
1091
1092 let fs_backend: Arc<dyn WorkspaceFileSystem> = Arc::new(EmptyFs);
1093 let services = WorkspaceServices::builder(
1094 WorkspaceRef::new("virtual", "virtual://workspace"),
1095 fs_backend,
1096 )
1097 .capabilities(WorkspaceCapabilities {
1098 exec: true,
1099 ..WorkspaceCapabilities::read_write()
1100 })
1101 .build();
1102
1103 assert!(!services.capabilities().exec);
1104 assert!(services.command_runner().is_none());
1105 }
1106
1107 use super::conformance::InMemoryFileSystem;
1118
1119 fn versioned_services(fs: Arc<InMemoryFileSystem>) -> Arc<WorkspaceServices> {
1120 let fs_ws: Arc<dyn WorkspaceFileSystem> = fs.clone();
1121 let fs_ext: Arc<dyn WorkspaceFileSystemExt> = fs;
1122 WorkspaceServices::builder(WorkspaceRef::new("mem", "mem://ws"), fs_ws)
1123 .file_system_ext(fs_ext)
1124 .build()
1125 }
1126
1127 async fn seed(fs: &Arc<InMemoryFileSystem>, path: &str, content: &str) -> String {
1131 use super::WorkspaceFileSystemExt;
1132 let ws_path = WorkspacePath::from_normalized(path);
1133 (*fs).write_text(&ws_path, content).await.unwrap();
1134 let (_, version) = (*fs).read_text_with_version(&ws_path).await.unwrap();
1135 version
1136 }
1137
1138 #[test]
1139 fn version_conflict_is_downcastable_from_anyhow() {
1140 let e: anyhow::Error = anyhow::Error::new(WorkspaceVersionConflict {
1141 path: "a/b.txt".to_string(),
1142 expected: "etag-1".to_string(),
1143 actual: Some("etag-2".to_string()),
1144 });
1145 let c = e.downcast_ref::<WorkspaceVersionConflict>().unwrap();
1146 assert_eq!(c.path, "a/b.txt");
1147 assert_eq!(c.expected, "etag-1");
1148 assert_eq!(c.actual.as_deref(), Some("etag-2"));
1149 let msg = e.to_string();
1151 assert!(msg.contains("a/b.txt"), "msg: {msg}");
1152 assert!(msg.contains("etag-1"), "msg: {msg}");
1153 }
1154
1155 #[tokio::test]
1156 async fn read_for_edit_returns_version_when_ext_available() {
1157 let fs = Arc::new(InMemoryFileSystem::new());
1158 let seeded_version = seed(&fs, "notes.md", "hello").await;
1159 let services = versioned_services(fs);
1160
1161 let path = WorkspacePath::from_normalized("notes.md");
1162 let (content, version) = services.read_for_edit(&path).await.unwrap();
1163 assert_eq!(content, "hello");
1164 assert_eq!(
1165 version.as_deref(),
1166 Some(seeded_version.as_str()),
1167 "read_for_edit must return the version produced by the prior write"
1168 );
1169 }
1170
1171 #[tokio::test]
1172 async fn read_for_edit_returns_no_version_when_ext_absent() {
1173 struct PlainFs;
1174 #[async_trait]
1175 impl WorkspaceFileSystem for PlainFs {
1176 async fn read_text(&self, _path: &WorkspacePath) -> WorkspaceResult<String> {
1177 Ok("plain".to_string())
1178 }
1179 async fn write_text(
1180 &self,
1181 _path: &WorkspacePath,
1182 content: &str,
1183 ) -> WorkspaceResult<WorkspaceWriteOutcome> {
1184 Ok(WorkspaceWriteOutcome {
1185 bytes: content.len(),
1186 lines: content.lines().count(),
1187 })
1188 }
1189 async fn list_dir(
1190 &self,
1191 _path: &WorkspacePath,
1192 ) -> WorkspaceResult<Vec<WorkspaceDirEntry>> {
1193 Ok(Vec::new())
1194 }
1195 }
1196 let fs: Arc<dyn WorkspaceFileSystem> = Arc::new(PlainFs);
1197 let services =
1198 WorkspaceServices::builder(WorkspaceRef::new("plain", "plain://ws"), fs).build();
1199
1200 let path = WorkspacePath::from_normalized("any.txt");
1201 let (content, version) = services.read_for_edit(&path).await.unwrap();
1202 assert_eq!(content, "plain");
1203 assert!(version.is_none());
1204 assert!(services.fs_ext().is_none());
1205 }
1206
1207 #[tokio::test]
1208 async fn write_for_edit_succeeds_on_matching_version() {
1209 let fs = Arc::new(InMemoryFileSystem::new());
1210 seed(&fs, "doc.md", "alpha").await;
1211 let services = versioned_services(fs.clone());
1212 let path = WorkspacePath::from_normalized("doc.md");
1213
1214 let (content, version) = services.read_for_edit(&path).await.unwrap();
1215 assert_eq!(content, "alpha");
1216
1217 services
1218 .write_for_edit(&path, "beta", version.as_deref())
1219 .await
1220 .expect("write should succeed with matching version");
1221
1222 let current = fs.read_text(&path).await.unwrap();
1223 assert_eq!(current, "beta");
1224 }
1225
1226 #[tokio::test]
1227 async fn write_for_edit_surfaces_conflict_when_version_changed() {
1228 let fs = Arc::new(InMemoryFileSystem::new());
1229 let seeded_version = seed(&fs, "doc.md", "alpha").await;
1230 let services = versioned_services(fs.clone());
1231 let path = WorkspacePath::from_normalized("doc.md");
1232
1233 let (_, version) = services.read_for_edit(&path).await.unwrap();
1234 fs.write_text(&path, "from-concurrent-writer")
1237 .await
1238 .unwrap();
1239
1240 let err = services
1241 .write_for_edit(&path, "beta", version.as_deref())
1242 .await
1243 .expect_err("write should reject with conflict");
1244 let WorkspaceError::VersionConflict(conflict) = err else {
1245 panic!("expected WorkspaceError::VersionConflict, got {err:?}");
1246 };
1247 assert_eq!(conflict.path, "doc.md");
1248 assert_eq!(conflict.expected, seeded_version);
1249 let actual = conflict
1252 .actual
1253 .as_deref()
1254 .expect("conflict must report the current version");
1255 assert_ne!(actual, seeded_version);
1256 }
1257
1258 #[tokio::test]
1259 async fn write_for_edit_falls_back_to_plain_write_when_version_is_none() {
1260 let fs = Arc::new(InMemoryFileSystem::new());
1263 seed(&fs, "doc.md", "alpha").await;
1264 let services = versioned_services(fs.clone());
1265 let path = WorkspacePath::from_normalized("doc.md");
1266
1267 fs.write_text(&path, "from-concurrent-writer")
1269 .await
1270 .unwrap();
1271
1272 services
1273 .write_for_edit(&path, "beta", None)
1274 .await
1275 .expect("plain write should not check version");
1276 let current = fs.read_text(&path).await.unwrap();
1277 assert_eq!(current, "beta");
1278 }
1279}