mars_agents/platform/
path_syntax.rs1use std::path::PathBuf;
6
7pub fn classify_local_source(input: &str) -> Option<PathBuf> {
29 if input.is_empty() {
31 return None;
32 }
33
34 if input.contains("://") {
36 return None;
37 }
38
39 if is_ssh_shorthand(input) {
41 return None;
42 }
43
44 if input.starts_with("github:") || input.starts_with("gitlab:") {
46 return None;
47 }
48
49 if input.starts_with('/') {
51 return Some(PathBuf::from(input));
52 }
53
54 if input == "." || input == ".." || input.starts_with("./") || input.starts_with("../") {
56 return Some(PathBuf::from(input));
57 }
58
59 if input.starts_with('~') {
61 return Some(PathBuf::from(input));
62 }
63
64 if is_windows_drive_path(input) {
66 return Some(PathBuf::from(input));
67 }
68
69 if input.starts_with("\\\\") {
71 if input.starts_with("\\\\?\\") || input.starts_with("\\\\.\\") {
73 return None;
74 }
75 return Some(PathBuf::from(input));
76 }
77
78 if input.starts_with('\\') {
80 return Some(PathBuf::from(input));
81 }
82
83 if input.starts_with(".\\") || input.starts_with("..\\") {
85 return Some(PathBuf::from(input));
86 }
87
88 if input.contains('\\') && !input.contains('/') {
90 return Some(PathBuf::from(input));
91 }
92
93 if input.contains('/') && !input.contains('\\') && !input.contains('.') {
95 return None;
96 }
97
98 None
99}
100
101fn 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
107fn 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}