decdn_common/cli/bundle.rs
1//! Arguments for the `decdn bundle` subcommand group (issue #391).
2//!
3//! Bundles are a publisher-side convenience for grouping multiple
4//! BLAKE3-content-addressed blobs into a single JSON manifest file. The
5//! manifest itself is also a blob (BLAKE3-hashed), so origins can publish
6//! a single bundle hash and clients fetch the bundle then everything it
7//! references. See [`appendix-bundles`](https://github.com/decdn/decdn/blob/main/adr/appendix-bundles.md)
8//! for the on-disk schema, hash format (`b3:<hex>`), path-safety rules,
9//! and the determinism requirement that makes bundle-as-blob distribution
10//! viable.
11//!
12//! `origin import --dry-run` produces manifests; `bundle pull` (#391)
13//! realizes them — fetching every referenced blob over the paid
14//! `cdn/client/v1` path (`decdn fetch`'s primitive), with per-entry node
15//! discovery and bounded concurrency.
16
17use std::path::PathBuf;
18
19use clap::{ArgGroup, Args, Subcommand};
20
21use super::fetch::ClientFetchArgs;
22
23/// Top-level `decdn bundle` group.
24#[derive(Args, Debug)]
25pub struct BundleArgs {
26 /// The `decdn bundle` subcommand to run.
27 #[command(subcommand)]
28 pub cmd: BundleCommand,
29}
30
31/// Subcommands under `decdn bundle`.
32#[derive(Subcommand, Debug)]
33pub enum BundleCommand {
34 /// Fetch every blob a bundle references into an output directory over the
35 /// paid `cdn/client/v1` path (#391). The bundle is read from a local file
36 /// (`-i`) or fetched first by its own hash (`--hash`); each entry is then
37 /// discovered + fetched independently, with `--jobs` concurrency.
38 Pull(BundlePullArgs),
39}
40
41/// `decdn bundle pull` — fetch a bundle's blobs into a directory.
42///
43/// The bundle source is exactly one of `-i <file>` (a local manifest) or
44/// `--hash <b3>` (fetch the manifest blob first, then its entries). Both then
45/// run the same per-entry fetch loop. The network/chain/target flags come from
46/// the flattened [`ClientFetchArgs`] — `--node-id` pins every entry to one node,
47/// `--channel-id` (#1481) adopts one publisher-opened channel and pins the whole
48/// bundle to its provider, otherwise each entry is discovered independently
49/// (#936/#391). `--namespace` (ADR 002) routes every cache-miss origin pull to a
50/// namespace's authorized origins.
51#[derive(Args, Debug)]
52#[command(group(ArgGroup::new("bundle_source").required(true).args(["input", "hash"])))]
53#[expect(
54 clippy::struct_excessive_bools,
55 reason = "independent CLI flags (--overwrite/--dry-run/--json/--select), not a state machine"
56)]
57pub struct BundlePullArgs {
58 /// Local bundle manifest file to pull. Mutually exclusive with `--hash`.
59 #[arg(short = 'i', long, value_name = "FILE")]
60 pub input: Option<PathBuf>,
61
62 /// BLAKE3 hash (64 hex, optional `0x`/`b3:` prefix) of the bundle manifest
63 /// blob to fetch first, then pull. Mutually exclusive with `-i`.
64 #[arg(long, value_name = "HASH")]
65 pub hash: Option<String>,
66
67 /// Output directory the bundle's files are written under (created if
68 /// absent). Each entry's relative path is resolved within this root;
69 /// `..`/absolute/escaping paths are rejected.
70 #[arg(short = 'o', long, value_name = "DIR")]
71 pub output: PathBuf,
72
73 /// Maximum concurrent entry fetches across the whole run. Each distinct blob
74 /// is one unit of work holding one slot: a plain whole-file entry, or a
75 /// range-dedup entry across its complement drive, donor splice, and any
76 /// re-fetch. A range-dedup entry that only waits for a sibling to finish the
77 /// chunks it splices gives its slot to a queued entry until then. A byte
78 /// range a sibling entry already holds is spliced from disk, not fetched, so
79 /// it never takes a slot of its own.
80 #[arg(long, value_name = "N", default_value_t = 4)]
81 pub jobs: usize,
82
83 /// Maximum concurrent streams to one `(pool, signer, provider)` lane.
84 /// Same-lane streams share one cumulative voucher watermark, and the serving
85 /// node credits each stream's delivered bytes from that shared lane fairly
86 /// (a slow stream is paid from the headroom a faster sibling opened), so
87 /// same-lane concurrency is safe and this only bounds how many streams touch
88 /// one provider at once. Cross-lane parallelism (distinct providers) is
89 /// bounded by `--jobs` regardless.
90 ///
91 /// One connection per node carries every stream of the command, so the
92 /// bound (1 to 64) stays below the node's limit of concurrent streams on
93 /// one connection.
94 #[arg(
95 long,
96 value_name = "N",
97 default_value_t = 4,
98 value_parser = clap::value_parser!(u16).range(1..=64)
99 )]
100 pub max_lane_streams: u16,
101
102 /// Only pull entries whose POSIX relative path matches one of these globs
103 /// (`models/*.bin`), matched against the manifest's `path` field, never the
104 /// on-disk absolute path. Repeatable — an entry passes the include gate if it
105 /// matches any one. Absent => every entry passes the gate. gitignore
106 /// semantics: `*` does not cross `/`, `**` recurses.
107 #[arg(long = "include", value_name = "GLOB", action = clap::ArgAction::Append)]
108 pub include: Vec<String>,
109
110 /// Skip entries whose POSIX relative path matches one of these globs,
111 /// matched against the manifest's `path` field. Repeatable — every pattern
112 /// is OR-ed. `--exclude` wins over `--include`: an entry matching both is
113 /// skipped. Same gitignore glob semantics as `--include`.
114 #[arg(long = "exclude", value_name = "GLOB", action = clap::ArgAction::Append)]
115 pub exclude: Vec<String>,
116
117 /// Choose which files to pull interactively: open the (post-`--include`/
118 /// `--exclude`) file list in `$VISUAL`/`$EDITOR`, one `path` per line. Comment
119 /// out (prefix `#`) or delete any line to skip that file; save and exit to pull
120 /// the rest. Useful when you don't know a bundle's contents up front — the list
121 /// is what the manifest actually holds. Requires an interactive terminal;
122 /// incompatible with `--json` and `--dry-run`.
123 #[arg(long)]
124 pub select: bool,
125
126 /// Re-fetch and overwrite entries whose destination file already exists.
127 /// Default is skip-existing (resume-friendly): a path skips when its saved
128 /// `.decdn-manifest.json` record still matches the file's hash, size, and
129 /// mtime — the fast path, which trusts that gate without re-hashing — or,
130 /// failing that, when re-hashing its on-disk bytes matches the new manifest.
131 /// A file byte-identical to one already anywhere else in the output root is
132 /// likewise reused with no download: it is linked (or copied) from that
133 /// on-disk file once its hash is confirmed by a re-hash. Use `--overwrite`
134 /// to force a full re-fetch of every entry, for example when the output
135 /// directory may have been modified outside `decdn` (an in-place edit that
136 /// keeps a file's size and mtime is otherwise trusted by the fast path). It
137 /// skips consulting `.decdn-manifest.json` for skip decisions, so it
138 /// disables both the skip-existing path and the whole-root reuse above; the
139 /// file is still read and rewritten with the run's results.
140 #[arg(long)]
141 pub overwrite: bool,
142
143 /// Print the entries the run would pull (paths + sizes, after
144 /// `--include`/`--exclude`; files already present are still listed) and exit
145 /// without any network or chain activity. With `--hash`, entries are listed
146 /// only when an earlier pull into the same output directory cached the
147 /// bundle manifest.
148 #[arg(long)]
149 pub dry_run: bool,
150
151 /// Emit a one-line JSON summary instead of human-readable output.
152 #[arg(long)]
153 pub json: bool,
154
155 /// Namespace the whole bundle is published under (ADR 002 § Retrieval by
156 /// namespace). Applies to EVERY fetch in the run — the manifest blob and each
157 /// entry — so a serving node routes any cache-miss origin pull to that
158 /// namespace's DAO-authorized origins. The manifest format carries no per-entry
159 /// namespace, so this is necessarily bundle-level. Absent => no namespace:
160 /// served best-effort from cache / DHT only.
161 #[arg(long, value_name = "ID", value_parser = super::fetch::parse_fetch_namespace_id)]
162 pub namespace: Option<u64>,
163
164 /// Retrieval flags shared with `decdn fetch`: peer discovery, payment
165 /// pool, and output handling.
166 #[command(flatten)]
167 pub common: ClientFetchArgs,
168}