pigeon/commands/job/cli.rs
1use std::path::PathBuf;
2
3use clap::{Args, Subcommand};
4
5use crate::core::observability::Observable;
6
7#[derive(Args, Debug)]
8pub struct JobArgs {
9 #[command(subcommand)]
10 pub command: JobCommands,
11}
12
13#[derive(Subcommand, Debug)]
14pub enum JobCommands {
15 /// Run a job (see subcommands for available job types)
16 Run(RunArgs),
17}
18
19#[derive(Args, Debug)]
20pub struct RunArgs {
21 #[command(subcommand)]
22 pub job_type: JobType,
23}
24
25#[derive(Subcommand, Debug)]
26pub enum JobType {
27 /// Email jobs addressed through the keyring. See subcommands for the
28 /// available actions.
29 Email(EmailArgs),
30
31 /// Recursively scans a bucket, always inflates every zip found (the
32 /// zip container itself is never uploaded, only its inflated
33 /// contents), content-hashes every file bucket-wide to keep one
34 /// byte-identical copy of each, writes a human-readable merge report,
35 /// and optionally uploads the result unencrypted to a (possibly
36 /// different) bucket-config (ADR-0082). Every file is always processed
37 /// and every zip is always expanded -- there is no file-type or
38 /// zip-expansion selection, and this job never offers encryption.
39 Deduplicate {
40 /// Alias of a configured bucket-config to pull from. Repeat the
41 /// flag for more than one (`--source-bucket a --source-bucket b`)
42 /// -- every listed bucket is downloaded into one shared local
43 /// staging tree and deduplicated across the combined set, not
44 /// per-bucket (ADR-0109). Interactively multi-selected from the
45 /// configured bucket-configs when omitted and stdin is a terminal;
46 /// at least one is required otherwise.
47 #[arg(long)]
48 source_bucket: Vec<String>,
49
50 /// Local directory to stage and store output under. Defaults to a
51 /// directory under the OS temp directory when omitted.
52 #[arg(long)]
53 local_output: Option<PathBuf>,
54
55 /// Alias of a configured bucket-config to upload the deduped
56 /// result to, once local processing is complete. Always uploaded
57 /// unencrypted. Single bucket only (ADR-0109).
58 #[arg(long)]
59 destination_bucket: Option<String>,
60
61 /// Maximum number of files to download/hash concurrently.
62 #[arg(long)]
63 concurrency: Option<usize>,
64
65 /// rclone --transfers for the upload phase, now one bulk `rclone
66 /// copy` rather than a per-file task queue (ADR-0136). Defaults to
67 /// 8 when omitted. With `--upload-only`, this is the only transfer
68 /// concurrency flag that has any effect.
69 #[arg(long)]
70 transfers: Option<usize>,
71
72 /// rclone --checkers for the upload phase. Defaults to 16 when
73 /// omitted.
74 #[arg(long)]
75 checkers: Option<usize>,
76
77 /// rclone --tpslimit for the upload phase. Unset by default -- no
78 /// rate cap.
79 #[arg(long)]
80 tpslimit: Option<usize>,
81
82 /// Resumes uploading an already-completed local deduplicate run instead
83 /// of starting a new one: skips the bucket listing/download/hash/
84 /// placement phases entirely (and the source bucket credentials
85 /// they'd otherwise need) and uploads straight from an existing
86 /// `--local-output`'s `result/` tree, picking up where a prior
87 /// run's upload phase left off via the same `.staging/.uploaded`
88 /// index (ADR-0089). Requires a `--local-output` from a completed
89 /// prior run (its `.staging/.processed` checkpoint must exist and
90 /// its `result/` must be non-empty) and a mandatory
91 /// `--remote-output`.
92 #[arg(long)]
93 upload_only: bool,
94
95 /// Alias of a configured bucket-config this run's report, the
96 /// shared observability log, and a transcript of its printed
97 /// output are uploaded to, always unencrypted, under a
98 /// `YYYY-MM-DD-job-name-{run-id}/` prefix (ADR-0100). Mandatory --
99 /// interactively selected when omitted and stdin is a terminal;
100 /// required otherwise.
101 #[arg(long)]
102 report_bucket: Option<String>,
103
104 /// Suppress every wizard prompt (ADR-0114): each omitted input
105 /// resolves via its non-interactive fallback (or errors if none
106 /// exists) instead of asking, and the final "proceed?"
107 /// confirmation is auto-answered yes.
108 #[arg(long)]
109 non_interactive: bool,
110 },
111
112 /// Shells out to the external `rclone` binary for copy/delete
113 /// operations against pigeon keyring `BucketConfig` aliases. `rclone`
114 /// is always invoked against a config file pigeon itself generates
115 /// from the keyring (ADR-0136, reversing ADR-0101/ADR-0110's original
116 /// raw-passthrough design) -- never an externally-provisioned or
117 /// user-general-purpose `rclone.conf`. See subcommands for the
118 /// available actions. Requires `rclone` on `PATH`.
119 Rclone(RcloneArgs),
120
121 /// Transcodes image files (`png`/`jpeg`/`heic`) into full-size,
122 /// maximum-quality `.jpg`, video files (`mov`/`m4v`/`mp4`, ADR-0122)
123 /// into space-optimized H.265/HEVC `.mp4` at a chosen `--video-quality`,
124 /// audio files -- an audio-only recording carrying an `.mp4` extension
125 /// (`mp4-audio`, ADR-0142) or `m4a`/`mp3`/`aac`/`wav`/`opus`
126 /// (ADR-0153) -- into Opus `.opus` at a chosen `--audio-quality`, or
127 /// spreadsheets
128 /// (`numbers`/`xlsx`/`xls`/`csv`, ADR-0149) into one `.csv` per sheet,
129 /// or documents (ADR-0154) -- `doc`/`docx`/`pages`/`rtf`/`pptx`/`key`/
130 /// `epub` into `.pdf` with `pdf` copied through,
131 /// `txt`/`md`/`markdown`/`mkd`/`rst`/`textclipping` into `.txt`, and
132 /// `tsv` into `.csv`
133 /// (ADR-0112, pipeline reworked by ADR-0116): shells out to `rclone` to
134 /// pull `--source` into a local staging tree, transcoding/copying each
135 /// file locally and pushing it individually via `rclone copyto` the
136 /// moment it's ready, rather than waiting for the whole batch. A
137 /// multi-sheet workbook is the one input kind that yields several
138 /// outputs, each named `{stem}-{hash}-{nn}of{tt}-{sheet}.csv` so they
139 /// stay recognizably related and a half-pushed workbook is detectable
140 /// on a rerun (ADR-0149).
141 /// `--source`/`--destination` are pigeon keyring `BucketConfig`
142 /// aliases, optionally composed with a prefix within that bucket
143 /// (`alias:prefix/`, ADR-0138); an omitted prefix defaults to
144 /// `--input-file-type`'s own extension (source) or output extension
145 /// (destination). Deduplication is never involved -- every output file's
146 /// name is derived deterministically and is unique by construction;
147 /// duplicate source content is deliberately retained at separate
148 /// destination names. A single file's transcode or push failure (e.g.
149 /// a HEIC file the local `ffmpeg` build can't decode, retried before
150 /// giving up) is recorded and reported but never stops any other file
151 /// from being processed and pushed. A rerun is resumable without any
152 /// local state: "already done" is answered by listing the destination
153 /// (ADR-0120 removed the local checkpoint). Requires `rclone` on
154 /// `PATH`, plus `ffmpeg`/`ffprobe` for an image, video, or audio input
155 /// kind, `soffice` (LibreOffice) for `numbers` and for
156 /// `doc`/`docx`/`pages`/`rtf`/`pptx`/`key`, `ebook-convert` (Calibre)
157 /// for `epub`, and `pandoc` for `md`/`rst`;
158 /// `xlsx`/`xls`/`csv`/`tsv`/`pdf`/`txt`/`textclipping` need none of
159 /// them (ADR-0149, ADR-0154).
160 Transform {
161 /// Which input format this run transcodes: `png`, `jpeg`, `heic`,
162 /// `mov`, `m4v`, `mp4`, `mp4-audio`, `m4a`, `mp3`, `aac`, `wav`,
163 /// `opus`, `numbers`, `xlsx`, `xls`, `csv`, `doc`, `docx`,
164 /// `pages`, `rtf`, `pdf`, `epub`, `pptx`, `key`, `txt`, `md`
165 /// (or `markdown`/`mkd`), `rst`, `textclipping`, or `tsv`.
166 /// Interactively selected when omitted and stdin is a terminal;
167 /// required otherwise.
168 /// `mp4-audio` is for audio-only recordings carrying an `.mp4`
169 /// extension (voice notes), transcoded to `.opus`; a file that
170 /// actually has a video stream is refused rather than silently
171 /// losing its video -- embedded cover art does not count as video
172 /// (ADR-0153). `opus` input is re-encoded rather than copied
173 /// through, so a mixed library normalizes to one bitrate. The four spreadsheet kinds all output `.csv`,
174 /// one file per non-empty sheet (ADR-0149); `csv` input is copied
175 /// through byte-for-byte, as are `pdf` and `txt` (ADR-0154).
176 /// `key` is sniffed before conversion and a file that is not an
177 /// iWork container is refused -- that extension is also
178 /// conventional for a private key, and LibreOffice would otherwise
179 /// render one into a readable PDF. `md`/`rst` are rendered to
180 /// prose, which **discards link URLs and all inline markup**.
181 #[arg(long)]
182 input_file_type: Option<String>,
183
184 /// Compression level for a true video input kind (`mov`/`m4v`/`mp4`
185 /// -- not `mp4-audio`, which has no video stream):
186 /// `low`, `medium` (default), `high`, or `lossless` -- a small
187 /// vetted `libx265` CRF menu (ADR-0122). Ignored for every other
188 /// input kind. Interactively selected when omitted, stdin is a
189 /// terminal, and the input kind is video; defaults to `medium`
190 /// otherwise.
191 #[arg(long)]
192 video_quality: Option<String>,
193
194 /// Opus bitrate for an audio input kind (`mp4-audio`, `m4a`,
195 /// `mp3`, `aac`, `wav`, `opus`): `low` (32k, speech), `medium`
196 /// (48k), `high` (96k), or `transparent` (128k) -- a small vetted
197 /// menu (ADR-0153), never a raw bitrate. Ignored for every other
198 /// input kind. Interactively selected when omitted, stdin is a
199 /// terminal, and the input kind is audio; otherwise it defaults
200 /// **per kind**: `medium` for `mp4-audio`, preserving ADR-0142's
201 /// choice for voice notes, and `transparent` for the rest, which
202 /// are music or (for `wav`) lossless and would be badly served by
203 /// a speech bitrate.
204 #[arg(long)]
205 audio_quality: Option<String>,
206
207 /// Where to pull from: a configured bucket-config alias, optionally
208 /// composed with a prefix inside it -- `media` is the whole bucket,
209 /// `media:mp4/` its `mp4/` directory (ADR-0138). An omitted prefix
210 /// defaults to `--input-file-type`'s own extension, so
211 /// `--source media --input-file-type mp4` means `media:mp4/`; an
212 /// explicit prefix is always used verbatim. Interactively selected
213 /// (bucket root, then the default prefix) when omitted and stdin is
214 /// a terminal; required otherwise.
215 #[arg(long)]
216 source: Option<String>,
217
218 /// Where to push to, same `alias[:prefix/]` form as `--source`
219 /// (ADR-0138). An omitted prefix defaults to `--input-file-type`'s
220 /// *output* extension (`jpg` for an image kind, `mp4` for a video
221 /// one, `opus` for every audio kind, `csv` for every spreadsheet
222 /// kind),
223 /// so one bucket can serve as both source and destination when the
224 /// prefixes differ.
225 #[arg(long)]
226 destination: Option<String>,
227
228 /// Local directory this run stages pulled source files, transcoded
229 /// results, and bookkeeping under. Defaults to a directory under
230 /// the OS temp directory when omitted.
231 #[arg(long)]
232 local_output: Option<PathBuf>,
233
234 /// Maximum number of files to convert concurrently (the local
235 /// conversion phase only -- `rclone` manages its own parallelism via
236 /// `--download-transfers`/`--download-checkers`). Interactively
237 /// prompted (a cores-based default) when omitted and stdin is a
238 /// terminal; required otherwise.
239 #[arg(long)]
240 concurrency: Option<usize>,
241
242 /// rclone --transfers for the download (pull) phase. Defaults to 8
243 /// when omitted.
244 #[arg(long)]
245 download_transfers: Option<usize>,
246
247 /// rclone --checkers for the download (pull) phase. Defaults to 16
248 /// when omitted.
249 #[arg(long)]
250 download_checkers: Option<usize>,
251
252 /// Maximum number of concurrent per-file push (`rclone copyto`)
253 /// operations. Defaults to 8 when omitted. Separate from
254 /// `--download-transfers` to allow independent tuning of pull vs.
255 /// push phases (ADR-0129).
256 #[arg(long)]
257 upload_transfers: Option<usize>,
258
259 /// Maximum number of downloaded but not-yet-processed files
260 /// (pending transcode/push) before the pull phase is paused to allow
261 /// the pipeline to catch up. Defaults to `concurrency * 4` when
262 /// omitted. Enables automatic disk-pressure backpressure without
263 /// requiring manual tuning (ADR-0129).
264 #[arg(long)]
265 max_pending_downloads: Option<usize>,
266
267 /// rclone --tpslimit, used by the pull phase only. Unset by
268 /// default -- no rate cap.
269 #[arg(long)]
270 tpslimit: Option<usize>,
271
272 /// Alias of a configured bucket-config this run's report, the
273 /// shared observability log, and a transcript of its printed
274 /// output are uploaded to, always unencrypted, under a
275 /// `YYYY-MM-DD-job-name-{run-id}/` prefix (ADR-0100). Mandatory --
276 /// interactively selected when omitted and stdin is a terminal;
277 /// required otherwise.
278 #[arg(long)]
279 report_bucket: Option<String>,
280
281 /// Suppress every wizard prompt (ADR-0114): each omitted input
282 /// resolves via its non-interactive fallback (or errors if none
283 /// exists) instead of asking, and the final "proceed?"
284 /// confirmation is auto-answered yes.
285 #[arg(long)]
286 non_interactive: bool,
287 },
288}
289
290#[derive(Args, Debug)]
291pub struct EmailArgs {
292 #[command(subcommand)]
293 pub action: EmailAction,
294}
295
296#[derive(Subcommand, Debug)]
297pub enum EmailAction {
298 /// Reads one or more authenticated IMAP identities and delivers each
299 /// message's raw `.eml` plus its unpacked attachments to
300 /// `--destination`, at a declared, rate-limit-respecting pace
301 /// (ADR-0146). Deliberately minimal: no deduplication, no indexing, no
302 /// renaming, no content transform, and no encryption -- it moves bytes
303 /// and nothing else. Each message is pushed individually via `rclone
304 /// copyto` the moment it is fetched and its local copy deleted
305 /// immediately afterwards, so a run lasting days delivers
306 /// incrementally and local disk stays bounded to the in-flight window
307 /// regardless of how large the account is. A message that fails is
308 /// simply not checkpointed, so a rerun refetches it; nothing is
309 /// retained locally for diagnosis, because the server still has it.
310 /// Requires `rclone` on `PATH`.
311 Import {
312 /// Aliases of the identities to import, comma-separated.
313 /// Interactively multi-selected from the authenticated identities
314 /// when omitted and stdin is a terminal; required otherwise.
315 #[arg(long, value_delimiter = ',')]
316 identities: Option<Vec<String>>,
317
318 /// Where to deliver to: a configured bucket-config alias,
319 /// optionally composed with a prefix inside it -- `archive` is the
320 /// whole bucket, `archive:eml/` its `eml/` directory (ADR-0138).
321 /// Unlike `transform`, an omitted prefix stays the bucket root
322 /// (this job has no file-type concept to derive one from).
323 /// Mandatory -- interactively selected when omitted and stdin is a
324 /// terminal; required otherwise.
325 #[arg(long)]
326 destination: Option<String>,
327
328 /// Local directory in-flight messages and this run's bookkeeping
329 /// are staged under. Defaults to a directory under the OS temp
330 /// directory when omitted. Unlike the other email jobs this is not
331 /// an output tree: each message's local copy is deleted as soon as
332 /// its push resolves (ADR-0146 §5), so it stays near-empty.
333 #[arg(long)]
334 local_output: Option<PathBuf>,
335
336 /// Ceiling on how many messages per minute are fetched, across
337 /// every selected identity -- the run's throughput governor
338 /// (ADR-0146 §4). Deliberately has no "unlimited" value: the
339 /// absence of a cap is what this job exists to fix. Interactively
340 /// prompted when omitted and stdin is a terminal; defaults to 120
341 /// otherwise, which puts a 200,000-message account at roughly 28
342 /// hours.
343 #[arg(long)]
344 messages_per_minute: Option<usize>,
345
346 /// How many UIDs one `UID FETCH` asks for at a time. Small enough
347 /// that a failed batch loses little work, large enough to amortise
348 /// the round trip. Interactively prompted when omitted and stdin is
349 /// a terminal; defaults to 20 otherwise.
350 #[arg(long)]
351 batch_size: Option<usize>,
352
353 /// Maximum simultaneous IMAP connections opened to any one
354 /// identity. A per-identity cap set via `pigeon keyring
355 /// add/modify email` still overrides this for that identity only
356 /// (ADR-0080). Interactively prompted when omitted and stdin is a
357 /// terminal; defaults to 2 otherwise -- well below the other email
358 /// jobs' 6, since throughput here comes from
359 /// `--messages-per-minute` rather than from parallelism.
360 #[arg(long)]
361 max_connections: Option<usize>,
362
363 /// Maximum concurrent per-object pushes (`rclone copyto`).
364 /// Interactively prompted when omitted and stdin is a terminal;
365 /// defaults to 4 otherwise -- deliberately below the other jobs'
366 /// upload concurrency, since these pushes interleave with a
367 /// throttled fetch rather than racing to drain a finished queue.
368 #[arg(long)]
369 push_concurrency: Option<usize>,
370
371 /// Alias of a configured bucket-config this run's report, the
372 /// shared observability log, and a transcript of its printed
373 /// output are uploaded to, always unencrypted, under a
374 /// `YYYY-MM-DD-job-name-{run-id}/` prefix (ADR-0100). Mandatory --
375 /// interactively selected when omitted and stdin is a terminal;
376 /// required otherwise.
377 #[arg(long)]
378 report_bucket: Option<String>,
379
380 /// Suppress every wizard prompt (ADR-0114): each omitted input
381 /// resolves via its non-interactive fallback (or errors if none
382 /// exists) instead of asking, and the final "proceed?"
383 /// confirmation is auto-answered yes.
384 #[arg(long)]
385 non_interactive: bool,
386 },
387}
388
389#[derive(Args, Debug)]
390pub struct RcloneArgs {
391 #[command(subcommand)]
392 pub action: RcloneAction,
393}
394
395#[derive(Subcommand, Debug)]
396pub enum RcloneAction {
397 /// Copies data from one configured bucket-config to another by
398 /// shelling out to `rclone copy`, against a config file pigeon itself
399 /// generates from the keyring (ADR-0136, reversing ADR-0101's original
400 /// raw-passthrough design). `--transfers`/`--checkers`/`--tpslimit` are
401 /// overridable per-destination (ADR-0108); every other rclone
402 /// performance/retry flag stays fixed.
403 Copy {
404 /// Where to pull from: a configured bucket-config alias, optionally
405 /// composed with a prefix inside it -- `media` is the whole bucket,
406 /// `media:mp4/` its `mp4/` directory (ADR-0138). Unlike
407 /// `transform`, an omitted prefix stays the bucket root (this job
408 /// has no file-type concept to derive one from). Interactively
409 /// selected when omitted and stdin is a terminal; required
410 /// otherwise.
411 #[arg(long)]
412 source_bucket: Option<String>,
413
414 /// Where to push to, same `alias[:prefix/]` form as
415 /// `--source-bucket` (ADR-0138). Interactively selected when
416 /// omitted and stdin is a terminal; required otherwise.
417 #[arg(long)]
418 destination_bucket: Option<String>,
419
420 /// Local directory this run's rclone log (also serving as this
421 /// job's report) and transcript are written under. Defaults to a
422 /// directory under the OS temp directory when omitted. Unlike
423 /// every other job, this is not a staging area for transferred
424 /// data -- rclone transfers directly source -> destination with no
425 /// pigeon-side staging.
426 #[arg(long)]
427 local_output: Option<PathBuf>,
428
429 /// Alias of a configured bucket-config this run's report (the
430 /// rclone log itself, for this job), the shared observability log,
431 /// and a transcript of its printed output are uploaded to, always
432 /// unencrypted, under a `YYYY-MM-DD-job-name-{run-id}/` prefix
433 /// (ADR-0100). Mandatory -- interactively selected when omitted
434 /// and stdin is a terminal; required otherwise.
435 #[arg(long)]
436 report_bucket: Option<String>,
437
438 /// rclone --transfers (concurrent file transfers). Defaults to 8
439 /// (ADR-0106) when omitted.
440 #[arg(long)]
441 transfers: Option<usize>,
442
443 /// rclone --checkers (concurrent list/compare operations).
444 /// Defaults to 16 (ADR-0106) when omitted.
445 #[arg(long)]
446 checkers: Option<usize>,
447
448 /// rclone --tpslimit (transactions/sec ceiling across transfers
449 /// and checkers combined). Unset by default -- no rate cap --
450 /// unless a specific destination needs one (ADR-0108).
451 #[arg(long)]
452 tpslimit: Option<usize>,
453
454 /// Suppress every wizard prompt (ADR-0114): each omitted input
455 /// resolves via its non-interactive fallback (or errors if none
456 /// exists) instead of asking, and the final "proceed?"
457 /// confirmation is auto-answered yes.
458 #[arg(long)]
459 non_interactive: bool,
460 },
461
462 /// Recursively and permanently deletes everything in a configured
463 /// bucket-config by shelling out to `rclone purge` (ADR-0110).
464 /// Irreversible -- there is no `--dry-run` and no undo beyond the
465 /// existing `--non-interactive`/interactive confirmation. Unlike
466 /// `copy`, there is no destination and no per-file transfer-rate
467 /// tuning, since purge transfers no file content.
468 Delete {
469 /// What to recursively and permanently delete the contents of: a
470 /// configured bucket-config alias, optionally composed with a
471 /// prefix to scope the purge to one directory inside it
472 /// (`media:mp4/`, ADR-0138). An omitted prefix purges the **whole
473 /// bucket**. Interactively selected when omitted and stdin is a
474 /// terminal; required otherwise.
475 #[arg(long)]
476 source_bucket: Option<String>,
477
478 /// Local directory this run's rclone log (also serving as this
479 /// job's report) and transcript are written under. Defaults to a
480 /// directory under the OS temp directory when omitted.
481 #[arg(long)]
482 local_output: Option<PathBuf>,
483
484 /// Alias of a configured bucket-config this run's report, the
485 /// shared observability log, and a transcript of its printed
486 /// output are uploaded to, always unencrypted, under a
487 /// `YYYY-MM-DD-job-name-{run-id}/` prefix (ADR-0100). Mandatory --
488 /// interactively selected when omitted and stdin is a terminal;
489 /// required otherwise.
490 #[arg(long)]
491 report_bucket: Option<String>,
492
493 /// rclone --checkers (concurrent list operations during
494 /// enumeration). Defaults to 16 when omitted.
495 #[arg(long)]
496 checkers: Option<usize>,
497
498 /// Suppress every wizard prompt (ADR-0114): each omitted input
499 /// resolves via its non-interactive fallback (or errors if none
500 /// exists) instead of asking, and the final "proceed?"
501 /// confirmation is auto-answered yes.
502 #[arg(long)]
503 non_interactive: bool,
504 },
505}
506
507impl Observable for JobType {
508 fn command_name(&self) -> &'static str {
509 match self {
510 JobType::Email(args) => match args.action {
511 EmailAction::Import { .. } => "job.email-import",
512 },
513 JobType::Deduplicate { .. } => "job.deduplicate",
514 JobType::Rclone(args) => match args.action {
515 RcloneAction::Copy { .. } => "job.rclone-copy",
516 RcloneAction::Delete { .. } => "job.rclone-delete",
517 },
518 JobType::Transform { .. } => "job.transform",
519 }
520 }
521}
522
523impl JobType {
524 /// The bare dash-form job name (e.g. `"deduplicate"`) used in the
525 /// report-bucket upload prefix (ADR-0100) -- `command_name()`'s value
526 /// with its `"job."` prefix stripped, rather than a 6th place in this
527 /// codebase repeating the same job-name literals already duplicated
528 /// across every job's `worker.rs` metrics call sites.
529 pub(crate) fn job_name(&self) -> &'static str {
530 self.command_name().trim_start_matches("job.")
531 }
532}