1use std::fmt::Write as _;
4
5use base64::Engine as _;
6use base64::engine::general_purpose::URL_SAFE_NO_PAD;
7use serde::Deserialize;
8
9use super::{GraphClient, PagedResponse};
10use crate::error::{CliError, Result};
11
12#[derive(Debug, Clone, Deserialize)]
13pub struct Drive {
14 pub id: String,
15 pub name: String,
16 #[serde(rename = "driveType", default)]
17 pub drive_type: String,
18 #[serde(rename = "webUrl", default)]
19 pub web_url: String,
20}
21
22#[derive(Debug, Clone, Deserialize)]
23pub struct DriveItem {
24 pub id: String,
25 pub name: String,
26 #[serde(default)]
27 pub size: u64,
28 #[serde(rename = "eTag", default)]
29 pub etag: Option<String>,
30 #[serde(rename = "webUrl", default)]
31 pub web_url: Option<String>,
32 #[serde(rename = "createdDateTime", default)]
33 pub created: Option<String>,
34 #[serde(rename = "lastModifiedDateTime", default)]
35 pub modified: Option<String>,
36 #[serde(rename = "parentReference", default)]
37 pub parent_reference: Option<ParentReference>,
38 #[serde(default)]
39 pub folder: Option<Folder>,
40 #[serde(default)]
41 pub file: Option<File>,
42 #[serde(rename = "@microsoft.graph.downloadUrl", default)]
46 pub download_url: Option<String>,
47}
48
49#[derive(Debug, Clone, Deserialize, Default)]
50pub struct ParentReference {
51 #[serde(rename = "driveId", default)]
52 pub drive_id: String,
53 #[serde(rename = "path", default)]
54 pub path: String,
55}
56
57#[derive(Debug, Clone, Deserialize)]
58pub struct Folder {
59 #[serde(rename = "childCount", default)]
60 pub child_count: u64,
61}
62
63#[derive(Debug, Clone, Deserialize, Default)]
64pub struct File {
65 #[serde(default)]
66 pub hashes: Hashes,
67}
68
69#[derive(Debug, Clone, Deserialize, Default)]
70pub struct Hashes {
71 #[serde(rename = "quickXorHash", default)]
72 pub quick_xor: Option<String>,
73 #[serde(rename = "sha1Hash", default)]
74 pub sha1: Option<String>,
75}
76
77pub async fn list_drives(graph: &GraphClient, site_id: &str) -> Result<Vec<Drive>> {
78 let path = format!("/sites/{site_id}/drives");
79 let page: PagedResponse<Drive> = graph.get_json(&path).await?;
80 Ok(page.value)
81}
82
83pub async fn find_drive_by_name(graph: &GraphClient, site_id: &str, name: &str) -> Result<Drive> {
84 let drives = list_drives(graph, site_id).await?;
85 let lower = name.to_ascii_lowercase();
86 drives
87 .iter()
88 .find(|d| d.name.to_ascii_lowercase() == lower)
89 .cloned()
90 .ok_or_else(|| {
91 let available = drives
92 .iter()
93 .map(|d| d.name.as_str())
94 .collect::<Vec<_>>()
95 .join(", ");
96 CliError::NotFound(format!(
97 "drive (library) '{name}' not found on this site. Available: {available}"
98 ))
99 })
100}
101
102pub async fn get_item(graph: &GraphClient, drive_id: &str, path: &str) -> Result<DriveItem> {
105 let api = if path.is_empty() || path == "/" {
106 format!("/drives/{drive_id}/root")
107 } else {
108 let trimmed = path.trim_start_matches('/');
109 let encoded = encode_path_segments(trimmed);
110 format!("/drives/{drive_id}/root:/{encoded}")
111 };
112 graph.get_json(&api).await
113}
114
115pub async fn get_item_with_download_url(
117 graph: &GraphClient,
118 drive_id: &str,
119 path: &str,
120) -> Result<DriveItem> {
121 let api_base = if path.is_empty() || path == "/" {
122 format!("/drives/{drive_id}/root")
123 } else {
124 let trimmed = path.trim_start_matches('/');
125 let encoded = encode_path_segments(trimmed);
126 format!("/drives/{drive_id}/root:/{encoded}")
127 };
128 let select = "id,name,size,eTag,webUrl,createdDateTime,lastModifiedDateTime,parentReference,folder,file,@microsoft.graph.downloadUrl";
129 let api = format!("{api_base}?$select={select}");
130 graph.get_json(&api).await
131}
132
133pub struct ListChildrenResult {
134 pub items: Vec<DriveItem>,
135 pub next: Option<String>,
136}
137
138pub async fn list_children(
139 graph: &GraphClient,
140 drive_id: &str,
141 path: &str,
142 page_token: Option<&str>,
143) -> Result<ListChildrenResult> {
144 let api = match page_token {
145 Some(t) => decode_page_token(t)?,
146 None => {
147 if path.is_empty() || path == "/" {
148 format!("/drives/{drive_id}/root/children")
149 } else {
150 let trimmed = path.trim_start_matches('/');
151 let encoded = encode_path_segments(trimmed);
152 format!("/drives/{drive_id}/root:/{encoded}:/children")
153 }
154 }
155 };
156 let page: PagedResponse<DriveItem> = graph.get_json(&api).await?;
157 Ok(ListChildrenResult {
158 items: page.value,
159 next: page.next_link.as_deref().map(encode_page_token),
160 })
161}
162
163pub async fn list_children_recursive(
164 graph: &GraphClient,
165 drive_id: &str,
166 path: &str,
167) -> Result<Vec<DriveItem>> {
168 let mut out = Vec::new();
169 let mut stack = vec![path.to_string()];
170 while let Some(p) = stack.pop() {
171 let mut next: Option<String> = None;
172 loop {
173 let page = list_children(graph, drive_id, &p, next.as_deref()).await?;
174 for item in page.items {
175 if item.folder.is_some() {
176 let child_path = item_path(&p, &item.name);
177 stack.push(child_path);
178 }
179 out.push(item);
180 }
181 next = page.next;
182 if next.is_none() {
183 break;
184 }
185 }
186 }
187 Ok(out)
188}
189
190fn item_path(parent: &str, name: &str) -> String {
191 if parent.is_empty() || parent == "/" {
192 format!("/{name}")
193 } else {
194 format!("{}/{name}", parent.trim_end_matches('/'))
195 }
196}
197
198pub fn canonical_json(
200 item: &DriveItem,
201 site: &super::sites::Site,
202 drive: &Drive,
203 include_download_url: bool,
204) -> serde_json::Value {
205 let kind = if item.folder.is_some() {
206 "folder"
207 } else {
208 "file"
209 };
210 let path = derive_full_path(item);
211
212 let mut hash = serde_json::Map::new();
213 if let Some(file) = &item.file {
214 if let Some(qx) = &file.hashes.quick_xor {
215 hash.insert("quickXor".into(), serde_json::Value::String(qx.clone()));
216 }
217 if let Some(s) = &file.hashes.sha1 {
218 hash.insert("sha1".into(), serde_json::Value::String(s.clone()));
219 }
220 }
221
222 let mut map = serde_json::Map::new();
223 map.insert("id".into(), serde_json::Value::String(item.id.clone()));
224 map.insert("name".into(), serde_json::Value::String(item.name.clone()));
225 map.insert("path".into(), serde_json::Value::String(path));
226 map.insert(
227 "site".into(),
228 serde_json::json!({
229 "id": site.id,
230 "name": site.display_name,
231 "url": site.web_url,
232 }),
233 );
234 map.insert(
235 "drive".into(),
236 serde_json::json!({
237 "id": drive.id,
238 "name": drive.name,
239 }),
240 );
241 map.insert("kind".into(), serde_json::Value::String(kind.into()));
242 map.insert("size".into(), serde_json::json!(item.size));
243 map.insert("etag".into(), serde_json::json!(item.etag));
244 map.insert("created".into(), serde_json::json!(item.created));
245 map.insert("modified".into(), serde_json::json!(item.modified));
246 map.insert("web_url".into(), serde_json::json!(item.web_url));
247
248 if !hash.is_empty() {
249 map.insert("hash".into(), serde_json::Value::Object(hash));
250 }
251 if include_download_url && let Some(u) = &item.download_url {
252 map.insert("download_url".into(), serde_json::Value::String(u.clone()));
253 }
254
255 serde_json::Value::Object(map)
256}
257
258fn derive_full_path(item: &DriveItem) -> String {
259 let parent = item
260 .parent_reference
261 .as_ref()
262 .map(|p| p.path.as_str())
263 .unwrap_or("");
264 let suffix = parent.split_once(":/").map(|(_, b)| b).unwrap_or("");
266 if suffix.is_empty() {
267 format!("/{}", item.name)
268 } else {
269 format!("/{}/{}", suffix, item.name)
270 }
271}
272
273pub(super) fn encode_path_segments(path: &str) -> String {
276 let mut out = String::with_capacity(path.len());
277 let mut first = true;
278 for seg in path.split('/') {
279 if !first {
280 out.push('/');
281 }
282 first = false;
283 for b in seg.bytes() {
284 match b {
285 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
286 out.push(b as char)
287 }
288 _ => write!(out, "%{b:02X}").unwrap(),
289 }
290 }
291 }
292 out
293}
294
295fn encode_page_token(next_link: &str) -> String {
296 URL_SAFE_NO_PAD.encode(next_link.as_bytes())
297}
298
299fn decode_page_token(token: &str) -> Result<String> {
300 let bytes = URL_SAFE_NO_PAD
301 .decode(token.as_bytes())
302 .map_err(|e| CliError::Input(format!("invalid --page token: {e}")))?;
303 String::from_utf8(bytes).map_err(|e| CliError::Input(format!("invalid --page token: {e}")))
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 fn fake_site() -> super::super::sites::Site {
311 super::super::sites::Site {
312 id: "S1".into(),
313 display_name: "Marketing".into(),
314 web_url: "https://contoso.sharepoint.com/sites/Marketing".into(),
315 url_segment: "Marketing".into(),
316 }
317 }
318
319 fn fake_drive() -> Drive {
320 Drive {
321 id: "D1".into(),
322 name: "Documents".into(),
323 drive_type: "documentLibrary".into(),
324 web_url: String::new(),
325 }
326 }
327
328 #[test]
329 fn canonical_includes_hash_when_file() {
330 let item = DriveItem {
331 id: "I1".into(),
332 name: "plan.pptx".into(),
333 size: 100,
334 etag: Some("\"abc\"".into()),
335 web_url: Some("https://example".into()),
336 created: Some("2025-01-01T00:00:00Z".into()),
337 modified: Some("2025-02-01T00:00:00Z".into()),
338 parent_reference: Some(ParentReference {
339 drive_id: "D1".into(),
340 path: "/drives/D1/root:/Folder".into(),
341 }),
342 folder: None,
343 file: Some(File {
344 hashes: Hashes {
345 quick_xor: Some("QX".into()),
346 sha1: Some("S1".into()),
347 },
348 }),
349 download_url: None,
350 };
351 let v = canonical_json(&item, &fake_site(), &fake_drive(), false);
352 assert_eq!(v["kind"], "file");
353 assert_eq!(v["hash"]["quickXor"], "QX");
354 assert_eq!(v["hash"]["sha1"], "S1");
355 assert_eq!(v["path"], "/Folder/plan.pptx");
356 assert!(v.get("download_url").is_none());
357 }
358
359 #[test]
360 fn canonical_includes_download_url_only_when_requested() {
361 let item = DriveItem {
362 id: "I1".into(),
363 name: "f".into(),
364 size: 0,
365 etag: None,
366 web_url: None,
367 created: None,
368 modified: None,
369 parent_reference: None,
370 folder: None,
371 file: Some(File::default()),
372 download_url: Some("https://short-lived".into()),
373 };
374 let with = canonical_json(&item, &fake_site(), &fake_drive(), true);
375 assert_eq!(with["download_url"], "https://short-lived");
376 let without = canonical_json(&item, &fake_site(), &fake_drive(), false);
377 assert!(without.get("download_url").is_none());
378 }
379
380 #[test]
381 fn item_path_handles_root() {
382 assert_eq!(item_path("", "x"), "/x");
383 assert_eq!(item_path("/", "x"), "/x");
384 assert_eq!(item_path("/A/B", "x"), "/A/B/x");
385 }
386
387 #[test]
388 fn encode_path_segments_handles_spaces_and_keeps_slashes() {
389 assert_eq!(
390 encode_path_segments("Marketing Plans/Q1 2025 Deck.pptx"),
391 "Marketing%20Plans/Q1%202025%20Deck.pptx"
392 );
393 assert_eq!(encode_path_segments(""), "");
394 assert_eq!(encode_path_segments("a/b/c"), "a/b/c");
395 }
396}