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}