Skip to main content

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}