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::{GraphClient, drives, 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/// Parse `reference`, build auth+client, resolve site and drive.
23async fn resolve(rt: &Runtime, reference: &str) -> Result<(GraphClient, Resolved)> {
24    let default_site_set = rt.cfg.default_site.is_some();
25    let parsed = parse(reference, default_site_set)?;
26
27    let auth = AuthContext::new(rt.cfg.clone(), rt.cache_path.clone());
28    let graph = GraphClient::new(auth);
29
30    let site = crate::graph::sites::resolve(
31        &graph,
32        &parsed.site,
33        &rt.cfg.site_aliases,
34        rt.cfg.default_site.as_deref(),
35    )
36    .await?;
37
38    let library = parsed.library.as_deref().ok_or_else(|| {
39        CliError::Input(format!(
40            "reference '{reference}' has no library; use 'Site:Library/path'"
41        ))
42    })?;
43
44    let drive = drives::find_drive_by_name(&graph, &site.id, library).await?;
45
46    Ok((
47        graph,
48        Resolved {
49            site,
50            drive,
51            parsed,
52        },
53    ))
54}
55
56pub async fn run(rt: &Runtime, cmd: FilesCmd) -> Result<()> {
57    match cmd {
58        FilesCmd::Ls {
59            reference,
60            recursive,
61            limit,
62            all,
63            page,
64        } => ls(rt, &reference, recursive, limit, all, page.as_deref()).await,
65
66        FilesCmd::Stat { reference } => stat(rt, &reference).await,
67
68        FilesCmd::Download {
69            reference,
70            output,
71            overwrite,
72        } => download(rt, &reference, output.as_deref(), overwrite).await,
73
74        FilesCmd::Find {
75            reference,
76            query,
77            name,
78            limit,
79            all,
80            page,
81        } => {
82            find(
83                rt,
84                &reference,
85                query.as_deref(),
86                name.as_deref(),
87                limit,
88                all,
89                page.as_deref(),
90            )
91            .await
92        }
93    }
94}
95
96async fn ls(
97    rt: &Runtime,
98    reference: &str,
99    recursive: bool,
100    limit: usize,
101    all: bool,
102    page: Option<&str>,
103) -> Result<()> {
104    let (graph, r) = resolve(rt, reference).await?;
105
106    if recursive {
107        let items = drives::list_children_recursive(&graph, &r.drive.id, &r.parsed.path).await?;
108
109        if rt.out.json {
110            let json_items: Vec<_> = items
111                .iter()
112                .map(|it| canonical_json(it, &r.site, &r.drive, false))
113                .collect();
114            rt.out.print_json(&serde_json::json!({
115                "total": json_items.len(),
116                "next": null,
117                "items": json_items,
118            }));
119        } else {
120            let name_w = terminal_width().saturating_sub(40).max(20);
121            rt.out.print_data(&format!(
122                "{:<name_w$}  {:<6}  {:>10}  {:<16}",
123                "NAME", "KIND", "SIZE", "MODIFIED"
124            ));
125            for it in &items {
126                let kind = if it.folder.is_some() {
127                    "folder"
128                } else {
129                    "file"
130                };
131                let modified = it.modified.as_deref().unwrap_or("");
132                let modified_short = modified
133                    .replace('T', " ")
134                    .chars()
135                    .take(16)
136                    .collect::<String>();
137                rt.out.print_data(&format!(
138                    "{:<name_w$}  {:<6}  {:>10}  {:<16}",
139                    it.name, kind, it.size, modified_short
140                ));
141            }
142            rt.out.print_message(&format!("({} item(s))", items.len()));
143        }
144        return Ok(());
145    }
146
147    // Paginated single-level listing.
148    let mut items: Vec<_> = Vec::new();
149    let mut next = page.map(str::to_owned);
150    loop {
151        let pageres =
152            drives::list_children(&graph, &r.drive.id, &r.parsed.path, next.as_deref()).await?;
153        for it in pageres.items {
154            items.push(it);
155            if !all && items.len() >= limit {
156                break;
157            }
158        }
159        if !all || pageres.next.is_none() {
160            next = if all { None } else { pageres.next };
161            break;
162        }
163        next = pageres.next;
164    }
165    let next_token = next;
166
167    if rt.out.json {
168        let json_items: Vec<_> = items
169            .iter()
170            .map(|it| canonical_json(it, &r.site, &r.drive, false))
171            .collect();
172        rt.out.print_json(&serde_json::json!({
173            "total": json_items.len(),
174            "next": next_token,
175            "items": json_items,
176        }));
177    } else {
178        let name_w = terminal_width().saturating_sub(40).max(20);
179        rt.out.print_data(&format!(
180            "{:<name_w$}  {:<6}  {:>10}  {:<16}",
181            "NAME", "KIND", "SIZE", "MODIFIED"
182        ));
183        for it in &items {
184            let kind = if it.folder.is_some() {
185                "folder"
186            } else {
187                "file"
188            };
189            let modified = it.modified.as_deref().unwrap_or("");
190            let modified_short = modified
191                .replace('T', " ")
192                .chars()
193                .take(16)
194                .collect::<String>();
195            rt.out.print_data(&format!(
196                "{:<name_w$}  {:<6}  {:>10}  {:<16}",
197                it.name, kind, it.size, modified_short
198            ));
199        }
200        rt.out.print_message(&format!("({} item(s))", items.len()));
201    }
202    Ok(())
203}
204
205async fn stat(rt: &Runtime, reference: &str) -> Result<()> {
206    let (graph, r) = resolve(rt, reference).await?;
207    let item = drives::get_item_with_download_url(&graph, &r.drive.id, &r.parsed.path).await?;
208    let v = canonical_json(&item, &r.site, &r.drive, true);
209    // stat is data-heavy: emit canonical JSON in both modes (human readers
210    // benefit from the structured form when inspecting metadata).
211    rt.out.print_json(&v);
212    Ok(())
213}
214
215async fn download(
216    rt: &Runtime,
217    reference: &str,
218    output: Option<&str>,
219    overwrite: bool,
220) -> Result<()> {
221    let (graph, r) = resolve(rt, reference).await?;
222
223    // Derive the target filename from the path when --output is not given.
224    let derived;
225    let target: &str = match output {
226        Some(o) => o,
227        None => {
228            derived = r
229                .parsed
230                .path
231                .rsplit('/')
232                .find(|s| !s.is_empty())
233                .unwrap_or("download")
234                .to_string();
235            &derived
236        }
237    };
238
239    if target == "-" {
240        let mut stdout = tokio::io::stdout();
241        let bytes = crate::graph::download::download_to_writer(
242            &graph,
243            &r.drive.id,
244            &r.parsed.path,
245            &mut stdout,
246        )
247        .await?;
248        stdout.flush().await.ok();
249        rt.out
250            .print_message(&format!("{bytes} bytes written to stdout"));
251        return Ok(());
252    }
253
254    let path = std::path::Path::new(target);
255    if path.exists() && !overwrite {
256        return Err(CliError::Input(format!(
257            "'{target}' already exists; use --overwrite to replace it"
258        )));
259    }
260
261    if let Some(parent) = path.parent()
262        && !parent.as_os_str().is_empty()
263    {
264        tokio::fs::create_dir_all(parent).await.map_err(|e| {
265            CliError::Other(format!("create directory '{}': {e}", parent.display()))
266        })?;
267    }
268
269    let mut file = File::create(path)
270        .await
271        .map_err(|e| CliError::Other(format!("create '{target}': {e}")))?;
272
273    let n =
274        crate::graph::download::download_to_writer(&graph, &r.drive.id, &r.parsed.path, &mut file)
275            .await?;
276    rt.out
277        .print_message(&format!("Downloaded {n} byte(s) to {}", path.display()));
278    if rt.out.json {
279        rt.out.print_json(&serde_json::json!({
280            "path": path.display().to_string(),
281            "bytes": n,
282        }));
283    }
284    Ok(())
285}
286
287async fn find(
288    rt: &Runtime,
289    reference: &str,
290    query: Option<&str>,
291    name_glob: Option<&str>,
292    limit: usize,
293    all: bool,
294    page: Option<&str>,
295) -> Result<()> {
296    let (graph, r) = resolve(rt, reference).await?;
297    // Permissive default query when only --name is given (Graph requires a query).
298    let q = query.unwrap_or("*");
299
300    let mut items = Vec::new();
301    let mut next: Option<String> = page.map(String::from);
302    loop {
303        let res = search::search(&graph, &r.drive.id, q, next.as_deref()).await?;
304        for it in res.items {
305            if let Some(g) = name_glob
306                && !search::glob_matches(g, &it.name)
307            {
308                continue;
309            }
310            items.push(it);
311            if !all && items.len() >= limit {
312                break;
313            }
314        }
315        if !all || res.next.is_none() {
316            next = if all { None } else { res.next };
317            break;
318        }
319        next = res.next;
320    }
321
322    let total = items.len();
323    if rt.out.json {
324        let json_items: Vec<_> = items
325            .iter()
326            .map(|it| canonical_json(it, &r.site, &r.drive, false))
327            .collect();
328        rt.out.print_json(&serde_json::json!({
329            "total": total,
330            "next": next,
331            "items": json_items,
332        }));
333    } else {
334        for it in &items {
335            rt.out.print_data(&it.name);
336        }
337        rt.out.print_message(&format!("({total} match(es))"));
338    }
339    Ok(())
340}