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//!
6//! Note: only single-quote doubling (OData escaping) is applied to the query
7//! string. Characters like `&`, `#`, or non-ASCII are sent as-is.
8
9use base64::Engine as _;
10use base64::engine::general_purpose::URL_SAFE_NO_PAD;
11
12use super::drives::DriveItem;
13use super::{GraphClient, PagedResponse};
14use crate::error::{CliError, Result};
15
16pub struct SearchResult {
17    pub items: Vec<DriveItem>,
18    pub next: Option<String>,
19}
20
21pub async fn search(
22    graph: &GraphClient,
23    drive_id: &str,
24    query: &str,
25    page_token: Option<&str>,
26) -> Result<SearchResult> {
27    let api = match page_token {
28        Some(t) => decode_page_token(t)?,
29        None => {
30            // Graph spec: search(q='<query>'). Single-quote escaping: double the quote.
31            let escaped = query.replace('\'', "''");
32            format!("/drives/{drive_id}/root/search(q='{escaped}')")
33        }
34    };
35    let page: PagedResponse<DriveItem> = graph.get_json(&api).await?;
36    Ok(SearchResult {
37        items: page.value,
38        next: page.next_link.as_deref().map(encode_page_token),
39    })
40}
41
42/// Shell-style glob match. `*` matches any run of characters, `?` matches one.
43/// Case-insensitive.
44pub fn glob_matches(pattern: &str, name: &str) -> bool {
45    let p = pattern.to_ascii_lowercase();
46    let n = name.to_ascii_lowercase();
47    glob_inner(p.as_bytes(), n.as_bytes())
48}
49
50fn glob_inner(pat: &[u8], s: &[u8]) -> bool {
51    // Iterative DP avoids stack overflow on long patterns.
52    let m = pat.len();
53    let n = s.len();
54    let mut dp = vec![vec![false; n + 1]; m + 1];
55    dp[0][0] = true;
56    for i in 1..=m {
57        if pat[i - 1] == b'*' {
58            dp[i][0] = dp[i - 1][0];
59        }
60    }
61    for i in 1..=m {
62        for j in 1..=n {
63            if pat[i - 1] == b'*' {
64                dp[i][j] = dp[i - 1][j] || dp[i][j - 1];
65            } else if pat[i - 1] == b'?' || pat[i - 1] == s[j - 1] {
66                dp[i][j] = dp[i - 1][j - 1];
67            }
68        }
69    }
70    dp[m][n]
71}
72
73fn encode_page_token(next_link: &str) -> String {
74    URL_SAFE_NO_PAD.encode(next_link.as_bytes())
75}
76
77fn decode_page_token(token: &str) -> Result<String> {
78    let bytes = URL_SAFE_NO_PAD
79        .decode(token.as_bytes())
80        .map_err(|e| CliError::Input(format!("invalid --page token: {e}")))?;
81    String::from_utf8(bytes).map_err(|e| CliError::Input(format!("invalid --page token: {e}")))
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn glob_basic_matches() {
90        assert!(glob_matches("*.pptx", "Q4-plan.pptx"));
91        assert!(glob_matches("Q?-*.xlsx", "Q4-summary.xlsx"));
92        assert!(!glob_matches("*.pdf", "report.docx"));
93    }
94
95    #[test]
96    fn glob_is_case_insensitive() {
97        assert!(glob_matches("*.PPTX", "plan.pptx"));
98        assert!(glob_matches("Plan.*", "PLAN.pptx"));
99    }
100
101    #[test]
102    fn glob_handles_empty_pattern() {
103        assert!(glob_matches("", ""));
104        assert!(!glob_matches("", "x"));
105        assert!(glob_matches("*", "anything"));
106    }
107}