1use crate::error::{Error, Result};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct RemotePath {
11 pub alias: String,
13 pub bucket: String,
15 pub key: String,
17 pub is_dir: bool,
19}
20
21impl RemotePath {
22 pub fn new(
24 alias: impl Into<String>,
25 bucket: impl Into<String>,
26 key: impl Into<String>,
27 ) -> Self {
28 let key = key.into();
29 let is_dir = key.ends_with('/') || key.is_empty();
30 Self {
31 alias: alias.into(),
32 bucket: bucket.into(),
33 key,
34 is_dir,
35 }
36 }
37
38 pub fn to_full_path(&self) -> String {
40 if self.key.is_empty() {
41 format!("{}/{}", self.alias, self.bucket)
42 } else {
43 format!("{}/{}/{}", self.alias, self.bucket, self.key)
44 }
45 }
46
47 pub fn parent(&self) -> Option<Self> {
49 if self.key.is_empty() {
50 None
52 } else {
53 let key = self.key.trim_end_matches('/');
54 match key.rfind('/') {
55 Some(pos) => Some(Self {
56 alias: self.alias.clone(),
57 bucket: self.bucket.clone(),
58 key: format!("{}/", &key[..pos]),
59 is_dir: true,
60 }),
61 None => Some(Self {
62 alias: self.alias.clone(),
63 bucket: self.bucket.clone(),
64 key: String::new(),
65 is_dir: true,
66 }),
67 }
68 }
69 }
70
71 pub fn join(&self, child: &str) -> Self {
73 let base = self.key.trim_end_matches('/');
74 let key = if base.is_empty() {
75 child.to_string()
76 } else {
77 format!("{base}/{child}")
78 };
79 let is_dir = child.ends_with('/');
80 Self {
81 alias: self.alias.clone(),
82 bucket: self.bucket.clone(),
83 key,
84 is_dir,
85 }
86 }
87}
88
89impl std::fmt::Display for RemotePath {
90 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 write!(f, "{}", self.to_full_path())
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
97pub enum ParsedPath {
98 Local(std::path::PathBuf),
100 Remote(RemotePath),
102}
103
104impl ParsedPath {
105 pub fn is_remote(&self) -> bool {
107 matches!(self, ParsedPath::Remote(_))
108 }
109
110 pub fn is_local(&self) -> bool {
112 matches!(self, ParsedPath::Local(_))
113 }
114
115 pub fn as_remote(&self) -> Option<&RemotePath> {
117 match self {
118 ParsedPath::Remote(p) => Some(p),
119 ParsedPath::Local(_) => None,
120 }
121 }
122
123 pub fn as_local(&self) -> Option<&std::path::PathBuf> {
125 match self {
126 ParsedPath::Local(p) => Some(p),
127 ParsedPath::Remote(_) => None,
128 }
129 }
130}
131
132pub fn parse_path(path: &str) -> Result<ParsedPath> {
141 if path.is_empty() {
143 return Err(Error::InvalidPath("Path cannot be empty".into()));
144 }
145
146 if path.starts_with('/') {
148 return Ok(ParsedPath::Local(std::path::PathBuf::from(path)));
149 }
150
151 if path.starts_with("./") || path.starts_with("../") {
153 return Ok(ParsedPath::Local(std::path::PathBuf::from(path)));
154 }
155
156 #[cfg(windows)]
158 if path.len() >= 2 && path.chars().nth(1) == Some(':') {
159 return Ok(ParsedPath::Local(std::path::PathBuf::from(path)));
160 }
161
162 let parts: Vec<&str> = path.splitn(3, '/').collect();
164
165 match parts.len() {
166 1 => {
168 if parts[0].contains('.') || parts[0].contains('\\') {
171 Ok(ParsedPath::Local(std::path::PathBuf::from(path)))
172 } else {
173 Err(Error::InvalidPath(format!(
176 "Path '{path}' is incomplete. Use format: alias/bucket[/key]"
177 )))
178 }
179 }
180 2 => {
182 let alias = parts[0];
183 let bucket = parts[1];
184
185 if !is_valid_alias_name(alias) {
187 return Ok(ParsedPath::Local(std::path::PathBuf::from(path)));
188 }
189
190 if bucket.is_empty() {
192 return Err(Error::InvalidPath("Bucket name cannot be empty".into()));
193 }
194
195 Ok(ParsedPath::Remote(RemotePath::new(alias, bucket, "")))
196 }
197 3 => {
199 let alias = parts[0];
200 let bucket = parts[1];
201 let key = parts[2];
202
203 if !is_valid_alias_name(alias) {
204 return Ok(ParsedPath::Local(std::path::PathBuf::from(path)));
205 }
206
207 if bucket.is_empty() {
208 return Err(Error::InvalidPath("Bucket name cannot be empty".into()));
209 }
210
211 Ok(ParsedPath::Remote(RemotePath::new(alias, bucket, key)))
212 }
213 _ => unreachable!(),
214 }
215}
216
217fn is_valid_alias_name(name: &str) -> bool {
219 !name.is_empty()
220 && name
221 .chars()
222 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[test]
230 fn test_parse_remote_path() {
231 let path = parse_path("myalias/bucket/file.txt").unwrap();
232 assert!(path.is_remote());
233
234 let remote = path.as_remote().unwrap();
235 assert_eq!(remote.alias, "myalias");
236 assert_eq!(remote.bucket, "bucket");
237 assert_eq!(remote.key, "file.txt");
238 assert!(!remote.is_dir);
239 }
240
241 #[test]
242 fn test_parse_remote_path_dir() {
243 let path = parse_path("myalias/bucket/dir/").unwrap();
244 let remote = path.as_remote().unwrap();
245 assert_eq!(remote.key, "dir/");
246 assert!(remote.is_dir);
247 }
248
249 #[test]
250 fn test_parse_remote_path_bucket_only() {
251 let path = parse_path("myalias/bucket").unwrap();
252 let remote = path.as_remote().unwrap();
253 assert_eq!(remote.alias, "myalias");
254 assert_eq!(remote.bucket, "bucket");
255 assert_eq!(remote.key, "");
256 assert!(remote.is_dir);
257 }
258
259 #[test]
260 fn test_parse_local_absolute_path() {
261 let path = parse_path("/home/user/file.txt").unwrap();
262 assert!(path.is_local());
263 assert_eq!(
264 path.as_local().unwrap().to_str().unwrap(),
265 "/home/user/file.txt"
266 );
267 }
268
269 #[test]
270 fn test_parse_local_relative_path() {
271 let path = parse_path("./file.txt").unwrap();
272 assert!(path.is_local());
273
274 let path = parse_path("../file.txt").unwrap();
275 assert!(path.is_local());
276 }
277
278 #[test]
279 fn test_parse_empty_path() {
280 let result = parse_path("");
281 assert!(result.is_err());
282 }
283
284 #[test]
285 fn test_parse_alias_only() {
286 let result = parse_path("myalias");
287 assert!(result.is_err());
288 }
289
290 #[test]
291 fn test_remote_path_parent() {
292 let path = RemotePath::new("myalias", "bucket", "a/b/c.txt");
293 let parent = path.parent().unwrap();
294 assert_eq!(parent.key, "a/b/");
295
296 let parent = parent.parent().unwrap();
297 assert_eq!(parent.key, "a/");
298
299 let parent = parent.parent().unwrap();
300 assert_eq!(parent.key, "");
301
302 assert!(parent.parent().is_none());
303 }
304
305 #[test]
306 fn test_remote_path_join() {
307 let path = RemotePath::new("myalias", "bucket", "");
308 let child = path.join("dir/");
309 assert_eq!(child.key, "dir/");
310 assert!(child.is_dir);
311
312 let file = child.join("file.txt");
313 assert_eq!(file.key, "dir/file.txt");
314 assert!(!file.is_dir);
315 }
316
317 #[test]
318 fn test_remote_path_display() {
319 let path = RemotePath::new("myalias", "bucket", "key/file.txt");
320 assert_eq!(path.to_string(), "myalias/bucket/key/file.txt");
321 }
322
323 #[test]
324 fn test_local_path_with_dots() {
325 let path = parse_path("some.file.txt");
327 assert!(path.is_ok());
328 assert!(path.unwrap().is_local());
329 }
330}