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 let mut http = self.http_config()?;
266 if http.basic_auth.is_none() {
269 if let Some(host) = urls.first().and_then(|u| u.host_str()) {
270 http.basic_auth = crate::netrc::lookup(host);
271 }
272 }
273 Ok(DownloadRequest {
274 urls,
275 output: self.output.clone(),
276 dir: self.dir.clone(),
277 connections: self.connections()?,
278 checksum: self.checksum()?,
279 limit: match &self.limit {
280 Some(raw) => Some(limit::parse_rate(raw).map_err(|e| anyhow::anyhow!(e))?),
281 None => None,
282 },
283 http,
284 retries: self.retries,
285 overwrite: self.overwrite,
286 restart: self.restart,
287 preallocate: !self.no_preallocate,
288 })
289 }
290}
291
292pub const EXIT_OK: i32 = 0;
294pub const EXIT_FAILURE: i32 = 1;
295pub const EXIT_INTERRUPTED: i32 = 130;
296
297pub async fn dispatch(cli: Cli) -> Result<i32> {
298 let store = Arc::new(Store::open_default()?);
299
300 match &cli.command {
301 Some(Command::List) => {
302 cmd_list(&store, cli.json)?;
303 Ok(EXIT_OK)
304 }
305 Some(Command::Info { id }) => {
306 cmd_info(&store, id, cli.json)?;
307 Ok(EXIT_OK)
308 }
309 Some(Command::Forget { id, all, files }) => {
310 cmd_forget(&store, id.as_deref(), *all, *files)?;
311 Ok(EXIT_OK)
312 }
313 Some(Command::Config { dir, reset }) => {
314 cmd_config(&store, dir.as_deref(), *reset, cli.json)?;
315 Ok(EXIT_OK)
316 }
317 Some(Command::Resume { id, all }) => cmd_resume(&store, &cli, id.as_deref(), *all).await,
318 None => {
319 if cli.get.urls.is_empty() {
320 use clap::CommandFactory;
322 Cli::command().print_help()?;
323 println!();
324 return Ok(EXIT_FAILURE);
325 }
326 let urls = cli.get.parse_urls()?;
327 let mut request = cli.get.to_request(urls)?;
328 request.dir = Some(resolve_dir(&store, &cli)?);
330 run_download(store, request, &cli).await
331 }
332 }
333}
334
335fn resolve_dir(store: &Store, cli: &Cli) -> Result<String> {
338 let machine_output = cli.json || cli.quiet;
339 let resolved = config::resolve_download_dir(store, cli.get.dir.as_deref(), |default| {
340 config::prompt_for_download_dir(default, machine_output)
341 })?;
342
343 if cli.verbose {
344 eprintln!(
345 " downloading into {} ({})",
346 config::tildify(&resolved.path),
347 match resolved.source {
348 config::DirSource::Flag => "--dir",
349 config::DirSource::Saved => "saved setting",
350 config::DirSource::Prompted => "just chosen",
351 config::DirSource::PlatformDefault => "platform default",
352 }
353 );
354 }
355 Ok(resolved.path.to_string_lossy().to_string())
356}
357
358async fn run_download(store: Arc<Store>, request: DownloadRequest, cli: &Cli) -> Result<i32> {
360 let mode = ui::Mode::detect(cli.json, cli.quiet, cli.verbose);
361 let (reporter, rx) = Reporter::new();
362 let cancel = Cancel::new();
363
364 let ui_task = tokio::spawn(ui::run(mode, reporter.stats.clone(), rx, cli.verbose));
365 install_signal_handlers(cancel.clone());
366 watch_shutdown_deadline(cancel.clone());
367
368 let result = engine::download(store, request, reporter.clone(), cancel).await;
369
370 drop(reporter);
372 let _ = ui_task.await;
373
374 match result {
375 Ok(report) if report.paused => Ok(EXIT_INTERRUPTED),
376 Ok(_) => Ok(EXIT_OK),
377 Err(err) => Err(err),
378 }
379}
380
381async fn cmd_resume(store: &Arc<Store>, cli: &Cli, id: Option<&str>, all: bool) -> Result<i32> {
382 let targets: Vec<DownloadRecord> = match (id, all) {
383 (Some(_), true) => bail!("pass either an id or --all, not both"),
384 (Some(id), false) => vec![store.resolve_id(id)?],
385 (None, true) => store.list_resumable()?,
386 (None, false) => bail!("which download? pass an id or --all (see `rget list`)"),
387 };
388
389 if targets.is_empty() {
390 if !cli.quiet {
391 println!("Nothing to resume.");
392 }
393 return Ok(EXIT_OK);
394 }
395
396 let mut worst = EXIT_OK;
397 for record in targets {
398 if record.status == Status::Complete {
399 if !cli.quiet {
400 println!("{} is already complete.", record.filename);
401 }
402 continue;
403 }
404 let request = request_from_record(cli, &record)?;
405 match run_download(store.clone(), request, cli).await {
406 Ok(EXIT_OK) => {}
407 Ok(code) => worst = worst.max(code),
408 Err(err) => {
409 eprintln!("{}: {err:#}", record.filename);
411 worst = EXIT_FAILURE;
412 }
413 }
414 }
415 Ok(worst)
416}
417
418fn request_from_record(cli: &Cli, record: &DownloadRecord) -> Result<DownloadRequest> {
421 let mut urls = vec![
422 Url::parse(&record.original_url)
423 .with_context(|| format!("stored URL is invalid: {}", record.original_url))?,
424 ];
425 for mirror in &record.mirrors {
426 if let Ok(url) = Url::parse(mirror) {
427 urls.push(url);
428 }
429 }
430
431 let checksum = match (&record.expected_checksum, &record.checksum_algorithm) {
432 (Some(digest), Some(algo)) => Some(Checksum::parse(algo.parse::<Algorithm>()?, digest)?),
433 _ => None,
434 };
435
436 let mut request = cli.get.to_request(urls)?;
437 request.output = Some(record.destination.clone());
439 request.dir = None;
440 if request.checksum.is_none() {
441 request.checksum = checksum;
442 }
443 Ok(request)
444}
445
446fn cmd_list(store: &Store, json: bool) -> Result<()> {
447 let downloads = store.list()?;
448 if json {
449 println!("{}", serde_json::to_string_pretty(&downloads)?);
450 return Ok(());
451 }
452 if downloads.is_empty() {
453 println!("No downloads yet.");
454 return Ok(());
455 }
456
457 let style = fmt::Style::stdout();
458 println!("{}", list_header(&style));
459 for d in downloads {
460 println!("{}", list_row(&d, &style));
461 }
462 Ok(())
463}
464
465const ID_W: usize = 8;
467const NAME_W: usize = 26;
468const BAR_W: usize = 10;
469const PCT_W: usize = 5;
470
471fn list_header(style: &fmt::Style) -> String {
472 style.dim(&format!(
473 "{:<ID_W$} {:<NAME_W$} {:<BAR_W$} {:<PCT_W$} STATUS",
474 "ID", "FILE", "PROGRESS", ""
475 ))
476}
477
478fn list_row(d: &DownloadRecord, style: &fmt::Style) -> String {
479 let pct = match d.total_size {
480 Some(total) if total > 0 => {
481 format!("{:.0}%", (d.durable_bytes as f64 / total as f64) * 100.0)
482 }
483 _ => "--".to_string(),
484 };
485 let (filled, empty) = fmt::bar_parts(d.durable_bytes, d.total_size, BAR_W);
488 format!(
491 "{} {} {}{} {:>PCT_W$} {}",
492 style.dim(&format!("{:<ID_W$}", d.id)),
493 format_args!("{:<NAME_W$}", truncate(&d.filename, NAME_W)),
494 style.bright_green(&filled),
495 style.dim(&empty),
496 pct,
497 colour_status(style, d.status),
498 )
499}
500
501fn colour_status(style: &fmt::Style, status: Status) -> String {
504 let text = status.as_str();
505 match status {
506 Status::Complete => style.green(text),
507 Status::Downloading => style.bright_cyan(text),
508 Status::Verifying => style.cyan(text),
509 Status::Paused => style.yellow(text),
510 Status::Failed => style.red(text),
511 Status::Pending => style.dim(text),
512 }
513}
514
515fn cmd_info(store: &Store, id: &str, json: bool) -> Result<()> {
516 let record = store.resolve_id(id)?;
517 let ranges = store.load_ranges(&record.id)?;
518
519 if json {
520 println!(
521 "{}",
522 serde_json::to_string_pretty(&serde_json::json!({
523 "download": record,
524 "ranges": ranges,
525 }))?
526 );
527 return Ok(());
528 }
529
530 let style = fmt::Style::stdout();
531 let field = |label: &str, value: &str| {
532 println!(" {} {value}", style.dim(&format!("{label:<13}")));
533 };
534
535 println!(
536 "{} {}",
537 style.dim(&record.id),
538 style.bold(&record.filename)
539 );
540 field("status", &colour_status(&style, record.status));
541 field("url", &record.original_url);
542 if let Some(resolved) = &record.resolved_url {
543 if resolved != &record.original_url {
544 field("resolved", &style.dim(resolved));
545 }
546 }
547 for mirror in &record.mirrors {
548 field("mirror", &style.dim(mirror));
549 }
550 field("destination", &record.destination);
551 field(
552 "size",
553 &record
554 .total_size
555 .map(fmt::bytes)
556 .unwrap_or_else(|| "unknown".into()),
557 );
558
559 let pct = match record.total_size {
560 Some(t) if t > 0 => (record.durable_bytes as f64 / t as f64) * 100.0,
561 _ => 0.0,
562 };
563 let (filled, empty) = fmt::bar_parts(record.durable_bytes, record.total_size, 20);
564 field(
565 "downloaded",
566 &format!(
567 "{}{} {} {}",
568 style.bright_green(&filled),
569 style.dim(&empty),
570 style.bold(&format!("{pct:.1}%")),
571 style.dim(&fmt::bytes(record.durable_bytes)),
572 ),
573 );
574
575 let complete = ranges
576 .iter()
577 .filter(|r| r.state == RangeState::Complete)
578 .count();
579 field(
580 "ranges",
581 &format!(
582 "{} {} {}",
583 style.magenta(&format!("{complete}/{}", ranges.len())),
584 style.dim("complete ·"),
585 style.dim(if record.accept_ranges {
586 "server supports resuming"
587 } else {
588 "server cannot resume"
589 }),
590 ),
591 );
592 if let Some(etag) = &record.etag {
593 field("etag", etag);
594 }
595 if let Some(lm) = &record.last_modified {
596 field("last-modified", lm);
597 }
598 if let (Some(algo), Some(digest)) = (&record.checksum_algorithm, &record.expected_checksum) {
599 field(algo, &style.dim(digest));
600 }
601 if let Some(err) = &record.error {
602 field("last error", &style.red(err));
603 }
604 Ok(())
605}
606
607fn cmd_config(store: &Store, dir: Option<&str>, reset: bool, json: bool) -> Result<()> {
608 if reset {
609 store.clear_meta(config::DOWNLOAD_DIR_KEY)?;
610 let style = fmt::Style::stdout();
611 println!(
612 "{} Forgot the saved download folder; the next download will ask again.",
613 style.bold_green("✓")
614 );
615 return Ok(());
616 }
617
618 if let Some(dir) = dir {
619 let path = config::normalise_dir(dir)?;
620 config::save_download_dir(store, &path)?;
621 let style = fmt::Style::stdout();
622 println!(
623 "{} Downloads will be saved to {}",
624 style.bold_green("✓"),
625 style.bold(&config::tildify(&path))
626 );
627 return Ok(());
628 }
629
630 let saved = config::saved_download_dir(store)?;
631 let effective = saved.clone().unwrap_or_else(config::platform_download_dir);
632
633 if json {
634 println!(
635 "{}",
636 serde_json::to_string_pretty(&serde_json::json!({
637 "download_dir": effective.to_string_lossy(),
638 "download_dir_is_saved": saved.is_some(),
639 "platform_default": config::platform_download_dir().to_string_lossy(),
640 "state_database": store.path().to_string_lossy(),
641 }))?
642 );
643 return Ok(());
644 }
645
646 let style = fmt::Style::stdout();
647 println!(
648 " {} {}{}",
649 style.dim("download folder "),
650 style.bold(&config::tildify(&effective)),
651 if saved.is_none() {
652 style.dim(" (platform default; not saved yet)")
653 } else {
654 String::new()
655 }
656 );
657 println!(
658 " {} {}",
659 style.dim("state database "),
660 style.dim(&store.path().display().to_string())
661 );
662 println!();
663 println!(
664 "{}",
665 style.dim("Change it with `rget config --dir <path>`, or `--reset` to be asked again.")
666 );
667 Ok(())
668}
669
670fn cmd_forget(store: &Store, id: Option<&str>, all: bool, files: bool) -> Result<()> {
671 let targets: Vec<DownloadRecord> = match (id, all) {
672 (Some(_), true) => bail!("pass either an id or --all, not both"),
673 (Some(id), false) => vec![store.resolve_id(id)?],
674 (None, true) => store.list()?,
675 (None, false) => bail!("which download? pass an id or --all (see `rget list`)"),
676 };
677
678 let style = fmt::Style::stdout();
679 if targets.is_empty() {
680 println!("Nothing to forget.");
681 return Ok(());
682 }
683
684 for record in &targets {
685 if files {
686 let path = std::path::Path::new(&record.destination);
687 match std::fs::remove_file(path) {
688 Ok(()) => {}
689 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
690 Err(err) => {
691 return Err(err).with_context(|| format!("cannot delete {}", path.display()));
692 }
693 }
694 }
695 store.forget(&record.id)?;
696 }
697
698 if targets.len() == 1 {
699 let record = &targets[0];
700 let note = if files {
701 format!("\n deleted {}", record.destination)
702 } else {
703 format!("\n the file at {} was left alone", record.destination)
704 };
705 println!(
706 "{} Forgot {} ({}){}",
707 style.bold_green("✓"),
708 style.dim(&record.id),
709 style.bold(&record.filename),
710 style.dim(¬e)
711 );
712 } else {
713 let note = if files {
714 "metadata and files deleted"
715 } else {
716 "metadata forgotten; files left alone"
717 };
718 println!(
719 "{} Forgot {} downloads ({})",
720 style.bold_green("✓"),
721 targets.len(),
722 style.dim(note)
723 );
724 }
725 Ok(())
726}
727
728fn truncate(s: &str, max: usize) -> String {
729 if s.chars().count() <= max {
730 return s.to_string();
731 }
732 let keep: String = s.chars().take(max.saturating_sub(1)).collect();
733 format!("{keep}…")
734}
735
736fn install_signal_handlers(cancel: Cancel) {
738 tokio::spawn({
739 let cancel = cancel.clone();
740 async move {
741 let mut hits = 0u32;
742 loop {
743 if tokio::signal::ctrl_c().await.is_err() {
744 return;
745 }
746 hits += 1;
747 if hits == 1 {
748 eprintln!("\nPausing download...");
749 cancel.cancel();
750 } else {
751 eprintln!("Forcing exit; progress up to the last checkpoint is saved.");
752 std::process::exit(EXIT_INTERRUPTED);
753 }
754 }
755 }
756 });
757
758 #[cfg(unix)]
759 tokio::spawn(async move {
760 use tokio::signal::unix::{SignalKind, signal};
761 let Ok(mut term) = signal(SignalKind::terminate()) else {
762 return;
763 };
764 if term.recv().await.is_some() {
765 eprintln!("\nTerminated; saving progress...");
766 cancel.cancel();
767 }
768 });
769}
770
771fn watch_shutdown_deadline(cancel: Cancel) {
775 tokio::spawn(async move {
776 cancel.cancelled().await;
777 tokio::time::sleep(Duration::from_secs(15)).await;
778 eprintln!("Workers did not stop in time; exiting.");
779 std::process::exit(EXIT_INTERRUPTED);
780 });
781}
782
783#[cfg(test)]
784mod tests {
785 use super::*;
786 use clap::CommandFactory;
787
788 fn parse(args: &[&str]) -> Cli {
789 Cli::parse_from(args)
790 }
791
792 #[test]
793 fn clap_definition_is_valid() {
794 Cli::command().debug_assert();
795 }
796
797 #[test]
798 fn plain_url_is_a_download() {
799 let cli = parse(&["rget", "https://example.com/f.iso"]);
800 assert!(cli.command.is_none());
801 assert_eq!(cli.get.urls, vec!["https://example.com/f.iso"]);
802 assert_eq!(cli.get.connections().unwrap(), DEFAULT_CONNECTIONS);
803 }
804
805 #[test]
806 fn several_urls_are_mirrors() {
807 let cli = parse(&["rget", "https://a/f.iso", "https://b/f.iso"]);
808 assert_eq!(cli.get.urls.len(), 2);
809 let urls = cli.get.parse_urls().unwrap();
810 assert_eq!(urls[0].host_str(), Some("a"));
811 }
812
813 #[test]
814 fn subcommands_win_over_urls() {
815 let cli = parse(&["rget", "list"]);
816 assert!(matches!(cli.command, Some(Command::List)));
817 assert!(cli.get.urls.is_empty());
818
819 let cli = parse(&["rget", "resume", "--all"]);
820 match cli.command {
821 Some(Command::Resume { id, all }) => {
822 assert!(id.is_none());
823 assert!(all);
824 }
825 other => panic!("expected resume, got {other:?}"),
826 }
827
828 let cli = parse(&["rget", "forget", "--all", "--files"]);
829 match cli.command {
830 Some(Command::Forget { id, all, files }) => {
831 assert!(id.is_none());
832 assert!(all);
833 assert!(files);
834 }
835 other => panic!("expected forget, got {other:?}"),
836 }
837
838 let cli = parse(&["rget", "forget", "a82fd1", "--files"]);
839 match cli.command {
840 Some(Command::Forget { id, all, files }) => {
841 assert_eq!(id.as_deref(), Some("a82fd1"));
842 assert!(!all);
843 assert!(files);
844 }
845 other => panic!("expected forget, got {other:?}"),
846 }
847 }
848
849 #[test]
850 fn global_flags_work_with_subcommands() {
851 let cli = parse(&["rget", "--json", "list"]);
852 assert!(cli.json);
853 let cli = parse(&["rget", "list", "--json"]);
854 assert!(cli.json);
855 }
856
857 #[test]
858 fn rejects_unsupported_schemes() {
859 let cli = parse(&["rget", "ftp://example.com/f.iso"]);
860 let err = cli.get.parse_urls().unwrap_err().to_string();
861 assert!(err.contains("not supported"), "{err}");
862
863 let cli = parse(&["rget", "not a url"]);
864 assert!(cli.get.parse_urls().is_err());
865 }
866
867 #[test]
868 fn rejects_multiple_checksums() {
869 let cli = parse(&[
870 "rget",
871 "https://a/f",
872 "--sha256",
873 &"a".repeat(64),
874 "--blake3",
875 &"b".repeat(64),
876 ]);
877 assert!(cli.get.checksum().is_err());
878 }
879
880 #[test]
881 fn accepts_one_checksum() {
882 let digest = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
883 let cli = parse(&["rget", "https://a/f", "--sha256", digest]);
884 let checksum = cli.get.checksum().unwrap().unwrap();
885 assert_eq!(checksum.algorithm, Algorithm::Sha256);
886 assert_eq!(checksum.expected, digest);
887 }
888
889 #[test]
890 fn validates_connection_counts() {
891 let cli = parse(&["rget", "https://a/f", "-c", "0"]);
892 assert!(cli.get.connections().is_err());
893 let cli = parse(&["rget", "https://a/f", "-c", "1000"]);
894 assert!(cli.get.connections().is_err());
895 let cli = parse(&["rget", "https://a/f", "-c", "16"]);
896 assert_eq!(cli.get.connections().unwrap(), 16);
897 }
898
899 #[test]
900 fn parses_headers_and_auth() {
901 let cli = parse(&[
902 "rget",
903 "https://a/f",
904 "--header",
905 "X-Token: abc",
906 "--user",
907 "alice:s3cret",
908 ]);
909 let cfg = cli.get.http_config().unwrap();
910 assert_eq!(
911 cfg.headers,
912 vec![("X-Token".to_string(), " abc".to_string())]
913 );
914 assert_eq!(cfg.basic_auth, Some(("alice".into(), "s3cret".into())));
915
916 let cli = parse(&["rget", "https://a/f", "--header", "nonsense"]);
917 assert!(cli.get.http_config().is_err());
918 let cli = parse(&["rget", "https://a/f", "--user", "nocolon"]);
919 assert!(cli.get.http_config().is_err());
920 }
921
922 #[test]
923 fn parses_limits_and_timeouts() {
924 let cli = parse(&[
925 "rget",
926 "https://a/f",
927 "--limit",
928 "20MiB/s",
929 "--timeout",
930 "45s",
931 ]);
932 let req = cli.get.to_request(cli.get.parse_urls().unwrap()).unwrap();
933 assert_eq!(req.limit, Some(20 * 1024 * 1024));
934 assert_eq!(req.http.timeout, Duration::from_secs(45));
935
936 let cli = parse(&["rget", "https://a/f", "--limit", "fast"]);
937 assert!(cli.get.to_request(vec![]).is_err());
938 }
939
940 #[test]
941 fn preallocation_is_on_by_default() {
942 let cli = parse(&["rget", "https://a/f"]);
943 assert!(cli.get.to_request(vec![]).unwrap().preallocate);
944 let cli = parse(&["rget", "https://a/f", "--no-preallocate"]);
945 assert!(!cli.get.to_request(vec![]).unwrap().preallocate);
946 }
947
948 fn visible(s: &str) -> String {
951 let mut out = String::new();
952 let mut chars = s.chars();
953 while let Some(c) = chars.next() {
954 if c == '\x1b' {
955 for c in chars.by_ref() {
956 if c == 'm' {
957 break;
958 }
959 }
960 } else {
961 out.push(c);
962 }
963 }
964 out
965 }
966
967 fn column_of(line: &str, needle: &str) -> Option<usize> {
970 let byte = line.find(needle)?;
971 Some(line[..byte].chars().count())
972 }
973
974 fn listed(id: &str, filename: &str, total: Option<u64>, done: u64) -> DownloadRecord {
975 DownloadRecord {
976 id: id.into(),
977 original_url: "https://x.example/f".into(),
978 resolved_url: None,
979 mirrors: vec![],
980 destination: "/tmp/f".into(),
981 filename: filename.into(),
982 total_size: total,
983 etag: None,
984 last_modified: None,
985 content_type: None,
986 accept_ranges: true,
987 expected_checksum: None,
988 checksum_algorithm: None,
989 file_cookie: "cookie".into(),
990 file_dev: None,
991 file_ino: None,
992 durable_bytes: done,
993 status: Status::Paused,
994 error: None,
995 created_at: 0,
996 updated_at: 0,
997 completed_at: None,
998 }
999 }
1000
1001 #[test]
1005 fn list_columns_line_up_with_colour_on() {
1006 let style = fmt::Style::new(true);
1007 if !style.is_enabled() {
1008 return; }
1010 let header = visible(&list_header(&style));
1011 let status_col = column_of(&header, "STATUS").expect("header has a STATUS column");
1012
1013 for record in [
1014 listed("ab12cd", "short.iso", Some(1000), 500),
1015 listed(
1016 "ef34gh",
1017 "a-considerably-longer-filename.tar.gz",
1018 Some(1 << 30),
1019 0,
1020 ),
1021 listed("ij56kl", "unknown-size.bin", None, 0),
1022 listed("mn78op", "done.bin", Some(10), 10),
1023 ] {
1024 let row = visible(&list_row(&record, &style));
1025 let plain_row = visible(&list_row(&record, &fmt::Style::new(false)));
1026 assert_eq!(
1027 row, plain_row,
1028 "styled and unstyled rows must occupy identical columns"
1029 );
1030 let status = visible(&colour_status(&style, record.status));
1031 let at = column_of(&row, &status).expect("row has a status");
1032 assert_eq!(
1033 at, status_col,
1034 "status column misaligned for {}: {row:?} vs header {header:?}",
1035 record.filename
1036 );
1037 }
1038 }
1039
1040 #[test]
1041 fn list_bar_reflects_progress() {
1042 let style = fmt::Style::new(false);
1043 assert!(list_row(&listed("a", "f", Some(100), 0), &style).contains("░"));
1044 let full = list_row(&listed("a", "f", Some(100), 100), &style);
1045 assert!(full.contains("█"));
1046 assert!(
1047 !full.contains("░"),
1048 "a finished bar should be solid: {full}"
1049 );
1050 assert!(list_row(&listed("a", "f", None, 50), &style).contains("--"));
1052 }
1053
1054 #[test]
1055 fn truncates_long_filenames_for_the_table() {
1056 assert_eq!(truncate("short.iso", 24), "short.iso");
1057 let long = truncate(&"x".repeat(40), 10);
1058 assert_eq!(long.chars().count(), 10);
1059 assert!(long.ends_with('…'));
1060 }
1061}