Skip to main content

a3s_code_core/workspace/
mod.rs

1//! Workspace capability abstractions.
2//!
3//! Built-in tools expose stable model-facing contracts (`read`, `write`, `ls`,
4//! `bash`, ...). The concrete place where those operations happen is supplied
5//! by a workspace capability backend. The default backend is the local
6//! filesystem (see [`LocalWorkspaceBackend`]); hosts can provide remote,
7//! browser, DFS, or container-backed implementations by assembling
8//! [`WorkspaceServices`] through [`WorkspaceServicesBuilder`].
9
10#[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/// Identity and display metadata for a workspace.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct WorkspaceRef {
38    /// Stable workspace identifier used by host backends.
39    pub id: String,
40    /// Human-readable root shown in tool output.
41    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/// A normalized virtual path inside a workspace.
54#[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/// Workspace capability flags used to gate which built-in tools are registered.
88///
89/// Each flag corresponds to a provider trait on [`WorkspaceServices`]; flags
90/// without a backing provider are deliberately omitted so the surface stays
91/// minimal until a real consumer appears.
92#[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/// Directory entry kind.
130#[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/// Directory entry returned by a workspace backend.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct WorkspaceDirEntry {
152    pub name: String,
153    pub kind: WorkspaceFileType,
154    pub size: u64,
155}
156
157/// Result metadata for a write operation.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct WorkspaceWriteOutcome {
160    pub bytes: usize,
161    pub lines: usize,
162}
163
164/// Glob request for workspace-backed search.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct WorkspaceGlobRequest {
167    pub base: WorkspacePath,
168    pub pattern: String,
169}
170
171/// Glob result returned by a workspace search provider.
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct WorkspaceGlobResult {
174    pub matches: Vec<WorkspacePath>,
175}
176
177/// Grep request for workspace-backed search.
178#[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/// Grep result returned by a workspace search provider.
189#[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/// Repository status returned by a workspace Git provider.
198#[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/// Commit information returned by a workspace Git provider.
208#[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/// Branch information returned by a workspace Git provider.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct WorkspaceGitBranch {
219    pub name: String,
220    pub is_current: bool,
221}
222
223/// Branch creation request for a workspace Git provider.
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct WorkspaceGitCreateBranchRequest {
226    pub name: String,
227    pub base: String,
228}
229
230/// Checkout request for a workspace Git provider.
231#[derive(Debug, Clone, PartialEq, Eq)]
232pub struct WorkspaceGitCheckoutRequest {
233    pub refspec: String,
234    pub force: bool,
235}
236
237/// Checkout output returned by a workspace Git provider.
238#[derive(Debug, Clone, PartialEq, Eq)]
239pub struct WorkspaceGitCheckoutOutput {
240    pub stdout: String,
241}
242
243/// Diff request for a workspace Git provider.
244#[derive(Debug, Clone, PartialEq, Eq)]
245pub struct WorkspaceGitDiffRequest {
246    pub target: Option<String>,
247}
248
249/// Stash information returned by a workspace Git provider.
250#[derive(Debug, Clone, PartialEq, Eq)]
251pub struct WorkspaceGitStash {
252    pub index: usize,
253    pub message: String,
254}
255
256/// Stash request for a workspace Git provider.
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub struct WorkspaceGitStashRequest {
259    pub message: Option<String>,
260    pub include_untracked: bool,
261}
262
263/// Remote information returned by a workspace Git provider.
264#[derive(Debug, Clone, PartialEq, Eq)]
265pub struct WorkspaceGitRemote {
266    pub name: String,
267    pub url: String,
268    pub direction: String,
269}
270
271/// Worktree information returned by a workspace Git provider.
272#[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/// Worktree creation request for a workspace Git provider.
281#[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/// Worktree removal request for a workspace Git provider.
289#[derive(Debug, Clone, PartialEq, Eq)]
290pub struct WorkspaceGitRemoveWorktreeRequest {
291    pub path: String,
292    pub force: bool,
293}
294
295/// Mutation result for workspace Git worktree operations.
296#[derive(Debug, Clone, PartialEq, Eq)]
297pub struct WorkspaceGitWorktreeMutation {
298    pub path: String,
299    pub branch: Option<String>,
300}
301
302/// Observer that receives streaming output deltas from a workspace command.
303///
304/// Backend implementations call this on each chunk of stdout/stderr they
305/// observe. Tool layers wire host event channels behind this trait, so the
306/// workspace abstraction does not depend on any tool event type.
307#[async_trait]
308pub trait CommandOutputObserver: Send + Sync {
309    async fn on_output_delta(&self, delta: &str);
310}
311
312/// Command execution request.
313#[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/// Command execution output.
333#[derive(Debug, Clone, PartialEq, Eq)]
334pub struct CommandOutput {
335    pub output: String,
336    pub exit_code: i32,
337    pub timed_out: bool,
338}
339
340/// Normalizes and validates host-supplied paths before they reach a backend.
341pub trait WorkspacePathResolver: Send + Sync {
342    fn normalize(&self, input: &str) -> Result<WorkspacePath>;
343}
344
345/// File operations available to built-in file tools.
346///
347/// **Trait stability policy:** new methods added to this trait are a breaking
348/// change for every external backend implementation. Until the workspace
349/// extension story is stabilised, new methods will be added to a separate
350/// `WorkspaceFileSystemExt` trait (with default implementations that fall back
351/// to the core methods) rather than to this trait directly. Backend authors
352/// can rely on this trait surface remaining additive only through extension
353/// traits.
354#[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/// Error returned by [`WorkspaceFileSystemExt::write_text_if_version`] when
366/// the underlying object version no longer matches the expected version.
367///
368/// Surfaced through `anyhow::Error`; tools recover by downcasting:
369/// `err.downcast_ref::<WorkspaceVersionConflict>()`. The typical response is
370/// to re-read the file and retry the modify-write cycle once.
371#[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    /// Backend-reported current version, if known. S3 does not return the
379    /// current ETag on `412 Precondition Failed`, so this is typically `None`.
380    pub actual: Option<String>,
381}
382
383/// Optional compare-and-swap extensions to [`WorkspaceFileSystem`].
384///
385/// Implemented by backends that expose object-level versioning (S3 ETag,
386/// future GCS generation, ...) so tools that perform read-modify-write
387/// cycles can reject concurrent overwrites. Tools should access this through
388/// [`WorkspaceServices::fs_ext`] — when absent, callers fall back to plain
389/// `read_text` / `write_text` (last-writer-wins).
390///
391/// Kept as a separate trait rather than inheriting from
392/// [`WorkspaceFileSystem`] so existing backend implementations are not
393/// forced to opt in.
394#[async_trait]
395pub trait WorkspaceFileSystemExt: Send + Sync {
396    /// Read text content together with an opaque version token. Tokens are
397    /// backend-specific (S3 returns the ETag) and treated as opaque by
398    /// callers — they are only ever compared for equality on the backend
399    /// side.
400    async fn read_text_with_version(
401        &self,
402        path: &WorkspacePath,
403    ) -> WorkspaceResult<(String, String)>;
404
405    /// Write content iff the current object version matches `expected_version`.
406    /// On mismatch the returned error is the typed
407    /// [`WorkspaceError::VersionConflict`] variant; callers can also still
408    /// downcast through `anyhow::Error` when the value has been lifted into
409    /// the legacy result type.
410    async fn write_text_if_version(
411        &self,
412        path: &WorkspacePath,
413        content: &str,
414        expected_version: &str,
415    ) -> WorkspaceResult<WorkspaceWriteOutcome>;
416}
417
418/// Shell/command execution available to the `bash` tool.
419#[async_trait]
420pub trait WorkspaceCommandRunner: Send + Sync {
421    async fn exec(&self, request: CommandRequest) -> Result<CommandOutput>;
422}
423
424/// Search operations available to `glob` and `grep`.
425#[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/// Core Git operations supported by virtually every workspace Git backend.
432///
433/// Optional features (stash, worktrees) live in separate traits so backends
434/// like browser-side `isomorphic-git` can implement only what they support
435/// instead of returning runtime "unsupported" errors.
436#[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/// Optional Git stash operations.
452///
453/// Browser-side libraries such as `isomorphic-git` do not implement stash;
454/// backends that cannot stash simply do not implement this trait.
455#[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/// Optional Git worktree operations.
462///
463/// Worktrees are a local-filesystem concept and are typically not supported
464/// by remote or browser-backed git providers.
465#[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
478/// The host-provided workspace capability bundle used by tool execution.
479pub 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    /// Default timeout applied to non-bash workspace operations (file system,
491    /// search, git). Bash uses its own per-call timeout in [`CommandRequest`].
492    /// `None` means no enforced timeout — appropriate for the local backend.
493    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    /// Local workspace services backed by an in-memory file manifest for
585    /// search. `read`/`write`/`ls`/`bash`/`git` preserve local backend
586    /// behavior; `glob` and `grep` use the manifest once the initial scan has
587    /// completed and fall back to filesystem search before that.
588    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    /// Build local workspace services from a shared manifest backend. Hosts
594    /// can keep the same manifest for UI file pickers and agent tools.
595    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    /// Optional compare-and-swap file system extensions.
640    ///
641    /// Returns `Some` when the backend supports version-aware writes (e.g.
642    /// S3 via ETag). Tools that perform read-modify-write cycles should
643    /// route through [`Self::read_for_edit`] and [`Self::write_for_edit`]
644    /// rather than touching this directly.
645    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    /// Internal helper used by decorators (`with_remote_git` and any
670    /// future git-provider override) to swap the git layer of an existing
671    /// `WorkspaceServices` without losing unrelated fields.
672    ///
673    /// Every field is **explicitly listed** in the returned struct
674    /// literal. This is the point of the helper — adding a new field to
675    /// `WorkspaceServices` will trip a compile error here, and the author
676    /// of that new field has to decide whether a git-provider swap
677    /// preserves it. Previously the decorator went through
678    /// `WorkspaceServicesBuilder`, which silently dropped any field the
679    /// builder did not know about (notably `local_root`).
680    ///
681    /// `git_worktree` is reset to `None` because worktree operations are
682    /// part of the same domain as the git provider — keeping the local
683    /// worktree provider while routing `status`/`log`/`diff` to a remote
684    /// server would surface inconsistent state to the model.
685    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    /// Default timeout applied to non-bash workspace operations.
709    ///
710    /// `None` means no enforced timeout. Backends that may stall (remote,
711    /// browser, DFS) should set this so tools using [`Self::run_with_timeout`]
712    /// surface a timeout error instead of letting the agent loop hang.
713    pub fn operation_timeout(&self) -> Option<std::time::Duration> {
714        self.operation_timeout
715    }
716
717    /// Run a workspace future under the configured operation timeout.
718    ///
719    /// Tools that route through file system / search / git providers should
720    /// wrap their calls with this helper so non-local backends never stall
721    /// the agent loop indefinitely.
722    ///
723    /// Polymorphic in the error type so the helper works equally well for
724    /// futures returning `anyhow::Result<T>` (the legacy callers — search,
725    /// git, etc.) and for futures returning [`WorkspaceResult<T>`] (the
726    /// migrated `WorkspaceFileSystem` callers). The `E: From<anyhow::Error>`
727    /// bound is satisfied by both `anyhow::Error` (trivially) and
728    /// [`WorkspaceError`] (via its `#[from]` `Backend` variant); a timeout
729    /// surfaces as that From conversion of an `anyhow!(...)` message.
730    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    /// Read a file for a subsequent modify-write cycle, requesting a version
752    /// token when the backend supports compare-and-swap writes.
753    ///
754    /// Returns `(content, Some(version))` when [`Self::fs_ext`] is available
755    /// (e.g. on S3, where the version is the object ETag); `(content, None)`
756    /// otherwise. Pair with [`Self::write_for_edit`].
757    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    /// Companion to [`Self::read_for_edit`]. Performs a compare-and-swap
779    /// write when both [`Self::fs_ext`] is available *and* a version token
780    /// was returned by the prior read; falls back to a plain write
781    /// otherwise. On version mismatch the returned error is the typed
782    /// [`WorkspaceError::VersionConflict`] variant; callers can also still
783    /// downcast `anyhow::Error::downcast_ref::<WorkspaceVersionConflict>()`
784    /// when the value has been lifted into an `anyhow::Result`.
785    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
829/// Builder for assembling workspace services without constructor arity churn.
830pub 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    /// Attach optional compare-and-swap file system extensions
895    /// ([`WorkspaceFileSystemExt`]). Tools that perform read-modify-write
896    /// cycles will pick this up via [`WorkspaceServices::read_for_edit`]
897    /// and [`WorkspaceServices::write_for_edit`].
898    pub fn file_system_ext(mut self, ext: Arc<dyn WorkspaceFileSystemExt>) -> Self {
899        self.file_system_ext = Some(ext);
900        self
901    }
902
903    /// Apply a default timeout to non-bash workspace operations (file system,
904    /// search, git). Backends that may stall — remote, browser, DFS — should
905    /// set this so tools surface a timeout error rather than hanging.
906    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/// Lexical resolver suitable for virtual/browser/DFS workspaces.
930#[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    // --- helpers for fs_ext / read_for_edit / write_for_edit coverage ---
1108    //
1109    // The mock backend used here is `InMemoryFileSystem` from the
1110    // `workspace::conformance` module — the same fixture the conformance
1111    // suite runs against, so any drift between the test fixture and the
1112    // contract documented for new backends shows up immediately. Tests
1113    // capture the auto-generated version at read time rather than
1114    // hard-coding it, which keeps assertions decoupled from the version
1115    // scheme of the mock.
1116
1117    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    /// Seed a file into the mock and return its current version, so callers
1128    /// can use it as an expected value without hardcoding the version
1129    /// scheme.
1130    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        // Display must include the path and both versions so logs are useful.
1150        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        // Simulate a concurrent overwrite — a real "second writer" doing
1235        // exactly what the conflict protection is meant to catch.
1236        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        // We don't pin the actual version's exact value — only that the
1250        // backend supplies one and that it differs from what we expected.
1251        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        // Even with fs_ext present, passing version=None must route through
1261        // unconditional write_text (e.g. for fresh-create paths).
1262        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        // Concurrent overwriter, but caller did not request CAS semantics:
1268        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}