Skip to main content

a3s_code_core/workspace/
local.rs

1//! Local filesystem-backed workspace implementation.
2//!
3//! [`LocalWorkspaceBackend`] preserves the historical "agent runs on the host
4//! filesystem" behavior. It implements every workspace capability trait so
5//! local sessions get the full tool surface (read, write, edit, patch, ls,
6//! bash, grep, glob, git, git_stash, git_worktree).
7
8use super::{
9    default_path_input, escape_control_chars_for_display, has_windows_path_prefix,
10    normalize_relative_path, pathbuf_to_workspace_path, validate_relative_pattern, CommandOutput,
11    CommandRequest, WorkspaceCommandRunner, WorkspaceDirEntry, WorkspaceError, WorkspaceFileSystem,
12    WorkspaceFileType, WorkspaceGit, WorkspaceGitBranch, WorkspaceGitCheckoutOutput,
13    WorkspaceGitCheckoutRequest, WorkspaceGitCommit, WorkspaceGitCreateBranchRequest,
14    WorkspaceGitCreateWorktreeRequest, WorkspaceGitDiffRequest, WorkspaceGitRemote,
15    WorkspaceGitRemoveWorktreeRequest, WorkspaceGitStash, WorkspaceGitStashProvider,
16    WorkspaceGitStashRequest, WorkspaceGitStatus, WorkspaceGitWorktree,
17    WorkspaceGitWorktreeMutation, WorkspaceGitWorktreeProvider, WorkspaceGlobRequest,
18    WorkspaceGlobResult, WorkspaceGrepOutcome, WorkspaceGrepRequest, WorkspaceGrepResult,
19    WorkspacePath, WorkspacePathResolver, WorkspaceResult, WorkspaceSearch, WorkspaceTextRange,
20    WorkspaceTextReader, WorkspaceWriteOutcome,
21};
22use anyhow::{anyhow, bail, Result};
23use async_trait::async_trait;
24use std::path::{Component, Path, PathBuf};
25use tokio::io::{AsyncBufReadExt, BufReader};
26
27/// Local filesystem-backed workspace implementation.
28#[derive(Debug)]
29pub struct LocalWorkspaceBackend {
30    pub(super) root: PathBuf,
31}
32
33impl LocalWorkspaceBackend {
34    pub fn new(root: PathBuf) -> Self {
35        let canonical = root.canonicalize();
36        let root = match canonical {
37            Ok(canonical) => canonical,
38            Err(e) => {
39                tracing::warn!(
40                    "LocalWorkspaceBackend: failed to canonicalize root '{}' at construction: {} \
41                     (path resolution will fail-closed at first use)",
42                    root.display(),
43                    e
44                );
45                root
46            }
47        };
48        Self { root }
49    }
50
51    fn local_path_for_read(&self, path: &WorkspacePath) -> Result<PathBuf> {
52        a3s_common::tools::resolve_path(&self.root, path.as_str()).map_err(|e| anyhow!("{}", e))
53    }
54
55    fn local_path_for_write(&self, path: &WorkspacePath) -> Result<PathBuf> {
56        let target = if path.is_root() {
57            self.root.clone()
58        } else {
59            self.root.join(path.as_str())
60        };
61
62        if let Some(parent) = target.parent() {
63            std::fs::create_dir_all(parent).map_err(|e| {
64                anyhow!(
65                    "Failed to create parent directories for {}: {}",
66                    target.display(),
67                    e
68                )
69            })?;
70        }
71
72        a3s_common::tools::resolve_path_for_write(&self.root, path.as_str())
73            .map_err(|e| anyhow!("{}", e))
74    }
75}
76
77impl WorkspacePathResolver for LocalWorkspaceBackend {
78    fn normalize(&self, input: &str) -> Result<WorkspacePath> {
79        normalize_local_path(&self.root, input)
80    }
81}
82
83#[async_trait]
84impl WorkspaceFileSystem for LocalWorkspaceBackend {
85    async fn read_text(&self, path: &WorkspacePath) -> WorkspaceResult<String> {
86        let resolved = self.local_path_for_read(path)?;
87        match tokio::fs::read_to_string(&resolved).await {
88            Ok(s) => Ok(s),
89            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(WorkspaceError::NotFound {
90                path: resolved.display().to_string(),
91            }),
92            Err(e) => Err(WorkspaceError::Backend(anyhow!(
93                "Failed to read file {}: {}",
94                resolved.display(),
95                e
96            ))),
97        }
98    }
99
100    async fn write_text(
101        &self,
102        path: &WorkspacePath,
103        content: &str,
104    ) -> WorkspaceResult<WorkspaceWriteOutcome> {
105        let resolved = self.local_path_for_write(path)?;
106        tokio::fs::write(&resolved, content).await.map_err(|e| {
107            WorkspaceError::Backend(anyhow!(
108                "Failed to write file {}: {}",
109                resolved.display(),
110                e
111            ))
112        })?;
113
114        Ok(WorkspaceWriteOutcome {
115            bytes: content.len(),
116            lines: content.lines().count(),
117        })
118    }
119
120    async fn list_dir(&self, path: &WorkspacePath) -> WorkspaceResult<Vec<WorkspaceDirEntry>> {
121        let target = self.local_path_for_read(path)?;
122        if !target.exists() {
123            return Err(WorkspaceError::NotFound {
124                path: target.display().to_string(),
125            });
126        }
127        if !target.is_dir() {
128            return Err(WorkspaceError::InvalidArgument {
129                message: format!("Not a directory: {}", target.display()),
130            });
131        }
132
133        let mut dir = tokio::fs::read_dir(&target).await.map_err(|e| {
134            WorkspaceError::Backend(anyhow!(
135                "Failed to read directory {}: {}",
136                target.display(),
137                e
138            ))
139        })?;
140        let mut entries = Vec::new();
141
142        while let Some(entry) = dir
143            .next_entry()
144            .await
145            .map_err(|e| WorkspaceError::Backend(anyhow!("Failed to iterate directory: {}", e)))?
146        {
147            let name = entry.file_name().to_string_lossy().to_string();
148            let file_type = entry.file_type().await;
149            let metadata = entry.metadata().await;
150            let (kind, size) = match (&file_type, &metadata) {
151                (Ok(ft), Ok(m)) => {
152                    let kind = if ft.is_dir() {
153                        WorkspaceFileType::Directory
154                    } else if ft.is_symlink() {
155                        WorkspaceFileType::Symlink
156                    } else {
157                        WorkspaceFileType::File
158                    };
159                    (kind, m.len())
160                }
161                _ => (WorkspaceFileType::Unknown, 0),
162            };
163            entries.push(WorkspaceDirEntry { name, kind, size });
164        }
165
166        Ok(entries)
167    }
168}
169
170#[async_trait]
171impl WorkspaceTextReader for LocalWorkspaceBackend {
172    async fn read_text_range(
173        &self,
174        path: &WorkspacePath,
175        offset: usize,
176        limit: usize,
177    ) -> WorkspaceResult<WorkspaceTextRange> {
178        let resolved = self.local_path_for_read(path)?;
179        let file = tokio::fs::File::open(&resolved).await.map_err(|error| {
180            if error.kind() == std::io::ErrorKind::NotFound {
181                WorkspaceError::NotFound {
182                    path: resolved.display().to_string(),
183                }
184            } else {
185                WorkspaceError::Backend(anyhow!(
186                    "Failed to open file {}: {}",
187                    resolved.display(),
188                    error
189                ))
190            }
191        })?;
192        let mut lines = BufReader::new(file).lines();
193        let mut line_index = 0usize;
194        while line_index < offset {
195            match lines.next_line().await.map_err(|error| {
196                WorkspaceError::Backend(anyhow!(
197                    "Failed to read file {}: {}",
198                    resolved.display(),
199                    error
200                ))
201            })? {
202                Some(_) => line_index += 1,
203                None => {
204                    return Ok(WorkspaceTextRange {
205                        lines: Vec::new(),
206                        next_offset: None,
207                        eof: true,
208                        total_lines: Some(line_index),
209                    })
210                }
211            }
212        }
213
214        let mut selected = Vec::with_capacity(limit);
215        while selected.len() < limit {
216            match lines.next_line().await.map_err(|error| {
217                WorkspaceError::Backend(anyhow!(
218                    "Failed to read file {}: {}",
219                    resolved.display(),
220                    error
221                ))
222            })? {
223                Some(line) => selected.push(line),
224                None => {
225                    let total_lines = offset.saturating_add(selected.len());
226                    return Ok(WorkspaceTextRange {
227                        lines: selected,
228                        next_offset: None,
229                        eof: true,
230                        total_lines: Some(total_lines),
231                    });
232                }
233            }
234        }
235
236        let has_more = lines
237            .next_line()
238            .await
239            .map_err(|error| {
240                WorkspaceError::Backend(anyhow!(
241                    "Failed to read file {}: {}",
242                    resolved.display(),
243                    error
244                ))
245            })?
246            .is_some();
247        Ok(WorkspaceTextRange {
248            lines: selected,
249            next_offset: has_more.then_some(offset.saturating_add(limit)),
250            eof: !has_more,
251            total_lines: (!has_more).then_some(offset.saturating_add(limit)),
252        })
253    }
254}
255
256#[async_trait]
257impl WorkspaceSearch for LocalWorkspaceBackend {
258    async fn glob(&self, request: WorkspaceGlobRequest) -> Result<WorkspaceGlobResult> {
259        validate_relative_pattern(&request.pattern, "glob pattern")?;
260        let base = self.local_path_for_read(&request.base)?;
261        let full_pattern = base.join(&request.pattern);
262        let full_pattern = full_pattern.to_string_lossy().replace('\\', "/");
263
264        let entries = glob::glob(&full_pattern)
265            .map_err(|e| anyhow!("Invalid glob pattern '{}': {}", request.pattern, e))?;
266
267        let mut matches = Vec::new();
268        for entry in entries {
269            match entry {
270                Ok(path) => {
271                    // On Windows, `canonicalize()` commonly gives the backend
272                    // root a verbatim `\\?\` prefix while `glob` returns a
273                    // regular drive path. Canonicalize each match before
274                    // stripping so equivalent paths use the same form.
275                    let normalized = path.canonicalize().unwrap_or(path);
276                    if let Ok(relative) = normalized.strip_prefix(&self.root) {
277                        matches.push(pathbuf_to_workspace_path(relative));
278                    }
279                }
280                Err(e) => tracing::warn!("Glob entry error: {}", e),
281            }
282        }
283
284        matches.sort_by(|a, b| a.as_str().cmp(b.as_str()));
285        Ok(WorkspaceGlobResult { matches })
286    }
287
288    async fn grep(&self, request: WorkspaceGrepRequest) -> Result<WorkspaceGrepResult> {
289        Ok(self.grep_with_sources(request).await?.result)
290    }
291
292    async fn grep_with_sources(
293        &self,
294        request: WorkspaceGrepRequest,
295    ) -> Result<WorkspaceGrepOutcome> {
296        if let Some(ref glob) = request.glob {
297            validate_relative_pattern(glob, "grep glob filter")?;
298        }
299
300        let regex_pattern = if request.case_insensitive {
301            format!("(?i){}", request.pattern)
302        } else {
303            request.pattern.clone()
304        };
305        let regex = regex::Regex::new(&regex_pattern)
306            .map_err(|e| anyhow!("Invalid regex pattern '{}': {}", request.pattern, e))?;
307
308        let search_path = self.local_path_for_read(&request.base)?;
309        let mut builder = ignore::WalkBuilder::new(&search_path);
310        builder.hidden(false).git_ignore(true).git_global(true);
311
312        if let Some(ref glob_pat) = request.glob {
313            let mut types = ignore::types::TypesBuilder::new();
314            types.add("custom", glob_pat).ok();
315            types.select("custom");
316            if let Ok(built) = types.build() {
317                builder.types(built);
318            }
319        }
320
321        let mut output = String::new();
322        let mut match_count = 0;
323        let mut file_count = 0;
324        let mut total_size = 0;
325        let mut matched_paths = Vec::new();
326
327        for entry in builder.build().flatten() {
328            if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
329                continue;
330            }
331
332            let file_path = entry.path();
333            let content = match std::fs::read_to_string(file_path) {
334                Ok(content) => content,
335                Err(_) => continue,
336            };
337
338            let lines: Vec<&str> = content.lines().collect();
339            let mut file_matches = Vec::new();
340            for (line_idx, line) in lines.iter().enumerate() {
341                if regex.is_match(line) {
342                    file_matches.push(line_idx);
343                }
344            }
345
346            if file_matches.is_empty() {
347                continue;
348            }
349
350            file_count += 1;
351            let workspace_path =
352                pathbuf_to_workspace_path(file_path.strip_prefix(&self.root).unwrap_or(file_path));
353            let rel_path = workspace_path.as_str();
354            let display_path = escape_control_chars_for_display(rel_path);
355            let mut path_recorded = false;
356
357            for &match_idx in &file_matches {
358                if total_size > request.max_output_size {
359                    return Ok(WorkspaceGrepOutcome {
360                        result: WorkspaceGrepResult {
361                            output,
362                            match_count,
363                            file_count,
364                            truncated: true,
365                        },
366                        matched_paths: Some(matched_paths),
367                    });
368                }
369
370                if !path_recorded {
371                    matched_paths.push(workspace_path.clone());
372                    path_recorded = true;
373                }
374                match_count += 1;
375
376                let start = match_idx.saturating_sub(request.context_lines);
377                let end = (match_idx + request.context_lines + 1).min(lines.len());
378
379                for (i, line) in lines[start..end].iter().enumerate() {
380                    let abs_i = start + i;
381                    let prefix = if abs_i == match_idx { ">" } else { " " };
382                    let line = format!("{}{}:{}: {}\n", prefix, display_path, abs_i + 1, line);
383                    total_size += line.len();
384                    output.push_str(&line);
385                }
386
387                if request.context_lines > 0 {
388                    output.push_str("--\n");
389                    total_size += 3;
390                }
391            }
392        }
393
394        Ok(WorkspaceGrepOutcome {
395            result: WorkspaceGrepResult {
396                output,
397                match_count,
398                file_count,
399                truncated: false,
400            },
401            matched_paths: Some(matched_paths),
402        })
403    }
404}
405
406#[async_trait]
407impl WorkspaceGit for LocalWorkspaceBackend {
408    async fn is_repository(&self) -> Result<bool> {
409        self.run_blocking_git(|root| Ok(crate::git::is_git_repo(&root)))
410            .await
411    }
412
413    async fn status(&self) -> Result<WorkspaceGitStatus> {
414        self.run_blocking_git(|root| {
415            let status = crate::git::get_status(&root)?;
416            Ok(WorkspaceGitStatus {
417                branch: status.branch,
418                commit: status.commit,
419                is_worktree: status.is_worktree,
420                is_dirty: status.is_dirty,
421                dirty_count: status.dirty_count,
422            })
423        })
424        .await
425    }
426
427    async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>> {
428        self.run_blocking_git(move |root| {
429            Ok(crate::git::get_log(&root, max_count)?
430                .into_iter()
431                .map(|commit| WorkspaceGitCommit {
432                    id: commit.id,
433                    message: commit.message,
434                    author: commit.author,
435                    date: commit.date,
436                })
437                .collect())
438        })
439        .await
440    }
441
442    async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>> {
443        self.run_blocking_git(|root| {
444            Ok(crate::git::list_branches(&root)?
445                .into_iter()
446                .map(|branch| WorkspaceGitBranch {
447                    name: branch.name,
448                    is_current: branch.is_current,
449                })
450                .collect())
451        })
452        .await
453    }
454
455    async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()> {
456        self.run_blocking_git(move |root| {
457            crate::git::create_branch(&root, &request.name, &request.base)
458        })
459        .await
460    }
461
462    async fn checkout(
463        &self,
464        request: WorkspaceGitCheckoutRequest,
465    ) -> Result<WorkspaceGitCheckoutOutput> {
466        let args = if request.force {
467            vec![
468                "checkout".to_string(),
469                "--force".to_string(),
470                request.refspec,
471            ]
472        } else {
473            vec!["checkout".to_string(), request.refspec]
474        };
475        let (success, stdout, stderr) = self.run_git_command(args).await?;
476        if !success {
477            bail!("{}", stderr.trim_end());
478        }
479        Ok(WorkspaceGitCheckoutOutput { stdout })
480    }
481
482    async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String> {
483        self.run_blocking_git(move |root| crate::git::get_diff(&root, request.target.as_deref()))
484            .await
485    }
486
487    async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>> {
488        let (success, stdout, stderr) = self
489            .run_git_command(vec!["remote".to_string(), "-v".to_string()])
490            .await?;
491        if !success {
492            bail!("{}", stderr.trim_end());
493        }
494
495        Ok(stdout.lines().filter_map(parse_git_remote_line).collect())
496    }
497}
498
499#[async_trait]
500impl WorkspaceGitStashProvider for LocalWorkspaceBackend {
501    async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>> {
502        self.run_blocking_git(|root| {
503            Ok(crate::git::list_stashes(&root)?
504                .into_iter()
505                .map(|stash| WorkspaceGitStash {
506                    index: stash.index,
507                    message: stash.message,
508                })
509                .collect())
510        })
511        .await
512    }
513
514    async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()> {
515        self.run_blocking_git(move |root| {
516            crate::git::stash(&root, request.message.as_deref(), request.include_untracked)
517        })
518        .await
519    }
520}
521
522#[async_trait]
523impl WorkspaceGitWorktreeProvider for LocalWorkspaceBackend {
524    async fn list_worktrees(&self) -> Result<Vec<WorkspaceGitWorktree>> {
525        self.run_blocking_git(|root| {
526            Ok(crate::git::list_worktrees(&root)?
527                .into_iter()
528                .map(|worktree| WorkspaceGitWorktree {
529                    path: worktree.path,
530                    branch: worktree.branch,
531                    is_bare: worktree.is_bare,
532                    is_detached: worktree.is_detached,
533                })
534                .collect())
535        })
536        .await
537    }
538
539    async fn create_worktree(
540        &self,
541        request: WorkspaceGitCreateWorktreeRequest,
542    ) -> Result<WorkspaceGitWorktreeMutation> {
543        let branch = request.branch;
544        let path = request
545            .path
546            .map(|path| {
547                let path = PathBuf::from(path);
548                if path.is_absolute() {
549                    path
550                } else {
551                    self.root.join(path)
552                }
553            })
554            .unwrap_or_else(|| default_local_worktree_path(&self.root, &branch));
555        let display_path = path.display().to_string();
556        let new_branch = request.new_branch;
557        let branch_for_git = branch.clone();
558
559        self.run_blocking_git(move |root| {
560            crate::git::create_worktree(&root, &branch_for_git, &path, new_branch)
561        })
562        .await?;
563
564        Ok(WorkspaceGitWorktreeMutation {
565            path: display_path,
566            branch: Some(branch),
567        })
568    }
569
570    async fn remove_worktree(
571        &self,
572        request: WorkspaceGitRemoveWorktreeRequest,
573    ) -> Result<WorkspaceGitWorktreeMutation> {
574        let path = PathBuf::from(request.path);
575        let display_path = path.display().to_string();
576        let force = request.force;
577
578        self.run_blocking_git(move |root| crate::git::remove_worktree(&root, &path, force))
579            .await?;
580
581        Ok(WorkspaceGitWorktreeMutation {
582            path: display_path,
583            branch: None,
584        })
585    }
586}
587
588#[async_trait]
589impl WorkspaceCommandRunner for LocalWorkspaceBackend {
590    async fn exec(&self, request: CommandRequest) -> Result<CommandOutput> {
591        #[cfg(windows)]
592        if let Some(output) =
593            crate::tools::builtin::bash::maybe_execute_simple_windows_http_command(&request.command)
594                .await
595        {
596            let exit_code = output
597                .metadata
598                .as_ref()
599                .and_then(|m| m.get("exit_code"))
600                .and_then(|v| v.as_i64())
601                .map(|v| v as i32)
602                .unwrap_or(if output.success { 0 } else { -1 });
603            return Ok(CommandOutput {
604                output: output.content,
605                exit_code,
606                timed_out: false,
607            });
608        }
609
610        let mut child = crate::tools::builtin::bash::spawn_shell(
611            &request.command,
612            &self.root,
613            request.env.as_deref(),
614        )
615        .map_err(|e| anyhow!("Failed to spawn shell: {}", e))?;
616
617        let (output, timed_out) = crate::tools::process::read_process_output(
618            &mut child,
619            request.timeout_ms,
620            request.output_observer.as_deref(),
621        )
622        .await;
623
624        if timed_out {
625            return Ok(CommandOutput {
626                output,
627                exit_code: -1,
628                timed_out: true,
629            });
630        }
631
632        let status = child
633            .wait()
634            .await
635            .map_err(|e| anyhow!("Failed to wait for shell: {}", e))?;
636        let exit_code = status.code().unwrap_or(-1);
637
638        Ok(CommandOutput {
639            output,
640            exit_code,
641            timed_out: false,
642        })
643    }
644}
645
646impl LocalWorkspaceBackend {
647    async fn run_blocking_git<T, F>(&self, operation: F) -> Result<T>
648    where
649        T: Send + 'static,
650        F: FnOnce(PathBuf) -> Result<T> + Send + 'static,
651    {
652        let root = self.root.clone();
653        tokio::task::spawn_blocking(move || operation(root))
654            .await
655            .map_err(|e| anyhow!("Git worker failed: {}", e))?
656    }
657
658    async fn run_git_command(&self, args: Vec<String>) -> Result<(bool, String, String)> {
659        tokio::task::spawn_blocking(crate::git::ensure_git_installed)
660            .await
661            .map_err(|e| anyhow!("Git worker failed: {}", e))??;
662
663        let mut command = tokio::process::Command::new("git");
664        command
665            .arg("-C")
666            .arg(self.root.as_os_str())
667            .args(&args)
668            .kill_on_drop(true);
669        let output = command
670            .output()
671            .await
672            .map_err(|e| anyhow!("Failed to execute git: {}", e))?;
673
674        Ok((
675            output.status.success(),
676            String::from_utf8_lossy(&output.stdout).to_string(),
677            String::from_utf8_lossy(&output.stderr).to_string(),
678        ))
679    }
680}
681
682fn parse_git_remote_line(line: &str) -> Option<WorkspaceGitRemote> {
683    let mut parts = line.split_whitespace();
684    let name = parts.next()?;
685    let url = parts.next()?;
686    let direction = parts
687        .next()
688        .unwrap_or_default()
689        .trim_start_matches('(')
690        .trim_end_matches(')');
691
692    Some(WorkspaceGitRemote {
693        name: name.to_string(),
694        url: url.to_string(),
695        direction: direction.to_string(),
696    })
697}
698
699fn default_local_worktree_path(root: &Path, branch: &str) -> PathBuf {
700    let repo_name = root
701        .file_name()
702        .map(|name| name.to_string_lossy().to_string())
703        .unwrap_or_else(|| "repo".to_string());
704    root.parent()
705        .unwrap_or(root)
706        .join(format!("{repo_name}-{branch}"))
707}
708
709pub(super) fn normalize_local_path(root: &Path, input: &str) -> Result<WorkspacePath> {
710    let input = default_path_input(input);
711    let candidate = Path::new(input);
712
713    if candidate.is_absolute() {
714        let root = normalize_absolute_path(root)?;
715        let target = normalize_absolute_path(candidate)?;
716        if !target.starts_with(&root) {
717            bail!(
718                "Workspace boundary violation: path '{}' escapes workspace '{}'",
719                input,
720                root.display()
721            );
722        }
723        let relative = target
724            .strip_prefix(&root)
725            .map_err(|_| anyhow!("Failed to compute workspace-relative path"))?;
726        return Ok(pathbuf_to_workspace_path(relative));
727    }
728
729    if has_windows_path_prefix(input) {
730        bail!("Absolute paths are not supported by this workspace backend");
731    }
732
733    let normalized_input = input.replace('\\', "/");
734    let path = Path::new(&normalized_input);
735    if path.is_absolute() {
736        bail!("Absolute paths are not supported by this workspace backend");
737    }
738
739    let relative = normalize_relative_path(path)?;
740    Ok(pathbuf_to_workspace_path(&relative))
741}
742
743fn normalize_absolute_path(path: &Path) -> Result<PathBuf> {
744    let lexical = normalize_absolute_path_lexical(path)?;
745    if let Ok(canonical) = lexical.canonicalize() {
746        return Ok(canonical);
747    }
748
749    let mut current = lexical.as_path();
750    let mut suffix = Vec::new();
751    while !current.exists() {
752        let Some(file_name) = current.file_name() else {
753            return Ok(lexical);
754        };
755        suffix.push(file_name.to_os_string());
756        let Some(parent) = current.parent() else {
757            return Ok(lexical);
758        };
759        current = parent;
760    }
761
762    let mut normalized = current.canonicalize().unwrap_or_else(|_| {
763        normalize_absolute_path_lexical(current).unwrap_or_else(|_| current.into())
764    });
765    for part in suffix.iter().rev() {
766        normalized.push(part);
767    }
768    Ok(normalized)
769}
770
771fn normalize_absolute_path_lexical(path: &Path) -> Result<PathBuf> {
772    let mut out = PathBuf::new();
773    for component in path.components() {
774        match component {
775            Component::Prefix(prefix) => out.push(prefix.as_os_str()),
776            Component::RootDir => out.push(Path::new(std::path::MAIN_SEPARATOR_STR)),
777            Component::CurDir => {}
778            Component::Normal(part) => out.push(part),
779            Component::ParentDir => {
780                if !out.pop() {
781                    bail!("Invalid absolute path");
782                }
783            }
784        }
785    }
786    Ok(out)
787}
788
789#[cfg(test)]
790mod tests {
791    use super::super::WorkspaceServices;
792    use super::*;
793
794    #[tokio::test]
795    async fn local_backend_reads_writes_and_lists() {
796        let temp = tempfile::tempdir().unwrap();
797        let services = WorkspaceServices::local(temp.path());
798        let path = services.normalize_path("dir/file.txt").unwrap();
799
800        let written = services
801            .fs()
802            .write_text(&path, "hello\nworld\n")
803            .await
804            .unwrap();
805        assert_eq!(written.bytes, 12);
806        assert_eq!(written.lines, 2);
807
808        let content = services.fs().read_text(&path).await.unwrap();
809        assert_eq!(content, "hello\nworld\n");
810
811        let dir = services.normalize_path("dir").unwrap();
812        let entries = services.fs().list_dir(&dir).await.unwrap();
813        assert_eq!(entries.len(), 1);
814        assert_eq!(entries[0].name, "file.txt");
815    }
816
817    #[tokio::test]
818    async fn local_backend_searches_glob_and_grep() {
819        let temp = tempfile::tempdir().unwrap();
820        let services = WorkspaceServices::local(temp.path());
821        services
822            .fs()
823            .write_text(
824                &services.normalize_path("src/main.rs").unwrap(),
825                "fn main() {\n    println!(\"hello\");\n}\n",
826            )
827            .await
828            .unwrap();
829        services
830            .fs()
831            .write_text(
832                &services.normalize_path("README.md").unwrap(),
833                "hello from docs\n",
834            )
835            .await
836            .unwrap();
837
838        let search = services.search().expect("local backend supports search");
839        let glob = search
840            .glob(WorkspaceGlobRequest {
841                base: services.normalize_path("src").unwrap(),
842                pattern: "*.rs".to_string(),
843            })
844            .await
845            .unwrap();
846        assert_eq!(glob.matches[0].as_str(), "src/main.rs");
847
848        let grep = search
849            .grep(WorkspaceGrepRequest {
850                base: WorkspacePath::root(),
851                pattern: "hello".to_string(),
852                glob: Some("**/*.rs".to_string()),
853                context_lines: 0,
854                case_insensitive: false,
855                max_output_size: 1024,
856            })
857            .await
858            .unwrap();
859        assert_eq!(grep.match_count, 1);
860        assert_eq!(grep.file_count, 1);
861        assert!(grep.output.contains("src/main.rs:2"));
862    }
863
864    #[test]
865    fn local_backend_rejects_absolute_paths_outside_workspace() {
866        let temp = tempfile::tempdir().unwrap();
867        let services = WorkspaceServices::local(temp.path());
868        let outside = temp.path().parent().unwrap().join("secret.txt");
869        let err = services
870            .normalize_path(outside.to_str().unwrap())
871            .expect_err("outside absolute path should be rejected");
872        assert!(err.to_string().contains("escapes workspace"));
873    }
874
875    #[test]
876    fn local_backend_rejects_backslash_parent_escape() {
877        let temp = tempfile::tempdir().unwrap();
878        let services = WorkspaceServices::local(temp.path());
879        let err = services
880            .normalize_path(r"..\secret.txt")
881            .expect_err("backslash parent traversal should be rejected");
882        assert!(err.to_string().contains("escapes workspace"));
883    }
884
885    #[test]
886    fn local_backend_allows_absolute_paths_inside_workspace() {
887        let temp = tempfile::tempdir().unwrap();
888        let services = WorkspaceServices::local(temp.path());
889        let absolute = temp.path().join("src/main.rs");
890        let path = services
891            .normalize_path(absolute.to_str().unwrap())
892            .expect("absolute path inside workspace should normalize");
893        assert_eq!(path.as_str(), "src/main.rs");
894    }
895}