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
10mod local;
11#[cfg(feature = "s3")]
12mod s3;
13
14pub use local::LocalWorkspaceBackend;
15#[cfg(feature = "s3")]
16pub use s3::{S3BackendConfig, S3WorkspaceBackend};
17
18use anyhow::{anyhow, bail, Result};
19use async_trait::async_trait;
20use std::collections::HashMap;
21use std::path::{Component, Path, PathBuf};
22use std::sync::Arc;
23
24/// Identity and display metadata for a workspace.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct WorkspaceRef {
27    /// Stable workspace identifier used by host backends.
28    pub id: String,
29    /// Human-readable root shown in tool output.
30    pub display_root: String,
31}
32
33impl WorkspaceRef {
34    pub fn new(id: impl Into<String>, display_root: impl Into<String>) -> Self {
35        Self {
36            id: id.into(),
37            display_root: display_root.into(),
38        }
39    }
40}
41
42/// A normalized virtual path inside a workspace.
43#[derive(Debug, Clone, PartialEq, Eq, Hash)]
44pub struct WorkspacePath {
45    inner: String,
46}
47
48impl WorkspacePath {
49    pub fn root() -> Self {
50        Self {
51            inner: ".".to_string(),
52        }
53    }
54
55    pub fn from_normalized(path: impl Into<String>) -> Self {
56        let path = path.into();
57        let path = path.trim_matches('/');
58        if path.is_empty() || path == "." {
59            Self::root()
60        } else {
61            Self {
62                inner: path.replace('\\', "/"),
63            }
64        }
65    }
66
67    pub fn as_str(&self) -> &str {
68        &self.inner
69    }
70
71    pub fn is_root(&self) -> bool {
72        self.inner == "."
73    }
74}
75
76/// Workspace capability flags used to gate which built-in tools are registered.
77///
78/// Each flag corresponds to a provider trait on [`WorkspaceServices`]; flags
79/// without a backing provider are deliberately omitted so the surface stays
80/// minimal until a real consumer appears.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub struct WorkspaceCapabilities {
83    pub read: bool,
84    pub write: bool,
85    pub exec: bool,
86    pub search: bool,
87    pub git: bool,
88}
89
90impl WorkspaceCapabilities {
91    pub fn local_default() -> Self {
92        Self {
93            read: true,
94            write: true,
95            exec: true,
96            search: true,
97            git: true,
98        }
99    }
100
101    pub fn read_write() -> Self {
102        Self {
103            read: true,
104            write: true,
105            exec: false,
106            search: false,
107            git: false,
108        }
109    }
110}
111
112impl Default for WorkspaceCapabilities {
113    fn default() -> Self {
114        Self::read_write()
115    }
116}
117
118/// Directory entry kind.
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum WorkspaceFileType {
121    File,
122    Directory,
123    Symlink,
124    Unknown,
125}
126
127impl WorkspaceFileType {
128    pub fn as_tool_kind(self) -> &'static str {
129        match self {
130            Self::File => "file",
131            Self::Directory => "dir",
132            Self::Symlink => "link",
133            Self::Unknown => "unknown",
134        }
135    }
136}
137
138/// Directory entry returned by a workspace backend.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct WorkspaceDirEntry {
141    pub name: String,
142    pub kind: WorkspaceFileType,
143    pub size: u64,
144}
145
146/// Result metadata for a write operation.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct WorkspaceWriteOutcome {
149    pub bytes: usize,
150    pub lines: usize,
151}
152
153/// Glob request for workspace-backed search.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct WorkspaceGlobRequest {
156    pub base: WorkspacePath,
157    pub pattern: String,
158}
159
160/// Glob result returned by a workspace search provider.
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct WorkspaceGlobResult {
163    pub matches: Vec<WorkspacePath>,
164}
165
166/// Grep request for workspace-backed search.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct WorkspaceGrepRequest {
169    pub base: WorkspacePath,
170    pub pattern: String,
171    pub glob: Option<String>,
172    pub context_lines: usize,
173    pub case_insensitive: bool,
174    pub max_output_size: usize,
175}
176
177/// Grep result returned by a workspace search provider.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct WorkspaceGrepResult {
180    pub output: String,
181    pub match_count: usize,
182    pub file_count: usize,
183    pub truncated: bool,
184}
185
186/// Repository status returned by a workspace Git provider.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct WorkspaceGitStatus {
189    pub branch: String,
190    pub commit: String,
191    pub is_worktree: bool,
192    pub is_dirty: bool,
193    pub dirty_count: usize,
194}
195
196/// Commit information returned by a workspace Git provider.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct WorkspaceGitCommit {
199    pub id: String,
200    pub message: String,
201    pub author: String,
202    pub date: String,
203}
204
205/// Branch information returned by a workspace Git provider.
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct WorkspaceGitBranch {
208    pub name: String,
209    pub is_current: bool,
210}
211
212/// Branch creation request for a workspace Git provider.
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct WorkspaceGitCreateBranchRequest {
215    pub name: String,
216    pub base: String,
217}
218
219/// Checkout request for a workspace Git provider.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct WorkspaceGitCheckoutRequest {
222    pub refspec: String,
223    pub force: bool,
224}
225
226/// Checkout output returned by a workspace Git provider.
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct WorkspaceGitCheckoutOutput {
229    pub stdout: String,
230}
231
232/// Diff request for a workspace Git provider.
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct WorkspaceGitDiffRequest {
235    pub target: Option<String>,
236}
237
238/// Stash information returned by a workspace Git provider.
239#[derive(Debug, Clone, PartialEq, Eq)]
240pub struct WorkspaceGitStash {
241    pub index: usize,
242    pub message: String,
243}
244
245/// Stash request for a workspace Git provider.
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub struct WorkspaceGitStashRequest {
248    pub message: Option<String>,
249    pub include_untracked: bool,
250}
251
252/// Remote information returned by a workspace Git provider.
253#[derive(Debug, Clone, PartialEq, Eq)]
254pub struct WorkspaceGitRemote {
255    pub name: String,
256    pub url: String,
257    pub direction: String,
258}
259
260/// Worktree information returned by a workspace Git provider.
261#[derive(Debug, Clone, PartialEq, Eq)]
262pub struct WorkspaceGitWorktree {
263    pub path: String,
264    pub branch: String,
265    pub is_bare: bool,
266    pub is_detached: bool,
267}
268
269/// Worktree creation request for a workspace Git provider.
270#[derive(Debug, Clone, PartialEq, Eq)]
271pub struct WorkspaceGitCreateWorktreeRequest {
272    pub branch: String,
273    pub path: Option<String>,
274    pub new_branch: bool,
275}
276
277/// Worktree removal request for a workspace Git provider.
278#[derive(Debug, Clone, PartialEq, Eq)]
279pub struct WorkspaceGitRemoveWorktreeRequest {
280    pub path: String,
281    pub force: bool,
282}
283
284/// Mutation result for workspace Git worktree operations.
285#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct WorkspaceGitWorktreeMutation {
287    pub path: String,
288    pub branch: Option<String>,
289}
290
291/// Observer that receives streaming output deltas from a workspace command.
292///
293/// Backend implementations call this on each chunk of stdout/stderr they
294/// observe. Tool layers wire host event channels behind this trait, so the
295/// workspace abstraction does not depend on any tool event type.
296#[async_trait]
297pub trait CommandOutputObserver: Send + Sync {
298    async fn on_output_delta(&self, delta: &str);
299}
300
301/// Command execution request.
302#[derive(Clone)]
303pub struct CommandRequest {
304    pub command: String,
305    pub timeout_ms: u64,
306    pub output_observer: Option<Arc<dyn CommandOutputObserver>>,
307    pub env: Option<Arc<HashMap<String, String>>>,
308}
309
310impl std::fmt::Debug for CommandRequest {
311    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
312        f.debug_struct("CommandRequest")
313            .field("command", &self.command)
314            .field("timeout_ms", &self.timeout_ms)
315            .field("output_observer", &self.output_observer.is_some())
316            .field("env", &self.env.as_ref().map(|env| env.len()))
317            .finish()
318    }
319}
320
321/// Command execution output.
322#[derive(Debug, Clone, PartialEq, Eq)]
323pub struct CommandOutput {
324    pub output: String,
325    pub exit_code: i32,
326    pub timed_out: bool,
327}
328
329/// Normalizes and validates host-supplied paths before they reach a backend.
330pub trait WorkspacePathResolver: Send + Sync {
331    fn normalize(&self, input: &str) -> Result<WorkspacePath>;
332}
333
334/// File operations available to built-in file tools.
335///
336/// **Trait stability policy:** new methods added to this trait are a breaking
337/// change for every external backend implementation. Until the workspace
338/// extension story is stabilised, new methods will be added to a separate
339/// `WorkspaceFileSystemExt` trait (with default implementations that fall back
340/// to the core methods) rather than to this trait directly. Backend authors
341/// can rely on this trait surface remaining additive only through extension
342/// traits.
343#[async_trait]
344pub trait WorkspaceFileSystem: Send + Sync {
345    async fn read_text(&self, path: &WorkspacePath) -> Result<String>;
346    async fn write_text(
347        &self,
348        path: &WorkspacePath,
349        content: &str,
350    ) -> Result<WorkspaceWriteOutcome>;
351    async fn list_dir(&self, path: &WorkspacePath) -> Result<Vec<WorkspaceDirEntry>>;
352}
353
354/// Shell/command execution available to the `bash` tool.
355#[async_trait]
356pub trait WorkspaceCommandRunner: Send + Sync {
357    async fn exec(&self, request: CommandRequest) -> Result<CommandOutput>;
358}
359
360/// Search operations available to `glob` and `grep`.
361#[async_trait]
362pub trait WorkspaceSearch: Send + Sync {
363    async fn glob(&self, request: WorkspaceGlobRequest) -> Result<WorkspaceGlobResult>;
364    async fn grep(&self, request: WorkspaceGrepRequest) -> Result<WorkspaceGrepResult>;
365}
366
367/// Core Git operations supported by virtually every workspace Git backend.
368///
369/// Optional features (stash, worktrees) live in separate traits so backends
370/// like browser-side `isomorphic-git` can implement only what they support
371/// instead of returning runtime "unsupported" errors.
372#[async_trait]
373pub trait WorkspaceGit: Send + Sync {
374    async fn is_repository(&self) -> Result<bool>;
375    async fn status(&self) -> Result<WorkspaceGitStatus>;
376    async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>>;
377    async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>>;
378    async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()>;
379    async fn checkout(
380        &self,
381        request: WorkspaceGitCheckoutRequest,
382    ) -> Result<WorkspaceGitCheckoutOutput>;
383    async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String>;
384    async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>>;
385}
386
387/// Optional Git stash operations.
388///
389/// Browser-side libraries such as `isomorphic-git` do not implement stash;
390/// backends that cannot stash simply do not implement this trait.
391#[async_trait]
392pub trait WorkspaceGitStashProvider: Send + Sync {
393    async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>>;
394    async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()>;
395}
396
397/// Optional Git worktree operations.
398///
399/// Worktrees are a local-filesystem concept and are typically not supported
400/// by remote or browser-backed git providers.
401#[async_trait]
402pub trait WorkspaceGitWorktreeProvider: Send + Sync {
403    async fn list_worktrees(&self) -> Result<Vec<WorkspaceGitWorktree>>;
404    async fn create_worktree(
405        &self,
406        request: WorkspaceGitCreateWorktreeRequest,
407    ) -> Result<WorkspaceGitWorktreeMutation>;
408    async fn remove_worktree(
409        &self,
410        request: WorkspaceGitRemoveWorktreeRequest,
411    ) -> Result<WorkspaceGitWorktreeMutation>;
412}
413
414/// The host-provided workspace capability bundle used by tool execution.
415pub struct WorkspaceServices {
416    workspace_ref: WorkspaceRef,
417    capabilities: WorkspaceCapabilities,
418    path_resolver: Arc<dyn WorkspacePathResolver>,
419    file_system: Arc<dyn WorkspaceFileSystem>,
420    command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
421    search: Option<Arc<dyn WorkspaceSearch>>,
422    git: Option<Arc<dyn WorkspaceGit>>,
423    git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
424    git_worktree: Option<Arc<dyn WorkspaceGitWorktreeProvider>>,
425    /// Default timeout applied to non-bash workspace operations (file system,
426    /// search, git). Bash uses its own per-call timeout in [`CommandRequest`].
427    /// `None` means no enforced timeout — appropriate for the local backend.
428    operation_timeout: Option<std::time::Duration>,
429    local_root: Option<PathBuf>,
430}
431
432impl std::fmt::Debug for WorkspaceServices {
433    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
434        f.debug_struct("WorkspaceServices")
435            .field("workspace_ref", &self.workspace_ref)
436            .field("capabilities", &self.capabilities)
437            .field("command_runner", &self.command_runner.is_some())
438            .field("search", &self.search.is_some())
439            .field("git", &self.git.is_some())
440            .field("git_stash", &self.git_stash.is_some())
441            .field("git_worktree", &self.git_worktree.is_some())
442            .field("local_root", &self.local_root)
443            .finish()
444    }
445}
446
447impl WorkspaceServices {
448    pub(crate) fn new_with_git(
449        workspace_ref: WorkspaceRef,
450        mut capabilities: WorkspaceCapabilities,
451        path_resolver: Arc<dyn WorkspacePathResolver>,
452        file_system: Arc<dyn WorkspaceFileSystem>,
453        command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
454        search: Option<Arc<dyn WorkspaceSearch>>,
455        git: Option<Arc<dyn WorkspaceGit>>,
456    ) -> Self {
457        if command_runner.is_none() {
458            capabilities.exec = false;
459        }
460        if search.is_none() {
461            capabilities.search = false;
462        }
463        if git.is_none() {
464            capabilities.git = false;
465        }
466        Self {
467            workspace_ref,
468            capabilities,
469            path_resolver,
470            file_system,
471            command_runner,
472            search,
473            git,
474            git_stash: None,
475            git_worktree: None,
476            operation_timeout: None,
477            local_root: None,
478        }
479    }
480
481    pub fn builder(
482        workspace_ref: WorkspaceRef,
483        file_system: Arc<dyn WorkspaceFileSystem>,
484    ) -> WorkspaceServicesBuilder {
485        WorkspaceServicesBuilder::new(workspace_ref, file_system)
486    }
487
488    pub fn local(root: impl Into<PathBuf>) -> Arc<Self> {
489        let backend = Arc::new(LocalWorkspaceBackend::new(root.into()));
490        let workspace_ref = WorkspaceRef::new(
491            backend.root.display().to_string(),
492            backend.root.display().to_string(),
493        );
494        let path_resolver: Arc<dyn WorkspacePathResolver> = backend.clone();
495        let file_system: Arc<dyn WorkspaceFileSystem> = backend.clone();
496        let command_runner: Arc<dyn WorkspaceCommandRunner> = backend.clone();
497        let search: Arc<dyn WorkspaceSearch> = backend.clone();
498        let git: Arc<dyn WorkspaceGit> = backend.clone();
499        let git_stash: Arc<dyn WorkspaceGitStashProvider> = backend.clone();
500        let git_worktree: Arc<dyn WorkspaceGitWorktreeProvider> = backend.clone();
501        Arc::new(Self {
502            workspace_ref,
503            capabilities: WorkspaceCapabilities::local_default(),
504            path_resolver,
505            file_system,
506            command_runner: Some(command_runner),
507            search: Some(search),
508            git: Some(git),
509            git_stash: Some(git_stash),
510            git_worktree: Some(git_worktree),
511            operation_timeout: None,
512            local_root: Some(backend.root.clone()),
513        })
514    }
515
516    pub fn workspace_ref(&self) -> &WorkspaceRef {
517        &self.workspace_ref
518    }
519
520    pub fn capabilities(&self) -> WorkspaceCapabilities {
521        self.capabilities
522    }
523
524    pub fn normalize_path(&self, input: &str) -> Result<WorkspacePath> {
525        self.path_resolver.normalize(input)
526    }
527
528    pub fn fs(&self) -> Arc<dyn WorkspaceFileSystem> {
529        Arc::clone(&self.file_system)
530    }
531
532    pub fn command_runner(&self) -> Option<Arc<dyn WorkspaceCommandRunner>> {
533        self.command_runner.clone()
534    }
535
536    pub fn search(&self) -> Option<Arc<dyn WorkspaceSearch>> {
537        self.search.clone()
538    }
539
540    pub fn git(&self) -> Option<Arc<dyn WorkspaceGit>> {
541        self.git.clone()
542    }
543
544    pub fn git_stash(&self) -> Option<Arc<dyn WorkspaceGitStashProvider>> {
545        self.git_stash.clone()
546    }
547
548    pub fn git_worktree(&self) -> Option<Arc<dyn WorkspaceGitWorktreeProvider>> {
549        self.git_worktree.clone()
550    }
551
552    /// Default timeout applied to non-bash workspace operations.
553    ///
554    /// `None` means no enforced timeout. Backends that may stall (remote,
555    /// browser, DFS) should set this so tools using [`Self::run_with_timeout`]
556    /// surface a timeout error instead of letting the agent loop hang.
557    pub fn operation_timeout(&self) -> Option<std::time::Duration> {
558        self.operation_timeout
559    }
560
561    /// Run a workspace future under the configured operation timeout.
562    ///
563    /// Tools that route through file system / search / git providers should
564    /// wrap their calls with this helper so non-local backends never stall
565    /// the agent loop indefinitely.
566    pub async fn run_with_timeout<F, T>(&self, op: &'static str, fut: F) -> Result<T>
567    where
568        F: std::future::Future<Output = Result<T>>,
569    {
570        match self.operation_timeout {
571            Some(d) => tokio::time::timeout(d, fut)
572                .await
573                .map_err(|_| anyhow!("workspace operation '{}' timed out after {:?}", op, d))?,
574            None => fut.await,
575        }
576    }
577
578    pub fn local_root(&self) -> Option<&Path> {
579        self.local_root.as_deref()
580    }
581
582    pub fn display_path(&self, path: &WorkspacePath) -> String {
583        if path.is_root() {
584            return self.workspace_ref.display_root.clone();
585        }
586
587        let root = self.workspace_ref.display_root.trim_end_matches('/');
588        if root.is_empty() {
589            path.as_str().to_string()
590        } else {
591            format!("{root}/{}", path.as_str())
592        }
593    }
594}
595
596/// Builder for assembling workspace services without constructor arity churn.
597pub struct WorkspaceServicesBuilder {
598    workspace_ref: WorkspaceRef,
599    capabilities: WorkspaceCapabilities,
600    path_resolver: Arc<dyn WorkspacePathResolver>,
601    file_system: Arc<dyn WorkspaceFileSystem>,
602    command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
603    search: Option<Arc<dyn WorkspaceSearch>>,
604    git: Option<Arc<dyn WorkspaceGit>>,
605    git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
606    git_worktree: Option<Arc<dyn WorkspaceGitWorktreeProvider>>,
607    operation_timeout: Option<std::time::Duration>,
608}
609
610impl WorkspaceServicesBuilder {
611    pub fn new(workspace_ref: WorkspaceRef, file_system: Arc<dyn WorkspaceFileSystem>) -> Self {
612        Self {
613            workspace_ref,
614            capabilities: WorkspaceCapabilities::read_write(),
615            path_resolver: Arc::new(VirtualPathResolver),
616            file_system,
617            command_runner: None,
618            search: None,
619            git: None,
620            git_stash: None,
621            git_worktree: None,
622            operation_timeout: None,
623        }
624    }
625
626    pub fn capabilities(mut self, capabilities: WorkspaceCapabilities) -> Self {
627        self.capabilities = capabilities;
628        self
629    }
630
631    pub fn command_runner(mut self, command_runner: Arc<dyn WorkspaceCommandRunner>) -> Self {
632        self.capabilities.exec = true;
633        self.command_runner = Some(command_runner);
634        self
635    }
636
637    pub fn search(mut self, search: Arc<dyn WorkspaceSearch>) -> Self {
638        self.capabilities.search = true;
639        self.search = Some(search);
640        self
641    }
642
643    pub fn git(mut self, git: Arc<dyn WorkspaceGit>) -> Self {
644        self.capabilities.git = true;
645        self.git = Some(git);
646        self
647    }
648
649    pub fn git_stash(mut self, git_stash: Arc<dyn WorkspaceGitStashProvider>) -> Self {
650        self.git_stash = Some(git_stash);
651        self
652    }
653
654    pub fn git_worktree(mut self, git_worktree: Arc<dyn WorkspaceGitWorktreeProvider>) -> Self {
655        self.git_worktree = Some(git_worktree);
656        self
657    }
658
659    /// Apply a default timeout to non-bash workspace operations (file system,
660    /// search, git). Backends that may stall — remote, browser, DFS — should
661    /// set this so tools surface a timeout error rather than hanging.
662    pub fn operation_timeout(mut self, timeout: std::time::Duration) -> Self {
663        self.operation_timeout = Some(timeout);
664        self
665    }
666
667    pub fn build(self) -> Arc<WorkspaceServices> {
668        let mut services = WorkspaceServices::new_with_git(
669            self.workspace_ref,
670            self.capabilities,
671            self.path_resolver,
672            self.file_system,
673            self.command_runner,
674            self.search,
675            self.git,
676        );
677        services.git_stash = self.git_stash;
678        services.git_worktree = self.git_worktree;
679        services.operation_timeout = self.operation_timeout;
680        Arc::new(services)
681    }
682}
683
684/// Lexical resolver suitable for virtual/browser/DFS workspaces.
685#[derive(Debug, Default)]
686pub struct VirtualPathResolver;
687
688impl WorkspacePathResolver for VirtualPathResolver {
689    fn normalize(&self, input: &str) -> Result<WorkspacePath> {
690        normalize_virtual_path(input)
691    }
692}
693
694fn normalize_virtual_path(input: &str) -> Result<WorkspacePath> {
695    let input = default_path_input(input);
696    if has_windows_path_prefix(input) {
697        bail!("Absolute paths are not supported by this workspace backend");
698    }
699
700    let normalized_input = input.replace('\\', "/");
701    let path = Path::new(&normalized_input);
702    if path.is_absolute() {
703        bail!("Absolute paths are not supported by this workspace backend");
704    }
705
706    let relative = normalize_relative_path(path)?;
707    Ok(pathbuf_to_workspace_path(&relative))
708}
709
710fn default_path_input(input: &str) -> &str {
711    let trimmed = input.trim();
712    if trimmed.is_empty() {
713        "."
714    } else {
715        trimmed
716    }
717}
718
719fn has_windows_path_prefix(input: &str) -> bool {
720    let bytes = input.as_bytes();
721    if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
722        return true;
723    }
724
725    input.starts_with("\\\\") || input.starts_with("//")
726}
727
728fn validate_relative_pattern(pattern: &str, label: &str) -> Result<()> {
729    let pattern = pattern.trim();
730    if pattern.is_empty() {
731        bail!("{label} cannot be empty");
732    }
733    if has_windows_path_prefix(pattern) || Path::new(pattern).is_absolute() {
734        bail!("{label} must be relative to the workspace");
735    }
736
737    let normalized = pattern.replace('\\', "/");
738    if normalized.split('/').any(|component| component == "..") {
739        bail!("{label} must not contain parent directory traversal");
740    }
741
742    Ok(())
743}
744
745fn normalize_relative_path(path: &Path) -> Result<PathBuf> {
746    let mut out = PathBuf::new();
747    for component in path.components() {
748        match component {
749            Component::CurDir => {}
750            Component::Normal(part) => out.push(part),
751            Component::ParentDir => {
752                if !out.pop() {
753                    bail!("Workspace boundary violation: path escapes workspace");
754                }
755            }
756            Component::RootDir | Component::Prefix(_) => {
757                bail!("Absolute paths are not supported by this workspace backend");
758            }
759        }
760    }
761    Ok(out)
762}
763
764fn pathbuf_to_workspace_path(path: &Path) -> WorkspacePath {
765    let display = path.to_string_lossy().replace('\\', "/");
766    WorkspacePath::from_normalized(display)
767}
768
769#[cfg(test)]
770mod tests {
771    use super::*;
772
773    #[test]
774    fn virtual_resolver_normalizes_relative_paths() {
775        let resolver = VirtualPathResolver;
776        let path = resolver.normalize("./src/../README.md").unwrap();
777        assert_eq!(path.as_str(), "README.md");
778    }
779
780    #[test]
781    fn virtual_resolver_normalizes_backslash_separators() {
782        let resolver = VirtualPathResolver;
783        let path = resolver.normalize(r"src\main.rs").unwrap();
784        assert_eq!(path.as_str(), "src/main.rs");
785    }
786
787    #[test]
788    fn virtual_resolver_rejects_escape() {
789        let resolver = VirtualPathResolver;
790        let err = resolver.normalize("../secret.txt").unwrap_err();
791        assert!(err.to_string().contains("escapes workspace"));
792    }
793
794    #[test]
795    fn virtual_resolver_rejects_backslash_escape() {
796        let resolver = VirtualPathResolver;
797        let err = resolver.normalize(r"..\secret.txt").unwrap_err();
798        assert!(err.to_string().contains("escapes workspace"));
799    }
800
801    #[test]
802    fn virtual_resolver_rejects_absolute_paths() {
803        let resolver = VirtualPathResolver;
804        let err = resolver.normalize("/tmp/secret.txt").unwrap_err();
805        assert!(err.to_string().contains("Absolute paths"));
806    }
807
808    #[test]
809    fn virtual_resolver_rejects_windows_absolute_paths() {
810        let resolver = VirtualPathResolver;
811
812        let drive_err = resolver.normalize(r"C:\Users\secret.txt").unwrap_err();
813        assert!(drive_err.to_string().contains("Absolute paths"));
814
815        let unc_err = resolver
816            .normalize(r"\\server\share\secret.txt")
817            .unwrap_err();
818        assert!(unc_err.to_string().contains("Absolute paths"));
819    }
820
821    #[test]
822    fn workspace_services_disable_exec_without_runner() {
823        struct EmptyFs;
824
825        #[async_trait]
826        impl WorkspaceFileSystem for EmptyFs {
827            async fn read_text(&self, _path: &WorkspacePath) -> Result<String> {
828                bail!("not implemented")
829            }
830
831            async fn write_text(
832                &self,
833                _path: &WorkspacePath,
834                _content: &str,
835            ) -> Result<WorkspaceWriteOutcome> {
836                bail!("not implemented")
837            }
838
839            async fn list_dir(&self, _path: &WorkspacePath) -> Result<Vec<WorkspaceDirEntry>> {
840                bail!("not implemented")
841            }
842        }
843
844        let fs_backend: Arc<dyn WorkspaceFileSystem> = Arc::new(EmptyFs);
845        let services = WorkspaceServices::builder(
846            WorkspaceRef::new("virtual", "virtual://workspace"),
847            fs_backend,
848        )
849        .capabilities(WorkspaceCapabilities {
850            exec: true,
851            ..WorkspaceCapabilities::read_write()
852        })
853        .build();
854
855        assert!(!services.capabilities().exec);
856        assert!(services.command_runner().is_none());
857    }
858}