Skip to main content

mars_agents/platform/
path_syntax.rs

1//! Source string syntax classification.
2//!
3//! Determines whether a CLI/config source string is a local path or a source identifier.
4
5use std::path::PathBuf;
6
7/// Classify a source string as a local path if it matches local path syntax.
8///
9/// Accepted POSIX forms:
10/// - `/absolute`
11/// - `./relative`, `../relative`
12/// - `~/home-relative`
13/// - `.` and `..`
14///
15/// Accepted Windows forms:
16/// - Drive paths: `C:\path`, `C:/path`, `C:relative`
17/// - UNC paths: `\\server\share\path`
18/// - Root-relative: `\path`
19/// - Relative backslash: `.\path`, `..\path`, `foo\bar`
20///
21/// Returns `Some(PathBuf)` if the input is a local path, `None` otherwise.
22///
23/// NOT classified as local paths (URL-like or shorthand):
24/// - Contains `://` (URL scheme)
25/// - SSH shorthand: `git@host:path`
26/// - GitHub/GitLab shorthand: `owner/repo` (no backslash, has forward slash)
27/// - Protocol aliases: `github:`, `gitlab:`
28pub fn classify_local_source(input: &str) -> Option<PathBuf> {
29    // Empty input is not a local path.
30    if input.is_empty() {
31        return None;
32    }
33
34    // URL-like inputs are never local paths.
35    if input.contains("://") {
36        return None;
37    }
38
39    // SSH shorthand (git@host:path) is not a local path.
40    if is_ssh_shorthand(input) {
41        return None;
42    }
43
44    // Protocol aliases are not local paths.
45    if input.starts_with("github:") || input.starts_with("gitlab:") {
46        return None;
47    }
48
49    // POSIX absolute path.
50    if input.starts_with('/') {
51        return Some(PathBuf::from(input));
52    }
53
54    // POSIX relative (current dir, parent dir).
55    if input == "." || input == ".." || input.starts_with("./") || input.starts_with("../") {
56        return Some(PathBuf::from(input));
57    }
58
59    // Home directory expansion.
60    if input.starts_with('~') {
61        return Some(PathBuf::from(input));
62    }
63
64    // Windows drive path: C:\, C:/, C:relative.
65    if is_windows_drive_path(input) {
66        return Some(PathBuf::from(input));
67    }
68
69    // Windows UNC path: \\server\share.
70    if input.starts_with("\\\\") {
71        // Reject extended/device paths.
72        if input.starts_with("\\\\?\\") || input.starts_with("\\\\.\\") {
73            return None;
74        }
75        return Some(PathBuf::from(input));
76    }
77
78    // Windows root-relative: \path.
79    if input.starts_with('\\') {
80        return Some(PathBuf::from(input));
81    }
82
83    // Windows relative with backslash: .\path, ..\path.
84    if input.starts_with(".\\") || input.starts_with("..\\") {
85        return Some(PathBuf::from(input));
86    }
87
88    // Contains backslash but no slash - treat as Windows relative path.
89    if input.contains('\\') && !input.contains('/') {
90        return Some(PathBuf::from(input));
91    }
92
93    // Forward-slash-only owner/repo-style shorthand is not a local path.
94    if input.contains('/') && !input.contains('\\') && !input.contains('.') {
95        return None;
96    }
97
98    None
99}
100
101/// Check if input is a Windows drive path (C:\, C:/, C:relative).
102fn is_windows_drive_path(input: &str) -> bool {
103    let bytes = input.as_bytes();
104    bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
105}
106
107/// Check if input is SSH shorthand (git@host:path).
108fn is_ssh_shorthand(input: &str) -> bool {
109    if !input.contains('@') || !input.contains(':') {
110        return false;
111    }
112
113    match (input.find('@'), input.find(':')) {
114        (Some(at), Some(colon)) => at < colon && colon + 1 < input.len(),
115        _ => false,
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::classify_local_source;
122
123    #[test]
124    fn classify_posix_absolute() {
125        assert!(classify_local_source("/absolute/path").is_some());
126        assert!(classify_local_source("/").is_some());
127    }
128
129    #[test]
130    fn classify_posix_relative() {
131        assert!(classify_local_source(".").is_some());
132        assert!(classify_local_source("..").is_some());
133        assert!(classify_local_source("./relative").is_some());
134        assert!(classify_local_source("../parent").is_some());
135    }
136
137    #[test]
138    fn classify_home_relative() {
139        assert!(classify_local_source("~/path").is_some());
140        assert!(classify_local_source("~").is_some());
141    }
142
143    #[test]
144    fn classify_windows_drive_paths() {
145        assert!(classify_local_source("C:\\path").is_some());
146        assert!(classify_local_source("C:/path").is_some());
147        assert!(classify_local_source("D:\\").is_some());
148        assert!(classify_local_source("C:relative").is_some());
149    }
150
151    #[test]
152    fn classify_windows_unc_paths() {
153        assert!(classify_local_source("\\\\server\\share").is_some());
154        assert!(classify_local_source("\\\\server\\share\\path").is_some());
155    }
156
157    #[test]
158    fn classify_windows_root_relative() {
159        assert!(classify_local_source("\\path").is_some());
160    }
161
162    #[test]
163    fn classify_windows_backslash_relative() {
164        assert!(classify_local_source(".\\relative").is_some());
165        assert!(classify_local_source("..\\parent").is_some());
166        assert!(classify_local_source("foo\\bar").is_some());
167    }
168
169    #[test]
170    fn classify_rejects_extended_paths() {
171        assert!(classify_local_source("\\\\?\\C:\\path").is_none());
172        assert!(classify_local_source("\\\\.\\Device").is_none());
173    }
174
175    #[test]
176    fn classify_rejects_urls() {
177        assert!(classify_local_source("https://github.com/owner/repo").is_none());
178        assert!(classify_local_source("git://host/path").is_none());
179        assert!(classify_local_source("ssh://git@host/path").is_none());
180    }
181
182    #[test]
183    fn classify_rejects_ssh_shorthand() {
184        assert!(classify_local_source("git@github.com:owner/repo").is_none());
185        assert!(classify_local_source("git@host:path").is_none());
186    }
187
188    #[test]
189    fn classify_rejects_protocol_aliases() {
190        assert!(classify_local_source("github:owner/repo").is_none());
191        assert!(classify_local_source("gitlab:group/repo").is_none());
192    }
193
194    #[test]
195    fn classify_rejects_shorthand() {
196        assert!(classify_local_source("owner/repo").is_none());
197        assert!(classify_local_source("owner/repo/subpath").is_none());
198    }
199}