Skip to main content

a3s_code_core/workspace/
path.rs

1//! Virtual workspace path normalization and display helpers.
2
3use super::{WorkspacePath, WorkspacePathResolver};
4use anyhow::{bail, Result};
5use std::path::{Component, Path, PathBuf};
6
7/// Lexical resolver suitable for virtual/browser/DFS workspaces.
8#[derive(Debug, Default)]
9pub struct VirtualPathResolver;
10
11impl WorkspacePathResolver for VirtualPathResolver {
12    fn normalize(&self, input: &str) -> Result<WorkspacePath> {
13        normalize_virtual_path(input)
14    }
15}
16
17fn normalize_virtual_path(input: &str) -> Result<WorkspacePath> {
18    let input = default_path_input(input);
19    if has_windows_path_prefix(input) {
20        bail!("Absolute paths are not supported by this workspace backend");
21    }
22
23    let normalized_input = input.replace('\\', "/");
24    let path = Path::new(&normalized_input);
25    if path.is_absolute() {
26        bail!("Absolute paths are not supported by this workspace backend");
27    }
28
29    let relative = normalize_relative_path(path)?;
30    Ok(pathbuf_to_workspace_path(&relative))
31}
32
33pub(super) fn default_path_input(input: &str) -> &str {
34    let trimmed = input.trim();
35    if trimmed.is_empty() {
36        "."
37    } else {
38        trimmed
39    }
40}
41
42pub(super) fn has_windows_path_prefix(input: &str) -> bool {
43    let bytes = input.as_bytes();
44    if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
45        return true;
46    }
47
48    input.starts_with("\\\\") || input.starts_with("//")
49}
50
51pub(crate) fn validate_relative_pattern(pattern: &str, label: &str) -> Result<()> {
52    let pattern = pattern.trim();
53    if pattern.is_empty() {
54        bail!("{label} cannot be empty");
55    }
56    if has_windows_path_prefix(pattern) || Path::new(pattern).is_absolute() {
57        bail!("{label} must be relative to the workspace");
58    }
59
60    let normalized = pattern.replace('\\', "/");
61    if normalized.split('/').any(|component| component == "..") {
62        bail!("{label} must not contain parent directory traversal");
63    }
64
65    Ok(())
66}
67
68pub(super) fn normalize_relative_path(path: &Path) -> Result<PathBuf> {
69    let mut out = PathBuf::new();
70    for component in path.components() {
71        match component {
72            Component::CurDir => {}
73            Component::Normal(part) => out.push(part),
74            Component::ParentDir => {
75                if !out.pop() {
76                    bail!("Workspace boundary violation: path escapes workspace");
77                }
78            }
79            Component::RootDir | Component::Prefix(_) => {
80                bail!("Absolute paths are not supported by this workspace backend");
81            }
82        }
83    }
84    Ok(out)
85}
86
87pub(super) fn pathbuf_to_workspace_path(path: &Path) -> WorkspacePath {
88    let display = path.to_string_lossy().replace('\\', "/");
89    WorkspacePath::from_normalized(display)
90}
91
92pub(super) fn escape_control_chars_for_display(path: &str) -> String {
93    let mut escaped = String::with_capacity(path.len());
94    for ch in path.chars() {
95        if ch.is_control() {
96            escaped.extend(ch.escape_default());
97        } else {
98            escaped.push(ch);
99        }
100    }
101    escaped
102}