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