Files
IronStorage/apps/tui/src/workflow.rs
2026-08-10 11:39:45 +00:00

1095 lines
37 KiB
Rust

//! Keyboard-only presentation state for storage-owned write workflows.
use std::{fmt, num::NonZeroUsize};
use crossterm::event::{KeyCode, KeyModifiers};
use ironstorage::{
command::{
CopyRequest, GenerateRequest, GeneratedPresentation, GrepRequest, InitRequest, InsertInput,
InsertRequest, MoveRequest, OtpAppendRequest, OtpInputSource, OtpInsertRequest,
RemoveRequest,
},
crypto::KeyInfo,
otp::OtpInput,
write::{InsertContent, OverwriteDecision},
};
use zeroize::Zeroize;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InsertMode {
Hidden,
Echoed,
Multiline,
}
impl InsertMode {
fn next(self) -> Self {
match self {
Self::Hidden => Self::Echoed,
Self::Echoed => Self::Multiline,
Self::Multiline => Self::Hidden,
}
}
pub fn label(self) -> &'static str {
match self {
Self::Hidden => "single line (confirmed)",
Self::Echoed => "single line",
Self::Multiline => "multiline",
}
}
}
#[derive(Default)]
struct SecretText {
characters: Vec<char>,
}
impl SecretText {
fn push(&mut self, character: char) {
self.characters.push(character);
}
fn pop(&mut self) {
self.characters.pop();
}
fn bytes(&self) -> Vec<u8> {
self.characters.iter().collect::<String>().into_bytes()
}
fn is_empty(&self) -> bool {
self.characters.is_empty()
}
}
impl Drop for SecretText {
fn drop(&mut self) {
self.characters.zeroize();
}
}
impl fmt::Debug for SecretText {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("SecretText([REDACTED])")
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct RecipientChoice {
identity: String,
label: String,
selected: bool,
}
#[derive(Debug)]
pub struct InitForm {
path: String,
recipients: Vec<RecipientChoice>,
focus: usize,
}
#[derive(Debug)]
pub struct InsertForm {
entry: String,
mode: InsertMode,
secret: SecretText,
confirmation: SecretText,
overwrite: bool,
focus: usize,
}
#[derive(Debug)]
pub struct GenerateForm {
entry: String,
length: String,
no_symbols: bool,
overwrite: bool,
in_place: bool,
presentation: GeneratedPresentation,
focus: usize,
}
#[derive(Debug)]
pub struct GrepForm {
pattern: String,
ignore_case: bool,
invert_match: bool,
line_number: bool,
fixed_strings: bool,
focus: usize,
}
#[derive(Debug)]
pub struct RemoveForm {
target: String,
recursive: bool,
force: bool,
confirmed: bool,
focus: usize,
}
#[derive(Debug)]
pub struct TransferForm {
source: String,
destination: String,
destination_directory: bool,
overwrite: bool,
focus: usize,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OtpFormKind {
Insert,
Append,
Validate,
}
#[derive(Debug)]
pub struct OtpForm {
kind: OtpFormKind,
entry: String,
uri: SecretText,
force: bool,
confirmed: bool,
focus: usize,
}
#[derive(Debug)]
pub enum WorkflowForm {
Init(InitForm),
Insert(InsertForm),
Generate(GenerateForm),
Grep(GrepForm),
Remove(RemoveForm),
Move(TransferForm),
Copy(TransferForm),
Otp(OtpForm),
}
#[derive(Debug)]
pub enum WorkflowSubmission {
Init(InitRequest),
Insert {
request: InsertRequest,
contents: InsertContent,
overwrite: OverwriteDecision,
},
Generate {
request: GenerateRequest,
overwrite: OverwriteDecision,
},
Grep(GrepRequest),
Remove(RemoveRequest),
Move {
request: MoveRequest,
overwrite: OverwriteDecision,
},
Copy {
request: CopyRequest,
overwrite: OverwriteDecision,
},
OtpInsert {
request: OtpInsertRequest,
input: OtpInput,
},
OtpAppend {
request: OtpAppendRequest,
input: OtpInput,
},
OtpValidate {
uri: OtpInput,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum WorkflowInput {
Consumed,
Cancel,
Lock,
Submit,
}
impl WorkflowForm {
pub fn otp(kind: OtpFormKind, entry: Option<String>, force: bool) -> Self {
Self::Otp(OtpForm {
kind,
entry: entry.unwrap_or_default(),
uri: SecretText::default(),
force,
confirmed: false,
focus: 0,
})
}
pub fn init(keys: &[KeyInfo], default: Option<&KeyInfo>, request: Option<InitRequest>) -> Self {
let request = request.unwrap_or_else(|| InitRequest {
path: None,
key_identities: default
.map(|key| vec![key.fingerprint().to_string()])
.unwrap_or_default(),
});
let mut recipients = keys
.iter()
.filter(|key| key.can_encrypt())
.map(|key| {
let identity = key.fingerprint().to_string();
let user = key
.user_ids()
.first()
.map_or("unknown identity", String::as_str);
RecipientChoice {
selected: request.key_identities.iter().any(|item| item == &identity),
label: format!("{} — {user}", key.key_id()),
identity,
}
})
.collect::<Vec<_>>();
for identity in &request.key_identities {
if !recipients.iter().any(|choice| &choice.identity == identity) {
recipients.push(RecipientChoice {
identity: identity.clone(),
label: format!("requested identity {identity}"),
selected: true,
});
}
}
Self::Init(InitForm {
path: request.path.unwrap_or_default(),
recipients,
focus: 0,
})
}
pub fn insert(request: Option<InsertRequest>) -> Self {
let request = request.unwrap_or(InsertRequest {
entry: String::new(),
input: InsertInput::HiddenConfirmed,
force: false,
});
let mode = match request.input {
InsertInput::HiddenConfirmed => InsertMode::Hidden,
InsertInput::EchoedLine => InsertMode::Echoed,
InsertInput::Multiline => InsertMode::Multiline,
};
Self::Insert(InsertForm {
entry: request.entry,
mode,
secret: SecretText::default(),
confirmation: SecretText::default(),
overwrite: request.force,
focus: 0,
})
}
pub fn generate(request: Option<GenerateRequest>) -> Self {
let request = request.unwrap_or(GenerateRequest {
entry: String::new(),
length: None,
no_symbols: false,
force: false,
in_place: false,
presentation: GeneratedPresentation::Terminal,
});
Self::Generate(GenerateForm {
entry: request.entry,
length: request
.length
.map_or_else(String::new, |length| length.to_string()),
no_symbols: request.no_symbols,
overwrite: request.force,
in_place: request.in_place,
presentation: request.presentation,
focus: 0,
})
}
pub fn grep(request: Option<GrepRequest>) -> Self {
let request = request.unwrap_or(GrepRequest {
pattern: String::new(),
ignore_case: false,
invert_match: false,
line_number: true,
fixed_strings: false,
});
Self::Grep(GrepForm {
pattern: request.pattern,
ignore_case: request.ignore_case,
invert_match: request.invert_match,
line_number: request.line_number,
fixed_strings: request.fixed_strings,
focus: 0,
})
}
pub fn remove(request: Option<RemoveRequest>, selected: Option<(&str, bool)>) -> Self {
let request = request.unwrap_or_else(|| RemoveRequest {
entry: selected.map_or_else(String::new, |(path, _)| path.to_owned()),
recursive: selected.is_some_and(|(_, directory)| directory),
force: false,
});
Self::Remove(RemoveForm {
target: request.entry,
recursive: request.recursive,
force: request.force,
confirmed: false,
focus: 0,
})
}
pub fn move_entry(request: Option<MoveRequest>, selected: Option<&str>) -> Self {
let request = request.unwrap_or_else(|| MoveRequest {
source: selected.unwrap_or_default().to_owned(),
destination: String::new(),
force: false,
});
Self::Move(transfer_form(
request.source,
request.destination,
request.force,
))
}
pub fn copy_entry(request: Option<CopyRequest>, selected: Option<&str>) -> Self {
let request = request.unwrap_or_else(|| CopyRequest {
source: selected.unwrap_or_default().to_owned(),
destination: String::new(),
force: false,
});
Self::Copy(transfer_form(
request.source,
request.destination,
request.force,
))
}
pub fn title(&self) -> &'static str {
match self {
Self::Init(_) => "Initialize recipients",
Self::Insert(_) => "Insert entry",
Self::Generate(_) => "Generate password",
Self::Grep(_) => "Search decrypted entries",
Self::Remove(_) => "Remove entry or folder",
Self::Move(_) => "Move or rename",
Self::Copy(_) => "Copy entry or folder",
Self::Otp(form) => match form.kind {
OtpFormKind::Insert => "Insert OTP URI",
OtpFormKind::Append => "Append OTP URI",
OtpFormKind::Validate => "Validate OTP URI",
},
}
}
pub fn rows(&self) -> Vec<String> {
match self {
Self::Init(form) => {
let mut rows = vec![row(
form.focus == 0,
"Path",
if form.path.is_empty() {
"/"
} else {
&form.path
},
)];
rows.push("Recipients (Space toggles):".to_owned());
rows.extend(form.recipients.iter().enumerate().map(|(index, choice)| {
row(
form.focus == index + 1,
if choice.selected { "[x]" } else { "[ ]" },
&choice.label,
)
}));
rows
}
Self::Insert(form) => vec![
row(form.focus == 0, "Entry", &form.entry),
row(form.focus == 1, "Input", form.mode.label()),
row(
form.focus == 2,
"Secret",
if form.secret.is_empty() {
"(empty)"
} else {
"••••••••"
},
),
row(
form.focus == 3,
"Confirm",
if form.mode != InsertMode::Hidden {
"(not required)"
} else if form.confirmation.is_empty() {
"(empty)"
} else {
"••••••••"
},
),
row(form.focus == 4, "Overwrite", yes_no(form.overwrite)),
],
Self::Generate(form) => vec![
row(form.focus == 0, "Entry", &form.entry),
row(
form.focus == 1,
"Length",
if form.length.is_empty() {
"pass default"
} else {
&form.length
},
),
row(form.focus == 2, "Symbols", yes_no(!form.no_symbols)),
row(form.focus == 3, "Overwrite", yes_no(form.overwrite)),
row(form.focus == 4, "In place", yes_no(form.in_place)),
row(
form.focus == 5,
"Presentation",
generated_presentation_label(form.presentation),
),
],
Self::Grep(form) => vec![
row(form.focus == 0, "Pattern", &form.pattern),
row(form.focus == 1, "Ignore case", yes_no(form.ignore_case)),
row(form.focus == 2, "Invert match", yes_no(form.invert_match)),
row(form.focus == 3, "Line numbers", yes_no(form.line_number)),
row(form.focus == 4, "Fixed string", yes_no(form.fixed_strings)),
],
Self::Remove(form) => vec![
row(form.focus == 0, "Target", &form.target),
row(form.focus == 1, "Recursive folder", yes_no(form.recursive)),
row(form.focus == 2, "Force", yes_no(form.force)),
row(
form.focus == 3,
"Confirm permanent removal",
yes_no(form.confirmed),
),
],
Self::Move(form) | Self::Copy(form) => vec![
row(form.focus == 0, "Source", &form.source),
row(form.focus == 1, "Destination", &form.destination),
row(
form.focus == 2,
"Destination is existing directory",
yes_no(form.destination_directory),
),
row(
form.focus == 3,
"Overwrite collision",
yes_no(form.overwrite),
),
],
Self::Otp(form) => {
vec![
row(
form.focus == 0,
"Entry",
if form.kind == OtpFormKind::Validate {
"(validation only)"
} else if form.entry.is_empty() {
"(derive from URI)"
} else {
&form.entry
},
),
row(
form.focus == 1,
"OTP URI",
if form.uri.is_empty() {
"(empty)"
} else {
"••••••••"
},
),
row(
form.focus == 2,
"Force replacement",
if form.kind == OtpFormKind::Validate {
"(not used)"
} else {
yes_no(form.force)
},
),
row(
form.focus == 3,
"Confirm write",
if form.kind == OtpFormKind::Validate {
"(not required)"
} else {
yes_no(form.confirmed)
},
),
]
}
}
}
pub fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> WorkflowInput {
if modifiers.contains(KeyModifiers::CONTROL) {
return match code {
KeyCode::Char('s') => WorkflowInput::Submit,
KeyCode::Char('l' | 'z') => WorkflowInput::Lock,
_ => WorkflowInput::Consumed,
};
}
match code {
KeyCode::Esc => return WorkflowInput::Cancel,
KeyCode::Tab | KeyCode::Down => self.move_focus(true),
KeyCode::BackTab | KeyCode::Up => self.move_focus(false),
KeyCode::Char(' ') => self.space(),
KeyCode::Enter => self.enter(),
KeyCode::Backspace => self.backspace(),
KeyCode::Char(character) if !character.is_control() => self.character(character),
_ => {}
}
WorkflowInput::Consumed
}
pub fn submission(&self) -> Result<WorkflowSubmission, String> {
match self {
Self::Init(form) => {
let key_identities = form
.recipients
.iter()
.filter(|choice| choice.selected)
.map(|choice| choice.identity.clone())
.collect::<Vec<_>>();
if key_identities.is_empty() {
return Err("Select at least one encryption recipient".to_owned());
}
Ok(WorkflowSubmission::Init(InitRequest {
path: (!form.path.trim().is_empty()).then(|| form.path.trim().to_owned()),
key_identities,
}))
}
Self::Insert(form) => {
if form.entry.trim().is_empty() {
return Err("Entry path is required".to_owned());
}
let (input, contents) = match form.mode {
InsertMode::Hidden => (
InsertInput::HiddenConfirmed,
InsertContent::hidden(form.secret.bytes(), form.confirmation.bytes()),
),
InsertMode::Echoed => (
InsertInput::EchoedLine,
InsertContent::echoed(form.secret.bytes()),
),
InsertMode::Multiline => (
InsertInput::Multiline,
Ok(InsertContent::multiline(form.secret.bytes())),
),
};
Ok(WorkflowSubmission::Insert {
request: InsertRequest {
entry: form.entry.trim().to_owned(),
input,
force: form.overwrite,
},
contents: contents.map_err(|error| error.to_string())?,
overwrite: if form.overwrite {
OverwriteDecision::Allow
} else {
OverwriteDecision::Decline
},
})
}
Self::Generate(form) => {
if form.entry.trim().is_empty() {
return Err("Entry path is required".to_owned());
}
let length = if form.length.is_empty() {
None
} else {
Some(
form.length
.parse::<NonZeroUsize>()
.map_err(|_| "Length must be a positive integer".to_owned())?,
)
};
Ok(WorkflowSubmission::Generate {
request: GenerateRequest {
entry: form.entry.trim().to_owned(),
length,
no_symbols: form.no_symbols,
force: form.overwrite,
in_place: form.in_place,
presentation: form.presentation,
},
overwrite: if form.overwrite || form.in_place {
OverwriteDecision::Allow
} else {
OverwriteDecision::Decline
},
})
}
Self::Grep(form) => {
if form.pattern.is_empty() {
return Err("Search pattern is required".to_owned());
}
Ok(WorkflowSubmission::Grep(GrepRequest {
pattern: form.pattern.clone(),
ignore_case: form.ignore_case,
invert_match: form.invert_match,
line_number: form.line_number,
fixed_strings: form.fixed_strings,
}))
}
Self::Remove(form) => {
if form.target.trim().is_empty() {
return Err("Removal target is required".to_owned());
}
if !form.confirmed {
return Err("Explicitly confirm permanent removal".to_owned());
}
Ok(WorkflowSubmission::Remove(RemoveRequest {
entry: form.target.trim().to_owned(),
recursive: form.recursive,
force: form.force,
}))
}
Self::Move(form) => {
let (source, destination, overwrite) = transfer_submission(form)?;
Ok(WorkflowSubmission::Move {
request: MoveRequest {
source,
destination,
force: form.overwrite,
},
overwrite,
})
}
Self::Copy(form) => {
let (source, destination, overwrite) = transfer_submission(form)?;
Ok(WorkflowSubmission::Copy {
request: CopyRequest {
source,
destination,
force: form.overwrite,
},
overwrite,
})
}
Self::Otp(form) => {
if form.uri.is_empty() {
return Err("OTP URI is required".to_owned());
}
let input = OtpInput::line(form.uri.bytes()).map_err(|error| error.to_string())?;
match form.kind {
OtpFormKind::Validate => Ok(WorkflowSubmission::OtpValidate { uri: input }),
OtpFormKind::Insert => {
if !form.confirmed {
return Err("Explicitly confirm the OTP write".to_owned());
}
Ok(WorkflowSubmission::OtpInsert {
request: OtpInsertRequest {
entry: (!form.entry.trim().is_empty())
.then(|| form.entry.trim().to_owned()),
force: form.force,
echo: false,
source: OtpInputSource::Uri,
},
input,
})
}
OtpFormKind::Append => {
if form.entry.trim().is_empty() {
return Err("Entry path is required".to_owned());
}
if !form.confirmed {
return Err("Explicitly confirm the OTP write".to_owned());
}
Ok(WorkflowSubmission::OtpAppend {
request: OtpAppendRequest {
entry: form.entry.trim().to_owned(),
force: form.force,
echo: false,
source: OtpInputSource::Uri,
},
input,
})
}
}
}
}
}
fn focus_count(&self) -> usize {
match self {
Self::Init(form) => form.recipients.len() + 1,
Self::Insert(_) => 5,
Self::Generate(_) => 6,
Self::Grep(_) => 5,
Self::Remove(_) | Self::Move(_) | Self::Copy(_) => 4,
Self::Otp(_) => 4,
}
}
fn move_focus(&mut self, forward: bool) {
let count = self.focus_count();
let focus = match self {
Self::Init(form) => &mut form.focus,
Self::Insert(form) => &mut form.focus,
Self::Generate(form) => &mut form.focus,
Self::Grep(form) => &mut form.focus,
Self::Remove(form) => &mut form.focus,
Self::Move(form) | Self::Copy(form) => &mut form.focus,
Self::Otp(form) => &mut form.focus,
};
*focus = if forward {
(*focus + 1) % count
} else {
(*focus + count - 1) % count
};
}
fn space(&mut self) {
match self {
Self::Init(form) if form.focus > 0 => form.recipients[form.focus - 1].selected ^= true,
Self::Insert(form) if form.focus == 1 => form.mode = form.mode.next(),
Self::Insert(form) if form.focus == 4 => form.overwrite ^= true,
Self::Generate(form) if form.focus == 2 => form.no_symbols ^= true,
Self::Generate(form) if form.focus == 3 => {
form.overwrite ^= true;
if form.overwrite {
form.in_place = false;
}
}
Self::Generate(form) if form.focus == 4 => {
form.in_place ^= true;
if form.in_place {
form.overwrite = false;
}
}
Self::Generate(form) if form.focus == 5 => {
form.presentation = match form.presentation {
GeneratedPresentation::Terminal => GeneratedPresentation::Clipboard,
GeneratedPresentation::Clipboard => GeneratedPresentation::QrCode,
GeneratedPresentation::QrCode => GeneratedPresentation::Terminal,
}
}
Self::Grep(form) if form.focus == 1 => form.ignore_case ^= true,
Self::Grep(form) if form.focus == 2 => form.invert_match ^= true,
Self::Grep(form) if form.focus == 3 => form.line_number ^= true,
Self::Grep(form) if form.focus == 4 => form.fixed_strings ^= true,
Self::Remove(form) if form.focus == 1 => form.recursive ^= true,
Self::Remove(form) if form.focus == 2 => form.force ^= true,
Self::Remove(form) if form.focus == 3 => form.confirmed ^= true,
Self::Move(form) | Self::Copy(form) if form.focus == 2 => {
form.destination_directory ^= true
}
Self::Move(form) | Self::Copy(form) if form.focus == 3 => form.overwrite ^= true,
Self::Otp(form) if form.focus == 2 => form.force ^= true,
Self::Otp(form) if form.focus == 3 => form.confirmed ^= true,
_ => self.character(' '),
}
}
fn enter(&mut self) {
match self {
Self::Insert(form) if form.focus == 2 && form.mode == InsertMode::Multiline => {
form.secret.push('\n')
}
_ => self.space(),
}
}
fn backspace(&mut self) {
match self {
Self::Init(form) if form.focus == 0 => {
form.path.pop();
}
Self::Insert(form) if form.focus == 0 => {
form.entry.pop();
}
Self::Insert(form) if form.focus == 2 => form.secret.pop(),
Self::Insert(form) if form.focus == 3 && form.mode == InsertMode::Hidden => {
form.confirmation.pop()
}
Self::Generate(form) if form.focus == 0 => {
form.entry.pop();
}
Self::Generate(form) if form.focus == 1 => {
form.length.pop();
}
Self::Grep(form) if form.focus == 0 => {
form.pattern.pop();
}
Self::Remove(form) if form.focus == 0 => {
form.target.pop();
}
Self::Move(form) | Self::Copy(form) if form.focus == 0 => {
form.source.pop();
}
Self::Move(form) | Self::Copy(form) if form.focus == 1 => {
form.destination.pop();
}
Self::Otp(form) if form.focus == 0 => {
form.entry.pop();
}
Self::Otp(form) if form.focus == 1 => {
form.uri.pop();
}
_ => {}
}
}
fn character(&mut self, character: char) {
match self {
Self::Init(form) if form.focus == 0 => form.path.push(character),
Self::Insert(form) if form.focus == 0 => form.entry.push(character),
Self::Insert(form)
if form.focus == 2 && (form.mode == InsertMode::Multiline || character != '\n') =>
{
form.secret.push(character)
}
Self::Insert(form)
if form.focus == 3 && form.mode == InsertMode::Hidden && character != '\n' =>
{
form.confirmation.push(character)
}
Self::Generate(form) if form.focus == 0 => form.entry.push(character),
Self::Generate(form) if form.focus == 1 && character.is_ascii_digit() => {
form.length.push(character)
}
Self::Grep(form) if form.focus == 0 => form.pattern.push(character),
Self::Remove(form) if form.focus == 0 => form.target.push(character),
Self::Move(form) | Self::Copy(form) if form.focus == 0 => form.source.push(character),
Self::Move(form) | Self::Copy(form) if form.focus == 1 => {
form.destination.push(character)
}
Self::Otp(form) if form.focus == 0 => form.entry.push(character),
Self::Otp(form) if form.focus == 1 && character != '\n' => form.uri.push(character),
_ => {}
}
}
}
fn transfer_form(source: String, mut destination: String, overwrite: bool) -> TransferForm {
let destination_directory = destination.ends_with('/');
while destination.ends_with('/') {
destination.pop();
}
TransferForm {
source,
destination,
destination_directory,
overwrite,
focus: 0,
}
}
fn transfer_submission(form: &TransferForm) -> Result<(String, String, OverwriteDecision), String> {
if form.source.trim().is_empty() {
return Err("Source path is required".to_owned());
}
if form.destination.trim().is_empty() {
return Err("Destination path is required".to_owned());
}
let mut destination = form.destination.trim().to_owned();
if form.destination_directory {
destination.push('/');
}
Ok((
form.source.trim().to_owned(),
destination,
if form.overwrite {
OverwriteDecision::Allow
} else {
OverwriteDecision::Decline
},
))
}
fn row(focused: bool, label: &str, value: &str) -> String {
format!("{} {label}: {value}", if focused { ">" } else { " " })
}
fn yes_no(value: bool) -> &'static str {
if value { "yes" } else { "no" }
}
fn generated_presentation_label(presentation: GeneratedPresentation) -> &'static str {
match presentation {
GeneratedPresentation::Terminal => "masked viewer",
GeneratedPresentation::Clipboard => "clipboard with timeout",
GeneratedPresentation::QrCode => "terminal QR",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn type_text(form: &mut WorkflowForm, text: &str) {
for character in text.chars() {
form.handle_key(KeyCode::Char(character), KeyModifiers::NONE);
}
}
#[test]
fn hidden_entry_is_confirmed_and_never_rendered_or_debugged() {
let mut form = WorkflowForm::insert(None);
type_text(&mut form, "nested/account");
form.handle_key(KeyCode::Tab, KeyModifiers::NONE);
form.handle_key(KeyCode::Tab, KeyModifiers::NONE);
type_text(&mut form, "do-not-display");
form.handle_key(KeyCode::Tab, KeyModifiers::NONE);
type_text(&mut form, "do-not-display");
let rendered = form.rows().join("\n");
let debug = format!("{form:?}");
assert!(!rendered.contains("do-not-display"));
assert!(!debug.contains("do-not-display"));
assert!(matches!(
form.submission(),
Ok(WorkflowSubmission::Insert { .. })
));
}
#[test]
fn confirmation_and_generation_validation_are_storage_compatible() {
let mut insert = WorkflowForm::insert(None);
type_text(&mut insert, "entry");
insert.handle_key(KeyCode::Tab, KeyModifiers::NONE);
insert.handle_key(KeyCode::Tab, KeyModifiers::NONE);
type_text(&mut insert, "first");
insert.handle_key(KeyCode::Tab, KeyModifiers::NONE);
type_text(&mut insert, "second");
assert!(insert.submission().unwrap_err().contains("match"));
let mut generate = WorkflowForm::generate(None);
type_text(&mut generate, "entry");
generate.handle_key(KeyCode::Tab, KeyModifiers::NONE);
assert!(generate.submission().is_ok());
type_text(&mut generate, "0");
assert!(generate.submission().unwrap_err().contains("positive"));
let qr = WorkflowForm::generate(Some(GenerateRequest {
entry: "entry".to_owned(),
length: None,
no_symbols: false,
force: false,
in_place: false,
presentation: GeneratedPresentation::QrCode,
}));
assert!(matches!(
qr.submission(),
Ok(WorkflowSubmission::Generate {
request: GenerateRequest {
presentation: GeneratedPresentation::QrCode,
..
},
..
})
));
}
#[test]
fn multiline_input_and_generate_options_are_preserved() {
let mut insert = WorkflowForm::insert(Some(InsertRequest {
entry: "notes/item".into(),
input: InsertInput::Multiline,
force: true,
}));
insert.handle_key(KeyCode::Tab, KeyModifiers::NONE);
insert.handle_key(KeyCode::Tab, KeyModifiers::NONE);
type_text(&mut insert, "first");
insert.handle_key(KeyCode::Enter, KeyModifiers::NONE);
type_text(&mut insert, "second");
match insert.submission().expect("submission") {
WorkflowSubmission::Insert {
contents,
overwrite,
..
} => {
assert_eq!(contents.expose(), b"first\nsecond");
assert_eq!(overwrite, OverwriteDecision::Allow);
}
_ => panic!("wrong submission"),
}
}
#[test]
fn search_and_mutation_forms_preserve_every_explicit_storage_choice() {
let grep = WorkflowForm::grep(Some(GrepRequest {
pattern: "literal.*value".to_owned(),
ignore_case: true,
invert_match: true,
line_number: true,
fixed_strings: true,
}));
assert!(matches!(
grep.submission(),
Ok(WorkflowSubmission::Grep(GrepRequest {
ignore_case: true,
invert_match: true,
line_number: true,
fixed_strings: true,
..
}))
));
let mut remove = WorkflowForm::remove(
Some(RemoveRequest {
entry: "nested/folder".to_owned(),
recursive: true,
force: true,
}),
None,
);
assert!(remove.submission().unwrap_err().contains("confirm"));
for _ in 0..3 {
remove.handle_key(KeyCode::Tab, KeyModifiers::NONE);
}
remove.handle_key(KeyCode::Char(' '), KeyModifiers::NONE);
assert!(matches!(
remove.submission(),
Ok(WorkflowSubmission::Remove(RemoveRequest {
recursive: true,
force: true,
..
}))
));
let copied = WorkflowForm::copy_entry(
Some(CopyRequest {
source: "source/entry".to_owned(),
destination: "existing/folder/".to_owned(),
force: true,
}),
None,
);
assert!(
copied
.rows()
.join("\n")
.contains("Destination is existing directory: yes")
);
assert!(matches!(
copied.submission(),
Ok(WorkflowSubmission::Copy {
request: CopyRequest {
ref destination,
force: true,
..
},
overwrite: OverwriteDecision::Allow,
}) if destination == "existing/folder/"
));
let moved = WorkflowForm::move_entry(
Some(MoveRequest {
source: "old".to_owned(),
destination: "new".to_owned(),
force: false,
}),
None,
);
assert!(matches!(
moved.submission(),
Ok(WorkflowSubmission::Move {
overwrite: OverwriteDecision::Decline,
..
})
));
}
}