Skip to main content

sharepoint_cli/graph/
drives.rs

1//! Drive (document library) lookup, item listing, and canonical-shape mapping.
2
3use 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    /// Pre-authenticated short-lived URL — only populated by `/driveItem`
43    /// `?select=...&expand=...` when explicitly requested. We never include
44    /// it in canonical_json() output unless the caller asks for `stat`.
45    #[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
102/// `path` here is the leading-slash path within the drive root, e.g. "/folder/file.txt".
103/// Empty / "/" means the drive root.
104pub 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
115/// Get an item with the `@microsoft.graph.downloadUrl` field populated.
116pub 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
198/// Canonical-shape JSON per spec (every list/show command emits this shape).
199pub 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    // Graph parent path looks like "/drives/{id}/root:/Folder/Sub". Strip prefix.
265    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
273/// Percent-encodes each path segment using the RFC 3986 unreserved character
274/// set, preserving `/` separators so the full path structure is maintained.
275pub(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}