vv_agent/workspace/
mod.rs1pub 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}