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