Skip to main content

chamber_secret_manager_cli/
lib.rs

1mod api;
2mod backup;
3mod health;
4mod stats;
5mod utils;
6mod vault;
7
8use crate::api::handle_api_command;
9use crate::backup::{BackupCommand, handle_backup_command};
10use crate::health::{analyze_password_strength, handle_health_command};
11use crate::stats::handle_stats_command;
12use crate::utils::{filter_and_sort_items, format_relative_time};
13use crate::vault::{VaultCommand, handle_vault_command};
14use chamber_import_export::{ExportFormat, detect_format_from_extension, export_items, import_items};
15use chamber_password_gen::{
16    PasswordConfig, generate_complex_password, generate_memorable_password, generate_simple_password,
17};
18use chamber_vault::{Item, ItemKind, NewItem, Vault, VaultManager};
19use clap::{Parser, Subcommand};
20use color_eyre::Result;
21use color_eyre::eyre::eyre;
22use std::path::PathBuf;
23use std::str::FromStr;
24use time::OffsetDateTime;
25
26#[derive(Parser, Debug)]
27#[command(
28    name = "chamber",
29    about = "A secure, local-first secrets manager with encrypted storage and multiple vault support",
30    long_about = "Chamber is a secure secrets manager that stores your passwords, API keys, and other \
31                  sensitive information using strong encryption. All data is stored locally with \
32                  zero-knowledge architecture.\n\n\
33                  Features:\n\
34                  • ChaCha20-Poly1305 authenticated encryption\n\
35                  • Argon2 key derivation for master passwords\n\
36                  • Multiple vault support for organization\n\
37                  • Automatic backup system with retention policies\n\
38                  • Import/export in JSON, CSV, and Chamber formats\n\
39                  • Terminal UI and command-line interface\n\
40                  • Secure password generation\n\n\
41                  Quick start:\n\
42                  1. chamber init              # Initialize your first vault\n\
43                  2. chamber add -n \"github-token\" -k apikey -v \"your-token\"\n\
44                  3. chamber list              # View your secrets\n\
45                  4. chamber                   # Launch terminal interface"
46)]
47pub struct Cli {
48    #[command(subcommand)]
49    pub command: Option<Commands>,
50}
51
52#[derive(Subcommand, Debug)]
53pub enum Commands {
54    /// Start the REST API server
55    Api {
56        #[arg(
57            short,
58            long,
59            default_value = "127.0.0.1:3000",
60            help = "Address to bind the API server to"
61        )]
62        bind: String,
63        #[arg(short, long, help = "Port to bind the API server to")]
64        port: Option<u16>,
65    },
66
67    /// Initialize a new Chamber vault with master password encryption
68    Init,
69
70    /// Add a new secret item to the vault
71    Add {
72        /// Name/identifier for the secret (e.g., "github-token", "database-password")
73        #[arg(short, long)]
74        name: String,
75        /// Type of secret: password, apikey, envvar, sshkey, certificate, database, note
76        #[arg(short, long, default_value = "note")]
77        kind: String,
78        /// The secret value to store (will be encrypted)
79        #[arg(short, long)]
80        value: Option<String>,
81        #[arg(long, help = "Generate a secure password automatically")]
82        generate: bool,
83        #[arg(long, default_value = "16", help = "Length of generated password")]
84        length: Option<usize>,
85        #[arg(long, help = "Generate simple password (alphanumeric only)")]
86        simple: bool,
87        #[arg(long, help = "Generate complex password (all character types)")]
88        complex: bool,
89        #[arg(long, help = "Generate memorable password")]
90        memorable: bool,
91    },
92
93    /// Show vault health report
94    Health {
95        #[arg(long, help = "Show detailed health analysis")]
96        detailed: bool,
97    },
98
99    /// List all secrets in the vault (names and types only)
100    List {
101        #[arg(long, help = "Filter by item type (password, apikey, envvar, etc.)")]
102        r#type: Option<String>,
103        #[arg(long, help = "Show items created since date (e.g., '1 week ago', '3 days ago')")]
104        since: Option<String>,
105        #[arg(long, help = "Show N most recent items")]
106        recent: Option<usize>,
107        #[arg(long, help = "Filter by name pattern (supports wildcards like GitHub*)")]
108        name: Option<String>,
109    },
110
111    /// Retrieve and display a specific secret by name
112    Get {
113        /// Name of the secret to retrieve
114        #[arg(short, long)]
115        name: String,
116        #[arg(long, help = "Copy value to clipboard instead of displaying it")]
117        copy_value: bool,
118    },
119
120    /// Generate secure passwords with customizable options
121    Generate {
122        /// Password length (default: 16 characters)
123        #[arg(short, long, default_value = "16")]
124        length: usize,
125        /// Generate simple alphanumeric password
126        #[arg(long)]
127        simple: bool,
128        /// Generate complex password with all character types
129        #[arg(long)]
130        complex: bool,
131        /// Generate memorable password with words
132        #[arg(long)]
133        memorable: bool,
134        /// Exclude uppercase letters (A-Z)
135        #[arg(long)]
136        no_uppercase: bool,
137        /// Exclude lowercase letters (a-z)
138        #[arg(long)]
139        no_lowercase: bool,
140        /// Exclude digits (0-9)
141        #[arg(long)]
142        no_digits: bool,
143        /// Exclude symbols (!@#$%^&*)
144        #[arg(long)]
145        no_symbols: bool,
146        /// Include ambiguous characters (0/O, 1/l/I)
147        #[arg(long)]
148        include_ambiguous: bool,
149        /// Number of passwords to generate
150        #[arg(short, long)]
151        count: Option<usize>,
152    },
153
154    /// Export vault contents to a file for backup or migration
155    Export {
156        /// Output file path (e.g., backup.json, secrets.csv)
157        #[arg(short, long)]
158        output: PathBuf,
159        /// Export format: json, csv, backup (auto-detected from file extension)
160        #[arg(short, long)]
161        format: Option<String>,
162        /// Include creation/modification timestamps in export
163        #[arg(long)]
164        include_metadata: bool,
165    },
166
167    /// Import secrets from a file into the vault
168    Import {
169        /// Input file path containing secrets to import
170        #[arg(short, long)]
171        input: PathBuf,
172        /// Import format: json, csv, backup (auto-detected from file extension)
173        #[arg(short, long)]
174        format: Option<String>,
175        /// Preview import without making changes
176        #[arg(long)]
177        dry_run: bool,
178        /// Skip items that already exist in the vault
179        #[arg(long)]
180        skip_duplicates: bool,
181    },
182    /// Show vault statistics
183    Stats,
184
185    /// Update an existing secret
186    Update {
187        #[arg(short, long, help = "Name of the item to update")]
188        name: String,
189        #[arg(short, long, help = "New value for the item")]
190        value: Option<String>,
191        #[arg(long, help = "Generate a new secure password automatically")]
192        generate: bool,
193        #[arg(long, default_value = "16", help = "Length of generated password")]
194        length: Option<usize>,
195        #[arg(long, help = "Generate simple password (alphanumeric only)")]
196        simple: bool,
197        #[arg(long, help = "Generate complex password (all character types)")]
198        complex: bool,
199        #[arg(long, help = "Generate memorable password")]
200        memorable: bool,
201        #[arg(long, help = "Copy updated value to clipboard")]
202        copy: bool,
203        #[arg(long, help = "Specify which vault to search in (otherwise searches all)")]
204        vault: Option<String>,
205    },
206
207    /// Backup management commands for automatic data protection
208    #[command(subcommand)]
209    Backup(BackupCommand),
210
211    /// Multiple vault management commands for organizing secrets
212    #[command(subcommand)]
213    Registry(VaultCommand),
214}
215
216/// Handles various commands related to a vault system, including initialization,
217/// adding items, listing items, retrieving items, generating passwords,
218/// exporting items, and importing items.
219///
220/// # Arguments
221/// * `cmd` - An instance of the `Commands` enumeration specifying the action to perform.
222///
223/// # Returns
224/// * A `Result` that indicates success (`Ok`) or failure (`Err`) with an error message.
225///
226/// # Supported Commands
227/// - `Commands::Init`:
228///   Initializes a vault at a specified location. If the vault is already initialized,
229///   it informs the user. Prompts the user to create and confirm a master key during initialization.
230///
231/// - `Commands::Add`:
232///   Adds a new item to the vault.
233///   - Prompts the user for the master key to unlock the vault.
234///   - Accepts parameters `name`, `kind` (e.g., password, ssh key, note), and `value`.
235///   - Returns an error if the item already exists or if any operation fails.
236///
237/// - `Commands::List`:
238///   Lists all items in the vault.
239///   - Prompts the user for the master key to unlock the vault.
240///   - Outputs items in the format `- <name> [<item_kind>]`.
241///
242/// - `Commands::Get`:
243///   Retrieves the value of an item by its name.
244///   - Prompts the user for the master key to unlock the vault.
245///   - Returns the item's value if found, or an error if the item is not found.
246///
247/// - `Commands::Generate`:
248///   Generates passwords with customizable options.
249///   - `length`: Specifies the password length.
250///   - `simple`, `complex`, `memorable`: Password style preferences.
251///   - `no_uppercase`, `no_lowercase`, `no_digits`, `no_symbols`: Exclusion flags for character sets.
252///   - `include_ambiguous`: Includes or excludes ambiguous characters.
253///   - `count`: Number of passwords to generate (if greater than 1, passwords are numbered in output).
254///   - Outputs the generated password(s).
255///
256/// - `Commands::Export`:
257///   Exports all items in the vault to a file.
258///   - Prompts the user for the master key to unlock the vault.
259///   - Accepts `output` (path to export file) and `format` (e.g., JSON, XML, etc.).
260///   - Detects file format from the output file extension if not explicitly specified.
261///   - Outputs the number of items exported and details about the export location and format.
262///
263/// - `Commands::Import`:
264///   Imports items from a file into the vault.
265///   - Checks if the input file exists.
266///   - Detects file format from input file extension if not explicitly specified.
267///   - Accepts options:
268///     - `dry_run`: Only displays items to be imported without making changes.
269///     - `skip_duplicates`: Skips importing items that already exist in the vault.
270///   - Prompts the user for the master key to unlock the vault.
271///   - Outputs the number of items imported, skipped, or failed to import.
272///
273/// # Errors
274/// This function returns errors in the following cases:
275/// - Failure to open or create a vault.
276/// - Failure to unlock or initialize the vault.
277/// - IO errors while exporting or importing.
278/// - Validation errors during input or conflicts in item names.
279/// - Password generation errors for invalid configurations.
280///
281/// # Panics
282///
283/// # Examples
284/// ```ignore
285/// handle_command(Commands::Init);
286/// handle_command(Commands::Add {
287///     name: "example_credential".to_string(),
288///     kind: "password".to_string(),
289///     value: "securepassword123".to_string(),
290/// });
291/// handle_command(Commands::List);
292/// handle_command(Commands::Get { name: "example_credential".to_string() });
293/// handle_command(Commands::Generate {
294///     length: 16,
295///     complex: true,
296///     simple: false,
297///     memorable: false,
298///     no_uppercase: false,
299///     no_lowercase: false,
300///     no_digits: false,
301///     no_symbols: false,
302///     include_ambiguous: true,
303///     count: Some(3),
304/// });
305/// ```
306#[allow(clippy::cognitive_complexity)]
307#[allow(clippy::too_many_lines)]
308pub async fn handle_command(cmd: Commands) -> Result<()> {
309    match cmd {
310        Commands::Api { bind, port } => handle_api_command(bind, port).await?,
311
312        Commands::Init => {
313            let mut vault = Vault::open_or_create(None)?;
314            if vault.is_initialized() {
315                println!("Vault already initialized at {}", vault.db_path().display());
316            } else {
317                let master = prompt_secret("Create master key: ")?;
318                let confirm = prompt_secret("Confirm master key: ")?;
319                if master != confirm {
320                    return Err(eyre!("Master keys do not match"));
321                }
322                vault.initialize(&master)?;
323                println!("Initialized vault at {}", vault.db_path().display());
324            }
325        }
326        Commands::Add {
327            name,
328            kind,
329            value,
330            generate,
331            length,
332            simple,
333            complex,
334            memorable,
335        } => {
336            let mut vault = Vault::open_or_create(None)?;
337            let master = prompt_secret("Enter master key: ")?;
338            vault.unlock(&master)?;
339
340            // Determine the value to use
341            let item_value = if generate {
342                // Validate that value wasn't also provided
343                if value.is_some() {
344                    return Err(eyre!("Cannot use both --value and --generate options together"));
345                }
346
347                // Generate password based on options
348                let password_length = length.unwrap_or(16);
349                let generated_password = if memorable {
350                    generate_memorable_password()
351                } else if simple {
352                    generate_simple_password(password_length)?
353                } else if complex {
354                    generate_complex_password(password_length)?
355                } else {
356                    // Default: secure password with good character mix
357                    PasswordConfig::new()
358                        .with_length(password_length)
359                        .with_uppercase(true)
360                        .with_lowercase(true)
361                        .with_digits(true)
362                        .with_symbols(true)
363                        .with_exclude_ambiguous(true)
364                        .generate()?
365                };
366
367                // Show the generated password to the user
368                println!("Generated password: {generated_password}");
369                generated_password
370            } else {
371                match value {
372                    Some(v) => v,
373                    None => prompt_secret("Enter value: ")?,
374                }
375            };
376
377            let kind = match kind.as_str() {
378                "pass" | "password" => ItemKind::Password,
379                "env" | "envvar" => ItemKind::EnvVar,
380                "ssh" | "sshkey" => ItemKind::SshKey,
381                "apikey" | "api_key" | "api-key" | "token" => ItemKind::ApiKey,
382                "certificate" | "cert" | "ssl" | "tls" => ItemKind::Certificate,
383                "database" | "db" | "connection" => ItemKind::Database,
384                _ => ItemKind::Note,
385            };
386
387            let item = NewItem {
388                name: name.clone(),
389                kind,
390                value: item_value,
391            };
392
393            match vault.create_item(&item) {
394                Ok(()) => {
395                    println!("✅ Item '{name}' added successfully.");
396                }
397                Err(e) => {
398                    let msg = e.to_string();
399                    if msg.contains("already exists") {
400                        println!("❌ Item '{name}' already exists. Use a different name or update it.");
401                        return Ok(());
402                    }
403                    // Other errors should still bubble up
404                    return Err(e);
405                }
406            }
407        }
408
409        Commands::Health { detailed } => {
410            let mut vault = match Vault::open_active() {
411                Ok(vault) => vault,
412                Err(_) => {
413                    // If no active vault, try default
414                    Vault::open_default()?
415                }
416            };
417
418            let master = prompt_secret("Enter master key: ")?;
419            vault.unlock(&master)?;
420            if !vault.is_unlocked() {
421                eprintln!("❌ Vault is locked. Please unlock it first.");
422                std::process::exit(1);
423            }
424            handle_health_command(&vault, detailed)?;
425        }
426
427        Commands::List {
428            r#type,
429            since,
430            recent,
431            name,
432        } => {
433            let mut vault = Vault::open_or_create(None)?;
434            let master = prompt_secret("Enter master key: ")?;
435            vault.unlock(&master)?;
436
437            let all_items = vault.list_items()?;
438            let filtered_items =
439                match filter_and_sort_items(all_items, r#type.as_deref(), since.as_deref(), recent, name.as_deref()) {
440                    Ok(items) => items,
441                    Err(e) => {
442                        return Err(eyre!("Error while filtering items: {e}"));
443                    }
444                };
445
446            if filtered_items.is_empty() {
447                println!("No items found matching the criteria.");
448                return Ok(());
449            }
450
451            // Display results with enhanced formatting
452            println!("Found {} item(s):", filtered_items.len());
453            println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
454
455            for item in filtered_items {
456                let age = format_relative_time(item.created_at);
457                println!(
458                    "• {} [{}] - created {} ({})",
459                    item.name,
460                    item.kind.display_name(),
461                    age,
462                    item.created_at
463                        .format(&time::format_description::well_known::Rfc3339)
464                        .unwrap_or_else(|_| "unknown".to_string())
465                );
466            }
467        }
468
469        Commands::Get { name, copy_value } => {
470            let mut vault = Vault::open_or_create(None)?;
471            let master = prompt_secret("Enter master key: ")?;
472            vault.unlock(&master)?;
473            if let Some(item) = vault.get_item_by_name(&name)? {
474                if copy_value {
475                    // Copy to clipboard instead of displaying
476                    let mut clipboard =
477                        arboard::Clipboard::new().map_err(|e| eyre!("Failed to access clipboard: {}", e))?;
478                    clipboard
479                        .set_text(&item.value)
480                        .map_err(|e| eyre!("Failed to copy to clipboard: {}", e))?;
481                    println!("✅ Value for '{name}' copied to clipboard");
482                } else {
483                    println!("{}", item.value);
484                }
485            } else {
486                return Err(eyre!("Item not found"));
487            }
488        }
489
490        Commands::Generate {
491            length,
492            simple,
493            complex,
494            memorable,
495            no_uppercase,
496            no_lowercase,
497            no_digits,
498            no_symbols,
499            include_ambiguous,
500            count,
501        } => {
502            let count = count.unwrap_or(1);
503
504            for i in 0..count {
505                let password = if memorable {
506                    generate_memorable_password()
507                } else if simple {
508                    generate_simple_password(length)?
509                } else if complex {
510                    generate_complex_password(length)?
511                } else {
512                    // Custom configuration
513                    PasswordConfig::new()
514                        .with_length(length)
515                        .with_uppercase(!no_uppercase)
516                        .with_lowercase(!no_lowercase)
517                        .with_digits(!no_digits)
518                        .with_symbols(!no_symbols)
519                        .with_exclude_ambiguous(!include_ambiguous)
520                        .generate()?
521                };
522
523                if count > 1 {
524                    println!("{}: {password}", i + 1);
525                } else {
526                    println!("{password}");
527                }
528            }
529        }
530        Commands::Export {
531            output,
532            format,
533            include_metadata: _,
534        } => {
535            let mut vault = Vault::open_or_create(None)?;
536            let master = prompt_secret("Enter master key: ")?;
537            vault.unlock(&master)?;
538
539            let items = vault.list_items()?;
540            if items.is_empty() {
541                println!("No items to export.");
542                return Ok(());
543            }
544
545            // Determine format
546            let export_format = if let Some(format_str) = format {
547                ExportFormat::from_str(&format_str)?
548            } else {
549                // Try to detect from file extension
550                detect_format_from_extension(&output).unwrap_or(ExportFormat::Json)
551            };
552
553            export_items(&items, &export_format, &output)?;
554            println!(
555                "Exported {} items to {} (format: {:?})",
556                items.len(),
557                output.display(),
558                export_format
559            );
560        }
561        Commands::Import {
562            input,
563            format,
564            dry_run,
565            skip_duplicates,
566        } => {
567            if !input.exists() {
568                return Err(eyre!("Input file does not exist: {}", input.display()));
569            }
570
571            // Determine format
572            let import_format = if let Some(format_str) = format {
573                ExportFormat::from_str(&format_str)?
574            } else {
575                // Try to detect from file extension
576                detect_format_from_extension(&input)
577                    .ok_or_else(|| eyre!("Could not detect file format. Please specify with --format"))?
578            };
579
580            let new_items = import_items(&input, &import_format)?;
581            if new_items.is_empty() {
582                println!("No items found in import file.");
583                return Ok(());
584            }
585
586            println!("Found {} items to import", new_items.len());
587
588            if dry_run {
589                println!("DRY RUN - Items that would be imported:");
590                for item in &new_items {
591                    println!("  - {} [{}]", item.name, item.kind.as_str());
592                }
593                return Ok(());
594            }
595
596            let master = prompt_secret("Enter master key: ")?;
597            let mut vault = Vault::open_or_create(None)?;
598            vault.unlock(&master)?;
599
600            let existing_items = vault.list_items()?;
601            let existing_names: std::collections::HashSet<String> =
602                existing_items.iter().map(|item| item.name.clone()).collect();
603
604            let mut imported_count = 0;
605            let mut skipped_count = 0;
606
607            for item in new_items {
608                if existing_names.contains(&item.name) {
609                    if skip_duplicates {
610                        println!("Skipping duplicate: {}", item.name);
611                        skipped_count += 1;
612                        continue;
613                    }
614                    println!(
615                        "Warning: Item '{}' already exists and will be skipped. Use --skip-duplicates to suppress this warning.",
616                        item.name
617                    );
618                    skipped_count += 1;
619                    continue;
620                }
621
622                match vault.create_item(&item) {
623                    Ok(()) => {
624                        imported_count += 1;
625                    }
626                    Err(e) => {
627                        println!("Failed to import item: {e}");
628                    }
629                }
630            }
631
632            println!("Import complete: {imported_count} imported, {skipped_count} skipped");
633        }
634        Commands::Stats => {
635            let mut vault = match Vault::open_active() {
636                Ok(vault) => vault,
637                Err(_) => {
638                    // If no active vault, try default
639                    Vault::open_default()?
640                }
641            };
642
643            let master = prompt_secret("Enter master key: ")?;
644            vault.unlock(&master)?;
645            if !vault.is_unlocked() {
646                eprintln!("❌ Vault is locked. Please unlock it first.");
647                std::process::exit(1);
648            }
649            handle_stats_command(&vault)?;
650        }
651
652        Commands::Update {
653            name,
654            value,
655            generate,
656            length,
657            simple,
658            complex,
659            memorable,
660            copy,
661            vault: vault_name,
662        } => {
663            // First, try to find the item across all vaults
664            let (vault, existing_item, found_vault_name) = if let Some(specific_vault) = vault_name.clone() {
665                // Search only in the specified vault
666                find_item_in_specific_vault(&name, &specific_vault)?
667            } else {
668                // Search across all vaults
669                find_item_across_vaults(&name)?
670            };
671
672            if let (mut vault, Some(item), vault_name) = (vault, existing_item, found_vault_name) {
673                println!("🔍 Found '{name}' in vault: {vault_name}");
674                println!("📝 Updating '{}' [{}]", item.name, item.kind.display_name());
675
676                // Determine the new value to use
677                let new_value = if generate {
678                    // Validate that value wasn't also provided
679                    if value.is_some() {
680                        return Err(eyre!("Cannot use both --value and --generate options together"));
681                    }
682
683                    // Generate password based on options
684                    let password_length = length.unwrap_or(16);
685                    let generated_password = if memorable {
686                        generate_memorable_password()
687                    } else if simple {
688                        generate_simple_password(password_length)?
689                    } else if complex {
690                        generate_complex_password(password_length)?
691                    } else {
692                        // Default: secure password with good character mix
693                        PasswordConfig::new()
694                            .with_length(password_length)
695                            .with_uppercase(true)
696                            .with_lowercase(true)
697                            .with_digits(true)
698                            .with_symbols(true)
699                            .with_exclude_ambiguous(true)
700                            .generate()?
701                    };
702
703                    println!("🔐 Generated new password: {generated_password}");
704                    generated_password
705                } else if let Some(v) = value {
706                    println!("📝 Using provided value");
707                    v
708                } else {
709                    // Interactive mode - show current value (masked for passwords)
710                    let display_current = if matches!(item.kind, ItemKind::Password | ItemKind::ApiKey) {
711                        format!("{}***", &item.value.chars().take(3).collect::<String>())
712                    } else {
713                        #[allow(clippy::redundant_clone)]
714                        item.value.clone()
715                    };
716
717                    println!("Current value: {display_current}");
718                    prompt_secret("Enter new value (or press Ctrl+C to cancel): ")?
719                };
720
721                // Confirm if values are the same
722                if new_value == item.value {
723                    println!("⚠️  New value is the same as the current value. No changes made.");
724                    return Ok(());
725                }
726
727                // Update the item
728                match vault.update_item(item.id, &new_value) {
729                    Ok(()) => {
730                        println!("✅ Item '{name}' updated successfully in vault '{vault_name}'.");
731
732                        // Show password strength for password-type items
733                        if matches!(item.kind, ItemKind::Password | ItemKind::ApiKey) {
734                            let strength = analyze_password_strength(&new_value);
735                            println!("🔒 Password strength: {strength}");
736                        }
737
738                        // Copy to clipboard if requested
739                        if copy {
740                            match arboard::Clipboard::new() {
741                                Ok(mut clipboard) => {
742                                    if let Err(e) = clipboard.set_text(&new_value) {
743                                        println!("⚠️  Warning: Failed to copy to clipboard: {e}");
744                                    } else {
745                                        println!("📋 New value copied to clipboard.");
746                                    }
747                                }
748                                Err(e) => {
749                                    println!("⚠️  Warning: Failed to access clipboard: {e}");
750                                }
751                            }
752                        }
753
754                        // Show update timestamp
755                        println!(
756                            "🕐 Updated at: {}",
757                            OffsetDateTime::now_utc()
758                                .format(&time::format_description::well_known::Rfc3339)
759                                .unwrap_or_else(|_| "unknown".to_string())
760                        );
761                    }
762                    Err(e) => {
763                        return Err(eyre!("Failed to update item: {}", e));
764                    }
765                }
766            } else {
767                println!(
768                    "❌ Item '{}' not found{}.",
769                    name,
770                    if vault_name.is_some() {
771                        format!(" in vault '{}'", vault_name.unwrap())
772                    } else {
773                        " in any vault".to_string()
774                    }
775                );
776
777                // Show suggestions from all vaults
778                suggest_similar_items_across_vaults(&name)?;
779
780                println!();
781                println!("💡 Use 'chamber list' to see items in current vault");
782                println!("💡 Use 'chamber registry list' to see all vaults");
783                println!("💡 Use 'chamber add' to create a new item");
784            }
785        }
786
787        Commands::Backup(backup_cmd) => {
788            let vault = Vault::open_or_create(None)?;
789            let _ = handle_backup_command(vault, backup_cmd);
790        }
791        Commands::Registry(registry_cmd) => {
792            let mut manager = VaultManager::new()?;
793            handle_vault_command(&mut manager, registry_cmd)?;
794        }
795    }
796    Ok(())
797}
798
799fn is_weak_password(password: &str) -> bool {
800    // Strong password criteria:
801    // - At least 10 characters
802    // - Contains lowercase letter
803    // - Contains uppercase letter
804    // - Contains number
805    // - Contains special character
806
807    if password.len() < 10 {
808        return true;
809    }
810
811    let has_lower = password.chars().any(|c| c.is_ascii_lowercase());
812    let has_upper = password.chars().any(|c| c.is_ascii_uppercase());
813    let has_digit = password.chars().any(|c| c.is_ascii_digit());
814    let has_special = password.chars().any(|c| !c.is_ascii_alphanumeric());
815
816    !(has_lower && has_upper && has_digit && has_special)
817}
818
819const fn get_type_emoji(item_type: ItemKind) -> &'static str {
820    match item_type {
821        ItemKind::Password => "🔑",
822        ItemKind::EnvVar => "🌐",
823        ItemKind::Note => "📝",
824        ItemKind::ApiKey | ItemKind::SshKey => "🔐",
825        ItemKind::Certificate => "📜",
826        ItemKind::Database => "🗄️",
827        ItemKind::CreditCard => "💳",
828        ItemKind::SecureNote => "🔒",
829        ItemKind::Identity => "🆔",
830        ItemKind::Server => "🖥️",
831        ItemKind::WifiPassword => "📶",
832        ItemKind::License => "📄",
833        ItemKind::BankAccount => "🏦",
834        ItemKind::Document => "📋",
835        ItemKind::Recovery => "🔄",
836        ItemKind::OAuth => "🎫",
837    }
838}
839
840fn prompt_secret(prompt: &str) -> Result<String> {
841    use std::io::{Write, stdout};
842    print!("{prompt}");
843    stdout().flush()?;
844    // Read without echo on Windows/Linux/macOS
845    let pass = rpassword::prompt_password("")?;
846    Ok(pass)
847}
848
849/// Searches for an item by name across all available vaults
850/// Returns (vault, item, `vault_name`) if found, or (_, None, _) if not found
851fn find_item_across_vaults(item_name: &str) -> Result<(Vault, Option<Item>, String)> {
852    let manager = VaultManager::new()?;
853    let vaults = manager.list_vaults();
854
855    // First try the active vault
856    if let Ok(mut vault) = Vault::open_active() {
857        if let Ok(Some(vault_id)) = vault.get_vault_id() {
858            let master = prompt_secret(&format!(
859                "Enter master key for active vault ({}): ",
860                get_vault_display_name(&manager, &vault_id)
861            ))?;
862
863            if vault.unlock(&master).is_ok() {
864                if let Ok(Some(item)) = vault.get_item_by_name(item_name) {
865                    let vault_name = get_vault_display_name(&manager, &vault_id);
866                    return Ok((vault, Some(item), vault_name));
867                }
868            }
869        }
870    }
871
872    // If not found in active vault, search all other vaults
873    let mut tried_vaults = Vec::new();
874
875    for vault_info in vaults {
876        // Skip if it's the active vault (already tried)
877        if let Ok(active_vault) = Vault::open_active() {
878            if let Ok(Some(active_id)) = active_vault.get_vault_id() {
879                if vault_info.id == active_id {
880                    continue;
881                }
882            }
883        }
884
885        println!("🔍 Searching in vault: {} ({})", vault_info.name, vault_info.category);
886
887        let master = prompt_secret(&format!("Enter master key for '{}': ", vault_info.name))?;
888
889        match Vault::open_by_id(&vault_info.id) {
890            Ok(mut vault) => match vault.unlock(&master) {
891                Ok(()) => {
892                    tried_vaults.push(vault_info.name.clone());
893                    if let Ok(Some(item)) = vault.get_item_by_name(item_name) {
894                        return Ok((vault, Some(item), vault_info.name.clone()));
895                    }
896                }
897                Err(_) => {
898                    println!("❌ Failed to unlock vault '{}' (incorrect password?)", vault_info.name);
899                }
900            },
901            Err(e) => {
902                println!("❌ Failed to open vault '{}': {}", vault_info.name, e);
903            }
904        }
905    }
906
907    if !tried_vaults.is_empty() {
908        println!(
909            "🔍 Searched in {} vault(s): {}",
910            tried_vaults.len(),
911            tried_vaults.join(", ")
912        );
913    }
914
915    // Return empty result if not found anywhere
916    Ok((Vault::open_default()?, None, String::new()))
917}
918
919/// Gets a user-friendly display name for a vault
920fn get_vault_display_name(manager: &VaultManager, vault_id: &str) -> String {
921    if let Some(vault_info) = manager.list_vaults().iter().find(|v| v.id == vault_id) {
922        format!("{} ({})", vault_info.name, vault_info.category)
923    } else {
924        vault_id.to_string()
925    }
926}
927
928/// Suggests similar items from all vaults
929fn suggest_similar_items_across_vaults(item_name: &str) -> Result<()> {
930    let manager = VaultManager::new()?;
931    let vaults = manager.list_vaults();
932    let mut suggestions = Vec::new();
933
934    for vault_info in vaults {
935        // Try to open and search each vault (but don't prompt for password for suggestions)
936        if let Ok(vault) = Vault::open_by_id(&vault_info.id) {
937            // Only suggest from already unlocked vaults to avoid password prompts
938            if vault.is_unlocked() {
939                if let Ok(items) = vault.list_items() {
940                    for item in items {
941                        if is_similar_name(&item.name, item_name) {
942                            suggestions.push((item.name, item.kind, vault_info.name.clone()));
943                        }
944                    }
945                }
946            }
947        }
948    }
949
950    if !suggestions.is_empty() {
951        println!("💡 Did you mean one of these?");
952        for (name, kind, vault_name) in suggestions.into_iter().take(5) {
953            println!("   • {} [{}] in vault '{}'", name, kind.display_name(), vault_name);
954        }
955    }
956
957    Ok(())
958}
959
960/// Simple fuzzy matching for item names
961fn is_similar_name(item_name: &str, search_name: &str) -> bool {
962    let item_lower = item_name.to_lowercase();
963    let search_lower = search_name.to_lowercase();
964
965    // Check if names contain each other
966    if item_lower.contains(&search_lower) || search_lower.contains(&item_lower) {
967        return true;
968    }
969
970    // Check for common substrings of length 3+
971    if search_lower.len() >= 3 {
972        search_lower.chars().collect::<Vec<_>>().windows(3).any(|window| {
973            let substr: String = window.iter().collect();
974            item_lower.contains(&substr)
975        })
976    } else {
977        false
978    }
979}
980
981fn find_item_in_specific_vault(item_name: &str, vault_identifier: &str) -> Result<(Vault, Option<Item>, String)> {
982    let manager = VaultManager::new()?;
983    let vaults = manager.list_vaults();
984
985    // Find the vault by name or ID
986    let vault_info = vaults
987        .iter()
988        .find(|v| v.name.eq_ignore_ascii_case(vault_identifier) || v.id == vault_identifier)
989        .ok_or_else(|| eyre!("Vault '{}' not found", vault_identifier))?;
990
991    let mut vault = Vault::open_by_id(&vault_info.id)?;
992    let master = prompt_secret(&format!("Enter master key for '{}': ", vault_info.name))?;
993    vault.unlock(&master)?;
994
995    let item = vault.get_item_by_name(item_name)?;
996    Ok((vault, item, vault_info.name.clone()))
997}
998
999// Rust
1000#[cfg(test)]
1001mod handle_command_tests {
1002    #![allow(clippy::unwrap_used)]
1003    use super::*;
1004
1005    fn tmp_path(name: &str) -> PathBuf {
1006        let now = time::OffsetDateTime::now_utc().unix_timestamp_nanos();
1007        let pid = std::process::id();
1008        std::env::temp_dir().join(format!("chamber_cli_{name}_{pid}_{now}"))
1009    }
1010
1011    #[tokio::test]
1012    async fn test_generate_simple_ok() {
1013        // Runs the simple generator branch (no prompts involved)
1014        let cmd = Commands::Generate {
1015            length: 12,
1016            simple: true,
1017            complex: false,
1018            memorable: false,
1019            no_uppercase: false,
1020            no_lowercase: false,
1021            no_digits: false,
1022            no_symbols: false,
1023            include_ambiguous: false,
1024            count: None,
1025        };
1026        let res = handle_command(cmd);
1027        assert!(res.await.is_ok());
1028    }
1029
1030    #[tokio::test]
1031    async fn test_generate_complex_ok() {
1032        // Runs the complex generator branch (no prompts involved)
1033        let cmd = Commands::Generate {
1034            length: 24,
1035            simple: false,
1036            complex: true,
1037            memorable: false,
1038            no_uppercase: false,
1039            no_lowercase: false,
1040            no_digits: false,
1041            no_symbols: false,
1042            include_ambiguous: false,
1043            count: Some(1),
1044        };
1045        let res = handle_command(cmd);
1046        assert!(res.await.is_ok());
1047    }
1048
1049    #[tokio::test]
1050    async fn test_generate_memorable_ok() {
1051        // Runs the memorable generator branch (no prompts involved)
1052        let cmd = Commands::Generate {
1053            length: 16, // ignored by memorable branch but required by struct
1054            simple: false,
1055            complex: false,
1056            memorable: true,
1057            no_uppercase: false,
1058            no_lowercase: false,
1059            no_digits: false,
1060            no_symbols: false,
1061            include_ambiguous: false,
1062            count: None,
1063        };
1064        let res = handle_command(cmd);
1065        assert!(res.await.is_ok());
1066    }
1067
1068    #[tokio::test]
1069    async fn test_generate_count_multiple_ok() {
1070        // Ensure the loop over count works (no prompts involved)
1071        let cmd = Commands::Generate {
1072            length: 10,
1073            simple: true,
1074            complex: false,
1075            memorable: false,
1076            no_uppercase: false,
1077            no_lowercase: false,
1078            no_digits: false,
1079            no_symbols: false,
1080            include_ambiguous: false,
1081            count: Some(3),
1082        };
1083        let res = handle_command(cmd);
1084        assert!(res.await.is_ok());
1085    }
1086
1087    #[tokio::test]
1088    async fn test_generate_custom_no_character_sets_err() {
1089        // Triggers the custom branch and makes the generator return an error
1090        // by disabling all sets. The error should propagate out of handle_command.
1091        let cmd = Commands::Generate {
1092            length: 10,
1093            simple: false,
1094            complex: false,
1095            memorable: false,
1096            no_uppercase: true,
1097            no_lowercase: true,
1098            no_digits: true,
1099            no_symbols: true,
1100            include_ambiguous: false,
1101            count: None,
1102        };
1103        let err = handle_command(cmd).await.unwrap_err().to_string();
1104        println!("{err}");
1105        assert!(err.contains("At least one character set must be enabled"));
1106    }
1107
1108    #[tokio::test]
1109    async fn test_import_missing_file_err() {
1110        // This hits the early error path for missing an input file without prompting.
1111        let missing = tmp_path("missing").with_extension("json");
1112        let cmd = Commands::Import {
1113            input: missing,
1114            format: Some("json".to_string()),
1115            dry_run: false,
1116            skip_duplicates: false,
1117        };
1118        let err = handle_command(cmd).await.unwrap_err().to_string();
1119        assert!(err.contains("Input file does not exist"));
1120    }
1121}