Skip to main content

rget/
cli.rs

1//! Argument parsing and command dispatch (PRD §37).
2
3use 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
21/// Conservative by default: enough connections to saturate a fast link, few
22/// enough that no reasonable origin treats us as abuse (PRD §7).
23pub 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    /// Only report errors and the final result
51    #[arg(long, global = true)]
52    pub quiet: bool,
53
54    /// Explain what is happening, including resume decisions
55    #[arg(short, long, global = true)]
56    pub verbose: bool,
57
58    /// Emit machine-readable progress events on stdout, one JSON object per line
59    #[arg(long, global = true)]
60    pub json: bool,
61}
62
63#[derive(Subcommand, Debug)]
64pub enum Command {
65    /// List every download this machine knows about
66    List,
67    /// Show everything recorded about one download
68    Info {
69        /// Download id, or any unambiguous prefix
70        id: String,
71    },
72    /// Continue an interrupted download
73    Resume {
74        /// Download id, or any unambiguous prefix
75        id: Option<String>,
76        /// Resume every interrupted download, one after another
77        #[arg(long)]
78        all: bool,
79    },
80    /// Forget a download's metadata. Never deletes the downloaded file
81    Forget {
82        /// Download id, or any unambiguous prefix
83        id: String,
84    },
85    /// Show or change settings
86    Config {
87        /// Set the folder downloads go to when no --dir is given
88        #[arg(long, value_name = "DIR")]
89        dir: Option<String>,
90        /// Forget the saved folder, so the next download asks again
91        #[arg(long, conflicts_with = "dir")]
92        reset: bool,
93    },
94}
95
96#[derive(Args, Debug, Default)]
97pub struct GetArgs {
98    /// URL to download. Pass several for mirrors of the same file
99    #[arg(value_name = "URL")]
100    pub urls: Vec<String>,
101
102    /// Write to this filename instead of the one the server suggests
103    #[arg(short, long, value_name = "FILE")]
104    pub output: Option<String>,
105
106    /// Directory to download into
107    #[arg(long, value_name = "DIR")]
108    pub dir: Option<String>,
109
110    /// Parallel connections to use when the server supports ranges
111    #[arg(short = 'c', long, value_name = "N")]
112    pub connections: Option<usize>,
113
114    /// Verify the finished file against this SHA-256 digest
115    #[arg(long, value_name = "HEX")]
116    pub sha256: Option<String>,
117
118    /// Verify the finished file against this SHA-512 digest
119    #[arg(long, value_name = "HEX")]
120    pub sha512: Option<String>,
121
122    /// Verify the finished file against this BLAKE3 digest
123    #[arg(long, value_name = "HEX")]
124    pub blake3: Option<String>,
125
126    /// Cap total download speed, e.g. 20MiB/s
127    #[arg(long, value_name = "RATE")]
128    pub limit: Option<String>,
129
130    /// Give up on a stalled connection after this long, e.g. 30s
131    #[arg(long, value_name = "DURATION", default_value = "30s")]
132    pub timeout: String,
133
134    /// Attempts per range before giving up
135    #[arg(long, value_name = "N", default_value_t = 10)]
136    pub retries: u32,
137
138    /// Extra request header, repeatable: --header 'Key: value'
139    #[arg(long = "header", value_name = "KEY:VALUE")]
140    pub headers: Vec<String>,
141
142    /// User-Agent to send
143    #[arg(long, value_name = "STRING")]
144    pub user_agent: Option<String>,
145
146    /// Proxy URL, e.g. http://localhost:8080 or socks5://localhost:1080
147    #[arg(long, value_name = "URL")]
148    pub proxy: Option<String>,
149
150    /// HTTP basic auth, as user:password
151    #[arg(long, value_name = "USER:PASS")]
152    pub user: Option<String>,
153
154    /// Replace an existing file at the destination
155    #[arg(long)]
156    pub overwrite: bool,
157
158    /// Throw away existing progress and download again from the start
159    #[arg(long)]
160    pub restart: bool,
161
162    /// Do not reserve the file's full size up front
163    #[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
276/// Process exit codes. `130` is the shell convention for SIGINT.
277pub 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                // No URL and no subcommand: show help rather than a bare error.
305                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            // Ask where downloads go, once, before anything touches the network.
313            request.dir = Some(resolve_dir(&store, &cli)?);
314            run_download(store, request, &cli).await
315        }
316    }
317}
318
319/// Settle the destination folder: `--dir`, else the saved setting, else ask
320/// (first run only), else the platform's Downloads folder.
321fn 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
342/// Run one download with a UI attached and signals wired up.
343async 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    // Dropping the reporter closes the event channel, which ends the UI task.
355    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                // One failed download must not abandon the rest of --all.
394                eprintln!("{}: {err:#}", record.filename);
395                worst = EXIT_FAILURE;
396            }
397        }
398    }
399    Ok(worst)
400}
401
402/// Rebuild a request from what we persisted, so `rget resume <id>` needs no
403/// flags at all.
404fn 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    // The destination is already decided; do not re-derive it from headers.
422    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
449// Column widths, shared by the header and the rows so they cannot drift apart.
450const 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    // A miniature version of the download bar, so a glance down the column
470    // tells you how far along everything is.
471    let (filled, empty) = fmt::bar_parts(d.durable_bytes, d.total_size, BAR_W);
472    // Pad *before* styling: ANSI escapes have length but occupy no columns, so
473    // `{:<8}` applied to an already-coloured string silently does nothing.
474    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
485/// Status colours are the fastest way to read a long list: green is done,
486/// yellow is waiting for you, red needs attention.
487fn 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
679/// First Ctrl+C (or SIGTERM) pauses; a second one exits immediately (PRD §26).
680fn 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
714/// Backstop for PRD §26's "do not wait indefinitely": if the engine has not
715/// wound down a while after cancellation, exit anyway. Correctness does not
716/// depend on this — the last checkpoint is already durable.
717fn 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    /// Remove ANSI sequences so we can measure what the terminal actually
872    /// shows, rather than how many bytes we wrote.
873    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    /// Column (not byte) at which `needle` starts. The bar glyphs are three
891    /// bytes each, so byte offsets would be meaningless here.
892    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    /// Padding an already-coloured string is a silent no-op, because escape
925    /// sequences have length but occupy no columns. This is the guard against
926    /// that whole class of bug.
927    #[test]
928    fn list_columns_line_up_with_colour_on() {
929        let style = fmt::Style::new(true);
930        if !style.is_enabled() {
931            return; // NO_COLOR set in this environment.
932        }
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        // Unknown size cannot claim progress it does not know about.
974        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}