Skip to main content

sharepoint_cli/
reference.rs

1//! Reference parsing: turns whatever the user types into a structured
2//! `ParsedRef { site, library, path }` without touching the network.
3//!
4//! Five input forms (tried in order):
5//!   1. Full SharePoint URL (incl. `Forms/AllItems.aspx?id=...`)
6//!   2. `Site:Library/path`
7//!   3. `:Library/path` (uses default_site from profile)
8//!   4. Bare `Library/path` (only when default_site is set; resolved later)
9//!   5. `spo://<site>/<library>/<path>`
10//!
11//! The parser is intentionally network-free; site/library lookup happens in
12//! `graph::sites` / `graph::drives` after this returns.
13
14use crate::error::{CliError, Result};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum SiteRef {
18    /// `https://contoso.sharepoint.com/sites/Marketing` or OneDrive `*-my.sharepoint.com/personal/...`
19    Url(String),
20    /// Display name or alias (e.g. `Marketing`); resolved against profile aliases or Graph search.
21    Name(String),
22    /// Use the `default_site` from config.
23    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    // Lightweight percent-decoder — handles `%20` etc. without pulling in another dep.
35    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    // Browser copy: `.../sites/<name>/<Library>/Forms/AllItems.aspx?id=<encoded path>`.
94    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        // Url::query_pairs() already percent-decodes the value once. Decoding
102        // again would corrupt user-typed literal percent sequences.
103        let decoded = value.to_string();
104        // `decoded` looks like `/sites/Marketing/Shared Documents/2025/Q4/plan.pptx`.
105        // Strip the `/sites/<name>/` prefix to find library + path.
106        let trimmed = decoded.trim_start_matches('/');
107        let mut parts = trimmed.splitn(3, '/');
108        let kind = parts.next().unwrap_or(""); // "sites" or "personal"
109        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    // Plain path form: `https://<host>/sites/<name>/<Library>/<path>`.
127    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
170/// Parse a reference string. `default_site_set` lets the parser distinguish
171/// the bare `Library/path` form from an unrelated identifier.
172pub 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    // `:Library/path` — default-site form
186    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    // `Site:Library/path` — note: must occur BEFORE `Library/path` so colons take priority.
201    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    // Bare `Library/path` — only when default_site is configured.
217    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        // `Url::query_pairs()` decodes exactly one layer of percent-encoding. When
334        // the source contains double-encoded sequences (e.g. SharePoint encodes a
335        // space as `%20`, then a transport layer encodes the `%` to `%25`,
336        // producing `%2520`), one decode pass yields `%20` — which is part of
337        // SharePoint's own path and must NOT be decoded again.
338        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}