Skip to main content

a3s_code_core/workspace/
services.rs

1//! Workspace service aggregation and builder.
2
3#[allow(unused_imports)]
4use super::{CommandRequest, WorkspaceError, WorkspaceVersionConflict};
5use super::{
6    LocalWorkspaceBackend, ManifestWorkspaceBackend, VirtualPathResolver, WorkspaceCapabilities,
7    WorkspaceChunkCatalog, WorkspaceCommandRunner, WorkspaceFileSystem, WorkspaceFileSystemExt,
8    WorkspaceGit, WorkspaceGitStashProvider, WorkspaceGitWorktreeProvider, WorkspacePath,
9    WorkspacePathResolver, WorkspacePersistentIndex, WorkspaceRef, WorkspaceResult,
10    WorkspaceRetrievalRuntime, WorkspaceSearch, WorkspaceTextReader, WorkspaceWriteOutcome,
11};
12use crate::code_intelligence::{LocalCodeIntelligence, WorkspaceCodeIntelligence};
13use anyhow::{anyhow, Result};
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16
17/// The host-provided workspace capability bundle used by tool execution.
18pub struct WorkspaceServices {
19    workspace_ref: WorkspaceRef,
20    capabilities: WorkspaceCapabilities,
21    path_resolver: Arc<dyn WorkspacePathResolver>,
22    file_system: Arc<dyn WorkspaceFileSystem>,
23    file_system_ext: Option<Arc<dyn WorkspaceFileSystemExt>>,
24    text_reader: Option<Arc<dyn WorkspaceTextReader>>,
25    command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
26    search: Option<Arc<dyn WorkspaceSearch>>,
27    code_intelligence: Option<Arc<dyn WorkspaceCodeIntelligence>>,
28    chunk_catalog: Option<Arc<WorkspaceChunkCatalog>>,
29    persistent_index: Option<Arc<WorkspacePersistentIndex>>,
30    workspace_retrieval: Option<Arc<WorkspaceRetrievalRuntime>>,
31    git: Option<Arc<dyn WorkspaceGit>>,
32    git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
33    git_worktree: Option<Arc<dyn WorkspaceGitWorktreeProvider>>,
34    /// Default timeout applied to non-bash workspace operations (file system,
35    /// search, git). Bash uses its own per-call timeout in [`CommandRequest`].
36    /// `None` means no enforced timeout — appropriate for the local backend.
37    operation_timeout: Option<std::time::Duration>,
38    local_root: Option<PathBuf>,
39}
40
41impl std::fmt::Debug for WorkspaceServices {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        f.debug_struct("WorkspaceServices")
44            .field("workspace_ref", &self.workspace_ref)
45            .field("capabilities", &self.capabilities)
46            .field("file_system_ext", &self.file_system_ext.is_some())
47            .field("text_reader", &self.text_reader.is_some())
48            .field("command_runner", &self.command_runner.is_some())
49            .field("search", &self.search.is_some())
50            .field("code_intelligence", &self.code_intelligence.is_some())
51            .field("chunk_catalog", &self.chunk_catalog.is_some())
52            .field("persistent_index", &self.persistent_index.is_some())
53            .field("workspace_retrieval", &self.workspace_retrieval.is_some())
54            .field("git", &self.git.is_some())
55            .field("git_stash", &self.git_stash.is_some())
56            .field("git_worktree", &self.git_worktree.is_some())
57            .field("local_root", &self.local_root)
58            .finish()
59    }
60}
61
62impl WorkspaceServices {
63    pub(crate) fn new_with_git(
64        workspace_ref: WorkspaceRef,
65        mut capabilities: WorkspaceCapabilities,
66        path_resolver: Arc<dyn WorkspacePathResolver>,
67        file_system: Arc<dyn WorkspaceFileSystem>,
68        command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
69        search: Option<Arc<dyn WorkspaceSearch>>,
70        git: Option<Arc<dyn WorkspaceGit>>,
71    ) -> Self {
72        if command_runner.is_none() {
73            capabilities.exec = false;
74        }
75        if search.is_none() {
76            capabilities.search = false;
77        }
78        if git.is_none() {
79            capabilities.git = false;
80        }
81        capabilities.code_intelligence = false;
82        Self {
83            workspace_ref,
84            capabilities,
85            path_resolver,
86            file_system,
87            file_system_ext: None,
88            text_reader: None,
89            command_runner,
90            search,
91            code_intelligence: None,
92            chunk_catalog: None,
93            persistent_index: None,
94            workspace_retrieval: None,
95            git,
96            git_stash: None,
97            git_worktree: None,
98            operation_timeout: None,
99            local_root: None,
100        }
101    }
102
103    pub fn builder(
104        workspace_ref: WorkspaceRef,
105        file_system: Arc<dyn WorkspaceFileSystem>,
106    ) -> WorkspaceServicesBuilder {
107        WorkspaceServicesBuilder::new(workspace_ref, file_system)
108    }
109
110    pub fn local(root: impl Into<PathBuf>) -> Arc<Self> {
111        let backend = Arc::new(LocalWorkspaceBackend::new(root.into()));
112        let workspace_ref = WorkspaceRef::new(
113            backend.root.display().to_string(),
114            backend.root.display().to_string(),
115        );
116        let path_resolver: Arc<dyn WorkspacePathResolver> = backend.clone();
117        let file_system: Arc<dyn WorkspaceFileSystem> = backend.clone();
118        let text_reader: Arc<dyn WorkspaceTextReader> = backend.clone();
119        let command_runner: Arc<dyn WorkspaceCommandRunner> = backend.clone();
120        let search: Arc<dyn WorkspaceSearch> = backend.clone();
121        let git: Arc<dyn WorkspaceGit> = backend.clone();
122        let git_stash: Arc<dyn WorkspaceGitStashProvider> = backend.clone();
123        let git_worktree: Arc<dyn WorkspaceGitWorktreeProvider> = backend.clone();
124        Arc::new(Self {
125            workspace_ref,
126            capabilities: WorkspaceCapabilities::local_default(),
127            path_resolver,
128            file_system,
129            file_system_ext: None,
130            text_reader: Some(text_reader),
131            command_runner: Some(command_runner),
132            search: Some(search),
133            code_intelligence: None,
134            chunk_catalog: None,
135            persistent_index: None,
136            workspace_retrieval: None,
137            git: Some(git),
138            git_stash: Some(git_stash),
139            git_worktree: Some(git_worktree),
140            operation_timeout: None,
141            local_root: Some(backend.root.clone()),
142        })
143    }
144
145    /// Local workspace services backed by an in-memory file manifest for
146    /// search. `read`/`write`/`ls`/`bash`/`git` preserve local backend
147    /// behavior; `glob` and `grep` use the manifest once the initial scan has
148    /// completed and fall back to filesystem search before that.
149    pub fn local_with_manifest(root: impl Into<PathBuf>) -> Arc<Self> {
150        let backend = ManifestWorkspaceBackend::new(root);
151        Self::local_with_manifest_backend(backend)
152    }
153
154    /// Build local manifest services with an asynchronous catalog and the
155    /// best-effort workspace-owned persistent zvec projection.
156    pub fn local_with_retrieval(root: impl Into<PathBuf>) -> Arc<Self> {
157        let backend = ManifestWorkspaceBackend::new(root);
158        Self::local_with_retrieval_backend(backend)
159    }
160
161    /// Build local manifest-backed services with native Code Intelligence.
162    ///
163    /// The provider subscribes to the manifest's existing change stream and
164    /// therefore does not create a second filesystem watcher or file index.
165    pub async fn local_with_code_intelligence(
166        root: impl Into<PathBuf>,
167        isolation_scope: impl Into<String>,
168    ) -> Result<Arc<Self>> {
169        let backend = ManifestWorkspaceBackend::new(root);
170        Self::local_with_code_intelligence_backend(backend, isolation_scope).await
171    }
172
173    /// Build one local session with retrieval and Code Intelligence sharing
174    /// the same manifest scanner and filesystem change stream.
175    pub async fn local_with_retrieval_and_code_intelligence(
176        root: impl Into<PathBuf>,
177        isolation_scope: impl Into<String>,
178    ) -> Result<Arc<Self>> {
179        let backend = ManifestWorkspaceBackend::new(root);
180        Self::local_with_retrieval_and_code_intelligence_backend(backend, isolation_scope).await
181    }
182
183    /// Attach native Code Intelligence to one shared manifest backend.
184    pub async fn local_with_code_intelligence_backend(
185        backend: Arc<ManifestWorkspaceBackend>,
186        isolation_scope: impl Into<String>,
187    ) -> Result<Arc<Self>> {
188        let manifest = backend.manifest();
189        let file_system: Arc<dyn WorkspaceFileSystem> = backend.clone();
190        let services = Self::local_with_manifest_backend(backend);
191        let provider = LocalCodeIntelligence::start(isolation_scope, manifest, file_system)
192            .await
193            .map_err(|error| anyhow!("failed to start Code Intelligence: {error}"))?;
194        Ok(services.with_code_intelligence(provider))
195    }
196
197    /// Attach retrieval and Code Intelligence to one shared manifest backend.
198    pub async fn local_with_retrieval_and_code_intelligence_backend(
199        backend: Arc<ManifestWorkspaceBackend>,
200        isolation_scope: impl Into<String>,
201    ) -> Result<Arc<Self>> {
202        let manifest = backend.manifest();
203        let file_system: Arc<dyn WorkspaceFileSystem> = backend.clone();
204        let services = Self::local_with_retrieval_backend(backend);
205        let provider = LocalCodeIntelligence::start(isolation_scope, manifest, file_system)
206            .await
207            .map_err(|error| anyhow!("failed to start Code Intelligence: {error}"))?;
208        Ok(services.with_code_intelligence(provider))
209    }
210
211    /// Build local workspace services from a shared manifest backend. Hosts
212    /// can keep the same manifest for UI file pickers and agent tools.
213    pub fn local_with_manifest_backend(backend: Arc<ManifestWorkspaceBackend>) -> Arc<Self> {
214        Self::local_with_manifest_backend_and_catalog(backend, None, None)
215    }
216
217    /// Enable retrieval on a shared manifest backend.
218    pub fn local_with_retrieval_backend(backend: Arc<ManifestWorkspaceBackend>) -> Arc<Self> {
219        let catalog = backend.chunk_catalog();
220        let persistent = backend.persistent_index();
221        Self::local_with_manifest_backend_and_catalog(backend, Some(catalog), persistent)
222    }
223
224    /// Build local manifest-backed services with the workspace-owned persistent
225    /// zvec FTS projection. This compatibility constructor is equivalent to
226    /// the default local retrieval path when the native feature is available.
227    pub fn local_with_indexed_retrieval(root: impl Into<PathBuf>) -> Result<Arc<Self>> {
228        let backend = ManifestWorkspaceBackend::new(root);
229        let index_root = backend.local_root().join(".a3s-code").join("index");
230        let persistent = backend
231            .configure_persistent_index(index_root)
232            .map_err(|error| anyhow!("failed to configure persistent workspace index: {error}"))?;
233        let catalog = backend.chunk_catalog();
234        Ok(Self::local_with_manifest_backend_and_catalog(
235            backend,
236            Some(catalog),
237            Some(persistent),
238        ))
239    }
240
241    fn local_with_manifest_backend_and_catalog(
242        backend: Arc<ManifestWorkspaceBackend>,
243        chunk_catalog: Option<Arc<WorkspaceChunkCatalog>>,
244        persistent_index: Option<Arc<WorkspacePersistentIndex>>,
245    ) -> Arc<Self> {
246        let workspace_ref = WorkspaceRef::new(
247            backend.local_root().display().to_string(),
248            backend.local_root().display().to_string(),
249        );
250        let path_resolver: Arc<dyn WorkspacePathResolver> = backend.clone();
251        let file_system: Arc<dyn WorkspaceFileSystem> = backend.clone();
252        let text_reader: Arc<dyn WorkspaceTextReader> = backend.clone();
253        let command_runner: Arc<dyn WorkspaceCommandRunner> = backend.clone();
254        let search: Arc<dyn WorkspaceSearch> = backend.clone();
255        let git: Arc<dyn WorkspaceGit> = backend.clone();
256        let git_stash: Arc<dyn WorkspaceGitStashProvider> = backend.clone();
257        let git_worktree: Arc<dyn WorkspaceGitWorktreeProvider> = backend.clone();
258        Arc::new(Self {
259            workspace_ref,
260            capabilities: WorkspaceCapabilities::local_default(),
261            path_resolver,
262            file_system,
263            file_system_ext: None,
264            text_reader: Some(text_reader),
265            command_runner: Some(command_runner),
266            search: Some(search),
267            code_intelligence: None,
268            chunk_catalog,
269            persistent_index,
270            workspace_retrieval: None,
271            git: Some(git),
272            git_stash: Some(git_stash),
273            git_worktree: Some(git_worktree),
274            operation_timeout: None,
275            local_root: Some(backend.local_root().to_path_buf()),
276        })
277    }
278
279    pub fn workspace_ref(&self) -> &WorkspaceRef {
280        &self.workspace_ref
281    }
282
283    pub fn capabilities(&self) -> WorkspaceCapabilities {
284        self.capabilities
285    }
286
287    pub fn normalize_path(&self, input: &str) -> Result<WorkspacePath> {
288        self.path_resolver.normalize(input)
289    }
290
291    pub fn fs(&self) -> Arc<dyn WorkspaceFileSystem> {
292        Arc::clone(&self.file_system)
293    }
294
295    /// Optional compare-and-swap file system extensions.
296    ///
297    /// Returns `Some` when the backend supports version-aware writes (e.g.
298    /// S3 via ETag). Tools that perform read-modify-write cycles should
299    /// route through [`Self::read_for_edit`] and [`Self::write_for_edit`]
300    /// rather than touching this directly.
301    pub fn fs_ext(&self) -> Option<Arc<dyn WorkspaceFileSystemExt>> {
302        self.file_system_ext.clone()
303    }
304
305    pub fn text_reader(&self) -> Option<Arc<dyn WorkspaceTextReader>> {
306        self.text_reader.clone()
307    }
308
309    pub fn command_runner(&self) -> Option<Arc<dyn WorkspaceCommandRunner>> {
310        self.command_runner.clone()
311    }
312
313    pub fn search(&self) -> Option<Arc<dyn WorkspaceSearch>> {
314        self.search.clone()
315    }
316
317    /// Optional workspace-scoped semantic code query provider.
318    pub fn code_intelligence(&self) -> Option<Arc<dyn WorkspaceCodeIntelligence>> {
319        self.code_intelligence.clone()
320    }
321
322    /// Optional session-local catalog shared by lexical and semantic retrieval.
323    pub fn chunk_catalog(&self) -> Option<Arc<WorkspaceChunkCatalog>> {
324        self.chunk_catalog.clone()
325    }
326
327    pub fn persistent_index(&self) -> Option<Arc<WorkspacePersistentIndex>> {
328        self.persistent_index.clone()
329    }
330
331    /// Optional semantic retrieval runtime bound to this workspace session.
332    pub fn workspace_retrieval(&self) -> Option<Arc<WorkspaceRetrievalRuntime>> {
333        self.workspace_retrieval.clone()
334    }
335
336    /// Search the session-owned semantic index and verify every returned
337    /// source chunk against the workspace backend before exposing its text.
338    pub async fn semantic_search(
339        &self,
340        mut request: super::WorkspaceSemanticSearchRequest,
341        cancellation: tokio_util::sync::CancellationToken,
342    ) -> super::WorkspaceRetrievalResult<super::WorkspaceSemanticSearchResult> {
343        if !self.capabilities.read {
344            return Err(super::WorkspaceRetrievalError::Unavailable);
345        }
346        if let Some(path) = request.path.take() {
347            request.path = Some(
348                self.path_resolver
349                    .normalize(&path)
350                    .map_err(|_| {
351                        super::WorkspaceRetrievalError::InvalidQuery(
352                            "path was rejected by the workspace resolver".to_owned(),
353                        )
354                    })?
355                    .as_str()
356                    .to_owned(),
357            );
358        }
359        let runtime = self
360            .workspace_retrieval
361            .as_ref()
362            .ok_or(super::WorkspaceRetrievalError::Unavailable)?;
363        runtime
364            .search(
365                request,
366                Arc::clone(&self.file_system),
367                self.operation_timeout,
368                cancellation,
369            )
370            .await
371    }
372
373    /// Run exact, lexical, structural, and semantic retrieval against one
374    /// catalog revision, fuse channel ranks, and verify returned source text.
375    pub async fn hybrid_search(
376        &self,
377        mut request: super::WorkspaceHybridSearchRequest,
378        cancellation: tokio_util::sync::CancellationToken,
379    ) -> super::WorkspaceRetrievalResult<super::WorkspaceHybridSearchResult> {
380        if !self.capabilities.read {
381            return Err(super::WorkspaceRetrievalError::Unavailable);
382        }
383        if let Some(path) = request.path.take() {
384            request.path = Some(
385                self.path_resolver
386                    .normalize(&path)
387                    .map_err(|_| {
388                        super::WorkspaceRetrievalError::InvalidQuery(
389                            "path was rejected by the workspace resolver".to_owned(),
390                        )
391                    })?
392                    .as_str()
393                    .to_owned(),
394            );
395        }
396        let runtime = self
397            .workspace_retrieval
398            .as_ref()
399            .ok_or(super::WorkspaceRetrievalError::Unavailable)?;
400        runtime
401            .hybrid_search(
402                request,
403                Arc::clone(&self.file_system),
404                self.code_intelligence.clone(),
405                self.operation_timeout,
406                cancellation,
407            )
408            .await
409    }
410
411    pub(crate) fn with_workspace_retrieval(
412        &self,
413        runtime: Arc<WorkspaceRetrievalRuntime>,
414    ) -> Option<Arc<Self>> {
415        if self.workspace_retrieval.is_some() {
416            return None;
417        }
418        Some(Arc::new(Self {
419            workspace_ref: self.workspace_ref.clone(),
420            capabilities: self.capabilities,
421            path_resolver: Arc::clone(&self.path_resolver),
422            file_system: Arc::clone(&self.file_system),
423            file_system_ext: self.file_system_ext.clone(),
424            text_reader: self.text_reader.clone(),
425            command_runner: self.command_runner.clone(),
426            search: self.search.clone(),
427            code_intelligence: self.code_intelligence.clone(),
428            chunk_catalog: self.chunk_catalog.clone(),
429            persistent_index: self.persistent_index.clone(),
430            workspace_retrieval: Some(runtime),
431            git: self.git.clone(),
432            git_stash: self.git_stash.clone(),
433            git_worktree: self.git_worktree.clone(),
434            operation_timeout: self.operation_timeout,
435            local_root: self.local_root.clone(),
436        }))
437    }
438
439    /// Attach a semantic code query provider while preserving every existing
440    /// workspace capability and backend.
441    pub fn with_code_intelligence(
442        &self,
443        provider: Arc<dyn WorkspaceCodeIntelligence>,
444    ) -> Arc<Self> {
445        let mut capabilities = self.capabilities;
446        capabilities.code_intelligence = true;
447        Arc::new(Self {
448            workspace_ref: self.workspace_ref.clone(),
449            capabilities,
450            path_resolver: Arc::clone(&self.path_resolver),
451            file_system: Arc::clone(&self.file_system),
452            file_system_ext: self.file_system_ext.clone(),
453            text_reader: self.text_reader.clone(),
454            command_runner: self.command_runner.clone(),
455            search: self.search.clone(),
456            code_intelligence: Some(provider),
457            chunk_catalog: self.chunk_catalog.clone(),
458            persistent_index: self.persistent_index.clone(),
459            workspace_retrieval: self.workspace_retrieval.clone(),
460            git: self.git.clone(),
461            git_stash: self.git_stash.clone(),
462            git_worktree: self.git_worktree.clone(),
463            operation_timeout: self.operation_timeout,
464            local_root: self.local_root.clone(),
465        })
466    }
467
468    pub fn git(&self) -> Option<Arc<dyn WorkspaceGit>> {
469        self.git.clone()
470    }
471
472    pub fn git_stash(&self) -> Option<Arc<dyn WorkspaceGitStashProvider>> {
473        self.git_stash.clone()
474    }
475
476    pub fn git_worktree(&self) -> Option<Arc<dyn WorkspaceGitWorktreeProvider>> {
477        self.git_worktree.clone()
478    }
479
480    /// Internal helper used by decorators (`with_remote_git` and any
481    /// future git-provider override) to swap the git layer of an existing
482    /// `WorkspaceServices` without losing unrelated fields.
483    ///
484    /// Every field is **explicitly listed** in the returned struct
485    /// literal. This is the point of the helper — adding a new field to
486    /// `WorkspaceServices` will trip a compile error here, and the author
487    /// of that new field has to decide whether a git-provider swap
488    /// preserves it. Previously the decorator went through
489    /// `WorkspaceServicesBuilder`, which silently dropped any field the
490    /// builder did not know about (notably `local_root`).
491    ///
492    /// `git_worktree` is reset to `None` because worktree operations are
493    /// part of the same domain as the git provider — keeping the local
494    /// worktree provider while routing `status`/`log`/`diff` to a remote
495    /// server would surface inconsistent state to the model.
496    pub(crate) fn with_git_provider(
497        &self,
498        git: Arc<dyn WorkspaceGit>,
499        git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
500    ) -> Arc<Self> {
501        let mut capabilities = self.capabilities;
502        capabilities.git = true;
503        Arc::new(Self {
504            workspace_ref: self.workspace_ref.clone(),
505            capabilities,
506            path_resolver: Arc::clone(&self.path_resolver),
507            file_system: Arc::clone(&self.file_system),
508            file_system_ext: self.file_system_ext.clone(),
509            text_reader: self.text_reader.clone(),
510            command_runner: self.command_runner.clone(),
511            search: self.search.clone(),
512            code_intelligence: self.code_intelligence.clone(),
513            chunk_catalog: self.chunk_catalog.clone(),
514            persistent_index: self.persistent_index.clone(),
515            workspace_retrieval: self.workspace_retrieval.clone(),
516            git: Some(git),
517            git_stash,
518            git_worktree: None,
519            operation_timeout: self.operation_timeout,
520            local_root: self.local_root.clone(),
521        })
522    }
523
524    /// Default timeout applied to non-bash workspace operations.
525    ///
526    /// `None` means no enforced timeout. Backends that may stall (remote,
527    /// browser, DFS) should set this so tools using [`Self::run_with_timeout`]
528    /// surface a timeout error instead of letting the agent loop hang.
529    pub fn operation_timeout(&self) -> Option<std::time::Duration> {
530        self.operation_timeout
531    }
532
533    /// Run a workspace future under the configured operation timeout.
534    ///
535    /// Tools that route through file system / search / git providers should
536    /// wrap their calls with this helper so non-local backends never stall
537    /// the agent loop indefinitely.
538    ///
539    /// Polymorphic in the error type so the helper works equally well for
540    /// futures returning `anyhow::Result<T>` (the legacy callers — search,
541    /// git, etc.) and for futures returning [`WorkspaceResult<T>`] (the
542    /// migrated `WorkspaceFileSystem` callers). The `E: From<anyhow::Error>`
543    /// bound is satisfied by both `anyhow::Error` (trivially) and
544    /// [`WorkspaceError`] (via its `#[from]` `Backend` variant); a timeout
545    /// surfaces as that From conversion of an `anyhow!(...)` message.
546    pub async fn run_with_timeout<F, T, E>(
547        &self,
548        op: &'static str,
549        fut: F,
550    ) -> std::result::Result<T, E>
551    where
552        F: std::future::Future<Output = std::result::Result<T, E>>,
553        E: From<anyhow::Error>,
554    {
555        match self.operation_timeout {
556            Some(d) => tokio::time::timeout(d, fut).await.map_err(|_| {
557                E::from(anyhow!(
558                    "workspace operation '{}' timed out after {:?}",
559                    op,
560                    d
561                ))
562            })?,
563            None => fut.await,
564        }
565    }
566
567    /// Read a file for a subsequent modify-write cycle, requesting a version
568    /// token when the backend supports compare-and-swap writes.
569    ///
570    /// Returns `(content, Some(version))` when [`Self::fs_ext`] is available
571    /// (e.g. on S3, where the version is the object ETag); `(content, None)`
572    /// otherwise. Pair with [`Self::write_for_edit`].
573    pub async fn read_for_edit(
574        &self,
575        path: &WorkspacePath,
576    ) -> WorkspaceResult<(String, Option<String>)> {
577        if let Some(ext) = self.fs_ext() {
578            let path = path.clone();
579            return self
580                .run_with_timeout("read_text_with_version", async move {
581                    let (content, version) = ext.read_text_with_version(&path).await?;
582                    Ok((content, Some(version)))
583                })
584                .await;
585        }
586        let fs = self.fs();
587        let path_owned = path.clone();
588        let content = self
589            .run_with_timeout("read_text", async move { fs.read_text(&path_owned).await })
590            .await?;
591        Ok((content, None))
592    }
593
594    /// Companion to [`Self::read_for_edit`]. Performs a compare-and-swap
595    /// write when both [`Self::fs_ext`] is available *and* a version token
596    /// was returned by the prior read; falls back to a plain write
597    /// otherwise. On version mismatch the returned error is the typed
598    /// [`WorkspaceError::VersionConflict`] variant; callers can also still
599    /// downcast `anyhow::Error::downcast_ref::<WorkspaceVersionConflict>()`
600    /// when the value has been lifted into an `anyhow::Result`.
601    pub async fn write_for_edit(
602        &self,
603        path: &WorkspacePath,
604        content: &str,
605        expected_version: Option<&str>,
606    ) -> WorkspaceResult<WorkspaceWriteOutcome> {
607        if let (Some(ext), Some(version)) = (self.fs_ext(), expected_version) {
608            let path = path.clone();
609            let content = content.to_string();
610            let expected = version.to_string();
611            return self
612                .run_with_timeout("write_text_if_version", async move {
613                    ext.write_text_if_version(&path, &content, &expected).await
614                })
615                .await;
616        }
617        let fs = self.fs();
618        let path = path.clone();
619        let content = content.to_string();
620        self.run_with_timeout(
621            "write_text",
622            async move { fs.write_text(&path, &content).await },
623        )
624        .await
625    }
626
627    pub fn local_root(&self) -> Option<&Path> {
628        self.local_root.as_deref()
629    }
630
631    pub fn display_path(&self, path: &WorkspacePath) -> String {
632        if path.is_root() {
633            return self.workspace_ref.display_root.clone();
634        }
635
636        let root = self.workspace_ref.display_root.trim_end_matches('/');
637        if root.is_empty() {
638            path.as_str().to_string()
639        } else {
640            format!("{root}/{}", path.as_str())
641        }
642    }
643}
644
645/// Builder for assembling workspace services without constructor arity churn.
646pub struct WorkspaceServicesBuilder {
647    workspace_ref: WorkspaceRef,
648    capabilities: WorkspaceCapabilities,
649    path_resolver: Arc<dyn WorkspacePathResolver>,
650    file_system: Arc<dyn WorkspaceFileSystem>,
651    file_system_ext: Option<Arc<dyn WorkspaceFileSystemExt>>,
652    text_reader: Option<Arc<dyn WorkspaceTextReader>>,
653    command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
654    search: Option<Arc<dyn WorkspaceSearch>>,
655    code_intelligence: Option<Arc<dyn WorkspaceCodeIntelligence>>,
656    chunk_catalog: Option<Arc<WorkspaceChunkCatalog>>,
657    persistent_index: Option<Arc<WorkspacePersistentIndex>>,
658    git: Option<Arc<dyn WorkspaceGit>>,
659    git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
660    git_worktree: Option<Arc<dyn WorkspaceGitWorktreeProvider>>,
661    operation_timeout: Option<std::time::Duration>,
662}
663
664impl WorkspaceServicesBuilder {
665    pub fn new(workspace_ref: WorkspaceRef, file_system: Arc<dyn WorkspaceFileSystem>) -> Self {
666        Self {
667            workspace_ref,
668            capabilities: WorkspaceCapabilities::read_write(),
669            path_resolver: Arc::new(VirtualPathResolver),
670            file_system,
671            file_system_ext: None,
672            text_reader: None,
673            command_runner: None,
674            search: None,
675            code_intelligence: None,
676            chunk_catalog: None,
677            persistent_index: None,
678            git: None,
679            git_stash: None,
680            git_worktree: None,
681            operation_timeout: None,
682        }
683    }
684
685    pub fn capabilities(mut self, capabilities: WorkspaceCapabilities) -> Self {
686        self.capabilities = capabilities;
687        self
688    }
689
690    pub fn command_runner(mut self, command_runner: Arc<dyn WorkspaceCommandRunner>) -> Self {
691        self.capabilities.exec = true;
692        self.command_runner = Some(command_runner);
693        self
694    }
695
696    pub fn search(mut self, search: Arc<dyn WorkspaceSearch>) -> Self {
697        self.capabilities.search = true;
698        self.search = Some(search);
699        self
700    }
701
702    pub fn code_intelligence(mut self, provider: Arc<dyn WorkspaceCodeIntelligence>) -> Self {
703        self.capabilities.code_intelligence = true;
704        self.code_intelligence = Some(provider);
705        self
706    }
707
708    /// Attach a typed, session-local workspace retrieval catalog.
709    pub fn chunk_catalog(mut self, catalog: Arc<WorkspaceChunkCatalog>) -> Self {
710        self.chunk_catalog = Some(catalog);
711        self
712    }
713
714    pub fn persistent_index(mut self, index: Arc<WorkspacePersistentIndex>) -> Self {
715        self.persistent_index = Some(index);
716        self
717    }
718
719    pub fn git(mut self, git: Arc<dyn WorkspaceGit>) -> Self {
720        self.capabilities.git = true;
721        self.git = Some(git);
722        self
723    }
724
725    pub fn git_stash(mut self, git_stash: Arc<dyn WorkspaceGitStashProvider>) -> Self {
726        self.git_stash = Some(git_stash);
727        self
728    }
729
730    pub fn git_worktree(mut self, git_worktree: Arc<dyn WorkspaceGitWorktreeProvider>) -> Self {
731        self.git_worktree = Some(git_worktree);
732        self
733    }
734
735    /// Attach optional compare-and-swap file system extensions
736    /// ([`WorkspaceFileSystemExt`]). Tools that perform read-modify-write
737    /// cycles will pick this up via [`WorkspaceServices::read_for_edit`]
738    /// and [`WorkspaceServices::write_for_edit`].
739    pub fn file_system_ext(mut self, ext: Arc<dyn WorkspaceFileSystemExt>) -> Self {
740        self.file_system_ext = Some(ext);
741        self
742    }
743
744    pub fn text_reader(mut self, reader: Arc<dyn WorkspaceTextReader>) -> Self {
745        self.text_reader = Some(reader);
746        self
747    }
748
749    /// Apply a default timeout to non-bash workspace operations (file system,
750    /// search, git). Backends that may stall — remote, browser, DFS — should
751    /// set this so tools surface a timeout error rather than hanging.
752    pub fn operation_timeout(mut self, timeout: std::time::Duration) -> Self {
753        self.operation_timeout = Some(timeout);
754        self
755    }
756
757    pub fn build(self) -> Arc<WorkspaceServices> {
758        let mut services = WorkspaceServices::new_with_git(
759            self.workspace_ref,
760            self.capabilities,
761            self.path_resolver,
762            self.file_system,
763            self.command_runner,
764            self.search,
765            self.git,
766        );
767        services.file_system_ext = self.file_system_ext;
768        services.text_reader = self.text_reader;
769        services.capabilities.code_intelligence = self.code_intelligence.is_some();
770        services.code_intelligence = self.code_intelligence;
771        services.chunk_catalog = self.chunk_catalog;
772        services.persistent_index = self.persistent_index;
773        services.workspace_retrieval = None;
774        services.git_stash = self.git_stash;
775        services.git_worktree = self.git_worktree;
776        services.operation_timeout = self.operation_timeout;
777        Arc::new(services)
778    }
779}