use std::time::Duration;
use zeroize::Zeroizing;
use crate::cli::{clipboard, i18n, prompts};
use crate::commands::store_op::{StoreMutation, with_store, with_store_then};
use crate::models::cli::{Cli, OtpCommands};
use crate::models::configuration::Configuration;
use crate::models::password_store::{OneTimePassword, OneTimePasswordType, PasswordStore};
use crate::secrets;
pub fn dispatch(
command: &OtpCommands,
cli: &Cli,
configuration: &Configuration,
) -> anyhow::Result<()> {
match command {
OtpCommands::Add(args) => add(
configuration,
args.store_selection.store.as_ref(),
&args.password_path,
&args.parse_password()?,
args.force,
cli.offline,
),
OtpCommands::Copy(args) => copy(
configuration,
args.store_selection.store.as_ref(),
args.force,
&args.source_path,
&args.target_path,
cli.offline,
),
OtpCommands::List(args) => list(
configuration,
args.store_selection.store.as_ref(),
args.tree,
cli.offline,
),
OtpCommands::Move(args) => mv(
configuration,
args.store_selection.store.as_ref(),
args.force,
&args.current_path,
&args.new_path,
cli.offline,
),
OtpCommands::Remove(args) => remove(
configuration,
args.store_selection.store.as_ref(),
args.force,
&args.password_path,
cli.offline,
),
OtpCommands::Show(args) => show(
configuration,
args.store_selection.store.as_ref(),
&args.password_path,
args.clip,
cli.offline,
),
}
}
fn add(
configuration: &Configuration,
store_name: Option<&String>,
password_path: &str,
password: &OneTimePassword,
force: bool,
offline: bool,
) -> anyhow::Result<()> {
with_store(configuration, store_name, offline, |_, store| {
let allow_overwrite = !store.otp.contains_key(password_path)
|| force
|| prompts::get_confirmation_from_user(&i18n::prompt_overwrite_one_time_password())?;
let mutation = insert_one_time_password(store, password_path, password, allow_overwrite)?;
i18n::one_time_password_added(password_path);
Ok(((), mutation))
})
}
fn remove(
configuration: &Configuration,
store_name: Option<&String>,
force: bool,
password_path: &str,
offline: bool,
) -> anyhow::Result<()> {
with_store(configuration, store_name, offline, |_, store| {
let allow_remove = !store.otp.contains_key(password_path)
|| force
|| prompts::get_confirmation_from_user(&i18n::prompt_remove_one_time_password())?;
let mutation = remove_one_time_password(store, password_path, allow_remove)?;
i18n::one_time_password_removed(password_path);
Ok(((), mutation))
})
}
fn list(
configuration: &Configuration,
store_name: Option<&String>,
tree: bool,
offline: bool,
) -> anyhow::Result<()> {
with_store(configuration, store_name, offline, |_, store| {
if tree {
print!(
"{}",
secrets::format_as_tree("", &store.otp_names_as_list())
);
} else {
for secret in store.otp_names_as_list() {
println!("{secret}");
}
}
Ok(((), StoreMutation::Unchanged))
})
}
fn show(
configuration: &Configuration,
store_name: Option<&String>,
password_path: &str,
clip: bool,
offline: bool,
) -> anyhow::Result<()> {
with_store_then(
configuration,
store_name,
offline,
|_, store| {
let Some(password) = store.otp.get_mut(password_path) else {
anyhow::bail!(i18n::error_no_one_time_password_found(password_path))
};
let is_hotp = password.otp_type == OneTimePasswordType::Hotp;
let code = password.generate()?;
let formatted = format_code(code, password.digits);
let mutation = if is_hotp {
StoreMutation::Modified
} else {
StoreMutation::Unchanged
};
Ok((formatted, mutation))
},
|code: &Zeroizing<String>| {
if clip {
let duration = Duration::from_secs(configuration.clipboard_timeout.unwrap_or(45));
let notify = configuration.clipboard_notify.unwrap_or(true);
i18n::one_time_password_copy_into_clipboard(password_path, &duration);
clipboard::copy_text_to_clipboard(code.as_str(), duration, notify)?;
} else {
i18n::one_time_password_show(password_path);
println!("{}", code.as_str());
}
Ok(())
},
)?;
Ok(())
}
fn mv(
configuration: &Configuration,
store_name: Option<&String>,
force: bool,
current_path: &str,
new_path: &str,
offline: bool,
) -> anyhow::Result<()> {
with_store(configuration, store_name, offline, |_, store| {
let allow_overwrite = !store.otp.contains_key(new_path)
|| force
|| prompts::get_confirmation_from_user(&i18n::prompt_overwrite_one_time_password())?;
let mutation = move_one_time_password(store, current_path, new_path, allow_overwrite)?;
i18n::one_time_password_moved(current_path, new_path);
Ok(((), mutation))
})
}
fn copy(
configuration: &Configuration,
store_name: Option<&String>,
force: bool,
source_path: &str,
target_path: &str,
offline: bool,
) -> anyhow::Result<()> {
with_store(configuration, store_name, offline, |_, store| {
let allow_overwrite = !store.otp.contains_key(target_path)
|| force
|| prompts::get_confirmation_from_user(&i18n::prompt_overwrite_one_time_password())?;
let mutation = copy_one_time_password(store, source_path, target_path, allow_overwrite)?;
i18n::one_time_password_copied(source_path, target_path);
Ok(((), mutation))
})
}
fn insert_one_time_password(
store: &mut PasswordStore,
password_path: &str,
password: &OneTimePassword,
allow_overwrite: bool,
) -> anyhow::Result<StoreMutation> {
if store.otp.contains_key(password_path) && !allow_overwrite {
anyhow::bail!(i18n::error_one_time_password_already_exists(password_path));
}
store.otp.insert(password_path.to_owned(), password.clone());
Ok(StoreMutation::Modified)
}
fn remove_one_time_password(
store: &mut PasswordStore,
password_path: &str,
allow_remove: bool,
) -> anyhow::Result<StoreMutation> {
if !store.otp.contains_key(password_path) {
anyhow::bail!(i18n::error_no_one_time_password_found(password_path));
}
if !allow_remove {
anyhow::bail!(i18n::error_not_allowed_to_remove_one_time_password(
password_path
));
}
store.otp.remove(password_path);
Ok(StoreMutation::Modified)
}
fn move_one_time_password(
store: &mut PasswordStore,
current_path: &str,
new_path: &str,
allow_overwrite: bool,
) -> anyhow::Result<StoreMutation> {
if store.otp.contains_key(new_path) && !allow_overwrite {
anyhow::bail!(i18n::error_one_time_password_already_exists(new_path));
}
let Some(password) = store.otp.remove(current_path) else {
anyhow::bail!(i18n::error_no_one_time_password_found(current_path));
};
store.otp.insert(new_path.to_owned(), password);
Ok(StoreMutation::Modified)
}
fn copy_one_time_password(
store: &mut PasswordStore,
source_path: &str,
target_path: &str,
allow_overwrite: bool,
) -> anyhow::Result<StoreMutation> {
if store.otp.contains_key(target_path) && !allow_overwrite {
anyhow::bail!(i18n::error_one_time_password_already_exists(target_path));
}
let Some(password) = store.otp.get(source_path) else {
anyhow::bail!(i18n::error_no_one_time_password_found(source_path));
};
let copy = password.clone();
store.otp.insert(target_path.to_owned(), copy);
Ok(StoreMutation::Modified)
}
fn format_code(code: u32, digits: u8) -> Zeroizing<String> {
Zeroizing::new(format!("{code:0width$}", width = usize::from(digits)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pads_with_leading_zeros_for_six_digits() {
let formatted = format_code(54321, 6);
assert_eq!(formatted.as_str(), "054321");
}
#[test]
fn pads_zero_to_full_width() {
let formatted = format_code(0, 6);
assert_eq!(formatted.as_str(), "000000");
}
#[test]
fn does_not_pad_when_value_already_fills_width() {
let formatted = format_code(999_999, 6);
assert_eq!(formatted.as_str(), "999999");
}
#[test]
fn pads_to_eight_digits_for_eight_digit_otps() {
let formatted = format_code(12_345, 8);
assert_eq!(formatted.as_str(), "00012345");
}
#[test]
fn pads_to_four_digits_for_four_digit_otps() {
let formatted = format_code(7, 4);
assert_eq!(formatted.as_str(), "0007");
}
#[test]
fn output_length_always_matches_digit_count() {
for digits in 4..=9u8 {
let max_in_range = 10u32.pow(u32::from(digits)) - 1;
for code in [0, 7, max_in_range] {
let formatted = format_code(code, digits);
assert_eq!(
formatted.len(),
usize::from(digits),
"code={code}, digits={digits}, formatted={formatted:?}",
);
}
}
}
fn otp_with_period(period: u64) -> OneTimePassword {
let mut otp = OneTimePassword::default();
otp.period = period;
otp
}
fn store_with(entries: &[(&str, OneTimePassword)]) -> PasswordStore {
let mut store = PasswordStore::default();
for (path, otp) in entries {
store.otp.insert((*path).to_owned(), otp.clone());
}
store
}
#[test]
fn insert_writes_a_new_entry_into_an_empty_path() {
let mut store = PasswordStore::default();
let password = otp_with_period(30);
let mutation = insert_one_time_password(&mut store, "github", &password, false).unwrap();
assert!(matches!(mutation, StoreMutation::Modified));
assert_eq!(store.otp.get("github"), Some(&password));
}
#[test]
fn insert_overwrites_existing_entry_when_allowed() {
let mut store = store_with(&[("github", otp_with_period(30))]);
let replacement = otp_with_period(60);
let mutation = insert_one_time_password(&mut store, "github", &replacement, true).unwrap();
assert!(matches!(mutation, StoreMutation::Modified));
assert_eq!(store.otp.get("github"), Some(&replacement));
}
#[test]
fn insert_bails_on_existing_entry_when_overwrite_not_allowed() {
let original = otp_with_period(30);
let mut store = store_with(&[("github", original.clone())]);
let replacement = otp_with_period(60);
let result = insert_one_time_password(&mut store, "github", &replacement, false);
assert!(result.is_err());
assert_eq!(store.otp.get("github"), Some(&original));
}
#[test]
fn remove_drops_the_entry_when_allowed() {
let mut store = store_with(&[("github", otp_with_period(30))]);
let mutation = remove_one_time_password(&mut store, "github", true).unwrap();
assert!(matches!(mutation, StoreMutation::Modified));
assert!(!store.otp.contains_key("github"));
}
#[test]
fn remove_bails_when_user_declined_and_keeps_entry() {
let original = otp_with_period(30);
let mut store = store_with(&[("github", original.clone())]);
let result = remove_one_time_password(&mut store, "github", false);
assert!(result.is_err());
assert_eq!(store.otp.get("github"), Some(&original));
}
#[test]
fn remove_bails_when_entry_does_not_exist() {
i18n::init_for_tests();
let mut store = PasswordStore::default();
let result = remove_one_time_password(&mut store, "missing", true);
assert!(result.is_err());
let message = result.unwrap_err().to_string();
assert!(
message.contains("No one-time password found"),
"expected not-found message, got: {message}"
);
}
#[test]
fn move_relocates_entry_when_destination_is_empty() {
let original = otp_with_period(30);
let mut store = store_with(&[("old", original.clone())]);
let mutation = move_one_time_password(&mut store, "old", "new", false).unwrap();
assert!(matches!(mutation, StoreMutation::Modified));
assert!(!store.otp.contains_key("old"));
assert_eq!(store.otp.get("new"), Some(&original));
}
#[test]
fn move_overwrites_destination_when_allowed() {
let source = otp_with_period(30);
let target = otp_with_period(60);
let mut store = store_with(&[("old", source.clone()), ("new", target)]);
let mutation = move_one_time_password(&mut store, "old", "new", true).unwrap();
assert!(matches!(mutation, StoreMutation::Modified));
assert!(!store.otp.contains_key("old"));
assert_eq!(store.otp.get("new"), Some(&source));
}
#[test]
fn move_bails_when_destination_exists_and_overwrite_not_allowed() {
let source = otp_with_period(30);
let target = otp_with_period(60);
let mut store = store_with(&[("old", source.clone()), ("new", target.clone())]);
let result = move_one_time_password(&mut store, "old", "new", false);
assert!(result.is_err());
assert_eq!(store.otp.get("old"), Some(&source));
assert_eq!(store.otp.get("new"), Some(&target));
}
#[test]
fn move_bails_when_source_is_missing() {
i18n::init_for_tests();
let mut store = PasswordStore::default();
let result = move_one_time_password(&mut store, "missing", "new", true);
assert!(result.is_err());
let message = result.unwrap_err().to_string();
assert!(
message.contains("No one-time password found"),
"expected not-found message, got: {message}"
);
}
#[test]
fn move_to_same_path_is_a_noop_when_overwrite_allowed() {
let original = otp_with_period(30);
let mut store = store_with(&[("github", original.clone())]);
let mutation = move_one_time_password(&mut store, "github", "github", true).unwrap();
assert!(matches!(mutation, StoreMutation::Modified));
assert_eq!(store.otp.get("github"), Some(&original));
}
#[test]
fn copy_duplicates_entry_to_new_path() {
let original = otp_with_period(30);
let mut store = store_with(&[("source", original.clone())]);
let mutation = copy_one_time_password(&mut store, "source", "target", false).unwrap();
assert!(matches!(mutation, StoreMutation::Modified));
assert_eq!(store.otp.get("source"), Some(&original));
assert_eq!(store.otp.get("target"), Some(&original));
}
#[test]
fn copy_overwrites_destination_when_allowed() {
let source = otp_with_period(30);
let target = otp_with_period(60);
let mut store = store_with(&[("source", source.clone()), ("target", target)]);
let mutation = copy_one_time_password(&mut store, "source", "target", true).unwrap();
assert!(matches!(mutation, StoreMutation::Modified));
assert_eq!(store.otp.get("source"), Some(&source));
assert_eq!(store.otp.get("target"), Some(&source));
}
#[test]
fn copy_bails_when_destination_exists_and_overwrite_not_allowed() {
let source = otp_with_period(30);
let target = otp_with_period(60);
let mut store = store_with(&[("source", source.clone()), ("target", target.clone())]);
let result = copy_one_time_password(&mut store, "source", "target", false);
assert!(result.is_err());
assert_eq!(store.otp.get("source"), Some(&source));
assert_eq!(store.otp.get("target"), Some(&target));
}
#[test]
fn copy_bails_when_source_is_missing() {
i18n::init_for_tests();
let mut store = PasswordStore::default();
let result = copy_one_time_password(&mut store, "missing", "target", true);
assert!(result.is_err());
let message = result.unwrap_err().to_string();
assert!(
message.contains("No one-time password found"),
"expected not-found message, got: {message}"
);
}
}