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 serde::Deserialize;
6
7use super::{GraphClient, PagedResponse};
8use crate::error::{CliError, Result};
9
10#[derive(Debug, Clone, Deserialize)]
11pub(crate) struct Drive {
12    pub(crate) id: String,
13    pub(crate) name: String,
14    #[serde(rename = "driveType", default)]
15    pub(crate) drive_type: String,
16}
17
18#[derive(Debug, Clone, Deserialize)]
19pub(crate) struct DriveItem {
20    pub(crate) id: String,
21    pub(crate) name: String,
22    #[serde(default)]
23    pub(crate) size: u64,
24    #[serde(rename = "eTag", default)]
25    pub(crate) etag: Option<String>,
26    #[serde(rename = "webUrl", default)]
27    pub(crate) web_url: Option<String>,
28    #[serde(rename = "createdDateTime", default)]
29    pub(crate) created: Option<String>,
30    #[serde(rename = "lastModifiedDateTime", default)]
31    pub(crate) modified: Option<String>,
32    #[serde(rename = "parentReference", default)]
33    pub(crate) parent_reference: Option<ParentReference>,
34    #[serde(default)]
35    pub(crate) folder: Option<Folder>,
36    #[serde(default)]
37    pub(crate) file: Option<File>,
38    /// Pre-authenticated short-lived URL — only populated by `/driveItem`
39    /// `?select=...&expand=...` when explicitly requested. We never include
40    /// it in canonical_json() output unless the caller asks for `stat`.
41    #[serde(rename = "@microsoft.graph.downloadUrl", default)]
42    pub(crate) download_url: Option<String>,
43}
44
45#[derive(Debug, Clone, Deserialize, Default)]
46pub(crate) struct ParentReference {
47    #[serde(rename = "path", default)]
48    pub(crate) path: String,
49}
50
51#[derive(Debug, Clone, Deserialize)]
52pub(crate) struct Folder {}
53
54#[derive(Debug, Clone, Deserialize, Default)]
55pub(crate) struct File {
56    #[serde(default)]
57    pub(crate) hashes: Hashes,
58}
59
60#[derive(Debug, Clone, Deserialize, Default)]
61pub(crate) struct Hashes {
62    #[serde(rename = "quickXorHash", default)]
63    pub(crate) quick_xor: Option<String>,
64    #[serde(rename = "sha1Hash", default)]
65    pub(crate) sha1: Option<String>,
66}
67
68pub(crate) async fn list_drives(graph: &GraphClient, site_id: &str) -> Result<Vec<Drive>> {
69    let first_path = format!("/sites/{site_id}/drives");
70    graph.page_all(&first_path).await
71}
72
73pub(crate) async fn find_drive_by_name(
74    graph: &GraphClient,
75    site_id: &str,
76    name: &str,
77) -> Result<Drive> {
78    let drives = list_drives(graph, site_id).await?;
79    let lower = name.to_ascii_lowercase();
80    drives
81        .iter()
82        .find(|d| d.name.to_ascii_lowercase() == lower)
83        .cloned()
84        .ok_or_else(|| {
85            let available = drives
86                .iter()
87                .map(|d| d.name.as_str())
88                .collect::<Vec<_>>()
89                .join(", ");
90            CliError::NotFound(format!(
91                "drive (library) '{name}' not found on this site. Available: {available}"
92            ))
93        })
94}
95
96/// Get an item with the `@microsoft.graph.downloadUrl` field populated.
97pub(crate) async fn get_item_with_download_url(
98    graph: &GraphClient,
99    drive_id: &str,
100    path: &str,
101) -> Result<DriveItem> {
102    let api_base = if path.is_empty() || path == "/" {
103        format!("/drives/{drive_id}/root")
104    } else {
105        let trimmed = path.trim_start_matches('/');
106        let encoded = encode_path_segments(trimmed);
107        format!("/drives/{drive_id}/root:/{encoded}")
108    };
109    let select = "id,name,size,eTag,webUrl,createdDateTime,lastModifiedDateTime,parentReference,folder,file,@microsoft.graph.downloadUrl";
110    let api = format!("{api_base}?$select={select}");
111    graph.get_json(&api).await
112}
113
114pub(crate) struct ListChildrenResult {
115    pub(crate) items: Vec<DriveItem>,
116    /// The raw `@odata.nextLink` URL returned by Graph, if there are more items.
117    pub(crate) next_url: Option<String>,
118    /// The URL that was actually fetched (the `page_url` argument resolved to a
119    /// full URL). Used by callers that need to encode a mid-page cursor.
120    pub(crate) fetched_url: String,
121}
122
123/// Fetch one page of children. `page_url` is the full Graph URL to fetch;
124/// when `None` the default first-page path is derived from `drive_id` and `path`.
125pub(crate) async fn list_children(
126    graph: &GraphClient,
127    drive_id: &str,
128    path: &str,
129    page_url: Option<&str>,
130) -> Result<ListChildrenResult> {
131    let api = match page_url {
132        Some(url) => url.to_string(),
133        None => {
134            if path.is_empty() || path == "/" {
135                format!("/drives/{drive_id}/root/children")
136            } else {
137                let trimmed = path.trim_start_matches('/');
138                let encoded = encode_path_segments(trimmed);
139                format!("/drives/{drive_id}/root:/{encoded}:/children")
140            }
141        }
142    };
143    // Resolve to a full absolute URL so the cursor stored in `fetched_url`
144    // is always a complete URL (required by the host-validation check on decode).
145    let absolute_url = graph.url(&api).await;
146    let page: PagedResponse<DriveItem> = graph.get_json(&absolute_url).await?;
147    Ok(ListChildrenResult {
148        items: page.value,
149        next_url: page.next_link,
150        fetched_url: absolute_url,
151    })
152}
153
154pub(crate) async fn list_children_recursive(
155    graph: &GraphClient,
156    drive_id: &str,
157    path: &str,
158) -> Result<Vec<DriveItem>> {
159    let mut out = Vec::new();
160    let mut stack = vec![path.to_string()];
161    while let Some(p) = stack.pop() {
162        let mut next_url: Option<String> = None;
163        loop {
164            let page = list_children(graph, drive_id, &p, next_url.as_deref()).await?;
165            for item in page.items {
166                if item.folder.is_some() {
167                    let child_path = item_path(&p, &item.name);
168                    stack.push(child_path);
169                }
170                out.push(item);
171            }
172            next_url = page.next_url;
173            if next_url.is_none() {
174                break;
175            }
176        }
177    }
178    Ok(out)
179}
180
181fn item_path(parent: &str, name: &str) -> String {
182    if parent.is_empty() || parent == "/" {
183        format!("/{name}")
184    } else {
185        format!("{}/{name}", parent.trim_end_matches('/'))
186    }
187}
188
189/// Canonical-shape JSON per spec (every list/show command emits this shape).
190pub(crate) fn canonical_json(
191    item: &DriveItem,
192    site: &super::sites::Site,
193    drive: &Drive,
194    include_download_url: bool,
195) -> serde_json::Value {
196    let kind = if item.folder.is_some() {
197        "folder"
198    } else {
199        "file"
200    };
201    let path = derive_full_path(item);
202
203    let mut hash = serde_json::Map::new();
204    if let Some(file) = &item.file {
205        if let Some(qx) = &file.hashes.quick_xor {
206            hash.insert("quickXor".into(), serde_json::Value::String(qx.clone()));
207        }
208        if let Some(s) = &file.hashes.sha1 {
209            hash.insert("sha1".into(), serde_json::Value::String(s.clone()));
210        }
211    }
212
213    let mut map = serde_json::Map::new();
214    map.insert("id".into(), serde_json::Value::String(item.id.clone()));
215    map.insert("name".into(), serde_json::Value::String(item.name.clone()));
216    map.insert("path".into(), serde_json::Value::String(path));
217    map.insert(
218        "site".into(),
219        serde_json::json!({
220            "id": site.id,
221            "name": site.display_name,
222            "url": site.web_url,
223        }),
224    );
225    map.insert(
226        "drive".into(),
227        serde_json::json!({
228            "id": drive.id,
229            "name": drive.name,
230        }),
231    );
232    map.insert("kind".into(), serde_json::Value::String(kind.into()));
233    map.insert("size".into(), serde_json::json!(item.size));
234    map.insert("etag".into(), serde_json::json!(item.etag));
235    map.insert("created".into(), serde_json::json!(item.created));
236    map.insert("modified".into(), serde_json::json!(item.modified));
237    map.insert("web_url".into(), serde_json::json!(item.web_url));
238
239    if !hash.is_empty() {
240        map.insert("hash".into(), serde_json::Value::Object(hash));
241    }
242    if include_download_url && let Some(u) = &item.download_url {
243        map.insert("download_url".into(), serde_json::Value::String(u.clone()));
244    }
245
246    serde_json::Value::Object(map)
247}
248
249fn derive_full_path(item: &DriveItem) -> String {
250    let parent = item
251        .parent_reference
252        .as_ref()
253        .map(|p| p.path.as_str())
254        .unwrap_or("");
255    // Graph parent path looks like "/drives/{id}/root:/Folder/Sub". Strip prefix.
256    let suffix = parent.split_once(":/").map(|(_, b)| b).unwrap_or("");
257    if suffix.is_empty() {
258        format!("/{}", item.name)
259    } else {
260        format!("/{}/{}", suffix, item.name)
261    }
262}
263
264/// Percent-encodes each path segment using the RFC 3986 unreserved character
265/// set, preserving `/` separators so the full path structure is maintained.
266pub(super) fn encode_path_segments(path: &str) -> String {
267    let mut out = String::with_capacity(path.len());
268    let mut first = true;
269    for seg in path.split('/') {
270        if !first {
271            out.push('/');
272        }
273        first = false;
274        for b in seg.bytes() {
275            match b {
276                b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
277                    out.push(b as char)
278                }
279                _ => write!(out, "%{b:02X}").unwrap(),
280            }
281        }
282    }
283    out
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    fn fake_site() -> super::super::sites::Site {
291        super::super::sites::Site {
292            id: "S1".into(),
293            display_name: "Marketing".into(),
294            web_url: "https://contoso.sharepoint.com/sites/Marketing".into(),
295        }
296    }
297
298    fn fake_drive() -> Drive {
299        Drive {
300            id: "D1".into(),
301            name: "Documents".into(),
302            drive_type: "documentLibrary".into(),
303        }
304    }
305
306    #[test]
307    fn canonical_includes_hash_when_file() {
308        let item = DriveItem {
309            id: "I1".into(),
310            name: "plan.pptx".into(),
311            size: 100,
312            etag: Some("\"abc\"".into()),
313            web_url: Some("https://example".into()),
314            created: Some("2025-01-01T00:00:00Z".into()),
315            modified: Some("2025-02-01T00:00:00Z".into()),
316            parent_reference: Some(ParentReference {
317                path: "/drives/D1/root:/Folder".into(),
318            }),
319            folder: None,
320            file: Some(File {
321                hashes: Hashes {
322                    quick_xor: Some("QX".into()),
323                    sha1: Some("S1".into()),
324                },
325            }),
326            download_url: None,
327        };
328        let v = canonical_json(&item, &fake_site(), &fake_drive(), false);
329        assert_eq!(v["kind"], "file");
330        assert_eq!(v["hash"]["quickXor"], "QX");
331        assert_eq!(v["hash"]["sha1"], "S1");
332        assert_eq!(v["path"], "/Folder/plan.pptx");
333        assert!(v.get("download_url").is_none());
334    }
335
336    #[test]
337    fn canonical_includes_download_url_only_when_requested() {
338        let item = DriveItem {
339            id: "I1".into(),
340            name: "f".into(),
341            size: 0,
342            etag: None,
343            web_url: None,
344            created: None,
345            modified: None,
346            parent_reference: None,
347            folder: None,
348            file: Some(File::default()),
349            download_url: Some("https://short-lived".into()),
350        };
351        let with = canonical_json(&item, &fake_site(), &fake_drive(), true);
352        assert_eq!(with["download_url"], "https://short-lived");
353        let without = canonical_json(&item, &fake_site(), &fake_drive(), false);
354        assert!(without.get("download_url").is_none());
355    }
356
357    #[test]
358    fn item_path_handles_root() {
359        assert_eq!(item_path("", "x"), "/x");
360        assert_eq!(item_path("/", "x"), "/x");
361        assert_eq!(item_path("/A/B", "x"), "/A/B/x");
362    }
363
364    #[test]
365    fn encode_path_segments_handles_spaces_and_keeps_slashes() {
366        assert_eq!(
367            encode_path_segments("Marketing Plans/Q1 2025 Deck.pptx"),
368            "Marketing%20Plans/Q1%202025%20Deck.pptx"
369        );
370        assert_eq!(encode_path_segments(""), "");
371        assert_eq!(encode_path_segments("a/b/c"), "a/b/c");
372    }
373}