Skip to main content

rc_core/
path.rs

1//! Path parsing and resolution
2//!
3//! Handles parsing of remote paths in the format: alias/bucket[/key]
4//! Local paths are passed through as-is.
5
6use crate::error::{Error, Result};
7
8/// A parsed remote path pointing to an S3 location
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct RemotePath {
11    /// Alias name
12    pub alias: String,
13    /// Bucket name
14    pub bucket: String,
15    /// Object key (empty for bucket root)
16    pub key: String,
17    /// Whether the path ends with a slash (directory semantics)
18    pub is_dir: bool,
19}
20
21impl RemotePath {
22    /// Create a new RemotePath
23    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    /// Get the full path as a string (alias/bucket/key)
39    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    /// Get the parent path (one level up)
48    pub fn parent(&self) -> Option<Self> {
49        if self.key.is_empty() {
50            // At bucket level, no parent within the remote context
51            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    /// Join a child path component
72    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/// Parsed path that can be either local or remote
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub enum ParsedPath {
98    /// Local filesystem path
99    Local(std::path::PathBuf),
100    /// Remote S3 path
101    Remote(RemotePath),
102}
103
104impl ParsedPath {
105    /// Check if this is a remote path
106    pub fn is_remote(&self) -> bool {
107        matches!(self, ParsedPath::Remote(_))
108    }
109
110    /// Check if this is a local path
111    pub fn is_local(&self) -> bool {
112        matches!(self, ParsedPath::Local(_))
113    }
114
115    /// Get the remote path if this is a remote path
116    pub fn as_remote(&self) -> Option<&RemotePath> {
117        match self {
118            ParsedPath::Remote(p) => Some(p),
119            ParsedPath::Local(_) => None,
120        }
121    }
122
123    /// Get the local path if this is a local path
124    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
132/// Parse a path string into a ParsedPath
133///
134/// Remote paths have the format: alias/bucket[/key]
135/// Local paths are anything that:
136/// - Starts with / (absolute path)
137/// - Starts with ./ or ../ (relative path)
138/// - Contains no / (could be local file in current directory)
139/// - Or doesn't match the alias/bucket pattern
140pub fn parse_path(path: &str) -> Result<ParsedPath> {
141    // Empty path is invalid
142    if path.is_empty() {
143        return Err(Error::InvalidPath("Path cannot be empty".into()));
144    }
145
146    // Absolute paths are local
147    if path.starts_with('/') {
148        return Ok(ParsedPath::Local(std::path::PathBuf::from(path)));
149    }
150
151    // Explicit relative paths are local
152    if path.starts_with("./") || path.starts_with("../") {
153        return Ok(ParsedPath::Local(std::path::PathBuf::from(path)));
154    }
155
156    // Windows absolute paths
157    #[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    // Try to parse as remote path
163    let parts: Vec<&str> = path.splitn(3, '/').collect();
164
165    match parts.len() {
166        // Just alias name - invalid for most operations but could be valid for 'alias list'
167        1 => {
168            // Treat as local path if it doesn't look like an alias
169            // In Phase 1, we'll validate against known aliases
170            if parts[0].contains('.') || parts[0].contains('\\') {
171                Ok(ParsedPath::Local(std::path::PathBuf::from(path)))
172            } else {
173                // Could be just an alias, return as remote path with empty bucket
174                // This will be validated later against actual aliases
175                Err(Error::InvalidPath(format!(
176                    "Path '{path}' is incomplete. Use format: alias/bucket[/key]"
177                )))
178            }
179        }
180        // alias/bucket
181        2 => {
182            let alias = parts[0];
183            let bucket = parts[1];
184
185            // Validate alias name (alphanumeric, underscore, hyphen)
186            if !is_valid_alias_name(alias) {
187                return Ok(ParsedPath::Local(std::path::PathBuf::from(path)));
188            }
189
190            // Validate bucket name
191            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        // alias/bucket/key
198        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
217/// Parse a remote path that must refer to a single object (`alias/bucket/key` with non-empty key).
218pub fn parse_object_path(path: &str) -> Result<RemotePath> {
219    match parse_path(path)? {
220        ParsedPath::Remote(r) => {
221            if r.key.is_empty() || r.is_dir {
222                Err(Error::InvalidPath(
223                    "Object path required: alias/bucket/key (key must not be empty)".into(),
224                ))
225            } else {
226                Ok(r)
227            }
228        }
229        ParsedPath::Local(_) => Err(Error::InvalidPath(
230            "Expected remote path in the form alias/bucket/key".into(),
231        )),
232    }
233}
234
235/// Check if a string is a valid alias name
236fn is_valid_alias_name(name: &str) -> bool {
237    !name.is_empty()
238        && name
239            .chars()
240            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn test_parse_remote_path() {
249        let path = parse_path("myalias/bucket/file.txt").unwrap();
250        assert!(path.is_remote());
251
252        let remote = path.as_remote().unwrap();
253        assert_eq!(remote.alias, "myalias");
254        assert_eq!(remote.bucket, "bucket");
255        assert_eq!(remote.key, "file.txt");
256        assert!(!remote.is_dir);
257    }
258
259    #[test]
260    fn test_parse_remote_path_dir() {
261        let path = parse_path("myalias/bucket/dir/").unwrap();
262        let remote = path.as_remote().unwrap();
263        assert_eq!(remote.key, "dir/");
264        assert!(remote.is_dir);
265    }
266
267    #[test]
268    fn test_parse_remote_path_bucket_only() {
269        let path = parse_path("myalias/bucket").unwrap();
270        let remote = path.as_remote().unwrap();
271        assert_eq!(remote.alias, "myalias");
272        assert_eq!(remote.bucket, "bucket");
273        assert_eq!(remote.key, "");
274        assert!(remote.is_dir);
275    }
276
277    #[test]
278    fn test_parse_object_path_requires_non_empty_key() {
279        assert!(parse_object_path("myalias/bucket").is_err());
280        assert!(parse_object_path("myalias/bucket/dir/").is_err());
281        let r = parse_object_path("myalias/bucket/key.txt").unwrap();
282        assert_eq!(r.key, "key.txt");
283    }
284
285    #[test]
286    fn test_parse_local_absolute_path() {
287        let path = parse_path("/home/user/file.txt").unwrap();
288        assert!(path.is_local());
289        assert_eq!(
290            path.as_local().unwrap().to_str().unwrap(),
291            "/home/user/file.txt"
292        );
293    }
294
295    #[test]
296    fn test_parse_local_relative_path() {
297        let path = parse_path("./file.txt").unwrap();
298        assert!(path.is_local());
299
300        let path = parse_path("../file.txt").unwrap();
301        assert!(path.is_local());
302    }
303
304    #[test]
305    fn test_parse_empty_path() {
306        let result = parse_path("");
307        assert!(result.is_err());
308    }
309
310    #[test]
311    fn test_parse_alias_only() {
312        let result = parse_path("myalias");
313        assert!(result.is_err());
314    }
315
316    #[test]
317    fn test_remote_path_parent() {
318        let path = RemotePath::new("myalias", "bucket", "a/b/c.txt");
319        let parent = path.parent().unwrap();
320        assert_eq!(parent.key, "a/b/");
321
322        let parent = parent.parent().unwrap();
323        assert_eq!(parent.key, "a/");
324
325        let parent = parent.parent().unwrap();
326        assert_eq!(parent.key, "");
327
328        assert!(parent.parent().is_none());
329    }
330
331    #[test]
332    fn test_remote_path_join() {
333        let path = RemotePath::new("myalias", "bucket", "");
334        let child = path.join("dir/");
335        assert_eq!(child.key, "dir/");
336        assert!(child.is_dir);
337
338        let file = child.join("file.txt");
339        assert_eq!(file.key, "dir/file.txt");
340        assert!(!file.is_dir);
341    }
342
343    #[test]
344    fn test_remote_path_display() {
345        let path = RemotePath::new("myalias", "bucket", "key/file.txt");
346        assert_eq!(path.to_string(), "myalias/bucket/key/file.txt");
347    }
348
349    #[test]
350    fn test_local_path_with_dots() {
351        // Files like "file.txt" in current directory should be local
352        let path = parse_path("some.file.txt");
353        assert!(path.is_ok());
354        assert!(path.unwrap().is_local());
355    }
356}