Skip to main content

decdn_cli/commands/
get.rs

1//! `decdn get <URI>` (#2449): pull what a `decdn:` URI names
2//! (`adr/appendix-bundles.md` ยง `decdn:` URIs).
3//!
4//! The command parses the URI with [`decdn_client::uri::DecdnUri`], checks the
5//! flags against the URI's target, and runs the route for that target:
6//!
7//! - `decdn:<hash>` runs the blob fetch, `fetch::fetch`.
8//! - `decdn:<hash>/` runs the bundle pull, `bundle_pull::bundle_pull`, over
9//!   every entry.
10//! - `decdn:<hash>/<path>` runs the bundle pull for exactly that entry. With
11//!   `-o -` it runs the blob fetch instead, which reads the manifest and
12//!   streams that entry's blob to stdout.
13//!
14//! The URI's `ns` is the namespace hint of either route.
15//!
16//! Without `-o`, the destination follows the target: `./<hash>` for a blob,
17//! `.` for a bundle, and `./<name>` for an entry, where `<name>` is the last
18//! segment of the entry's path. The command then writes the absolute
19//! destination to stderr before the pull starts, except under `--json` or
20//! `--dry-run`. A bundle or entry pull into a default destination refuses a
21//! path that holds a file no earlier pull of the same entry left there
22//! unchanged, unless `--overwrite`.
23//!
24//! A default destination, like any relative `-o`, resolves against the
25//! current directory, and so does the run's resume state. A run that stops on
26//! Ctrl-C or a give-up names that directory as the one to run the command again
27//! from.
28
29use std::path::{Path, PathBuf};
30
31use anyhow::Context as _;
32use decdn_client::uri::{DecdnUri, UriTarget};
33use decdn_common::cli::GetArgs;
34
35/// Run `decdn get`: parse the URI and run the route its target names.
36///
37/// # Errors
38///
39/// The URI does not parse, a flag does not apply to the URI's target, or the
40/// route fails.
41pub async fn get(args: &GetArgs, config_path: Option<&Path>) -> anyhow::Result<()> {
42    let uri = parse_and_check(args)?;
43    let dest = Destination::of(args, &uri);
44    if args.output.is_none() && !args.json && !args.dry_run {
45        let shown = dest.shown();
46        let shown = std::path::absolute(&shown).unwrap_or(shown);
47        eprintln!("Downloading to {}", shown.display());
48    }
49    let result = if streams_one_blob(args, &uri) {
50        super::fetch::fetch(args, &dest.path, &uri, config_path).await
51    } else {
52        super::bundle_pull::bundle_pull(args, &dest, &uri, config_path).await
53    };
54    with_resume_hint(args, result)
55}
56
57/// `result` with the [`resume_dir`] of `args` attached to a stop, so its
58/// re-run advice names the directory to run the command again from.
59fn with_resume_hint<T>(args: &GetArgs, result: anyhow::Result<T>) -> anyhow::Result<T> {
60    match resume_dir(args) {
61        Some(dir) => super::interrupt::name_resume_dir(result, &dir),
62        None => result,
63    }
64}
65
66/// The directory a stopped run must run again from to resume: the current
67/// directory, which a default destination or a relative `-o` resolves against.
68/// `None` for an absolute `-o`, whose resume state a re-run from any directory
69/// finds, for `-o -`, which keeps none, and when the current directory cannot
70/// be read.
71fn resume_dir(args: &GetArgs) -> Option<PathBuf> {
72    if args.output.as_deref().is_some_and(Path::is_absolute) || is_stdout(args) {
73        return None;
74    }
75    std::env::current_dir().ok()
76}
77
78/// Where a `decdn get` run writes: `-o`, or the default for the URI's target.
79#[derive(Debug, PartialEq, Eq)]
80pub(crate) struct Destination {
81    /// The blob's destination file, or the bundle's output directory.
82    pub(crate) path: PathBuf,
83    /// The file name the entry an entry URI names lands at, directly in
84    /// `path`, in place of its full manifest path under `path`. Set only for
85    /// an entry URI without `-o`.
86    pub(crate) entry_name: Option<String>,
87}
88
89impl Destination {
90    /// The destination of `args` for `uri`: `-o` as given, or else
91    /// `./<hash>` for a blob, `.` for a bundle, and `.` for an entry with the
92    /// entry landing at the last segment of its path.
93    pub(crate) fn of(args: &GetArgs, uri: &DecdnUri) -> Self {
94        if let Some(output) = &args.output {
95            return Self {
96                path: output.clone(),
97                entry_name: None,
98            };
99        }
100        match &uri.target {
101            UriTarget::Blob => Self {
102                path: Path::new(".").join(blake3::Hash::from_bytes(uri.hash).to_hex().as_str()),
103                entry_name: None,
104            },
105            // The URI parser holds every segment non-empty and free of `/`,
106            // so the last segment is a plain name. The pull still checks it
107            // against the path-safety rules, like any manifest path.
108            UriTarget::BundleEntry(entry) => Self {
109                path: PathBuf::from("."),
110                entry_name: Some(
111                    entry
112                        .rsplit_once('/')
113                        .map_or(entry.as_str(), |(_, name)| name)
114                        .to_owned(),
115                ),
116            },
117            _ => Self {
118                path: PathBuf::from("."),
119                entry_name: None,
120            },
121        }
122    }
123
124    /// The path to name for the user: the entry's file when it lands at its
125    /// name, and `path` otherwise.
126    fn shown(&self) -> PathBuf {
127        self.entry_name
128            .as_ref()
129            .map_or_else(|| self.path.clone(), |name| self.path.join(name))
130    }
131}
132
133/// Whether the run delivers one blob: a blob URI, or an entry URI to stdout.
134fn streams_one_blob(args: &GetArgs, uri: &DecdnUri) -> bool {
135    match uri.target {
136        UriTarget::Blob => true,
137        UriTarget::BundleEntry(_) => is_stdout(args),
138        _ => false,
139    }
140}
141
142/// Whether `-o` is `-` (stdout).
143fn is_stdout(args: &GetArgs) -> bool {
144    args.output.as_deref() == Some(Path::new("-"))
145}
146
147/// The URI `args` names, once its flags are checked against its target. Rejects
148/// a flag that does not apply: any bundle flag on a blob URI, or on an entry
149/// URI to stdout; an entry filter on an entry URI; and `-o -` on a whole
150/// bundle.
151fn parse_and_check(args: &GetArgs) -> anyhow::Result<DecdnUri> {
152    let uri: DecdnUri = args
153        .uri
154        .parse()
155        .with_context(|| format!("parse URI {:?}", args.uri))?;
156    match &uri.target {
157        UriTarget::Blob => {
158            let given = bundle_flags_given(args);
159            anyhow::ensure!(
160                given.is_empty(),
161                "{} only apply to a bundle URI (`decdn:<hash>/`), not a blob URI",
162                given.join(", ")
163            );
164            return Ok(uri);
165        }
166        UriTarget::BundleEntry(_) => {
167            let given = entry_filters_given(args);
168            anyhow::ensure!(
169                given.is_empty(),
170                "{} do not apply to an entry URI: the URI already names one entry",
171                given.join(", ")
172            );
173            if is_stdout(args) {
174                let given = bundle_flags_given(args);
175                anyhow::ensure!(
176                    given.is_empty(),
177                    "{} do not apply with `-o -`: an entry streams to stdout as one blob",
178                    given.join(", ")
179                );
180            }
181            return Ok(uri);
182        }
183        _ => {}
184    }
185    anyhow::ensure!(
186        !is_stdout(args),
187        "`-o -` (stdout) takes a blob or one bundle entry (`decdn:<hash>/<path>`); \
188         a whole bundle writes to a directory"
189    );
190    Ok(uri)
191}
192
193/// The bundle-only flags `args` sets, by name.
194fn bundle_flags_given(args: &GetArgs) -> Vec<&'static str> {
195    let mut given = entry_filters_given(args);
196    for (set, name) in [
197        (args.jobs.is_some(), "--jobs"),
198        (args.max_lane_streams.is_some(), "--max-lane-streams"),
199        (args.overwrite, "--overwrite"),
200        (args.dry_run, "--dry-run"),
201        (args.json, "--json"),
202    ] {
203        if set {
204            given.push(name);
205        }
206    }
207    given
208}
209
210/// The entry filters `args` sets, by name.
211fn entry_filters_given(args: &GetArgs) -> Vec<&'static str> {
212    [
213        (!args.include.is_empty(), "--include"),
214        (!args.exclude.is_empty(), "--exclude"),
215        (args.select, "--select"),
216    ]
217    .into_iter()
218    .filter_map(|(set, name)| set.then_some(name))
219    .collect()
220}
221
222#[cfg(test)]
223#[allow(
224    clippy::unwrap_used,
225    clippy::expect_used,
226    clippy::indexing_slicing,
227    clippy::panic
228)]
229mod tests;