//! 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, } impl SecretText { fn push(&mut self, character: char) { self.characters.push(character); } fn pop(&mut self) { self.characters.pop(); } fn bytes(&self) -> Vec { self.characters.iter().collect::().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, 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, 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) -> 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::>(); 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) -> 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) -> 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) -> 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, 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, 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, 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 { 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 { match self { Self::Init(form) => { let key_identities = form .recipients .iter() .filter(|choice| choice.selected) .map(|choice| choice.identity.clone()) .collect::>(); 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::() .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, .. }) )); } }