Skip to main content

sharepoint_cli/commands/
files.rs

1//! `sharepoint files ls | stat | download | find`
2
3use tokio::fs::File;
4use tokio::io::AsyncWriteExt as _;
5
6use crate::auth::AuthContext;
7use crate::cli::{FilesCmd, Runtime};
8use crate::error::{CliError, Result};
9use crate::graph::drives::{Drive, canonical_json};
10use crate::graph::sites::Site;
11use crate::graph::{Cursor, GraphClient, decode_cursor, drives, encode_cursor, search};
12use crate::output::terminal_width;
13use crate::reference::{ParsedRef, parse};
14
15/// Resolved reference: everything the sub-commands need to call Graph.
16struct Resolved {
17    site: Site,
18    drive: Drive,
19    parsed: ParsedRef,
20}
21
22/// Pagination options shared by list-style commands.
23struct PageOpts<'a> {
24    limit: usize,
25    all: bool,
26    page: Option<&'a str>,
27    fields: &'a [String],
28}
29
30/// Parse `reference`, build auth+client, resolve site and drive.
31async fn resolve(rt: &Runtime, reference: &str) -> Result<(GraphClient, Resolved)> {
32    let default_site_set = rt.cfg.default_site.is_some();
33    let parsed = parse(reference, default_site_set)?;
34
35    let auth = AuthContext::new(rt.cfg.clone(), rt.cache_path.clone());
36    let graph = GraphClient::new(auth);
37
38    let site = crate::graph::sites::resolve(
39        &graph,
40        &parsed.site,
41        &rt.cfg.site_aliases,
42        rt.cfg.default_site.as_deref(),
43    )
44    .await?;
45
46    let library = parsed.library.as_deref().ok_or_else(|| {
47        CliError::Input(format!(
48            "reference '{reference}' has no library; use 'Site:Library/path'"
49        ))
50    })?;
51
52    let drive = drives::find_drive_by_name(&graph, &site.id, library).await?;
53
54    Ok((
55        graph,
56        Resolved {
57            site,
58            drive,
59            parsed,
60        },
61    ))
62}
63
64pub async fn run(rt: &Runtime, cmd: FilesCmd) -> Result<()> {
65    match cmd {
66        FilesCmd::Ls {
67            reference,
68            recursive,
69            limit,
70            all,
71            page,
72            fields,
73        } => {
74            ls(
75                rt,
76                &reference,
77                recursive,
78                limit,
79                all,
80                page.as_deref(),
81                &fields,
82            )
83            .await
84        }
85
86        FilesCmd::Stat { reference } => stat(rt, &reference).await,
87
88        FilesCmd::Download {
89            reference,
90            path,
91            overwrite,
92        } => download(rt, &reference, path.as_deref(), overwrite).await,
93
94        FilesCmd::Find {
95            reference,
96            query,
97            name,
98            limit,
99            all,
100            page,
101            fields,
102        } => {
103            find(
104                rt,
105                &reference,
106                query.as_deref(),
107                name.as_deref(),
108                PageOpts {
109                    limit,
110                    all,
111                    page: page.as_deref(),
112                    fields: &fields,
113                },
114            )
115            .await
116        }
117    }
118}
119
120const DEFAULT_LIMIT: usize = 200;
121
122async fn ls(
123    rt: &Runtime,
124    reference: &str,
125    recursive: bool,
126    limit: Option<usize>,
127    all: bool,
128    page: Option<&str>,
129    fields: &[String],
130) -> Result<()> {
131    if recursive && (limit.is_some() || all || page.is_some()) {
132        return Err(CliError::Input(
133            "`--recursive` cannot be combined with `--limit`/`--all`/`--page`; it always returns the full tree".into(),
134        ));
135    }
136
137    let (graph, r) = resolve(rt, reference).await?;
138
139    if recursive {
140        let items = drives::list_children_recursive(&graph, &r.drive.id, &r.parsed.path).await?;
141
142        if rt.out.json {
143            let json_items: Vec<_> = items
144                .iter()
145                .map(|it| filter_fields(canonical_json(it, &r.site, &r.drive, false), fields))
146                .collect();
147            rt.out.print_json(&serde_json::json!({
148                "total": json_items.len(),
149                "next": null,
150                "items": json_items,
151            }));
152        } else {
153            let name_w = terminal_width().saturating_sub(40).max(20);
154            rt.out.print_data(&format!(
155                "{:<name_w$}  {:<6}  {:>10}  {:<16}",
156                "NAME", "KIND", "SIZE", "MODIFIED"
157            ));
158            for it in &items {
159                let kind = if it.folder.is_some() {
160                    "folder"
161                } else {
162                    "file"
163                };
164                let modified = it.modified.as_deref().unwrap_or("");
165                let modified_short = modified
166                    .replace('T', " ")
167                    .chars()
168                    .take(16)
169                    .collect::<String>();
170                rt.out.print_data(&format!(
171                    "{:<name_w$}  {:<6}  {:>10}  {:<16}",
172                    it.name, kind, it.size, modified_short
173                ));
174            }
175            rt.out.print_message(&format!("({} item(s))", items.len()));
176        }
177        return Ok(());
178    }
179
180    // Paginated single-level listing.
181    let effective_limit = limit.unwrap_or(DEFAULT_LIMIT);
182    let (mut current_url, mut skip) = if let Some(token) = page {
183        let endpoint = graph.graph_endpoint().await;
184        let cursor = decode_cursor(&endpoint, token)?;
185        (cursor.next, cursor.skip)
186    } else {
187        (None, 0)
188    };
189
190    let mut items: Vec<_> = Vec::new();
191
192    let out_cursor: Option<Cursor> = 'outer: loop {
193        let pageres =
194            drives::list_children(&graph, &r.drive.id, &r.parsed.path, current_url.as_deref())
195                .await?;
196
197        for (idx, it) in pageres.items.iter().enumerate() {
198            if idx < skip {
199                continue;
200            }
201            items.push(it.clone());
202            if !all && items.len() >= effective_limit {
203                // Mid-page: point the cursor back at the same page with updated skip.
204                let consumed_in_page = idx + 1;
205                break 'outer Some(Cursor {
206                    next: Some(pageres.fetched_url),
207                    skip: consumed_in_page,
208                });
209            }
210        }
211        skip = 0;
212
213        match pageres.next_url {
214            None => break None,
215            Some(url) if !all && items.len() >= effective_limit => {
216                break Some(Cursor {
217                    next: Some(url),
218                    skip: 0,
219                });
220            }
221            Some(url) => current_url = Some(url),
222        }
223    };
224
225    let next_token = out_cursor.as_ref().map(encode_cursor);
226
227    if rt.out.json {
228        let json_items: Vec<_> = items
229            .iter()
230            .map(|it| filter_fields(canonical_json(it, &r.site, &r.drive, false), fields))
231            .collect();
232        rt.out.print_json(&serde_json::json!({
233            "total": json_items.len(),
234            "next": next_token,
235            "items": json_items,
236        }));
237    } else {
238        let name_w = terminal_width().saturating_sub(40).max(20);
239        rt.out.print_data(&format!(
240            "{:<name_w$}  {:<6}  {:>10}  {:<16}",
241            "NAME", "KIND", "SIZE", "MODIFIED"
242        ));
243        for it in &items {
244            let kind = if it.folder.is_some() {
245                "folder"
246            } else {
247                "file"
248            };
249            let modified = it.modified.as_deref().unwrap_or("");
250            let modified_short = modified
251                .replace('T', " ")
252                .chars()
253                .take(16)
254                .collect::<String>();
255            rt.out.print_data(&format!(
256                "{:<name_w$}  {:<6}  {:>10}  {:<16}",
257                it.name, kind, it.size, modified_short
258            ));
259        }
260        rt.out.print_message(&format!("({} item(s))", items.len()));
261    }
262    Ok(())
263}
264
265async fn stat(rt: &Runtime, reference: &str) -> Result<()> {
266    let (graph, r) = resolve(rt, reference).await?;
267    let item = drives::get_item_with_download_url(&graph, &r.drive.id, &r.parsed.path).await?;
268    let v = canonical_json(&item, &r.site, &r.drive, true);
269    // stat is data-heavy: emit canonical JSON in both modes (human readers
270    // benefit from the structured form when inspecting metadata).
271    rt.out.print_json(&v);
272    Ok(())
273}
274
275async fn download(
276    rt: &Runtime,
277    reference: &str,
278    output: Option<&str>,
279    overwrite: bool,
280) -> Result<()> {
281    let (graph, r) = resolve(rt, reference).await?;
282
283    // Derive the target filename from the path when --output is not given.
284    let derived;
285    let target: &str = match output {
286        Some(o) => o,
287        None => {
288            derived = r
289                .parsed
290                .path
291                .rsplit('/')
292                .find(|s| !s.is_empty())
293                .unwrap_or("download")
294                .to_string();
295            &derived
296        }
297    };
298
299    if target == "-" {
300        let mut stdout = tokio::io::stdout();
301        let bytes = crate::graph::download::download_to_writer(
302            &graph,
303            &r.drive.id,
304            &r.parsed.path,
305            &mut stdout,
306        )
307        .await?;
308        stdout.flush().await.ok();
309        rt.out
310            .print_message(&format!("{bytes} bytes written to stdout"));
311        return Ok(());
312    }
313
314    let path = std::path::Path::new(target);
315    if path.exists() && !overwrite {
316        return Err(CliError::Input(format!(
317            "'{target}' already exists; use --overwrite to replace it"
318        )));
319    }
320
321    if let Some(parent) = path.parent()
322        && !parent.as_os_str().is_empty()
323    {
324        tokio::fs::create_dir_all(parent).await.map_err(|e| {
325            CliError::Other(format!("create directory '{}': {e}", parent.display()))
326        })?;
327    }
328
329    let mut file = File::create(path)
330        .await
331        .map_err(|e| CliError::Other(format!("create '{target}': {e}")))?;
332
333    let n =
334        crate::graph::download::download_to_writer(&graph, &r.drive.id, &r.parsed.path, &mut file)
335            .await?;
336    rt.out
337        .print_message(&format!("Downloaded {n} byte(s) to {}", path.display()));
338    if rt.out.json {
339        rt.out.print_json(&serde_json::json!({
340            "path": path.display().to_string(),
341            "bytes": n,
342        }));
343    }
344    Ok(())
345}
346
347async fn find(
348    rt: &Runtime,
349    reference: &str,
350    query: Option<&str>,
351    name_glob: Option<&str>,
352    opts: PageOpts<'_>,
353) -> Result<()> {
354    let PageOpts {
355        limit,
356        all,
357        page,
358        fields,
359    } = opts;
360    let (graph, r) = resolve(rt, reference).await?;
361    // Permissive default query when only --name is given (Graph requires a query).
362    let q = query.unwrap_or("*");
363
364    // Decode the incoming page token to a (url, skip) cursor.
365    let (mut current_url, mut skip) = if let Some(token) = page {
366        let endpoint = graph.graph_endpoint().await;
367        let cursor = decode_cursor(&endpoint, token)?;
368        (cursor.next, cursor.skip)
369    } else {
370        (None, 0)
371    };
372
373    let mut items = Vec::new();
374
375    let out_cursor: Option<Cursor> = 'outer: loop {
376        let res = search::search(&graph, &r.drive.id, q, current_url.as_deref()).await?;
377
378        for (idx, it) in res.items.iter().enumerate() {
379            if idx < skip {
380                continue;
381            }
382            // Apply optional glob filter without counting filtered items toward skip.
383            if let Some(g) = name_glob
384                && !search::glob_matches(g, &it.name)
385            {
386                continue;
387            }
388            items.push(it.clone());
389            if !all && items.len() >= limit {
390                // Mid-page: note how many raw items from this page we consumed.
391                // `skip` is a raw-item index, not a post-filter count. Resume correctness
392                // relies on the glob filter being stateless: re-applying it to the same
393                // items on the resumed page yields identical filter decisions, so no item
394                // is double-emitted or skipped.
395                let consumed_raw = idx + 1;
396                break 'outer Some(Cursor {
397                    next: Some(res.fetched_url),
398                    skip: consumed_raw,
399                });
400            }
401        }
402        skip = 0;
403
404        if all {
405            if res.next_url.is_none() {
406                break None;
407            }
408            current_url = res.next_url;
409        } else {
410            break res.next_url.map(|url| Cursor {
411                next: Some(url),
412                skip: 0,
413            });
414        }
415    };
416
417    let next_token = out_cursor.as_ref().map(encode_cursor);
418    let total = items.len();
419    if rt.out.json {
420        let json_items: Vec<_> = items
421            .iter()
422            .map(|it| filter_fields(canonical_json(it, &r.site, &r.drive, false), fields))
423            .collect();
424        rt.out.print_json(&serde_json::json!({
425            "total": total,
426            "next": next_token,
427            "items": json_items,
428        }));
429    } else {
430        for it in &items {
431            rt.out.print_data(&it.name);
432        }
433        rt.out.print_message(&format!("({total} match(es))"));
434    }
435    Ok(())
436}
437
438/// Filter a JSON object to only include the specified fields.
439/// When `fields` is empty, return the object unchanged.
440fn filter_fields(mut value: serde_json::Value, fields: &[String]) -> serde_json::Value {
441    if fields.is_empty() {
442        return value;
443    }
444    if let serde_json::Value::Object(ref mut map) = value {
445        map.retain(|k, _| fields.iter().any(|f| f == k));
446    }
447    value
448}