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 local_access;
15mod manifest;
16mod path;
17mod remote_git;
18#[cfg(feature = "s3")]
19mod s3;
20mod services;
21
22pub use error::{WorkspaceError, WorkspaceResult};
23pub use local::LocalWorkspaceBackend;
24pub use local_access::LocalWorkspaceAccessPolicy;
25pub use manifest::{
26    scan_workspace_files, LocalWorkspaceFile, LocalWorkspaceFileStatus, LocalWorkspaceManifest,
27    LocalWorkspaceManifestSnapshot, ManifestWorkspaceBackend, RecentWorkspaceFile,
28    WorkspaceFileChange, WorkspaceFileChangeKind,
29};
30pub use path::VirtualPathResolver;
31use path::{
32    default_path_input, has_windows_path_prefix, normalize_relative_path, pathbuf_to_workspace_path,
33};
34pub(crate) use path::{escape_control_chars_for_display, validate_relative_pattern};
35pub use remote_git::{RemoteGitBackend, RemoteGitBackendConfig, RemoteGitConflict};
36#[cfg(feature = "s3")]
37pub use s3::{S3BackendConfig, S3WorkspaceBackend};
38pub use services::{WorkspaceServices, WorkspaceServicesBuilder};
39
40use anyhow::Result;
41use async_trait::async_trait;
42use std::collections::HashMap;
43use std::sync::Arc;
44
45/// Identity and display metadata for a workspace.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct WorkspaceRef {
48    /// Stable workspace identifier used by host backends.
49    pub id: String,
50    /// Human-readable root shown in tool output.
51    pub display_root: String,
52}
53
54impl WorkspaceRef {
55    pub fn new(id: impl Into<String>, display_root: impl Into<String>) -> Self {
56        Self {
57            id: id.into(),
58            display_root: display_root.into(),
59        }
60    }
61}
62
63/// A normalized virtual path inside a workspace.
64#[derive(Debug, Clone, PartialEq, Eq, Hash)]
65pub struct WorkspacePath {
66    inner: String,
67}
68
69impl WorkspacePath {
70    pub fn root() -> Self {
71        Self {
72            inner: ".".to_string(),
73        }
74    }
75
76    pub fn from_normalized(path: impl Into<String>) -> Self {
77        let path = path.into();
78        let path = path.trim_matches('/');
79        if path.is_empty() || path == "." {
80            Self::root()
81        } else {
82            Self {
83                inner: path.replace('\\', "/"),
84            }
85        }
86    }
87
88    pub fn as_str(&self) -> &str {
89        &self.inner
90    }
91
92    pub fn is_root(&self) -> bool {
93        self.inner == "."
94    }
95}
96
97/// Workspace capability flags used to gate which built-in tools are registered.
98///
99/// Each flag corresponds to a provider trait on [`WorkspaceServices`]; flags
100/// without a backing provider are deliberately omitted so the surface stays
101/// minimal until a real consumer appears.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub struct WorkspaceCapabilities {
104    pub read: bool,
105    pub write: bool,
106    pub exec: bool,
107    pub search: bool,
108    pub git: bool,
109    pub code_intelligence: bool,
110}
111
112impl WorkspaceCapabilities {
113    pub fn local_default() -> Self {
114        Self {
115            read: true,
116            write: true,
117            exec: true,
118            search: true,
119            git: true,
120            code_intelligence: false,
121        }
122    }
123
124    pub fn read_write() -> Self {
125        Self {
126            read: true,
127            write: true,
128            exec: false,
129            search: false,
130            git: false,
131            code_intelligence: false,
132        }
133    }
134}
135
136impl Default for WorkspaceCapabilities {
137    fn default() -> Self {
138        Self::read_write()
139    }
140}
141
142/// Directory entry kind.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum WorkspaceFileType {
145    File,
146    Directory,
147    Symlink,
148    Unknown,
149}
150
151impl WorkspaceFileType {
152    pub fn as_tool_kind(self) -> &'static str {
153        match self {
154            Self::File => "file",
155            Self::Directory => "dir",
156            Self::Symlink => "link",
157            Self::Unknown => "unknown",
158        }
159    }
160}
161
162/// Directory entry returned by a workspace backend.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct WorkspaceDirEntry {
165    pub name: String,
166    pub kind: WorkspaceFileType,
167    pub size: u64,
168}
169
170/// Result metadata for a write operation.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct WorkspaceWriteOutcome {
173    pub bytes: usize,
174    pub lines: usize,
175}
176
177/// Glob request for workspace-backed search.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct WorkspaceGlobRequest {
180    pub base: WorkspacePath,
181    pub pattern: String,
182}
183
184/// Glob result returned by a workspace search provider.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct WorkspaceGlobResult {
187    pub matches: Vec<WorkspacePath>,
188}
189
190/// Grep request for workspace-backed search.
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct WorkspaceGrepRequest {
193    pub base: WorkspacePath,
194    pub pattern: String,
195    pub glob: Option<String>,
196    pub context_lines: usize,
197    pub case_insensitive: bool,
198    /// Maximum rendered match bytes. Zero requests a metadata-only scan:
199    /// backends count every matching line and collect distinct source paths
200    /// without constructing match text.
201    pub max_output_size: usize,
202}
203
204/// Grep result returned by a workspace search provider.
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct WorkspaceGrepResult {
207    pub output: String,
208    pub match_count: usize,
209    pub file_count: usize,
210    pub truncated: bool,
211}
212
213/// Grep result plus structured source evidence when supplied by a backend.
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub struct WorkspaceGrepOutcome {
216    pub result: WorkspaceGrepResult,
217    /// Distinct paths that contributed rendered match lines, in result order.
218    /// `None` denotes a legacy/custom backend with display output only.
219    pub matched_paths: Option<Vec<WorkspacePath>>,
220}
221
222/// Repository status returned by a workspace Git provider.
223#[derive(Debug, Clone, PartialEq, Eq)]
224pub struct WorkspaceGitStatus {
225    pub branch: String,
226    pub commit: String,
227    pub is_worktree: bool,
228    pub is_dirty: bool,
229    pub dirty_count: usize,
230}
231
232/// Commit information returned by a workspace Git provider.
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct WorkspaceGitCommit {
235    pub id: String,
236    pub message: String,
237    pub author: String,
238    pub date: String,
239}
240
241/// Branch information returned by a workspace Git provider.
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct WorkspaceGitBranch {
244    pub name: String,
245    pub is_current: bool,
246}
247
248/// Branch creation request for a workspace Git provider.
249#[derive(Debug, Clone, PartialEq, Eq)]
250pub struct WorkspaceGitCreateBranchRequest {
251    pub name: String,
252    pub base: String,
253}
254
255/// Checkout request for a workspace Git provider.
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct WorkspaceGitCheckoutRequest {
258    pub refspec: String,
259    pub force: bool,
260}
261
262/// Checkout output returned by a workspace Git provider.
263#[derive(Debug, Clone, PartialEq, Eq)]
264pub struct WorkspaceGitCheckoutOutput {
265    pub stdout: String,
266}
267
268/// Diff request for a workspace Git provider.
269#[derive(Debug, Clone, PartialEq, Eq)]
270pub struct WorkspaceGitDiffRequest {
271    pub target: Option<String>,
272}
273
274/// Stash information returned by a workspace Git provider.
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub struct WorkspaceGitStash {
277    pub index: usize,
278    pub message: String,
279}
280
281/// Stash request for a workspace Git provider.
282#[derive(Debug, Clone, PartialEq, Eq)]
283pub struct WorkspaceGitStashRequest {
284    pub message: Option<String>,
285    pub include_untracked: bool,
286}
287
288/// Remote information returned by a workspace Git provider.
289#[derive(Debug, Clone, PartialEq, Eq)]
290pub struct WorkspaceGitRemote {
291    pub name: String,
292    pub url: String,
293    pub direction: String,
294}
295
296/// Worktree information returned by a workspace Git provider.
297#[derive(Debug, Clone, PartialEq, Eq)]
298pub struct WorkspaceGitWorktree {
299    pub path: String,
300    pub branch: String,
301    pub is_bare: bool,
302    pub is_detached: bool,
303}
304
305/// Worktree creation request for a workspace Git provider.
306#[derive(Debug, Clone, PartialEq, Eq)]
307pub struct WorkspaceGitCreateWorktreeRequest {
308    pub branch: String,
309    pub path: Option<String>,
310    pub new_branch: bool,
311}
312
313/// Worktree removal request for a workspace Git provider.
314#[derive(Debug, Clone, PartialEq, Eq)]
315pub struct WorkspaceGitRemoveWorktreeRequest {
316    pub path: String,
317    pub force: bool,
318}
319
320/// Mutation result for workspace Git worktree operations.
321#[derive(Debug, Clone, PartialEq, Eq)]
322pub struct WorkspaceGitWorktreeMutation {
323    pub path: String,
324    pub branch: Option<String>,
325}
326
327/// Observer that receives streaming output deltas from a workspace command.
328///
329/// Backend implementations call this on each chunk of stdout/stderr they
330/// observe. Tool layers wire host event channels behind this trait, so the
331/// workspace abstraction does not depend on any tool event type.
332#[async_trait]
333pub trait CommandOutputObserver: Send + Sync {
334    async fn on_output_delta(&self, delta: &str);
335
336    /// Receive the final bounded-capture accounting for the command.
337    ///
338    /// The default keeps existing remote workspace runners source-compatible.
339    /// Runners that bound output should report the original byte count so
340    /// callers can distinguish a complete result from a partial observation.
341    async fn on_output_complete(&self, _summary: &CommandOutputSummary) {}
342}
343
344/// Final accounting for a bounded command-output capture.
345#[derive(Debug, Clone, Copy, PartialEq, Eq)]
346pub struct CommandOutputSummary {
347    /// Total stdout and stderr bytes observed before completion or timeout.
348    pub total_bytes: usize,
349    /// Original process bytes retained in the rendered output.
350    pub captured_bytes: usize,
351    /// Whether bytes were omitted from the middle of the rendered output.
352    pub truncated: bool,
353    /// Whether command execution reached its own deadline.
354    pub timed_out: bool,
355}
356
357/// Command execution request.
358#[derive(Clone)]
359pub struct CommandRequest {
360    pub command: String,
361    pub timeout_ms: u64,
362    pub output_observer: Option<Arc<dyn CommandOutputObserver>>,
363    pub env: Option<Arc<HashMap<String, String>>>,
364}
365
366impl std::fmt::Debug for CommandRequest {
367    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
368        f.debug_struct("CommandRequest")
369            .field("command", &self.command)
370            .field("timeout_ms", &self.timeout_ms)
371            .field("output_observer", &self.output_observer.is_some())
372            .field("env", &self.env.as_ref().map(|env| env.len()))
373            .finish()
374    }
375}
376
377/// Command execution output.
378#[derive(Debug, Clone, PartialEq, Eq)]
379pub struct CommandOutput {
380    pub output: String,
381    pub exit_code: i32,
382    pub timed_out: bool,
383}
384
385/// Normalizes and validates host-supplied paths before they reach a backend.
386pub trait WorkspacePathResolver: Send + Sync {
387    fn normalize(&self, input: &str) -> Result<WorkspacePath>;
388}
389
390/// File operations available to built-in file tools.
391///
392/// **Trait stability policy:** new methods added to this trait are a breaking
393/// change for every external backend implementation. Until the workspace
394/// extension story is stabilised, new methods will be added to a separate
395/// `WorkspaceFileSystemExt` trait (with default implementations that fall back
396/// to the core methods) rather than to this trait directly. Backend authors
397/// can rely on this trait surface remaining additive only through extension
398/// traits.
399#[async_trait]
400pub trait WorkspaceFileSystem: Send + Sync {
401    async fn read_text(&self, path: &WorkspacePath) -> WorkspaceResult<String>;
402    async fn write_text(
403        &self,
404        path: &WorkspacePath,
405        content: &str,
406    ) -> WorkspaceResult<WorkspaceWriteOutcome>;
407    async fn list_dir(&self, path: &WorkspacePath) -> WorkspaceResult<Vec<WorkspaceDirEntry>>;
408}
409
410/// A bounded text range returned by an optional streaming workspace reader.
411#[derive(Debug, Clone, PartialEq, Eq)]
412pub struct WorkspaceTextRange {
413    pub lines: Vec<String>,
414    pub next_offset: Option<usize>,
415    pub eof: bool,
416    /// Exact line count when EOF was observed while satisfying the request.
417    pub total_lines: Option<usize>,
418}
419
420/// Optional streaming text capability for backends that can avoid loading a
421/// complete file when a caller needs only a line range.
422#[async_trait]
423pub trait WorkspaceTextReader: Send + Sync {
424    async fn read_text_range(
425        &self,
426        path: &WorkspacePath,
427        offset: usize,
428        limit: usize,
429    ) -> WorkspaceResult<WorkspaceTextRange>;
430}
431
432/// Error returned by [`WorkspaceFileSystemExt::write_text_if_version`] when
433/// the underlying object version no longer matches the expected version.
434///
435/// Surfaced through `anyhow::Error`; tools recover by downcasting:
436/// `err.downcast_ref::<WorkspaceVersionConflict>()`. The typical response is
437/// to re-read the file and retry the modify-write cycle once.
438#[derive(Debug, Clone, thiserror::Error)]
439#[error(
440    "version conflict on {path}: expected version {expected:?}, found {actual:?} (file modified by another writer; re-read and retry)"
441)]
442pub struct WorkspaceVersionConflict {
443    pub path: String,
444    pub expected: String,
445    /// Backend-reported current version, if known. S3 does not return the
446    /// current ETag on `412 Precondition Failed`, so this is typically `None`.
447    pub actual: Option<String>,
448}
449
450/// Optional compare-and-swap extensions to [`WorkspaceFileSystem`].
451///
452/// Implemented by backends that expose object-level versioning (S3 ETag,
453/// future GCS generation, ...) so tools that perform read-modify-write
454/// cycles can reject concurrent overwrites. Tools should access this through
455/// [`WorkspaceServices::fs_ext`] — when absent, callers fall back to plain
456/// `read_text` / `write_text` (last-writer-wins).
457///
458/// Kept as a separate trait rather than inheriting from
459/// [`WorkspaceFileSystem`] so existing backend implementations are not
460/// forced to opt in.
461#[async_trait]
462pub trait WorkspaceFileSystemExt: Send + Sync {
463    /// Read text content together with an opaque version token. Tokens are
464    /// backend-specific (S3 returns the ETag) and treated as opaque by
465    /// callers — they are only ever compared for equality on the backend
466    /// side.
467    async fn read_text_with_version(
468        &self,
469        path: &WorkspacePath,
470    ) -> WorkspaceResult<(String, String)>;
471
472    /// Write content iff the current object version matches `expected_version`.
473    /// On mismatch the returned error is the typed
474    /// [`WorkspaceError::VersionConflict`] variant; callers can also still
475    /// downcast through `anyhow::Error` when the value has been lifted into
476    /// the legacy result type.
477    async fn write_text_if_version(
478        &self,
479        path: &WorkspacePath,
480        content: &str,
481        expected_version: &str,
482    ) -> WorkspaceResult<WorkspaceWriteOutcome>;
483}
484
485/// Shell/command execution available to the `bash` tool.
486#[async_trait]
487pub trait WorkspaceCommandRunner: Send + Sync {
488    async fn exec(&self, request: CommandRequest) -> Result<CommandOutput>;
489}
490
491/// Search operations available to `glob` and `grep`.
492#[async_trait]
493pub trait WorkspaceSearch: Send + Sync {
494    async fn glob(&self, request: WorkspaceGlobRequest) -> Result<WorkspaceGlobResult>;
495    async fn grep(&self, request: WorkspaceGrepRequest) -> Result<WorkspaceGrepResult>;
496
497    /// Run grep with structured source paths when the backend can provide them.
498    ///
499    /// The default preserves compatibility for custom backends implementing
500    /// only [`Self::grep`]. Callers must treat its display output as untrusted.
501    async fn grep_with_sources(
502        &self,
503        request: WorkspaceGrepRequest,
504    ) -> Result<WorkspaceGrepOutcome> {
505        let result = self.grep(request).await?;
506        Ok(WorkspaceGrepOutcome {
507            result,
508            matched_paths: None,
509        })
510    }
511}
512
513/// Core Git operations supported by virtually every workspace Git backend.
514///
515/// Optional features (stash, worktrees) live in separate traits so backends
516/// like browser-side `isomorphic-git` can implement only what they support
517/// instead of returning runtime "unsupported" errors.
518#[async_trait]
519pub trait WorkspaceGit: Send + Sync {
520    async fn is_repository(&self) -> Result<bool>;
521    async fn status(&self) -> Result<WorkspaceGitStatus>;
522    async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>>;
523    async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>>;
524    async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()>;
525    async fn checkout(
526        &self,
527        request: WorkspaceGitCheckoutRequest,
528    ) -> Result<WorkspaceGitCheckoutOutput>;
529    async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String>;
530    async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>>;
531}
532
533/// Optional Git stash operations.
534///
535/// Browser-side libraries such as `isomorphic-git` do not implement stash;
536/// backends that cannot stash simply do not implement this trait.
537#[async_trait]
538pub trait WorkspaceGitStashProvider: Send + Sync {
539    async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>>;
540    async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()>;
541}
542
543/// Optional Git worktree operations.
544///
545/// Worktrees are a local-filesystem concept and are typically not supported
546/// by remote or browser-backed git providers.
547#[async_trait]
548pub trait WorkspaceGitWorktreeProvider: Send + Sync {
549    async fn list_worktrees(&self) -> Result<Vec<WorkspaceGitWorktree>>;
550    async fn create_worktree(
551        &self,
552        request: WorkspaceGitCreateWorktreeRequest,
553    ) -> Result<WorkspaceGitWorktreeMutation>;
554    async fn remove_worktree(
555        &self,
556        request: WorkspaceGitRemoveWorktreeRequest,
557    ) -> Result<WorkspaceGitWorktreeMutation>;
558}
559
560#[cfg(test)]
561#[path = "tests.rs"]
562mod tests;