use super::drives::DriveItem;
use super::{GraphClient, PagedResponse};
use crate::error::Result;
pub(crate) struct SearchResult {
pub(crate) items: Vec<DriveItem>,
pub(crate) next_url: Option<String>,
pub(crate) fetched_url: String,
}
pub(crate) async fn search(
graph: &GraphClient,
drive_id: &str,
query: &str,
page_url: Option<&str>,
) -> Result<SearchResult> {
let api = match page_url {
Some(url) => url.to_string(),
None => build_search_url(&format!("drives/{drive_id}"), query),
};
let absolute_url = graph.url(&api).await;
let page: PagedResponse<DriveItem> = graph.get_json(&absolute_url).await?;
Ok(SearchResult {
items: page.value,
next_url: page.next_link,
fetched_url: absolute_url,
})
}
pub(super) fn build_search_url(drive_path: &str, query: &str) -> String {
let odata_escaped = query.replace('\'', "''");
let encoded = url_encode_query(&odata_escaped);
format!("/{drive_path}/root/search(q='{encoded}')")
}
fn url_encode_query(input: &str) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(input.len());
for b in input.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => write!(out, "%{b:02X}").unwrap(),
}
}
out
}
pub(crate) fn glob_matches(pattern: &str, name: &str) -> bool {
let p = pattern.to_ascii_lowercase();
let n = name.to_ascii_lowercase();
glob_inner(p.as_bytes(), n.as_bytes())
}
fn glob_inner(pat: &[u8], s: &[u8]) -> bool {
let m = pat.len();
let n = s.len();
let mut dp = vec![vec![false; n + 1]; m + 1];
dp[0][0] = true;
for i in 1..=m {
if pat[i - 1] == b'*' {
dp[i][0] = dp[i - 1][0];
}
}
for i in 1..=m {
for j in 1..=n {
if pat[i - 1] == b'*' {
dp[i][j] = dp[i - 1][j] || dp[i][j - 1];
} else if pat[i - 1] == b'?' || pat[i - 1] == s[j - 1] {
dp[i][j] = dp[i - 1][j - 1];
}
}
}
dp[m][n]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn glob_basic_matches() {
assert!(glob_matches("*.pptx", "Q4-plan.pptx"));
assert!(glob_matches("Q?-*.xlsx", "Q4-summary.xlsx"));
assert!(!glob_matches("*.pdf", "report.docx"));
}
#[test]
fn glob_is_case_insensitive() {
assert!(glob_matches("*.PPTX", "plan.pptx"));
assert!(glob_matches("Plan.*", "PLAN.pptx"));
}
#[test]
fn glob_handles_empty_pattern() {
assert!(glob_matches("", ""));
assert!(!glob_matches("", "x"));
assert!(glob_matches("*", "anything"));
}
#[test]
fn build_search_url_percent_encodes_reserved_characters() {
let url = build_search_url("drives/D1", "foo & bar=baz#frag");
assert!(
!url.contains(" & "),
"spaces and & must be encoded; got {url}"
);
assert!(!url.contains("=baz"), "= must be encoded; got {url}");
assert!(!url.contains("#frag"), "# must be encoded; got {url}");
assert!(
url.contains("foo%20%26%20bar%3Dbaz%23frag"),
"expected encoded form; got {url}"
);
}
#[test]
fn build_search_url_odata_escapes_single_quotes() {
let url = build_search_url("drives/D1", "Bob's");
assert!(
url.contains("Bob%27%27s"),
"single quote must be OData-escaped and percent-encoded; got {url}"
);
}
#[test]
fn build_search_url_plain_alphanumeric_is_unchanged() {
let url = build_search_url("drives/D1", "quarterly-report_2025.xlsx");
assert!(
url.contains("quarterly-report_2025.xlsx"),
"unreserved chars must not be encoded; got {url}"
);
}
}