kptui 0.20.0

TUI password manager for KeePass vaults
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};

use anyhow::Context;
use crossterm::event::{self, Event};
use keepass::{Database, DatabaseKey};
use ratatui::{
	Terminal,
	backend::CrosstermBackend,
	style::Style,
	widgets::{Block, ListState, TableState},
};
use zeroize::Zeroizing;

use crate::config::{Config, load_config};
use crate::db::Entry;
use crate::input::edit::handle_edit_input;
use crate::input::fieldedit::FieldEditor;
use crate::input::index::handle_index_input;
use crate::input::login::{attempt_unlock, create_database_with_password, database_missing, handle_login_input};
use crate::input::settings::handle_settings_input;
use crate::theme::{Theme, load_theme};
use crate::ui::edit::draw_edit;
use crate::ui::index::draw_index;
use crate::ui::login::draw_login;
use crate::ui::settings::draw_settings;

pub enum Screen {
	Login,
	Index,
	Edit,
	Settings,
}

#[derive(PartialEq, Eq)]
pub enum PasswordChangeStep {
	Current,
	New,
	ConfirmNew,
}

#[derive(PartialEq, Eq)]
pub enum ImportStep {
	Path,
	KdbxPassword,
}

#[derive(PartialEq, Eq)]
pub enum ExportStep {
	Path,
	Confirm,
}

pub struct App {
	pub screen: Screen,
	pub password: Zeroizing<String>,
	pub query: Zeroizing<String>,
	pub max_len: usize,
	pub index_state: TableState,
	pub index_page_size: usize,
	pub confirm_index_delete: Option<usize>,
	pub edit_state: ListState,
	pub edit_entry: Option<Entry>,
	pub edit_original: Option<Entry>,
	pub edit_target: Option<usize>,
	pub editing_field: bool,
	pub field_editor: Option<FieldEditor>,
	pub field_editor_scroll: usize,
	pub confirm_delete: bool,
	pub confirm_exit: bool,
	pub entries: Vec<Entry>,
	pub filtered: Vec<usize>,
	pub kdbx: Option<Database>,
	pub db_key: Option<DatabaseKey>,
	pub theme: Theme,
	pub config: Config,

	pub creating_database: bool,
	pub confirming_new_db_password: bool,
	pub new_db_confirm: Zeroizing<String>,

	pub available_themes: Vec<String>,
	pub settings_state: ListState,
	pub choosing_theme: bool,
	pub theme_state: ListState,

	pub changing_password: bool,
	pub password_change_step: PasswordChangeStep,
	pub current_password_buffer: Zeroizing<String>,
	pub new_password_buffer: Zeroizing<String>,
	pub new_password_confirm: Zeroizing<String>,

	pub importing_database: bool,
	pub import_step: ImportStep,
	pub import_path_buffer: String,
	pub import_kdbx_password_buffer: Zeroizing<String>,
	pub pending_import_path: Option<PathBuf>,

	pub exporting_database: bool,
	pub export_step: ExportStep,
	pub export_path_buffer: String,
	pub pending_export_path: Option<PathBuf>,

	pub login_error: Option<String>,

	pub last_activity: Instant,

	pub status: Option<String>,

	pub should_quit: bool,
	pub slim_mode: bool,
}

impl App {
	pub fn new(config: Config, theme: Theme, slim_mode: bool, password: Zeroizing<String>) -> Self {
		Self {
			screen: Screen::Login,
			password,
			query: Zeroizing::new(String::new()),
			max_len: 0,
			theme,
			config,
			available_themes: Vec::new(),
			settings_state: ListState::default(),
			choosing_theme: false,
			theme_state: ListState::default(),
			changing_password: false,
			password_change_step: PasswordChangeStep::Current,
			current_password_buffer: Zeroizing::new(String::new()),
			new_password_buffer: Zeroizing::new(String::new()),
			new_password_confirm: Zeroizing::new(String::new()),
			importing_database: false,
			import_step: ImportStep::Path,
			import_path_buffer: String::new(),
			import_kdbx_password_buffer: Zeroizing::new(String::new()),
			pending_import_path: None,
			exporting_database: false,
			export_step: ExportStep::Path,
			export_path_buffer: String::new(),
			pending_export_path: None,
			login_error: None,
			last_activity: Instant::now(),
			index_state: TableState::default().with_selected(Some(0)),
			index_page_size: 1,
			confirm_index_delete: None,
			edit_state: ListState::default(),
			edit_entry: None,
			edit_original: None,
			edit_target: None,
			editing_field: false,
			field_editor: None,
			field_editor_scroll: 0,
			confirm_delete: false,
			confirm_exit: false,
			entries: Vec::new(),
			filtered: Vec::new(),
			kdbx: None,
			db_key: None,
			creating_database: false,
			confirming_new_db_password: false,
			new_db_confirm: Zeroizing::new(String::new()),
			status: None,
			should_quit: false,
			slim_mode,
		}
	}

	fn compute_filtered(&self) -> Vec<usize> {
		let query = Zeroizing::new(self.query.to_lowercase());
		let mut indices: Vec<usize> = self
			.entries
			.iter()
			.enumerate()
			.filter(|(_, entry)| query.is_empty() || entry.name.to_lowercase().contains(query.as_str()) || entry.user.to_lowercase().contains(query.as_str()))
			.map(|(i, _)| i)
			.collect();
		indices.sort_by(|&a, &b| {
			let ea = &self.entries[a];
			let eb = &self.entries[b];
			ea.name.to_lowercase().cmp(&eb.name.to_lowercase())
		});
		indices
	}

	pub fn refresh_filter(&mut self) {
		self.filtered = self.compute_filtered();

		let count = self.filtered.len();
		self.index_state.select(if count == 0 { None } else { Some(0) });
	}
}

fn maybe_auto_lock(app: &mut App) {
	if !matches!(app.screen, Screen::Index | Screen::Edit | Screen::Settings) {
		return;
	}

	if app.last_activity.elapsed() < app.config.auto_lock {
		return;
	}

	app.entries.clear();
	app.filtered.clear();
	app.password.clear();
	app.query.clear();
	app.kdbx = None;
	app.db_key = None;
	app.edit_entry = None;
	app.edit_original = None;
	app.edit_target = None;
	app.editing_field = false;
	app.field_editor = None;
	app.field_editor_scroll = 0;
	app.confirm_delete = false;
	app.confirm_exit = false;
	app.available_themes.clear();
	app.settings_state.select(None);
	app.choosing_theme = false;
	app.theme_state.select(None);
	app.creating_database = false;
	app.confirming_new_db_password = false;
	app.new_db_confirm.clear();
	app.changing_password = false;
	app.password_change_step = PasswordChangeStep::Current;
	app.current_password_buffer.clear();
	app.new_password_buffer.clear();
	app.new_password_confirm.clear();
	app.importing_database = false;
	app.import_step = ImportStep::Path;
	app.import_path_buffer.clear();
	app.import_kdbx_password_buffer.clear();
	app.pending_import_path = None;
	app.exporting_database = false;
	app.export_step = ExportStep::Path;
	app.export_path_buffer.clear();
	app.pending_export_path = None;
	app.screen = Screen::Login;
	app.login_error = Some("Locked after inactivity".to_string());
}

fn load_app_config() -> (Config, Theme) {
	let _ = crate::theme::ensure_default_themes();
	let mut config = load_config().unwrap_or_default();
	if config.default_database.is_none() {
		let default_path = crate::util::default_new_database_path();
		if default_path.is_file() {
			config.default_database = Some(default_path);
			let _ = crate::config::save_config(&config);
		}
	}
	let theme = load_theme(config.theme.as_deref()).unwrap_or_default();
	(config, theme)
}

fn initialize_database(app: &mut App) {
	if app.password.is_empty() {
		return;
	}

	if database_missing(app) {
		create_database_with_password(app);
	} else {
		attempt_unlock(app);
	}
}
pub fn export(path: &Path, password: Zeroizing<String>) -> anyhow::Result<()> {
	let (config, _theme) = load_app_config();
	let db_path = config.default_database.as_deref().context("No default_database set in configfile")?;

	let (_, _, entries) = crate::db::unlock_database(db_path, &password, config.keyfile.as_deref())?;
	match crate::export::detect_format(path)? {
		crate::export::ExportFormat::Csv => crate::export::export_csv(path, &entries),
		crate::export::ExportFormat::Json => crate::export::export_json(path, &entries),
		crate::export::ExportFormat::Kdbx => crate::db::export_database(path, &password, config.keyfile.as_deref(), &entries),
	}
}

pub fn import(path: &Path, password: Zeroizing<String>, keyfile: Option<&Path>) -> anyhow::Result<()> {
	let (config, _) = load_app_config();
	let database_path = config.default_database.as_deref().context("no default database configured")?;
	let imported = match crate::import::detect_format(path)? {
		crate::import::ImportFormat::Kdbx => crate::import::import_kdbx(path, &password)?,
		crate::import::ImportFormat::Csv => crate::import::import_csv(path)?,
		crate::import::ImportFormat::Json => crate::import::import_json(path)?,
	};
	let (mut db, key, mut entries) = crate::db::unlock_database(database_path, &password, keyfile)?;
	entries.extend(imported);
	crate::db::save_database(database_path, &key, &mut db, &mut entries)
}

pub fn run(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, slim_mode: bool, password: Zeroizing<String>) -> io::Result<()> {
	let (config, theme) = load_app_config();
	let mut app = App::new(config, theme, slim_mode, password);
	initialize_database(&mut app);
	const TICK_RATE: Duration = Duration::from_millis(200);
	loop {
		terminal.draw(|frame| {
			frame.render_widget(Block::default().style(Style::default().bg(app.theme.background)), frame.area());
			match app.screen {
				Screen::Login => draw_login(frame, &mut app),
				Screen::Index => draw_index(frame, &mut app),
				Screen::Edit => draw_edit(frame, &mut app),
				Screen::Settings => draw_settings(frame, &mut app),
			}
		})?;
		if event::poll(TICK_RATE)?
			&& let Event::Key(key) = event::read()?
		{
			app.last_activity = Instant::now();
			match app.screen {
				Screen::Login => handle_login_input(&mut app, key.code),
				Screen::Index => handle_index_input(&mut app, key),
				Screen::Edit => handle_edit_input(&mut app, key),
				Screen::Settings => handle_settings_input(&mut app, key),
			}
		}
		maybe_auto_lock(&mut app);
		if app.should_quit {
			break;
		}
	}

	Ok(())
}