sharepoint_cli/graph/
search.rs1use super::drives::DriveItem;
7use super::{GraphClient, PagedResponse};
8use crate::error::Result;
9
10pub(crate) struct SearchResult {
11 pub(crate) items: Vec<DriveItem>,
12 pub(crate) next_url: Option<String>,
14 pub(crate) fetched_url: String,
17}
18
19pub(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 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
42pub(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
54fn 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
70pub(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 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 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 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}