Skip to main content

sharepoint_cli/graph/
search.rs

1//! Drive-scoped search via Graph's `/drives/{id}/root/search(q='…')`.
2//!
3//! The `find` command uses this and adds client-side glob filtering on top
4//! when `--name <glob>` is given.
5
6use super::drives::DriveItem;
7use super::{GraphClient, PagedResponse};
8use crate::error::Result;
9
10pub(crate) struct SearchResult {
11    pub(crate) items: Vec<DriveItem>,
12    /// The raw `@odata.nextLink` URL returned by Graph, if there are more results.
13    pub(crate) next_url: Option<String>,
14    /// The URL that was actually fetched. Used by callers that need to encode a
15    /// mid-page cursor.
16    pub(crate) fetched_url: String,
17}
18
19/// Execute a drive search. `page_url` is the full Graph URL to fetch;
20/// when `None` the default search URL is constructed from `drive_id` and `query`.
21pub(crate) async fn search(
22    graph: &GraphClient,
23    drive_id: &str,
24    query: &str,
25    page_url: Option<&str>,
26) -> Result<SearchResult> {
27    let api = match page_url {
28        Some(url) => url.to_string(),
29        None => build_search_url(&format!("drives/{drive_id}"), query),
30    };
31    // Resolve to a full absolute URL so the cursor stored in `fetched_url`
32    // is always a complete URL (required by the host-validation check on decode).
33    let absolute_url = graph.url(&api).await;
34    let page: PagedResponse<DriveItem> = graph.get_json(&absolute_url).await?;
35    Ok(SearchResult {
36        items: page.value,
37        next_url: page.next_link,
38        fetched_url: absolute_url,
39    })
40}
41
42/// Build the Graph API path for a drive search request.
43///
44/// The `drive_path` is a bare path prefix such as `"drives/{id}"`.
45/// OData single-quote escaping is applied first (doubling `'` to `''`),
46/// then the result is percent-encoded so reserved URL characters like
47/// `&`, `#`, `=`, and space are never interpolated raw into the URL.
48pub(super) fn build_search_url(drive_path: &str, query: &str) -> String {
49    let odata_escaped = query.replace('\'', "''");
50    let encoded = url_encode_query(&odata_escaped);
51    format!("/{drive_path}/root/search(q='{encoded}')")
52}
53
54/// Percent-encode a string using the RFC 3986 unreserved character set,
55/// leaving only `A-Z a-z 0-9 - _ . ~` unencoded.
56fn url_encode_query(input: &str) -> String {
57    use std::fmt::Write as _;
58    let mut out = String::with_capacity(input.len());
59    for b in input.bytes() {
60        match b {
61            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
62                out.push(b as char)
63            }
64            _ => write!(out, "%{b:02X}").unwrap(),
65        }
66    }
67    out
68}
69
70/// Shell-style glob match. `*` matches any run of characters, `?` matches one.
71/// Case-insensitive.
72pub(crate) fn glob_matches(pattern: &str, name: &str) -> bool {
73    let p = pattern.to_ascii_lowercase();
74    let n = name.to_ascii_lowercase();
75    glob_inner(p.as_bytes(), n.as_bytes())
76}
77
78fn glob_inner(pat: &[u8], s: &[u8]) -> bool {
79    // Iterative DP avoids stack overflow on long patterns.
80    let m = pat.len();
81    let n = s.len();
82    let mut dp = vec![vec![false; n + 1]; m + 1];
83    dp[0][0] = true;
84    for i in 1..=m {
85        if pat[i - 1] == b'*' {
86            dp[i][0] = dp[i - 1][0];
87        }
88    }
89    for i in 1..=m {
90        for j in 1..=n {
91            if pat[i - 1] == b'*' {
92                dp[i][j] = dp[i - 1][j] || dp[i][j - 1];
93            } else if pat[i - 1] == b'?' || pat[i - 1] == s[j - 1] {
94                dp[i][j] = dp[i - 1][j - 1];
95            }
96        }
97    }
98    dp[m][n]
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn glob_basic_matches() {
107        assert!(glob_matches("*.pptx", "Q4-plan.pptx"));
108        assert!(glob_matches("Q?-*.xlsx", "Q4-summary.xlsx"));
109        assert!(!glob_matches("*.pdf", "report.docx"));
110    }
111
112    #[test]
113    fn glob_is_case_insensitive() {
114        assert!(glob_matches("*.PPTX", "plan.pptx"));
115        assert!(glob_matches("Plan.*", "PLAN.pptx"));
116    }
117
118    #[test]
119    fn glob_handles_empty_pattern() {
120        assert!(glob_matches("", ""));
121        assert!(!glob_matches("", "x"));
122        assert!(glob_matches("*", "anything"));
123    }
124
125    #[test]
126    fn build_search_url_percent_encodes_reserved_characters() {
127        let url = build_search_url("drives/D1", "foo & bar=baz#frag");
128        // Reserved characters must be percent-encoded.
129        assert!(
130            !url.contains(" & "),
131            "spaces and & must be encoded; got {url}"
132        );
133        assert!(!url.contains("=baz"), "= must be encoded; got {url}");
134        assert!(!url.contains("#frag"), "# must be encoded; got {url}");
135        assert!(
136            url.contains("foo%20%26%20bar%3Dbaz%23frag"),
137            "expected encoded form; got {url}"
138        );
139    }
140
141    #[test]
142    fn build_search_url_odata_escapes_single_quotes() {
143        let url = build_search_url("drives/D1", "Bob's");
144        // OData: single quote doubled, then the doubled single quote is percent-encoded.
145        // ' becomes '' in OData, and '' becomes %27%27 in URL encoding.
146        assert!(
147            url.contains("Bob%27%27s"),
148            "single quote must be OData-escaped and percent-encoded; got {url}"
149        );
150    }
151
152    #[test]
153    fn build_search_url_plain_alphanumeric_is_unchanged() {
154        let url = build_search_url("drives/D1", "quarterly-report_2025.xlsx");
155        assert!(
156            url.contains("quarterly-report_2025.xlsx"),
157            "unreserved chars must not be encoded; got {url}"
158        );
159    }
160}