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