use std::path::PathBuf;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::clipboard;
use crate::crypto::{KdfParams, MasterKey};
use crate::db::Database;
use crate::error::{Error, Result};
use crate::model::{instantiate_template, FieldKind, Item};
use crate::search::{self, Filter};
use crate::store;
use crate::vault;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
Continue,
Quit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PassKind {
Open,
Create,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Mode {
Normal,
Search,
PromptPassphrase(PassKind),
PickTemplate,
NewItem(String),
EditItem,
ConfirmDelete,
CategoryMgr,
TagMgr,
Attachments,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MgrEdit {
Add,
Rename,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AttMeta {
pub id: i64,
pub filename: String,
pub mime_type: Option<String>,
pub size: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttEdit {
Add,
Export,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MgrEntity {
Category,
Tag,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Cursor {
Title,
Field(usize),
}
#[derive(Debug, Clone)]
pub struct EditorState {
pub draft: Item,
pub field: Cursor,
}
pub struct App {
pub db: Option<Database>,
pub path: Option<PathBuf>,
pub master_key: Option<MasterKey>,
pub salt: Option<[u8; 16]>,
pub kdf: Option<KdfParams>,
pub mode: Mode,
pub filter: Filter,
pub items: Vec<Item>,
pub selected: usize,
pub categories: Vec<crate::model::Category>,
pub tags: Vec<String>,
pub message: Option<String>,
pub editor: Option<EditorState>,
pub input: String,
pub input_mask: bool,
pub mgr_selected: usize,
pub mgr_edit: Option<MgrEdit>,
pub att_item_id: Option<i64>,
pub att_list: Vec<AttMeta>,
pub att_selected: usize,
pub att_edit: Option<AttEdit>,
pub tpl_selected: usize,
pub quit: bool,
}
impl App {
pub fn for_open(path: PathBuf) -> App {
App {
db: None,
path: Some(path),
master_key: None,
salt: None,
kdf: None,
mode: Mode::PromptPassphrase(PassKind::Open),
filter: Filter::default(),
items: Vec::new(),
selected: 0,
categories: Vec::new(),
tags: Vec::new(),
message: None,
editor: None,
input: String::new(),
input_mask: true,
mgr_selected: 0,
mgr_edit: None,
att_item_id: None,
att_list: Vec::new(),
att_selected: 0,
att_edit: None,
tpl_selected: 0,
quit: false,
}
}
pub fn for_create(path: PathBuf) -> App {
App {
db: None,
path: Some(path),
master_key: None,
salt: None,
kdf: None,
mode: Mode::PromptPassphrase(PassKind::Create),
filter: Filter::default(),
items: Vec::new(),
selected: 0,
categories: Vec::new(),
tags: Vec::new(),
message: None,
editor: None,
input: String::new(),
input_mask: true,
mgr_selected: 0,
mgr_edit: None,
att_item_id: None,
att_list: Vec::new(),
att_selected: 0,
att_edit: None,
tpl_selected: 0,
quit: false,
}
}
pub fn from_unlocked(db: Database, path: PathBuf, passphrase: String) -> Result<App> {
let (master_key, salt, kdf) = match std::fs::read(&path) {
Ok(file) => match crate::vault::VaultHeader::parse(&file) {
Ok(h) => {
let key = crate::crypto::derive_key(passphrase.as_bytes(), &h.salt, &h.kdf)?;
(Some(key), Some(h.salt), Some(h.kdf))
}
Err(_) => (None, None, None),
},
Err(_) => (None, None, None),
};
let mut app = App {
db: Some(db),
path: Some(path),
master_key,
salt,
kdf,
mode: Mode::Normal,
filter: Filter::default(),
items: Vec::new(),
selected: 0,
categories: Vec::new(),
tags: Vec::new(),
message: None,
editor: None,
input: String::new(),
input_mask: false,
mgr_selected: 0,
mgr_edit: None,
att_item_id: None,
att_list: Vec::new(),
att_selected: 0,
att_edit: None,
tpl_selected: 0,
quit: false,
};
app.reload()?;
Ok(app)
}
pub fn reload(&mut self) -> Result<()> {
let Some(ref db) = self.db else {
self.items.clear();
self.categories.clear();
self.tags.clear();
return Ok(());
};
let conn = db.conn();
self.items = search::search(conn, &self.filter)?;
self.categories = store::list_categories(conn)?;
self.tags = store::list_tags(conn)?
.into_iter()
.map(|t| t.name)
.collect();
if self.selected >= self.items.len() {
self.selected = self.items.len().saturating_sub(1);
}
Ok(())
}
pub fn save(&self) -> Result<()> {
let path = self.path.as_ref().ok_or_else(|| {
Error::Other("save: no vault path".into())
})?;
let key = self
.master_key
.as_ref()
.ok_or_else(|| Error::Other("save: no master key (locked?)".into()))?;
let salt = self
.salt
.ok_or_else(|| Error::Other("save: no salt (locked?)".into()))?;
let kdf = self
.kdf
.ok_or_else(|| Error::Other("save: no kdf (locked?)".into()))?;
let db = self
.db
.as_ref()
.ok_or_else(|| Error::Other("save: no database".into()))?;
vault::save_with_key(path, key, &kdf, salt, db)
}
pub fn lock(&mut self) {
self.db = None;
self.master_key = None;
self.salt = None;
self.kdf = None;
self.mode = Mode::PromptPassphrase(PassKind::Open);
self.items.clear();
self.categories.clear();
self.tags.clear();
self.selected = 0;
self.filter = Filter::default();
self.input.clear();
self.input_mask = true;
self.editor = None;
self.message = Some("locked".into());
}
pub fn selected_item(&self) -> Option<&Item> {
self.items.get(self.selected)
}
fn copy_password_of_selected(&mut self) {
let Some(item) = self.items.get(self.selected).cloned() else {
self.message = Some("no item selected".into());
return;
};
let pw = item
.fields
.iter()
.find(|f| f.name == "password" && f.kind == FieldKind::Secret)
.or_else(|| item.fields.iter().find(|f| f.kind == FieldKind::Secret))
.map(|f| f.value.clone());
let Some(pw) = pw else {
self.message = Some("selected item has no password".into());
return;
};
match clipboard::copy_and_clear_after(&pw, 20) {
Ok(()) => self.message = Some("password copied (clears in 20s)".into()),
Err(e) => self.message = Some(format!("clipboard unavailable: {e}")),
}
}
fn copy_totp_of_selected(&mut self) {
let Some(item) = self.items.get(self.selected).cloned() else {
self.message = Some("no item selected".into());
return;
};
let secret = match item.totp_value() {
Some(s) => s.to_string(),
None => {
self.message = Some("no totp secret".into());
return;
}
};
if secret.trim().is_empty() {
self.message = Some("no totp secret".into());
return;
}
match crate::totp::current_totp(&secret) {
Ok(code) => match clipboard::copy_and_clear_after(&code, 20) {
Ok(()) => self.message = Some("totp copied (clears in 20s)".into()),
Err(e) => self.message = Some(format!("clipboard unavailable: {e}")),
},
Err(e) => self.message = Some(format!("totp failed: {e}")),
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> Result<Action> {
match self.mode.clone() {
Mode::PromptPassphrase(kind) => self.handle_passphrase(key, kind),
Mode::Normal => self.handle_normal(key),
Mode::Search => self.handle_search(key),
Mode::PickTemplate => self.handle_pick_template(key),
Mode::NewItem(tpl) => self.handle_editor(key, true, tpl),
Mode::EditItem => self.handle_editor(key, false, String::new()),
Mode::ConfirmDelete => self.handle_confirm_delete(key),
Mode::CategoryMgr => self.handle_category_mgr(key),
Mode::TagMgr => self.handle_tag_mgr(key),
Mode::Attachments => self.handle_attachments(key),
}
}
fn handle_pick_template(&mut self, key: KeyEvent) -> Result<Action> {
let tpls = crate::model::builtin_templates();
let len = tpls.len();
match key.code {
KeyCode::Char('j') | KeyCode::Down => {
if len > 0 && self.tpl_selected + 1 < len {
self.tpl_selected += 1;
}
}
KeyCode::Char('k') | KeyCode::Up => {
if self.tpl_selected > 0 {
self.tpl_selected -= 1;
}
}
KeyCode::Enter => {
let id = tpls
.get(self.tpl_selected)
.map(|t| t.id.clone());
match id {
Some(tpl) => self.start_editor_new(&tpl),
None => self.message = Some("no template selected".into()),
}
}
KeyCode::Esc => {
self.mode = Mode::Normal;
}
_ => {}
}
Ok(Action::Continue)
}
fn handle_passphrase(&mut self, key: KeyEvent, kind: PassKind) -> Result<Action> {
match key.code {
KeyCode::Char(c) => {
self.input.push(c);
self.input_mask = true;
}
KeyCode::Backspace => {
self.input.pop();
}
KeyCode::Enter => {
let pass = std::mem::take(&mut self.input);
self.input_mask = false;
let path = match self.path.as_ref() {
Some(p) => p.clone(),
None => {
self.message = Some("no vault path".into());
return Ok(Action::Continue);
}
};
match kind {
PassKind::Create => {
match vault::create(&path, &pass) {
Ok(()) => {
match vault::unlock_full(&path, &pass) {
Ok((db, key, kdf, salt)) => {
self.master_key = Some(key);
self.salt = Some(salt);
self.kdf = Some(kdf);
self.db = Some(db);
self.mode = Mode::Normal;
self.reload()?;
self.message = Some("vault created".into());
}
Err(e) => {
self.message =
Some(format!("unlock after create failed: {e}"));
}
}
}
Err(e) => {
self.message =
Some(format!("create failed: {e}"));
}
}
}
PassKind::Open => match vault::unlock_full(&path, &pass) {
Ok((db, key, kdf, salt)) => {
self.master_key = Some(key);
self.salt = Some(salt);
self.kdf = Some(kdf);
self.db = Some(db);
self.mode = Mode::Normal;
self.reload()?;
self.message = Some("unlocked".into());
}
Err(e) => {
self.message = Some(format!("unlock failed: {e}"));
}
},
}
}
KeyCode::Esc => {
self.quit = true;
return Ok(Action::Quit);
}
_ => {}
}
Ok(Action::Continue)
}
fn handle_normal(&mut self, key: KeyEvent) -> Result<Action> {
if self.db.is_none() {
if matches!(key.code, KeyCode::Char('q')) {
self.quit = true;
return Ok(Action::Quit);
}
return Ok(Action::Continue);
}
match key.code {
KeyCode::Char('j') | KeyCode::Down => {
if !self.items.is_empty() && self.selected + 1 < self.items.len() {
self.selected += 1;
}
}
KeyCode::Char('k') | KeyCode::Up => {
if self.selected > 0 {
self.selected -= 1;
}
}
KeyCode::Char('/') => {
self.mode = Mode::Search;
self.input.clear();
self.input_mask = false;
}
KeyCode::Char('n') => {
self.tpl_selected = 0;
self.mode = Mode::PickTemplate;
}
KeyCode::Char('e') => {
self.start_editor_edit();
}
KeyCode::Char('x') => {
if self.selected_item().is_some() {
self.mode = Mode::ConfirmDelete;
} else {
self.message = Some("no item selected".into());
}
}
KeyCode::Char('y') => {
self.copy_password_of_selected();
}
KeyCode::Char('o') => {
self.copy_totp_of_selected();
}
KeyCode::Char('l') => {
self.lock();
}
KeyCode::Char('c') => {
self.enter_mgr(MgrEntity::Category);
}
KeyCode::Char('t') => {
self.enter_mgr(MgrEntity::Tag);
}
KeyCode::Char('a') => {
self.enter_attachments();
}
KeyCode::Char('q') => {
self.quit = true;
return Ok(Action::Quit);
}
_ => {}
}
Ok(Action::Continue)
}
fn handle_search(&mut self, key: KeyEvent) -> Result<Action> {
match key.code {
KeyCode::Char(c) => {
self.input.push(c);
}
KeyCode::Backspace => {
self.input.pop();
}
KeyCode::Enter => {
self.filter.query = Some(self.input.clone());
self.reload()?;
self.mode = Mode::Normal;
}
KeyCode::Esc => {
self.mode = Mode::Normal;
self.input.clear();
}
_ => {}
}
Ok(Action::Continue)
}
fn handle_editor(
&mut self,
key: KeyEvent,
is_new: bool,
_tpl: String,
) -> Result<Action> {
let Some(ref mut ed) = self.editor else {
self.mode = Mode::Normal;
return Ok(Action::Continue);
};
match key.code {
KeyCode::Char(c) => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
return Ok(Action::Continue);
}
Self::write_field(&mut ed.draft, &ed.field, c);
}
KeyCode::Backspace => {
Self::backspace_field(&mut ed.draft, &ed.field);
}
KeyCode::Tab | KeyCode::Down => {
ed.field = Self::next_cursor(ed.draft.fields.len(), &ed.field);
}
KeyCode::Up => {
ed.field = Self::prev_cursor(ed.draft.fields.len(), &ed.field);
}
KeyCode::Enter => {
let draft = ed.draft.clone();
let saved = if is_new {
self.save_new_item(draft)
} else {
self.save_edit_item(draft)
};
match saved {
Ok(()) => {
self.editor = None;
self.mode = Mode::Normal;
}
Err(e) => {
self.message = Some(format!("save failed: {e}"));
}
}
}
KeyCode::Esc => {
self.editor = None;
self.mode = Mode::Normal;
}
_ => {}
}
Ok(Action::Continue)
}
fn handle_confirm_delete(&mut self, key: KeyEvent) -> Result<Action> {
match key.code {
KeyCode::Char('y') => {
if let Some(id) = self
.items
.get(self.selected)
.and_then(|i| i.id)
{
let res = (|| -> Result<()> {
let db = self.db.as_ref().ok_or_else(|| {
Error::Other("delete: no database".into())
})?;
store::delete_item(db.conn(), id)?;
self.save()?;
self.reload()?;
Ok(())
})();
match res {
Ok(()) => self.message = Some("deleted".into()),
Err(e) => self.message = Some(format!("delete failed: {e}")),
}
}
self.mode = Mode::Normal;
}
KeyCode::Char('n') | KeyCode::Esc => {
self.mode = Mode::Normal;
}
_ => {}
}
Ok(Action::Continue)
}
fn handle_category_mgr(&mut self, key: KeyEvent) -> Result<Action> {
self.mgr_handle(key, MgrEntity::Category)
}
fn handle_tag_mgr(&mut self, key: KeyEvent) -> Result<Action> {
self.mgr_handle(key, MgrEntity::Tag)
}
fn enter_mgr(&mut self, entity: MgrEntity) {
self.mgr_selected = 0;
self.mgr_edit = None;
self.input.clear();
self.mode = match entity {
MgrEntity::Category => Mode::CategoryMgr,
MgrEntity::Tag => Mode::TagMgr,
};
self.message = Some("a:add r:rename x:del Esc:back".into());
}
fn mgr_list_len(&self, entity: MgrEntity) -> usize {
match entity {
MgrEntity::Category => self.categories.len(),
MgrEntity::Tag => self.tags.len(),
}
}
fn mgr_clamp(&mut self, entity: MgrEntity) {
let len = self.mgr_list_len(entity);
if len == 0 {
self.mgr_selected = 0;
} else if self.mgr_selected >= len {
self.mgr_selected = len - 1;
}
}
fn mgr_handle(&mut self, key: KeyEvent, entity: MgrEntity) -> Result<Action> {
match self.mgr_edit {
Some(_) => self.mgr_handle_edit(key, entity),
None => self.mgr_handle_browse(key, entity),
}
}
fn mgr_handle_browse(&mut self, key: KeyEvent, entity: MgrEntity) -> Result<Action> {
match key.code {
KeyCode::Char('j') | KeyCode::Down => {
let len = self.mgr_list_len(entity);
if len > 0 && self.mgr_selected + 1 < len {
self.mgr_selected += 1;
}
}
KeyCode::Char('k') | KeyCode::Up => {
if self.mgr_selected > 0 {
self.mgr_selected -= 1;
}
}
KeyCode::Char('a') => {
self.mgr_edit = Some(MgrEdit::Add);
self.input.clear();
self.message = Some("add: type name, Enter to confirm".into());
}
KeyCode::Char('r') => {
if self.mgr_list_len(entity) > 0 {
let name = match entity {
MgrEntity::Category => self
.categories
.get(self.mgr_selected)
.map(|c| c.name.clone()),
MgrEntity::Tag => self.tags.get(self.mgr_selected).cloned(),
};
if let Some(n) = name {
self.input = n;
self.mgr_edit = Some(MgrEdit::Rename);
self.message = Some("rename: edit, Enter to confirm".into());
}
} else {
self.message = Some("nothing to rename".into());
}
}
KeyCode::Char('x') => {
self.mgr_delete(entity)?;
}
KeyCode::Esc => {
self.mode = Mode::Normal;
self.input.clear();
}
_ => {}
}
Ok(Action::Continue)
}
fn mgr_handle_edit(&mut self, key: KeyEvent, entity: MgrEntity) -> Result<Action> {
match key.code {
KeyCode::Char(c) => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
return Ok(Action::Continue);
}
self.input.push(c);
}
KeyCode::Backspace => {
self.input.pop();
}
KeyCode::Enter => {
let edit = self.mgr_edit;
self.mgr_commit(edit, entity)?;
}
KeyCode::Esc => {
self.mgr_edit = None;
self.input.clear();
self.message = Some("a:add r:rename x:del Esc:back".into());
}
_ => {}
}
Ok(Action::Continue)
}
fn mgr_commit(&mut self, edit: Option<MgrEdit>, entity: MgrEntity) -> Result<()> {
let name = self.input.trim().to_string();
if name.is_empty() {
self.message = Some("name cannot be empty".into());
return Ok(());
}
let res = (|| -> Result<String> {
let db = self
.db
.as_ref()
.ok_or_else(|| Error::Other("mgr: no database".into()))?;
let conn = db.conn();
match (edit, entity) {
(Some(MgrEdit::Add), MgrEntity::Category) => {
let mut cat = crate::model::Category {
id: None,
name: name.clone(),
parent_id: None,
sort_order: 0,
};
store::insert_category(conn, &mut cat)?;
}
(Some(MgrEdit::Add), MgrEntity::Tag) => {
store::ensure_tag(conn, &name)?;
}
(Some(MgrEdit::Rename), MgrEntity::Category) => {
let cur = self
.categories
.get(self.mgr_selected)
.ok_or_else(|| Error::Other("rename: no category selected".into()))?;
let mut updated = cur.clone();
updated.name = name.clone();
store::update_category(conn, &updated)?;
}
(Some(MgrEdit::Rename), MgrEntity::Tag) => {
let id = self.tag_id_at(self.mgr_selected)?;
store::update_tag(conn, id, &name)?;
}
_ => return Ok(name),
}
Ok(name)
})();
match res {
Ok(n) => {
self.save()?;
self.reload()?;
self.mgr_clamp(entity);
self.mgr_edit = None;
self.input.clear();
let verb = match edit {
Some(MgrEdit::Add) => "added",
Some(MgrEdit::Rename) => "renamed",
None => "added",
};
self.message = Some(format!("{verb} \"{n}\""));
self.mgr_clamp(entity);
}
Err(e) => {
self.message = Some(format!("mgr failed: {e}"));
}
}
Ok(())
}
fn mgr_delete(&mut self, entity: MgrEntity) -> Result<()> {
let res = (|| -> Result<String> {
let db = self
.db
.as_ref()
.ok_or_else(|| Error::Other("delete: no database".into()))?;
let conn = db.conn();
match entity {
MgrEntity::Category => {
let cur = self
.categories
.get(self.mgr_selected)
.ok_or_else(|| Error::Other("delete: no category selected".into()))?;
let name = cur.name.clone();
let id = cur
.id
.ok_or_else(|| Error::Other("delete: category has no id".into()))?;
store::delete_category(conn, id)?;
Ok(name)
}
MgrEntity::Tag => {
let name = self
.tags
.get(self.mgr_selected)
.cloned()
.ok_or_else(|| Error::Other("delete: no tag selected".into()))?;
let id = self.tag_id_at(self.mgr_selected)?;
store::delete_tag(conn, id)?;
Ok(name)
}
}
})();
match res {
Ok(n) => {
self.save()?;
self.reload()?;
self.mgr_clamp(entity);
self.message = Some(format!("deleted \"{n}\""));
}
Err(e) => {
self.message = Some(format!("delete failed: {e}"));
}
}
Ok(())
}
fn tag_id_at(&self, idx: usize) -> Result<i64> {
let name = self
.tags
.get(idx)
.ok_or_else(|| Error::Other("tag: index out of range".into()))?;
let db = self
.db
.as_ref()
.ok_or_else(|| Error::Other("tag: no database".into()))?;
let tags = store::list_tags(db.conn())?;
tags.into_iter()
.find(|t| &t.name == name)
.map(|t| t.id)
.ok_or_else(|| Error::Other("tag: not found".into()))
}
fn enter_attachments(&mut self) {
let Some(item) = self.selected_item() else {
self.message = Some("no item selected".into());
return;
};
let id = match item.id {
Some(id) => id,
None => {
self.message = Some("no item id".into());
return;
}
};
self.att_item_id = Some(id);
self.att_selected = 0;
self.att_edit = None;
self.input.clear();
self.mode = Mode::Attachments;
if let Err(e) = self.reload_att() {
self.message = Some(format!("load attachments failed: {e}"));
return;
}
self.message = Some("a:add e:export x:del Esc:back".into());
}
fn reload_att(&mut self) -> Result<()> {
let item_id = match self.att_item_id {
Some(id) => id,
None => {
self.att_list.clear();
self.att_selected = 0;
return Ok(());
}
};
let Some(ref db) = self.db else {
self.att_list.clear();
return Ok(());
};
let conn = db.conn();
let mut stmt = conn.prepare(
"SELECT id, filename, mime_type, size FROM attachments
WHERE item_id = ?1 ORDER BY id ASC",
)?;
let list: Vec<AttMeta> = stmt
.query_map(rusqlite::params![item_id], |r| {
Ok(AttMeta {
id: r.get::<_, i64>(0)?,
filename: r.get::<_, String>(1)?,
mime_type: r.get::<_, Option<String>>(2)?,
size: r.get::<_, i64>(3)?,
})
})?
.filter_map(|r| r.ok())
.collect();
self.att_list = list;
if self.att_selected >= self.att_list.len() {
self.att_selected = self.att_list.len().saturating_sub(1);
}
Ok(())
}
fn handle_attachments(&mut self, key: KeyEvent) -> Result<Action> {
match self.att_edit {
Some(_) => self.handle_att_edit(key),
None => self.handle_att_browse(key),
}
}
fn handle_att_browse(&mut self, key: KeyEvent) -> Result<Action> {
match key.code {
KeyCode::Char('j') | KeyCode::Down => {
if !self.att_list.is_empty() && self.att_selected + 1 < self.att_list.len() {
self.att_selected += 1;
}
}
KeyCode::Char('k') | KeyCode::Up => {
if self.att_selected > 0 {
self.att_selected -= 1;
}
}
KeyCode::Char('a') => {
self.att_edit = Some(AttEdit::Add);
self.input.clear();
self.message = Some("add: type file path, Enter to confirm".into());
}
KeyCode::Char('e') => {
if self.att_list.is_empty() {
self.message = Some("nothing to export".into());
} else {
self.att_edit = Some(AttEdit::Export);
self.input.clear();
self.message = Some("export: type output path, Enter to confirm".into());
}
}
KeyCode::Char('x') => {
self.att_delete()?;
}
KeyCode::Esc => {
self.mode = Mode::Normal;
self.input.clear();
}
_ => {}
}
Ok(Action::Continue)
}
fn handle_att_edit(&mut self, key: KeyEvent) -> Result<Action> {
match key.code {
KeyCode::Char(c) => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
return Ok(Action::Continue);
}
self.input.push(c);
}
KeyCode::Backspace => {
self.input.pop();
}
KeyCode::Enter => {
let edit = self.att_edit;
match edit {
Some(AttEdit::Add) => self.att_add()?,
Some(AttEdit::Export) => self.att_export()?,
None => {}
}
}
KeyCode::Esc => {
self.att_edit = None;
self.input.clear();
self.message = Some("a:add e:export x:del Esc:back".into());
}
_ => {}
}
Ok(Action::Continue)
}
fn att_add(&mut self) -> Result<()> {
let path = self.input.clone();
let item_id = self
.att_item_id
.ok_or_else(|| Error::Other("attachments: no item id".into()))?;
let res = (|| -> Result<(String, i64)> {
let p = std::path::Path::new(&path);
let blob = std::fs::read(p).map_err(|e| {
Error::Other(format!("read {path:?} failed: {e}"))
})?;
let filename = p
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| path.clone());
let mime_type = crate::cli::guess_mime(p);
let size = blob.len() as i64;
let db = self
.db
.as_ref()
.ok_or_else(|| Error::Other("attachments: no database".into()))?;
let conn = db.conn();
let mut att = crate::model::Attachment {
id: None,
item_id,
filename: filename.clone(),
mime_type,
size: 0,
blob,
};
store::insert_attachment(conn, &mut att)?;
Ok((filename, size))
})();
match res {
Ok((name, size)) => {
self.save()?;
self.reload_att()?;
self.att_edit = None;
self.input.clear();
self.message = Some(format!("attached {name} ({size})"));
}
Err(e) => {
self.message = Some(format!("add failed: {e}"));
}
}
Ok(())
}
fn att_export(&mut self) -> Result<()> {
let out_path = self.input.clone();
let item_id = self
.att_item_id
.ok_or_else(|| Error::Other("attachments: no item id".into()))?;
let Some(att) = self.att_list.get(self.att_selected).cloned() else {
self.message = Some("no attachment selected".into());
self.att_edit = None;
self.input.clear();
return Ok(());
};
let res = (|| -> Result<(String, i64)> {
let db = self
.db
.as_ref()
.ok_or_else(|| Error::Other("attachments: no database".into()))?;
let conn = db.conn();
let got = store::get_attachment(conn, att.id)?
.ok_or_else(|| Error::Other("export: attachment not found".into()))?;
if got.item_id != item_id {
return Err(Error::Other("export: attachment belongs to another item".into()));
}
std::fs::write(&out_path, &got.blob).map_err(|e| {
Error::Other(format!("write {out_path:?} failed: {e}"))
})?;
Ok((got.filename, got.size))
})();
match res {
Ok((name, size)) => {
self.message = Some(format!("wrote {name} ({size})"));
self.att_edit = None;
self.input.clear();
}
Err(e) => {
self.message = Some(format!("export failed: {e}"));
}
}
Ok(())
}
fn att_delete(&mut self) -> Result<()> {
let Some(att) = self.att_list.get(self.att_selected).cloned() else {
self.message = Some("no attachment selected".into());
return Ok(());
};
let name = att.filename.clone();
let res = (|| -> Result<()> {
let db = self
.db
.as_ref()
.ok_or_else(|| Error::Other("delete: no database".into()))?;
store::delete_attachment(db.conn(), att.id)?;
Ok(())
})();
match res {
Ok(()) => {
self.save()?;
self.reload_att()?;
self.message = Some(format!("deleted {name}"));
}
Err(e) => {
self.message = Some(format!("delete failed: {e}"));
}
}
Ok(())
}
fn start_editor_new(&mut self, template_id: &str) {
let fields = instantiate_template(template_id).unwrap_or_default();
let draft = Item {
id: None,
template_id: template_id.to_string(),
title: String::new(),
category_id: None,
fields,
favorite: false,
tags: Vec::new(),
created_at: 0,
updated_at: 0,
};
self.editor = Some(EditorState {
draft,
field: Cursor::Title,
});
self.mode = Mode::NewItem(template_id.to_string());
}
fn start_editor_edit(&mut self) {
let Some(item) = self.items.get(self.selected).cloned() else {
self.message = Some("no item selected".into());
return;
};
self.editor = Some(EditorState {
draft: item,
field: Cursor::Title,
});
self.mode = Mode::EditItem;
}
fn save_new_item(&mut self, mut draft: Item) -> Result<()> {
let db = self
.db
.as_ref()
.ok_or_else(|| Error::Other("save_new: no database".into()))?;
store::insert_item(db.conn(), &mut draft)?;
self.save()?;
self.reload()?;
Ok(())
}
fn save_edit_item(&mut self, draft: Item) -> Result<()> {
let db = self
.db
.as_ref()
.ok_or_else(|| Error::Other("save_edit: no database".into()))?;
store::update_item(db.conn(), &draft)?;
self.save()?;
self.reload()?;
Ok(())
}
fn write_field(draft: &mut Item, cur: &Cursor, c: char) {
match cur {
Cursor::Title => draft.title.push(c),
Cursor::Field(i) => {
if let Some(f) = draft.fields.get_mut(*i) {
f.value.push(c);
}
}
}
}
fn backspace_field(draft: &mut Item, cur: &Cursor) {
match cur {
Cursor::Title => {
draft.title.pop();
}
Cursor::Field(i) => {
if let Some(f) = draft.fields.get_mut(*i) {
f.value.pop();
}
}
}
}
fn next_cursor(n_fields: usize, cur: &Cursor) -> Cursor {
let total = 1 + n_fields; if total == 0 {
return Cursor::Title;
}
let idx = cur_index(cur);
let next = (idx + 1) % total.max(1);
index_to_cursor(next)
}
fn prev_cursor(n_fields: usize, cur: &Cursor) -> Cursor {
let total = 1 + n_fields;
if total == 0 {
return Cursor::Title;
}
let idx = cur_index(cur);
let prev = if idx == 0 {
total.saturating_sub(1)
} else {
idx - 1
};
index_to_cursor(prev)
}
}
fn cur_index(cur: &Cursor) -> usize {
match cur {
Cursor::Title => 0,
Cursor::Field(i) => *i + 1,
}
}
fn index_to_cursor(idx: usize) -> Cursor {
if idx == 0 {
Cursor::Title
} else {
Cursor::Field(idx - 1)
}
}
#[allow(dead_code)]
fn str_to_keys(s: &str) -> Vec<KeyEvent> {
s.chars()
.map(|c| KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
.collect()
}
#[cfg(test)]
fn key(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
}
#[cfg(test)]
fn key_code(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::KdfParams;
use crate::db::Database;
use crate::model::FieldKind;
use crate::store;
use crate::test_support::{mk_item, mk_password_item};
fn app_with_items(n: usize) -> App {
let db = Database::open_in_memory().unwrap();
let conn = db.conn();
for i in 0..n {
let mut it = mk_item(
"password",
&format!("item-{i}"),
&[
("username", &format!("user-{i}"), FieldKind::Text),
("password", &format!("pw-{i}"), FieldKind::Secret),
("url", "", FieldKind::Text),
("totp", "", FieldKind::Totp),
("notes", "", FieldKind::Multiline),
],
);
store::insert_item(conn, &mut it).unwrap();
}
App::from_unlocked(db, std::path::PathBuf::from("/tmp/zkv_unused.zkv"), "dummy".into())
.unwrap()
}
#[test]
fn from_unlocked_loads_items() {
let app = app_with_items(3);
assert_eq!(app.items.len(), 3);
assert!(matches!(app.mode, Mode::Normal));
assert!(app.db.is_some());
}
#[test]
fn normal_n_enters_pick_template() {
let mut app = app_with_items(1);
let act = app.handle_key(key('n')).unwrap();
assert_eq!(act, Action::Continue);
assert!(matches!(app.mode, Mode::PickTemplate));
assert_eq!(app.tpl_selected, 0);
}
#[test]
fn pick_template_jk_and_enter_starts_editor() {
let mut app = app_with_items(0);
app.handle_key(key('n')).unwrap();
assert!(matches!(app.mode, Mode::PickTemplate));
app.handle_key(key('j')).unwrap();
assert_eq!(app.tpl_selected, 1);
app.handle_key(key_code(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::NewItem(_)));
let ed = app.editor.as_ref().unwrap();
assert_eq!(ed.draft.template_id, "note");
assert_eq!(ed.draft.fields.len(), 2);
}
#[test]
fn pick_template_esc_returns_normal() {
let mut app = app_with_items(0);
app.handle_key(key('n')).unwrap();
app.handle_key(key_code(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Normal));
}
#[test]
fn create_item_increments_count() {
let path = tmp_path("create_item");
cleanup(&path);
let kdf = KdfParams { m_kib: 4096, t_cost: 1, p_cost: 1 };
crate::vault::create_with_params(&path, "pw", &kdf).unwrap();
let db = crate::vault::unlock(&path, "pw").unwrap();
{
let conn = db.conn();
let mut seed = mk_password_item("u", "p");
seed.title = "seed".into();
store::insert_item(conn, &mut seed).unwrap();
}
let mut app = App::from_unlocked(db, path.clone(), "pw".into()).unwrap();
let before = app.items.len();
app.handle_key(key('n')).unwrap();
assert!(matches!(app.mode, Mode::PickTemplate));
app.handle_key(key_code(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::NewItem(_)));
for c in "new".chars() {
app.handle_key(key(c)).unwrap();
}
app.handle_key(key_code(KeyCode::Tab)).unwrap();
for c in "bob".chars() {
app.handle_key(key(c)).unwrap();
}
app.handle_key(key_code(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Normal));
assert_eq!(app.items.len(), before + 1);
let db2 = crate::vault::unlock(&path, "pw").unwrap();
let cnt = db2
.conn()
.query_row("SELECT COUNT(*) FROM items", [], |r| r.get::<_, i64>(0))
.unwrap();
assert_eq!(cnt as usize, before + 1);
cleanup(&path);
}
#[test]
fn normal_q_returns_quit() {
let mut app = app_with_items(1);
let act = app.handle_key(key('q')).unwrap();
assert_eq!(act, Action::Quit);
assert!(app.quit);
}
#[test]
fn normal_l_locks_clears_db() {
let mut app = app_with_items(2);
app.handle_key(key('l')).unwrap();
assert!(app.db.is_none());
assert!(app.master_key.is_none());
assert!(app.items.is_empty());
assert!(matches!(app.mode, Mode::PromptPassphrase(PassKind::Open)));
}
#[test]
fn lock_keeps_path_and_enters_prompt_for_reunlock() {
let mut app = app_with_items(1);
let kept_path = app.path.clone();
app.lock();
assert_eq!(app.path, kept_path, "lock must not clear path");
assert!(matches!(app.mode, Mode::PromptPassphrase(PassKind::Open)));
assert!(app.db.is_none());
assert!(app.input_mask, "lock should enable passphrase masking");
}
#[test]
fn normal_y_does_not_panic_without_clipboard() {
let mut app = app_with_items(1);
app.selected = 0;
app.handle_key(key('y')).unwrap();
assert!(app.message.is_some());
}
#[test]
fn normal_o_handles_no_totp() {
let mut app = app_with_items(1);
app.selected = 0;
app.handle_key(key('o')).unwrap();
assert!(app.message.is_some());
assert!(
app.message.as_deref().unwrap_or("").contains("no totp secret"),
"expected no-totp hint, got {:?}",
app.message
);
}
#[test]
fn copy_totp_finds_totp_field() {
let db = Database::open_in_memory().unwrap();
{
let conn = db.conn();
let mut it = mk_item(
"password",
"T",
&[
("username", "u", FieldKind::Text),
("password", "p", FieldKind::Secret),
("url", "", FieldKind::Text),
("totp", "JBSWY3DPEHPK3PXP", FieldKind::Totp),
("notes", "", FieldKind::Multiline),
],
);
store::insert_item(conn, &mut it).unwrap();
}
let mut app =
App::from_unlocked(db, std::path::PathBuf::from("/tmp/zkv_unused.zkv"), "x".into())
.unwrap();
app.selected = 0;
app.copy_totp_of_selected();
assert!(
!app.message.as_deref().unwrap_or("").contains("no totp secret"),
"got {:?}",
app.message
);
}
#[test]
fn normal_jk_moves_selection() {
let mut app = app_with_items(3);
app.selected = 0;
app.handle_key(key('j')).unwrap();
assert_eq!(app.selected, 1);
app.handle_key(key('j')).unwrap();
assert_eq!(app.selected, 2);
app.handle_key(key('j')).unwrap();
assert_eq!(app.selected, 2);
app.handle_key(key('k')).unwrap();
assert_eq!(app.selected, 1);
}
#[test]
fn search_filters_items() {
let db = Database::open_in_memory().unwrap();
{
let conn = db.conn();
let mut a = mk_item(
"password",
"GitHub",
&[("username", "u", FieldKind::Text), ("password", "p", FieldKind::Secret)],
);
let mut b = mk_item(
"password",
"GitLab",
&[("username", "u", FieldKind::Text), ("password", "p", FieldKind::Secret)],
);
store::insert_item(conn, &mut a).unwrap();
store::insert_item(conn, &mut b).unwrap();
}
let mut app =
App::from_unlocked(db, std::path::PathBuf::from("/tmp/zkv_unused.zkv"), "x".into())
.unwrap();
assert_eq!(app.items.len(), 2);
app.handle_key(key('/')).unwrap();
assert!(matches!(app.mode, Mode::Search));
for c in "github".chars() {
app.handle_key(key(c)).unwrap();
}
app.handle_key(key_code(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Normal));
assert_eq!(app.items.len(), 1, "应只命中 GitHub");
assert_eq!(app.filter.query.as_deref(), Some("github"));
}
#[test]
fn search_esc_does_not_apply() {
let mut app = app_with_items(3);
app.handle_key(key('/')).unwrap();
for c in "zzz".chars() {
app.handle_key(key(c)).unwrap();
}
app.handle_key(key_code(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Normal));
assert!(app.filter.query.is_none());
assert_eq!(app.items.len(), 3);
}
#[test]
fn confirm_delete_y_removes_item() {
let path = tmp_path("del_item");
cleanup(&path);
let kdf = KdfParams { m_kib: 4096, t_cost: 1, p_cost: 1 };
crate::vault::create_with_params(&path, "pw", &kdf).unwrap();
let db = crate::vault::unlock(&path, "pw").unwrap();
{
let conn = db.conn();
let mut it = mk_password_item("u", "p");
it.title = "to-delete".into();
store::insert_item(conn, &mut it).unwrap();
}
let mut app = App::from_unlocked(db, path.clone(), "pw".into()).unwrap();
assert_eq!(app.items.len(), 1);
app.handle_key(key('x')).unwrap();
assert!(matches!(app.mode, Mode::ConfirmDelete));
app.handle_key(key('y')).unwrap();
assert!(matches!(app.mode, Mode::Normal));
assert_eq!(app.items.len(), 0);
cleanup(&path);
}
#[test]
fn editor_tab_cycles_cursors() {
let n = 5;
let next = App::next_cursor(n, &Cursor::Title);
assert_eq!(next, Cursor::Field(0));
let wrap = App::next_cursor(n, &Cursor::Field(4));
assert_eq!(wrap, Cursor::Title);
let prev = App::prev_cursor(n, &Cursor::Title);
assert_eq!(prev, Cursor::Field(4));
}
#[test]
fn write_and_backspace_field_by_cursor() {
let mut draft = mk_item(
"password",
"",
&[
("username", "", FieldKind::Text),
("password", "", FieldKind::Secret),
],
);
App::write_field(&mut draft, &Cursor::Title, 'A');
assert_eq!(draft.title, "A");
App::backspace_field(&mut draft, &Cursor::Title);
assert_eq!(draft.title, "");
App::write_field(&mut draft, &Cursor::Field(0), 'b');
assert_eq!(draft.fields[0].value, "b");
App::write_field(&mut draft, &Cursor::Field(1), 'x');
assert_eq!(draft.fields[1].value, "x");
App::backspace_field(&mut draft, &Cursor::Field(1));
assert_eq!(draft.fields[1].value, "");
App::write_field(&mut draft, &Cursor::Field(99), 'z');
App::backspace_field(&mut draft, &Cursor::Field(99));
}
#[test]
fn passphrase_wrong_stays_in_prompt() {
let path = tmp_path("wrong_pass");
cleanup(&path);
let kdf = KdfParams { m_kib: 4096, t_cost: 1, p_cost: 1 };
crate::vault::create_with_params(&path, "correct", &kdf).unwrap();
let mut app = App::for_open(path.clone());
for c in "wrong".chars() {
app.handle_key(key(c)).unwrap();
}
let act = app.handle_key(key_code(KeyCode::Enter)).unwrap();
assert_eq!(act, Action::Continue);
assert!(matches!(app.mode, Mode::PromptPassphrase(PassKind::Open)));
assert!(app.db.is_none());
assert!(app.message.is_some());
cleanup(&path);
}
#[test]
fn passphrase_correct_opens() {
let path = tmp_path("right_pass");
cleanup(&path);
let kdf = KdfParams { m_kib: 4096, t_cost: 1, p_cost: 1 };
crate::vault::create_with_params(&path, "correct", &kdf).unwrap();
let mut app = App::for_open(path.clone());
for c in "correct".chars() {
app.handle_key(key(c)).unwrap();
}
app.handle_key(key_code(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Normal));
assert!(app.db.is_some());
cleanup(&path);
}
fn app_with_path(tag: &str) -> (App, std::path::PathBuf) {
let path = tmp_path(tag);
cleanup(&path);
let kdf = KdfParams { m_kib: 4096, t_cost: 1, p_cost: 1 };
crate::vault::create_with_params(&path, "pw", &kdf).unwrap();
let db = crate::vault::unlock(&path, "pw").unwrap();
let app = App::from_unlocked(db, path.clone(), "pw".into()).unwrap();
(app, path)
}
#[test]
fn mgr_enter_resets_state() {
let (mut app, _p) = app_with_path("mgr_enter");
app.mgr_selected = 9;
app.mgr_edit = Some(MgrEdit::Add);
app.input = "junk".into();
app.handle_key(key('c')).unwrap();
assert!(matches!(app.mode, Mode::CategoryMgr));
assert_eq!(app.mgr_selected, 0);
assert_eq!(app.mgr_edit, None);
assert!(app.input.is_empty());
}
#[test]
fn category_mgr_add_works() {
let (mut app, path) = app_with_path("cat_add");
app.handle_key(key('c')).unwrap();
assert!(matches!(app.mode, Mode::CategoryMgr));
app.handle_key(key('a')).unwrap();
assert_eq!(app.mgr_edit, Some(MgrEdit::Add));
for c in "Work".chars() {
app.handle_key(key(c)).unwrap();
}
app.handle_key(key_code(KeyCode::Enter)).unwrap();
assert!(app.categories.iter().any(|c| c.name == "Work"));
assert!(matches!(app.mode, Mode::CategoryMgr));
assert_eq!(app.mgr_edit, None);
let db2 = crate::vault::unlock(&path, "pw").unwrap();
let cats = store::list_categories(db2.conn()).unwrap();
assert!(cats.iter().any(|c| c.name == "Work"));
cleanup(&path);
}
#[test]
fn category_mgr_add_empty_name_aborts() {
let (mut app, path) = app_with_path("cat_add_empty");
app.handle_key(key('c')).unwrap();
app.handle_key(key('a')).unwrap();
app.handle_key(key_code(KeyCode::Enter)).unwrap();
assert!(app.categories.is_empty());
assert!(app.message.as_deref().unwrap_or("").contains("empty"));
cleanup(&path);
}
#[test]
fn category_mgr_rename_works() {
let (mut app, path) = app_with_path("cat_rename");
{
let conn = app.db.as_ref().unwrap().conn();
let mut cat = crate::model::Category {
id: None,
name: "Old".into(),
parent_id: None,
sort_order: 0,
};
store::insert_category(conn, &mut cat).unwrap();
}
app.reload().unwrap();
app.mgr_clamp(MgrEntity::Category);
app.handle_key(key('c')).unwrap();
app.mgr_selected = 0;
app.handle_key(key('r')).unwrap();
assert_eq!(app.mgr_edit, Some(MgrEdit::Rename));
assert_eq!(app.input, "Old");
app.input.clear();
for c in "New".chars() {
app.handle_key(key(c)).unwrap();
}
app.handle_key(key_code(KeyCode::Enter)).unwrap();
assert!(app.categories.iter().any(|c| c.name == "New"));
assert!(!app.categories.iter().any(|c| c.name == "Old"));
cleanup(&path);
}
#[test]
fn category_mgr_delete_works() {
let (mut app, path) = app_with_path("cat_del");
{
let conn = app.db.as_ref().unwrap().conn();
let mut cat = crate::model::Category {
id: None,
name: "Tmp".into(),
parent_id: None,
sort_order: 0,
};
store::insert_category(conn, &mut cat).unwrap();
}
app.reload().unwrap();
app.handle_key(key('c')).unwrap();
app.mgr_selected = 0;
app.handle_key(key('x')).unwrap();
assert!(app.categories.is_empty());
assert!(app.message.as_deref().unwrap_or("").contains("deleted"));
cleanup(&path);
}
#[test]
fn tag_mgr_add_works() {
let (mut app, path) = app_with_path("tag_add");
app.handle_key(key('t')).unwrap();
assert!(matches!(app.mode, Mode::TagMgr));
app.handle_key(key('a')).unwrap();
for c in "vip".chars() {
app.handle_key(key(c)).unwrap();
}
app.handle_key(key_code(KeyCode::Enter)).unwrap();
assert!(app.tags.iter().any(|t| t == "vip"));
cleanup(&path);
}
#[test]
fn tag_mgr_rename_works() {
let (mut app, path) = app_with_path("tag_rename");
{
let conn = app.db.as_ref().unwrap().conn();
store::ensure_tag(conn, "old").unwrap();
}
app.reload().unwrap();
app.handle_key(key('t')).unwrap();
app.mgr_selected = 0;
app.handle_key(key('r')).unwrap();
assert_eq!(app.input, "old");
app.input.clear();
for c in "fresh".chars() {
app.handle_key(key(c)).unwrap();
}
app.handle_key(key_code(KeyCode::Enter)).unwrap();
assert!(app.tags.iter().any(|t| t == "fresh"));
assert!(!app.tags.iter().any(|t| t == "old"));
cleanup(&path);
}
#[test]
fn tag_mgr_delete_works() {
let (mut app, path) = app_with_path("tag_del");
{
let conn = app.db.as_ref().unwrap().conn();
store::ensure_tag(conn, "gone").unwrap();
}
app.reload().unwrap();
app.handle_key(key('t')).unwrap();
app.mgr_selected = 0;
app.handle_key(key('x')).unwrap();
assert!(app.tags.is_empty());
cleanup(&path);
}
#[test]
fn mgr_edit_esc_cancels() {
let (mut app, path) = app_with_path("mgr_esc");
app.handle_key(key('c')).unwrap();
app.handle_key(key('a')).unwrap();
for c in "Z".chars() {
app.handle_key(key(c)).unwrap();
}
app.handle_key(key_code(KeyCode::Esc)).unwrap();
assert_eq!(app.mgr_edit, None);
assert!(app.input.is_empty());
assert!(matches!(app.mode, Mode::CategoryMgr));
assert!(app.categories.is_empty());
cleanup(&path);
}
#[test]
fn mgr_browse_esc_returns_normal() {
let (mut app, _p) = app_with_path("mgr_back");
app.handle_key(key('t')).unwrap();
app.handle_key(key_code(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Normal));
}
#[test]
fn mgr_browse_jk_moves_selection() {
let (mut app, _p) = app_with_path("mgr_jk");
{
let conn = app.db.as_ref().unwrap().conn();
for n in ["a", "b", "c"] {
store::ensure_tag(conn, n).unwrap();
}
}
app.reload().unwrap();
app.handle_key(key('t')).unwrap();
assert_eq!(app.mgr_selected, 0);
app.handle_key(key('j')).unwrap();
assert_eq!(app.mgr_selected, 1);
app.handle_key(key('j')).unwrap();
assert_eq!(app.mgr_selected, 2);
app.handle_key(key('j')).unwrap();
assert_eq!(app.mgr_selected, 2);
app.handle_key(key('k')).unwrap();
assert_eq!(app.mgr_selected, 1);
}
fn app_with_one_item(tag: &str) -> (App, std::path::PathBuf, i64) {
let (mut app, path) = app_with_path(tag);
let mut it = mk_password_item("u", "p");
it.title = "att-host".into();
let conn = app.db.as_ref().unwrap().conn();
store::insert_item(conn, &mut it).unwrap();
let id = it.id.unwrap();
app.reload().unwrap();
app.selected = 0;
(app, path, id)
}
#[test]
fn attachments_a_enters_mode_and_targets_selected() {
let (mut app, _p, id) = app_with_one_item("att_enter");
app.handle_key(key('a')).unwrap();
assert!(matches!(app.mode, Mode::Attachments));
assert_eq!(app.att_item_id, Some(id));
assert_eq!(app.att_selected, 0);
assert!(app.att_edit.is_none());
assert!(app.att_list.is_empty());
}
#[test]
fn attachments_a_without_item_errors() {
let (mut app, path, _id) = app_with_one_item("att_noitem");
{
let conn = app.db.as_ref().unwrap().conn();
store::delete_item(conn, app.items[0].id.unwrap()).unwrap();
}
app.reload().unwrap();
app.handle_key(key('a')).unwrap();
assert!(matches!(app.mode, Mode::Normal));
assert!(app.message.as_deref().unwrap_or("").contains("no item"));
cleanup(&path);
}
#[test]
fn attachments_add_inserts_and_lists() {
let (mut app, path, _id) = app_with_one_item("att_add");
let src = std::env::temp_dir().join(format!(
"zkv_att_src_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::write(&src, b"hello-bytes").unwrap();
app.handle_key(key('a')).unwrap();
app.handle_key(key('a')).unwrap();
assert_eq!(app.att_edit, Some(AttEdit::Add));
for c in src.to_string_lossy().chars() {
app.handle_key(key(c)).unwrap();
}
app.handle_key(key_code(KeyCode::Enter)).unwrap();
assert_eq!(app.att_edit, None);
assert!(app.input.is_empty());
assert_eq!(app.att_list.len(), 1);
let fname = src.file_name().unwrap().to_string_lossy().to_string();
assert_eq!(app.att_list[0].filename, fname);
assert_eq!(app.att_list[0].size, 11);
assert!(
app.message.as_deref().unwrap_or("").contains("attached"),
"got {:?}",
app.message
);
let db2 = crate::vault::unlock(&path, "pw").unwrap();
let cnt: i64 = db2
.conn()
.query_row(
"SELECT COUNT(*) FROM attachments",
[],
|r| r.get::<_, i64>(0),
)
.unwrap();
assert_eq!(cnt, 1);
let _ = std::fs::remove_file(&src);
cleanup(&path);
}
#[test]
fn attachments_export_roundtrips_blob() {
let (mut app, path, _id) = app_with_one_item("att_export");
let blob = b"binary-payload-123".to_vec();
let src = std::env::temp_dir().join(format!(
"zkv_att_src2_{}_{}.bin",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::write(&src, &blob).unwrap();
app.handle_key(key('a')).unwrap();
app.handle_key(key('a')).unwrap();
for c in src.to_string_lossy().chars() {
app.handle_key(key(c)).unwrap();
}
app.handle_key(key_code(KeyCode::Enter)).unwrap();
assert_eq!(app.att_list.len(), 1);
let out = std::env::temp_dir().join(format!(
"zkv_att_out_{}_{}.bin",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
app.handle_key(key('e')).unwrap();
assert_eq!(app.att_edit, Some(AttEdit::Export));
for c in out.to_string_lossy().chars() {
app.handle_key(key(c)).unwrap();
}
app.handle_key(key_code(KeyCode::Enter)).unwrap();
assert_eq!(app.att_edit, None);
assert!(app.message.as_deref().unwrap_or("").contains("wrote"));
let read_back = std::fs::read(&out).unwrap();
assert_eq!(read_back, blob);
let _ = std::fs::remove_file(&src);
let _ = std::fs::remove_file(&out);
cleanup(&path);
}
#[test]
fn attachments_delete_empties_list() {
let (mut app, path, _id) = app_with_one_item("att_del");
let src = std::env::temp_dir().join(format!(
"zkv_att_del_{}_{}.txt",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::write(&src, b"x").unwrap();
app.handle_key(key('a')).unwrap();
app.handle_key(key('a')).unwrap();
for c in src.to_string_lossy().chars() {
app.handle_key(key(c)).unwrap();
}
app.handle_key(key_code(KeyCode::Enter)).unwrap();
assert_eq!(app.att_list.len(), 1);
app.handle_key(key('x')).unwrap();
assert!(app.att_list.is_empty());
assert!(app.message.as_deref().unwrap_or("").contains("deleted"));
let _ = std::fs::remove_file(&src);
cleanup(&path);
}
#[test]
fn attachments_add_esc_cancels() {
let (mut app, path, _id) = app_with_one_item("att_esc");
app.handle_key(key('a')).unwrap();
app.handle_key(key('a')).unwrap();
for c in "nope.txt".chars() {
app.handle_key(key(c)).unwrap();
}
app.handle_key(key_code(KeyCode::Esc)).unwrap();
assert_eq!(app.att_edit, None);
assert!(app.input.is_empty());
assert!(app.att_list.is_empty(), "取消不应提交");
assert!(matches!(app.mode, Mode::Attachments));
cleanup(&path);
}
#[test]
fn attachments_jk_moves_selection() {
let (mut app, path, _id) = app_with_one_item("att_jk");
{
let conn = app.db.as_ref().unwrap().conn();
for i in 0..2 {
let mut a = crate::model::Attachment {
id: None,
item_id: app.att_item_id.unwrap_or_else(|| app.items[0].id.unwrap()),
filename: format!("f{i}.bin"),
mime_type: None,
size: 0,
blob: vec![i],
};
store::insert_attachment(conn, &mut a).unwrap();
}
}
app.handle_key(key('a')).unwrap();
assert_eq!(app.att_list.len(), 2);
assert_eq!(app.att_selected, 0);
app.handle_key(key('j')).unwrap();
assert_eq!(app.att_selected, 1);
app.handle_key(key('j')).unwrap();
assert_eq!(app.att_selected, 1);
app.handle_key(key('k')).unwrap();
assert_eq!(app.att_selected, 0);
cleanup(&path);
}
fn tmp_path(tag: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static C: AtomicU64 = AtomicU64::new(0);
let n = C.fetch_add(1, Ordering::Relaxed);
let mut p = std::env::temp_dir();
p.push(format!("zkv_app_{tag}_{}_{}", std::process::id(), n));
p
}
fn cleanup(p: &std::path::Path) {
let _ = std::fs::remove_file(p);
let mut t = p.as_os_str().to_owned();
t.push(".tmp");
let _ = std::fs::remove_file(std::path::PathBuf::from(t));
}
}