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