1use std::sync::Arc;
4use std::time::Duration;
5
6use anyhow::{Context, Result, bail};
7use clap::{Args, Parser, Subcommand};
8use url::Url;
9
10use crate::config;
11use crate::engine::{self, DownloadRequest};
12use crate::fmt;
13use crate::http::{DEFAULT_USER_AGENT, HttpConfig};
14use crate::integrity::{Algorithm, Checksum};
15use crate::limit;
16use crate::progress::Reporter;
17use crate::shutdown::Cancel;
18use crate::storage::{DownloadRecord, RangeState, Status, Store};
19use crate::ui;
20
21pub const DEFAULT_CONNECTIONS: usize = 8;
24const MAX_CONNECTIONS: usize = 64;
25
26#[derive(Parser, Debug)]
27#[command(
28 name = "rget",
29 version,
30 about = "High-performance resumable download manager",
31 long_about = "Downloads a URL as fast and as reliably as possible.\n\n\
32 If a download is interrupted, run the same command again — it \
33 resumes automatically.",
34 after_help = "EXAMPLES:\n \
35 rget https://example.com/linux.iso\n \
36 rget URL -o ubuntu.iso --dir ~/Downloads\n \
37 rget URL --connections 16 --sha256 <digest>\n \
38 rget https://mirror1/f.iso https://mirror2/f.iso --sha256 <digest>\n \
39 rget list\n \
40 rget resume --all\n \
41 rget forget --all\n \
42 rget forget --all --files\n \
43 rget config --dir ~/Downloads"
44)]
45pub struct Cli {
46 #[command(subcommand)]
47 pub command: Option<Command>,
48
49 #[command(flatten)]
50 pub get: GetArgs,
51
52 #[arg(long, global = true)]
54 pub quiet: bool,
55
56 #[arg(short, long, global = true)]
58 pub verbose: bool,
59
60 #[arg(long, global = true)]
62 pub json: bool,
63}
64
65#[derive(Subcommand, Debug)]
66pub enum Command {
67 List,
69 Info {
71 id: String,
73 },
74 Resume {
76 id: Option<String>,
78 #[arg(long)]
80 all: bool,
81 },
82 Forget {
84 id: Option<String>,
86 #[arg(long)]
88 all: bool,
89 #[arg(long)]
91 files: bool,
92 },
93 Config {
95 #[arg(long, value_name = "DIR")]
97 dir: Option<String>,
98 #[arg(long, conflicts_with = "dir")]
100 reset: bool,
101 },
102}
103
104#[derive(Args, Debug, Default)]
105pub struct GetArgs {
106 #[arg(value_name = "URL")]
108 pub urls: Vec<String>,
109
110 #[arg(short, long, value_name = "FILE")]
112 pub output: Option<String>,
113
114 #[arg(long, value_name = "DIR")]
116 pub dir: Option<String>,
117
118 #[arg(short = 'c', long, value_name = "N")]
120 pub connections: Option<usize>,
121
122 #[arg(long, value_name = "HEX")]
124 pub sha256: Option<String>,
125
126 #[arg(long, value_name = "HEX")]
128 pub sha512: Option<String>,
129
130 #[arg(long, value_name = "HEX")]
132 pub blake3: Option<String>,
133
134 #[arg(long, value_name = "RATE")]
136 pub limit: Option<String>,
137
138 #[arg(long, value_name = "DURATION", default_value = "30s")]
140 pub timeout: String,
141
142 #[arg(long, value_name = "N", default_value_t = 10)]
144 pub retries: u32,
145
146 #[arg(long = "header", value_name = "KEY:VALUE")]
148 pub headers: Vec<String>,
149
150 #[arg(long, value_name = "STRING")]
152 pub user_agent: Option<String>,
153
154 #[arg(long, value_name = "URL")]
156 pub proxy: Option<String>,
157
158 #[arg(long, value_name = "USER:PASS")]
160 pub user: Option<String>,
161
162 #[arg(long)]
164 pub overwrite: bool,
165
166 #[arg(long)]
168 pub restart: bool,
169
170 #[arg(long)]
172 pub no_preallocate: bool,
173}
174
175impl GetArgs {
176 fn checksum(&self) -> Result<Option<Checksum>> {
177 let candidates = [
178 (Algorithm::Sha256, self.sha256.as_deref()),
179 (Algorithm::Sha512, self.sha512.as_deref()),
180 (Algorithm::Blake3, self.blake3.as_deref()),
181 ];
182 let given: Vec<_> = candidates
183 .iter()
184 .filter_map(|(algo, value)| value.map(|v| (*algo, v)))
185 .collect();
186 match given.len() {
187 0 => Ok(None),
188 1 => {
189 let (algo, value) = given[0];
190 Ok(Some(Checksum::parse(algo, value)?))
191 }
192 _ => bail!("pass at most one of --sha256, --sha512, --blake3"),
193 }
194 }
195
196 fn connections(&self) -> Result<usize> {
197 let n = self.connections.unwrap_or(DEFAULT_CONNECTIONS);
198 if n == 0 {
199 bail!("--connections must be at least 1");
200 }
201 if n > MAX_CONNECTIONS {
202 bail!(
203 "--connections {n} is more than {MAX_CONNECTIONS}; that many parallel requests \
204 hurts throughput and looks like an attack to most servers"
205 );
206 }
207 Ok(n)
208 }
209
210 fn http_config(&self) -> Result<HttpConfig> {
211 let mut headers = Vec::new();
212 for raw in &self.headers {
213 let (k, v) = raw
214 .split_once(':')
215 .with_context(|| format!("--header must look like 'Key: value', got `{raw}`"))?;
216 if k.trim().is_empty() {
217 bail!("--header has an empty name: `{raw}`");
218 }
219 headers.push((k.to_string(), v.to_string()));
220 }
221
222 let basic_auth = match &self.user {
223 Some(spec) => {
224 let (user, pass) = spec
225 .split_once(':')
226 .with_context(|| "--user must look like user:password".to_string())?;
227 Some((user.to_string(), pass.to_string()))
228 }
229 None => None,
230 };
231
232 Ok(HttpConfig {
233 user_agent: self
234 .user_agent
235 .clone()
236 .unwrap_or_else(|| DEFAULT_USER_AGENT.to_string()),
237 timeout: limit::parse_duration(&self.timeout).map_err(|e| anyhow::anyhow!(e))?,
238 headers,
239 proxy: self.proxy.clone(),
240 max_redirects: 10,
241 basic_auth,
242 })
243 }
244
245 fn parse_urls(&self) -> Result<Vec<Url>> {
246 let mut out = Vec::with_capacity(self.urls.len());
247 for raw in &self.urls {
248 let url = Url::parse(raw).with_context(|| format!("`{raw}` is not a valid URL"))?;
249 match url.scheme() {
250 "http" | "https" => {}
251 other => bail!(
252 "`{other}` URLs are not supported yet (only http and https): {}",
253 crate::http::redact(&url)
254 ),
255 }
256 if url.host_str().is_none() {
257 bail!("`{raw}` has no host");
258 }
259 out.push(url);
260 }
261 Ok(out)
262 }
263
264 fn to_request(&self, urls: Vec<Url>) -> Result<DownloadRequest> {
265 Ok(DownloadRequest {
266 urls,
267 output: self.output.clone(),
268 dir: self.dir.clone(),
269 connections: self.connections()?,
270 checksum: self.checksum()?,
271 limit: match &self.limit {
272 Some(raw) => Some(limit::parse_rate(raw).map_err(|e| anyhow::anyhow!(e))?),
273 None => None,
274 },
275 http: self.http_config()?,
276 retries: self.retries,
277 overwrite: self.overwrite,
278 restart: self.restart,
279 preallocate: !self.no_preallocate,
280 })
281 }
282}
283
284pub const EXIT_OK: i32 = 0;
286pub const EXIT_FAILURE: i32 = 1;
287pub const EXIT_INTERRUPTED: i32 = 130;
288
289pub async fn dispatch(cli: Cli) -> Result<i32> {
290 let store = Arc::new(Store::open_default()?);
291
292 match &cli.command {
293 Some(Command::List) => {
294 cmd_list(&store, cli.json)?;
295 Ok(EXIT_OK)
296 }
297 Some(Command::Info { id }) => {
298 cmd_info(&store, id, cli.json)?;
299 Ok(EXIT_OK)
300 }
301 Some(Command::Forget { id, all, files }) => {
302 cmd_forget(&store, id.as_deref(), *all, *files)?;
303 Ok(EXIT_OK)
304 }
305 Some(Command::Config { dir, reset }) => {
306 cmd_config(&store, dir.as_deref(), *reset, cli.json)?;
307 Ok(EXIT_OK)
308 }
309 Some(Command::Resume { id, all }) => cmd_resume(&store, &cli, id.as_deref(), *all).await,
310 None => {
311 if cli.get.urls.is_empty() {
312 use clap::CommandFactory;
314 Cli::command().print_help()?;
315 println!();
316 return Ok(EXIT_FAILURE);
317 }
318 let urls = cli.get.parse_urls()?;
319 let mut request = cli.get.to_request(urls)?;
320 request.dir = Some(resolve_dir(&store, &cli)?);
322 run_download(store, request, &cli).await
323 }
324 }
325}
326
327fn resolve_dir(store: &Store, cli: &Cli) -> Result<String> {
330 let machine_output = cli.json || cli.quiet;
331 let resolved = config::resolve_download_dir(store, cli.get.dir.as_deref(), |default| {
332 config::prompt_for_download_dir(default, machine_output)
333 })?;
334
335 if cli.verbose {
336 eprintln!(
337 " downloading into {} ({})",
338 config::tildify(&resolved.path),
339 match resolved.source {
340 config::DirSource::Flag => "--dir",
341 config::DirSource::Saved => "saved setting",
342 config::DirSource::Prompted => "just chosen",
343 config::DirSource::PlatformDefault => "platform default",
344 }
345 );
346 }
347 Ok(resolved.path.to_string_lossy().to_string())
348}
349
350async fn run_download(store: Arc<Store>, request: DownloadRequest, cli: &Cli) -> Result<i32> {
352 let mode = ui::Mode::detect(cli.json, cli.quiet, cli.verbose);
353 let (reporter, rx) = Reporter::new();
354 let cancel = Cancel::new();
355
356 let ui_task = tokio::spawn(ui::run(mode, reporter.stats.clone(), rx, cli.verbose));
357 install_signal_handlers(cancel.clone());
358 watch_shutdown_deadline(cancel.clone());
359
360 let result = engine::download(store, request, reporter.clone(), cancel).await;
361
362 drop(reporter);
364 let _ = ui_task.await;
365
366 match result {
367 Ok(report) if report.paused => Ok(EXIT_INTERRUPTED),
368 Ok(_) => Ok(EXIT_OK),
369 Err(err) => Err(err),
370 }
371}
372
373async fn cmd_resume(store: &Arc<Store>, cli: &Cli, id: Option<&str>, all: bool) -> Result<i32> {
374 let targets: Vec<DownloadRecord> = match (id, all) {
375 (Some(_), true) => bail!("pass either an id or --all, not both"),
376 (Some(id), false) => vec![store.resolve_id(id)?],
377 (None, true) => store.list_resumable()?,
378 (None, false) => bail!("which download? pass an id or --all (see `rget list`)"),
379 };
380
381 if targets.is_empty() {
382 if !cli.quiet {
383 println!("Nothing to resume.");
384 }
385 return Ok(EXIT_OK);
386 }
387
388 let mut worst = EXIT_OK;
389 for record in targets {
390 if record.status == Status::Complete {
391 if !cli.quiet {
392 println!("{} is already complete.", record.filename);
393 }
394 continue;
395 }
396 let request = request_from_record(cli, &record)?;
397 match run_download(store.clone(), request, cli).await {
398 Ok(EXIT_OK) => {}
399 Ok(code) => worst = worst.max(code),
400 Err(err) => {
401 eprintln!("{}: {err:#}", record.filename);
403 worst = EXIT_FAILURE;
404 }
405 }
406 }
407 Ok(worst)
408}
409
410fn request_from_record(cli: &Cli, record: &DownloadRecord) -> Result<DownloadRequest> {
413 let mut urls = vec![
414 Url::parse(&record.original_url)
415 .with_context(|| format!("stored URL is invalid: {}", record.original_url))?,
416 ];
417 for mirror in &record.mirrors {
418 if let Ok(url) = Url::parse(mirror) {
419 urls.push(url);
420 }
421 }
422
423 let checksum = match (&record.expected_checksum, &record.checksum_algorithm) {
424 (Some(digest), Some(algo)) => Some(Checksum::parse(algo.parse::<Algorithm>()?, digest)?),
425 _ => None,
426 };
427
428 let mut request = cli.get.to_request(urls)?;
429 request.output = Some(record.destination.clone());
431 request.dir = None;
432 if request.checksum.is_none() {
433 request.checksum = checksum;
434 }
435 Ok(request)
436}
437
438fn cmd_list(store: &Store, json: bool) -> Result<()> {
439 let downloads = store.list()?;
440 if json {
441 println!("{}", serde_json::to_string_pretty(&downloads)?);
442 return Ok(());
443 }
444 if downloads.is_empty() {
445 println!("No downloads yet.");
446 return Ok(());
447 }
448
449 let style = fmt::Style::stdout();
450 println!("{}", list_header(&style));
451 for d in downloads {
452 println!("{}", list_row(&d, &style));
453 }
454 Ok(())
455}
456
457const ID_W: usize = 8;
459const NAME_W: usize = 26;
460const BAR_W: usize = 10;
461const PCT_W: usize = 5;
462
463fn list_header(style: &fmt::Style) -> String {
464 style.dim(&format!(
465 "{:<ID_W$} {:<NAME_W$} {:<BAR_W$} {:<PCT_W$} STATUS",
466 "ID", "FILE", "PROGRESS", ""
467 ))
468}
469
470fn list_row(d: &DownloadRecord, style: &fmt::Style) -> String {
471 let pct = match d.total_size {
472 Some(total) if total > 0 => {
473 format!("{:.0}%", (d.durable_bytes as f64 / total as f64) * 100.0)
474 }
475 _ => "--".to_string(),
476 };
477 let (filled, empty) = fmt::bar_parts(d.durable_bytes, d.total_size, BAR_W);
480 format!(
483 "{} {} {}{} {:>PCT_W$} {}",
484 style.dim(&format!("{:<ID_W$}", d.id)),
485 format_args!("{:<NAME_W$}", truncate(&d.filename, NAME_W)),
486 style.bright_green(&filled),
487 style.dim(&empty),
488 pct,
489 colour_status(style, d.status),
490 )
491}
492
493fn colour_status(style: &fmt::Style, status: Status) -> String {
496 let text = status.as_str();
497 match status {
498 Status::Complete => style.green(text),
499 Status::Downloading => style.bright_cyan(text),
500 Status::Verifying => style.cyan(text),
501 Status::Paused => style.yellow(text),
502 Status::Failed => style.red(text),
503 Status::Pending => style.dim(text),
504 }
505}
506
507fn cmd_info(store: &Store, id: &str, json: bool) -> Result<()> {
508 let record = store.resolve_id(id)?;
509 let ranges = store.load_ranges(&record.id)?;
510
511 if json {
512 println!(
513 "{}",
514 serde_json::to_string_pretty(&serde_json::json!({
515 "download": record,
516 "ranges": ranges,
517 }))?
518 );
519 return Ok(());
520 }
521
522 let style = fmt::Style::stdout();
523 let field = |label: &str, value: &str| {
524 println!(" {} {value}", style.dim(&format!("{label:<13}")));
525 };
526
527 println!(
528 "{} {}",
529 style.dim(&record.id),
530 style.bold(&record.filename)
531 );
532 field("status", &colour_status(&style, record.status));
533 field("url", &record.original_url);
534 if let Some(resolved) = &record.resolved_url {
535 if resolved != &record.original_url {
536 field("resolved", &style.dim(resolved));
537 }
538 }
539 for mirror in &record.mirrors {
540 field("mirror", &style.dim(mirror));
541 }
542 field("destination", &record.destination);
543 field(
544 "size",
545 &record
546 .total_size
547 .map(fmt::bytes)
548 .unwrap_or_else(|| "unknown".into()),
549 );
550
551 let pct = match record.total_size {
552 Some(t) if t > 0 => (record.durable_bytes as f64 / t as f64) * 100.0,
553 _ => 0.0,
554 };
555 let (filled, empty) = fmt::bar_parts(record.durable_bytes, record.total_size, 20);
556 field(
557 "downloaded",
558 &format!(
559 "{}{} {} {}",
560 style.bright_green(&filled),
561 style.dim(&empty),
562 style.bold(&format!("{pct:.1}%")),
563 style.dim(&fmt::bytes(record.durable_bytes)),
564 ),
565 );
566
567 let complete = ranges
568 .iter()
569 .filter(|r| r.state == RangeState::Complete)
570 .count();
571 field(
572 "ranges",
573 &format!(
574 "{} {} {}",
575 style.magenta(&format!("{complete}/{}", ranges.len())),
576 style.dim("complete ·"),
577 style.dim(if record.accept_ranges {
578 "server supports resuming"
579 } else {
580 "server cannot resume"
581 }),
582 ),
583 );
584 if let Some(etag) = &record.etag {
585 field("etag", etag);
586 }
587 if let Some(lm) = &record.last_modified {
588 field("last-modified", lm);
589 }
590 if let (Some(algo), Some(digest)) = (&record.checksum_algorithm, &record.expected_checksum) {
591 field(algo, &style.dim(digest));
592 }
593 if let Some(err) = &record.error {
594 field("last error", &style.red(err));
595 }
596 Ok(())
597}
598
599fn cmd_config(store: &Store, dir: Option<&str>, reset: bool, json: bool) -> Result<()> {
600 if reset {
601 store.clear_meta(config::DOWNLOAD_DIR_KEY)?;
602 let style = fmt::Style::stdout();
603 println!(
604 "{} Forgot the saved download folder; the next download will ask again.",
605 style.bold_green("✓")
606 );
607 return Ok(());
608 }
609
610 if let Some(dir) = dir {
611 let path = config::normalise_dir(dir)?;
612 config::save_download_dir(store, &path)?;
613 let style = fmt::Style::stdout();
614 println!(
615 "{} Downloads will be saved to {}",
616 style.bold_green("✓"),
617 style.bold(&config::tildify(&path))
618 );
619 return Ok(());
620 }
621
622 let saved = config::saved_download_dir(store)?;
623 let effective = saved.clone().unwrap_or_else(config::platform_download_dir);
624
625 if json {
626 println!(
627 "{}",
628 serde_json::to_string_pretty(&serde_json::json!({
629 "download_dir": effective.to_string_lossy(),
630 "download_dir_is_saved": saved.is_some(),
631 "platform_default": config::platform_download_dir().to_string_lossy(),
632 "state_database": store.path().to_string_lossy(),
633 }))?
634 );
635 return Ok(());
636 }
637
638 let style = fmt::Style::stdout();
639 println!(
640 " {} {}{}",
641 style.dim("download folder "),
642 style.bold(&config::tildify(&effective)),
643 if saved.is_none() {
644 style.dim(" (platform default; not saved yet)")
645 } else {
646 String::new()
647 }
648 );
649 println!(
650 " {} {}",
651 style.dim("state database "),
652 style.dim(&store.path().display().to_string())
653 );
654 println!();
655 println!(
656 "{}",
657 style.dim("Change it with `rget config --dir <path>`, or `--reset` to be asked again.")
658 );
659 Ok(())
660}
661
662fn cmd_forget(store: &Store, id: Option<&str>, all: bool, files: bool) -> Result<()> {
663 let targets: Vec<DownloadRecord> = match (id, all) {
664 (Some(_), true) => bail!("pass either an id or --all, not both"),
665 (Some(id), false) => vec![store.resolve_id(id)?],
666 (None, true) => store.list()?,
667 (None, false) => bail!("which download? pass an id or --all (see `rget list`)"),
668 };
669
670 let style = fmt::Style::stdout();
671 if targets.is_empty() {
672 println!("Nothing to forget.");
673 return Ok(());
674 }
675
676 for record in &targets {
677 if files {
678 let path = std::path::Path::new(&record.destination);
679 match std::fs::remove_file(path) {
680 Ok(()) => {}
681 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
682 Err(err) => {
683 return Err(err)
684 .with_context(|| format!("cannot delete {}", path.display()));
685 }
686 }
687 }
688 store.forget(&record.id)?;
689 }
690
691 if targets.len() == 1 {
692 let record = &targets[0];
693 let note = if files {
694 format!("\n deleted {}", record.destination)
695 } else {
696 format!("\n the file at {} was left alone", record.destination)
697 };
698 println!(
699 "{} Forgot {} ({}){}",
700 style.bold_green("✓"),
701 style.dim(&record.id),
702 style.bold(&record.filename),
703 style.dim(¬e)
704 );
705 } else {
706 let note = if files {
707 "metadata and files deleted"
708 } else {
709 "metadata forgotten; files left alone"
710 };
711 println!(
712 "{} Forgot {} downloads ({})",
713 style.bold_green("✓"),
714 targets.len(),
715 style.dim(note)
716 );
717 }
718 Ok(())
719}
720
721fn truncate(s: &str, max: usize) -> String {
722 if s.chars().count() <= max {
723 return s.to_string();
724 }
725 let keep: String = s.chars().take(max.saturating_sub(1)).collect();
726 format!("{keep}…")
727}
728
729fn install_signal_handlers(cancel: Cancel) {
731 tokio::spawn({
732 let cancel = cancel.clone();
733 async move {
734 let mut hits = 0u32;
735 loop {
736 if tokio::signal::ctrl_c().await.is_err() {
737 return;
738 }
739 hits += 1;
740 if hits == 1 {
741 eprintln!("\nPausing download...");
742 cancel.cancel();
743 } else {
744 eprintln!("Forcing exit; progress up to the last checkpoint is saved.");
745 std::process::exit(EXIT_INTERRUPTED);
746 }
747 }
748 }
749 });
750
751 #[cfg(unix)]
752 tokio::spawn(async move {
753 use tokio::signal::unix::{SignalKind, signal};
754 let Ok(mut term) = signal(SignalKind::terminate()) else {
755 return;
756 };
757 if term.recv().await.is_some() {
758 eprintln!("\nTerminated; saving progress...");
759 cancel.cancel();
760 }
761 });
762}
763
764fn watch_shutdown_deadline(cancel: Cancel) {
768 tokio::spawn(async move {
769 cancel.cancelled().await;
770 tokio::time::sleep(Duration::from_secs(15)).await;
771 eprintln!("Workers did not stop in time; exiting.");
772 std::process::exit(EXIT_INTERRUPTED);
773 });
774}
775
776#[cfg(test)]
777mod tests {
778 use super::*;
779 use clap::CommandFactory;
780
781 fn parse(args: &[&str]) -> Cli {
782 Cli::parse_from(args)
783 }
784
785 #[test]
786 fn clap_definition_is_valid() {
787 Cli::command().debug_assert();
788 }
789
790 #[test]
791 fn plain_url_is_a_download() {
792 let cli = parse(&["rget", "https://example.com/f.iso"]);
793 assert!(cli.command.is_none());
794 assert_eq!(cli.get.urls, vec!["https://example.com/f.iso"]);
795 assert_eq!(cli.get.connections().unwrap(), DEFAULT_CONNECTIONS);
796 }
797
798 #[test]
799 fn several_urls_are_mirrors() {
800 let cli = parse(&["rget", "https://a/f.iso", "https://b/f.iso"]);
801 assert_eq!(cli.get.urls.len(), 2);
802 let urls = cli.get.parse_urls().unwrap();
803 assert_eq!(urls[0].host_str(), Some("a"));
804 }
805
806 #[test]
807 fn subcommands_win_over_urls() {
808 let cli = parse(&["rget", "list"]);
809 assert!(matches!(cli.command, Some(Command::List)));
810 assert!(cli.get.urls.is_empty());
811
812 let cli = parse(&["rget", "resume", "--all"]);
813 match cli.command {
814 Some(Command::Resume { id, all }) => {
815 assert!(id.is_none());
816 assert!(all);
817 }
818 other => panic!("expected resume, got {other:?}"),
819 }
820
821 let cli = parse(&["rget", "forget", "--all", "--files"]);
822 match cli.command {
823 Some(Command::Forget { id, all, files }) => {
824 assert!(id.is_none());
825 assert!(all);
826 assert!(files);
827 }
828 other => panic!("expected forget, got {other:?}"),
829 }
830
831 let cli = parse(&["rget", "forget", "a82fd1", "--files"]);
832 match cli.command {
833 Some(Command::Forget { id, all, files }) => {
834 assert_eq!(id.as_deref(), Some("a82fd1"));
835 assert!(!all);
836 assert!(files);
837 }
838 other => panic!("expected forget, got {other:?}"),
839 }
840 }
841
842 #[test]
843 fn global_flags_work_with_subcommands() {
844 let cli = parse(&["rget", "--json", "list"]);
845 assert!(cli.json);
846 let cli = parse(&["rget", "list", "--json"]);
847 assert!(cli.json);
848 }
849
850 #[test]
851 fn rejects_unsupported_schemes() {
852 let cli = parse(&["rget", "ftp://example.com/f.iso"]);
853 let err = cli.get.parse_urls().unwrap_err().to_string();
854 assert!(err.contains("not supported"), "{err}");
855
856 let cli = parse(&["rget", "not a url"]);
857 assert!(cli.get.parse_urls().is_err());
858 }
859
860 #[test]
861 fn rejects_multiple_checksums() {
862 let cli = parse(&[
863 "rget",
864 "https://a/f",
865 "--sha256",
866 &"a".repeat(64),
867 "--blake3",
868 &"b".repeat(64),
869 ]);
870 assert!(cli.get.checksum().is_err());
871 }
872
873 #[test]
874 fn accepts_one_checksum() {
875 let digest = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
876 let cli = parse(&["rget", "https://a/f", "--sha256", digest]);
877 let checksum = cli.get.checksum().unwrap().unwrap();
878 assert_eq!(checksum.algorithm, Algorithm::Sha256);
879 assert_eq!(checksum.expected, digest);
880 }
881
882 #[test]
883 fn validates_connection_counts() {
884 let cli = parse(&["rget", "https://a/f", "-c", "0"]);
885 assert!(cli.get.connections().is_err());
886 let cli = parse(&["rget", "https://a/f", "-c", "1000"]);
887 assert!(cli.get.connections().is_err());
888 let cli = parse(&["rget", "https://a/f", "-c", "16"]);
889 assert_eq!(cli.get.connections().unwrap(), 16);
890 }
891
892 #[test]
893 fn parses_headers_and_auth() {
894 let cli = parse(&[
895 "rget",
896 "https://a/f",
897 "--header",
898 "X-Token: abc",
899 "--user",
900 "alice:s3cret",
901 ]);
902 let cfg = cli.get.http_config().unwrap();
903 assert_eq!(
904 cfg.headers,
905 vec![("X-Token".to_string(), " abc".to_string())]
906 );
907 assert_eq!(cfg.basic_auth, Some(("alice".into(), "s3cret".into())));
908
909 let cli = parse(&["rget", "https://a/f", "--header", "nonsense"]);
910 assert!(cli.get.http_config().is_err());
911 let cli = parse(&["rget", "https://a/f", "--user", "nocolon"]);
912 assert!(cli.get.http_config().is_err());
913 }
914
915 #[test]
916 fn parses_limits_and_timeouts() {
917 let cli = parse(&[
918 "rget",
919 "https://a/f",
920 "--limit",
921 "20MiB/s",
922 "--timeout",
923 "45s",
924 ]);
925 let req = cli.get.to_request(cli.get.parse_urls().unwrap()).unwrap();
926 assert_eq!(req.limit, Some(20 * 1024 * 1024));
927 assert_eq!(req.http.timeout, Duration::from_secs(45));
928
929 let cli = parse(&["rget", "https://a/f", "--limit", "fast"]);
930 assert!(cli.get.to_request(vec![]).is_err());
931 }
932
933 #[test]
934 fn preallocation_is_on_by_default() {
935 let cli = parse(&["rget", "https://a/f"]);
936 assert!(cli.get.to_request(vec![]).unwrap().preallocate);
937 let cli = parse(&["rget", "https://a/f", "--no-preallocate"]);
938 assert!(!cli.get.to_request(vec![]).unwrap().preallocate);
939 }
940
941 fn visible(s: &str) -> String {
944 let mut out = String::new();
945 let mut chars = s.chars();
946 while let Some(c) = chars.next() {
947 if c == '\x1b' {
948 for c in chars.by_ref() {
949 if c == 'm' {
950 break;
951 }
952 }
953 } else {
954 out.push(c);
955 }
956 }
957 out
958 }
959
960 fn column_of(line: &str, needle: &str) -> Option<usize> {
963 let byte = line.find(needle)?;
964 Some(line[..byte].chars().count())
965 }
966
967 fn listed(id: &str, filename: &str, total: Option<u64>, done: u64) -> DownloadRecord {
968 DownloadRecord {
969 id: id.into(),
970 original_url: "https://x.example/f".into(),
971 resolved_url: None,
972 mirrors: vec![],
973 destination: "/tmp/f".into(),
974 filename: filename.into(),
975 total_size: total,
976 etag: None,
977 last_modified: None,
978 content_type: None,
979 accept_ranges: true,
980 expected_checksum: None,
981 checksum_algorithm: None,
982 file_cookie: "cookie".into(),
983 file_dev: None,
984 file_ino: None,
985 durable_bytes: done,
986 status: Status::Paused,
987 error: None,
988 created_at: 0,
989 updated_at: 0,
990 completed_at: None,
991 }
992 }
993
994 #[test]
998 fn list_columns_line_up_with_colour_on() {
999 let style = fmt::Style::new(true);
1000 if !style.is_enabled() {
1001 return; }
1003 let header = visible(&list_header(&style));
1004 let status_col = column_of(&header, "STATUS").expect("header has a STATUS column");
1005
1006 for record in [
1007 listed("ab12cd", "short.iso", Some(1000), 500),
1008 listed(
1009 "ef34gh",
1010 "a-considerably-longer-filename.tar.gz",
1011 Some(1 << 30),
1012 0,
1013 ),
1014 listed("ij56kl", "unknown-size.bin", None, 0),
1015 listed("mn78op", "done.bin", Some(10), 10),
1016 ] {
1017 let row = visible(&list_row(&record, &style));
1018 let plain_row = visible(&list_row(&record, &fmt::Style::new(false)));
1019 assert_eq!(
1020 row, plain_row,
1021 "styled and unstyled rows must occupy identical columns"
1022 );
1023 let status = visible(&colour_status(&style, record.status));
1024 let at = column_of(&row, &status).expect("row has a status");
1025 assert_eq!(
1026 at, status_col,
1027 "status column misaligned for {}: {row:?} vs header {header:?}",
1028 record.filename
1029 );
1030 }
1031 }
1032
1033 #[test]
1034 fn list_bar_reflects_progress() {
1035 let style = fmt::Style::new(false);
1036 assert!(list_row(&listed("a", "f", Some(100), 0), &style).contains("░"));
1037 let full = list_row(&listed("a", "f", Some(100), 100), &style);
1038 assert!(full.contains("█"));
1039 assert!(
1040 !full.contains("░"),
1041 "a finished bar should be solid: {full}"
1042 );
1043 assert!(list_row(&listed("a", "f", None, 50), &style).contains("--"));
1045 }
1046
1047 #[test]
1048 fn truncates_long_filenames_for_the_table() {
1049 assert_eq!(truncate("short.iso", 24), "short.iso");
1050 let long = truncate(&"x".repeat(40), 10);
1051 assert_eq!(long.chars().count(), 10);
1052 assert!(long.ends_with('…'));
1053 }
1054}