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