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/// Check if a string is a valid alias name
218fn 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        // Files like "file.txt" in current directory should be local
326        let path = parse_path("some.file.txt");
327        assert!(path.is_ok());
328        assert!(path.unwrap().is_local());
329    }
330}