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 path;
16mod remote_git;
17#[cfg(feature = "s3")]
18mod s3;
19
20pub use error::{WorkspaceError, WorkspaceResult};
21pub use local::LocalWorkspaceBackend;
22pub use manifest::{
23    scan_workspace_files, LocalWorkspaceFile, LocalWorkspaceFileStatus, LocalWorkspaceManifest,
24    LocalWorkspaceManifestSnapshot, ManifestWorkspaceBackend, RecentWorkspaceFile,
25};
26pub(crate) use path::validate_relative_pattern;
27pub use path::VirtualPathResolver;
28use path::{
29    default_path_input, escape_control_chars_for_display, has_windows_path_prefix,
30    normalize_relative_path, pathbuf_to_workspace_path,
31};
32pub use remote_git::{RemoteGitBackend, RemoteGitBackendConfig, RemoteGitConflict};
33#[cfg(feature = "s3")]
34pub use s3::{S3BackendConfig, S3WorkspaceBackend};
35
36use anyhow::{anyhow, Result};
37use async_trait::async_trait;
38use std::collections::HashMap;
39use std::path::{Path, PathBuf};
40use std::sync::Arc;
41
42/// Identity and display metadata for a workspace.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct WorkspaceRef {
45    /// Stable workspace identifier used by host backends.
46    pub id: String,
47    /// Human-readable root shown in tool output.
48    pub display_root: String,
49}
50
51impl WorkspaceRef {
52    pub fn new(id: impl Into<String>, display_root: impl Into<String>) -> Self {
53        Self {
54            id: id.into(),
55            display_root: display_root.into(),
56        }
57    }
58}
59
60/// A normalized virtual path inside a workspace.
61#[derive(Debug, Clone, PartialEq, Eq, Hash)]
62pub struct WorkspacePath {
63    inner: String,
64}
65
66impl WorkspacePath {
67    pub fn root() -> Self {
68        Self {
69            inner: ".".to_string(),
70        }
71    }
72
73    pub fn from_normalized(path: impl Into<String>) -> Self {
74        let path = path.into();
75        let path = path.trim_matches('/');
76        if path.is_empty() || path == "." {
77            Self::root()
78        } else {
79            Self {
80                inner: path.replace('\\', "/"),
81            }
82        }
83    }
84
85    pub fn as_str(&self) -> &str {
86        &self.inner
87    }
88
89    pub fn is_root(&self) -> bool {
90        self.inner == "."
91    }
92}
93
94/// Workspace capability flags used to gate which built-in tools are registered.
95///
96/// Each flag corresponds to a provider trait on [`WorkspaceServices`]; flags
97/// without a backing provider are deliberately omitted so the surface stays
98/// minimal until a real consumer appears.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub struct WorkspaceCapabilities {
101    pub read: bool,
102    pub write: bool,
103    pub exec: bool,
104    pub search: bool,
105    pub git: bool,
106}
107
108impl WorkspaceCapabilities {
109    pub fn local_default() -> Self {
110        Self {
111            read: true,
112            write: true,
113            exec: true,
114            search: true,
115            git: true,
116        }
117    }
118
119    pub fn read_write() -> Self {
120        Self {
121            read: true,
122            write: true,
123            exec: false,
124            search: false,
125            git: false,
126        }
127    }
128}
129
130impl Default for WorkspaceCapabilities {
131    fn default() -> Self {
132        Self::read_write()
133    }
134}
135
136/// Directory entry kind.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum WorkspaceFileType {
139    File,
140    Directory,
141    Symlink,
142    Unknown,
143}
144
145impl WorkspaceFileType {
146    pub fn as_tool_kind(self) -> &'static str {
147        match self {
148            Self::File => "file",
149            Self::Directory => "dir",
150            Self::Symlink => "link",
151            Self::Unknown => "unknown",
152        }
153    }
154}
155
156/// Directory entry returned by a workspace backend.
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct WorkspaceDirEntry {
159    pub name: String,
160    pub kind: WorkspaceFileType,
161    pub size: u64,
162}
163
164/// Result metadata for a write operation.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct WorkspaceWriteOutcome {
167    pub bytes: usize,
168    pub lines: usize,
169}
170
171/// Glob request for workspace-backed search.
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct WorkspaceGlobRequest {
174    pub base: WorkspacePath,
175    pub pattern: String,
176}
177
178/// Glob result returned by a workspace search provider.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct WorkspaceGlobResult {
181    pub matches: Vec<WorkspacePath>,
182}
183
184/// Grep request for workspace-backed search.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct WorkspaceGrepRequest {
187    pub base: WorkspacePath,
188    pub pattern: String,
189    pub glob: Option<String>,
190    pub context_lines: usize,
191    pub case_insensitive: bool,
192    pub max_output_size: usize,
193}
194
195/// Grep result returned by a workspace search provider.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct WorkspaceGrepResult {
198    pub output: String,
199    pub match_count: usize,
200    pub file_count: usize,
201    pub truncated: bool,
202}
203
204/// Grep result plus structured source evidence when supplied by a backend.
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct WorkspaceGrepOutcome {
207    pub result: WorkspaceGrepResult,
208    /// Distinct paths that contributed rendered match lines, in result order.
209    /// `None` denotes a legacy/custom backend with display output only.
210    pub matched_paths: Option<Vec<WorkspacePath>>,
211}
212
213/// Repository status returned by a workspace Git provider.
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub struct WorkspaceGitStatus {
216    pub branch: String,
217    pub commit: String,
218    pub is_worktree: bool,
219    pub is_dirty: bool,
220    pub dirty_count: usize,
221}
222
223/// Commit information returned by a workspace Git provider.
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct WorkspaceGitCommit {
226    pub id: String,
227    pub message: String,
228    pub author: String,
229    pub date: String,
230}
231
232/// Branch information returned by a workspace Git provider.
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct WorkspaceGitBranch {
235    pub name: String,
236    pub is_current: bool,
237}
238
239/// Branch creation request for a workspace Git provider.
240#[derive(Debug, Clone, PartialEq, Eq)]
241pub struct WorkspaceGitCreateBranchRequest {
242    pub name: String,
243    pub base: String,
244}
245
246/// Checkout request for a workspace Git provider.
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub struct WorkspaceGitCheckoutRequest {
249    pub refspec: String,
250    pub force: bool,
251}
252
253/// Checkout output returned by a workspace Git provider.
254#[derive(Debug, Clone, PartialEq, Eq)]
255pub struct WorkspaceGitCheckoutOutput {
256    pub stdout: String,
257}
258
259/// Diff request for a workspace Git provider.
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct WorkspaceGitDiffRequest {
262    pub target: Option<String>,
263}
264
265/// Stash information returned by a workspace Git provider.
266#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct WorkspaceGitStash {
268    pub index: usize,
269    pub message: String,
270}
271
272/// Stash request for a workspace Git provider.
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub struct WorkspaceGitStashRequest {
275    pub message: Option<String>,
276    pub include_untracked: bool,
277}
278
279/// Remote information returned by a workspace Git provider.
280#[derive(Debug, Clone, PartialEq, Eq)]
281pub struct WorkspaceGitRemote {
282    pub name: String,
283    pub url: String,
284    pub direction: String,
285}
286
287/// Worktree information returned by a workspace Git provider.
288#[derive(Debug, Clone, PartialEq, Eq)]
289pub struct WorkspaceGitWorktree {
290    pub path: String,
291    pub branch: String,
292    pub is_bare: bool,
293    pub is_detached: bool,
294}
295
296/// Worktree creation request for a workspace Git provider.
297#[derive(Debug, Clone, PartialEq, Eq)]
298pub struct WorkspaceGitCreateWorktreeRequest {
299    pub branch: String,
300    pub path: Option<String>,
301    pub new_branch: bool,
302}
303
304/// Worktree removal request for a workspace Git provider.
305#[derive(Debug, Clone, PartialEq, Eq)]
306pub struct WorkspaceGitRemoveWorktreeRequest {
307    pub path: String,
308    pub force: bool,
309}
310
311/// Mutation result for workspace Git worktree operations.
312#[derive(Debug, Clone, PartialEq, Eq)]
313pub struct WorkspaceGitWorktreeMutation {
314    pub path: String,
315    pub branch: Option<String>,
316}
317
318/// Observer that receives streaming output deltas from a workspace command.
319///
320/// Backend implementations call this on each chunk of stdout/stderr they
321/// observe. Tool layers wire host event channels behind this trait, so the
322/// workspace abstraction does not depend on any tool event type.
323#[async_trait]
324pub trait CommandOutputObserver: Send + Sync {
325    async fn on_output_delta(&self, delta: &str);
326}
327
328/// Command execution request.
329#[derive(Clone)]
330pub struct CommandRequest {
331    pub command: String,
332    pub timeout_ms: u64,
333    pub output_observer: Option<Arc<dyn CommandOutputObserver>>,
334    pub env: Option<Arc<HashMap<String, String>>>,
335}
336
337impl std::fmt::Debug for CommandRequest {
338    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
339        f.debug_struct("CommandRequest")
340            .field("command", &self.command)
341            .field("timeout_ms", &self.timeout_ms)
342            .field("output_observer", &self.output_observer.is_some())
343            .field("env", &self.env.as_ref().map(|env| env.len()))
344            .finish()
345    }
346}
347
348/// Command execution output.
349#[derive(Debug, Clone, PartialEq, Eq)]
350pub struct CommandOutput {
351    pub output: String,
352    pub exit_code: i32,
353    pub timed_out: bool,
354}
355
356/// Normalizes and validates host-supplied paths before they reach a backend.
357pub trait WorkspacePathResolver: Send + Sync {
358    fn normalize(&self, input: &str) -> Result<WorkspacePath>;
359}
360
361/// File operations available to built-in file tools.
362///
363/// **Trait stability policy:** new methods added to this trait are a breaking
364/// change for every external backend implementation. Until the workspace
365/// extension story is stabilised, new methods will be added to a separate
366/// `WorkspaceFileSystemExt` trait (with default implementations that fall back
367/// to the core methods) rather than to this trait directly. Backend authors
368/// can rely on this trait surface remaining additive only through extension
369/// traits.
370#[async_trait]
371pub trait WorkspaceFileSystem: Send + Sync {
372    async fn read_text(&self, path: &WorkspacePath) -> WorkspaceResult<String>;
373    async fn write_text(
374        &self,
375        path: &WorkspacePath,
376        content: &str,
377    ) -> WorkspaceResult<WorkspaceWriteOutcome>;
378    async fn list_dir(&self, path: &WorkspacePath) -> WorkspaceResult<Vec<WorkspaceDirEntry>>;
379}
380
381/// Error returned by [`WorkspaceFileSystemExt::write_text_if_version`] when
382/// the underlying object version no longer matches the expected version.
383///
384/// Surfaced through `anyhow::Error`; tools recover by downcasting:
385/// `err.downcast_ref::<WorkspaceVersionConflict>()`. The typical response is
386/// to re-read the file and retry the modify-write cycle once.
387#[derive(Debug, Clone, thiserror::Error)]
388#[error(
389    "version conflict on {path}: expected version {expected:?}, found {actual:?} (file modified by another writer; re-read and retry)"
390)]
391pub struct WorkspaceVersionConflict {
392    pub path: String,
393    pub expected: String,
394    /// Backend-reported current version, if known. S3 does not return the
395    /// current ETag on `412 Precondition Failed`, so this is typically `None`.
396    pub actual: Option<String>,
397}
398
399/// Optional compare-and-swap extensions to [`WorkspaceFileSystem`].
400///
401/// Implemented by backends that expose object-level versioning (S3 ETag,
402/// future GCS generation, ...) so tools that perform read-modify-write
403/// cycles can reject concurrent overwrites. Tools should access this through
404/// [`WorkspaceServices::fs_ext`] — when absent, callers fall back to plain
405/// `read_text` / `write_text` (last-writer-wins).
406///
407/// Kept as a separate trait rather than inheriting from
408/// [`WorkspaceFileSystem`] so existing backend implementations are not
409/// forced to opt in.
410#[async_trait]
411pub trait WorkspaceFileSystemExt: Send + Sync {
412    /// Read text content together with an opaque version token. Tokens are
413    /// backend-specific (S3 returns the ETag) and treated as opaque by
414    /// callers — they are only ever compared for equality on the backend
415    /// side.
416    async fn read_text_with_version(
417        &self,
418        path: &WorkspacePath,
419    ) -> WorkspaceResult<(String, String)>;
420
421    /// Write content iff the current object version matches `expected_version`.
422    /// On mismatch the returned error is the typed
423    /// [`WorkspaceError::VersionConflict`] variant; callers can also still
424    /// downcast through `anyhow::Error` when the value has been lifted into
425    /// the legacy result type.
426    async fn write_text_if_version(
427        &self,
428        path: &WorkspacePath,
429        content: &str,
430        expected_version: &str,
431    ) -> WorkspaceResult<WorkspaceWriteOutcome>;
432}
433
434/// Shell/command execution available to the `bash` tool.
435#[async_trait]
436pub trait WorkspaceCommandRunner: Send + Sync {
437    async fn exec(&self, request: CommandRequest) -> Result<CommandOutput>;
438}
439
440/// Search operations available to `glob` and `grep`.
441#[async_trait]
442pub trait WorkspaceSearch: Send + Sync {
443    async fn glob(&self, request: WorkspaceGlobRequest) -> Result<WorkspaceGlobResult>;
444    async fn grep(&self, request: WorkspaceGrepRequest) -> Result<WorkspaceGrepResult>;
445
446    /// Run grep with structured source paths when the backend can provide them.
447    ///
448    /// The default preserves compatibility for custom backends implementing
449    /// only [`Self::grep`]. Callers must treat its display output as untrusted.
450    async fn grep_with_sources(
451        &self,
452        request: WorkspaceGrepRequest,
453    ) -> Result<WorkspaceGrepOutcome> {
454        let result = self.grep(request).await?;
455        Ok(WorkspaceGrepOutcome {
456            result,
457            matched_paths: None,
458        })
459    }
460}
461
462/// Core Git operations supported by virtually every workspace Git backend.
463///
464/// Optional features (stash, worktrees) live in separate traits so backends
465/// like browser-side `isomorphic-git` can implement only what they support
466/// instead of returning runtime "unsupported" errors.
467#[async_trait]
468pub trait WorkspaceGit: Send + Sync {
469    async fn is_repository(&self) -> Result<bool>;
470    async fn status(&self) -> Result<WorkspaceGitStatus>;
471    async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>>;
472    async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>>;
473    async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()>;
474    async fn checkout(
475        &self,
476        request: WorkspaceGitCheckoutRequest,
477    ) -> Result<WorkspaceGitCheckoutOutput>;
478    async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String>;
479    async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>>;
480}
481
482/// Optional Git stash operations.
483///
484/// Browser-side libraries such as `isomorphic-git` do not implement stash;
485/// backends that cannot stash simply do not implement this trait.
486#[async_trait]
487pub trait WorkspaceGitStashProvider: Send + Sync {
488    async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>>;
489    async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()>;
490}
491
492/// Optional Git worktree operations.
493///
494/// Worktrees are a local-filesystem concept and are typically not supported
495/// by remote or browser-backed git providers.
496#[async_trait]
497pub trait WorkspaceGitWorktreeProvider: Send + Sync {
498    async fn list_worktrees(&self) -> Result<Vec<WorkspaceGitWorktree>>;
499    async fn create_worktree(
500        &self,
501        request: WorkspaceGitCreateWorktreeRequest,
502    ) -> Result<WorkspaceGitWorktreeMutation>;
503    async fn remove_worktree(
504        &self,
505        request: WorkspaceGitRemoveWorktreeRequest,
506    ) -> Result<WorkspaceGitWorktreeMutation>;
507}
508
509/// The host-provided workspace capability bundle used by tool execution.
510pub struct WorkspaceServices {
511    workspace_ref: WorkspaceRef,
512    capabilities: WorkspaceCapabilities,
513    path_resolver: Arc<dyn WorkspacePathResolver>,
514    file_system: Arc<dyn WorkspaceFileSystem>,
515    file_system_ext: Option<Arc<dyn WorkspaceFileSystemExt>>,
516    command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
517    search: Option<Arc<dyn WorkspaceSearch>>,
518    git: Option<Arc<dyn WorkspaceGit>>,
519    git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
520    git_worktree: Option<Arc<dyn WorkspaceGitWorktreeProvider>>,
521    /// Default timeout applied to non-bash workspace operations (file system,
522    /// search, git). Bash uses its own per-call timeout in [`CommandRequest`].
523    /// `None` means no enforced timeout — appropriate for the local backend.
524    operation_timeout: Option<std::time::Duration>,
525    local_root: Option<PathBuf>,
526}
527
528impl std::fmt::Debug for WorkspaceServices {
529    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
530        f.debug_struct("WorkspaceServices")
531            .field("workspace_ref", &self.workspace_ref)
532            .field("capabilities", &self.capabilities)
533            .field("file_system_ext", &self.file_system_ext.is_some())
534            .field("command_runner", &self.command_runner.is_some())
535            .field("search", &self.search.is_some())
536            .field("git", &self.git.is_some())
537            .field("git_stash", &self.git_stash.is_some())
538            .field("git_worktree", &self.git_worktree.is_some())
539            .field("local_root", &self.local_root)
540            .finish()
541    }
542}
543
544impl WorkspaceServices {
545    pub(crate) fn new_with_git(
546        workspace_ref: WorkspaceRef,
547        mut capabilities: WorkspaceCapabilities,
548        path_resolver: Arc<dyn WorkspacePathResolver>,
549        file_system: Arc<dyn WorkspaceFileSystem>,
550        command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
551        search: Option<Arc<dyn WorkspaceSearch>>,
552        git: Option<Arc<dyn WorkspaceGit>>,
553    ) -> Self {
554        if command_runner.is_none() {
555            capabilities.exec = false;
556        }
557        if search.is_none() {
558            capabilities.search = false;
559        }
560        if git.is_none() {
561            capabilities.git = false;
562        }
563        Self {
564            workspace_ref,
565            capabilities,
566            path_resolver,
567            file_system,
568            file_system_ext: None,
569            command_runner,
570            search,
571            git,
572            git_stash: None,
573            git_worktree: None,
574            operation_timeout: None,
575            local_root: None,
576        }
577    }
578
579    pub fn builder(
580        workspace_ref: WorkspaceRef,
581        file_system: Arc<dyn WorkspaceFileSystem>,
582    ) -> WorkspaceServicesBuilder {
583        WorkspaceServicesBuilder::new(workspace_ref, file_system)
584    }
585
586    pub fn local(root: impl Into<PathBuf>) -> Arc<Self> {
587        let backend = Arc::new(LocalWorkspaceBackend::new(root.into()));
588        let workspace_ref = WorkspaceRef::new(
589            backend.root.display().to_string(),
590            backend.root.display().to_string(),
591        );
592        let path_resolver: Arc<dyn WorkspacePathResolver> = backend.clone();
593        let file_system: Arc<dyn WorkspaceFileSystem> = backend.clone();
594        let command_runner: Arc<dyn WorkspaceCommandRunner> = backend.clone();
595        let search: Arc<dyn WorkspaceSearch> = backend.clone();
596        let git: Arc<dyn WorkspaceGit> = backend.clone();
597        let git_stash: Arc<dyn WorkspaceGitStashProvider> = backend.clone();
598        let git_worktree: Arc<dyn WorkspaceGitWorktreeProvider> = backend.clone();
599        Arc::new(Self {
600            workspace_ref,
601            capabilities: WorkspaceCapabilities::local_default(),
602            path_resolver,
603            file_system,
604            file_system_ext: None,
605            command_runner: Some(command_runner),
606            search: Some(search),
607            git: Some(git),
608            git_stash: Some(git_stash),
609            git_worktree: Some(git_worktree),
610            operation_timeout: None,
611            local_root: Some(backend.root.clone()),
612        })
613    }
614
615    /// Local workspace services backed by an in-memory file manifest for
616    /// search. `read`/`write`/`ls`/`bash`/`git` preserve local backend
617    /// behavior; `glob` and `grep` use the manifest once the initial scan has
618    /// completed and fall back to filesystem search before that.
619    pub fn local_with_manifest(root: impl Into<PathBuf>) -> Arc<Self> {
620        let backend = ManifestWorkspaceBackend::new(root);
621        Self::local_with_manifest_backend(backend)
622    }
623
624    /// Build local workspace services from a shared manifest backend. Hosts
625    /// can keep the same manifest for UI file pickers and agent tools.
626    pub fn local_with_manifest_backend(backend: Arc<ManifestWorkspaceBackend>) -> Arc<Self> {
627        let workspace_ref = WorkspaceRef::new(
628            backend.local_root().display().to_string(),
629            backend.local_root().display().to_string(),
630        );
631        let path_resolver: Arc<dyn WorkspacePathResolver> = backend.clone();
632        let file_system: Arc<dyn WorkspaceFileSystem> = backend.clone();
633        let command_runner: Arc<dyn WorkspaceCommandRunner> = backend.clone();
634        let search: Arc<dyn WorkspaceSearch> = backend.clone();
635        let git: Arc<dyn WorkspaceGit> = backend.clone();
636        let git_stash: Arc<dyn WorkspaceGitStashProvider> = backend.clone();
637        let git_worktree: Arc<dyn WorkspaceGitWorktreeProvider> = backend.clone();
638        Arc::new(Self {
639            workspace_ref,
640            capabilities: WorkspaceCapabilities::local_default(),
641            path_resolver,
642            file_system,
643            file_system_ext: None,
644            command_runner: Some(command_runner),
645            search: Some(search),
646            git: Some(git),
647            git_stash: Some(git_stash),
648            git_worktree: Some(git_worktree),
649            operation_timeout: None,
650            local_root: Some(backend.local_root().to_path_buf()),
651        })
652    }
653
654    pub fn workspace_ref(&self) -> &WorkspaceRef {
655        &self.workspace_ref
656    }
657
658    pub fn capabilities(&self) -> WorkspaceCapabilities {
659        self.capabilities
660    }
661
662    pub fn normalize_path(&self, input: &str) -> Result<WorkspacePath> {
663        self.path_resolver.normalize(input)
664    }
665
666    pub fn fs(&self) -> Arc<dyn WorkspaceFileSystem> {
667        Arc::clone(&self.file_system)
668    }
669
670    /// Optional compare-and-swap file system extensions.
671    ///
672    /// Returns `Some` when the backend supports version-aware writes (e.g.
673    /// S3 via ETag). Tools that perform read-modify-write cycles should
674    /// route through [`Self::read_for_edit`] and [`Self::write_for_edit`]
675    /// rather than touching this directly.
676    pub fn fs_ext(&self) -> Option<Arc<dyn WorkspaceFileSystemExt>> {
677        self.file_system_ext.clone()
678    }
679
680    pub fn command_runner(&self) -> Option<Arc<dyn WorkspaceCommandRunner>> {
681        self.command_runner.clone()
682    }
683
684    pub fn search(&self) -> Option<Arc<dyn WorkspaceSearch>> {
685        self.search.clone()
686    }
687
688    pub fn git(&self) -> Option<Arc<dyn WorkspaceGit>> {
689        self.git.clone()
690    }
691
692    pub fn git_stash(&self) -> Option<Arc<dyn WorkspaceGitStashProvider>> {
693        self.git_stash.clone()
694    }
695
696    pub fn git_worktree(&self) -> Option<Arc<dyn WorkspaceGitWorktreeProvider>> {
697        self.git_worktree.clone()
698    }
699
700    /// Internal helper used by decorators (`with_remote_git` and any
701    /// future git-provider override) to swap the git layer of an existing
702    /// `WorkspaceServices` without losing unrelated fields.
703    ///
704    /// Every field is **explicitly listed** in the returned struct
705    /// literal. This is the point of the helper — adding a new field to
706    /// `WorkspaceServices` will trip a compile error here, and the author
707    /// of that new field has to decide whether a git-provider swap
708    /// preserves it. Previously the decorator went through
709    /// `WorkspaceServicesBuilder`, which silently dropped any field the
710    /// builder did not know about (notably `local_root`).
711    ///
712    /// `git_worktree` is reset to `None` because worktree operations are
713    /// part of the same domain as the git provider — keeping the local
714    /// worktree provider while routing `status`/`log`/`diff` to a remote
715    /// server would surface inconsistent state to the model.
716    pub(crate) fn with_git_provider(
717        &self,
718        git: Arc<dyn WorkspaceGit>,
719        git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
720    ) -> Arc<Self> {
721        let mut capabilities = self.capabilities;
722        capabilities.git = true;
723        Arc::new(Self {
724            workspace_ref: self.workspace_ref.clone(),
725            capabilities,
726            path_resolver: Arc::clone(&self.path_resolver),
727            file_system: Arc::clone(&self.file_system),
728            file_system_ext: self.file_system_ext.clone(),
729            command_runner: self.command_runner.clone(),
730            search: self.search.clone(),
731            git: Some(git),
732            git_stash,
733            git_worktree: None,
734            operation_timeout: self.operation_timeout,
735            local_root: self.local_root.clone(),
736        })
737    }
738
739    /// Default timeout applied to non-bash workspace operations.
740    ///
741    /// `None` means no enforced timeout. Backends that may stall (remote,
742    /// browser, DFS) should set this so tools using [`Self::run_with_timeout`]
743    /// surface a timeout error instead of letting the agent loop hang.
744    pub fn operation_timeout(&self) -> Option<std::time::Duration> {
745        self.operation_timeout
746    }
747
748    /// Run a workspace future under the configured operation timeout.
749    ///
750    /// Tools that route through file system / search / git providers should
751    /// wrap their calls with this helper so non-local backends never stall
752    /// the agent loop indefinitely.
753    ///
754    /// Polymorphic in the error type so the helper works equally well for
755    /// futures returning `anyhow::Result<T>` (the legacy callers — search,
756    /// git, etc.) and for futures returning [`WorkspaceResult<T>`] (the
757    /// migrated `WorkspaceFileSystem` callers). The `E: From<anyhow::Error>`
758    /// bound is satisfied by both `anyhow::Error` (trivially) and
759    /// [`WorkspaceError`] (via its `#[from]` `Backend` variant); a timeout
760    /// surfaces as that From conversion of an `anyhow!(...)` message.
761    pub async fn run_with_timeout<F, T, E>(
762        &self,
763        op: &'static str,
764        fut: F,
765    ) -> std::result::Result<T, E>
766    where
767        F: std::future::Future<Output = std::result::Result<T, E>>,
768        E: From<anyhow::Error>,
769    {
770        match self.operation_timeout {
771            Some(d) => tokio::time::timeout(d, fut).await.map_err(|_| {
772                E::from(anyhow!(
773                    "workspace operation '{}' timed out after {:?}",
774                    op,
775                    d
776                ))
777            })?,
778            None => fut.await,
779        }
780    }
781
782    /// Read a file for a subsequent modify-write cycle, requesting a version
783    /// token when the backend supports compare-and-swap writes.
784    ///
785    /// Returns `(content, Some(version))` when [`Self::fs_ext`] is available
786    /// (e.g. on S3, where the version is the object ETag); `(content, None)`
787    /// otherwise. Pair with [`Self::write_for_edit`].
788    pub async fn read_for_edit(
789        &self,
790        path: &WorkspacePath,
791    ) -> WorkspaceResult<(String, Option<String>)> {
792        if let Some(ext) = self.fs_ext() {
793            let path = path.clone();
794            return self
795                .run_with_timeout("read_text_with_version", async move {
796                    let (content, version) = ext.read_text_with_version(&path).await?;
797                    Ok((content, Some(version)))
798                })
799                .await;
800        }
801        let fs = self.fs();
802        let path_owned = path.clone();
803        let content = self
804            .run_with_timeout("read_text", async move { fs.read_text(&path_owned).await })
805            .await?;
806        Ok((content, None))
807    }
808
809    /// Companion to [`Self::read_for_edit`]. Performs a compare-and-swap
810    /// write when both [`Self::fs_ext`] is available *and* a version token
811    /// was returned by the prior read; falls back to a plain write
812    /// otherwise. On version mismatch the returned error is the typed
813    /// [`WorkspaceError::VersionConflict`] variant; callers can also still
814    /// downcast `anyhow::Error::downcast_ref::<WorkspaceVersionConflict>()`
815    /// when the value has been lifted into an `anyhow::Result`.
816    pub async fn write_for_edit(
817        &self,
818        path: &WorkspacePath,
819        content: &str,
820        expected_version: Option<&str>,
821    ) -> WorkspaceResult<WorkspaceWriteOutcome> {
822        if let (Some(ext), Some(version)) = (self.fs_ext(), expected_version) {
823            let path = path.clone();
824            let content = content.to_string();
825            let expected = version.to_string();
826            return self
827                .run_with_timeout("write_text_if_version", async move {
828                    ext.write_text_if_version(&path, &content, &expected).await
829                })
830                .await;
831        }
832        let fs = self.fs();
833        let path = path.clone();
834        let content = content.to_string();
835        self.run_with_timeout(
836            "write_text",
837            async move { fs.write_text(&path, &content).await },
838        )
839        .await
840    }
841
842    pub fn local_root(&self) -> Option<&Path> {
843        self.local_root.as_deref()
844    }
845
846    pub fn display_path(&self, path: &WorkspacePath) -> String {
847        if path.is_root() {
848            return self.workspace_ref.display_root.clone();
849        }
850
851        let root = self.workspace_ref.display_root.trim_end_matches('/');
852        if root.is_empty() {
853            path.as_str().to_string()
854        } else {
855            format!("{root}/{}", path.as_str())
856        }
857    }
858}
859
860/// Builder for assembling workspace services without constructor arity churn.
861pub struct WorkspaceServicesBuilder {
862    workspace_ref: WorkspaceRef,
863    capabilities: WorkspaceCapabilities,
864    path_resolver: Arc<dyn WorkspacePathResolver>,
865    file_system: Arc<dyn WorkspaceFileSystem>,
866    file_system_ext: Option<Arc<dyn WorkspaceFileSystemExt>>,
867    command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
868    search: Option<Arc<dyn WorkspaceSearch>>,
869    git: Option<Arc<dyn WorkspaceGit>>,
870    git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
871    git_worktree: Option<Arc<dyn WorkspaceGitWorktreeProvider>>,
872    operation_timeout: Option<std::time::Duration>,
873}
874
875impl WorkspaceServicesBuilder {
876    pub fn new(workspace_ref: WorkspaceRef, file_system: Arc<dyn WorkspaceFileSystem>) -> Self {
877        Self {
878            workspace_ref,
879            capabilities: WorkspaceCapabilities::read_write(),
880            path_resolver: Arc::new(VirtualPathResolver),
881            file_system,
882            file_system_ext: None,
883            command_runner: None,
884            search: None,
885            git: None,
886            git_stash: None,
887            git_worktree: None,
888            operation_timeout: None,
889        }
890    }
891
892    pub fn capabilities(mut self, capabilities: WorkspaceCapabilities) -> Self {
893        self.capabilities = capabilities;
894        self
895    }
896
897    pub fn command_runner(mut self, command_runner: Arc<dyn WorkspaceCommandRunner>) -> Self {
898        self.capabilities.exec = true;
899        self.command_runner = Some(command_runner);
900        self
901    }
902
903    pub fn search(mut self, search: Arc<dyn WorkspaceSearch>) -> Self {
904        self.capabilities.search = true;
905        self.search = Some(search);
906        self
907    }
908
909    pub fn git(mut self, git: Arc<dyn WorkspaceGit>) -> Self {
910        self.capabilities.git = true;
911        self.git = Some(git);
912        self
913    }
914
915    pub fn git_stash(mut self, git_stash: Arc<dyn WorkspaceGitStashProvider>) -> Self {
916        self.git_stash = Some(git_stash);
917        self
918    }
919
920    pub fn git_worktree(mut self, git_worktree: Arc<dyn WorkspaceGitWorktreeProvider>) -> Self {
921        self.git_worktree = Some(git_worktree);
922        self
923    }
924
925    /// Attach optional compare-and-swap file system extensions
926    /// ([`WorkspaceFileSystemExt`]). Tools that perform read-modify-write
927    /// cycles will pick this up via [`WorkspaceServices::read_for_edit`]
928    /// and [`WorkspaceServices::write_for_edit`].
929    pub fn file_system_ext(mut self, ext: Arc<dyn WorkspaceFileSystemExt>) -> Self {
930        self.file_system_ext = Some(ext);
931        self
932    }
933
934    /// Apply a default timeout to non-bash workspace operations (file system,
935    /// search, git). Backends that may stall — remote, browser, DFS — should
936    /// set this so tools surface a timeout error rather than hanging.
937    pub fn operation_timeout(mut self, timeout: std::time::Duration) -> Self {
938        self.operation_timeout = Some(timeout);
939        self
940    }
941
942    pub fn build(self) -> Arc<WorkspaceServices> {
943        let mut services = WorkspaceServices::new_with_git(
944            self.workspace_ref,
945            self.capabilities,
946            self.path_resolver,
947            self.file_system,
948            self.command_runner,
949            self.search,
950            self.git,
951        );
952        services.file_system_ext = self.file_system_ext;
953        services.git_stash = self.git_stash;
954        services.git_worktree = self.git_worktree;
955        services.operation_timeout = self.operation_timeout;
956        Arc::new(services)
957    }
958}
959
960#[cfg(test)]
961#[path = "tests.rs"]
962mod tests;