1use crate::error::{CliError, Result};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum SiteRef {
18 Url(String),
20 Name(String),
22 Default,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ParsedRef {
28 pub site: SiteRef,
29 pub library: Option<String>,
30 pub path: String,
31}
32
33fn percent_decode(input: &str) -> String {
34 let bytes = input.as_bytes();
36 let mut out = Vec::with_capacity(bytes.len());
37 let mut i = 0;
38 while i < bytes.len() {
39 if bytes[i] == b'%'
40 && i + 2 < bytes.len()
41 && let (Some(h), Some(l)) = (hex_nibble(bytes[i + 1]), hex_nibble(bytes[i + 2]))
42 {
43 out.push((h << 4) | l);
44 i += 3;
45 continue;
46 }
47 out.push(bytes[i]);
48 i += 1;
49 }
50 String::from_utf8_lossy(&out).into_owned()
51}
52
53fn hex_nibble(b: u8) -> Option<u8> {
54 match b {
55 b'0'..=b'9' => Some(b - b'0'),
56 b'a'..=b'f' => Some(b - b'a' + 10),
57 b'A'..=b'F' => Some(b - b'A' + 10),
58 _ => None,
59 }
60}
61
62fn split_path_first(s: &str) -> (String, String) {
63 let trimmed = s.trim_start_matches('/');
64 match trimmed.split_once('/') {
65 Some((a, b)) => (a.to_string(), b.to_string()),
66 None => (trimmed.to_string(), String::new()),
67 }
68}
69
70fn normalize_path(p: &str) -> String {
71 let mut out = p.trim_matches('/').to_string();
72 if out.is_empty() {
73 return String::new();
74 }
75 out.insert(0, '/');
76 out
77}
78
79fn parse_url(raw: &str) -> Result<ParsedRef> {
80 let parsed =
81 url::Url::parse(raw).map_err(|e| CliError::Input(format!("invalid URL '{raw}': {e}")))?;
82
83 let host = parsed
84 .host_str()
85 .ok_or_else(|| CliError::Input(format!("URL missing host: {raw}")))?;
86
87 if !host.ends_with(".sharepoint.com") {
88 return Err(CliError::Input(format!(
89 "host '{host}' is not a SharePoint Online host (must end with .sharepoint.com)"
90 )));
91 }
92
93 if let Some(("id", value)) = parsed
95 .query_pairs()
96 .find(|(k, _)| k == "id")
97 .map(|(k, v)| (k.into_owned(), v.into_owned()))
98 .as_ref()
99 .map(|(k, v)| (k.as_str(), v.as_str()))
100 {
101 let decoded = value.to_string();
104 let trimmed = decoded.trim_start_matches('/');
107 let mut parts = trimmed.splitn(3, '/');
108 let kind = parts.next().unwrap_or(""); let site_name = parts.next().unwrap_or("");
110 let rest = parts.next().unwrap_or("");
111 if (kind == "sites" || kind == "personal" || kind == "teams") && !site_name.is_empty() {
112 let site_url = format!("{}://{}/{}/{}", parsed.scheme(), host, kind, site_name);
113 let (library, path) = split_path_first(rest);
114 return Ok(ParsedRef {
115 site: SiteRef::Url(site_url),
116 library: if library.is_empty() {
117 None
118 } else {
119 Some(library)
120 },
121 path: normalize_path(&path),
122 });
123 }
124 }
125
126 let path = percent_decode(parsed.path());
128 let trimmed = path.trim_start_matches('/');
129 let mut parts = trimmed.splitn(3, '/');
130 let kind = parts.next().unwrap_or("");
131 let site_name = parts.next().unwrap_or("");
132 let rest = parts.next().unwrap_or("");
133 if (kind == "sites" || kind == "personal" || kind == "teams") && !site_name.is_empty() {
134 let site_url = format!("{}://{}/{}/{}", parsed.scheme(), host, kind, site_name);
135 let (library, sub) = split_path_first(rest);
136 return Ok(ParsedRef {
137 site: SiteRef::Url(site_url),
138 library: if library.is_empty() {
139 None
140 } else {
141 Some(library)
142 },
143 path: normalize_path(&sub),
144 });
145 }
146
147 Err(CliError::Input(format!(
148 "URL does not look like a SharePoint site reference: {raw}"
149 )))
150}
151
152fn parse_spo_uri(raw: &str) -> Result<ParsedRef> {
153 let stripped = raw
154 .strip_prefix("spo://")
155 .ok_or_else(|| CliError::Input(format!("not an spo:// URI: {raw}")))?;
156 let mut parts = stripped.splitn(3, '/');
157 let site = parts
158 .next()
159 .filter(|s| !s.is_empty())
160 .ok_or_else(|| CliError::Input(format!("spo:// missing site segment: {raw}")))?;
161 let library = parts.next().filter(|s| !s.is_empty()).map(str::to_string);
162 let path = parts.next().unwrap_or("");
163 Ok(ParsedRef {
164 site: SiteRef::Name(percent_decode(site)),
165 library: library.map(|s| percent_decode(&s)),
166 path: normalize_path(&percent_decode(path)),
167 })
168}
169
170pub fn parse(raw: &str, default_site_set: bool) -> Result<ParsedRef> {
173 let raw = raw.trim();
174 if raw.is_empty() {
175 return Err(CliError::Input("empty reference".into()));
176 }
177
178 if raw.starts_with("spo://") {
179 return parse_spo_uri(raw);
180 }
181 if raw.starts_with("http://") || raw.starts_with("https://") {
182 return parse_url(raw);
183 }
184
185 if let Some(rest) = raw.strip_prefix(':') {
187 let (library, path) = split_path_first(rest);
188 if library.is_empty() {
189 return Err(CliError::Input(
190 "':Library/path' form needs a library name after the colon".into(),
191 ));
192 }
193 return Ok(ParsedRef {
194 site: SiteRef::Default,
195 library: Some(percent_decode(&library)),
196 path: normalize_path(&percent_decode(&path)),
197 });
198 }
199
200 if let Some((site, rest)) = raw.split_once(':')
202 && !site.is_empty()
203 && !rest.is_empty()
204 {
205 let (library, path) = split_path_first(rest);
206 if library.is_empty() {
207 return Err(CliError::Input(format!("'{raw}' has no library after ':'")));
208 }
209 return Ok(ParsedRef {
210 site: SiteRef::Name(percent_decode(site)),
211 library: Some(percent_decode(&library)),
212 path: normalize_path(&percent_decode(&path)),
213 });
214 }
215
216 if default_site_set {
218 let (library, path) = split_path_first(raw);
219 if library.is_empty() {
220 return Err(CliError::Input(format!("could not parse reference: {raw}")));
221 }
222 return Ok(ParsedRef {
223 site: SiteRef::Default,
224 library: Some(percent_decode(&library)),
225 path: normalize_path(&percent_decode(&path)),
226 });
227 }
228
229 Err(CliError::Input(format!(
230 "ambiguous reference '{raw}': set a default_site or use 'Site:Library/path'"
231 )))
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn parses_full_https_url() {
240 let r = parse(
241 "https://contoso.sharepoint.com/sites/Marketing/Shared Documents/2025/Q4.pptx",
242 false,
243 )
244 .unwrap();
245 assert!(matches!(r.site, SiteRef::Url(_)));
246 assert_eq!(r.library.as_deref(), Some("Shared Documents"));
247 assert_eq!(r.path, "/2025/Q4.pptx");
248 }
249
250 #[test]
251 fn parses_browser_form_with_id_query() {
252 let r = parse(
253 "https://contoso.sharepoint.com/sites/Marketing/Shared%20Documents/Forms/AllItems.aspx?id=%2Fsites%2FMarketing%2FShared%20Documents%2F2025%2FQ4%2Eplan%2Epptx&p=1",
254 false,
255 )
256 .unwrap();
257 assert_eq!(r.library.as_deref(), Some("Shared Documents"));
258 assert_eq!(r.path, "/2025/Q4.plan.pptx");
259 }
260
261 #[test]
262 fn parses_onedrive_personal_url() {
263 let r = parse(
264 "https://contoso-my.sharepoint.com/personal/alice_contoso_com/Documents/file.txt",
265 false,
266 )
267 .unwrap();
268 match r.site {
269 SiteRef::Url(u) => assert!(u.contains("/personal/alice_contoso_com")),
270 _ => panic!("expected URL site"),
271 }
272 assert_eq!(r.library.as_deref(), Some("Documents"));
273 }
274
275 #[test]
276 fn rejects_non_sharepoint_url() {
277 assert!(parse("https://example.com/x", false).is_err());
278 }
279
280 #[test]
281 fn parses_site_colon_library_path_form() {
282 let r = parse("Marketing:Shared Documents/2025/plan.pptx", false).unwrap();
283 assert_eq!(r.site, SiteRef::Name("Marketing".into()));
284 assert_eq!(r.library.as_deref(), Some("Shared Documents"));
285 assert_eq!(r.path, "/2025/plan.pptx");
286 }
287
288 #[test]
289 fn parses_default_site_colon_form() {
290 let r = parse(":Documents/file.txt", false).unwrap();
291 assert_eq!(r.site, SiteRef::Default);
292 assert_eq!(r.library.as_deref(), Some("Documents"));
293 assert_eq!(r.path, "/file.txt");
294 }
295
296 #[test]
297 fn bare_library_path_requires_default_site() {
298 assert!(parse("Documents/file.txt", false).is_err());
299 let r = parse("Documents/file.txt", true).unwrap();
300 assert_eq!(r.site, SiteRef::Default);
301 assert_eq!(r.library.as_deref(), Some("Documents"));
302 assert_eq!(r.path, "/file.txt");
303 }
304
305 #[test]
306 fn parses_spo_uri() {
307 let r = parse("spo://Marketing/Shared%20Documents/2025/plan.pptx", false).unwrap();
308 assert_eq!(r.site, SiteRef::Name("Marketing".into()));
309 assert_eq!(r.library.as_deref(), Some("Shared Documents"));
310 assert_eq!(r.path, "/2025/plan.pptx");
311 }
312
313 #[test]
314 fn empty_reference_errors() {
315 assert!(parse("", false).is_err());
316 assert!(parse(" ", false).is_err());
317 }
318
319 #[test]
320 fn library_only_path_normalizes_to_root() {
321 let r = parse(":Documents", false).unwrap();
322 assert_eq!(r.path, "");
323 }
324
325 #[test]
326 fn percent_escapes_in_colon_form_path_are_decoded() {
327 let r = parse(":Documents/Hello%20World.txt", false).unwrap();
328 assert_eq!(r.path, "/Hello World.txt");
329 }
330
331 #[test]
332 fn parses_id_query_with_literal_percent() {
333 let r = parse(
339 "https://contoso.sharepoint.com/sites/Marketing/Forms/AllItems.aspx?id=%2Fsites%2FMarketing%2FShared%2520Documents%2Ffile%2520name.pptx",
340 false,
341 )
342 .unwrap();
343 assert_eq!(
344 r.site,
345 SiteRef::Url("https://contoso.sharepoint.com/sites/Marketing".into())
346 );
347 assert_eq!(r.library.as_deref(), Some("Shared%20Documents"));
348 assert_eq!(r.path, "/file%20name.pptx");
349 }
350
351 #[test]
352 fn site_colon_library_decodes_percent_escapes() {
353 let r = parse("My%20Site:My%20Library/foo.txt", false).unwrap();
354 assert_eq!(r.site, SiteRef::Name("My Site".into()));
355 assert_eq!(r.library.as_deref(), Some("My Library"));
356 assert_eq!(r.path, "/foo.txt");
357 }
358
359 #[test]
360 fn default_site_colon_form_decodes_percent_escapes() {
361 let r = parse(":My%20Library/foo%20bar.txt", false).unwrap();
362 assert_eq!(r.site, SiteRef::Default);
363 assert_eq!(r.library.as_deref(), Some("My Library"));
364 assert_eq!(r.path, "/foo bar.txt");
365 }
366
367 #[test]
368 fn bare_library_path_decodes_percent_escapes() {
369 let r = parse("My%20Library/foo%20bar.txt", true).unwrap();
370 assert_eq!(r.site, SiteRef::Default);
371 assert_eq!(r.library.as_deref(), Some("My Library"));
372 assert_eq!(r.path, "/foo bar.txt");
373 }
374}