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;