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 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    /// Only report errors and the final result
53    #[arg(long, global = true)]
54    pub quiet: bool,
55
56    /// Explain what is happening, including resume decisions
57    #[arg(short, long, global = true)]
58    pub verbose: bool,
59
60    /// Emit machine-readable progress events on stdout, one JSON object per line
61    #[arg(long, global = true)]
62    pub json: bool,
63}
64
65#[derive(Subcommand, Debug)]
66pub enum Command {
67    /// List every download this machine knows about
68    List,
69    /// Show everything recorded about one download
70    Info {
71        /// Download id, or any unambiguous prefix
72        id: String,
73    },
74    /// Continue an interrupted download
75    Resume {
76        /// Download id, or any unambiguous prefix
77        id: Option<String>,
78        /// Resume every interrupted download, one after another
79        #[arg(long)]
80        all: bool,
81    },
82    /// Forget download metadata. Leaves files on disk unless --files is set
83    Forget {
84        /// Download id, or any unambiguous prefix
85        id: Option<String>,
86        /// Forget every download this machine knows about
87        #[arg(long)]
88        all: bool,
89        /// Also delete the downloaded file(s) from disk
90        #[arg(long)]
91        files: bool,
92    },
93    /// Show or change settings
94    Config {
95        /// Set the folder downloads go to when no --dir is given
96        #[arg(long, value_name = "DIR")]
97        dir: Option<String>,
98        /// Forget the saved folder, so the next download asks again
99        #[arg(long, conflicts_with = "dir")]
100        reset: bool,
101    },
102}
103
104#[derive(Args, Debug, Default)]
105pub struct GetArgs {
106    /// URL to download. Pass several for mirrors of the same file
107    #[arg(value_name = "URL")]
108    pub urls: Vec<String>,
109
110    /// Write to this filename instead of the one the server suggests
111    #[arg(short, long, value_name = "FILE")]
112    pub output: Option<String>,
113
114    /// Directory to download into
115    #[arg(long, value_name = "DIR")]
116    pub dir: Option<String>,
117
118    /// Parallel connections to use when the server supports ranges
119    #[arg(short = 'c', long, value_name = "N")]
120    pub connections: Option<usize>,
121
122    /// Verify the finished file against this SHA-256 digest
123    #[arg(long, value_name = "HEX")]
124    pub sha256: Option<String>,
125
126    /// Verify the finished file against this SHA-512 digest
127    #[arg(long, value_name = "HEX")]
128    pub sha512: Option<String>,
129
130    /// Verify the finished file against this BLAKE3 digest
131    #[arg(long, value_name = "HEX")]
132    pub blake3: Option<String>,
133
134    /// Cap total download speed, e.g. 20MiB/s
135    #[arg(long, value_name = "RATE")]
136    pub limit: Option<String>,
137
138    /// Give up on a stalled connection after this long, e.g. 30s
139    #[arg(long, value_name = "DURATION", default_value = "30s")]
140    pub timeout: String,
141
142    /// Attempts per range before giving up
143    #[arg(long, value_name = "N", default_value_t = 10)]
144    pub retries: u32,
145
146    /// Extra request header, repeatable: --header 'Key: value'
147    #[arg(long = "header", value_name = "KEY:VALUE")]
148    pub headers: Vec<String>,
149
150    /// User-Agent to send
151    #[arg(long, value_name = "STRING")]
152    pub user_agent: Option<String>,
153
154    /// Proxy URL, e.g. http://localhost:8080 or socks5://localhost:1080
155    #[arg(long, value_name = "URL")]
156    pub proxy: Option<String>,
157
158    /// HTTP basic auth, as user:password
159    #[arg(long, value_name = "USER:PASS")]
160    pub user: Option<String>,
161
162    /// Replace an existing file at the destination
163    #[arg(long)]
164    pub overwrite: bool,
165
166    /// Throw away existing progress and download again from the start
167    #[arg(long)]
168    pub restart: bool,
169
170    /// Do not reserve the file's full size up front
171    #[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        // `--user` always wins; only consult `.netrc` when the user gave us
267        // nothing to work with, same precedence curl and wget use.
268        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
292/// Process exit codes. `130` is the shell convention for SIGINT.
293pub 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                // No URL and no subcommand: show help rather than a bare error.
321                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            // Ask where downloads go, once, before anything touches the network.
329            request.dir = Some(resolve_dir(&store, &cli)?);
330            run_download(store, request, &cli).await
331        }
332    }
333}
334
335/// Settle the destination folder: `--dir`, else the saved setting, else ask
336/// (first run only), else the platform's Downloads folder.
337fn 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
358/// Run one download with a UI attached and signals wired up.
359async 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    // Dropping the reporter closes the event channel, which ends the UI task.
371    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                // One failed download must not abandon the rest of --all.
410                eprintln!("{}: {err:#}", record.filename);
411                worst = EXIT_FAILURE;
412            }
413        }
414    }
415    Ok(worst)
416}
417
418/// Rebuild a request from what we persisted, so `rget resume <id>` needs no
419/// flags at all.
420fn 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    // The destination is already decided; do not re-derive it from headers.
438    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
465// Column widths, shared by the header and the rows so they cannot drift apart.
466const 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    // A miniature version of the download bar, so a glance down the column
486    // tells you how far along everything is.
487    let (filled, empty) = fmt::bar_parts(d.durable_bytes, d.total_size, BAR_W);
488    // Pad *before* styling: ANSI escapes have length but occupy no columns, so
489    // `{:<8}` applied to an already-coloured string silently does nothing.
490    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
501/// Status colours are the fastest way to read a long list: green is done,
502/// yellow is waiting for you, red needs attention.
503fn 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(&note)
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
736/// First Ctrl+C (or SIGTERM) pauses; a second one exits immediately (PRD §26).
737fn 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
771/// Backstop for PRD §26's "do not wait indefinitely": if the engine has not
772/// wound down a while after cancellation, exit anyway. Correctness does not
773/// depend on this — the last checkpoint is already durable.
774fn 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    /// Remove ANSI sequences so we can measure what the terminal actually
949    /// shows, rather than how many bytes we wrote.
950    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    /// Column (not byte) at which `needle` starts. The bar glyphs are three
968    /// bytes each, so byte offsets would be meaningless here.
969    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    /// Padding an already-coloured string is a silent no-op, because escape
1002    /// sequences have length but occupy no columns. This is the guard against
1003    /// that whole class of bug.
1004    #[test]
1005    fn list_columns_line_up_with_colour_on() {
1006        let style = fmt::Style::new(true);
1007        if !style.is_enabled() {
1008            return; // NO_COLOR set in this environment.
1009        }
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        // Unknown size cannot claim progress it does not know about.
1051        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}