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 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 Init,
69
70 Add {
72 #[arg(short, long)]
74 name: String,
75 #[arg(short, long, default_value = "note")]
77 kind: String,
78 #[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 Health {
95 #[arg(long, help = "Show detailed health analysis")]
96 detailed: bool,
97 },
98
99 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 Get {
113 #[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 {
122 #[arg(short, long, default_value = "16")]
124 length: usize,
125 #[arg(long)]
127 simple: bool,
128 #[arg(long)]
130 complex: bool,
131 #[arg(long)]
133 memorable: bool,
134 #[arg(long)]
136 no_uppercase: bool,
137 #[arg(long)]
139 no_lowercase: bool,
140 #[arg(long)]
142 no_digits: bool,
143 #[arg(long)]
145 no_symbols: bool,
146 #[arg(long)]
148 include_ambiguous: bool,
149 #[arg(short, long)]
151 count: Option<usize>,
152 },
153
154 Export {
156 #[arg(short, long)]
158 output: PathBuf,
159 #[arg(short, long)]
161 format: Option<String>,
162 #[arg(long)]
164 include_metadata: bool,
165 },
166
167 Import {
169 #[arg(short, long)]
171 input: PathBuf,
172 #[arg(short, long)]
174 format: Option<String>,
175 #[arg(long)]
177 dry_run: bool,
178 #[arg(long)]
180 skip_duplicates: bool,
181 },
182 Stats,
184
185 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 #[command(subcommand)]
209 Backup(BackupCommand),
210
211 #[command(subcommand)]
213 Registry(VaultCommand),
214}
215
216#[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 let item_value = if generate {
342 if value.is_some() {
344 return Err(eyre!("Cannot use both --value and --generate options together"));
345 }
346
347 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 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 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 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 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 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 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 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 let export_format = if let Some(format_str) = format {
547 ExportFormat::from_str(&format_str)?
548 } else {
549 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 let import_format = if let Some(format_str) = format {
573 ExportFormat::from_str(&format_str)?
574 } else {
575 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 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 let (vault, existing_item, found_vault_name) = if let Some(specific_vault) = vault_name.clone() {
665 find_item_in_specific_vault(&name, &specific_vault)?
667 } else {
668 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 let new_value = if generate {
678 if value.is_some() {
680 return Err(eyre!("Cannot use both --value and --generate options together"));
681 }
682
683 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 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 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 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 match vault.update_item(item.id, &new_value) {
729 Ok(()) => {
730 println!("✅ Item '{name}' updated successfully in vault '{vault_name}'.");
731
732 if matches!(item.kind, ItemKind::Password | ItemKind::ApiKey) {
734 let strength = analyze_password_strength(&new_value);
735 println!("🔒 Password strength: {strength}");
736 }
737
738 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 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 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 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 let pass = rpassword::prompt_password("")?;
846 Ok(pass)
847}
848
849fn 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 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 let mut tried_vaults = Vec::new();
874
875 for vault_info in vaults {
876 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 Ok((Vault::open_default()?, None, String::new()))
917}
918
919fn 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
928fn 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 if let Ok(vault) = Vault::open_by_id(&vault_info.id) {
937 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
960fn 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 if item_lower.contains(&search_lower) || search_lower.contains(&item_lower) {
967 return true;
968 }
969
970 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 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#[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 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 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 let cmd = Commands::Generate {
1053 length: 16, 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 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 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 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}