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 remote_git;
15#[cfg(feature = "s3")]
16mod s3;
17
18pub use error::{WorkspaceError, WorkspaceResult};
19pub use local::LocalWorkspaceBackend;
20pub use remote_git::{RemoteGitBackend, RemoteGitBackendConfig, RemoteGitConflict};
21#[cfg(feature = "s3")]
22pub use s3::{S3BackendConfig, S3WorkspaceBackend};
23
24use anyhow::{anyhow, bail, Result};
25use async_trait::async_trait;
26use std::collections::HashMap;
27use std::path::{Component, Path, PathBuf};
28use std::sync::Arc;
29
30/// Identity and display metadata for a workspace.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct WorkspaceRef {
33    /// Stable workspace identifier used by host backends.
34    pub id: String,
35    /// Human-readable root shown in tool output.
36    pub display_root: String,
37}
38
39impl WorkspaceRef {
40    pub fn new(id: impl Into<String>, display_root: impl Into<String>) -> Self {
41        Self {
42            id: id.into(),
43            display_root: display_root.into(),
44        }
45    }
46}
47
48/// A normalized virtual path inside a workspace.
49#[derive(Debug, Clone, PartialEq, Eq, Hash)]
50pub struct WorkspacePath {
51    inner: String,
52}
53
54impl WorkspacePath {
55    pub fn root() -> Self {
56        Self {
57            inner: ".".to_string(),
58        }
59    }
60
61    pub fn from_normalized(path: impl Into<String>) -> Self {
62        let path = path.into();
63        let path = path.trim_matches('/');
64        if path.is_empty() || path == "." {
65            Self::root()
66        } else {
67            Self {
68                inner: path.replace('\\', "/"),
69            }
70        }
71    }
72
73    pub fn as_str(&self) -> &str {
74        &self.inner
75    }
76
77    pub fn is_root(&self) -> bool {
78        self.inner == "."
79    }
80}
81
82/// Workspace capability flags used to gate which built-in tools are registered.
83///
84/// Each flag corresponds to a provider trait on [`WorkspaceServices`]; flags
85/// without a backing provider are deliberately omitted so the surface stays
86/// minimal until a real consumer appears.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub struct WorkspaceCapabilities {
89    pub read: bool,
90    pub write: bool,
91    pub exec: bool,
92    pub search: bool,
93    pub git: bool,
94}
95
96impl WorkspaceCapabilities {
97    pub fn local_default() -> Self {
98        Self {
99            read: true,
100            write: true,
101            exec: true,
102            search: true,
103            git: true,
104        }
105    }
106
107    pub fn read_write() -> Self {
108        Self {
109            read: true,
110            write: true,
111            exec: false,
112            search: false,
113            git: false,
114        }
115    }
116}
117
118impl Default for WorkspaceCapabilities {
119    fn default() -> Self {
120        Self::read_write()
121    }
122}
123
124/// Directory entry kind.
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum WorkspaceFileType {
127    File,
128    Directory,
129    Symlink,
130    Unknown,
131}
132
133impl WorkspaceFileType {
134    pub fn as_tool_kind(self) -> &'static str {
135        match self {
136            Self::File => "file",
137            Self::Directory => "dir",
138            Self::Symlink => "link",
139            Self::Unknown => "unknown",
140        }
141    }
142}
143
144/// Directory entry returned by a workspace backend.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct WorkspaceDirEntry {
147    pub name: String,
148    pub kind: WorkspaceFileType,
149    pub size: u64,
150}
151
152/// Result metadata for a write operation.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct WorkspaceWriteOutcome {
155    pub bytes: usize,
156    pub lines: usize,
157}
158
159/// Glob request for workspace-backed search.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct WorkspaceGlobRequest {
162    pub base: WorkspacePath,
163    pub pattern: String,
164}
165
166/// Glob result returned by a workspace search provider.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct WorkspaceGlobResult {
169    pub matches: Vec<WorkspacePath>,
170}
171
172/// Grep request for workspace-backed search.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct WorkspaceGrepRequest {
175    pub base: WorkspacePath,
176    pub pattern: String,
177    pub glob: Option<String>,
178    pub context_lines: usize,
179    pub case_insensitive: bool,
180    pub max_output_size: usize,
181}
182
183/// Grep result returned by a workspace search provider.
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct WorkspaceGrepResult {
186    pub output: String,
187    pub match_count: usize,
188    pub file_count: usize,
189    pub truncated: bool,
190}
191
192/// Repository status returned by a workspace Git provider.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct WorkspaceGitStatus {
195    pub branch: String,
196    pub commit: String,
197    pub is_worktree: bool,
198    pub is_dirty: bool,
199    pub dirty_count: usize,
200}
201
202/// Commit information returned by a workspace Git provider.
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct WorkspaceGitCommit {
205    pub id: String,
206    pub message: String,
207    pub author: String,
208    pub date: String,
209}
210
211/// Branch information returned by a workspace Git provider.
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct WorkspaceGitBranch {
214    pub name: String,
215    pub is_current: bool,
216}
217
218/// Branch creation request for a workspace Git provider.
219#[derive(Debug, Clone, PartialEq, Eq)]
220pub struct WorkspaceGitCreateBranchRequest {
221    pub name: String,
222    pub base: String,
223}
224
225/// Checkout request for a workspace Git provider.
226#[derive(Debug, Clone, PartialEq, Eq)]
227pub struct WorkspaceGitCheckoutRequest {
228    pub refspec: String,
229    pub force: bool,
230}
231
232/// Checkout output returned by a workspace Git provider.
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct WorkspaceGitCheckoutOutput {
235    pub stdout: String,
236}
237
238/// Diff request for a workspace Git provider.
239#[derive(Debug, Clone, PartialEq, Eq)]
240pub struct WorkspaceGitDiffRequest {
241    pub target: Option<String>,
242}
243
244/// Stash information returned by a workspace Git provider.
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct WorkspaceGitStash {
247    pub index: usize,
248    pub message: String,
249}
250
251/// Stash request for a workspace Git provider.
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct WorkspaceGitStashRequest {
254    pub message: Option<String>,
255    pub include_untracked: bool,
256}
257
258/// Remote information returned by a workspace Git provider.
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct WorkspaceGitRemote {
261    pub name: String,
262    pub url: String,
263    pub direction: String,
264}
265
266/// Worktree information returned by a workspace Git provider.
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct WorkspaceGitWorktree {
269    pub path: String,
270    pub branch: String,
271    pub is_bare: bool,
272    pub is_detached: bool,
273}
274
275/// Worktree creation request for a workspace Git provider.
276#[derive(Debug, Clone, PartialEq, Eq)]
277pub struct WorkspaceGitCreateWorktreeRequest {
278    pub branch: String,
279    pub path: Option<String>,
280    pub new_branch: bool,
281}
282
283/// Worktree removal request for a workspace Git provider.
284#[derive(Debug, Clone, PartialEq, Eq)]
285pub struct WorkspaceGitRemoveWorktreeRequest {
286    pub path: String,
287    pub force: bool,
288}
289
290/// Mutation result for workspace Git worktree operations.
291#[derive(Debug, Clone, PartialEq, Eq)]
292pub struct WorkspaceGitWorktreeMutation {
293    pub path: String,
294    pub branch: Option<String>,
295}
296
297/// Observer that receives streaming output deltas from a workspace command.
298///
299/// Backend implementations call this on each chunk of stdout/stderr they
300/// observe. Tool layers wire host event channels behind this trait, so the
301/// workspace abstraction does not depend on any tool event type.
302#[async_trait]
303pub trait CommandOutputObserver: Send + Sync {
304    async fn on_output_delta(&self, delta: &str);
305}
306
307/// Command execution request.
308#[derive(Clone)]
309pub struct CommandRequest {
310    pub command: String,
311    pub timeout_ms: u64,
312    pub output_observer: Option<Arc<dyn CommandOutputObserver>>,
313    pub env: Option<Arc<HashMap<String, String>>>,
314}
315
316impl std::fmt::Debug for CommandRequest {
317    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
318        f.debug_struct("CommandRequest")
319            .field("command", &self.command)
320            .field("timeout_ms", &self.timeout_ms)
321            .field("output_observer", &self.output_observer.is_some())
322            .field("env", &self.env.as_ref().map(|env| env.len()))
323            .finish()
324    }
325}
326
327/// Command execution output.
328#[derive(Debug, Clone, PartialEq, Eq)]
329pub struct CommandOutput {
330    pub output: String,
331    pub exit_code: i32,
332    pub timed_out: bool,
333}
334
335/// Normalizes and validates host-supplied paths before they reach a backend.
336pub trait WorkspacePathResolver: Send + Sync {
337    fn normalize(&self, input: &str) -> Result<WorkspacePath>;
338}
339
340/// File operations available to built-in file tools.
341///
342/// **Trait stability policy:** new methods added to this trait are a breaking
343/// change for every external backend implementation. Until the workspace
344/// extension story is stabilised, new methods will be added to a separate
345/// `WorkspaceFileSystemExt` trait (with default implementations that fall back
346/// to the core methods) rather than to this trait directly. Backend authors
347/// can rely on this trait surface remaining additive only through extension
348/// traits.
349#[async_trait]
350pub trait WorkspaceFileSystem: Send + Sync {
351    async fn read_text(&self, path: &WorkspacePath) -> WorkspaceResult<String>;
352    async fn write_text(
353        &self,
354        path: &WorkspacePath,
355        content: &str,
356    ) -> WorkspaceResult<WorkspaceWriteOutcome>;
357    async fn list_dir(&self, path: &WorkspacePath) -> WorkspaceResult<Vec<WorkspaceDirEntry>>;
358}
359
360/// Error returned by [`WorkspaceFileSystemExt::write_text_if_version`] when
361/// the underlying object version no longer matches the expected version.
362///
363/// Surfaced through `anyhow::Error`; tools recover by downcasting:
364/// `err.downcast_ref::<WorkspaceVersionConflict>()`. The typical response is
365/// to re-read the file and retry the modify-write cycle once.
366#[derive(Debug, Clone, thiserror::Error)]
367#[error(
368    "version conflict on {path}: expected version {expected:?}, found {actual:?} (file modified by another writer; re-read and retry)"
369)]
370pub struct WorkspaceVersionConflict {
371    pub path: String,
372    pub expected: String,
373    /// Backend-reported current version, if known. S3 does not return the
374    /// current ETag on `412 Precondition Failed`, so this is typically `None`.
375    pub actual: Option<String>,
376}
377
378/// Optional compare-and-swap extensions to [`WorkspaceFileSystem`].
379///
380/// Implemented by backends that expose object-level versioning (S3 ETag,
381/// future GCS generation, ...) so tools that perform read-modify-write
382/// cycles can reject concurrent overwrites. Tools should access this through
383/// [`WorkspaceServices::fs_ext`] — when absent, callers fall back to plain
384/// `read_text` / `write_text` (last-writer-wins).
385///
386/// Kept as a separate trait rather than inheriting from
387/// [`WorkspaceFileSystem`] so existing backend implementations are not
388/// forced to opt in.
389#[async_trait]
390pub trait WorkspaceFileSystemExt: Send + Sync {
391    /// Read text content together with an opaque version token. Tokens are
392    /// backend-specific (S3 returns the ETag) and treated as opaque by
393    /// callers — they are only ever compared for equality on the backend
394    /// side.
395    async fn read_text_with_version(
396        &self,
397        path: &WorkspacePath,
398    ) -> WorkspaceResult<(String, String)>;
399
400    /// Write content iff the current object version matches `expected_version`.
401    /// On mismatch the returned error is the typed
402    /// [`WorkspaceError::VersionConflict`] variant; callers can also still
403    /// downcast through `anyhow::Error` when the value has been lifted into
404    /// the legacy result type.
405    async fn write_text_if_version(
406        &self,
407        path: &WorkspacePath,
408        content: &str,
409        expected_version: &str,
410    ) -> WorkspaceResult<WorkspaceWriteOutcome>;
411}
412
413/// Shell/command execution available to the `bash` tool.
414#[async_trait]
415pub trait WorkspaceCommandRunner: Send + Sync {
416    async fn exec(&self, request: CommandRequest) -> Result<CommandOutput>;
417}
418
419/// Search operations available to `glob` and `grep`.
420#[async_trait]
421pub trait WorkspaceSearch: Send + Sync {
422    async fn glob(&self, request: WorkspaceGlobRequest) -> Result<WorkspaceGlobResult>;
423    async fn grep(&self, request: WorkspaceGrepRequest) -> Result<WorkspaceGrepResult>;
424}
425
426/// Core Git operations supported by virtually every workspace Git backend.
427///
428/// Optional features (stash, worktrees) live in separate traits so backends
429/// like browser-side `isomorphic-git` can implement only what they support
430/// instead of returning runtime "unsupported" errors.
431#[async_trait]
432pub trait WorkspaceGit: Send + Sync {
433    async fn is_repository(&self) -> Result<bool>;
434    async fn status(&self) -> Result<WorkspaceGitStatus>;
435    async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>>;
436    async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>>;
437    async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()>;
438    async fn checkout(
439        &self,
440        request: WorkspaceGitCheckoutRequest,
441    ) -> Result<WorkspaceGitCheckoutOutput>;
442    async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String>;
443    async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>>;
444}
445
446/// Optional Git stash operations.
447///
448/// Browser-side libraries such as `isomorphic-git` do not implement stash;
449/// backends that cannot stash simply do not implement this trait.
450#[async_trait]
451pub trait WorkspaceGitStashProvider: Send + Sync {
452    async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>>;
453    async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()>;
454}
455
456/// Optional Git worktree operations.
457///
458/// Worktrees are a local-filesystem concept and are typically not supported
459/// by remote or browser-backed git providers.
460#[async_trait]
461pub trait WorkspaceGitWorktreeProvider: Send + Sync {
462    async fn list_worktrees(&self) -> Result<Vec<WorkspaceGitWorktree>>;
463    async fn create_worktree(
464        &self,
465        request: WorkspaceGitCreateWorktreeRequest,
466    ) -> Result<WorkspaceGitWorktreeMutation>;
467    async fn remove_worktree(
468        &self,
469        request: WorkspaceGitRemoveWorktreeRequest,
470    ) -> Result<WorkspaceGitWorktreeMutation>;
471}
472
473/// The host-provided workspace capability bundle used by tool execution.
474pub struct WorkspaceServices {
475    workspace_ref: WorkspaceRef,
476    capabilities: WorkspaceCapabilities,
477    path_resolver: Arc<dyn WorkspacePathResolver>,
478    file_system: Arc<dyn WorkspaceFileSystem>,
479    file_system_ext: Option<Arc<dyn WorkspaceFileSystemExt>>,
480    command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
481    search: Option<Arc<dyn WorkspaceSearch>>,
482    git: Option<Arc<dyn WorkspaceGit>>,
483    git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
484    git_worktree: Option<Arc<dyn WorkspaceGitWorktreeProvider>>,
485    /// Default timeout applied to non-bash workspace operations (file system,
486    /// search, git). Bash uses its own per-call timeout in [`CommandRequest`].
487    /// `None` means no enforced timeout — appropriate for the local backend.
488    operation_timeout: Option<std::time::Duration>,
489    local_root: Option<PathBuf>,
490}
491
492impl std::fmt::Debug for WorkspaceServices {
493    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
494        f.debug_struct("WorkspaceServices")
495            .field("workspace_ref", &self.workspace_ref)
496            .field("capabilities", &self.capabilities)
497            .field("file_system_ext", &self.file_system_ext.is_some())
498            .field("command_runner", &self.command_runner.is_some())
499            .field("search", &self.search.is_some())
500            .field("git", &self.git.is_some())
501            .field("git_stash", &self.git_stash.is_some())
502            .field("git_worktree", &self.git_worktree.is_some())
503            .field("local_root", &self.local_root)
504            .finish()
505    }
506}
507
508impl WorkspaceServices {
509    pub(crate) fn new_with_git(
510        workspace_ref: WorkspaceRef,
511        mut capabilities: WorkspaceCapabilities,
512        path_resolver: Arc<dyn WorkspacePathResolver>,
513        file_system: Arc<dyn WorkspaceFileSystem>,
514        command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
515        search: Option<Arc<dyn WorkspaceSearch>>,
516        git: Option<Arc<dyn WorkspaceGit>>,
517    ) -> Self {
518        if command_runner.is_none() {
519            capabilities.exec = false;
520        }
521        if search.is_none() {
522            capabilities.search = false;
523        }
524        if git.is_none() {
525            capabilities.git = false;
526        }
527        Self {
528            workspace_ref,
529            capabilities,
530            path_resolver,
531            file_system,
532            file_system_ext: None,
533            command_runner,
534            search,
535            git,
536            git_stash: None,
537            git_worktree: None,
538            operation_timeout: None,
539            local_root: None,
540        }
541    }
542
543    pub fn builder(
544        workspace_ref: WorkspaceRef,
545        file_system: Arc<dyn WorkspaceFileSystem>,
546    ) -> WorkspaceServicesBuilder {
547        WorkspaceServicesBuilder::new(workspace_ref, file_system)
548    }
549
550    pub fn local(root: impl Into<PathBuf>) -> Arc<Self> {
551        let backend = Arc::new(LocalWorkspaceBackend::new(root.into()));
552        let workspace_ref = WorkspaceRef::new(
553            backend.root.display().to_string(),
554            backend.root.display().to_string(),
555        );
556        let path_resolver: Arc<dyn WorkspacePathResolver> = backend.clone();
557        let file_system: Arc<dyn WorkspaceFileSystem> = backend.clone();
558        let command_runner: Arc<dyn WorkspaceCommandRunner> = backend.clone();
559        let search: Arc<dyn WorkspaceSearch> = backend.clone();
560        let git: Arc<dyn WorkspaceGit> = backend.clone();
561        let git_stash: Arc<dyn WorkspaceGitStashProvider> = backend.clone();
562        let git_worktree: Arc<dyn WorkspaceGitWorktreeProvider> = backend.clone();
563        Arc::new(Self {
564            workspace_ref,
565            capabilities: WorkspaceCapabilities::local_default(),
566            path_resolver,
567            file_system,
568            file_system_ext: None,
569            command_runner: Some(command_runner),
570            search: Some(search),
571            git: Some(git),
572            git_stash: Some(git_stash),
573            git_worktree: Some(git_worktree),
574            operation_timeout: None,
575            local_root: Some(backend.root.clone()),
576        })
577    }
578
579    pub fn workspace_ref(&self) -> &WorkspaceRef {
580        &self.workspace_ref
581    }
582
583    pub fn capabilities(&self) -> WorkspaceCapabilities {
584        self.capabilities
585    }
586
587    pub fn normalize_path(&self, input: &str) -> Result<WorkspacePath> {
588        self.path_resolver.normalize(input)
589    }
590
591    pub fn fs(&self) -> Arc<dyn WorkspaceFileSystem> {
592        Arc::clone(&self.file_system)
593    }
594
595    /// Optional compare-and-swap file system extensions.
596    ///
597    /// Returns `Some` when the backend supports version-aware writes (e.g.
598    /// S3 via ETag). Tools that perform read-modify-write cycles should
599    /// route through [`Self::read_for_edit`] and [`Self::write_for_edit`]
600    /// rather than touching this directly.
601    pub fn fs_ext(&self) -> Option<Arc<dyn WorkspaceFileSystemExt>> {
602        self.file_system_ext.clone()
603    }
604
605    pub fn command_runner(&self) -> Option<Arc<dyn WorkspaceCommandRunner>> {
606        self.command_runner.clone()
607    }
608
609    pub fn search(&self) -> Option<Arc<dyn WorkspaceSearch>> {
610        self.search.clone()
611    }
612
613    pub fn git(&self) -> Option<Arc<dyn WorkspaceGit>> {
614        self.git.clone()
615    }
616
617    pub fn git_stash(&self) -> Option<Arc<dyn WorkspaceGitStashProvider>> {
618        self.git_stash.clone()
619    }
620
621    pub fn git_worktree(&self) -> Option<Arc<dyn WorkspaceGitWorktreeProvider>> {
622        self.git_worktree.clone()
623    }
624
625    /// Internal helper used by decorators (`with_remote_git` and any
626    /// future git-provider override) to swap the git layer of an existing
627    /// `WorkspaceServices` without losing unrelated fields.
628    ///
629    /// Every field is **explicitly listed** in the returned struct
630    /// literal. This is the point of the helper — adding a new field to
631    /// `WorkspaceServices` will trip a compile error here, and the author
632    /// of that new field has to decide whether a git-provider swap
633    /// preserves it. Previously the decorator went through
634    /// `WorkspaceServicesBuilder`, which silently dropped any field the
635    /// builder did not know about (notably `local_root`).
636    ///
637    /// `git_worktree` is reset to `None` because worktree operations are
638    /// part of the same domain as the git provider — keeping the local
639    /// worktree provider while routing `status`/`log`/`diff` to a remote
640    /// server would surface inconsistent state to the model.
641    pub(crate) fn with_git_provider(
642        &self,
643        git: Arc<dyn WorkspaceGit>,
644        git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
645    ) -> Arc<Self> {
646        let mut capabilities = self.capabilities;
647        capabilities.git = true;
648        Arc::new(Self {
649            workspace_ref: self.workspace_ref.clone(),
650            capabilities,
651            path_resolver: Arc::clone(&self.path_resolver),
652            file_system: Arc::clone(&self.file_system),
653            file_system_ext: self.file_system_ext.clone(),
654            command_runner: self.command_runner.clone(),
655            search: self.search.clone(),
656            git: Some(git),
657            git_stash,
658            git_worktree: None,
659            operation_timeout: self.operation_timeout,
660            local_root: self.local_root.clone(),
661        })
662    }
663
664    /// Default timeout applied to non-bash workspace operations.
665    ///
666    /// `None` means no enforced timeout. Backends that may stall (remote,
667    /// browser, DFS) should set this so tools using [`Self::run_with_timeout`]
668    /// surface a timeout error instead of letting the agent loop hang.
669    pub fn operation_timeout(&self) -> Option<std::time::Duration> {
670        self.operation_timeout
671    }
672
673    /// Run a workspace future under the configured operation timeout.
674    ///
675    /// Tools that route through file system / search / git providers should
676    /// wrap their calls with this helper so non-local backends never stall
677    /// the agent loop indefinitely.
678    ///
679    /// Polymorphic in the error type so the helper works equally well for
680    /// futures returning `anyhow::Result<T>` (the legacy callers — search,
681    /// git, etc.) and for futures returning [`WorkspaceResult<T>`] (the
682    /// migrated `WorkspaceFileSystem` callers). The `E: From<anyhow::Error>`
683    /// bound is satisfied by both `anyhow::Error` (trivially) and
684    /// [`WorkspaceError`] (via its `#[from]` `Backend` variant); a timeout
685    /// surfaces as that From conversion of an `anyhow!(...)` message.
686    pub async fn run_with_timeout<F, T, E>(
687        &self,
688        op: &'static str,
689        fut: F,
690    ) -> std::result::Result<T, E>
691    where
692        F: std::future::Future<Output = std::result::Result<T, E>>,
693        E: From<anyhow::Error>,
694    {
695        match self.operation_timeout {
696            Some(d) => tokio::time::timeout(d, fut).await.map_err(|_| {
697                E::from(anyhow!(
698                    "workspace operation '{}' timed out after {:?}",
699                    op,
700                    d
701                ))
702            })?,
703            None => fut.await,
704        }
705    }
706
707    /// Read a file for a subsequent modify-write cycle, requesting a version
708    /// token when the backend supports compare-and-swap writes.
709    ///
710    /// Returns `(content, Some(version))` when [`Self::fs_ext`] is available
711    /// (e.g. on S3, where the version is the object ETag); `(content, None)`
712    /// otherwise. Pair with [`Self::write_for_edit`].
713    pub async fn read_for_edit(
714        &self,
715        path: &WorkspacePath,
716    ) -> WorkspaceResult<(String, Option<String>)> {
717        if let Some(ext) = self.fs_ext() {
718            let path = path.clone();
719            return self
720                .run_with_timeout("read_text_with_version", async move {
721                    let (content, version) = ext.read_text_with_version(&path).await?;
722                    Ok((content, Some(version)))
723                })
724                .await;
725        }
726        let fs = self.fs();
727        let path_owned = path.clone();
728        let content = self
729            .run_with_timeout("read_text", async move { fs.read_text(&path_owned).await })
730            .await?;
731        Ok((content, None))
732    }
733
734    /// Companion to [`Self::read_for_edit`]. Performs a compare-and-swap
735    /// write when both [`Self::fs_ext`] is available *and* a version token
736    /// was returned by the prior read; falls back to a plain write
737    /// otherwise. On version mismatch the returned error is the typed
738    /// [`WorkspaceError::VersionConflict`] variant; callers can also still
739    /// downcast `anyhow::Error::downcast_ref::<WorkspaceVersionConflict>()`
740    /// when the value has been lifted into an `anyhow::Result`.
741    pub async fn write_for_edit(
742        &self,
743        path: &WorkspacePath,
744        content: &str,
745        expected_version: Option<&str>,
746    ) -> WorkspaceResult<WorkspaceWriteOutcome> {
747        if let (Some(ext), Some(version)) = (self.fs_ext(), expected_version) {
748            let path = path.clone();
749            let content = content.to_string();
750            let expected = version.to_string();
751            return self
752                .run_with_timeout("write_text_if_version", async move {
753                    ext.write_text_if_version(&path, &content, &expected).await
754                })
755                .await;
756        }
757        let fs = self.fs();
758        let path = path.clone();
759        let content = content.to_string();
760        self.run_with_timeout(
761            "write_text",
762            async move { fs.write_text(&path, &content).await },
763        )
764        .await
765    }
766
767    pub fn local_root(&self) -> Option<&Path> {
768        self.local_root.as_deref()
769    }
770
771    pub fn display_path(&self, path: &WorkspacePath) -> String {
772        if path.is_root() {
773            return self.workspace_ref.display_root.clone();
774        }
775
776        let root = self.workspace_ref.display_root.trim_end_matches('/');
777        if root.is_empty() {
778            path.as_str().to_string()
779        } else {
780            format!("{root}/{}", path.as_str())
781        }
782    }
783}
784
785/// Builder for assembling workspace services without constructor arity churn.
786pub struct WorkspaceServicesBuilder {
787    workspace_ref: WorkspaceRef,
788    capabilities: WorkspaceCapabilities,
789    path_resolver: Arc<dyn WorkspacePathResolver>,
790    file_system: Arc<dyn WorkspaceFileSystem>,
791    file_system_ext: Option<Arc<dyn WorkspaceFileSystemExt>>,
792    command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
793    search: Option<Arc<dyn WorkspaceSearch>>,
794    git: Option<Arc<dyn WorkspaceGit>>,
795    git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
796    git_worktree: Option<Arc<dyn WorkspaceGitWorktreeProvider>>,
797    operation_timeout: Option<std::time::Duration>,
798}
799
800impl WorkspaceServicesBuilder {
801    pub fn new(workspace_ref: WorkspaceRef, file_system: Arc<dyn WorkspaceFileSystem>) -> Self {
802        Self {
803            workspace_ref,
804            capabilities: WorkspaceCapabilities::read_write(),
805            path_resolver: Arc::new(VirtualPathResolver),
806            file_system,
807            file_system_ext: None,
808            command_runner: None,
809            search: None,
810            git: None,
811            git_stash: None,
812            git_worktree: None,
813            operation_timeout: None,
814        }
815    }
816
817    pub fn capabilities(mut self, capabilities: WorkspaceCapabilities) -> Self {
818        self.capabilities = capabilities;
819        self
820    }
821
822    pub fn command_runner(mut self, command_runner: Arc<dyn WorkspaceCommandRunner>) -> Self {
823        self.capabilities.exec = true;
824        self.command_runner = Some(command_runner);
825        self
826    }
827
828    pub fn search(mut self, search: Arc<dyn WorkspaceSearch>) -> Self {
829        self.capabilities.search = true;
830        self.search = Some(search);
831        self
832    }
833
834    pub fn git(mut self, git: Arc<dyn WorkspaceGit>) -> Self {
835        self.capabilities.git = true;
836        self.git = Some(git);
837        self
838    }
839
840    pub fn git_stash(mut self, git_stash: Arc<dyn WorkspaceGitStashProvider>) -> Self {
841        self.git_stash = Some(git_stash);
842        self
843    }
844
845    pub fn git_worktree(mut self, git_worktree: Arc<dyn WorkspaceGitWorktreeProvider>) -> Self {
846        self.git_worktree = Some(git_worktree);
847        self
848    }
849
850    /// Attach optional compare-and-swap file system extensions
851    /// ([`WorkspaceFileSystemExt`]). Tools that perform read-modify-write
852    /// cycles will pick this up via [`WorkspaceServices::read_for_edit`]
853    /// and [`WorkspaceServices::write_for_edit`].
854    pub fn file_system_ext(mut self, ext: Arc<dyn WorkspaceFileSystemExt>) -> Self {
855        self.file_system_ext = Some(ext);
856        self
857    }
858
859    /// Apply a default timeout to non-bash workspace operations (file system,
860    /// search, git). Backends that may stall — remote, browser, DFS — should
861    /// set this so tools surface a timeout error rather than hanging.
862    pub fn operation_timeout(mut self, timeout: std::time::Duration) -> Self {
863        self.operation_timeout = Some(timeout);
864        self
865    }
866
867    pub fn build(self) -> Arc<WorkspaceServices> {
868        let mut services = WorkspaceServices::new_with_git(
869            self.workspace_ref,
870            self.capabilities,
871            self.path_resolver,
872            self.file_system,
873            self.command_runner,
874            self.search,
875            self.git,
876        );
877        services.file_system_ext = self.file_system_ext;
878        services.git_stash = self.git_stash;
879        services.git_worktree = self.git_worktree;
880        services.operation_timeout = self.operation_timeout;
881        Arc::new(services)
882    }
883}
884
885/// Lexical resolver suitable for virtual/browser/DFS workspaces.
886#[derive(Debug, Default)]
887pub struct VirtualPathResolver;
888
889impl WorkspacePathResolver for VirtualPathResolver {
890    fn normalize(&self, input: &str) -> Result<WorkspacePath> {
891        normalize_virtual_path(input)
892    }
893}
894
895fn normalize_virtual_path(input: &str) -> Result<WorkspacePath> {
896    let input = default_path_input(input);
897    if has_windows_path_prefix(input) {
898        bail!("Absolute paths are not supported by this workspace backend");
899    }
900
901    let normalized_input = input.replace('\\', "/");
902    let path = Path::new(&normalized_input);
903    if path.is_absolute() {
904        bail!("Absolute paths are not supported by this workspace backend");
905    }
906
907    let relative = normalize_relative_path(path)?;
908    Ok(pathbuf_to_workspace_path(&relative))
909}
910
911fn default_path_input(input: &str) -> &str {
912    let trimmed = input.trim();
913    if trimmed.is_empty() {
914        "."
915    } else {
916        trimmed
917    }
918}
919
920fn has_windows_path_prefix(input: &str) -> bool {
921    let bytes = input.as_bytes();
922    if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
923        return true;
924    }
925
926    input.starts_with("\\\\") || input.starts_with("//")
927}
928
929fn validate_relative_pattern(pattern: &str, label: &str) -> Result<()> {
930    let pattern = pattern.trim();
931    if pattern.is_empty() {
932        bail!("{label} cannot be empty");
933    }
934    if has_windows_path_prefix(pattern) || Path::new(pattern).is_absolute() {
935        bail!("{label} must be relative to the workspace");
936    }
937
938    let normalized = pattern.replace('\\', "/");
939    if normalized.split('/').any(|component| component == "..") {
940        bail!("{label} must not contain parent directory traversal");
941    }
942
943    Ok(())
944}
945
946fn normalize_relative_path(path: &Path) -> Result<PathBuf> {
947    let mut out = PathBuf::new();
948    for component in path.components() {
949        match component {
950            Component::CurDir => {}
951            Component::Normal(part) => out.push(part),
952            Component::ParentDir => {
953                if !out.pop() {
954                    bail!("Workspace boundary violation: path escapes workspace");
955                }
956            }
957            Component::RootDir | Component::Prefix(_) => {
958                bail!("Absolute paths are not supported by this workspace backend");
959            }
960        }
961    }
962    Ok(out)
963}
964
965fn pathbuf_to_workspace_path(path: &Path) -> WorkspacePath {
966    let display = path.to_string_lossy().replace('\\', "/");
967    WorkspacePath::from_normalized(display)
968}
969
970#[cfg(test)]
971mod tests {
972    use super::*;
973
974    #[test]
975    fn virtual_resolver_normalizes_relative_paths() {
976        let resolver = VirtualPathResolver;
977        let path = resolver.normalize("./src/../README.md").unwrap();
978        assert_eq!(path.as_str(), "README.md");
979    }
980
981    #[test]
982    fn virtual_resolver_normalizes_backslash_separators() {
983        let resolver = VirtualPathResolver;
984        let path = resolver.normalize(r"src\main.rs").unwrap();
985        assert_eq!(path.as_str(), "src/main.rs");
986    }
987
988    #[test]
989    fn virtual_resolver_rejects_escape() {
990        let resolver = VirtualPathResolver;
991        let err = resolver.normalize("../secret.txt").unwrap_err();
992        assert!(err.to_string().contains("escapes workspace"));
993    }
994
995    #[test]
996    fn virtual_resolver_rejects_backslash_escape() {
997        let resolver = VirtualPathResolver;
998        let err = resolver.normalize(r"..\secret.txt").unwrap_err();
999        assert!(err.to_string().contains("escapes workspace"));
1000    }
1001
1002    #[test]
1003    fn virtual_resolver_rejects_absolute_paths() {
1004        let resolver = VirtualPathResolver;
1005        let err = resolver.normalize("/tmp/secret.txt").unwrap_err();
1006        assert!(err.to_string().contains("Absolute paths"));
1007    }
1008
1009    #[test]
1010    fn virtual_resolver_rejects_windows_absolute_paths() {
1011        let resolver = VirtualPathResolver;
1012
1013        let drive_err = resolver.normalize(r"C:\Users\secret.txt").unwrap_err();
1014        assert!(drive_err.to_string().contains("Absolute paths"));
1015
1016        let unc_err = resolver
1017            .normalize(r"\\server\share\secret.txt")
1018            .unwrap_err();
1019        assert!(unc_err.to_string().contains("Absolute paths"));
1020    }
1021
1022    #[test]
1023    fn workspace_services_disable_exec_without_runner() {
1024        struct EmptyFs;
1025
1026        #[async_trait]
1027        impl WorkspaceFileSystem for EmptyFs {
1028            async fn read_text(&self, _path: &WorkspacePath) -> WorkspaceResult<String> {
1029                Err(WorkspaceError::Unsupported("not implemented".into()))
1030            }
1031
1032            async fn write_text(
1033                &self,
1034                _path: &WorkspacePath,
1035                _content: &str,
1036            ) -> WorkspaceResult<WorkspaceWriteOutcome> {
1037                Err(WorkspaceError::Unsupported("not implemented".into()))
1038            }
1039
1040            async fn list_dir(
1041                &self,
1042                _path: &WorkspacePath,
1043            ) -> WorkspaceResult<Vec<WorkspaceDirEntry>> {
1044                Err(WorkspaceError::Unsupported("not implemented".into()))
1045            }
1046        }
1047
1048        let fs_backend: Arc<dyn WorkspaceFileSystem> = Arc::new(EmptyFs);
1049        let services = WorkspaceServices::builder(
1050            WorkspaceRef::new("virtual", "virtual://workspace"),
1051            fs_backend,
1052        )
1053        .capabilities(WorkspaceCapabilities {
1054            exec: true,
1055            ..WorkspaceCapabilities::read_write()
1056        })
1057        .build();
1058
1059        assert!(!services.capabilities().exec);
1060        assert!(services.command_runner().is_none());
1061    }
1062
1063    // --- helpers for fs_ext / read_for_edit / write_for_edit coverage ---
1064    //
1065    // The mock backend used here is `InMemoryFileSystem` from the
1066    // `workspace::conformance` module — the same fixture the conformance
1067    // suite runs against, so any drift between the test fixture and the
1068    // contract documented for new backends shows up immediately. Tests
1069    // capture the auto-generated version at read time rather than
1070    // hard-coding it, which keeps assertions decoupled from the version
1071    // scheme of the mock.
1072
1073    use super::conformance::InMemoryFileSystem;
1074
1075    fn versioned_services(fs: Arc<InMemoryFileSystem>) -> Arc<WorkspaceServices> {
1076        let fs_ws: Arc<dyn WorkspaceFileSystem> = fs.clone();
1077        let fs_ext: Arc<dyn WorkspaceFileSystemExt> = fs;
1078        WorkspaceServices::builder(WorkspaceRef::new("mem", "mem://ws"), fs_ws)
1079            .file_system_ext(fs_ext)
1080            .build()
1081    }
1082
1083    /// Seed a file into the mock and return its current version, so callers
1084    /// can use it as an expected value without hardcoding the version
1085    /// scheme.
1086    async fn seed(fs: &Arc<InMemoryFileSystem>, path: &str, content: &str) -> String {
1087        use super::WorkspaceFileSystemExt;
1088        let ws_path = WorkspacePath::from_normalized(path);
1089        (*fs).write_text(&ws_path, content).await.unwrap();
1090        let (_, version) = (*fs).read_text_with_version(&ws_path).await.unwrap();
1091        version
1092    }
1093
1094    #[test]
1095    fn version_conflict_is_downcastable_from_anyhow() {
1096        let e: anyhow::Error = anyhow::Error::new(WorkspaceVersionConflict {
1097            path: "a/b.txt".to_string(),
1098            expected: "etag-1".to_string(),
1099            actual: Some("etag-2".to_string()),
1100        });
1101        let c = e.downcast_ref::<WorkspaceVersionConflict>().unwrap();
1102        assert_eq!(c.path, "a/b.txt");
1103        assert_eq!(c.expected, "etag-1");
1104        assert_eq!(c.actual.as_deref(), Some("etag-2"));
1105        // Display must include the path and both versions so logs are useful.
1106        let msg = e.to_string();
1107        assert!(msg.contains("a/b.txt"), "msg: {msg}");
1108        assert!(msg.contains("etag-1"), "msg: {msg}");
1109    }
1110
1111    #[tokio::test]
1112    async fn read_for_edit_returns_version_when_ext_available() {
1113        let fs = Arc::new(InMemoryFileSystem::new());
1114        let seeded_version = seed(&fs, "notes.md", "hello").await;
1115        let services = versioned_services(fs);
1116
1117        let path = WorkspacePath::from_normalized("notes.md");
1118        let (content, version) = services.read_for_edit(&path).await.unwrap();
1119        assert_eq!(content, "hello");
1120        assert_eq!(
1121            version.as_deref(),
1122            Some(seeded_version.as_str()),
1123            "read_for_edit must return the version produced by the prior write"
1124        );
1125    }
1126
1127    #[tokio::test]
1128    async fn read_for_edit_returns_no_version_when_ext_absent() {
1129        struct PlainFs;
1130        #[async_trait]
1131        impl WorkspaceFileSystem for PlainFs {
1132            async fn read_text(&self, _path: &WorkspacePath) -> WorkspaceResult<String> {
1133                Ok("plain".to_string())
1134            }
1135            async fn write_text(
1136                &self,
1137                _path: &WorkspacePath,
1138                content: &str,
1139            ) -> WorkspaceResult<WorkspaceWriteOutcome> {
1140                Ok(WorkspaceWriteOutcome {
1141                    bytes: content.len(),
1142                    lines: content.lines().count(),
1143                })
1144            }
1145            async fn list_dir(
1146                &self,
1147                _path: &WorkspacePath,
1148            ) -> WorkspaceResult<Vec<WorkspaceDirEntry>> {
1149                Ok(Vec::new())
1150            }
1151        }
1152        let fs: Arc<dyn WorkspaceFileSystem> = Arc::new(PlainFs);
1153        let services =
1154            WorkspaceServices::builder(WorkspaceRef::new("plain", "plain://ws"), fs).build();
1155
1156        let path = WorkspacePath::from_normalized("any.txt");
1157        let (content, version) = services.read_for_edit(&path).await.unwrap();
1158        assert_eq!(content, "plain");
1159        assert!(version.is_none());
1160        assert!(services.fs_ext().is_none());
1161    }
1162
1163    #[tokio::test]
1164    async fn write_for_edit_succeeds_on_matching_version() {
1165        let fs = Arc::new(InMemoryFileSystem::new());
1166        seed(&fs, "doc.md", "alpha").await;
1167        let services = versioned_services(fs.clone());
1168        let path = WorkspacePath::from_normalized("doc.md");
1169
1170        let (content, version) = services.read_for_edit(&path).await.unwrap();
1171        assert_eq!(content, "alpha");
1172
1173        services
1174            .write_for_edit(&path, "beta", version.as_deref())
1175            .await
1176            .expect("write should succeed with matching version");
1177
1178        let current = fs.read_text(&path).await.unwrap();
1179        assert_eq!(current, "beta");
1180    }
1181
1182    #[tokio::test]
1183    async fn write_for_edit_surfaces_conflict_when_version_changed() {
1184        let fs = Arc::new(InMemoryFileSystem::new());
1185        let seeded_version = seed(&fs, "doc.md", "alpha").await;
1186        let services = versioned_services(fs.clone());
1187        let path = WorkspacePath::from_normalized("doc.md");
1188
1189        let (_, version) = services.read_for_edit(&path).await.unwrap();
1190        // Simulate a concurrent overwrite — a real "second writer" doing
1191        // exactly what the conflict protection is meant to catch.
1192        fs.write_text(&path, "from-concurrent-writer")
1193            .await
1194            .unwrap();
1195
1196        let err = services
1197            .write_for_edit(&path, "beta", version.as_deref())
1198            .await
1199            .expect_err("write should reject with conflict");
1200        let WorkspaceError::VersionConflict(conflict) = err else {
1201            panic!("expected WorkspaceError::VersionConflict, got {err:?}");
1202        };
1203        assert_eq!(conflict.path, "doc.md");
1204        assert_eq!(conflict.expected, seeded_version);
1205        // We don't pin the actual version's exact value — only that the
1206        // backend supplies one and that it differs from what we expected.
1207        let actual = conflict
1208            .actual
1209            .as_deref()
1210            .expect("conflict must report the current version");
1211        assert_ne!(actual, seeded_version);
1212    }
1213
1214    #[tokio::test]
1215    async fn write_for_edit_falls_back_to_plain_write_when_version_is_none() {
1216        // Even with fs_ext present, passing version=None must route through
1217        // unconditional write_text (e.g. for fresh-create paths).
1218        let fs = Arc::new(InMemoryFileSystem::new());
1219        seed(&fs, "doc.md", "alpha").await;
1220        let services = versioned_services(fs.clone());
1221        let path = WorkspacePath::from_normalized("doc.md");
1222
1223        // Concurrent overwriter, but caller did not request CAS semantics:
1224        fs.write_text(&path, "from-concurrent-writer")
1225            .await
1226            .unwrap();
1227
1228        services
1229            .write_for_edit(&path, "beta", None)
1230            .await
1231            .expect("plain write should not check version");
1232        let current = fs.read_text(&path).await.unwrap();
1233        assert_eq!(current, "beta");
1234    }
1235}