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    /// Receive the final bounded-capture accounting for the command.
328    ///
329    /// The default keeps existing remote workspace runners source-compatible.
330    /// Runners that bound output should report the original byte count so
331    /// callers can distinguish a complete result from a partial observation.
332    async fn on_output_complete(&self, _summary: &CommandOutputSummary) {}
333}
334
335/// Final accounting for a bounded command-output capture.
336#[derive(Debug, Clone, Copy, PartialEq, Eq)]
337pub struct CommandOutputSummary {
338    /// Total stdout and stderr bytes observed before completion or timeout.
339    pub total_bytes: usize,
340    /// Original process bytes retained in the rendered output.
341    pub captured_bytes: usize,
342    /// Whether bytes were omitted from the middle of the rendered output.
343    pub truncated: bool,
344    /// Whether command execution reached its own deadline.
345    pub timed_out: bool,
346}
347
348/// Command execution request.
349#[derive(Clone)]
350pub struct CommandRequest {
351    pub command: String,
352    pub timeout_ms: u64,
353    pub output_observer: Option<Arc<dyn CommandOutputObserver>>,
354    pub env: Option<Arc<HashMap<String, String>>>,
355}
356
357impl std::fmt::Debug for CommandRequest {
358    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359        f.debug_struct("CommandRequest")
360            .field("command", &self.command)
361            .field("timeout_ms", &self.timeout_ms)
362            .field("output_observer", &self.output_observer.is_some())
363            .field("env", &self.env.as_ref().map(|env| env.len()))
364            .finish()
365    }
366}
367
368/// Command execution output.
369#[derive(Debug, Clone, PartialEq, Eq)]
370pub struct CommandOutput {
371    pub output: String,
372    pub exit_code: i32,
373    pub timed_out: bool,
374}
375
376/// Normalizes and validates host-supplied paths before they reach a backend.
377pub trait WorkspacePathResolver: Send + Sync {
378    fn normalize(&self, input: &str) -> Result<WorkspacePath>;
379}
380
381/// File operations available to built-in file tools.
382///
383/// **Trait stability policy:** new methods added to this trait are a breaking
384/// change for every external backend implementation. Until the workspace
385/// extension story is stabilised, new methods will be added to a separate
386/// `WorkspaceFileSystemExt` trait (with default implementations that fall back
387/// to the core methods) rather than to this trait directly. Backend authors
388/// can rely on this trait surface remaining additive only through extension
389/// traits.
390#[async_trait]
391pub trait WorkspaceFileSystem: Send + Sync {
392    async fn read_text(&self, path: &WorkspacePath) -> WorkspaceResult<String>;
393    async fn write_text(
394        &self,
395        path: &WorkspacePath,
396        content: &str,
397    ) -> WorkspaceResult<WorkspaceWriteOutcome>;
398    async fn list_dir(&self, path: &WorkspacePath) -> WorkspaceResult<Vec<WorkspaceDirEntry>>;
399}
400
401/// A bounded text range returned by an optional streaming workspace reader.
402#[derive(Debug, Clone, PartialEq, Eq)]
403pub struct WorkspaceTextRange {
404    pub lines: Vec<String>,
405    pub next_offset: Option<usize>,
406    pub eof: bool,
407    /// Exact line count when EOF was observed while satisfying the request.
408    pub total_lines: Option<usize>,
409}
410
411/// Optional streaming text capability for backends that can avoid loading a
412/// complete file when a caller needs only a line range.
413#[async_trait]
414pub trait WorkspaceTextReader: Send + Sync {
415    async fn read_text_range(
416        &self,
417        path: &WorkspacePath,
418        offset: usize,
419        limit: usize,
420    ) -> WorkspaceResult<WorkspaceTextRange>;
421}
422
423/// Error returned by [`WorkspaceFileSystemExt::write_text_if_version`] when
424/// the underlying object version no longer matches the expected version.
425///
426/// Surfaced through `anyhow::Error`; tools recover by downcasting:
427/// `err.downcast_ref::<WorkspaceVersionConflict>()`. The typical response is
428/// to re-read the file and retry the modify-write cycle once.
429#[derive(Debug, Clone, thiserror::Error)]
430#[error(
431    "version conflict on {path}: expected version {expected:?}, found {actual:?} (file modified by another writer; re-read and retry)"
432)]
433pub struct WorkspaceVersionConflict {
434    pub path: String,
435    pub expected: String,
436    /// Backend-reported current version, if known. S3 does not return the
437    /// current ETag on `412 Precondition Failed`, so this is typically `None`.
438    pub actual: Option<String>,
439}
440
441/// Optional compare-and-swap extensions to [`WorkspaceFileSystem`].
442///
443/// Implemented by backends that expose object-level versioning (S3 ETag,
444/// future GCS generation, ...) so tools that perform read-modify-write
445/// cycles can reject concurrent overwrites. Tools should access this through
446/// [`WorkspaceServices::fs_ext`] — when absent, callers fall back to plain
447/// `read_text` / `write_text` (last-writer-wins).
448///
449/// Kept as a separate trait rather than inheriting from
450/// [`WorkspaceFileSystem`] so existing backend implementations are not
451/// forced to opt in.
452#[async_trait]
453pub trait WorkspaceFileSystemExt: Send + Sync {
454    /// Read text content together with an opaque version token. Tokens are
455    /// backend-specific (S3 returns the ETag) and treated as opaque by
456    /// callers — they are only ever compared for equality on the backend
457    /// side.
458    async fn read_text_with_version(
459        &self,
460        path: &WorkspacePath,
461    ) -> WorkspaceResult<(String, String)>;
462
463    /// Write content iff the current object version matches `expected_version`.
464    /// On mismatch the returned error is the typed
465    /// [`WorkspaceError::VersionConflict`] variant; callers can also still
466    /// downcast through `anyhow::Error` when the value has been lifted into
467    /// the legacy result type.
468    async fn write_text_if_version(
469        &self,
470        path: &WorkspacePath,
471        content: &str,
472        expected_version: &str,
473    ) -> WorkspaceResult<WorkspaceWriteOutcome>;
474}
475
476/// Shell/command execution available to the `bash` tool.
477#[async_trait]
478pub trait WorkspaceCommandRunner: Send + Sync {
479    async fn exec(&self, request: CommandRequest) -> Result<CommandOutput>;
480}
481
482/// Search operations available to `glob` and `grep`.
483#[async_trait]
484pub trait WorkspaceSearch: Send + Sync {
485    async fn glob(&self, request: WorkspaceGlobRequest) -> Result<WorkspaceGlobResult>;
486    async fn grep(&self, request: WorkspaceGrepRequest) -> Result<WorkspaceGrepResult>;
487
488    /// Run grep with structured source paths when the backend can provide them.
489    ///
490    /// The default preserves compatibility for custom backends implementing
491    /// only [`Self::grep`]. Callers must treat its display output as untrusted.
492    async fn grep_with_sources(
493        &self,
494        request: WorkspaceGrepRequest,
495    ) -> Result<WorkspaceGrepOutcome> {
496        let result = self.grep(request).await?;
497        Ok(WorkspaceGrepOutcome {
498            result,
499            matched_paths: None,
500        })
501    }
502}
503
504/// Core Git operations supported by virtually every workspace Git backend.
505///
506/// Optional features (stash, worktrees) live in separate traits so backends
507/// like browser-side `isomorphic-git` can implement only what they support
508/// instead of returning runtime "unsupported" errors.
509#[async_trait]
510pub trait WorkspaceGit: Send + Sync {
511    async fn is_repository(&self) -> Result<bool>;
512    async fn status(&self) -> Result<WorkspaceGitStatus>;
513    async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>>;
514    async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>>;
515    async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()>;
516    async fn checkout(
517        &self,
518        request: WorkspaceGitCheckoutRequest,
519    ) -> Result<WorkspaceGitCheckoutOutput>;
520    async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String>;
521    async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>>;
522}
523
524/// Optional Git stash operations.
525///
526/// Browser-side libraries such as `isomorphic-git` do not implement stash;
527/// backends that cannot stash simply do not implement this trait.
528#[async_trait]
529pub trait WorkspaceGitStashProvider: Send + Sync {
530    async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>>;
531    async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()>;
532}
533
534/// Optional Git worktree operations.
535///
536/// Worktrees are a local-filesystem concept and are typically not supported
537/// by remote or browser-backed git providers.
538#[async_trait]
539pub trait WorkspaceGitWorktreeProvider: Send + Sync {
540    async fn list_worktrees(&self) -> Result<Vec<WorkspaceGitWorktree>>;
541    async fn create_worktree(
542        &self,
543        request: WorkspaceGitCreateWorktreeRequest,
544    ) -> Result<WorkspaceGitWorktreeMutation>;
545    async fn remove_worktree(
546        &self,
547        request: WorkspaceGitRemoveWorktreeRequest,
548    ) -> Result<WorkspaceGitWorktreeMutation>;
549}
550
551/// The host-provided workspace capability bundle used by tool execution.
552pub struct WorkspaceServices {
553    workspace_ref: WorkspaceRef,
554    capabilities: WorkspaceCapabilities,
555    path_resolver: Arc<dyn WorkspacePathResolver>,
556    file_system: Arc<dyn WorkspaceFileSystem>,
557    file_system_ext: Option<Arc<dyn WorkspaceFileSystemExt>>,
558    text_reader: Option<Arc<dyn WorkspaceTextReader>>,
559    command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
560    search: Option<Arc<dyn WorkspaceSearch>>,
561    git: Option<Arc<dyn WorkspaceGit>>,
562    git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
563    git_worktree: Option<Arc<dyn WorkspaceGitWorktreeProvider>>,
564    /// Default timeout applied to non-bash workspace operations (file system,
565    /// search, git). Bash uses its own per-call timeout in [`CommandRequest`].
566    /// `None` means no enforced timeout — appropriate for the local backend.
567    operation_timeout: Option<std::time::Duration>,
568    local_root: Option<PathBuf>,
569}
570
571impl std::fmt::Debug for WorkspaceServices {
572    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
573        f.debug_struct("WorkspaceServices")
574            .field("workspace_ref", &self.workspace_ref)
575            .field("capabilities", &self.capabilities)
576            .field("file_system_ext", &self.file_system_ext.is_some())
577            .field("text_reader", &self.text_reader.is_some())
578            .field("command_runner", &self.command_runner.is_some())
579            .field("search", &self.search.is_some())
580            .field("git", &self.git.is_some())
581            .field("git_stash", &self.git_stash.is_some())
582            .field("git_worktree", &self.git_worktree.is_some())
583            .field("local_root", &self.local_root)
584            .finish()
585    }
586}
587
588impl WorkspaceServices {
589    pub(crate) fn new_with_git(
590        workspace_ref: WorkspaceRef,
591        mut capabilities: WorkspaceCapabilities,
592        path_resolver: Arc<dyn WorkspacePathResolver>,
593        file_system: Arc<dyn WorkspaceFileSystem>,
594        command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
595        search: Option<Arc<dyn WorkspaceSearch>>,
596        git: Option<Arc<dyn WorkspaceGit>>,
597    ) -> Self {
598        if command_runner.is_none() {
599            capabilities.exec = false;
600        }
601        if search.is_none() {
602            capabilities.search = false;
603        }
604        if git.is_none() {
605            capabilities.git = false;
606        }
607        Self {
608            workspace_ref,
609            capabilities,
610            path_resolver,
611            file_system,
612            file_system_ext: None,
613            text_reader: None,
614            command_runner,
615            search,
616            git,
617            git_stash: None,
618            git_worktree: None,
619            operation_timeout: None,
620            local_root: None,
621        }
622    }
623
624    pub fn builder(
625        workspace_ref: WorkspaceRef,
626        file_system: Arc<dyn WorkspaceFileSystem>,
627    ) -> WorkspaceServicesBuilder {
628        WorkspaceServicesBuilder::new(workspace_ref, file_system)
629    }
630
631    pub fn local(root: impl Into<PathBuf>) -> Arc<Self> {
632        let backend = Arc::new(LocalWorkspaceBackend::new(root.into()));
633        let workspace_ref = WorkspaceRef::new(
634            backend.root.display().to_string(),
635            backend.root.display().to_string(),
636        );
637        let path_resolver: Arc<dyn WorkspacePathResolver> = backend.clone();
638        let file_system: Arc<dyn WorkspaceFileSystem> = backend.clone();
639        let text_reader: Arc<dyn WorkspaceTextReader> = backend.clone();
640        let command_runner: Arc<dyn WorkspaceCommandRunner> = backend.clone();
641        let search: Arc<dyn WorkspaceSearch> = backend.clone();
642        let git: Arc<dyn WorkspaceGit> = backend.clone();
643        let git_stash: Arc<dyn WorkspaceGitStashProvider> = backend.clone();
644        let git_worktree: Arc<dyn WorkspaceGitWorktreeProvider> = backend.clone();
645        Arc::new(Self {
646            workspace_ref,
647            capabilities: WorkspaceCapabilities::local_default(),
648            path_resolver,
649            file_system,
650            file_system_ext: None,
651            text_reader: Some(text_reader),
652            command_runner: Some(command_runner),
653            search: Some(search),
654            git: Some(git),
655            git_stash: Some(git_stash),
656            git_worktree: Some(git_worktree),
657            operation_timeout: None,
658            local_root: Some(backend.root.clone()),
659        })
660    }
661
662    /// Local workspace services backed by an in-memory file manifest for
663    /// search. `read`/`write`/`ls`/`bash`/`git` preserve local backend
664    /// behavior; `glob` and `grep` use the manifest once the initial scan has
665    /// completed and fall back to filesystem search before that.
666    pub fn local_with_manifest(root: impl Into<PathBuf>) -> Arc<Self> {
667        let backend = ManifestWorkspaceBackend::new(root);
668        Self::local_with_manifest_backend(backend)
669    }
670
671    /// Build local workspace services from a shared manifest backend. Hosts
672    /// can keep the same manifest for UI file pickers and agent tools.
673    pub fn local_with_manifest_backend(backend: Arc<ManifestWorkspaceBackend>) -> Arc<Self> {
674        let workspace_ref = WorkspaceRef::new(
675            backend.local_root().display().to_string(),
676            backend.local_root().display().to_string(),
677        );
678        let path_resolver: Arc<dyn WorkspacePathResolver> = backend.clone();
679        let file_system: Arc<dyn WorkspaceFileSystem> = backend.clone();
680        let text_reader: Arc<dyn WorkspaceTextReader> = backend.clone();
681        let command_runner: Arc<dyn WorkspaceCommandRunner> = backend.clone();
682        let search: Arc<dyn WorkspaceSearch> = backend.clone();
683        let git: Arc<dyn WorkspaceGit> = backend.clone();
684        let git_stash: Arc<dyn WorkspaceGitStashProvider> = backend.clone();
685        let git_worktree: Arc<dyn WorkspaceGitWorktreeProvider> = backend.clone();
686        Arc::new(Self {
687            workspace_ref,
688            capabilities: WorkspaceCapabilities::local_default(),
689            path_resolver,
690            file_system,
691            file_system_ext: None,
692            text_reader: Some(text_reader),
693            command_runner: Some(command_runner),
694            search: Some(search),
695            git: Some(git),
696            git_stash: Some(git_stash),
697            git_worktree: Some(git_worktree),
698            operation_timeout: None,
699            local_root: Some(backend.local_root().to_path_buf()),
700        })
701    }
702
703    pub fn workspace_ref(&self) -> &WorkspaceRef {
704        &self.workspace_ref
705    }
706
707    pub fn capabilities(&self) -> WorkspaceCapabilities {
708        self.capabilities
709    }
710
711    pub fn normalize_path(&self, input: &str) -> Result<WorkspacePath> {
712        self.path_resolver.normalize(input)
713    }
714
715    pub fn fs(&self) -> Arc<dyn WorkspaceFileSystem> {
716        Arc::clone(&self.file_system)
717    }
718
719    /// Optional compare-and-swap file system extensions.
720    ///
721    /// Returns `Some` when the backend supports version-aware writes (e.g.
722    /// S3 via ETag). Tools that perform read-modify-write cycles should
723    /// route through [`Self::read_for_edit`] and [`Self::write_for_edit`]
724    /// rather than touching this directly.
725    pub fn fs_ext(&self) -> Option<Arc<dyn WorkspaceFileSystemExt>> {
726        self.file_system_ext.clone()
727    }
728
729    pub fn text_reader(&self) -> Option<Arc<dyn WorkspaceTextReader>> {
730        self.text_reader.clone()
731    }
732
733    pub fn command_runner(&self) -> Option<Arc<dyn WorkspaceCommandRunner>> {
734        self.command_runner.clone()
735    }
736
737    pub fn search(&self) -> Option<Arc<dyn WorkspaceSearch>> {
738        self.search.clone()
739    }
740
741    pub fn git(&self) -> Option<Arc<dyn WorkspaceGit>> {
742        self.git.clone()
743    }
744
745    pub fn git_stash(&self) -> Option<Arc<dyn WorkspaceGitStashProvider>> {
746        self.git_stash.clone()
747    }
748
749    pub fn git_worktree(&self) -> Option<Arc<dyn WorkspaceGitWorktreeProvider>> {
750        self.git_worktree.clone()
751    }
752
753    /// Internal helper used by decorators (`with_remote_git` and any
754    /// future git-provider override) to swap the git layer of an existing
755    /// `WorkspaceServices` without losing unrelated fields.
756    ///
757    /// Every field is **explicitly listed** in the returned struct
758    /// literal. This is the point of the helper — adding a new field to
759    /// `WorkspaceServices` will trip a compile error here, and the author
760    /// of that new field has to decide whether a git-provider swap
761    /// preserves it. Previously the decorator went through
762    /// `WorkspaceServicesBuilder`, which silently dropped any field the
763    /// builder did not know about (notably `local_root`).
764    ///
765    /// `git_worktree` is reset to `None` because worktree operations are
766    /// part of the same domain as the git provider — keeping the local
767    /// worktree provider while routing `status`/`log`/`diff` to a remote
768    /// server would surface inconsistent state to the model.
769    pub(crate) fn with_git_provider(
770        &self,
771        git: Arc<dyn WorkspaceGit>,
772        git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
773    ) -> Arc<Self> {
774        let mut capabilities = self.capabilities;
775        capabilities.git = true;
776        Arc::new(Self {
777            workspace_ref: self.workspace_ref.clone(),
778            capabilities,
779            path_resolver: Arc::clone(&self.path_resolver),
780            file_system: Arc::clone(&self.file_system),
781            file_system_ext: self.file_system_ext.clone(),
782            text_reader: self.text_reader.clone(),
783            command_runner: self.command_runner.clone(),
784            search: self.search.clone(),
785            git: Some(git),
786            git_stash,
787            git_worktree: None,
788            operation_timeout: self.operation_timeout,
789            local_root: self.local_root.clone(),
790        })
791    }
792
793    /// Default timeout applied to non-bash workspace operations.
794    ///
795    /// `None` means no enforced timeout. Backends that may stall (remote,
796    /// browser, DFS) should set this so tools using [`Self::run_with_timeout`]
797    /// surface a timeout error instead of letting the agent loop hang.
798    pub fn operation_timeout(&self) -> Option<std::time::Duration> {
799        self.operation_timeout
800    }
801
802    /// Run a workspace future under the configured operation timeout.
803    ///
804    /// Tools that route through file system / search / git providers should
805    /// wrap their calls with this helper so non-local backends never stall
806    /// the agent loop indefinitely.
807    ///
808    /// Polymorphic in the error type so the helper works equally well for
809    /// futures returning `anyhow::Result<T>` (the legacy callers — search,
810    /// git, etc.) and for futures returning [`WorkspaceResult<T>`] (the
811    /// migrated `WorkspaceFileSystem` callers). The `E: From<anyhow::Error>`
812    /// bound is satisfied by both `anyhow::Error` (trivially) and
813    /// [`WorkspaceError`] (via its `#[from]` `Backend` variant); a timeout
814    /// surfaces as that From conversion of an `anyhow!(...)` message.
815    pub async fn run_with_timeout<F, T, E>(
816        &self,
817        op: &'static str,
818        fut: F,
819    ) -> std::result::Result<T, E>
820    where
821        F: std::future::Future<Output = std::result::Result<T, E>>,
822        E: From<anyhow::Error>,
823    {
824        match self.operation_timeout {
825            Some(d) => tokio::time::timeout(d, fut).await.map_err(|_| {
826                E::from(anyhow!(
827                    "workspace operation '{}' timed out after {:?}",
828                    op,
829                    d
830                ))
831            })?,
832            None => fut.await,
833        }
834    }
835
836    /// Read a file for a subsequent modify-write cycle, requesting a version
837    /// token when the backend supports compare-and-swap writes.
838    ///
839    /// Returns `(content, Some(version))` when [`Self::fs_ext`] is available
840    /// (e.g. on S3, where the version is the object ETag); `(content, None)`
841    /// otherwise. Pair with [`Self::write_for_edit`].
842    pub async fn read_for_edit(
843        &self,
844        path: &WorkspacePath,
845    ) -> WorkspaceResult<(String, Option<String>)> {
846        if let Some(ext) = self.fs_ext() {
847            let path = path.clone();
848            return self
849                .run_with_timeout("read_text_with_version", async move {
850                    let (content, version) = ext.read_text_with_version(&path).await?;
851                    Ok((content, Some(version)))
852                })
853                .await;
854        }
855        let fs = self.fs();
856        let path_owned = path.clone();
857        let content = self
858            .run_with_timeout("read_text", async move { fs.read_text(&path_owned).await })
859            .await?;
860        Ok((content, None))
861    }
862
863    /// Companion to [`Self::read_for_edit`]. Performs a compare-and-swap
864    /// write when both [`Self::fs_ext`] is available *and* a version token
865    /// was returned by the prior read; falls back to a plain write
866    /// otherwise. On version mismatch the returned error is the typed
867    /// [`WorkspaceError::VersionConflict`] variant; callers can also still
868    /// downcast `anyhow::Error::downcast_ref::<WorkspaceVersionConflict>()`
869    /// when the value has been lifted into an `anyhow::Result`.
870    pub async fn write_for_edit(
871        &self,
872        path: &WorkspacePath,
873        content: &str,
874        expected_version: Option<&str>,
875    ) -> WorkspaceResult<WorkspaceWriteOutcome> {
876        if let (Some(ext), Some(version)) = (self.fs_ext(), expected_version) {
877            let path = path.clone();
878            let content = content.to_string();
879            let expected = version.to_string();
880            return self
881                .run_with_timeout("write_text_if_version", async move {
882                    ext.write_text_if_version(&path, &content, &expected).await
883                })
884                .await;
885        }
886        let fs = self.fs();
887        let path = path.clone();
888        let content = content.to_string();
889        self.run_with_timeout(
890            "write_text",
891            async move { fs.write_text(&path, &content).await },
892        )
893        .await
894    }
895
896    pub fn local_root(&self) -> Option<&Path> {
897        self.local_root.as_deref()
898    }
899
900    pub fn display_path(&self, path: &WorkspacePath) -> String {
901        if path.is_root() {
902            return self.workspace_ref.display_root.clone();
903        }
904
905        let root = self.workspace_ref.display_root.trim_end_matches('/');
906        if root.is_empty() {
907            path.as_str().to_string()
908        } else {
909            format!("{root}/{}", path.as_str())
910        }
911    }
912}
913
914/// Builder for assembling workspace services without constructor arity churn.
915pub struct WorkspaceServicesBuilder {
916    workspace_ref: WorkspaceRef,
917    capabilities: WorkspaceCapabilities,
918    path_resolver: Arc<dyn WorkspacePathResolver>,
919    file_system: Arc<dyn WorkspaceFileSystem>,
920    file_system_ext: Option<Arc<dyn WorkspaceFileSystemExt>>,
921    text_reader: Option<Arc<dyn WorkspaceTextReader>>,
922    command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
923    search: Option<Arc<dyn WorkspaceSearch>>,
924    git: Option<Arc<dyn WorkspaceGit>>,
925    git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
926    git_worktree: Option<Arc<dyn WorkspaceGitWorktreeProvider>>,
927    operation_timeout: Option<std::time::Duration>,
928}
929
930impl WorkspaceServicesBuilder {
931    pub fn new(workspace_ref: WorkspaceRef, file_system: Arc<dyn WorkspaceFileSystem>) -> Self {
932        Self {
933            workspace_ref,
934            capabilities: WorkspaceCapabilities::read_write(),
935            path_resolver: Arc::new(VirtualPathResolver),
936            file_system,
937            file_system_ext: None,
938            text_reader: None,
939            command_runner: None,
940            search: None,
941            git: None,
942            git_stash: None,
943            git_worktree: None,
944            operation_timeout: None,
945        }
946    }
947
948    pub fn capabilities(mut self, capabilities: WorkspaceCapabilities) -> Self {
949        self.capabilities = capabilities;
950        self
951    }
952
953    pub fn command_runner(mut self, command_runner: Arc<dyn WorkspaceCommandRunner>) -> Self {
954        self.capabilities.exec = true;
955        self.command_runner = Some(command_runner);
956        self
957    }
958
959    pub fn search(mut self, search: Arc<dyn WorkspaceSearch>) -> Self {
960        self.capabilities.search = true;
961        self.search = Some(search);
962        self
963    }
964
965    pub fn git(mut self, git: Arc<dyn WorkspaceGit>) -> Self {
966        self.capabilities.git = true;
967        self.git = Some(git);
968        self
969    }
970
971    pub fn git_stash(mut self, git_stash: Arc<dyn WorkspaceGitStashProvider>) -> Self {
972        self.git_stash = Some(git_stash);
973        self
974    }
975
976    pub fn git_worktree(mut self, git_worktree: Arc<dyn WorkspaceGitWorktreeProvider>) -> Self {
977        self.git_worktree = Some(git_worktree);
978        self
979    }
980
981    /// Attach optional compare-and-swap file system extensions
982    /// ([`WorkspaceFileSystemExt`]). Tools that perform read-modify-write
983    /// cycles will pick this up via [`WorkspaceServices::read_for_edit`]
984    /// and [`WorkspaceServices::write_for_edit`].
985    pub fn file_system_ext(mut self, ext: Arc<dyn WorkspaceFileSystemExt>) -> Self {
986        self.file_system_ext = Some(ext);
987        self
988    }
989
990    pub fn text_reader(mut self, reader: Arc<dyn WorkspaceTextReader>) -> Self {
991        self.text_reader = Some(reader);
992        self
993    }
994
995    /// Apply a default timeout to non-bash workspace operations (file system,
996    /// search, git). Backends that may stall — remote, browser, DFS — should
997    /// set this so tools surface a timeout error rather than hanging.
998    pub fn operation_timeout(mut self, timeout: std::time::Duration) -> Self {
999        self.operation_timeout = Some(timeout);
1000        self
1001    }
1002
1003    pub fn build(self) -> Arc<WorkspaceServices> {
1004        let mut services = WorkspaceServices::new_with_git(
1005            self.workspace_ref,
1006            self.capabilities,
1007            self.path_resolver,
1008            self.file_system,
1009            self.command_runner,
1010            self.search,
1011            self.git,
1012        );
1013        services.file_system_ext = self.file_system_ext;
1014        services.text_reader = self.text_reader;
1015        services.git_stash = self.git_stash;
1016        services.git_worktree = self.git_worktree;
1017        services.operation_timeout = self.operation_timeout;
1018        Arc::new(services)
1019    }
1020}
1021
1022#[cfg(test)]
1023#[path = "tests.rs"]
1024mod tests;