Implement TUI search and mutation workflows

This commit is contained in:
Hermes Agent
2026-08-10 10:11:34 +00:00
parent c86ea9eb0e
commit ce3e4a9f79
13 changed files with 1195 additions and 93 deletions

View File

@@ -164,7 +164,6 @@ const LOCKABLE: &[Mode] = &[
Mode::Help,
Mode::Command,
];
const EDITOR_AND_OVERLAYS: &[Mode] = &[Mode::Editor, Mode::Help, Mode::Command, Mode::Dialog];
#[cfg(test)]
const ALL: &[Mode] = &[
Mode::Browser,
@@ -232,7 +231,13 @@ pub static ACTIONS: &[ActionSpec] = &[
label: "back",
command: "cancel",
bindings: keys!((KeyCode::Esc, KeyModifiers::NONE, "Esc")),
modes: EDITOR_AND_OVERLAYS,
modes: &[
Mode::Browser,
Mode::Editor,
Mode::Help,
Mode::Command,
Mode::Dialog,
],
},
ActionSpec {
action: Action::Lock,
@@ -507,7 +512,7 @@ pub static ACTIONS: &[ActionSpec] = &[
label: "grep decrypted entries",
command: "grep",
bindings: keys!((KeyCode::Char('\\'), KeyModifiers::NONE, "\\")),
modes: &[Mode::Browser],
modes: BROWSER_LIKE,
},
ActionSpec {
action: Action::RemoveEntry,

View File

@@ -10,7 +10,7 @@ use ironstorage::{
crypto::KeyInfo,
document::{DocumentError, EntryDocument, EntryFieldId},
presentation::ClipboardDisposition,
read::{FindResults, TreeModel},
read::{FindResults, GrepResults, TreeModel},
repository::SecretBytes,
write::WriteOutcome,
};
@@ -19,6 +19,7 @@ use crate::{
action::{Action, WorkflowAction},
command::{CommandInvocation, CommandLine, operation_name},
editor::EntryEditor,
search::GrepView,
sidebar::{Sidebar, SidebarIntent},
viewer::EntryViewer,
workflow::{WorkflowForm, WorkflowInput, WorkflowSubmission},
@@ -71,8 +72,10 @@ pub struct StartupData {
#[derive(Debug)]
pub struct WorkflowSuccess {
pub tree: Option<TreeModel>,
pub selection: Option<(String, bool)>,
pub entry: Option<String>,
pub document: Option<Box<EntryDocument>>,
pub grep: Option<GrepResults>,
pub status: String,
}
@@ -84,6 +87,10 @@ pub enum AsyncPayload {
query: String,
results: FindResults,
},
Found {
query: String,
results: FindResults,
},
DocumentLoaded {
entry: String,
document: Box<EntryDocument>,
@@ -178,8 +185,6 @@ pub struct App {
viewer: Option<EntryViewer>,
editor: Option<EntryEditor>,
discard_confirmation: bool,
command_confirmation: Option<CommandRequest>,
command_confirmation_message: Option<String>,
command_line: CommandLine,
command_help: Option<String>,
command_open_target: Option<CommandOpenTarget>,
@@ -187,6 +192,7 @@ pub struct App {
authentication_pending: Option<String>,
workflow: Option<WorkflowForm>,
workflow_pending: bool,
grep_view: Option<GrepView>,
remaining_lease: Option<std::time::Duration>,
terminal_size: (u16, u16),
ticks: u64,
@@ -217,8 +223,6 @@ impl App {
viewer: None,
editor: None,
discard_confirmation: false,
command_confirmation: None,
command_confirmation_message: None,
command_line: CommandLine::default(),
command_help: None,
command_open_target: None,
@@ -226,6 +230,7 @@ impl App {
authentication_pending: None,
workflow: None,
workflow_pending: false,
grep_view: None,
remaining_lease: None,
terminal_size: (0, 0),
ticks: 0,
@@ -292,10 +297,6 @@ impl App {
self.discard_confirmation
}
pub fn command_confirmation_message(&self) -> Option<&str> {
self.command_confirmation_message.as_deref()
}
pub fn command_line(&self) -> &CommandLine {
&self.command_line
}
@@ -316,6 +317,10 @@ impl App {
self.workflow.as_ref()
}
pub fn grep_view(&self) -> Option<&GrepView> {
self.grep_view.as_ref()
}
pub fn remaining_lease(&self) -> Option<std::time::Duration> {
self.remaining_lease
}
@@ -381,6 +386,8 @@ impl App {
}
Ok(AsyncPayload::Refreshed(tree)) => {
self.sidebar.replace_tree(&tree);
self.grep_view = None;
self.focus = PaneFocus::Sidebar;
self.status = "Password store refreshed".to_owned();
}
Ok(AsyncPayload::Filtered { query, results }) => {
@@ -389,6 +396,17 @@ impl App {
}
self.status = format!("Filter: {query} ({} matches)", results.matches().len());
}
Ok(AsyncPayload::Found { query, results }) => {
let count = results.matches().len();
self.sidebar.apply_find_results(&query, &results);
self.grep_view = None;
self.focus = PaneFocus::Sidebar;
self.status = if count == 0 {
format!("Find: {query} (no matches)")
} else {
format!("Find: {query} ({count} matches)")
};
}
Ok(AsyncPayload::DocumentLoaded { entry, document }) => {
if self.mode != Mode::Viewer || self.selected_entry.as_deref() != Some(&entry) {
return ResultDisposition::Stale;
@@ -491,19 +509,33 @@ impl App {
}
self.workflow = None;
self.suspended_mode = None;
if let (Some(entry), Some(document)) = (success.entry, success.document) {
if let Some(grep) = success.grep {
self.selected_entry = None;
self.viewer = None;
self.editor = None;
self.grep_view = Some(GrepView::new(grep));
self.mode = Mode::Browser;
self.focus = PaneFocus::Main;
} else if let (Some(entry), Some(document)) =
(success.entry, success.document)
{
self.selected_entry = Some(entry);
self.viewer = Some(EntryViewer::new(*document));
self.editor = None;
self.grep_view = None;
self.mode = Mode::Viewer;
self.focus = PaneFocus::Main;
} else {
self.selected_entry = None;
self.viewer = None;
self.editor = None;
self.grep_view = None;
self.mode = Mode::Browser;
self.focus = PaneFocus::Sidebar;
}
if let Some((path, directory)) = success.selection {
self.sidebar.select_path(&path, directory);
}
self.status = success.status;
}
Err(error) => {
@@ -556,12 +588,14 @@ impl App {
self.transition(Transition::OpenCommand);
}
Action::Cancel => {
if self.mode == Mode::Editor {
if self.grep_view.is_some() && self.mode == Mode::Browser {
self.grep_view = None;
self.focus = PaneFocus::Sidebar;
self.status = "Decrypted search results closed".to_owned();
} else if self.mode == Mode::Editor {
self.cancel_editor();
} else if self.mode == Mode::Dialog && self.discard_confirmation {
self.keep_editing();
} else if self.mode == Mode::Dialog && self.command_confirmation.is_some() {
self.cancel_command_confirmation();
} else if self.mode == Mode::Dialog && self.workflow.is_some() {
self.cancel_workflow();
} else {
@@ -580,6 +614,15 @@ impl App {
self.transition(Transition::Unlock);
}
Action::Refresh => return AppEffect::RefreshTree,
Action::Next if self.grep_view.is_some() && self.focus == PaneFocus::Main => {
self.grep_view.as_mut().expect("checked grep view").next();
}
Action::Previous if self.grep_view.is_some() && self.focus == PaneFocus::Main => {
self.grep_view
.as_mut()
.expect("checked grep view")
.previous();
}
Action::Next => self.sidebar.move_next(),
Action::Previous => self.sidebar.move_previous(),
Action::PageDown => self.sidebar.page_down(),
@@ -589,6 +632,22 @@ impl App {
Action::Parent => self.sidebar.collapse_or_parent(),
Action::Child => self.sidebar.move_child(),
Action::Activate => {
if self.grep_view.is_some() && self.focus == PaneFocus::Main {
let entry = self
.grep_view
.as_ref()
.and_then(GrepView::selected_entry)
.map(|entry| entry.path().to_string());
if let Some(entry) = entry {
self.sidebar.select_path(&entry, false);
self.grep_view = None;
self.authentication_pending = Some(entry.clone());
self.status = format!("Authenticating to open search result {entry}");
return AppEffect::AuthenticateEntry(entry);
}
self.status = "The decrypted search has no results".to_owned();
return AppEffect::None;
}
if self.authentication_pending.is_none()
&& let SidebarIntent::OpenEntry(path) = self.sidebar.activate()
{
@@ -606,7 +665,11 @@ impl App {
return AppEffect::AuthenticateEntry(path);
}
}
Action::Filter => self.sidebar.begin_filter(),
Action::Filter => {
self.grep_view = None;
self.focus = PaneFocus::Sidebar;
self.sidebar.begin_filter();
}
Action::NextMatch => self.sidebar.next_match(),
Action::PreviousMatch => self.sidebar.previous_match(),
Action::FocusNext if self.mode == Mode::Viewer => {
@@ -739,15 +802,11 @@ impl App {
Action::ConfirmDiscard => {
if self.discard_confirmation {
self.discard_editor();
} else if let Some(request) = self.confirm_command() {
return AppEffect::RunCommand(request);
}
}
Action::KeepEditing => {
if self.discard_confirmation {
self.keep_editing();
} else {
self.cancel_command_confirmation();
}
}
Action::Initialize
@@ -765,6 +824,10 @@ impl App {
WorkflowAction::Initialize
| WorkflowAction::InsertEntry
| WorkflowAction::GenerateEntry
| WorkflowAction::Grep
| WorkflowAction::RemoveEntry
| WorkflowAction::MoveEntry
| WorkflowAction::CopyEntry
) {
self.open_workflow(workflow, None);
return AppEffect::None;
@@ -980,26 +1043,22 @@ impl App {
);
AppEffect::None
}
CommandInvocation::Storage(request @ CommandRequest::Remove(_)) => {
let message = format!(
"Confirm {}? y runs it; n or Esc cancels",
operation_name(&request)
);
self.transition(Transition::Dismiss);
self.command_confirmation = Some(request);
self.command_confirmation_message = Some(message.clone());
self.transition(Transition::OpenDialog);
self.status = message;
AppEffect::None
}
CommandInvocation::Storage(request @ CommandRequest::Init(_))
| CommandInvocation::Storage(request @ CommandRequest::Insert(_))
| CommandInvocation::Storage(request @ CommandRequest::Generate(_)) => {
| CommandInvocation::Storage(request @ CommandRequest::Generate(_))
| CommandInvocation::Storage(request @ CommandRequest::Grep(_))
| CommandInvocation::Storage(request @ CommandRequest::Remove(_))
| CommandInvocation::Storage(request @ CommandRequest::Move(_))
| CommandInvocation::Storage(request @ CommandRequest::Copy(_)) => {
self.transition(Transition::Dismiss);
let workflow = match &request {
CommandRequest::Init(_) => WorkflowAction::Initialize,
CommandRequest::Insert(_) => WorkflowAction::InsertEntry,
CommandRequest::Generate(_) => WorkflowAction::GenerateEntry,
CommandRequest::Grep(_) => WorkflowAction::Grep,
CommandRequest::Remove(_) => WorkflowAction::RemoveEntry,
CommandRequest::Move(_) => WorkflowAction::MoveEntry,
CommandRequest::Copy(_) => WorkflowAction::CopyEntry,
_ => unreachable!(),
};
self.open_workflow(workflow, Some(request));
@@ -1036,6 +1095,8 @@ impl App {
}
fn close_to_browser(&mut self) {
self.grep_view = None;
self.focus = PaneFocus::Sidebar;
if matches!(self.mode, Mode::Viewer | Mode::Editor) {
self.transition(Transition::CloseEntry);
}
@@ -1061,6 +1122,43 @@ impl App {
WorkflowForm::generate(Some(request))
}
(WorkflowAction::GenerateEntry, None) => WorkflowForm::generate(None),
(WorkflowAction::Grep, Some(CommandRequest::Grep(request))) => {
WorkflowForm::grep(Some(request))
}
(WorkflowAction::Grep, None) => WorkflowForm::grep(None),
(WorkflowAction::RemoveEntry, Some(CommandRequest::Remove(request))) => {
WorkflowForm::remove(Some(request), None)
}
(WorkflowAction::RemoveEntry, None) => {
let selected = self
.selected_entry
.as_deref()
.map(|entry| (entry, false))
.or_else(|| {
self.sidebar
.selected()
.map(|selected| (selected.path(), selected.is_directory()))
});
WorkflowForm::remove(None, selected)
}
(WorkflowAction::MoveEntry, Some(CommandRequest::Move(request))) => {
WorkflowForm::move_entry(Some(request), None)
}
(WorkflowAction::MoveEntry, None) => WorkflowForm::move_entry(
None,
self.selected_entry
.as_deref()
.or_else(|| self.sidebar.selected().map(|selected| selected.path())),
),
(WorkflowAction::CopyEntry, Some(CommandRequest::Copy(request))) => {
WorkflowForm::copy_entry(Some(request), None)
}
(WorkflowAction::CopyEntry, None) => WorkflowForm::copy_entry(
None,
self.selected_entry
.as_deref()
.or_else(|| self.sidebar.selected().map(|selected| selected.path())),
),
_ => return,
};
self.workflow = Some(form);
@@ -1088,27 +1186,6 @@ impl App {
self.status = format!("Authentication failed: {message}. Form retained.");
}
fn confirm_command(&mut self) -> Option<CommandRequest> {
if self.mode != Mode::Dialog {
return None;
}
let request = self.command_confirmation.take()?;
self.command_confirmation_message = None;
self.transition(Transition::Dismiss);
self.status = format!("Starting confirmed {} workflow…", operation_name(&request));
Some(request)
}
fn cancel_command_confirmation(&mut self) {
if self.mode != Mode::Dialog || self.command_confirmation.is_none() {
return;
}
self.command_confirmation = None;
self.command_confirmation_message = None;
self.transition(Transition::Dismiss);
self.status = "Destructive command cancelled".to_owned();
}
fn editor_context_active(&self) -> bool {
self.mode == Mode::Editor || self.suspended_mode == Some(Mode::Editor)
}
@@ -1284,6 +1361,7 @@ impl App {
self.selected_entry = Some(entry);
self.viewer = None;
self.editor = None;
self.grep_view = None;
self.status = "Authenticated; loading structured entry…".to_owned();
let transitioned = self.transition(Transition::OpenEntry);
if transitioned {
@@ -1388,14 +1466,13 @@ impl App {
self.viewer = None;
self.editor = None;
self.discard_confirmation = false;
self.command_confirmation = None;
self.command_confirmation_message = None;
self.command_line.clear();
self.command_help = None;
self.command_open_target = None;
self.editor_generation_pending = None;
self.workflow = None;
self.workflow_pending = false;
self.grep_view = None;
self.status = "Locked".to_owned();
} else if current == Mode::Locked {
self.status = "Authentication required".to_owned();
@@ -1664,11 +1741,11 @@ mod tests {
assert!(app.workflow().is_some());
app.dispatch(Action::Cancel);
app.open_test_document("email/personal", fixture_document("email/personal"));
assert!(matches!(
app.dispatch(Action::RemoveEntry),
AppEffect::OpenWorkflow(WorkflowAction::RemoveEntry)
));
assert!(app.status().contains("entry removal"));
assert!(matches!(app.dispatch(Action::RemoveEntry), AppEffect::None));
assert_eq!(app.mode(), Mode::Dialog);
let rows = app.workflow().expect("remove form").rows().join("\n");
assert!(rows.contains("email/personal"));
assert!(rows.contains("Confirm permanent removal: no"));
}
#[test]
@@ -1724,26 +1801,101 @@ mod tests {
AppEffect::None
));
assert_eq!(app.mode(), Mode::Dialog);
assert!(
app.command_confirmation_message()
.is_some_and(|message| message.contains("Confirm remove"))
let rows = app.workflow().expect("remove form").rows().join("\n");
assert!(rows.contains("old/folder"));
assert!(rows.contains("Recursive folder: yes"));
assert!(rows.contains("Confirm permanent removal: no"));
assert!(matches!(
app.handle_workflow_input(
crossterm::event::KeyCode::Char('s'),
crossterm::event::KeyModifiers::CONTROL,
),
Some(AppEffect::None)
));
assert!(app.status().contains("Explicitly confirm"));
for _ in 0..3 {
app.handle_workflow_input(
crossterm::event::KeyCode::Tab,
crossterm::event::KeyModifiers::NONE,
);
}
app.handle_workflow_input(
crossterm::event::KeyCode::Char(' '),
crossterm::event::KeyModifiers::NONE,
);
assert!(matches!(
app.dispatch(Action::ConfirmDiscard),
AppEffect::RunCommand(CommandRequest::Remove(request))
if request.entry == "old/folder" && request.recursive
app.handle_workflow_input(
crossterm::event::KeyCode::Char('s'),
crossterm::event::KeyModifiers::CONTROL,
),
Some(AppEffect::AuthenticateWorkflow(submission))
if matches!(*submission, WorkflowSubmission::Remove(ironstorage::command::RemoveRequest {
ref entry,
recursive: true,
..
}) if entry == "old/folder")
));
app.workflow_authentication_failed("cancel test".to_owned());
app.handle_workflow_input(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
);
assert_eq!(app.mode(), Mode::Browser);
assert!(matches!(
enter_command(&mut app, "remove old/other"),
AppEffect::None
));
assert!(matches!(app.dispatch(Action::KeepEditing), AppEffect::None));
app.handle_workflow_input(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
);
assert_eq!(app.mode(), Mode::Browser);
assert!(app.status().contains("cancelled"));
}
#[test]
fn mutation_failures_retain_all_explicit_choices_for_retry() {
let mut app = App::new();
assert!(matches!(
enter_command(&mut app, "copy --force source/entry destination/folder/"),
AppEffect::None
));
let before = app.workflow().expect("copy form").rows();
assert!(
before
.iter()
.any(|row| row.contains("Overwrite collision: yes"))
);
assert!(
before
.iter()
.any(|row| row.contains("Destination is existing directory: yes"))
);
assert!(matches!(
app.handle_workflow_input(
crossterm::event::KeyCode::Char('s'),
crossterm::event::KeyModifiers::CONTROL,
),
Some(AppEffect::AuthenticateWorkflow(_))
));
let token = app.begin_request();
assert_eq!(
app.apply_result(AsyncResult {
token,
payload: Ok(AsyncPayload::WorkflowFinished(Err(
"typed destination collision".to_owned(),
))),
}),
ResultDisposition::Applied
);
assert_eq!(app.mode(), Mode::Dialog);
assert_eq!(app.workflow().expect("retained form").rows(), before);
assert!(app.status().contains("typed destination collision"));
assert!(app.status().contains("Form retained"));
}
#[test]
fn command_mode_cancellation_unavailable_actions_and_unlock_are_explicit() {
let mut app = App::new();

View File

@@ -8,6 +8,7 @@ pub mod app;
pub mod command;
pub mod editor;
pub mod runtime;
pub mod search;
pub mod sidebar;
pub mod terminal;
pub mod ui;
@@ -263,7 +264,29 @@ fn apply_app_effect(
app.report_status(format!("locked after secure-store cleanup failed: {error}"));
}
}
AppEffect::RunCommand(_) | AppEffect::OpenWorkflow(_) | AppEffect::None => {}
AppEffect::RunCommand(ironstorage::command::CommandRequest::Find(request)) => {
if let Some(config) = app.config().cloned() {
let query = request.terms.join(" ");
let token = app.begin_latest_request();
executor.submit(token, move || {
let repository = ironstorage::repository::Repository::open(config.vault())
.map_err(|error| error.to_string())?;
let keys = ironstorage::crypto::KeyStore::load(config.key_material())
.map_err(|error| error.to_string())?;
let results = ironstorage::read::VaultReader::new(&repository, &keys)
.find(&request.terms)
.map_err(|error| error.to_string())?;
Ok(AsyncPayload::Found { query, results })
});
}
}
AppEffect::RunCommand(request) => {
app.report_status(format!(
"{} is not implemented by this terminal workflow yet",
crate::command::operation_name(&request)
));
}
AppEffect::OpenWorkflow(_) | AppEffect::None => {}
}
}
@@ -373,7 +396,10 @@ fn execute_workflow(
) -> Result<WorkflowSuccess, String> {
use ironstorage::{
generate::{GeneratorConfig, PasswordGenerator},
git::{AutomaticEntryCommitter, AutomaticPolicyCommitter, GitIdentity},
git::{
AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter, GitIdentity,
},
mutation::TreeMutator,
recipient::RecipientPolicyManager,
write::VaultWriter,
};
@@ -383,6 +409,9 @@ fn execute_workflow(
let keys = ironstorage::crypto::KeyStore::load(config.key_material())
.map_err(|error| error.to_string())?;
let identity = GitIdentity::ironstorage();
let mut selection = None;
let mut grep = None;
let mut refresh_tree = true;
let (entry, mut status) = match submission {
WorkflowSubmission::Init(request) => {
let directory = request.path.as_deref().unwrap_or_default();
@@ -428,13 +457,81 @@ fn execute_workflow(
format!("Generated password for {entry}"),
)
}
WorkflowSubmission::Grep(request) => {
let results = ironstorage::read::VaultReader::new(&repository, &keys)
.grep(&request, provider)
.map_err(|error| error.to_string())?;
let count = results.entries().len();
grep = Some(results);
refresh_tree = false;
(
None,
if count == 0 {
"Decrypted search completed with no matches".to_owned()
} else {
format!("Decrypted search found {count} matching entries")
},
)
}
WorkflowSubmission::Remove(request) => {
let target = request.entry.clone();
let mut committer = AutomaticTreeCommitter::for_source(&repository, &target, identity)
.map_err(|error| error.to_string())?;
let outcome = TreeMutator::new(&repository, &keys)
.remove(
&request,
ironstorage::write::OverwriteDecision::Allow,
&mut committer,
)
.map_err(|error| error.to_string())?;
selection = outcome
.selection()
.map(|selection| (selection.display_path(), selection.is_directory()));
(
None,
format!(
"Removed {target} ({} encrypted entries)",
outcome.entries().len()
),
)
}
WorkflowSubmission::Move { request, overwrite } => {
let source = request.source.clone();
let destination = request.destination.clone();
let mut committer = AutomaticTreeCommitter::for_source(&repository, &source, identity)
.map_err(|error| error.to_string())?;
let outcome = TreeMutator::new(&repository, &keys)
.move_tree(&request, overwrite, None, provider, &mut committer)
.map_err(|error| error.to_string())?;
selection = outcome
.selection()
.map(|selection| (selection.display_path(), selection.is_directory()));
(None, format!("Moved {source} to {destination}"))
}
WorkflowSubmission::Copy { request, overwrite } => {
let source = request.source.clone();
let destination = request.destination.clone();
let mut committer = AutomaticTreeCommitter::for_source(&repository, &source, identity)
.map_err(|error| error.to_string())?;
let outcome = TreeMutator::new(&repository, &keys)
.copy(&request, overwrite, None, provider, &mut committer)
.map_err(|error| error.to_string())?;
selection = outcome
.selection()
.map(|selection| (selection.display_path(), selection.is_directory()));
(None, format!("Copied {source} to {destination}"))
}
};
let tree = ironstorage::read::VaultReader::new(&repository, &keys)
.list(&ironstorage::repository::DirectoryPath::root())
.map_err(|error| {
status.push_str(&format!("; sidebar refresh failed: {error}"));
let tree = refresh_tree
.then(|| {
ironstorage::read::VaultReader::new(&repository, &keys)
.list(&ironstorage::repository::DirectoryPath::root())
.map_err(|error| {
status.push_str(&format!("; sidebar refresh failed: {error}"));
})
.ok()
})
.ok();
.flatten();
let document = entry
.as_deref()
.map(|entry| {
@@ -451,8 +548,10 @@ fn execute_workflow(
.flatten();
Ok(WorkflowSuccess {
tree,
selection,
entry,
document,
grep,
status,
})
}
@@ -567,7 +666,8 @@ mod tests {
use ironstorage::{
command::{
GenerateRequest, GeneratedPresentation, InitRequest, InsertInput, InsertRequest,
CopyRequest, GenerateRequest, GeneratedPresentation, GrepRequest, InitRequest,
InsertInput, InsertRequest, MoveRequest, RemoveRequest,
},
crypto::{KeyInfo, SecretProvider, SecretProviderError},
repository::SecretBytes,
@@ -575,7 +675,7 @@ mod tests {
};
use super::*;
use crate::{editor::EntryEditor, viewer::test_support::fixture_document_from};
use crate::{action::Action, editor::EntryEditor, viewer::test_support::fixture_document_from};
#[test]
fn press_and_terminal_repeat_events_dispatch_but_release_does_not() {
@@ -646,6 +746,14 @@ mod tests {
}
}
struct UnavailableSecrets;
impl SecretProvider for UnavailableSecrets {
fn secret_for(&mut self, _key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
Err(SecretProviderError::Unavailable)
}
}
#[test]
fn write_workflows_use_storage_rules_refresh_and_commit_in_isolation() {
let temporary = tempfile::tempdir().expect("temporary workflow store");
@@ -790,6 +898,259 @@ mod tests {
);
}
#[test]
fn decrypted_search_handles_cancel_empty_failure_and_result_navigation() {
let temporary = tempfile::tempdir().expect("temporary search store");
let fixtures = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../crates/storage/tests/fixtures/compatibility");
let store = temporary.path().join("store");
copy_directory(&fixtures.join("stores/basic"), &store);
let config_path = temporary.path().join("config.toml");
fs::write(
&config_path,
format!(
"vault = {:?}\ndefault_key = \"7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30\"\nkey_material = {:?}\n",
store,
fixtures.join("keys"),
),
)
.expect("write config");
let config = ironstorage::config::Config::load(Some(&config_path)).expect("config");
let mut app = App::new();
app.sidebar_mut()
.replace_tree(&load_tree(&config).expect("tree"));
let repository =
ironstorage::repository::Repository::open(config.vault()).expect("repository");
let keys =
ironstorage::crypto::KeyStore::load(config.key_material()).expect("key material");
let terms = vec!["personal".to_owned()];
let find = ironstorage::read::VaultReader::new(&repository, &keys)
.find(&terms)
.expect("name find");
let token = app.begin_request();
assert_eq!(
app.apply_result(crate::app::AsyncResult {
token,
payload: Ok(AsyncPayload::Found {
query: "personal".to_owned(),
results: find,
}),
}),
crate::app::ResultDisposition::Applied
);
assert_eq!(app.sidebar().filter_query(), "personal");
app.sidebar_mut().next_match();
assert_eq!(
app.sidebar().selected().map(|selected| selected.path()),
Some("email/personal")
);
app.sidebar_mut().clear_filter_results();
assert!(matches!(app.dispatch(Action::Grep), AppEffect::None));
assert_eq!(app.mode(), crate::app::Mode::Dialog);
app.handle_workflow_input(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
);
assert_eq!(app.mode(), crate::app::Mode::Browser);
assert!(app.status().contains("cancelled"));
let request = |pattern: &str| GrepRequest {
pattern: pattern.to_owned(),
ignore_case: false,
invert_match: false,
line_number: true,
fixed_strings: true,
};
let empty = execute_workflow(
&config,
WorkflowSubmission::Grep(request("not-present-anywhere")),
&mut FixtureSecrets,
)
.expect("empty grep");
assert!(
empty
.grep
.as_ref()
.is_some_and(|results| results.is_empty())
);
assert!(empty.status.contains("no matches"));
let failure = execute_workflow(
&config,
WorkflowSubmission::Grep(request("password")),
&mut UnavailableSecrets,
)
.expect_err("decryption must need a secret");
assert!(!failure.contains("fixture-alice-passphrase"));
let found = execute_workflow(
&config,
WorkflowSubmission::Grep(request("password")),
&mut FixtureSecrets,
)
.expect("matching grep");
let token = app.begin_request();
assert_eq!(
app.apply_result(crate::app::AsyncResult {
token,
payload: Ok(AsyncPayload::WorkflowFinished(Ok(Box::new(found)))),
}),
crate::app::ResultDisposition::Applied
);
let first = app
.grep_view()
.and_then(crate::search::GrepView::selected_entry)
.expect("first result")
.path()
.to_string();
app.dispatch(Action::Next);
let second = app
.grep_view()
.and_then(crate::search::GrepView::selected_entry)
.expect("second result")
.path()
.to_string();
assert_ne!(first, second);
assert!(matches!(
app.dispatch(Action::Activate),
AppEffect::AuthenticateEntry(ref entry) if entry == &second
));
assert_eq!(
app.sidebar().selected().map(|selected| selected.path()),
Some(second.as_str())
);
assert!(app.grep_view().is_none());
}
#[test]
fn existing_tree_mutations_commit_refresh_and_preserve_failed_collisions() {
let temporary = tempfile::tempdir().expect("temporary mutation store");
let fixtures = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../crates/storage/tests/fixtures/compatibility");
let store = temporary.path().join("store");
copy_directory(&fixtures.join("stores/basic"), &store);
let repository = ironstorage::repository::Repository::open(&store).expect("repository");
let identity = ironstorage::git::GitIdentity::ironstorage();
let git = ironstorage::git::GitRepository::init(&repository, identity.clone())
.expect("initialize git");
let initial_commits = git.log(None).expect("initial log").len();
drop(git);
let config_path = temporary.path().join("config.toml");
fs::write(
&config_path,
format!(
"vault = {:?}\ndefault_key = \"7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30\"\nkey_material = {:?}\n",
store,
fixtures.join("keys"),
),
)
.expect("write config");
let config = ironstorage::config::Config::load(Some(&config_path)).expect("config");
let mut secrets = FixtureSecrets;
let copied = execute_workflow(
&config,
WorkflowSubmission::Copy {
request: CopyRequest {
source: "email/personal".to_owned(),
destination: "team/copied".to_owned(),
force: false,
},
overwrite: OverwriteDecision::Decline,
},
&mut secrets,
)
.expect("copy across recipient boundary");
assert_eq!(copied.selection, Some(("team/copied".to_owned(), false)));
assert!(copied.tree.is_some());
assert_eq!(
fixture_document_from(&store, "team/copied")
.password()
.expect("copied password")
.value(),
b"correct horse fixture"
);
let collision_ciphertext =
fs::read(store.join("team/service.gpg")).expect("collision ciphertext");
let git =
ironstorage::git::GitRepository::open(&repository, identity.clone()).expect("open git");
let commits_before_collision = git.log(None).expect("log").len();
drop(git);
let collision = execute_workflow(
&config,
WorkflowSubmission::Copy {
request: CopyRequest {
source: "email/personal".to_owned(),
destination: "team/service".to_owned(),
force: false,
},
overwrite: OverwriteDecision::Decline,
},
&mut secrets,
);
assert!(collision.is_err());
assert_eq!(
fs::read(store.join("team/service.gpg")).expect("unchanged collision"),
collision_ciphertext
);
let git =
ironstorage::git::GitRepository::open(&repository, identity.clone()).expect("open git");
assert_eq!(git.log(None).expect("log").len(), commits_before_collision);
drop(git);
let moved = execute_workflow(
&config,
WorkflowSubmission::Move {
request: MoveRequest {
source: "team/copied".to_owned(),
destination: "shared/".to_owned(),
force: false,
},
overwrite: OverwriteDecision::Decline,
},
&mut secrets,
)
.expect("move using explicit destination directory semantics");
assert_eq!(moved.selection, Some(("shared/copied".to_owned(), false)));
assert!(store.join("shared/copied.gpg").is_file());
assert!(!store.join("team/copied.gpg").exists());
let copied_directory = execute_workflow(
&config,
WorkflowSubmission::Copy {
request: CopyRequest {
source: "team".to_owned(),
destination: "archive".to_owned(),
force: false,
},
overwrite: OverwriteDecision::Decline,
},
&mut secrets,
)
.expect("copy folder");
assert_eq!(
copied_directory.selection,
Some(("archive".to_owned(), true))
);
assert!(store.join("archive/service.gpg").is_file());
execute_workflow(
&config,
WorkflowSubmission::Remove(RemoveRequest {
entry: "archive".to_owned(),
recursive: true,
force: false,
}),
&mut secrets,
)
.expect("recursive remove");
assert!(!store.join("archive").exists());
let git = ironstorage::git::GitRepository::open(&repository, identity).expect("open git");
assert_eq!(git.log(None).expect("log").len(), initial_commits + 4);
}
fn copy_directory(source: &Path, destination: &Path) {
fs::create_dir_all(destination).expect("create destination");
for entry in fs::read_dir(source).expect("read source") {

50
apps/tui/src/search.rs Normal file
View File

@@ -0,0 +1,50 @@
//! Presentation state for storage-provided decrypted search results.
use ironstorage::read::{GrepEntry, GrepResults};
#[derive(Debug)]
pub struct GrepView {
results: GrepResults,
selected: Option<usize>,
}
impl GrepView {
pub fn new(results: GrepResults) -> Self {
let selected = (!results.entries().is_empty()).then_some(0);
Self { results, selected }
}
pub fn entries(&self) -> &[GrepEntry] {
self.results.entries()
}
pub fn selected_index(&self) -> Option<usize> {
self.selected
}
pub fn selected_entry(&self) -> Option<&GrepEntry> {
self.selected
.and_then(|index| self.results.entries().get(index))
}
pub fn includes_line_numbers(&self) -> bool {
self.results.includes_line_numbers()
}
pub fn next(&mut self) {
let count = self.results.entries().len();
if count != 0 {
self.selected = Some(self.selected.map_or(0, |index| (index + 1) % count));
}
}
pub fn previous(&mut self) {
let count = self.results.entries().len();
if count != 0 {
self.selected = Some(
self.selected
.map_or(0, |index| index.checked_sub(1).unwrap_or(count - 1)),
);
}
}
}

View File

@@ -112,6 +112,12 @@ impl Sidebar {
true
}
pub fn apply_find_results(&mut self, query: &str, results: &FindResults) {
self.filter_query = query.to_owned();
self.editing_filter = false;
let _applied = self.apply_filter_results(query, results);
}
pub fn clear_filter_results(&mut self) {
let previous_index = self.selected_index().unwrap_or(0);
self.filter_query.clear();

View File

@@ -170,6 +170,9 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
))
},
),
Mode::Browser if app.grep_view().is_some() => Paragraph::new(grep_lines(
app.grep_view().expect("checked decrypted search results"),
)),
_ => Paragraph::new(main_text(app)),
};
frame.render_widget(
@@ -252,10 +255,6 @@ fn main_text(app: &App) -> String {
Mode::Dialog if app.discard_confirmation() => {
"Discard all unsaved edits? Press y to discard, n or Esc to keep editing.".to_owned()
}
Mode::Dialog if app.command_confirmation_message().is_some() => app
.command_confirmation_message()
.unwrap_or_default()
.to_owned(),
Mode::Dialog => "Complete or cancel the active dialog.".to_owned(),
Mode::Command => "Enter a command on the bottom line.".to_owned(),
Mode::Help | Mode::Locked => String::new(),
@@ -404,6 +403,37 @@ fn editor_lines(editor: &EntryEditor) -> Vec<Line<'_>> {
.collect()
}
fn grep_lines(view: &crate::search::GrepView) -> Vec<Line<'_>> {
if view.entries().is_empty() {
return vec![Line::from("No decrypted entries matched.")];
}
let mut lines = Vec::new();
for (index, entry) in view.entries().iter().enumerate() {
let selected = view.selected_index() == Some(index);
lines.push(Line::styled(
format!("{} {}", if selected { ">" } else { " " }, entry.path()),
if selected {
Style::default().bg(Color::Blue).fg(Color::White)
} else {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
},
));
for matched in entry.lines() {
let contents = std::str::from_utf8(matched.contents().expose())
.unwrap_or("[non-UTF-8 matched line]");
let prefix = if view.includes_line_numbers() {
format!("{}: ", matched.number())
} else {
String::new()
};
lines.push(Line::raw(format!(" {prefix}{contents}")));
}
}
lines
}
fn mode_title(mode: Mode) -> &'static str {
match mode {
Mode::Browser => "Browser",
@@ -437,6 +467,9 @@ fn context_line(app: &App) -> Paragraph<'static> {
if app.mode() == Mode::Dialog && app.workflow().is_some() {
return Paragraph::new("Tab/Shift-Tab focus Space toggle Ctrl-S submit Esc cancel");
}
if app.grep_view().is_some() {
return Paragraph::new("j/k result Enter open entry Esc close decrypted results");
}
let text = context_actions(app.mode())
.map(|spec| format!("{} {}", spec.bindings[0].display, spec.label))
.collect::<Vec<_>>()
@@ -448,9 +481,6 @@ fn prompt_line(app: &App) -> Paragraph<'static> {
match app.mode() {
Mode::Command => Paragraph::new(format!(":{}", app.command_line().display()))
.style(Style::default().fg(Color::Yellow)),
Mode::Dialog if app.command_confirmation_message().is_some() => {
Paragraph::new("confirm> y / n / Esc").style(Style::default().fg(Color::Yellow))
}
Mode::Dialog if app.workflow().is_some() => {
Paragraph::new("form> ").style(Style::default().fg(Color::Yellow))
}
@@ -589,8 +619,9 @@ mod tests {
crossterm::event::KeyModifiers::NONE,
);
let confirmation = render(120, 24, &app);
assert!(confirmation.contains("Confirm remove"));
assert!(confirmation.contains("confirm> y / n / Esc"));
assert!(confirmation.contains("Remove entry or folder"));
assert!(confirmation.contains("Confirm permanent removal: no"));
assert!(confirmation.contains("Ctrl-S submit"));
}
#[test]

View File

@@ -4,7 +4,10 @@ use std::{fmt, num::NonZeroUsize};
use crossterm::event::{KeyCode, KeyModifiers};
use ironstorage::{
command::{GenerateRequest, GeneratedPresentation, InitRequest, InsertInput, InsertRequest},
command::{
CopyRequest, GenerateRequest, GeneratedPresentation, GrepRequest, InitRequest, InsertInput,
InsertRequest, MoveRequest, RemoveRequest,
},
crypto::KeyInfo,
write::{InsertContent, OverwriteDecision},
};
@@ -104,11 +107,43 @@ pub struct GenerateForm {
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(Debug)]
pub enum WorkflowForm {
Init(InitForm),
Insert(InsertForm),
Generate(GenerateForm),
Grep(GrepForm),
Remove(RemoveForm),
Move(TransferForm),
Copy(TransferForm),
}
#[derive(Debug)]
@@ -123,6 +158,16 @@ pub enum WorkflowSubmission {
request: GenerateRequest,
overwrite: OverwriteDecision,
},
Grep(GrepRequest),
Remove(RemoveRequest),
Move {
request: MoveRequest,
overwrite: OverwriteDecision,
},
Copy {
request: CopyRequest,
overwrite: OverwriteDecision,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -215,11 +260,74 @@ impl WorkflowForm {
})
}
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",
}
}
@@ -285,6 +393,37 @@ impl WorkflowForm {
row(form.focus == 3, "Overwrite", yes_no(form.overwrite)),
row(form.focus == 4, "In place", yes_no(form.in_place)),
],
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),
),
],
}
}
@@ -387,6 +526,53 @@ impl WorkflowForm {
},
})
}
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,
})
}
}
}
@@ -394,6 +580,8 @@ impl WorkflowForm {
match self {
Self::Init(form) => form.recipients.len() + 1,
Self::Insert(_) | Self::Generate(_) => 5,
Self::Grep(_) => 5,
Self::Remove(_) | Self::Move(_) | Self::Copy(_) => 4,
}
}
@@ -403,6 +591,9 @@ impl WorkflowForm {
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,
};
*focus = if forward {
(*focus + 1) % count
@@ -429,6 +620,17 @@ impl WorkflowForm {
form.overwrite = false;
}
}
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.character(' '),
}
}
@@ -460,6 +662,18 @@ impl WorkflowForm {
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();
}
_ => {}
}
}
@@ -482,11 +696,53 @@ impl WorkflowForm {
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)
}
_ => {}
}
}
}
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 { " " })
}
@@ -567,4 +823,89 @@ mod tests {
_ => 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,
..
})
));
}
}