1use 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#[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 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(®ex_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}