1use 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
15struct Resolved {
17 site: Site,
18 drive: Drive,
19 parsed: ParsedRef,
20}
21
22struct PageOpts<'a> {
24 limit: usize,
25 all: bool,
26 page: Option<&'a str>,
27 fields: &'a [String],
28}
29
30async 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 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 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 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 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 let q = query.unwrap_or("*");
363
364 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 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 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
438fn 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}