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
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    // Lightweight percent-decoder — handles `%20` etc. without pulling in another dep.
41    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    // Browser copy: `.../sites/<name>/<Library>/Forms/AllItems.aspx?id=<encoded path>`.
100    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        // `decoded` looks like `/sites/Marketing/Shared Documents/2025/Q4/plan.pptx`.
109        // Strip the `/sites/<name>/` prefix to find library + path.
110        let trimmed = decoded.trim_start_matches('/');
111        let mut parts = trimmed.splitn(3, '/');
112        let kind = parts.next().unwrap_or(""); // "sites" or "personal"
113        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    // Plain path form: `https://<host>/sites/<name>/<Library>/<path>`.
131    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
174/// Parse a reference string. `default_site_set` lets the parser distinguish
175/// the bare `Library/path` form from an unrelated identifier.
176pub 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    // `:Library/path` — default-site form
190    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    // `Site:Library/path` — note: must occur BEFORE `Library/path` so colons take priority.
205    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    // Bare `Library/path` — only when default_site is configured.
221    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        // We don't decode here — only the URL form does that. Bare paths are taken literally.
334        assert_eq!(r.path, "/Hello%20World.txt");
335    }
336}