use std::collections::HashMap;
use tsafe_core::{
audit::{AuditClipboardContext, AuditContext, AuditEntry, AuditLog},
errors::SafeError,
namespace_bulk::{apply_namespace_copy, apply_namespace_move, plan_namespace_bulk},
profile::{audit_log_path, list_profiles, validate_profile_name, vault_path},
snapshot,
vault::Vault,
};
#[cfg(test)]
use crate::app::empty_sensitive;
use crate::app::{
emit_delete_audit, emit_reveal_audit, sensitive_string, App, EditFocus, LoginFocus,
PendingUndo, Reveal, Screen, SensitiveString, CLIPBOARD_TTL, REVEAL_TTL, UNDO_TTL,
};
use std::time::Instant;
#[derive(Debug, Clone, PartialEq)]
pub enum AppInput {
Char(char),
Backspace,
Enter,
Esc,
Tab,
Up,
Down,
CtrlC,
}
pub fn dispatch_input(
app: &mut App,
input: AppInput,
password_store: &mut Option<SensitiveString>,
) -> bool {
if input == AppInput::CtrlC {
return true;
}
if app.help_visible {
match &input {
AppInput::Esc | AppInput::Char('?') => app.help_visible = false,
_ => {}
}
return false;
}
let question_goes_to_field = matches!(
app.screen,
Screen::EditSecret { .. } | Screen::NewProfile { .. } | Screen::RotatePassword
) || (app.screen == Screen::Login
&& app.login_focus == LoginFocus::Password)
|| (app.screen == Screen::Dashboard && app.search_mode);
if input == AppInput::Char('?') && !question_goes_to_field {
app.help_visible = true;
return false;
}
match app.screen.clone() {
Screen::Login => dispatch_login(app, input),
Screen::Dashboard => dispatch_dashboard(app, input, password_store),
Screen::EditSecret { is_new } => dispatch_edit(app, input, is_new, password_store),
Screen::ConfirmDelete => dispatch_confirm_delete(app, input, password_store),
Screen::RotatePassword => dispatch_rotate(app, input, password_store),
Screen::SnapshotRestore => dispatch_snapshot_restore(app, input, password_store),
Screen::AuditLog => dispatch_audit_log(app, input),
Screen::NewProfile { step } => dispatch_new_profile(app, input, step, password_store),
Screen::History { .. } => dispatch_history(app, input),
Screen::MoveSecret { source } => {
dispatch_move_secret(app, input, &source.clone(), password_store)
}
Screen::NsBulk { copy, from } => {
dispatch_ns_bulk(app, input, copy, &from.clone(), password_store)
}
Screen::Quitting => true,
}
}
fn dispatch_login(app: &mut App, input: AppInput) -> bool {
match input {
AppInput::Char(c) if app.login_focus == LoginFocus::Password => {
app.password_buf.push(c);
app.login_error = None;
}
AppInput::Char('q')
if app.password_buf.is_empty() && app.login_focus == LoginFocus::Profile =>
{
return true;
}
AppInput::Char('n')
if app.password_buf.is_empty() && app.login_focus == LoginFocus::Profile =>
{
app.new_profile_name.clear();
let _ = std::mem::take(&mut app.new_profile_pw1);
let _ = std::mem::take(&mut app.new_profile_pw2);
app.new_profile_error = None;
app.screen = Screen::NewProfile { step: 0 };
}
AppInput::Tab => {
if app.profiles.is_empty() {
return false;
}
app.login_focus = match app.login_focus {
LoginFocus::Profile => LoginFocus::Password,
LoginFocus::Password => LoginFocus::Profile,
};
app.login_error = None;
}
AppInput::Down if app.login_focus == LoginFocus::Profile => {
if !app.profiles.is_empty() {
app.profile_cursor = (app.profile_cursor + 1) % app.profiles.len();
app.login_error = None;
let _ = std::mem::take(&mut app.password_buf);
}
}
AppInput::Up if app.login_focus == LoginFocus::Profile => {
if !app.profiles.is_empty() {
app.profile_cursor =
(app.profile_cursor + app.profiles.len() - 1) % app.profiles.len();
app.login_error = None;
let _ = std::mem::take(&mut app.password_buf);
}
}
AppInput::Char(c) => {
app.password_buf.push(c);
app.login_error = None;
app.login_focus = LoginFocus::Password;
}
AppInput::Backspace => {
app.password_buf.pop();
}
AppInput::Enter => {
app.try_login();
}
AppInput::Esc => {
let _ = std::mem::take(&mut app.password_buf);
app.login_error = None;
}
_ => {}
}
false
}
fn dispatch_dashboard(
app: &mut App,
input: AppInput,
password_store: &mut Option<SensitiveString>,
) -> bool {
if app.search_mode {
return dispatch_dashboard_search(app, input);
}
match input {
AppInput::Char('q') => return true,
AppInput::Down | AppInput::Char('j') => {
let len = app.visible_entries().len();
if len > 0 {
app.secret_cursor = (app.secret_cursor + 1) % len;
}
app.clear_status();
}
AppInput::Up | AppInput::Char('k') => {
let len = app.visible_entries().len();
if len > 0 {
app.secret_cursor = (app.secret_cursor + len - 1) % len;
}
app.clear_status();
}
AppInput::Char(' ') => {
let entries = app.visible_entries();
if let Some(crate::app::ListEntry::Namespace { name, .. }) =
entries.get(app.secret_cursor)
{
let ns = name.clone();
if app.collapsed_namespaces.contains(&ns) {
app.collapsed_namespaces.remove(&ns);
} else {
app.collapsed_namespaces.insert(ns);
}
}
}
AppInput::Char('p') => {
*password_store = None;
let _ = std::mem::take(&mut app.password_buf);
app.clear_active_session();
app.login_error = None;
app.login_focus = if app.profiles.is_empty() {
LoginFocus::Profile
} else {
LoginFocus::Password
};
app.screen = Screen::Login;
}
AppInput::Char('/') => {
app.search_mode = true;
app.search_query.clear();
}
AppInput::Char('r') => {
if let Some(key_name) = app.selected_key() {
if app.revealed.contains_key(&key_name) {
app.revealed.remove(&key_name);
app.set_status("Hidden.");
} else if let Some(vault) = app.session_vault.as_ref() {
match vault.get(&key_name) {
Ok(value) => {
app.revealed.insert(
key_name.clone(),
Reveal {
value: sensitive_string((*value).clone()),
revealed_at: std::time::Instant::now(),
},
);
emit_reveal_audit(
app.active_profile.as_deref(),
"secret.reveal_started",
Some(&key_name),
);
app.set_status(format!(
"Revealed '{key_name}' (auto-conceals in {}s)",
REVEAL_TTL.as_secs()
));
}
Err(e) => app.set_status(format!("Error: {e}")),
}
} else {
app.set_status("Error: active vault session missing. Log in again.");
}
}
}
AppInput::Char('n') => {
app.screen = Screen::EditSecret { is_new: true };
app.edit_key.clear();
app.edit_value.clear();
app.edit_error = None;
app.edit_focus = EditFocus::Key;
}
AppInput::Char('e') | AppInput::Enter => {
if let Some(key_name) = app.selected_key() {
if let Some(vault) = app.session_vault.as_ref() {
let value = vault
.get(&key_name)
.map(|v| (*v).clone())
.unwrap_or_default();
app.edit_key = key_name;
app.edit_value = value;
app.edit_error = None;
app.edit_focus = EditFocus::Value;
app.screen = Screen::EditSecret { is_new: false };
} else {
app.set_status("Error: active vault session missing. Log in again.");
}
}
}
AppInput::Char('d') => {
if app.selected_key().is_some() {
app.screen = Screen::ConfirmDelete;
}
}
AppInput::Char('u') => {
let Some(undo) = app.pending_undo.take() else {
return false;
};
app.last_undo_status_secs = None;
let Some(mut vault) = app.session_vault.take() else {
app.set_status("✗ Active vault session missing. Log in again.");
return false;
};
let profile = app.active_profile.clone();
let key = undo.key.clone();
let value = sensitive_string((*undo.value).clone());
let tags = undo.tags.clone();
match vault.set(&key, &value, tags) {
Ok(()) => {
app.reload_from_vault(&vault);
app.session_vault = Some(vault);
emit_delete_audit(profile.as_deref(), "secret.delete_undone", Some(&key), None);
app.set_status(format!("✓ Restored '{key}'"));
}
Err(e) => {
app.session_vault = Some(vault);
let msg = e.to_string();
emit_delete_audit(
profile.as_deref(),
"secret.delete_undo_failed",
Some(&key),
Some(msg.clone()),
);
app.set_status(format!("✗ Undo failed: {msg}"));
}
}
}
AppInput::Char('y') => {
if let Some(key_name) = app.selected_key() {
if let Some(vault) = app.session_vault.as_ref() {
match vault.get(&key_name) {
Ok(boxed) => {
let value = sensitive_string((*boxed).clone());
let copy_result = app.copy_to_clipboard(&key_name, value);
if let Some(profile) = app.active_profile.clone() {
let reason = copy_result.as_ref().err().map(|e| e.to_string());
let excluded_from_history = match ©_result {
Ok(outcome) => outcome.excluded_from_history,
Err(_) => Some(false),
};
let entry = match ©_result {
Ok(_) => AuditEntry::success(
&profile,
"clipboard.copy",
Some(&key_name),
),
Err(err) => AuditEntry::failure(
&profile,
"clipboard.copy_failed",
Some(&key_name),
&err.to_string(),
),
}
.with_context(
AuditContext::from_clipboard(AuditClipboardContext {
ttl_secs: CLIPBOARD_TTL.as_secs(),
reason,
excluded_from_history,
cleared_verified: None,
}),
);
AuditLog::new(&audit_log_path(&profile)).append(&entry).ok();
}
if let Err(err) = copy_result {
app.set_status(err.to_string());
}
}
Err(e) => app.set_status(format!("Error: {e}")),
}
} else {
app.set_status("Error: active vault session missing. Log in again.");
}
}
}
AppInput::Char('R') => {
if app.session_vault.is_some() && app.active_profile.is_some() {
app.rotate_step = 0;
let _ = std::mem::take(&mut app.rotate_new1);
let _ = std::mem::take(&mut app.rotate_new2);
app.rotate_error = None;
app.screen = Screen::RotatePassword;
}
}
AppInput::Char('S') | AppInput::Char('s') => {
if let Some(profile) = app.active_profile.clone() {
match snapshot::list(&profile) {
Ok(paths) if !paths.is_empty() => {
app.snapshot_cursor = paths.len().saturating_sub(1);
app.snapshot_paths = paths;
app.screen = Screen::SnapshotRestore;
}
Ok(_) => app.set_status("No snapshots available for this profile."),
Err(e) => app.set_status(format!("Snapshot error: {e}")),
}
}
}
AppInput::Char('a') => {
if let Some(profile) = app.active_profile.clone() {
let log = AuditLog::new(&audit_log_path(&profile));
let entries = log.read(Some(200)).unwrap_or_default();
app.audit_lines = entries
.iter()
.map(|e| {
format!(
"[{}] {:8} {:?} {} {}",
e.timestamp.format("%Y-%m-%d %H:%M:%S"),
e.operation,
e.status,
e.key.as_deref().unwrap_or("-"),
e.message.as_deref().unwrap_or(""),
)
})
.collect();
app.audit_scroll = 0;
app.screen = Screen::AuditLog;
}
}
AppInput::Char('c') => {
if let Some(ns) = app.selected_namespace() {
app.mv_dest_buf.clear();
app.mv_error = None;
app.screen = Screen::NsBulk {
copy: true,
from: ns,
};
}
}
AppInput::Char('m') => {
if let Some(ns) = app.selected_namespace() {
app.mv_dest_buf.clear();
app.mv_error = None;
app.screen = Screen::NsBulk {
copy: false,
from: ns,
};
} else if let Some(key_name) = app.selected_key() {
app.mv_dest_buf = key_name.clone();
app.mv_error = None;
app.screen = Screen::MoveSecret { source: key_name };
}
}
AppInput::Char('h') => {
if let Some(key_name) = app.selected_key() {
if let Some(vault) = app.session_vault.as_ref() {
match vault.history(&key_name) {
Ok(entries) => {
app.history_entries = entries;
app.history_scroll = 0;
app.screen = Screen::History { key: key_name };
}
Err(e) => app.set_status(format!("History error: {e}")),
}
} else {
app.set_status("Error: active vault session missing. Log in again.");
}
}
}
AppInput::Char('T') => {
app.theme = app.theme.cycle();
app.save_theme();
app.set_status(format!("Theme: {}", app.theme.as_str()));
}
_ => {}
}
false
}
fn dispatch_dashboard_search(app: &mut App, input: AppInput) -> bool {
match input {
AppInput::Esc | AppInput::Enter => {
app.search_mode = false;
app.secret_cursor = 0;
}
AppInput::Char(c) => {
app.search_query.push(c);
app.secret_cursor = 0;
}
AppInput::Backspace => {
app.search_query.pop();
app.secret_cursor = 0;
}
_ => {}
}
false
}
fn dispatch_edit(
app: &mut App,
input: AppInput,
is_new: bool,
_password_store: &mut Option<SensitiveString>,
) -> bool {
match input {
AppInput::Esc => {
use zeroize::Zeroize;
app.edit_value.zeroize();
app.edit_value.clear();
app.screen = Screen::Dashboard;
app.edit_error = None;
}
AppInput::Tab => {
app.edit_focus = match app.edit_focus {
EditFocus::Key => EditFocus::Value,
EditFocus::Value => EditFocus::Key,
};
}
AppInput::Char(c) => match app.edit_focus {
EditFocus::Key => app.edit_key.push(c),
EditFocus::Value => app.edit_value.push(c),
},
AppInput::Backspace => match app.edit_focus {
EditFocus::Key => {
app.edit_key.pop();
}
EditFocus::Value => {
app.edit_value.pop();
}
},
AppInput::Enter => {
let key_name = app.edit_key.trim().to_string();
if key_name.is_empty() {
app.edit_error = Some("KEY must not be empty.".into());
return false;
}
if let (Some(profile), Some(mut vault)) =
(app.active_profile.clone(), app.session_vault.take())
{
if is_new && vault.list().contains(&key_name.as_str()) {
app.edit_error = Some(format!(
"'{key_name}' already exists — rename the key or delete it first."
));
app.session_vault = Some(vault);
return false;
}
match vault.set(&key_name, &app.edit_value, HashMap::new()) {
Ok(()) => {
let op = if is_new { "set" } else { "update" };
AuditLog::new(&audit_log_path(&profile))
.append(&AuditEntry::success(&profile, op, Some(&key_name)))
.ok();
app.finalize_pending_undo_for_key(&key_name);
app.reload_from_vault(&vault);
app.session_vault = Some(vault);
app.revealed.remove(&key_name);
use zeroize::Zeroize;
app.edit_value.zeroize();
app.edit_value.clear();
app.set_status(format!("✓ Saved '{key_name}'"));
app.screen = Screen::Dashboard;
}
Err(e) => {
app.edit_error = Some(format!("Save failed: {e}"));
app.session_vault = Some(vault);
}
}
} else {
app.edit_error = Some("No active vault session. Log in again.".into());
}
}
_ => {}
}
false
}
fn dispatch_confirm_delete(
app: &mut App,
input: AppInput,
_password_store: &mut Option<SensitiveString>,
) -> bool {
match input {
AppInput::Char('y') | AppInput::Char('Y') | AppInput::Enter => {
if let (Some(key_name), Some(profile), Some(mut vault)) = (
app.selected_key(),
app.active_profile.clone(),
app.session_vault.take(),
) {
app.finalize_pending_undo_for_key(&key_name);
let snapshot = vault.get(&key_name).ok().map(|v| {
let tags = vault
.file()
.secrets
.get(&key_name)
.map(|e| e.tags.clone())
.unwrap_or_default();
(sensitive_string((*v).clone()), tags)
});
match vault.delete(&key_name) {
Ok(()) => {
AuditLog::new(&audit_log_path(&profile))
.append(&AuditEntry::success(&profile, "delete", Some(&key_name)))
.ok();
app.revealed.remove(&key_name);
app.reload_from_vault(&vault);
app.session_vault = Some(vault);
if let Some((value, tags)) = snapshot {
app.pending_undo = Some(PendingUndo {
key: key_name.clone(),
value,
tags,
deleted_at: Instant::now(),
});
app.set_status(format!(
"Deleted '{key_name}' — press u to undo ({}s)",
UNDO_TTL.as_secs()
));
} else {
app.set_status(format!("✓ Deleted '{key_name}'"));
}
app.screen = Screen::Dashboard;
}
Err(e) => {
app.session_vault = Some(vault);
app.set_status(format!("✗ Delete error: {e}"));
app.screen = Screen::Dashboard;
}
}
} else {
app.set_status("✗ Active vault session missing. Log in again.");
app.screen = Screen::Dashboard;
}
}
AppInput::Char('n') | AppInput::Char('N') | AppInput::Esc => {
app.screen = Screen::Dashboard;
}
_ => {}
}
false
}
fn dispatch_audit_log(app: &mut App, input: AppInput) -> bool {
match input {
AppInput::Char('q') | AppInput::Esc => {
app.screen = Screen::Dashboard;
}
AppInput::Down | AppInput::Char('j') => {
if app.audit_scroll + 1 < app.audit_lines.len() {
app.audit_scroll += 1;
}
}
AppInput::Up | AppInput::Char('k') => {
app.audit_scroll = app.audit_scroll.saturating_sub(1);
}
_ => {}
}
false
}
fn dispatch_rotate(
app: &mut App,
input: AppInput,
password_store: &mut Option<SensitiveString>,
) -> bool {
match input {
AppInput::Esc => {
app.screen = Screen::Dashboard;
app.rotate_error = None;
}
AppInput::Backspace => match app.rotate_step {
0 => {
app.rotate_new1.pop();
}
_ => {
app.rotate_new2.pop();
}
},
AppInput::Char(c) => match app.rotate_step {
0 => app.rotate_new1.push(c),
_ => app.rotate_new2.push(c),
},
AppInput::Enter => {
if app.rotate_step == 0 {
if app.rotate_new1.is_empty() {
app.rotate_error = Some("Password must not be empty.".into());
} else {
app.rotate_step = 1;
app.rotate_error = None;
}
} else if app.rotate_new1 != app.rotate_new2 {
app.rotate_error = Some("Passwords do not match.".into());
app.rotate_step = 0;
let _ = std::mem::take(&mut app.rotate_new1);
let _ = std::mem::take(&mut app.rotate_new2);
} else if let (Some(profile), Some(mut vault)) =
(app.active_profile.clone(), app.session_vault.take())
{
let new_pw = std::mem::take(&mut app.rotate_new1);
let _ = std::mem::take(&mut app.rotate_new2);
match vault.rotate(new_pw.as_bytes()) {
Ok(()) => {
AuditLog::new(&audit_log_path(&profile))
.append(&AuditEntry::success(&profile, "rotate", None))
.ok();
*password_store = Some(new_pw.clone());
app.reload_from_vault(&vault);
app.session_vault = Some(vault);
app.rotate_error = None;
let keyring_note =
match tsafe_core::keyring_store::store_password(&profile, &new_pw) {
Ok(()) => " OS quick unlock updated.",
Err(_) => {
" Run `tsafe biometric enable` to refresh OS quick unlock."
}
};
app.set_status(format!("✓ Password rotated successfully.{keyring_note}"));
app.screen = Screen::Dashboard;
}
Err(e) => {
app.rotate_error = Some(format!("Rotate failed: {e}"));
app.session_vault = Some(vault);
}
}
} else {
app.rotate_error = Some("No active vault session. Log in again.".into());
}
}
_ => {}
}
false
}
fn dispatch_snapshot_restore(
app: &mut App,
input: AppInput,
password_store: &mut Option<SensitiveString>,
) -> bool {
match input {
AppInput::Esc | AppInput::Char('q') => {
app.snapshot_paths.clear();
app.screen = Screen::Dashboard;
}
AppInput::Down | AppInput::Char('j') => {
let max = app.snapshot_paths.len().saturating_sub(1);
if app.snapshot_cursor < max {
app.snapshot_cursor += 1;
}
}
AppInput::Up | AppInput::Char('k') => {
app.snapshot_cursor = app.snapshot_cursor.saturating_sub(1);
}
AppInput::Enter | AppInput::Char('y') => {
let snap = app.snapshot_paths.get(app.snapshot_cursor).cloned();
if let (Some(snap_path), Some(profile), Some(password)) =
(snap, app.active_profile.clone(), password_store.as_ref())
{
let vault_file = vault_path(&profile);
let _ = app.session_vault.take();
app.pending_undo = None;
app.last_undo_status_secs = None;
match snapshot::restore(&vault_file, &snap_path) {
Ok(_) => match Vault::open(&vault_file, password.as_bytes()) {
Ok(vault) => {
AuditLog::new(&audit_log_path(&profile))
.append(&AuditEntry::success(
&profile,
"snapshot-restore",
Some(&snap_path.display().to_string()),
))
.ok();
app.reload_from_vault(&vault);
app.session_vault = Some(vault);
app.set_status("✓ Vault restored from snapshot.");
}
Err(e) => {
app.set_status(format!("✗ Reload after restore failed: {e}"));
}
},
Err(e) => {
if let Ok(vault) = Vault::open(&vault_file, password.as_bytes()) {
app.reload_from_vault(&vault);
app.session_vault = Some(vault);
}
app.set_status(format!("✗ Restore error: {e}"));
}
}
if app.session_vault.is_none() {
app.clear_active_session();
*password_store = None;
app.login_error =
Some("Vault session dropped after snapshot restore. Log in again.".into());
app.login_focus = if app.profiles.is_empty() {
LoginFocus::Profile
} else {
LoginFocus::Password
};
app.screen = Screen::Login;
} else {
app.snapshot_paths.clear();
app.screen = Screen::Dashboard;
}
}
}
_ => {}
}
false
}
fn dispatch_new_profile(
app: &mut App,
input: AppInput,
step: u8,
password_store: &mut Option<SensitiveString>,
) -> bool {
match step {
0 => match input {
AppInput::Esc => {
app.screen = Screen::Login;
app.new_profile_error = None;
app.login_focus = if app.profiles.is_empty() {
LoginFocus::Profile
} else {
LoginFocus::Password
};
}
AppInput::Char(c) => {
app.new_profile_name.push(c);
app.new_profile_error = None;
}
AppInput::Backspace => {
app.new_profile_name.pop();
}
AppInput::Enter => {
let name = app.new_profile_name.trim().to_string();
if name.is_empty() {
app.new_profile_error = Some("Profile name must not be empty.".into());
} else if let Err(e) = validate_profile_name(&name) {
app.new_profile_error = Some(format!("{e}"));
} else if app.profiles.iter().any(|p| p.eq_ignore_ascii_case(&name)) {
app.new_profile_error = Some(format!("Profile '{name}' already exists."));
} else {
app.new_profile_name = name;
app.new_profile_error = None;
app.screen = Screen::NewProfile { step: 1 };
}
}
_ => {}
},
1 => match input {
AppInput::Esc => {
let _ = std::mem::take(&mut app.new_profile_pw1);
app.new_profile_error = None;
app.screen = Screen::NewProfile { step: 0 };
}
AppInput::Char(c) => {
app.new_profile_pw1.push(c);
app.new_profile_error = None;
}
AppInput::Backspace => {
app.new_profile_pw1.pop();
}
AppInput::Enter => {
if app.new_profile_pw1.is_empty() {
app.new_profile_error = Some("Password must not be empty.".into());
} else {
app.new_profile_error = None;
app.screen = Screen::NewProfile { step: 2 };
}
}
_ => {}
},
_ => match input {
AppInput::Esc => {
let _ = std::mem::take(&mut app.new_profile_pw2);
app.new_profile_error = None;
app.screen = Screen::NewProfile { step: 1 };
}
AppInput::Char(c) => {
app.new_profile_pw2.push(c);
app.new_profile_error = None;
}
AppInput::Backspace => {
app.new_profile_pw2.pop();
}
AppInput::Enter => {
if app.new_profile_pw1 != app.new_profile_pw2 {
app.new_profile_error = Some("Passwords do not match — try again.".into());
let _ = std::mem::take(&mut app.new_profile_pw1);
let _ = std::mem::take(&mut app.new_profile_pw2);
app.screen = Screen::NewProfile { step: 1 };
} else {
let name = app.new_profile_name.clone();
let pw = std::mem::take(&mut app.new_profile_pw1);
let _ = std::mem::take(&mut app.new_profile_pw2);
match Vault::create(&vault_path(&name), pw.as_bytes()) {
Ok(vault) => {
AuditLog::new(&audit_log_path(&name))
.append(&AuditEntry::success(&name, "create", None))
.ok();
match list_profiles() {
Ok(profiles) => app.profiles = profiles,
Err(_) => {
if !app.profiles.iter().any(|p| p == &name) {
app.profiles.push(name.clone());
}
}
}
app.activate_session(name.clone(), vault);
*password_store = Some(pw);
app.set_status(format!("✓ Profile '{name}' created."));
}
Err(e) => {
app.new_profile_error = Some(format!("Create failed: {e}"));
let _ = std::mem::take(&mut app.new_profile_pw2);
}
}
}
}
_ => {}
},
}
false
}
fn dispatch_history(app: &mut App, input: AppInput) -> bool {
match input {
AppInput::Char('q') | AppInput::Esc => {
app.history_entries.clear();
app.screen = Screen::Dashboard;
}
AppInput::Down | AppInput::Char('j') => {
if app.history_scroll + 1 < app.history_entries.len() {
app.history_scroll += 1;
}
}
AppInput::Up | AppInput::Char('k') => {
app.history_scroll = app.history_scroll.saturating_sub(1);
}
_ => {}
}
false
}
fn dispatch_move_secret(
app: &mut App,
input: AppInput,
source: &str,
_password_store: &mut Option<SensitiveString>,
) -> bool {
match input {
AppInput::Esc => {
app.mv_dest_buf.clear();
app.mv_error = None;
app.screen = Screen::Dashboard;
}
AppInput::Char(c) => {
app.mv_dest_buf.push(c);
app.mv_error = None;
}
AppInput::Backspace => {
app.mv_dest_buf.pop();
app.mv_error = None;
}
AppInput::Enter => {
let dest = app.mv_dest_buf.trim().to_string();
if dest.is_empty() {
app.mv_error = Some("Destination key must not be empty.".into());
return false;
}
if dest == source {
app.mv_dest_buf.clear();
app.mv_error = None;
app.screen = Screen::Dashboard;
return false;
}
if let (Some(profile), Some(mut vault)) =
(app.active_profile.clone(), app.session_vault.take())
{
match vault.rename_key(source, &dest, false) {
Ok(()) => {
AuditLog::new(&audit_log_path(&profile))
.append(&AuditEntry::success(
&profile,
"mv",
Some(&format!("{source} → {dest}")),
))
.ok();
app.revealed.remove(source);
app.finalize_pending_undo_for_key(source);
app.finalize_pending_undo_for_key(&dest);
app.reload_from_vault(&vault);
app.session_vault = Some(vault);
app.mv_dest_buf.clear();
app.mv_error = None;
app.set_status(format!("✓ Renamed '{source}' → '{dest}'"));
app.screen = Screen::Dashboard;
}
Err(SafeError::SecretAlreadyExists { .. }) => {
app.mv_error = Some(format!(
"'{dest}' already exists — delete it first or use a different name."
));
app.session_vault = Some(vault);
}
Err(e) => {
app.mv_error = Some(format!("Move failed: {e}"));
app.session_vault = Some(vault);
}
}
} else {
app.mv_error = Some("No active vault session. Log in again.".into());
}
}
_ => {}
}
false
}
fn dispatch_ns_bulk(
app: &mut App,
input: AppInput,
copy: bool,
from_ns: &str,
_password_store: &mut Option<SensitiveString>,
) -> bool {
match input {
AppInput::Esc => {
app.mv_dest_buf.clear();
app.mv_error = None;
app.screen = Screen::Dashboard;
}
AppInput::Char(c) => {
app.mv_dest_buf.push(c);
app.mv_error = None;
}
AppInput::Backspace => {
app.mv_dest_buf.pop();
app.mv_error = None;
}
AppInput::Enter => {
let to = app.mv_dest_buf.trim().to_string();
if to.is_empty() {
app.mv_error = Some("Destination namespace must not be empty.".into());
return false;
}
if to == from_ns {
app.mv_error = Some("Source and destination are the same.".into());
return false;
}
if let (Some(profile), Some(mut vault)) =
(app.active_profile.clone(), app.session_vault.take())
{
match plan_namespace_bulk(&vault, from_ns, &to, false) {
Ok(pairs) if pairs.is_empty() => {
app.mv_error = Some(format!("No keys under namespace '{from_ns}'."));
app.session_vault = Some(vault);
}
Ok(pairs) => {
let res = if copy {
apply_namespace_copy(&mut vault, &pairs)
} else {
apply_namespace_move(&mut vault, &pairs, false)
};
match res {
Ok(()) => {
let op = if copy { "ns-copy" } else { "ns-move" };
let detail = format!("{from_ns}->{} ({} keys)", to, pairs.len());
let _ = AuditLog::new(&audit_log_path(&profile))
.append(&AuditEntry::success(&profile, op, Some(&detail)));
for (src, dst) in &pairs {
app.finalize_pending_undo_for_key(src);
app.finalize_pending_undo_for_key(dst);
}
app.reload_from_vault(&vault);
app.session_vault = Some(vault);
app.mv_dest_buf.clear();
app.mv_error = None;
let msg = if copy {
format!(
"✓ Copied {} key(s) {}/ → {}/",
pairs.len(),
from_ns,
to
)
} else {
format!("✓ Moved {} key(s) {}/ → {}/", pairs.len(), from_ns, to)
};
app.set_status(msg);
app.screen = Screen::Dashboard;
}
Err(e) => {
app.mv_error = Some(e.to_string());
app.session_vault = Some(vault);
}
}
}
Err(e) => {
app.mv_error = Some(e.to_string());
app.session_vault = Some(vault);
}
}
} else {
app.mv_error = Some("No active vault session. Log in again.".into());
}
}
_ => {}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::sensitive_string;
use crate::app::Theme;
use tempfile::tempdir;
fn make_app() -> App {
App {
screen: Screen::Dashboard,
profiles: vec!["test".into()],
profile_cursor: 0,
active_profile: Some("test".into()),
session_vault: None,
secret_keys: vec!["DB_URL".into(), "API_KEY".into()],
secret_cursor: 0,
search_mode: false,
search_query: String::new(),
password_buf: empty_sensitive(),
login_error: None,
login_session_password: None,
pending_keyring_master_password: None,
login_focus: LoginFocus::Password,
edit_key: String::new(),
edit_value: String::new(),
edit_focus: EditFocus::Key,
edit_error: None,
revealed: std::collections::HashMap::new(),
rotate_step: 0,
rotate_new1: empty_sensitive(),
rotate_new2: empty_sensitive(),
rotate_error: None,
snapshot_paths: Vec::new(),
snapshot_cursor: 0,
audit_lines: Vec::new(),
audit_scroll: 0,
new_profile_name: String::new(),
new_profile_pw1: empty_sensitive(),
new_profile_pw2: empty_sensitive(),
new_profile_error: None,
status_message: None,
help_visible: false,
pinned_keys: std::collections::HashSet::new(),
collapsed_namespaces: std::collections::HashSet::new(),
update_available: None,
update_rx: None,
theme: Theme::Dark,
history_entries: Vec::new(),
history_scroll: 0,
mv_dest_buf: String::new(),
mv_error: None,
clipboard: Box::new(crate::clipboard::SystemClipboard::new()),
copied_at: None,
copied_value: None,
pending_undo: None,
last_undo_status_secs: None,
}
}
fn make_login_app() -> App {
App {
screen: Screen::Login,
profiles: vec!["default".into()],
profile_cursor: 0,
active_profile: None,
session_vault: None,
secret_keys: Vec::new(),
secret_cursor: 0,
search_mode: false,
search_query: String::new(),
password_buf: empty_sensitive(),
login_error: None,
login_session_password: None,
pending_keyring_master_password: None,
login_focus: LoginFocus::Password,
edit_key: String::new(),
edit_value: String::new(),
edit_focus: EditFocus::Key,
edit_error: None,
revealed: std::collections::HashMap::new(),
rotate_step: 0,
rotate_new1: empty_sensitive(),
rotate_new2: empty_sensitive(),
rotate_error: None,
snapshot_paths: Vec::new(),
snapshot_cursor: 0,
audit_lines: Vec::new(),
audit_scroll: 0,
new_profile_name: String::new(),
new_profile_pw1: empty_sensitive(),
new_profile_pw2: empty_sensitive(),
new_profile_error: None,
status_message: None,
help_visible: false,
pinned_keys: std::collections::HashSet::new(),
collapsed_namespaces: std::collections::HashSet::new(),
update_available: None,
update_rx: None,
theme: Theme::Dark,
history_entries: Vec::new(),
history_scroll: 0,
mv_dest_buf: String::new(),
mv_error: None,
clipboard: Box::new(crate::clipboard::SystemClipboard::new()),
copied_at: None,
copied_value: None,
pending_undo: None,
last_undo_status_secs: None,
}
}
#[test]
fn login_password_field_accepts_n_as_first_character() {
let mut app = make_login_app();
let mut pw: Option<SensitiveString> = None;
assert_eq!(app.login_focus, LoginFocus::Password);
dispatch_input(&mut app, AppInput::Char('n'), &mut pw);
assert_eq!(app.password_buf.as_str(), "n");
assert_eq!(app.screen, Screen::Login);
}
#[test]
fn login_password_field_accepts_question_mark() {
let mut app = make_login_app();
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Char('?'), &mut pw);
assert_eq!(app.password_buf.as_str(), "?");
assert!(!app.help_visible);
}
#[test]
fn login_profile_row_n_opens_new_profile_wizard() {
let mut app = make_login_app();
app.login_focus = LoginFocus::Profile;
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Char('n'), &mut pw);
assert!(matches!(app.screen, Screen::NewProfile { step: 0 }));
}
#[test]
fn login_profile_row_question_opens_help() {
let mut app = make_login_app();
app.login_focus = LoginFocus::Profile;
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Char('?'), &mut pw);
assert!(app.help_visible);
}
#[test]
fn login_password_field_q_is_typed_not_quit() {
let mut app = make_login_app();
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Char('q'), &mut pw);
assert_eq!(app.password_buf.as_str(), "q");
}
#[test]
fn ctrl_c_always_quits() {
let mut app = make_app();
let mut pw: Option<SensitiveString> = None;
assert!(dispatch_input(&mut app, AppInput::CtrlC, &mut pw));
}
#[test]
fn q_quits_on_dashboard() {
let mut app = make_app();
let mut pw: Option<SensitiveString> = None;
assert!(dispatch_input(&mut app, AppInput::Char('q'), &mut pw));
}
#[test]
fn navigate_dashboard_down_wraps() {
let mut app = make_app();
let mut pw: Option<SensitiveString> = None;
let len = app.visible_entries().len();
dispatch_input(&mut app, AppInput::Up, &mut pw);
assert_eq!(app.secret_cursor, len - 1);
}
#[test]
fn search_mode_entered_on_slash() {
let mut app = make_app();
let mut pw: Option<SensitiveString> = None;
assert!(!app.search_mode);
dispatch_input(&mut app, AppInput::Char('/'), &mut pw);
assert!(app.search_mode);
}
#[test]
fn help_overlay_toggled_by_question_mark() {
let mut app = make_app();
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Char('?'), &mut pw);
assert!(app.help_visible);
dispatch_input(&mut app, AppInput::Char('?'), &mut pw);
assert!(!app.help_visible);
}
#[test]
fn theme_cycles_on_capital_t() {
let mut app = make_app();
let mut pw: Option<SensitiveString> = None;
assert_eq!(app.theme, Theme::Dark);
dispatch_input(&mut app, AppInput::Char('T'), &mut pw);
assert_eq!(app.theme, Theme::Light);
dispatch_input(&mut app, AppInput::Char('T'), &mut pw);
assert_eq!(app.theme, Theme::HighContrast);
dispatch_input(&mut app, AppInput::Char('T'), &mut pw);
assert_eq!(app.theme, Theme::Dark);
}
#[test]
fn audit_log_navigation_and_back() {
let mut app = make_app();
app.screen = Screen::AuditLog;
app.audit_lines = vec!["line1".into(), "line2".into(), "line3".into()];
app.audit_scroll = 0;
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Down, &mut pw);
assert_eq!(app.audit_scroll, 1);
dispatch_input(&mut app, AppInput::Up, &mut pw);
assert_eq!(app.audit_scroll, 0);
dispatch_input(&mut app, AppInput::Esc, &mut pw);
assert_eq!(app.screen, Screen::Dashboard);
}
#[test]
fn history_navigation_and_back() {
let mut app = make_app();
let key = "MY_KEY".to_string();
app.screen = Screen::History { key: key.clone() };
let now = chrono::Utc::now();
app.history_entries = vec![(0, now), (1, now)];
app.history_scroll = 0;
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Down, &mut pw);
assert_eq!(app.history_scroll, 1);
dispatch_input(&mut app, AppInput::Down, &mut pw);
assert_eq!(app.history_scroll, 1);
dispatch_input(&mut app, AppInput::Char('q'), &mut pw);
assert_eq!(app.screen, Screen::Dashboard);
assert!(app.history_entries.is_empty());
}
#[test]
fn dashboard_n_opens_add_secret_modal() {
let mut app = make_app();
let mut pw = Some(sensitive_string("test-unlock-password"));
dispatch_input(&mut app, AppInput::Char('n'), &mut pw);
assert!(matches!(app.screen, Screen::EditSecret { is_new: true }));
assert!(app.edit_key.is_empty());
assert!(app.edit_value.is_empty());
assert_eq!(app.edit_focus, EditFocus::Key);
}
#[test]
fn edit_modal_esc_returns_to_dashboard() {
let mut app = make_app();
app.screen = Screen::EditSecret { is_new: true };
app.edit_key = "K".into();
app.edit_value = "secret-value".into();
let mut pw = Some(sensitive_string("x"));
dispatch_input(&mut app, AppInput::Esc, &mut pw);
assert_eq!(app.screen, Screen::Dashboard);
assert!(app.edit_value.is_empty());
}
#[test]
fn edit_modal_enter_whitespace_key_shows_error() {
let mut app = make_app();
app.screen = Screen::EditSecret { is_new: true };
app.edit_key = " ".into();
app.edit_value.clear();
let mut pw = Some(sensitive_string("x"));
dispatch_input(&mut app, AppInput::Enter, &mut pw);
assert!(matches!(app.screen, Screen::EditSecret { .. }));
assert_eq!(app.edit_error.as_deref(), Some("KEY must not be empty."));
}
#[test]
fn confirm_delete_esc_returns_to_dashboard() {
let mut app = make_app();
app.screen = Screen::ConfirmDelete;
let mut pw = Some(sensitive_string("x"));
dispatch_input(&mut app, AppInput::Esc, &mut pw);
assert_eq!(app.screen, Screen::Dashboard);
}
#[test]
fn help_overlay_esc_dismisses() {
let mut app = make_app();
app.help_visible = true;
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Esc, &mut pw);
assert!(!app.help_visible);
}
#[test]
fn search_mode_slash_then_esc_exits_search() {
let mut app = make_app();
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Char('/'), &mut pw);
assert!(app.search_mode);
dispatch_input(&mut app, AppInput::Esc, &mut pw);
assert!(!app.search_mode);
assert!(app.search_query.is_empty());
}
#[test]
fn profile_switch_clears_active_session() {
let dir = tempdir().unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", Some(dir.path().as_os_str()), || {
let profile = "test";
let vault_file = vault_path(profile);
let vault = Vault::create(&vault_file, b"pw").unwrap();
let mut app = make_app();
app.active_profile = Some(profile.into());
app.session_vault = Some(vault);
app.secret_keys = vec!["SECRET".into()];
app.revealed.insert(
"SECRET".into(),
Reveal {
value: sensitive_string("value"),
revealed_at: std::time::Instant::now(),
},
);
app.search_mode = false;
app.search_query = "sec".into();
let mut pw = Some(sensitive_string("pw"));
dispatch_input(&mut app, AppInput::Char('p'), &mut pw);
assert_eq!(app.screen, Screen::Login);
assert!(app.active_profile.is_none());
assert!(app.session_vault.is_none());
assert!(app.secret_keys.is_empty());
assert!(app.revealed.is_empty());
assert!(!app.search_mode);
assert!(app.search_query.is_empty());
assert!(pw.is_none());
});
}
#[test]
fn edit_save_uses_active_session_vault_without_password_store() {
let dir = tempdir().unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", Some(dir.path().as_os_str()), || {
let profile = "test";
let vault_file = vault_path(profile);
let vault = Vault::create(&vault_file, b"pw").unwrap();
let mut app = make_app();
app.active_profile = Some(profile.into());
app.session_vault = Some(vault);
app.screen = Screen::EditSecret { is_new: true };
app.edit_key = "SECRET".into();
app.edit_value = "value".into();
let mut pw = None;
dispatch_input(&mut app, AppInput::Enter, &mut pw);
assert_eq!(app.screen, Screen::Dashboard);
assert!(app.edit_error.is_none());
assert_eq!(
&*app.session_vault.as_ref().unwrap().get("SECRET").unwrap(),
"value"
);
assert!(
pw.is_none(),
"save should not require reopening via password"
);
});
}
#[test]
fn dashboard_c_on_namespace_header_opens_copy_namespace_modal() {
let mut app = make_app();
app.secret_keys = vec!["prod/A".into()];
app.collapsed_namespaces.clear();
app.secret_cursor = 0;
let mut pw = None;
dispatch_input(&mut app, AppInput::Char('c'), &mut pw);
match &app.screen {
Screen::NsBulk { copy, from } => {
assert!(*copy);
assert_eq!(from, "prod");
}
_ => panic!("expected NsBulk copy"),
}
}
#[test]
fn dashboard_m_on_key_row_opens_single_move_modal() {
let mut app = make_app();
app.secret_keys = vec!["prod/A".into()];
app.collapsed_namespaces.clear();
app.secret_cursor = 1;
let mut pw = None;
dispatch_input(&mut app, AppInput::Char('m'), &mut pw);
match &app.screen {
Screen::MoveSecret { source } => assert_eq!(source, "prod/A"),
_ => panic!("expected MoveSecret"),
}
}
#[test]
fn dashboard_m_on_namespace_header_opens_move_namespace_modal() {
let mut app = make_app();
app.secret_keys = vec!["prod/A".into()];
app.collapsed_namespaces.clear();
app.secret_cursor = 0;
let mut pw = None;
dispatch_input(&mut app, AppInput::Char('m'), &mut pw);
match &app.screen {
Screen::NsBulk { copy, from } => {
assert!(!*copy);
assert_eq!(from, "prod");
}
_ => panic!("expected NsBulk move"),
}
}
#[test]
fn rotate_password_esc_returns_to_dashboard() {
let mut app = make_app();
app.screen = Screen::RotatePassword;
let mut pw = Some(sensitive_string("x"));
dispatch_input(&mut app, AppInput::Esc, &mut pw);
assert_eq!(app.screen, Screen::Dashboard);
assert!(app.rotate_error.is_none());
}
#[test]
fn rotate_password_step0_empty_shows_error() {
let mut app = make_app();
app.screen = Screen::RotatePassword;
app.rotate_step = 0;
let mut pw = Some(sensitive_string("x"));
dispatch_input(&mut app, AppInput::Enter, &mut pw);
assert!(matches!(app.screen, Screen::RotatePassword));
assert_eq!(
app.rotate_error.as_deref(),
Some("Password must not be empty.")
);
}
#[test]
fn rotate_password_step0_nonempty_advances_to_step1() {
let mut app = make_app();
app.screen = Screen::RotatePassword;
app.rotate_step = 0;
let mut pw = Some(sensitive_string("x"));
dispatch_input(&mut app, AppInput::Char('s'), &mut pw);
dispatch_input(&mut app, AppInput::Enter, &mut pw);
assert_eq!(app.rotate_step, 1);
assert!(app.rotate_error.is_none());
}
#[test]
fn rotate_password_step1_mismatch_shows_error_and_resets() {
let mut app = make_app();
app.screen = Screen::RotatePassword;
app.rotate_step = 1;
app.rotate_new1 = sensitive_string("abc");
app.rotate_new2 = sensitive_string("xyz");
let mut pw = Some(sensitive_string("x"));
dispatch_input(&mut app, AppInput::Enter, &mut pw);
assert_eq!(app.rotate_step, 0);
assert_eq!(app.rotate_error.as_deref(), Some("Passwords do not match."));
assert!(app.rotate_new1.is_empty());
assert!(app.rotate_new2.is_empty());
}
#[test]
fn rotate_password_step1_no_vault_shows_error() {
let mut app = make_app();
app.screen = Screen::RotatePassword;
app.rotate_step = 1;
app.rotate_new1 = sensitive_string("newpw");
app.rotate_new2 = sensitive_string("newpw");
app.session_vault = None;
app.active_profile = None;
let mut pw = Some(sensitive_string("x"));
dispatch_input(&mut app, AppInput::Enter, &mut pw);
assert!(app.rotate_error.is_some());
}
#[test]
fn snapshot_restore_esc_clears_paths_and_returns_dashboard() {
let mut app = make_app();
app.screen = Screen::SnapshotRestore;
app.snapshot_paths = vec![std::path::PathBuf::from("/tmp/test.snap.json")];
let mut pw = Some(sensitive_string("x"));
dispatch_input(&mut app, AppInput::Esc, &mut pw);
assert_eq!(app.screen, Screen::Dashboard);
assert!(app.snapshot_paths.is_empty());
}
#[test]
fn snapshot_restore_down_clamped_at_last() {
let mut app = make_app();
app.screen = Screen::SnapshotRestore;
app.snapshot_paths = vec![
std::path::PathBuf::from("/a"),
std::path::PathBuf::from("/b"),
];
app.snapshot_cursor = 0;
let mut pw = Some(sensitive_string("x"));
dispatch_input(&mut app, AppInput::Down, &mut pw);
assert_eq!(app.snapshot_cursor, 1);
dispatch_input(&mut app, AppInput::Down, &mut pw);
assert_eq!(app.snapshot_cursor, 1);
}
#[test]
fn snapshot_restore_up_stays_at_zero() {
let mut app = make_app();
app.screen = Screen::SnapshotRestore;
app.snapshot_paths = vec![std::path::PathBuf::from("/a")];
app.snapshot_cursor = 0;
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Up, &mut pw);
assert_eq!(app.snapshot_cursor, 0);
}
#[test]
fn snapshot_restore_enter_without_password_is_noop() {
let mut app = make_app();
app.screen = Screen::SnapshotRestore;
app.snapshot_paths = vec![std::path::PathBuf::from("/tmp/snap.json")];
app.snapshot_cursor = 0;
let mut pw: Option<SensitiveString> = None;
let quit = dispatch_input(&mut app, AppInput::Enter, &mut pw);
assert!(!quit);
}
#[test]
fn new_profile_step0_esc_returns_to_login() {
let mut app = make_login_app();
app.screen = Screen::NewProfile { step: 0 };
app.profiles = vec!["existing".into()];
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Esc, &mut pw);
assert_eq!(app.screen, Screen::Login);
assert!(app.new_profile_error.is_none());
}
#[test]
fn new_profile_step0_empty_name_shows_error() {
let mut app = make_login_app();
app.screen = Screen::NewProfile { step: 0 };
app.new_profile_name.clear();
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Enter, &mut pw);
assert!(matches!(app.screen, Screen::NewProfile { step: 0 }));
assert_eq!(
app.new_profile_error.as_deref(),
Some("Profile name must not be empty.")
);
}
#[test]
fn new_profile_step0_valid_name_advances_to_step1() {
let mut app = make_login_app();
app.screen = Screen::NewProfile { step: 0 };
app.profiles = vec![];
let mut pw: Option<SensitiveString> = None;
for c in "myapp".chars() {
dispatch_input(&mut app, AppInput::Char(c), &mut pw);
}
dispatch_input(&mut app, AppInput::Enter, &mut pw);
assert!(matches!(app.screen, Screen::NewProfile { step: 1 }));
assert!(app.new_profile_error.is_none());
}
#[test]
fn new_profile_step1_esc_goes_to_step0() {
let mut app = make_login_app();
app.screen = Screen::NewProfile { step: 1 };
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Esc, &mut pw);
assert!(matches!(app.screen, Screen::NewProfile { step: 0 }));
assert!(app.new_profile_pw1.is_empty());
}
#[test]
fn new_profile_step1_empty_password_shows_error() {
let mut app = make_login_app();
app.screen = Screen::NewProfile { step: 1 };
app.new_profile_pw1 = empty_sensitive();
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Enter, &mut pw);
assert!(matches!(app.screen, Screen::NewProfile { step: 1 }));
assert_eq!(
app.new_profile_error.as_deref(),
Some("Password must not be empty.")
);
}
#[test]
fn new_profile_step1_nonempty_advances_to_step2() {
let mut app = make_login_app();
app.screen = Screen::NewProfile { step: 1 };
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Char('p'), &mut pw);
dispatch_input(&mut app, AppInput::Enter, &mut pw);
assert!(matches!(app.screen, Screen::NewProfile { step: 2 }));
}
#[test]
fn new_profile_step2_esc_goes_to_step1() {
let mut app = make_login_app();
app.screen = Screen::NewProfile { step: 2 };
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Esc, &mut pw);
assert!(matches!(app.screen, Screen::NewProfile { step: 1 }));
assert!(app.new_profile_pw2.is_empty());
}
#[test]
fn new_profile_step2_mismatch_shows_error_and_resets_to_step1() {
let mut app = make_login_app();
app.screen = Screen::NewProfile { step: 2 };
app.new_profile_pw1 = sensitive_string("abc");
app.new_profile_pw2 = sensitive_string("xyz");
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Enter, &mut pw);
assert!(matches!(app.screen, Screen::NewProfile { step: 1 }));
assert_eq!(
app.new_profile_error.as_deref(),
Some("Passwords do not match — try again.")
);
}
#[test]
fn new_profile_step2_match_creates_vault_and_transitions_to_dashboard() {
let dir = tempdir().unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", Some(dir.path().as_os_str()), || {
let mut app = make_login_app();
app.screen = Screen::NewProfile { step: 2 };
app.new_profile_name = "myvault".into();
app.profiles = vec![];
app.new_profile_pw1 = sensitive_string("secret123");
app.new_profile_pw2 = sensitive_string("secret123");
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Enter, &mut pw);
assert_eq!(app.screen, Screen::Dashboard);
assert!(app.active_profile.as_deref() == Some("myvault"));
assert!(app.session_vault.is_some());
});
}
#[test]
fn move_secret_esc_returns_to_dashboard() {
let mut app = make_app();
app.screen = Screen::MoveSecret {
source: "prod/A".into(),
};
app.mv_dest_buf = "staging/A".into();
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Esc, &mut pw);
assert_eq!(app.screen, Screen::Dashboard);
assert!(app.mv_dest_buf.is_empty());
}
#[test]
fn move_secret_typing_fills_buffer() {
let mut app = make_app();
app.screen = Screen::MoveSecret { source: "A".into() };
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Char('x'), &mut pw);
dispatch_input(&mut app, AppInput::Char('y'), &mut pw);
assert_eq!(app.mv_dest_buf, "xy");
}
#[test]
fn move_secret_backspace_removes_char() {
let mut app = make_app();
app.screen = Screen::MoveSecret { source: "A".into() };
app.mv_dest_buf = "abc".into();
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Backspace, &mut pw);
assert_eq!(app.mv_dest_buf, "ab");
}
#[test]
fn move_secret_enter_empty_shows_error() {
let mut app = make_app();
app.screen = Screen::MoveSecret { source: "A".into() };
app.mv_dest_buf.clear();
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Enter, &mut pw);
assert!(matches!(app.screen, Screen::MoveSecret { .. }));
assert_eq!(
app.mv_error.as_deref(),
Some("Destination key must not be empty.")
);
}
#[test]
fn move_secret_enter_same_as_source_returns_to_dashboard() {
let mut app = make_app();
app.screen = Screen::MoveSecret {
source: "prod/A".into(),
};
app.mv_dest_buf = "prod/A".into();
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Enter, &mut pw);
assert_eq!(app.screen, Screen::Dashboard);
assert!(app.mv_error.is_none());
}
#[test]
fn move_secret_enter_no_vault_shows_error() {
let mut app = make_app();
app.screen = Screen::MoveSecret { source: "A".into() };
app.mv_dest_buf = "B".into();
app.session_vault = None;
app.active_profile = None;
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Enter, &mut pw);
assert!(app.mv_error.is_some());
}
#[test]
fn ns_bulk_esc_returns_to_dashboard() {
let mut app = make_app();
app.screen = Screen::NsBulk {
copy: true,
from: "prod".into(),
};
app.mv_dest_buf = "staging".into();
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Esc, &mut pw);
assert_eq!(app.screen, Screen::Dashboard);
assert!(app.mv_dest_buf.is_empty());
}
#[test]
fn ns_bulk_enter_empty_destination_shows_error() {
let mut app = make_app();
app.screen = Screen::NsBulk {
copy: true,
from: "prod".into(),
};
app.mv_dest_buf.clear();
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Enter, &mut pw);
assert_eq!(
app.mv_error.as_deref(),
Some("Destination namespace must not be empty.")
);
}
#[test]
fn ns_bulk_enter_same_as_source_shows_error() {
let mut app = make_app();
app.screen = Screen::NsBulk {
copy: false,
from: "prod".into(),
};
app.mv_dest_buf = "prod".into();
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Enter, &mut pw);
assert_eq!(
app.mv_error.as_deref(),
Some("Source and destination are the same.")
);
}
#[test]
fn ns_bulk_enter_no_vault_shows_error() {
let mut app = make_app();
app.screen = Screen::NsBulk {
copy: true,
from: "prod".into(),
};
app.mv_dest_buf = "staging".into();
app.session_vault = None;
app.active_profile = None;
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Enter, &mut pw);
assert!(app.mv_error.is_some());
}
#[test]
fn dashboard_d_on_selected_key_opens_confirm_delete() {
let mut app = make_app();
app.secret_keys = vec!["DB_URL".into()];
app.secret_cursor = 0;
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Char('d'), &mut pw);
assert_eq!(app.screen, Screen::ConfirmDelete);
}
#[test]
fn dashboard_capital_r_with_vault_opens_rotate_modal() {
let dir = tempdir().unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", Some(dir.path().as_os_str()), || {
let vault =
tsafe_core::vault::Vault::create(&tsafe_core::profile::vault_path("test"), b"pw")
.unwrap();
let mut app = make_app();
app.session_vault = Some(vault);
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Char('R'), &mut pw);
assert_eq!(app.screen, Screen::RotatePassword);
assert_eq!(app.rotate_step, 0);
});
}
#[test]
fn dashboard_capital_r_without_vault_is_noop() {
let mut app = make_app();
app.session_vault = None;
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Char('R'), &mut pw);
assert_eq!(app.screen, Screen::Dashboard);
}
#[test]
fn login_tab_switches_focus_between_profile_and_password() {
let mut app = make_login_app();
app.login_focus = LoginFocus::Password;
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Tab, &mut pw);
assert_eq!(app.login_focus, LoginFocus::Profile);
dispatch_input(&mut app, AppInput::Tab, &mut pw);
assert_eq!(app.login_focus, LoginFocus::Password);
}
#[test]
fn login_down_on_profile_focus_cycles_cursor() {
let mut app = make_login_app();
app.profiles = vec!["alpha".into(), "beta".into()];
app.profile_cursor = 0;
app.login_focus = LoginFocus::Profile;
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Down, &mut pw);
assert_eq!(app.profile_cursor, 1);
dispatch_input(&mut app, AppInput::Down, &mut pw);
assert_eq!(app.profile_cursor, 0);
}
#[test]
fn login_up_on_profile_focus_cycles_cursor() {
let mut app = make_login_app();
app.profiles = vec!["alpha".into(), "beta".into()];
app.profile_cursor = 0;
app.login_focus = LoginFocus::Profile;
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Up, &mut pw);
assert_eq!(app.profile_cursor, 1);
}
#[test]
fn login_backspace_removes_last_char_from_password_buf() {
let mut app = make_login_app();
app.login_focus = LoginFocus::Password;
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Char('a'), &mut pw);
dispatch_input(&mut app, AppInput::Char('b'), &mut pw);
dispatch_input(&mut app, AppInput::Backspace, &mut pw);
assert_eq!(app.password_buf.as_str(), "a");
}
#[test]
fn login_esc_clears_password_buffer() {
let mut app = make_login_app();
app.login_focus = LoginFocus::Password;
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Char('s'), &mut pw);
dispatch_input(&mut app, AppInput::Char('e'), &mut pw);
dispatch_input(&mut app, AppInput::Esc, &mut pw);
assert!(app.password_buf.is_empty());
}
#[test]
fn login_profile_row_q_quits_when_password_empty() {
let mut app = make_login_app();
app.login_focus = LoginFocus::Profile;
let mut pw: Option<SensitiveString> = None;
let quit = dispatch_input(&mut app, AppInput::Char('q'), &mut pw);
assert!(quit);
}
#[test]
fn search_typing_appends_to_query() {
let mut app = make_app();
app.search_mode = true;
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Char('a'), &mut pw);
dispatch_input(&mut app, AppInput::Char('p'), &mut pw);
dispatch_input(&mut app, AppInput::Char('i'), &mut pw);
assert_eq!(app.search_query, "api");
}
#[test]
fn search_backspace_removes_char_from_query() {
let mut app = make_app();
app.search_mode = true;
app.search_query = "api".into();
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Backspace, &mut pw);
assert_eq!(app.search_query, "ap");
}
#[test]
fn confirm_delete_n_returns_to_dashboard() {
let mut app = make_app();
app.screen = Screen::ConfirmDelete;
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Char('n'), &mut pw);
assert_eq!(app.screen, Screen::Dashboard);
}
#[test]
fn confirm_delete_y_with_vault_deletes_secret_and_returns_to_dashboard() {
let dir = tempdir().unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", Some(dir.path().as_os_str()), || {
let mut vault =
tsafe_core::vault::Vault::create(&tsafe_core::profile::vault_path("brigi-test"), b"pw")
.unwrap();
vault.set("DEMO_API_KEY", "v1", std::collections::HashMap::new()).unwrap();
vault.set("DEMO_API_KEY2", "v2", std::collections::HashMap::new()).unwrap();
vault.set("DEMO_API_KEY3", "v3", std::collections::HashMap::new()).unwrap();
let mut app = make_app();
app.active_profile = Some("brigi-test".to_string());
app.reload_from_vault(&vault);
app.session_vault = Some(vault);
app.screen = Screen::Dashboard;
app.secret_cursor = 2;
assert_eq!(app.selected_key().as_deref(), Some("DEMO_API_KEY3"),
"cursor should point at DEMO_API_KEY3");
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Char('d'), &mut pw);
assert_eq!(app.screen, Screen::ConfirmDelete, "should be on ConfirmDelete after 'd'");
dispatch_input(&mut app, AppInput::Char('y'), &mut pw);
assert_eq!(app.screen, Screen::Dashboard,
"BUG: screen stayed on ConfirmDelete after 'y'");
let remaining = app.secret_keys.clone();
assert!(!remaining.contains(&"DEMO_API_KEY3".to_string()),
"DEMO_API_KEY3 should have been deleted; remaining: {remaining:?}");
assert_eq!(remaining.len(), 2, "should have 2 keys left");
});
}
#[test]
fn confirm_delete_capital_y_also_deletes() {
let dir = tempdir().unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", Some(dir.path().as_os_str()), || {
let mut vault =
tsafe_core::vault::Vault::create(&tsafe_core::profile::vault_path("t"), b"pw")
.unwrap();
vault.set("TARGET", "v", std::collections::HashMap::new()).unwrap();
let mut app = make_app();
app.active_profile = Some("t".to_string());
app.reload_from_vault(&vault);
app.session_vault = Some(vault);
app.screen = Screen::ConfirmDelete;
app.secret_cursor = 0;
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Char('Y'), &mut pw);
assert_eq!(app.screen, Screen::Dashboard,
"BUG: capital Y did not confirm delete");
assert!(app.secret_keys.is_empty(), "TARGET should be deleted");
});
}
#[test]
fn confirm_delete_enter_also_confirms() {
let dir = tempdir().unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", Some(dir.path().as_os_str()), || {
let mut vault =
tsafe_core::vault::Vault::create(&tsafe_core::profile::vault_path("t"), b"pw")
.unwrap();
vault.set("TARGET", "v", std::collections::HashMap::new()).unwrap();
let mut app = make_app();
app.active_profile = Some("t".to_string());
app.reload_from_vault(&vault);
app.session_vault = Some(vault);
app.screen = Screen::ConfirmDelete;
app.secret_cursor = 0;
let mut pw: Option<SensitiveString> = None;
dispatch_input(&mut app, AppInput::Enter, &mut pw);
assert_eq!(app.screen, Screen::Dashboard, "Enter should confirm delete");
assert!(app.secret_keys.is_empty(), "TARGET should be deleted");
});
}
#[test]
fn footer_hints_returns_nonempty_slices_for_all_screens() {
let screens = vec![
Screen::Login,
Screen::Dashboard,
Screen::EditSecret { is_new: true },
Screen::EditSecret { is_new: false },
Screen::ConfirmDelete,
Screen::RotatePassword,
Screen::SnapshotRestore,
Screen::AuditLog,
Screen::NewProfile { step: 0 },
Screen::History { key: "K".into() },
Screen::MoveSecret { source: "K".into() },
Screen::NsBulk {
copy: true,
from: "ns".into(),
},
];
for screen in &screens {
assert!(
!screen.footer_hints().is_empty(),
"footer_hints empty for {screen:?}"
);
}
assert!(Screen::Quitting.footer_hints().is_empty());
}
}