Skip to main content

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`](../../../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}