mod theme;
mod ui;
use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use ratatui::widgets::TableState;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::crypto::VaultKey;
use crate::meta::{AccessConfig, AllowAgent, LoginMethod, VaultMeta, VaultSettings};
use crate::policy::SecretRule;
use crate::session;
use crate::vault::{list_vault_dirs, Vault, SVAULT_DIR};
pub fn run() -> Result<()> {
crate::usage::set_source(crate::usage::Source::Tui);
let mut terminal = ratatui::init();
let _ = crossterm::execute!(std::io::stdout(), crossterm::event::EnableBracketedPaste);
let mut app = App::new();
let result = app.event_loop(&mut terminal);
let _ = crossterm::execute!(std::io::stdout(), crossterm::event::DisableBracketedPaste);
ratatui::restore();
result
}
#[derive(Clone, Copy, PartialEq)]
pub enum MsgKind {
Info,
Ok,
Warn,
Error,
}
pub struct Status {
pub kind: MsgKind,
pub text: String,
}
#[derive(Clone)]
pub struct VaultRow {
pub name: String,
pub storage: String,
pub dir: PathBuf,
pub description: String,
pub created: String,
pub unlocked: bool,
}
fn load_vaults() -> Vec<VaultRow> {
list_vault_dirs()
.into_iter()
.filter_map(|dir| {
let meta = VaultMeta::load_unverified(&dir).ok()?;
let unlocked = session::is_unlocked(&dir);
Some(VaultRow {
name: meta.name,
storage: meta.storage,
dir,
description: meta.description,
created: short_date(&meta.created_at),
unlocked,
})
})
.collect()
}
fn short_date(rfc3339: &str) -> String {
chrono::DateTime::parse_from_rfc3339(rfc3339)
.map(|t| {
t.with_timezone(&chrono::Local)
.format("%Y-%m-%d")
.to_string()
})
.unwrap_or_else(|_| rfc3339.chars().take(10).collect())
}
#[derive(Clone, Copy)]
pub enum Pending {
List,
Secrets,
Settings,
FinishImport,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum CreateField {
Name,
Description,
AllowMode,
AllowList,
RateLimit,
Autolock,
AutolockTimer,
DefaultTier,
Judge,
Passphrase,
Confirm,
}
impl CreateField {
pub const ORDER: [CreateField; 11] = [
CreateField::Name,
CreateField::Description,
CreateField::AllowMode,
CreateField::AllowList,
CreateField::RateLimit,
CreateField::Autolock,
CreateField::AutolockTimer,
CreateField::DefaultTier,
CreateField::Judge,
CreateField::Passphrase,
CreateField::Confirm,
];
}
pub struct CreateForm {
pub name: String,
pub description: String,
pub allow_mode: usize, pub allow_list: String,
pub rate_limit: String,
pub autolock: bool,
pub autolock_timer: String,
pub default_tier: usize, pub judge: bool,
pub passphrase: String,
pub confirm: String,
pub focus: usize,
pub error: Option<String>,
}
impl CreateForm {
const FIELDS: usize = CreateField::ORDER.len();
fn new() -> Self {
let default_name = std::env::current_dir()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
.unwrap_or_else(|| "my-vault".to_string());
Self {
name: default_name,
description: String::new(),
allow_mode: 0,
allow_list: String::new(),
rate_limit: "10/hour".to_string(),
autolock: true,
autolock_timer: "1d".to_string(),
default_tier: 0,
judge: false,
passphrase: String::new(),
confirm: String::new(),
focus: 0,
error: None,
}
}
pub fn current(&self) -> CreateField {
CreateField::ORDER[self.focus]
}
pub fn focus_is_text(&self) -> bool {
!matches!(
self.current(),
CreateField::AllowMode
| CreateField::Autolock
| CreateField::DefaultTier
| CreateField::Judge
)
}
fn text_field(&mut self) -> Option<&mut String> {
Some(match self.current() {
CreateField::Name => &mut self.name,
CreateField::Description => &mut self.description,
CreateField::AllowList => &mut self.allow_list,
CreateField::RateLimit => &mut self.rate_limit,
CreateField::AutolockTimer => &mut self.autolock_timer,
CreateField::Passphrase => &mut self.passphrase,
CreateField::Confirm => &mut self.confirm,
_ => return None,
})
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum SettingsField {
Description,
AllowMode,
AllowList,
RateLimit,
Autolock,
AutolockTimer,
DefaultTier,
Judge,
}
impl SettingsField {
pub const ORDER: [SettingsField; 8] = [
SettingsField::Description,
SettingsField::AllowMode,
SettingsField::AllowList,
SettingsField::RateLimit,
SettingsField::Autolock,
SettingsField::AutolockTimer,
SettingsField::DefaultTier,
SettingsField::Judge,
];
}
pub struct SettingsForm {
pub vault_dir: PathBuf,
pub name: String,
pub description: String,
pub allow_mode: usize,
pub allow_list: String,
pub rate_limit: String,
pub autolock: bool,
pub autolock_timer: String,
pub default_tier: usize,
pub judge: bool,
pub focus: usize,
pub error: Option<String>,
}
impl SettingsForm {
const FIELDS: usize = SettingsField::ORDER.len();
fn from_meta(
vault_dir: PathBuf,
meta: VaultMeta,
policy: &crate::policy::VaultPolicyData,
) -> Self {
let (allow_mode, allow_list) = match &policy.access.allow_agent {
AllowAgent::Bool(true) => (0, String::new()),
AllowAgent::Bool(false) => (1, String::new()),
AllowAgent::List(v) => (2, v.join(", ")),
};
Self {
vault_dir,
name: meta.name,
description: meta.description,
allow_mode,
allow_list,
rate_limit: policy.access.rate_limit.clone(),
autolock: meta.settings.autolock,
autolock_timer: meta.settings.autolock_timer,
default_tier: tier_idx(policy.default_tier),
judge: policy.judge.enabled.unwrap_or(false),
focus: 0,
error: None,
}
}
pub fn current(&self) -> SettingsField {
SettingsField::ORDER[self.focus]
}
pub fn focus_is_text(&self) -> bool {
!matches!(
self.current(),
SettingsField::AllowMode
| SettingsField::Autolock
| SettingsField::DefaultTier
| SettingsField::Judge
)
}
fn text_field(&mut self) -> Option<&mut String> {
Some(match self.current() {
SettingsField::Description => &mut self.description,
SettingsField::AllowList => &mut self.allow_list,
SettingsField::RateLimit => &mut self.rate_limit,
SettingsField::AutolockTimer => &mut self.autolock_timer,
_ => return None,
})
}
}
pub struct UnlockForm {
pub vault_dir: PathBuf,
pub name: String,
pub passphrase: String,
pub error: Option<String>,
pub pending: Pending,
}
pub struct Reveal {
pub name: String,
pub value: zeroize::Zeroizing<String>,
pub masked: bool,
}
pub struct SecretScreen {
pub vault_dir: PathBuf,
pub name: String,
pub secrets: Vec<String>,
pub classifications: BTreeMap<String, SecretRule>,
pub default_tier: usize,
pub list_state: TableState,
pub reveal: Option<Reveal>,
pub pending_delete: Option<String>,
}
impl SecretScreen {
fn selected_name(&self) -> Option<String> {
self.list_state
.selected()
.and_then(|i| self.secrets.get(i).cloned())
}
}
pub struct SecretAddForm {
pub vault_dir: PathBuf,
pub vault_name: String,
pub name: String,
pub value: String,
pub scope: String,
pub description: String,
pub tier: usize, pub require_reason: bool,
pub focus: usize, pub error: Option<String>,
}
impl SecretAddForm {
const FIELDS: usize = 6;
fn focus_is_text(&self) -> bool {
self.focus < 4
}
}
pub struct ClassifyForm {
pub vault_dir: PathBuf,
pub vault_name: String,
pub secret: String,
pub scope: String,
pub description: String,
pub tier: usize, pub require_reason: bool,
pub focus: usize, pub error: Option<String>,
}
impl ClassifyForm {
const FIELDS: usize = 4;
fn focus_is_text(&self) -> bool {
self.focus < 2
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum JudgeField {
Enabled,
Model,
AllowThreshold,
HighThreshold,
Timeout,
ApiKey,
Test,
Save,
}
impl JudgeField {
pub const ORDER: [JudgeField; 8] = [
JudgeField::Enabled,
JudgeField::Model,
JudgeField::AllowThreshold,
JudgeField::HighThreshold,
JudgeField::Timeout,
JudgeField::ApiKey,
JudgeField::Test,
JudgeField::Save,
];
}
pub struct JudgeForm {
pub enabled: bool,
pub model: String,
pub allow_threshold: String,
pub high_threshold: String,
pub timeout: String,
pub key_status: String,
pub focus: usize,
pub error: Option<String>,
pub test_result: Option<(MsgKind, String)>,
pub key_entry: Option<String>,
}
impl JudgeForm {
const FIELDS: usize = JudgeField::ORDER.len();
fn load() -> Self {
let cfg = crate::config::SvaultConfig::load();
let j = &cfg.judge;
Self {
enabled: j.enabled,
model: j.model.clone(),
allow_threshold: j.allow_threshold.to_string(),
high_threshold: j.high_threshold.to_string(),
timeout: j.timeout_secs.to_string(),
key_status: key_status_line(j),
focus: 0,
error: None,
test_result: None,
key_entry: None,
}
}
pub fn current(&self) -> JudgeField {
JudgeField::ORDER[self.focus]
}
pub fn focus_is_text(&self) -> bool {
matches!(
self.current(),
JudgeField::Model
| JudgeField::AllowThreshold
| JudgeField::HighThreshold
| JudgeField::Timeout
)
}
fn text_field(&mut self) -> Option<&mut String> {
Some(match self.current() {
JudgeField::Model => &mut self.model,
JudgeField::AllowThreshold => &mut self.allow_threshold,
JudgeField::HighThreshold => &mut self.high_threshold,
JudgeField::Timeout => &mut self.timeout,
_ => return None,
})
}
}
fn key_status_line(cfg: &crate::config::JudgeConfig) -> String {
match crate::config::key_source(cfg) {
crate::config::KeySource::Env => {
format!("from ${} (environment)", crate::config::KEY_ENV)
}
crate::config::KeySource::File(p) => format!("present ({})", p.display()),
crate::config::KeySource::None => "none — press enter to set".to_string(),
}
}
pub struct ImportForm {
pub path: String,
pub error: Option<String>,
}
pub struct RecoverForm {
pub vault_dir: PathBuf,
pub name: String,
pub code: String,
pub new_pass: String,
pub confirm: String,
pub focus: usize, pub error: Option<String>,
}
impl RecoverForm {
const FIELDS: usize = 3;
fn field_mut(&mut self) -> &mut String {
match self.focus {
0 => &mut self.code,
1 => &mut self.new_pass,
_ => &mut self.confirm,
}
}
}
pub struct ActivityScreen {
pub name: String,
pub events: Vec<crate::usage::Event>,
pub state: TableState,
}
pub enum Screen {
List,
Create(CreateForm),
Settings(SettingsForm),
Unlock(UnlockForm),
Secrets(SecretScreen),
SecretAdd(SecretAddForm),
RecoveryCode(String),
Import(ImportForm),
Recover(RecoverForm),
Activity(ActivityScreen),
Classify(ClassifyForm),
Judge(JudgeForm),
}
pub struct App {
pub screen: Screen,
pub vaults: Vec<VaultRow>,
pub list_state: TableState,
pub status: Option<Status>,
pub should_quit: bool,
pub show_help: bool,
pub confirm_quit: bool,
pub daemon_running: bool,
}
impl App {
fn new() -> Self {
let vaults = load_vaults();
let mut list_state = TableState::default();
if !vaults.is_empty() {
list_state.select(Some(0));
}
let daemon_running = crate::daemon::is_running(&crate::daemon::base_dir());
Self {
screen: Screen::List,
vaults,
list_state,
status: None,
should_quit: false,
show_help: false,
confirm_quit: false,
daemon_running,
}
}
fn event_loop(&mut self, terminal: &mut ratatui::DefaultTerminal) -> Result<()> {
while !self.should_quit {
terminal.draw(|frame| ui::draw(frame, self))?;
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => self.on_key(key)?,
Event::Paste(text) => self.on_paste(text),
_ => {}
}
}
Ok(())
}
fn on_paste(&mut self, text: String) {
if self.show_help {
return;
}
let text = text.replace(['\n', '\r'], "");
if text.is_empty() {
return;
}
match &mut self.screen {
Screen::Create(form) => {
if let Some(s) = form.text_field() {
s.push_str(&text);
form.error = None;
}
}
Screen::Settings(form) => {
if let Some(s) = form.text_field() {
s.push_str(&text);
form.error = None;
}
}
Screen::Unlock(form) => {
form.passphrase.push_str(&text);
form.error = None;
}
Screen::SecretAdd(form) => {
if form.focus == 0 {
form.name.push_str(&text);
} else {
form.value.push_str(&text);
}
form.error = None;
}
Screen::Import(form) => {
form.path.push_str(&text);
form.error = None;
}
Screen::Recover(form) => {
form.field_mut().push_str(&text);
form.error = None;
}
Screen::Classify(form) => {
match form.focus {
0 => form.scope.push_str(&text),
1 => form.description.push_str(&text),
_ => {}
}
form.error = None;
}
Screen::Judge(form) => {
if let Some(buf) = form.key_entry.as_mut() {
buf.push_str(&text);
} else if let Some(s) = form.text_field() {
s.push_str(&text);
form.error = None;
}
}
_ => {}
}
}
fn set_status(&mut self, kind: MsgKind, text: impl Into<String>) {
self.status = Some(Status {
kind,
text: text.into(),
});
}
fn refresh_vaults(&mut self) {
self.vaults = load_vaults();
if self.vaults.is_empty() {
self.list_state.select(None);
} else {
let i = self
.list_state
.selected()
.unwrap_or(0)
.min(self.vaults.len() - 1);
self.list_state.select(Some(i));
}
self.refresh_daemon();
}
fn refresh_daemon(&mut self) {
self.daemon_running = crate::daemon::is_running(&crate::daemon::base_dir());
}
fn toggle_daemon(&mut self) {
let result = if self.daemon_running {
crate::daemon::stop_quiet()
} else {
crate::daemon::start_quiet()
};
match result {
Ok(msg) => self.set_status(MsgKind::Ok, msg),
Err(e) => self.set_status(MsgKind::Error, format!("{e}")),
}
self.refresh_daemon();
}
fn selected_vault(&self) -> Option<VaultRow> {
self.list_state
.selected()
.and_then(|i| self.vaults.get(i).cloned())
}
fn select_next(&mut self) {
if self.vaults.is_empty() {
return;
}
let i = self
.list_state
.selected()
.map_or(0, |i| (i + 1) % self.vaults.len());
self.list_state.select(Some(i));
}
fn select_prev(&mut self) {
if self.vaults.is_empty() {
return;
}
let len = self.vaults.len();
let i = self
.list_state
.selected()
.map_or(0, |i| (i + len - 1) % len);
self.list_state.select(Some(i));
}
fn on_key(&mut self, key: KeyEvent) -> Result<()> {
if self.confirm_quit {
match key.code {
KeyCode::Enter => self.should_quit = true,
_ => self.confirm_quit = false,
}
return Ok(());
}
if self.show_help {
self.show_help = false;
return Ok(());
}
let screen = std::mem::replace(&mut self.screen, Screen::List);
match screen {
Screen::List => self.key_list(key)?,
Screen::Create(form) => self.key_create(form, key)?,
Screen::Settings(form) => self.key_settings(form, key)?,
Screen::Unlock(form) => self.key_unlock(form, key)?,
Screen::Secrets(scr) => self.key_secrets(scr, key)?,
Screen::SecretAdd(form) => self.key_secret_add(form, key)?,
Screen::RecoveryCode(code) => self.key_recovery_code(code, key),
Screen::Import(form) => self.key_import(form, key)?,
Screen::Recover(form) => self.key_recover(form, key),
Screen::Activity(scr) => self.key_activity(scr, key),
Screen::Classify(form) => self.key_classify(form, key)?,
Screen::Judge(form) => self.key_judge(form, key)?,
}
Ok(())
}
fn key_activity(&mut self, mut scr: ActivityScreen, key: KeyEvent) {
match key.code {
KeyCode::Esc | KeyCode::Char('b') => {
self.screen = Screen::List;
return;
}
KeyCode::Char('q') => self.confirm_quit = true,
KeyCode::Down | KeyCode::Char('j') => activity_move(&mut scr, true),
KeyCode::Up | KeyCode::Char('k') => activity_move(&mut scr, false),
_ => {}
}
self.screen = Screen::Activity(scr);
}
fn start_activity(&mut self) {
let Some(v) = self.selected_vault() else {
return;
};
let mut events = crate::usage::recent(&v.dir, 200);
events.extend(crate::usage::recent(Path::new(SVAULT_DIR), 200));
events.sort_by(|a, b| b.ts.cmp(&a.ts));
events.truncate(200);
let mut state = TableState::default();
if !events.is_empty() {
state.select(Some(0));
}
self.screen = Screen::Activity(ActivityScreen {
name: v.name,
events,
state,
});
}
fn key_recovery_code(&mut self, code: String, key: KeyEvent) {
if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
self.screen = Screen::List;
} else {
self.screen = Screen::RecoveryCode(code);
}
}
fn key_import(&mut self, mut form: ImportForm, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => self.screen = Screen::List,
KeyCode::Backspace => {
form.path.pop();
form.error = None;
self.screen = Screen::Import(form);
}
KeyCode::Char(c) => {
form.path.push(c);
form.error = None;
self.screen = Screen::Import(form);
}
KeyCode::Enter => {
let path = form.path.trim();
if path.is_empty() {
form.error = Some("Enter a path to a .svault-export.json file".into());
self.screen = Screen::Import(form);
return Ok(());
}
let base = std::path::Path::new(SVAULT_DIR);
let result = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("cannot read {path}: {e}"))
.and_then(|raw| {
let bundle = crate::portable::parse_bundle(&raw)?;
let target = crate::portable::unique_vault_name(base, &bundle.name);
crate::portable::import_bundle_as(&raw, base, &target)?;
Ok((bundle.name, target))
});
match result {
Ok((orig, target)) => {
let dir = base.join(&target);
crate::usage::human(&dir, "import", None);
if target == orig {
self.refresh_vaults();
self.set_status(MsgKind::Ok, format!("Imported '{target}'"));
self.screen = Screen::List;
} else {
self.set_status(
MsgKind::Info,
format!("'{orig}' exists — importing as '{target}'; enter passphrase to finish"),
);
self.screen = Screen::Unlock(UnlockForm {
vault_dir: dir,
name: target,
passphrase: String::new(),
error: None,
pending: Pending::FinishImport,
});
}
}
Err(e) => {
form.error = Some(format!("{e}"));
self.screen = Screen::Import(form);
}
}
}
_ => self.screen = Screen::Import(form),
}
Ok(())
}
fn key_recover(&mut self, mut form: RecoverForm, key: KeyEvent) {
match key.code {
KeyCode::Esc => self.screen = Screen::List,
KeyCode::Tab | KeyCode::Down => {
form.focus = (form.focus + 1) % RecoverForm::FIELDS;
self.screen = Screen::Recover(form);
}
KeyCode::Up => {
form.focus = (form.focus + RecoverForm::FIELDS - 1) % RecoverForm::FIELDS;
self.screen = Screen::Recover(form);
}
KeyCode::Backspace => {
form.field_mut().pop();
form.error = None;
self.screen = Screen::Recover(form);
}
KeyCode::Char(c) => {
form.field_mut().push(c);
form.error = None;
self.screen = Screen::Recover(form);
}
KeyCode::Enter => {
if form.focus < RecoverForm::FIELDS - 1 {
form.focus += 1;
self.screen = Screen::Recover(form);
return;
}
self.submit_recover(form);
}
_ => self.screen = Screen::Recover(form),
}
}
fn submit_recover(&mut self, mut form: RecoverForm) {
if let Err(e) = crate::passphrase::meets_floor(&form.new_pass) {
form.error = Some(e);
form.new_pass.clear();
form.confirm.clear();
form.focus = 1;
self.screen = Screen::Recover(form);
return;
}
if form.new_pass != form.confirm {
form.error = Some("Passphrases do not match".into());
form.new_pass.clear();
form.confirm.clear();
form.focus = 1;
self.screen = Screen::Recover(form);
return;
}
match crate::recovery::recover_and_rekey(&form.vault_dir, &form.code, &form.new_pass) {
Ok(_) => {
crate::usage::human(&form.vault_dir, "recover", None);
session::lock(&form.vault_dir).ok();
self.refresh_vaults();
self.set_status(
MsgKind::Ok,
format!(
"Passphrase reset for '{}'. Recovery code unchanged.",
form.name
),
);
self.screen = Screen::List;
}
Err(e) => {
form.error = Some(format!("{e}"));
form.code.clear();
form.focus = 0;
self.screen = Screen::Recover(form);
}
}
}
fn key_list(&mut self, key: KeyEvent) -> Result<()> {
self.screen = Screen::List;
match key.code {
KeyCode::Char('q') | KeyCode::Esc => self.confirm_quit = true,
KeyCode::Down | KeyCode::Char('j') => self.select_next(),
KeyCode::Up | KeyCode::Char('k') => self.select_prev(),
KeyCode::Char('c') => self.screen = Screen::Create(CreateForm::new()),
KeyCode::Char('u') => self.unlock_selected()?,
KeyCode::Char('l') => self.lock_selected()?,
KeyCode::Char('s') => self.open_settings()?,
KeyCode::Char('e') => self.export_selected(),
KeyCode::Char('i') => {
self.screen = Screen::Import(ImportForm {
path: String::new(),
error: None,
})
}
KeyCode::Char('r') => self.start_recover(),
KeyCode::Char('v') => self.start_activity(),
KeyCode::Char('d') => self.toggle_daemon(),
KeyCode::Char('J') => self.screen = Screen::Judge(JudgeForm::load()),
KeyCode::Char('?') | KeyCode::Char('h') => self.show_help = true,
KeyCode::Enter => self.open_secrets()?,
_ => {}
}
Ok(())
}
fn export_selected(&mut self) {
let Some(v) = self.selected_vault() else {
return;
};
let meta = match VaultMeta::load_unverified(&v.dir) {
Ok(m) => m,
Err(e) => {
self.set_status(MsgKind::Error, format!("Cannot read vault: {e}"));
return;
}
};
match crate::portable::build_bundle(&v.dir, &meta.name, &meta.storage) {
Ok(json) => {
let ts = chrono::Local::now().format("%Y%m%d-%H%M%S");
let out = format!("{}-{}.svault-export.json", meta.name, ts);
match crate::secfile::write_owner_only(Path::new(&out), json.as_bytes()) {
Ok(_) => {
crate::portable::ensure_export_gitignored(Path::new("."));
let shown = std::fs::canonicalize(&out)
.map(|p| p.display().to_string())
.unwrap_or(out);
crate::usage::human(&v.dir, "export", None);
self.set_status(MsgKind::Ok, format!("Exported '{}' to {shown}", v.name))
}
Err(e) => self.set_status(MsgKind::Error, format!("Export failed: {e}")),
}
}
Err(e) => self.set_status(MsgKind::Error, format!("Export failed: {e}")),
}
}
fn start_recover(&mut self) {
let Some(v) = self.selected_vault() else {
return;
};
if !crate::recovery::exists(&v.dir) {
self.set_status(
MsgKind::Error,
format!("Vault '{}' has no recovery file", v.name),
);
return;
}
self.screen = Screen::Recover(RecoverForm {
vault_dir: v.dir,
name: v.name,
code: String::new(),
new_pass: String::new(),
confirm: String::new(),
focus: 0,
error: None,
});
}
fn unlock_selected(&mut self) -> Result<()> {
let Some(v) = self.selected_vault() else {
return Ok(());
};
if v.unlocked {
self.set_status(
MsgKind::Info,
format!("Vault '{}' is already unlocked", v.name),
);
} else {
self.screen = Screen::Unlock(UnlockForm {
vault_dir: v.dir,
name: v.name,
passphrase: String::new(),
error: None,
pending: Pending::List,
});
}
Ok(())
}
fn lock_selected(&mut self) -> Result<()> {
let Some(v) = self.selected_vault() else {
return Ok(());
};
if !v.unlocked {
self.set_status(
MsgKind::Info,
format!("Vault '{}' is already locked", v.name),
);
return Ok(());
}
session::lock(&v.dir)?;
crate::usage::human(&v.dir, "lock", None);
self.set_status(MsgKind::Ok, format!("Vault '{}' locked", v.name));
self.refresh_vaults();
Ok(())
}
fn open_secrets(&mut self) -> Result<()> {
let Some(v) = self.selected_vault() else {
return Ok(());
};
if v.unlocked {
self.enter_secrets(&v.dir, &v.name)?;
} else {
self.screen = Screen::Unlock(UnlockForm {
vault_dir: v.dir,
name: v.name,
passphrase: String::new(),
error: None,
pending: Pending::Secrets,
});
}
Ok(())
}
fn open_settings(&mut self) -> Result<()> {
let Some(v) = self.selected_vault() else {
return Ok(());
};
if !v.unlocked {
self.screen = Screen::Unlock(UnlockForm {
vault_dir: v.dir,
name: v.name,
passphrase: String::new(),
error: None,
pending: Pending::Settings,
});
return Ok(());
}
let Some(key) = session::get_key(&v.dir) else {
self.screen = Screen::Unlock(UnlockForm {
vault_dir: v.dir,
name: v.name,
passphrase: String::new(),
error: None,
pending: Pending::Settings,
});
return Ok(());
};
match Vault::open_with_key(&v.dir, VaultKey::from_bytes(key)) {
Ok(vault) => {
self.screen = Screen::Settings(SettingsForm::from_meta(
v.dir,
vault.meta.clone(),
&vault.policy,
));
}
Err(e) => self.set_status(MsgKind::Error, format!("Cannot open vault: {e}")),
}
Ok(())
}
fn enter_secrets(&mut self, dir: &Path, name: &str) -> Result<()> {
let Some(key) = session::get_key(dir) else {
self.screen = Screen::Unlock(UnlockForm {
vault_dir: dir.to_path_buf(),
name: name.to_string(),
passphrase: String::new(),
error: None,
pending: Pending::Secrets,
});
return Ok(());
};
match Vault::open_with_key(dir, VaultKey::from_bytes(key)) {
Ok(vault) => {
let secrets = vault.list_secret_names().unwrap_or_default();
let mut list_state = TableState::default();
if !secrets.is_empty() {
list_state.select(Some(0));
}
self.screen = Screen::Secrets(SecretScreen {
vault_dir: dir.to_path_buf(),
name: name.to_string(),
classifications: vault.policy.secrets.clone(),
default_tier: tier_idx(vault.policy.default_tier),
secrets,
list_state,
reveal: None,
pending_delete: None,
});
}
Err(e) => self.set_status(MsgKind::Error, format!("Cannot open vault: {e}")),
}
Ok(())
}
fn key_create(&mut self, mut form: CreateForm, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => {
self.screen = Screen::List;
return Ok(());
}
KeyCode::Tab | KeyCode::Down => form.focus = (form.focus + 1) % CreateForm::FIELDS,
KeyCode::BackTab | KeyCode::Up => {
form.focus = (form.focus + CreateForm::FIELDS - 1) % CreateForm::FIELDS
}
KeyCode::Enter => {
if form.focus == CreateForm::FIELDS - 1 {
return self.submit_create(form);
}
form.focus += 1;
}
KeyCode::Left => create_adjust(&mut form, false),
KeyCode::Right => create_adjust(&mut form, true),
KeyCode::Backspace => {
if let Some(s) = form.text_field() {
s.pop();
}
}
KeyCode::Char(c) => {
if c == ' ' && form.current() == CreateField::Autolock {
form.autolock = !form.autolock; } else if c == ' ' && form.current() == CreateField::Judge {
form.judge = !form.judge;
} else if c == ' ' && form.current() == CreateField::DefaultTier {
form.default_tier = cycle(form.default_tier, 3, true);
} else if let Some(s) = form.text_field() {
s.push(c);
form.error = None;
}
}
_ => {}
}
self.screen = Screen::Create(form);
Ok(())
}
fn submit_create(&mut self, mut form: CreateForm) -> Result<()> {
let name = form.name.trim().to_string();
if name.is_empty() {
form.error = Some("Name is required".into());
self.screen = Screen::Create(form);
return Ok(());
}
let vault_dir = PathBuf::from(SVAULT_DIR).join(&name);
if vault_dir.exists() {
let existing = VaultMeta::load_unverified(&vault_dir)
.map(|m| m.storage)
.unwrap_or_else(|_| "local".to_string());
form.error = Some(format!(
"a vault named '{name}' already exists ({existing}:{name}) — names must be unique across storage"
));
self.screen = Screen::Create(form);
return Ok(());
}
if form.passphrase.is_empty() {
form.error = Some("Passphrase is required".into());
self.screen = Screen::Create(form);
return Ok(());
}
if let Err(e) = crate::passphrase::meets_floor(&form.passphrase) {
form.error = Some(e);
self.screen = Screen::Create(form);
return Ok(());
}
if form.passphrase != form.confirm {
form.error = Some("Passphrases do not match".into());
self.screen = Screen::Create(form);
return Ok(());
}
let allow_agent = match form.allow_mode {
0 => AllowAgent::Bool(true),
1 => AllowAgent::Bool(false),
_ => AllowAgent::List(parse_agents(&form.allow_list)),
};
let meta = VaultMeta::new(
name.clone(),
form.description.clone(),
VaultSettings {
autolock: form.autolock,
autolock_timer: form.autolock_timer.clone(),
login_method: LoginMethod::Passphrase,
},
);
let mut vault_policy = crate::policy::VaultPolicyData {
access: AccessConfig {
allow_agent,
rate_limit: form.rate_limit.clone(),
},
default_tier: tier_at(form.default_tier),
..crate::policy::VaultPolicyData::default()
};
vault_policy.judge.enabled = Some(form.judge);
match Vault::init(&vault_dir, &form.passphrase, meta, vault_policy) {
Ok(vault) => {
let code = crate::recovery::generate_code();
if let Err(e) = crate::recovery::write(&vault_dir, vault.key(), &code) {
self.refresh_vaults();
self.set_status(
MsgKind::Warn,
format!(
"Vault '{name}' created, but recovery code could not be saved: {e}"
),
);
self.screen = Screen::List;
return Ok(());
}
crate::usage::human(&vault_dir, "vault.create", None);
self.refresh_vaults();
self.set_status(MsgKind::Ok, format!("Vault '{name}' created"));
self.screen = Screen::RecoveryCode(code);
}
Err(e) => {
form.error = Some(format!("{e}"));
self.screen = Screen::Create(form);
}
}
Ok(())
}
fn key_settings(&mut self, mut form: SettingsForm, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => {
self.screen = Screen::List;
return Ok(());
}
KeyCode::Tab | KeyCode::Down => form.focus = (form.focus + 1) % SettingsForm::FIELDS,
KeyCode::BackTab | KeyCode::Up => {
form.focus = (form.focus + SettingsForm::FIELDS - 1) % SettingsForm::FIELDS
}
KeyCode::Enter => {
if form.focus == SettingsForm::FIELDS - 1 {
return self.submit_settings(form);
}
form.focus += 1;
}
KeyCode::Left => settings_adjust(&mut form, false),
KeyCode::Right => settings_adjust(&mut form, true),
KeyCode::Backspace => {
if let Some(s) = form.text_field() {
s.pop();
}
}
KeyCode::Char(c) => {
if c == ' ' && form.current() == SettingsField::Autolock {
form.autolock = !form.autolock;
} else if c == ' ' && form.current() == SettingsField::Judge {
form.judge = !form.judge;
} else if c == ' ' && form.current() == SettingsField::DefaultTier {
form.default_tier = cycle(form.default_tier, 3, true);
} else if let Some(s) = form.text_field() {
s.push(c);
form.error = None;
}
}
_ => {}
}
self.screen = Screen::Settings(form);
Ok(())
}
fn submit_settings(&mut self, mut form: SettingsForm) -> Result<()> {
let Some(key) = session::get_key(&form.vault_dir) else {
self.set_status(
MsgKind::Error,
"Vault is locked — unlock before editing settings",
);
self.screen = Screen::List;
return Ok(());
};
let vault = match Vault::open_with_key(&form.vault_dir, VaultKey::from_bytes(key)) {
Ok(v) => v,
Err(e) => {
form.error = Some(format!("{e}"));
self.screen = Screen::Settings(form);
return Ok(());
}
};
let allow_agent = match form.allow_mode {
0 => AllowAgent::Bool(true),
1 => AllowAgent::Bool(false),
_ => AllowAgent::List(parse_agents(&form.allow_list)),
};
let mut meta = vault.meta.clone();
meta.description = form.description.clone();
meta.settings.autolock = form.autolock;
meta.settings.autolock_timer = form.autolock_timer.clone();
let mut vault_policy = vault.policy.clone();
vault_policy.access.allow_agent = allow_agent;
vault_policy.access.rate_limit = form.rate_limit.clone();
vault_policy.default_tier = tier_at(form.default_tier);
vault_policy.judge.enabled = Some(form.judge);
match vault
.save_meta(&meta)
.and_then(|_| vault.save_policy(&vault_policy))
{
Ok(_) => {
crate::usage::human(&form.vault_dir, "settings.update", None);
self.refresh_vaults();
self.set_status(MsgKind::Ok, format!("Settings for '{}' saved", form.name));
self.screen = Screen::List;
}
Err(e) => {
form.error = Some(format!("{e}"));
self.screen = Screen::Settings(form);
}
}
Ok(())
}
fn key_unlock(&mut self, mut form: UnlockForm, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => {
if matches!(form.pending, Pending::FinishImport) {
let _ = std::fs::remove_dir_all(&form.vault_dir);
self.refresh_vaults();
self.set_status(MsgKind::Warn, "Import cancelled");
}
self.screen = Screen::List;
}
KeyCode::Backspace => {
form.passphrase.pop();
self.screen = Screen::Unlock(form);
}
KeyCode::Enter => match Vault::open(&form.vault_dir, &form.passphrase) {
Ok(vault) => {
session::unlock_with_key(&form.vault_dir, vault.key().bytes())?;
crate::usage::human(&form.vault_dir, "unlock", None);
self.refresh_vaults();
self.set_status(MsgKind::Ok, format!("Vault '{}' unlocked", form.name));
match form.pending {
Pending::List => self.screen = Screen::List,
Pending::Secrets => self.enter_secrets(&form.vault_dir, &form.name)?,
Pending::Settings => {
self.screen = Screen::Settings(SettingsForm::from_meta(
form.vault_dir,
vault.meta.clone(),
&vault.policy,
));
}
Pending::FinishImport => {
let mut meta = vault.meta.clone();
meta.name = form.name.clone();
vault.save_meta(&meta)?;
self.refresh_vaults();
self.set_status(MsgKind::Ok, format!("Imported as '{}'", form.name));
self.screen = Screen::List;
}
}
}
Err(_) => {
form.error = Some("Wrong passphrase".into());
form.passphrase.clear();
self.screen = Screen::Unlock(form);
}
},
KeyCode::Char(c) => {
form.passphrase.push(c);
form.error = None;
self.screen = Screen::Unlock(form);
}
_ => self.screen = Screen::Unlock(form),
}
Ok(())
}
fn key_secrets(&mut self, mut scr: SecretScreen, key: KeyEvent) -> Result<()> {
if scr.reveal.is_some() {
match key.code {
KeyCode::Char(' ') => {
if let Some(r) = scr.reveal.as_mut() {
r.masked = !r.masked;
}
}
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => scr.reveal = None,
_ => {}
}
self.screen = Screen::Secrets(scr);
return Ok(());
}
if let Some(target) = scr.pending_delete.clone() {
match key.code {
KeyCode::Char('y') => {
self.delete_secret(&mut scr, &target);
scr.pending_delete = None;
}
_ => scr.pending_delete = None,
}
self.screen = Screen::Secrets(scr);
return Ok(());
}
match key.code {
KeyCode::Esc | KeyCode::Char('b') => {
self.screen = Screen::List;
return Ok(());
}
KeyCode::Char('q') => {
self.confirm_quit = true;
}
KeyCode::Down | KeyCode::Char('j') => secrets_next(&mut scr),
KeyCode::Up | KeyCode::Char('k') => secrets_prev(&mut scr),
KeyCode::Char('a') => {
let default_tier = scr.default_tier;
self.screen = Screen::SecretAdd(SecretAddForm {
vault_dir: scr.vault_dir.clone(),
vault_name: scr.name.clone(),
name: String::new(),
value: String::new(),
scope: "misc".to_string(),
description: String::new(),
tier: default_tier,
require_reason: false,
focus: 0,
error: None,
});
return Ok(());
}
KeyCode::Char('c') => {
if let Some(name) = scr.selected_name() {
let rule = scr.classifications.get(&name).cloned();
let default_tier = scr.default_tier;
self.screen = Screen::Classify(ClassifyForm {
vault_dir: scr.vault_dir.clone(),
vault_name: scr.name.clone(),
secret: name.clone(),
scope: rule
.as_ref()
.map(|r| r.scope.clone())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "misc".to_string()),
description: rule
.as_ref()
.map(|r| r.description.clone())
.unwrap_or_default(),
tier: rule
.as_ref()
.map(|r| tier_idx(r.tier))
.unwrap_or(default_tier),
require_reason: rule.as_ref().map(|r| r.require_reason).unwrap_or(false),
focus: 0,
error: None,
});
return Ok(());
}
}
KeyCode::Enter | KeyCode::Char('g') => self.reveal_secret(&mut scr),
KeyCode::Char('d') => {
if let Some(name) = scr.selected_name() {
scr.pending_delete = Some(name);
}
}
KeyCode::Char('l') => {
session::lock(&scr.vault_dir)?;
crate::usage::human(&scr.vault_dir, "lock", None);
self.set_status(MsgKind::Ok, format!("Vault '{}' locked", scr.name));
self.refresh_vaults();
self.screen = Screen::List;
return Ok(());
}
KeyCode::Char('?') | KeyCode::Char('h') => self.show_help = true,
_ => {}
}
self.screen = Screen::Secrets(scr);
Ok(())
}
fn reveal_secret(&mut self, scr: &mut SecretScreen) {
let Some(name) = scr.selected_name() else {
return;
};
let Some(key) = session::get_key(&scr.vault_dir) else {
self.set_status(MsgKind::Error, "Vault is locked");
return;
};
match Vault::open_with_key(&scr.vault_dir, VaultKey::from_bytes(key))
.and_then(|v| v.get_secret(&name))
{
Ok(Some(value)) => {
crate::usage::human(&scr.vault_dir, "secret.reveal", Some(&name));
scr.reveal = Some(Reveal {
name,
value,
masked: true,
})
}
Ok(None) => self.set_status(MsgKind::Error, format!("Secret '{name}' not found")),
Err(e) => self.set_status(MsgKind::Error, format!("{e}")),
}
}
fn delete_secret(&mut self, scr: &mut SecretScreen, name: &str) {
let Some(key) = session::get_key(&scr.vault_dir) else {
self.set_status(MsgKind::Error, "Vault is locked");
return;
};
match Vault::open_with_key(&scr.vault_dir, VaultKey::from_bytes(key)) {
Ok(vault) => match vault.remove_secret(name) {
Ok(true) => {
scr.secrets = vault.list_secret_names().unwrap_or_default();
let sel = if scr.secrets.is_empty() {
None
} else {
Some(
scr.list_state
.selected()
.unwrap_or(0)
.min(scr.secrets.len() - 1),
)
};
scr.list_state.select(sel);
crate::usage::human(&scr.vault_dir, "secret.remove", Some(name));
self.set_status(MsgKind::Ok, format!("Secret '{name}' removed"));
}
Ok(false) => self.set_status(MsgKind::Error, format!("Secret '{name}' not found")),
Err(e) => self.set_status(MsgKind::Error, format!("{e}")),
},
Err(e) => self.set_status(MsgKind::Error, format!("{e}")),
}
}
fn key_secret_add(&mut self, mut form: SecretAddForm, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => {
let (dir, name) = (form.vault_dir.clone(), form.vault_name.clone());
self.enter_secrets(&dir, &name)?;
return Ok(());
}
KeyCode::Tab | KeyCode::Down => form.focus = (form.focus + 1) % SecretAddForm::FIELDS,
KeyCode::BackTab | KeyCode::Up => {
form.focus = (form.focus + SecretAddForm::FIELDS - 1) % SecretAddForm::FIELDS
}
KeyCode::Left => secret_add_adjust(&mut form, false),
KeyCode::Right => secret_add_adjust(&mut form, true),
KeyCode::Enter => {
if form.focus == SecretAddForm::FIELDS - 1 {
return self.submit_secret_add(form);
}
form.focus += 1;
}
KeyCode::Backspace => match form.focus {
0 => {
form.name.pop();
}
1 => {
form.value.pop();
}
2 => {
form.scope.pop();
}
3 => {
form.description.pop();
}
_ => {}
},
KeyCode::Char(c) => match form.focus {
0 => {
form.name.push(c);
form.error = None;
}
1 => {
form.value.push(c);
form.error = None;
}
2 => {
form.scope.push(c);
form.error = None;
}
3 => {
form.description.push(c);
form.error = None;
}
4 if c == ' ' => form.tier = cycle(form.tier, 3, true),
5 if c == ' ' => form.require_reason = !form.require_reason,
_ => {}
},
_ => {}
}
self.screen = Screen::SecretAdd(form);
Ok(())
}
fn submit_secret_add(&mut self, mut form: SecretAddForm) -> Result<()> {
if form.name.trim().is_empty() {
form.error = Some("Secret name is required".into());
self.screen = Screen::SecretAdd(form);
return Ok(());
}
let Some(key) = session::get_key(&form.vault_dir) else {
self.set_status(MsgKind::Error, "Vault is locked");
self.screen = Screen::List;
return Ok(());
};
match Vault::open_with_key(&form.vault_dir, VaultKey::from_bytes(key)) {
Ok(vault) => match vault.add_secret(form.name.trim(), &form.value) {
Ok(_) => {
let scope = if form.scope.trim().is_empty() {
"misc".to_string()
} else {
form.scope.trim().to_string()
};
let mut vault_policy = vault.policy.clone();
vault_policy.secrets.insert(
form.name.trim().to_string(),
crate::policy::SecretRule {
scope,
tier: tier_at(form.tier),
require_reason: form.require_reason,
description: form.description.trim().to_string(),
},
);
let _ = vault.save_policy(&vault_policy);
crate::usage::human(&form.vault_dir, "secret.add", Some(form.name.trim()));
self.set_status(MsgKind::Ok, format!("Secret '{}' added", form.name.trim()));
let (dir, name) = (form.vault_dir.clone(), form.vault_name.clone());
self.enter_secrets(&dir, &name)?;
}
Err(e) => {
form.error = Some(format!("{e}"));
self.screen = Screen::SecretAdd(form);
}
},
Err(e) => {
form.error = Some(format!("{e}"));
self.screen = Screen::SecretAdd(form);
}
}
Ok(())
}
fn key_classify(&mut self, mut form: ClassifyForm, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => {
let (dir, name) = (form.vault_dir.clone(), form.vault_name.clone());
self.enter_secrets(&dir, &name)?;
return Ok(());
}
KeyCode::Tab | KeyCode::Down => form.focus = (form.focus + 1) % ClassifyForm::FIELDS,
KeyCode::BackTab | KeyCode::Up => {
form.focus = (form.focus + ClassifyForm::FIELDS - 1) % ClassifyForm::FIELDS
}
KeyCode::Left => classify_adjust(&mut form, false),
KeyCode::Right => classify_adjust(&mut form, true),
KeyCode::Enter => {
if form.focus == ClassifyForm::FIELDS - 1 {
return self.submit_classify(form);
}
form.focus += 1;
}
KeyCode::Backspace => match form.focus {
0 => {
form.scope.pop();
}
1 => {
form.description.pop();
}
_ => {}
},
KeyCode::Char(c) => match form.focus {
0 => {
form.scope.push(c);
form.error = None;
}
1 => {
form.description.push(c);
form.error = None;
}
2 if c == ' ' => form.tier = cycle(form.tier, 3, true),
3 if c == ' ' => form.require_reason = !form.require_reason,
_ => {}
},
_ => {}
}
self.screen = Screen::Classify(form);
Ok(())
}
fn submit_classify(&mut self, mut form: ClassifyForm) -> Result<()> {
let Some(key) = session::get_key(&form.vault_dir) else {
self.set_status(MsgKind::Error, "Vault is locked");
self.screen = Screen::List;
return Ok(());
};
match Vault::open_with_key(&form.vault_dir, VaultKey::from_bytes(key)) {
Ok(vault) => {
let scope = if form.scope.trim().is_empty() {
"misc".to_string()
} else {
form.scope.trim().to_string()
};
let mut vault_policy = vault.policy.clone();
vault_policy.secrets.insert(
form.secret.clone(),
SecretRule {
scope,
tier: tier_at(form.tier),
require_reason: form.require_reason,
description: form.description.trim().to_string(),
},
);
match vault.save_policy(&vault_policy) {
Ok(_) => {
crate::usage::human(&form.vault_dir, "secret.classify", Some(&form.secret));
self.set_status(
MsgKind::Ok,
format!("Classification for '{}' saved", form.secret),
);
let (dir, name) = (form.vault_dir.clone(), form.vault_name.clone());
self.enter_secrets(&dir, &name)?;
}
Err(e) => {
form.error = Some(format!("{e}"));
self.screen = Screen::Classify(form);
}
}
}
Err(e) => {
form.error = Some(format!("{e}"));
self.screen = Screen::Classify(form);
}
}
Ok(())
}
fn key_judge(&mut self, mut form: JudgeForm, key: KeyEvent) -> Result<()> {
if let Some(mut buf) = form.key_entry.take() {
match key.code {
KeyCode::Esc => { }
KeyCode::Backspace => {
buf.pop();
form.key_entry = Some(buf);
}
KeyCode::Char(c) => {
buf.push(c);
form.key_entry = Some(buf);
}
KeyCode::Enter => self.save_judge_key(&mut form, &buf),
_ => form.key_entry = Some(buf),
}
self.screen = Screen::Judge(form);
return Ok(());
}
match key.code {
KeyCode::Esc => {
self.screen = Screen::List;
return Ok(());
}
KeyCode::Tab | KeyCode::Down => form.focus = (form.focus + 1) % JudgeForm::FIELDS,
KeyCode::BackTab | KeyCode::Up => {
form.focus = (form.focus + JudgeForm::FIELDS - 1) % JudgeForm::FIELDS
}
KeyCode::Left | KeyCode::Right if form.current() == JudgeField::Enabled => {
form.enabled = !form.enabled;
}
KeyCode::Delete if form.current() == JudgeField::ApiKey => {
self.remove_judge_key(&mut form)
}
KeyCode::Enter => match form.current() {
JudgeField::ApiKey => form.key_entry = Some(String::new()),
JudgeField::Test => self.run_judge_test(&mut form),
JudgeField::Save => return self.submit_judge(form),
JudgeField::Enabled => form.enabled = !form.enabled,
_ => form.focus += 1,
},
KeyCode::Backspace => {
if let Some(s) = form.text_field() {
s.pop();
}
}
KeyCode::Char(c) => {
if c == ' ' && form.current() == JudgeField::Enabled {
form.enabled = !form.enabled;
} else if let Some(s) = form.text_field() {
s.push(c);
form.error = None;
}
}
_ => {}
}
self.screen = Screen::Judge(form);
Ok(())
}
fn save_judge_key(&mut self, form: &mut JudgeForm, key: &str) {
let key = key.trim();
if key.is_empty() {
form.error = Some("empty key — nothing written".into());
return;
}
let cfg = crate::config::SvaultConfig::load();
match crate::config::set_openrouter_key(&cfg.judge, key) {
Ok(path) => {
form.key_status = key_status_line(&cfg.judge);
form.error = None;
log_judge("judge.key.set", None);
self.set_status(
MsgKind::Ok,
format!("OpenRouter key stored at {} (0600)", path.display()),
);
}
Err(e) => form.error = Some(format!("could not store key: {e}")),
}
}
fn remove_judge_key(&mut self, form: &mut JudgeForm) {
let cfg = crate::config::SvaultConfig::load();
match crate::config::remove_openrouter_key(&cfg.judge) {
Ok(Some(path)) => {
log_judge("judge.key.remove", None);
self.set_status(MsgKind::Ok, format!("Removed key file {}", path.display()));
}
Ok(None) => self.set_status(MsgKind::Info, "No key file to remove"),
Err(e) => self.set_status(MsgKind::Error, format!("could not remove key: {e}")),
}
form.key_status = key_status_line(&cfg.judge);
}
fn run_judge_test(&mut self, form: &mut JudgeForm) {
let mut cfg = crate::config::SvaultConfig::load().judge;
cfg.enabled = true;
if !form.model.trim().is_empty() {
cfg.model = form.model.trim().to_string();
}
let Some(rt) = crate::judge::JudgeRuntime::from_config(&cfg) else {
form.test_result = Some((
MsgKind::Error,
format!(
"No OpenRouter key — set one here, or export ${}",
crate::config::KEY_ENV
),
));
return;
};
let model = rt.model.clone();
let ctx = crate::judge::JudgeContext {
caller: "claude-code",
scope: "database",
reason: "run the nightly database migration to apply pending changes",
secret: "DB_URL",
tier: crate::policy::Tier::Medium,
vault: "demo-vault",
vault_description: "",
secret_description: "",
recent: "no prior requests in the last hour",
};
form.test_result = Some(match crate::judge::evaluate(&rt, &model, &ctx) {
crate::judge::JudgeVerdict::Allow { score, rationale } => {
(MsgKind::Ok, format!("ALLOW (score {score}) — {rationale}"))
}
crate::judge::JudgeVerdict::Deny { score, rationale } => {
(MsgKind::Warn, format!("DENY (score {score}) — {rationale}"))
}
crate::judge::JudgeVerdict::Unavailable { err } => {
(MsgKind::Error, format!("unavailable: {err}"))
}
});
}
fn submit_judge(&mut self, mut form: JudgeForm) -> Result<()> {
let parse_u8 = |s: &str, what: &str| -> std::result::Result<u8, String> {
s.trim()
.parse::<u8>()
.map_err(|_| format!("{what} must be a number 0-100"))
};
let allow = match parse_u8(&form.allow_threshold, "allow threshold") {
Ok(v) => v,
Err(e) => {
form.error = Some(e);
self.screen = Screen::Judge(form);
return Ok(());
}
};
let high = match parse_u8(&form.high_threshold, "high threshold") {
Ok(v) => v,
Err(e) => {
form.error = Some(e);
self.screen = Screen::Judge(form);
return Ok(());
}
};
let timeout = match form.timeout.trim().parse::<u64>() {
Ok(v) if v > 0 => v,
_ => {
form.error = Some("timeout must be a positive number of seconds".into());
self.screen = Screen::Judge(form);
return Ok(());
}
};
if form.model.trim().is_empty() {
form.error = Some("model is required".into());
self.screen = Screen::Judge(form);
return Ok(());
}
let mut cfg = crate::config::SvaultConfig::load();
cfg.judge.enabled = form.enabled;
cfg.judge.model = form.model.trim().to_string();
cfg.judge.allow_threshold = allow;
cfg.judge.high_threshold = high;
cfg.judge.timeout_secs = timeout;
match cfg.save() {
Ok(_) => {
log_judge(
"judge.config",
Some(if form.enabled { "enabled" } else { "disabled" }),
);
self.set_status(MsgKind::Ok, "Judge config saved");
self.screen = Screen::List;
}
Err(e) => {
form.error = Some(format!("could not save config: {e}"));
self.screen = Screen::Judge(form);
}
}
Ok(())
}
}
fn parse_agents(raw: &str) -> Vec<String> {
raw.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
fn tier_at(idx: usize) -> crate::policy::Tier {
match idx {
1 => crate::policy::Tier::Medium,
2 => crate::policy::Tier::High,
_ => crate::policy::Tier::Low,
}
}
fn tier_idx(t: crate::policy::Tier) -> usize {
match t {
crate::policy::Tier::Low => 0,
crate::policy::Tier::Medium => 1,
crate::policy::Tier::High => 2,
}
}
pub fn tier_label(idx: usize) -> &'static str {
match idx {
1 => "medium",
2 => "high",
_ => "low",
}
}
fn create_adjust(form: &mut CreateForm, forward: bool) {
match form.current() {
CreateField::AllowMode => form.allow_mode = cycle(form.allow_mode, 3, forward),
CreateField::Autolock => form.autolock = !form.autolock,
CreateField::DefaultTier => form.default_tier = cycle(form.default_tier, 3, forward),
CreateField::Judge => form.judge = !form.judge,
_ => {}
}
}
fn settings_adjust(form: &mut SettingsForm, forward: bool) {
match form.current() {
SettingsField::AllowMode => form.allow_mode = cycle(form.allow_mode, 3, forward),
SettingsField::Autolock => form.autolock = !form.autolock,
SettingsField::DefaultTier => form.default_tier = cycle(form.default_tier, 3, forward),
SettingsField::Judge => form.judge = !form.judge,
_ => {}
}
}
fn secret_add_adjust(form: &mut SecretAddForm, forward: bool) {
match form.focus {
4 => form.tier = cycle(form.tier, 3, forward),
5 => form.require_reason = !form.require_reason,
_ => {}
}
}
fn classify_adjust(form: &mut ClassifyForm, forward: bool) {
match form.focus {
2 => form.tier = cycle(form.tier, 3, forward),
3 => form.require_reason = !form.require_reason,
_ => {}
}
}
fn log_judge(action: &str, detail: Option<&str>) {
crate::usage::human(Path::new(SVAULT_DIR), action, detail);
}
fn cycle(current: usize, len: usize, forward: bool) -> usize {
if forward {
(current + 1) % len
} else {
(current + len - 1) % len
}
}
fn secrets_next(scr: &mut SecretScreen) {
if scr.secrets.is_empty() {
return;
}
let i = scr
.list_state
.selected()
.map_or(0, |i| (i + 1) % scr.secrets.len());
scr.list_state.select(Some(i));
}
fn secrets_prev(scr: &mut SecretScreen) {
if scr.secrets.is_empty() {
return;
}
let len = scr.secrets.len();
let i = scr.list_state.selected().map_or(0, |i| (i + len - 1) % len);
scr.list_state.select(Some(i));
}
fn activity_move(scr: &mut ActivityScreen, down: bool) {
if scr.events.is_empty() {
return;
}
let last = scr.events.len() - 1;
let i = scr.state.selected().unwrap_or(0);
let next = if down {
(i + 1).min(last)
} else {
i.saturating_sub(1)
};
scr.state.select(Some(next));
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn bare_app(screen: Screen) -> App {
App {
screen,
vaults: Vec::new(),
list_state: TableState::default(),
status: None,
should_quit: false,
show_help: false,
confirm_quit: false,
daemon_running: false,
}
}
fn press(app: &mut App, code: KeyCode) {
app.on_key(KeyEvent::new(code, KeyModifiers::empty()))
.unwrap();
}
fn idx(field: CreateField) -> usize {
CreateField::ORDER.iter().position(|f| *f == field).unwrap()
}
fn create_at(field: CreateField) -> Screen {
let mut form = CreateForm::new();
form.focus = idx(field);
Screen::Create(form)
}
#[test]
fn space_in_rate_limit_types_a_space_and_leaves_autolock_alone() {
let mut app = bare_app(create_at(CreateField::RateLimit));
press(&mut app, KeyCode::Char(' '));
let Screen::Create(form) = &app.screen else {
panic!("expected create screen")
};
assert!(form.rate_limit.ends_with(' '));
assert!(form.autolock, "auto-lock must not toggle from rate-limit");
}
#[test]
fn space_on_the_autolock_field_toggles_it() {
let mut app = bare_app(create_at(CreateField::Autolock));
press(&mut app, KeyCode::Char(' '));
let Screen::Create(form) = &app.screen else {
panic!("expected create screen")
};
assert!(!form.autolock);
}
#[test]
fn create_field_order_matches_field_count() {
assert_eq!(CreateField::ORDER.len(), CreateForm::FIELDS);
assert_eq!(SettingsField::ORDER.len(), SettingsForm::FIELDS);
}
#[test]
fn focus_is_text_excludes_pickers_and_toggles() {
let mut form = CreateForm::new();
form.focus = idx(CreateField::AllowMode);
assert!(!form.focus_is_text());
form.focus = idx(CreateField::Autolock);
assert!(!form.focus_is_text());
form.focus = idx(CreateField::DefaultTier);
assert!(!form.focus_is_text());
form.focus = idx(CreateField::Judge);
assert!(!form.focus_is_text());
form.focus = idx(CreateField::Passphrase);
assert!(form.focus_is_text());
}
#[test]
fn space_on_judge_field_toggles_it() {
let mut app = bare_app(create_at(CreateField::Judge));
press(&mut app, KeyCode::Char(' '));
let Screen::Create(form) = &app.screen else {
panic!("expected create screen")
};
assert!(form.judge, "space must toggle the AI judge on");
}
#[test]
fn secret_add_tier_cycles_and_classifies() {
let form = SecretAddForm {
vault_dir: PathBuf::from("."),
vault_name: "v".into(),
name: String::new(),
value: String::new(),
scope: "misc".into(),
description: String::new(),
tier: 0,
require_reason: false,
focus: 4, error: None,
};
let mut app = bare_app(Screen::SecretAdd(form));
press(&mut app, KeyCode::Right);
let Screen::SecretAdd(f) = &app.screen else {
panic!("expected secret-add screen")
};
assert_eq!(f.tier, 1, "right arrow cycles tier low → medium");
assert_eq!(tier_at(f.tier), crate::policy::Tier::Medium);
}
#[test]
fn classify_form_cycles_tier_and_toggles_reason() {
let form = ClassifyForm {
vault_dir: PathBuf::from("."),
vault_name: "v".into(),
secret: "DB_URL".into(),
scope: "database".into(),
description: String::new(),
tier: 0,
require_reason: false,
focus: 2, error: None,
};
let mut app = bare_app(Screen::Classify(form));
press(&mut app, KeyCode::Right);
let Screen::Classify(f) = &app.screen else {
panic!("expected classify screen")
};
assert_eq!(f.tier, 1, "right arrow cycles tier low → medium");
press(&mut app, KeyCode::Down);
press(&mut app, KeyCode::Char(' '));
let Screen::Classify(f) = &app.screen else {
panic!("expected classify screen")
};
assert!(f.require_reason, "space toggles require-reason on");
}
#[test]
fn judge_screen_toggles_enabled_and_opens_key_entry() {
let form = JudgeForm {
enabled: false,
model: "google/gemini-2.5-flash".into(),
allow_threshold: "60".into(),
high_threshold: "80".into(),
timeout: "6".into(),
key_status: "none".into(),
focus: 0, error: None,
test_result: None,
key_entry: None,
};
let mut app = bare_app(Screen::Judge(form));
press(&mut app, KeyCode::Char(' '));
let Screen::Judge(f) = &app.screen else {
panic!("expected judge screen")
};
assert!(f.enabled, "space toggles the global judge on");
let key_row = JudgeField::ORDER
.iter()
.position(|x| *x == JudgeField::ApiKey)
.unwrap();
if let Screen::Judge(f) = &mut app.screen {
f.focus = key_row;
}
press(&mut app, KeyCode::Enter);
let Screen::Judge(f) = &app.screen else {
panic!("expected judge screen")
};
assert!(
f.key_entry.is_some(),
"enter on the key row starts key entry"
);
}
#[test]
fn down_wraps_from_last_create_field_to_first() {
let mut form = CreateForm::new();
form.focus = CreateForm::FIELDS - 1;
let mut app = bare_app(Screen::Create(form));
press(&mut app, KeyCode::Down);
let Screen::Create(form) = &app.screen else {
panic!("expected create screen")
};
assert_eq!(form.focus, 0);
}
#[test]
fn paste_appends_to_the_focused_field() {
let mut app = bare_app(create_at(CreateField::Passphrase));
app.on_paste("Str0ng!Pass#99".to_string());
let Screen::Create(form) = &app.screen else {
panic!("expected create screen")
};
assert_eq!(form.passphrase, "Str0ng!Pass#99");
}
#[test]
fn paste_strips_newlines() {
let mut app = bare_app(Screen::Import(ImportForm {
path: String::new(),
error: None,
}));
app.on_paste("/tmp/v.svault-export.json\n".to_string());
let Screen::Import(form) = &app.screen else {
panic!("expected import screen")
};
assert_eq!(form.path, "/tmp/v.svault-export.json");
}
#[test]
fn help_opens_from_list_and_any_key_closes_it() {
let mut app = bare_app(Screen::List);
press(&mut app, KeyCode::Char('?'));
assert!(app.show_help);
press(&mut app, KeyCode::Char('x'));
assert!(!app.show_help);
}
#[test]
fn help_also_opens_with_h() {
let mut app = bare_app(Screen::List);
press(&mut app, KeyCode::Char('h'));
assert!(app.show_help);
press(&mut app, KeyCode::Esc);
assert!(!app.show_help);
}
#[test]
fn quit_from_list_asks_for_confirmation_then_enter_quits() {
let mut app = bare_app(Screen::List);
press(&mut app, KeyCode::Char('q'));
assert!(app.confirm_quit, "q should open the quit confirmation");
assert!(!app.should_quit, "q alone must not quit");
press(&mut app, KeyCode::Enter);
assert!(app.should_quit, "enter confirms the quit");
}
#[test]
fn any_key_other_than_enter_cancels_the_quit_popup() {
let mut app = bare_app(Screen::List);
app.confirm_quit = true;
press(&mut app, KeyCode::Esc);
assert!(!app.confirm_quit, "esc cancels");
assert!(!app.should_quit);
}
#[test]
fn recovery_code_screen_needs_y_to_dismiss() {
let mut app = bare_app(Screen::RecoveryCode("AAAA-BBBB-CCCC".into()));
press(&mut app, KeyCode::Char('x'));
assert!(
matches!(app.screen, Screen::RecoveryCode(_)),
"a non-y key keeps the code on screen"
);
press(&mut app, KeyCode::Char('y'));
assert!(
matches!(app.screen, Screen::List),
"y confirms and returns to the list"
);
}
}