Skip to main content

vv_agent/workspace/
mod.rs

1pub mod base;
2mod discovery_filter;
3pub mod local;
4pub mod memory;
5pub mod s3;
6
7use std::collections::BTreeSet;
8use std::io::{Error, ErrorKind};
9use std::path::{Path, PathBuf};
10use std::time::SystemTime;
11
12pub use base::{FileInfo, WorkspaceBackend};
13pub use discovery_filter::{
14    validate_portable_exclude_pattern, DiscoveryFilteredWorkspaceBackend, PortableRegexError,
15    INVALID_EXCLUDE_FILES_PATTERN_CODE, INVALID_EXCLUDE_FILES_PATTERN_MESSAGE,
16};
17pub use local::LocalWorkspaceBackend;
18pub use memory::MemoryWorkspaceBackend;
19pub use s3::{S3WorkspaceBackend, S3WorkspaceConfig};
20
21pub(crate) fn normalized_glob_pattern(glob: &str) -> String {
22    let pattern = glob.trim();
23    if pattern.is_empty() {
24        "**/*".to_string()
25    } else {
26        pattern.replace('\\', "/")
27    }
28}
29
30pub(super) fn path_to_posix(path: &Path) -> String {
31    path.to_string_lossy().replace('\\', "/")
32}
33
34pub(super) fn absolutize_path(path: &Path) -> PathBuf {
35    if path.is_absolute() {
36        path.to_path_buf()
37    } else {
38        std::env::current_dir()
39            .unwrap_or_else(|_| PathBuf::from("."))
40            .join(path)
41    }
42}
43
44pub(crate) fn expand_home_path(raw_path: &str) -> PathBuf {
45    if raw_path == "~" {
46        return home_dir().unwrap_or_else(|| PathBuf::from(raw_path));
47    }
48    if let Some(rest) = raw_path.strip_prefix("~/") {
49        if let Some(home) = home_dir() {
50            return home.join(rest);
51        }
52    }
53    #[cfg(windows)]
54    if let Some(rest) = raw_path.strip_prefix("~\\") {
55        if let Some(home) = home_dir() {
56            return home.join(rest);
57        }
58    }
59    PathBuf::from(raw_path)
60}
61
62fn home_dir() -> Option<PathBuf> {
63    std::env::var_os("HOME")
64        .filter(|home| !home.is_empty())
65        .map(PathBuf::from)
66        .or_else(|| {
67            std::env::var_os("USERPROFILE")
68                .filter(|home| !home.is_empty())
69                .map(PathBuf::from)
70        })
71        .or_else(|| {
72            let drive = std::env::var_os("HOMEDRIVE")?;
73            let path = std::env::var_os("HOMEPATH")?;
74            if drive.is_empty() || path.is_empty() {
75                return None;
76            }
77            Some(PathBuf::from(format!(
78                "{}{}",
79                drive.to_string_lossy(),
80                path.to_string_lossy()
81            )))
82        })
83}
84
85pub(super) fn normalize_path_lexically(path: PathBuf) -> PathBuf {
86    let mut normalized = PathBuf::new();
87    for component in path.components() {
88        match component {
89            std::path::Component::CurDir => {}
90            std::path::Component::ParentDir => {
91                normalized.pop();
92            }
93            other => normalized.push(other.as_os_str()),
94        }
95    }
96    normalized
97}
98
99pub(super) fn normalize_workspace_path(path: &str) -> String {
100    let normalized = path.replace('\\', "/");
101    let mut parts = Vec::new();
102    for part in normalized.split('/') {
103        match part {
104            "" | "." => {}
105            ".." => {
106                parts.pop();
107            }
108            value => parts.push(value),
109        }
110    }
111    parts.join("/")
112}
113
114pub(super) fn suffix_with_dot(path: &str) -> String {
115    let suffix = Path::new(path)
116        .extension()
117        .and_then(|ext| ext.to_str())
118        .unwrap_or_default()
119        .to_string();
120    if suffix.is_empty() {
121        suffix
122    } else {
123        format!(".{suffix}")
124    }
125}
126
127pub(super) fn system_time_to_utc_isoformat(time: SystemTime) -> String {
128    let datetime: chrono::DateTime<chrono::Utc> = time.into();
129    datetime.to_rfc3339_opts(chrono::SecondsFormat::Micros, false)
130}
131
132pub(super) fn current_utc_isoformat() -> String {
133    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Micros, false)
134}
135
136pub(super) fn insert_parent_dirs(dirs: &mut BTreeSet<String>, key: &str) {
137    dirs.insert(String::new());
138    let mut current = Vec::new();
139    let mut parts = key.split('/').filter(|part| !part.is_empty()).peekable();
140    while let Some(part) = parts.next() {
141        current.push(part);
142        if parts.peek().is_some() {
143            dirs.insert(current.join("/"));
144        }
145    }
146}
147
148pub(super) fn not_found(path: &str) -> Error {
149    Error::new(ErrorKind::NotFound, format!("path not found: {path}"))
150}
151
152pub(super) fn object_store_error_to_io(error: object_store::Error) -> Error {
153    match error {
154        object_store::Error::NotFound { path, source } => Error::new(
155            ErrorKind::NotFound,
156            format!("path not found: {path}: {source}"),
157        ),
158        other => Error::other(other.to_string()),
159    }
160}
161
162pub(super) fn non_empty_option(value: Option<String>) -> Option<String> {
163    value.and_then(|value| {
164        let value = value.trim().to_string();
165        if value.is_empty() {
166            None
167        } else {
168            Some(value)
169        }
170    })
171}
172
173pub(crate) fn glob_match(path: &str, pattern: &str) -> bool {
174    glob_match_bytes(path.as_bytes(), pattern.as_bytes())
175}
176
177fn glob_match_bytes(path: &[u8], pattern: &[u8]) -> bool {
178    if pattern.is_empty() {
179        return path.is_empty();
180    }
181    if pattern.starts_with(b"**/") {
182        return glob_match_bytes(path, &pattern[3..])
183            || path
184                .iter()
185                .enumerate()
186                .filter(|(_, value)| **value == b'/')
187                .any(|(index, _)| glob_match_bytes(&path[index + 1..], &pattern[3..]));
188    }
189    if pattern.starts_with(b"**") {
190        return (0..=path.len()).any(|index| glob_match_bytes(&path[index..], &pattern[2..]));
191    }
192    match pattern[0] {
193        b'*' => {
194            if glob_match_bytes(path, &pattern[1..]) {
195                return true;
196            }
197            for index in 0..path.len() {
198                if path[index] == b'/' {
199                    break;
200                }
201                if glob_match_bytes(&path[index + 1..], &pattern[1..]) {
202                    return true;
203                }
204            }
205            false
206        }
207        b'?' => path
208            .first()
209            .is_some_and(|value| *value != b'/' && glob_match_bytes(&path[1..], &pattern[1..])),
210        literal => path
211            .first()
212            .is_some_and(|value| *value == literal && glob_match_bytes(&path[1..], &pattern[1..])),
213    }
214}