Skip to main content

rusmes_cli/
cli_def.rs

1//! Central CLI struct definition — shared between the binary (`main.rs`) and
2//! the unit tests inside the library modules (`completions`, `man`).
3
4use clap::{Parser, Subcommand};
5use clap_complete::Shell;
6
7/// Determine whether ANSI colors should be emitted given the user's choice and
8/// whether stdout is currently a TTY.
9///
10/// This is a pure function that can be tested without side-effects.
11///
12/// | `choice`        | `is_tty` | result |
13/// |-----------------|----------|--------|
14/// | `Always`        | any      | `true` |
15/// | `Never`         | any      | `false`|
16/// | `Auto`          | `true`   | `true` |
17/// | `Auto`          | `false`  | `false`|
18pub fn should_color(choice: ColorChoice, is_tty: bool) -> bool {
19    match choice {
20        ColorChoice::Always => true,
21        ColorChoice::Never => false,
22        ColorChoice::Auto => is_tty,
23    }
24}
25
26#[cfg(test)]
27mod cli_def_tests {
28    use super::*;
29
30    #[test]
31    fn test_should_color_always() {
32        assert!(should_color(ColorChoice::Always, false));
33        assert!(should_color(ColorChoice::Always, true));
34    }
35
36    #[test]
37    fn test_should_color_never() {
38        assert!(!should_color(ColorChoice::Never, false));
39        assert!(!should_color(ColorChoice::Never, true));
40    }
41
42    #[test]
43    fn test_should_color_auto_tty() {
44        assert!(should_color(ColorChoice::Auto, true));
45    }
46
47    #[test]
48    fn test_should_color_auto_no_tty() {
49        assert!(!should_color(ColorChoice::Auto, false));
50    }
51
52    /// When `NO_COLOR` is set to any non-empty value, color should be off.
53    ///
54    /// This test exercises the NO_COLOR convention (https://no-color.org/).
55    /// We call `should_color` directly after simulating the env check, rather
56    /// than setting the env var (which would be process-wide and could affect
57    /// parallel tests).
58    #[test]
59    fn test_no_color_env_logic() {
60        // Simulate: if NO_COLOR is set and non-empty, always treat as Never.
61        let no_color_set = true; // env var is present and non-empty
62        let effective_choice = if no_color_set {
63            ColorChoice::Never
64        } else {
65            ColorChoice::Auto
66        };
67        // Even with is_tty = true, color must be off.
68        assert!(!should_color(effective_choice, true));
69    }
70}
71
72/// The `rusmes` command-line application parser.
73#[derive(Parser)]
74#[command(name = "rusmes")]
75#[command(about = "RusMES - Rust Mail Enterprise Server", long_about = None)]
76#[command(version)]
77pub struct CliApp {
78    /// Server URL
79    #[arg(long, env = "RUSMES_SERVER", default_value = "http://localhost:8080")]
80    pub server: String,
81
82    /// Runtime directory where the PID file and sockets are stored
83    #[arg(long, default_value = "./data")]
84    pub runtime_dir: String,
85
86    /// Color output mode
87    #[arg(long, value_enum, default_value = "auto")]
88    pub color: ColorChoice,
89
90    /// Enable JSON output for structured commands
91    #[arg(long)]
92    pub json: bool,
93
94    #[command(subcommand)]
95    pub command: Commands,
96}
97
98/// Color output preference.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
100pub enum ColorChoice {
101    /// Enable colors only when writing to a TTY
102    Auto,
103    /// Always enable colors
104    Always,
105    /// Never emit ANSI escape codes
106    Never,
107}
108
109/// Top-level subcommands.
110#[derive(Subcommand)]
111pub enum Commands {
112    /// Initialize a new RusMES installation
113    Init {
114        /// Server domain
115        #[arg(long)]
116        domain: String,
117    },
118
119    /// Validate configuration file
120    CheckConfig {
121        /// Configuration file path
122        #[arg(short, long, default_value = "rusmes.toml")]
123        config: String,
124    },
125
126    /// Show server status
127    Status {
128        /// Watch mode — redraw every N seconds (minimum 1)
129        #[arg(long, value_name = "INTERVAL_SECS")]
130        watch: Option<u64>,
131    },
132
133    /// User management commands
134    User {
135        #[command(subcommand)]
136        action: UserAction,
137    },
138
139    /// Mailbox management commands
140    Mailbox {
141        #[command(subcommand)]
142        action: MailboxAction,
143    },
144
145    /// Queue management commands
146    Queue {
147        #[command(subcommand)]
148        action: QueueAction,
149    },
150
151    /// Backup commands
152    Backup {
153        #[command(subcommand)]
154        action: BackupAction,
155    },
156
157    /// Restore commands
158    Restore {
159        #[command(subcommand)]
160        action: RestoreAction,
161    },
162
163    /// Migrate storage between backends
164    Migrate {
165        /// Source backend type (filesystem, postgres, amaters)
166        #[arg(long)]
167        from: String,
168
169        /// Destination backend type
170        #[arg(long)]
171        to: String,
172
173        /// Source backend configuration (path or connection string)
174        #[arg(long)]
175        source_config: Option<String>,
176
177        /// Destination backend configuration
178        #[arg(long)]
179        dest_config: Option<String>,
180
181        /// Batch size (messages per batch)
182        #[arg(long, default_value = "100")]
183        batch_size: usize,
184
185        /// Parallel workers
186        #[arg(long, default_value = "4")]
187        parallel: usize,
188
189        /// Enable verification
190        #[arg(long)]
191        verify: bool,
192
193        /// Dry run (don't make changes)
194        #[arg(long)]
195        dry_run: bool,
196
197        /// Resume from previous migration
198        #[arg(long)]
199        resume: bool,
200    },
201
202    /// Generate shell completions
203    Completions {
204        /// Shell type
205        #[arg(value_enum)]
206        shell: Shell,
207    },
208
209    /// Generate man page (roff format written to stdout)
210    Man,
211}
212
213/// User management sub-actions.
214#[derive(Subcommand)]
215pub enum UserAction {
216    /// Add a new user
217    Add {
218        /// Email address
219        email: String,
220        /// Password
221        #[arg(long)]
222        password: String,
223        /// Quota in MB
224        #[arg(long)]
225        quota: Option<u64>,
226    },
227
228    /// List all users
229    List,
230
231    /// Delete a user
232    Delete {
233        /// Email address
234        email: String,
235        /// Force deletion without confirmation
236        #[arg(long)]
237        force: bool,
238    },
239
240    /// Change user password
241    Passwd {
242        /// Email address
243        email: String,
244        /// New password
245        #[arg(long)]
246        password: String,
247    },
248
249    /// Show user details
250    Show {
251        /// Email address
252        email: String,
253    },
254
255    /// Set user quota
256    SetQuota {
257        /// Email address
258        email: String,
259        /// Quota in MB
260        #[arg(long)]
261        quota: u64,
262    },
263
264    /// Enable user account
265    Enable {
266        /// Email address
267        email: String,
268    },
269
270    /// Disable user account
271    Disable {
272        /// Email address
273        email: String,
274    },
275}
276
277/// Mailbox management sub-actions.
278#[derive(Subcommand)]
279pub enum MailboxAction {
280    /// List mailboxes for a user
281    List {
282        /// User email
283        user: String,
284    },
285
286    /// Create a new mailbox
287    Create {
288        /// User email
289        user: String,
290        /// Mailbox name
291        #[arg(long)]
292        name: String,
293    },
294
295    /// Delete a mailbox
296    Delete {
297        /// User email
298        user: String,
299        /// Mailbox name
300        #[arg(long)]
301        name: String,
302        /// Force deletion without confirmation
303        #[arg(long)]
304        force: bool,
305    },
306
307    /// Rename a mailbox
308    Rename {
309        /// User email
310        user: String,
311        /// Old mailbox name
312        #[arg(long)]
313        old_name: String,
314        /// New mailbox name
315        #[arg(long)]
316        new_name: String,
317    },
318
319    /// Repair mailbox — validate on-disk state vs metadata index
320    Repair {
321        /// Target mailbox name (repairs all mailboxes when omitted)
322        #[arg(long)]
323        mailbox: Option<String>,
324
325        /// Compact expunged messages after repair
326        #[arg(long)]
327        vacuum: bool,
328    },
329
330    /// Subscribe to a mailbox
331    Subscribe {
332        /// User email
333        user: String,
334        /// Mailbox name
335        #[arg(long)]
336        name: String,
337    },
338
339    /// Unsubscribe from a mailbox
340    Unsubscribe {
341        /// User email
342        user: String,
343        /// Mailbox name
344        #[arg(long)]
345        name: String,
346    },
347
348    /// Show mailbox details
349    Show {
350        /// User email
351        user: String,
352        /// Mailbox name
353        #[arg(long)]
354        name: String,
355    },
356}
357
358/// Queue management sub-actions.
359#[derive(Subcommand)]
360pub enum QueueAction {
361    /// List messages in queue
362    List {
363        /// Filter by status (pending, retrying, failed)
364        #[arg(long)]
365        filter: Option<String>,
366    },
367
368    /// Flush the queue
369    Flush,
370
371    /// Inspect a specific message
372    Inspect {
373        /// Message ID
374        message_id: String,
375    },
376
377    /// Delete a message from the queue
378    Delete {
379        /// Message ID
380        message_id: String,
381    },
382
383    /// Retry a failed message
384    Retry {
385        /// Message ID
386        message_id: String,
387    },
388
389    /// Purge all failed messages
390    Purge,
391
392    /// Show queue statistics
393    Stats,
394}
395
396/// Backup sub-actions.
397#[derive(Subcommand)]
398pub enum BackupAction {
399    /// Create a full backup
400    Full {
401        /// Output file path
402        #[arg(short, long)]
403        output: String,
404        /// Backup format
405        #[arg(long, value_enum, default_value = "tar-gz")]
406        format: BackupFormat,
407        /// Compression type
408        #[arg(long, value_enum, default_value = "gzip")]
409        compression: CompressionType,
410        /// Encrypt backup
411        #[arg(long)]
412        encrypt: bool,
413    },
414
415    /// Create an incremental backup
416    Incremental {
417        /// Output file path
418        #[arg(short, long)]
419        output: String,
420        /// Base backup path
421        #[arg(long)]
422        base: String,
423        /// Backup format
424        #[arg(long, value_enum, default_value = "tar-gz")]
425        format: BackupFormat,
426        /// Compression type
427        #[arg(long, value_enum, default_value = "gzip")]
428        compression: CompressionType,
429        /// Encrypt backup
430        #[arg(long)]
431        encrypt: bool,
432    },
433
434    /// List available backups
435    List,
436
437    /// Verify backup integrity
438    Verify {
439        /// Backup file path
440        backup: String,
441        /// Encryption key (if encrypted)
442        #[arg(long)]
443        key: Option<String>,
444    },
445
446    /// Upload backup to S3
447    UploadS3 {
448        /// Backup file path
449        backup: String,
450        /// S3 bucket
451        #[arg(long)]
452        bucket: String,
453        /// AWS region
454        #[arg(long)]
455        region: String,
456        /// AWS access key
457        #[arg(long, env = "AWS_ACCESS_KEY_ID")]
458        access_key: String,
459        /// AWS secret key
460        #[arg(long, env = "AWS_SECRET_ACCESS_KEY")]
461        secret_key: String,
462    },
463}
464
465/// Restore sub-actions.
466#[derive(Subcommand)]
467pub enum RestoreAction {
468    /// Restore from a backup
469    Restore {
470        /// Backup file path
471        backup: String,
472        /// Encryption key (if encrypted)
473        #[arg(long)]
474        key: Option<String>,
475        /// Point-in-time to restore to
476        #[arg(long)]
477        point_in_time: Option<String>,
478        /// Dry run (don't actually restore)
479        #[arg(long)]
480        dry_run: bool,
481    },
482
483    /// Restore for a specific user
484    User {
485        /// Backup file path
486        backup: String,
487        /// User email
488        #[arg(long)]
489        user: String,
490        /// Encryption key (if encrypted)
491        #[arg(long)]
492        key: Option<String>,
493        /// Dry run (don't actually restore)
494        #[arg(long)]
495        dry_run: bool,
496    },
497
498    /// Download backup from S3 and restore
499    FromS3 {
500        /// S3 URL
501        s3_url: String,
502        /// S3 bucket
503        #[arg(long)]
504        bucket: String,
505        /// AWS region
506        #[arg(long)]
507        region: String,
508        /// AWS access key
509        #[arg(long, env = "AWS_ACCESS_KEY_ID")]
510        access_key: String,
511        /// AWS secret key
512        #[arg(long, env = "AWS_SECRET_ACCESS_KEY")]
513        secret_key: String,
514        /// Encryption key (if encrypted)
515        #[arg(long)]
516        key: Option<String>,
517    },
518
519    /// Show restore history
520    History,
521
522    /// Show details of a specific restore
523    Show {
524        /// Restore ID
525        restore_id: String,
526    },
527}
528
529/// Backup format selection.
530#[derive(Debug, Clone, Copy, clap::ValueEnum)]
531pub enum BackupFormat {
532    TarGz,
533    Binary,
534}
535
536/// Compression algorithm selection.
537#[derive(Debug, Clone, Copy, clap::ValueEnum)]
538pub enum CompressionType {
539    None,
540    Gzip,
541    Zstd,
542}
543
544impl From<BackupFormat> for crate::commands::backup::BackupFormat {
545    fn from(f: BackupFormat) -> Self {
546        match f {
547            BackupFormat::TarGz => crate::commands::backup::BackupFormat::TarGz,
548            BackupFormat::Binary => crate::commands::backup::BackupFormat::Binary,
549        }
550    }
551}
552
553impl From<CompressionType> for crate::commands::backup::CompressionType {
554    fn from(c: CompressionType) -> Self {
555        match c {
556            CompressionType::None => crate::commands::backup::CompressionType::None,
557            CompressionType::Gzip => crate::commands::backup::CompressionType::Gzip,
558            CompressionType::Zstd => crate::commands::backup::CompressionType::Zstd,
559        }
560    }
561}