#![forbid(unsafe_code)]
#![deny(clippy::disallowed_types)]

//! Presentation-only state, rendering, and event plumbing for the terminal UI.

pub mod action;
pub mod app;
pub mod command;
pub mod editor;
pub mod runtime;
pub mod search;
pub mod sidebar;
pub mod terminal;
pub mod ui;
pub mod viewer;
pub mod workflow;

use std::{
    io,
    path::PathBuf,
    sync::mpsc::{self, Receiver, Sender},
    time::{Duration, Instant},
};

use crossterm::event::{self, Event, KeyEventKind};
use ratatui::DefaultTerminal;
use zeroize::Zeroize as _;

use crate::{
    action::{KeyResolution, KeyResolver},
    app::{
        App, AppEffect, AsyncPayload, EditorSaveFailure, EditorSaveFailureKind, StartupData,
        WorkflowSuccess,
    },
    runtime::{
        AsyncExecutor, AuthenticationCoordinator, AuthenticationEvent, AuthenticationTarget,
    },
    workflow::WorkflowSubmission,
};

const TICK_INTERVAL: Duration = Duration::from_millis(250);

struct ClipboardCancellation {
    sender: Sender<()>,
    finished: Receiver<Result<(), ironstorage::presentation::ClipboardError>>,
}

#[derive(Default)]
struct ClipboardCancellations(Option<ClipboardCancellation>);

impl ClipboardCancellations {
    fn replace(
        &mut self,
        cancellation: Sender<()>,
        finished: Receiver<Result<(), ironstorage::presentation::ClipboardError>>,
    ) -> Option<Receiver<Result<(), ironstorage::presentation::ClipboardError>>> {
        let previous = self.0.take().map(|active| {
            let _ignored = active.sender.send(());
            active.finished
        });
        self.0 = Some(ClipboardCancellation {
            sender: cancellation,
            finished,
        });
        previous
    }

    fn cancel_all(&mut self) {
        if let Some(cancellation) = self.0.take() {
            let _ignored = cancellation.sender.send(());
        }
    }
}

impl Drop for ClipboardCancellations {
    fn drop(&mut self) {
        self.cancel_all();
    }
}

/// Run the interactive event loop. Polling keeps input responsive while storage
/// work runs on the executor and periodic repaints are pending.
pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
    let mut app = App::new();
    let executor = AsyncExecutor::new();
    let mut clipboard_cancellations = ClipboardCancellations::default();
    let mut authentication = None;
    let mut git_control = None;
    let mut authentication_initialized = false;
    let mut terminal_focused = true;
    let mut key_resolver = KeyResolver::default();
    let color_capability = ui::ColorCapability::detect();
    let startup = app.begin_latest_request();
    executor.submit(startup, || {
        load_startup().map(|startup| AsyncPayload::Startup(Box::new(startup)))
    });

    while !app.should_quit() {
        for result in executor.drain() {
            app.apply_result(result);
        }
        if let Some(effect) = app.take_clipboard_effect() {
            apply_app_effect(
                &mut app,
                effect,
                &mut authentication,
                &executor,
                &mut git_control,
                &mut clipboard_cancellations,
            );
        }
        if !app.git_pending() {
            git_control = None;
        }
        if !authentication_initialized
            && let (Some(config), Some(key)) = (app.config(), app.default_key())
        {
            authentication_initialized = true;
            match AuthenticationCoordinator::system(config.authentication_timeout(), key.clone()) {
                Ok(coordinator) => authentication = Some(coordinator),
                Err(error) => app.authentication_failed(error),
            }
        }
        if terminal_focused
            && let Some(coordinator) = authentication.as_mut()
            && let Some(event) = coordinator.completion()
        {
            apply_authentication_event(
                &mut app,
                coordinator,
                &executor,
                &mut git_control,
                &mut clipboard_cancellations,
                event,
            );
        }

        let unix_seconds = current_unix_seconds().map_err(io::Error::other)?;
        app.observe_time(unix_seconds);
        schedule_totp_refresh(&mut app, authentication.as_ref(), &executor);

        let size = terminal.size()?;
        app.resize(size.width, size.height);
        terminal.draw(|frame| ui::draw_with_color_capability(frame, &app, color_capability))?;
        if !event::poll(TICK_INTERVAL)? {
            app.tick();
            if let Some(coordinator) = authentication.as_mut() {
                if let Some(event) = coordinator.poll_lease() {
                    apply_authentication_event(
                        &mut app,
                        coordinator,
                        &executor,
                        &mut git_control,
                        &mut clipboard_cancellations,
                        event,
                    );
                }
                app.update_remaining_lease(coordinator.remaining_time());
            }
            continue;
        }

        match event::read()? {
            Event::Key(key) if is_dispatchable_key_kind(key.kind) => {
                if let Some(coordinator) = authentication.as_mut()
                    && let Some(event) = coordinator.touch_user_activity()
                {
                    apply_authentication_event(
                        &mut app,
                        coordinator,
                        &executor,
                        &mut git_control,
                        &mut clipboard_cancellations,
                        event,
                    );
                }
                if let Some(effect) = app.handle_command_input(key.code, key.modifiers) {
                    key_resolver.reset();
                    apply_app_effect(
                        &mut app,
                        effect,
                        &mut authentication,
                        &executor,
                        &mut git_control,
                        &mut clipboard_cancellations,
                    );
                    continue;
                }
                if let Some(effect) = app.handle_workflow_input(key.code, key.modifiers) {
                    key_resolver.reset();
                    apply_app_effect(
                        &mut app,
                        effect,
                        &mut authentication,
                        &executor,
                        &mut git_control,
                        &mut clipboard_cancellations,
                    );
                    continue;
                }
                if !key.modifiers.intersects(
                    crossterm::event::KeyModifiers::CONTROL
                        | crossterm::event::KeyModifiers::ALT
                        | crossterm::event::KeyModifiers::SUPER,
                ) && app.handle_editor_input(key.code)
                {
                    key_resolver.reset();
                    continue;
                }
                if app.sidebar().is_editing_filter() {
                    key_resolver.reset();
                    if handle_filter_key(&mut app, key.code) {
                        submit_filter(&mut app, &executor);
                    }
                } else {
                    match key_resolver.feed(app.mode(), key.code, key.modifiers) {
                        KeyResolution::Action(action) => {
                            let effect = app.dispatch(action);
                            apply_app_effect(
                                &mut app,
                                effect,
                                &mut authentication,
                                &executor,
                                &mut git_control,
                                &mut clipboard_cancellations,
                            );
                        }
                        KeyResolution::Pending => {
                            app.report_status("Key sequence pending; Esc cancels".to_owned());
                        }
                        KeyResolution::Unavailable => {
                            app.report_status("Key is unavailable in the current mode".to_owned());
                        }
                    }
                }
            }
            Event::Resize(width, height) => app.resize(width, height),
            Event::Paste(mut pasted) => {
                if let Some(coordinator) = authentication.as_mut()
                    && let Some(event) = coordinator.touch_user_activity()
                {
                    apply_authentication_event(
                        &mut app,
                        coordinator,
                        &executor,
                        &mut git_control,
                        &mut clipboard_cancellations,
                        event,
                    );
                }
                let filtering = app.sidebar().is_editing_filter();
                if app.handle_paste(&pasted) && filtering {
                    submit_filter(&mut app, &executor);
                }
                pasted.zeroize();
            }
            Event::FocusGained => terminal_focused = true,
            Event::FocusLost => {
                terminal_focused = false;
                let native_prompt_pending = authentication
                    .as_ref()
                    .is_some_and(AuthenticationCoordinator::native_prompt_pending);
                handle_terminal_focus_lost(
                    &mut app,
                    &mut authentication,
                    &mut git_control,
                    &mut clipboard_cancellations,
                    native_prompt_pending,
                );
            }
            _ => {}
        }
    }
    Ok(())
}

fn handle_terminal_focus_lost(
    app: &mut App,
    authentication: &mut Option<AuthenticationCoordinator>,
    git_control: &mut Option<ironstorage::git::GitOperationControl>,
    clipboard_cancellations: &mut ClipboardCancellations,
    native_prompt_pending: bool,
) {
    if native_prompt_pending {
        return;
    }
    handle_terminal_ownership_lost(app, authentication, git_control, clipboard_cancellations);
}

fn current_unix_seconds() -> Result<u64, std::time::SystemTimeError> {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|duration| duration.as_secs())
}

fn schedule_totp_refresh(
    app: &mut App,
    authentication: Option<&AuthenticationCoordinator>,
    executor: &AsyncExecutor,
) {
    let Some((entry, field)) = app.begin_totp_refresh() else {
        return;
    };
    let (Some(handle), Some(config)) = (
        authentication.and_then(AuthenticationCoordinator::handle),
        app.config().cloned(),
    ) else {
        app.authentication_failed("authentication lease expired".to_owned());
        return;
    };
    let token = app.begin_request();
    executor.submit(token, move || {
        execute_otp_ui(
            &config,
            crate::app::OtpUiRequest {
                request: ironstorage::command::OtpRequest::Code(
                    ironstorage::command::OtpCodeRequest {
                        entry,
                        clipboard: false,
                    },
                ),
                confirmed_hotp: false,
                field: Some(field),
            },
            handle,
        )
    });
}

fn handle_terminal_ownership_lost(
    app: &mut App,
    authentication: &mut Option<AuthenticationCoordinator>,
    git_control: &mut Option<ironstorage::git::GitOperationControl>,
    clipboard_cancellations: &mut ClipboardCancellations,
) {
    clipboard_cancellations.cancel_all();
    if let Some(control) = git_control.take() {
        control.cancel();
    }
    if let Some(coordinator) = authentication.as_mut() {
        let _ignored = coordinator.lock();
    }
    app.forced_relock("terminal ownership was lost");
}

fn apply_app_effect(
    app: &mut App,
    effect: AppEffect,
    authentication: &mut Option<AuthenticationCoordinator>,
    executor: &AsyncExecutor,
    git_control: &mut Option<ironstorage::git::GitOperationControl>,
    clipboard_cancellations: &mut ClipboardCancellations,
) {
    match effect {
        AppEffect::RefreshTree => {
            if let Some(config) = app.config().cloned() {
                let token = app.begin_latest_request();
                executor.submit(token, move || {
                    load_tree(&config).map(AsyncPayload::Refreshed)
                });
            }
        }
        AppEffect::AuthenticateEntry(entry) => {
            if let Some(coordinator) = authentication.as_mut() {
                coordinator.request_entry(entry);
            } else {
                app.authentication_failed(
                    "operating-system secure storage is unavailable".to_owned(),
                );
            }
        }
        AppEffect::AuthenticateWorkflow(submission) => {
            if let Some(coordinator) = authentication.as_mut() {
                coordinator.request_workflow(submission);
            } else {
                app.workflow_authentication_failed(
                    "operating-system secure storage is unavailable".to_owned(),
                );
            }
        }
        AppEffect::AuthenticateGit(request) => {
            if let Some(coordinator) = authentication.as_mut() {
                app.begin_git_operation(crate::command::operation_name(
                    &ironstorage::command::CommandRequest::Git(request.clone()),
                ));
                coordinator.request_git(request);
            } else {
                app.authentication_failed(
                    "operating-system secure storage is unavailable".to_owned(),
                );
            }
        }
        AppEffect::AuthenticateOtp(request) => {
            if let Some(coordinator) = authentication.as_mut() {
                coordinator.request_otp(request);
            } else {
                app.authentication_failed(
                    "operating-system secure storage is unavailable".to_owned(),
                );
            }
        }
        AppEffect::AuthenticateShow(request) => {
            if let Some(coordinator) = authentication.as_mut() {
                coordinator.request_show(request);
            } else {
                app.authentication_failed(
                    "operating-system secure storage is unavailable".to_owned(),
                );
            }
        }
        AppEffect::CancelGit => {
            if let Some(control) = git_control.as_ref() {
                control.cancel();
            }
        }
        AppEffect::ResolveGit(resolutions) => {
            if let Some(config) = app.config().cloned() {
                let token = app.begin_request();
                let control =
                    ironstorage::git::GitOperationControl::new(executor.progress_reporter(token));
                *git_control = Some(control.clone());
                executor.submit(token, move || {
                    execute_git_resolution(&config, &resolutions, &control)
                });
            }
        }
        AppEffect::CopyFocused {
            presentation,
            value,
        } => {
            let token = app.begin_request();
            let Some(config) = app.config().cloned() else {
                app.apply_result(crate::app::AsyncResult {
                    token,
                    payload: Ok(AsyncPayload::ClipboardFinished {
                        presentation,
                        result: Err(ironstorage::presentation::ClipboardError::Unavailable),
                    }),
                });
                return;
            };
            let (cancel, cancellation) = mpsc::channel();
            let (finished, completion) = mpsc::channel();
            let previous = clipboard_cancellations.replace(cancel, completion);
            let started = executor.clipboard_started_reporter(token);
            executor.submit(token, move || {
                // Finish the old cleanup before taking the new clipboard snapshot,
                // otherwise the new presentation could later restore an older secret.
                let previous = previous.map_or(Ok(()), |previous| {
                    previous.recv().unwrap_or(Err(
                        ironstorage::presentation::ClipboardError::CleanupFailed,
                    ))
                });
                let payload = match (previous, cancellation.try_recv()) {
                    (Err(error), _) => AsyncPayload::ClipboardFinished {
                        presentation,
                        result: Err(error),
                    },
                    (Ok(()), Err(mpsc::TryRecvError::Empty)) => run_clipboard_presentation(
                        ironstorage::presentation::NativeClipboardManager::system(
                            config.clipboard_timeout(),
                        ),
                        presentation,
                        value,
                        started,
                        move |deadline| match cancellation
                            .recv_timeout(deadline.saturating_duration_since(Instant::now()))
                        {
                            Err(mpsc::RecvTimeoutError::Timeout) => {
                                ironstorage::presentation::ClipboardWait::Elapsed
                            }
                            Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => {
                                ironstorage::presentation::ClipboardWait::Cancelled
                            }
                        },
                    ),
                    (Ok(()), Ok(())) | (Ok(()), Err(mpsc::TryRecvError::Disconnected)) => {
                        AsyncPayload::ClipboardFinished {
                            presentation,
                            result: Err(ironstorage::presentation::ClipboardError::Cancelled),
                        }
                    }
                };
                let cleanup = match &payload {
                    AsyncPayload::ClipboardFinished {
                        result: Ok(_) | Err(ironstorage::presentation::ClipboardError::Cancelled),
                        ..
                    } => Ok(()),
                    AsyncPayload::ClipboardFinished {
                        result: Err(error), ..
                    } => Err(*error),
                    _ => unreachable!("clipboard worker returns a clipboard result"),
                };
                let _ignored = finished.send(cleanup);
                Ok(payload)
            });
        }
        AppEffect::GenerateField(target) => {
            let token = app.begin_request();
            executor.submit(token, move || {
                ironstorage::generate::GeneratorConfig::pass_defaults()
                    .generate_secret(None, false)
                    .map(|password| AsyncPayload::GeneratedField { target, password })
                    .map_err(|error| error.to_string())
            });
        }
        AppEffect::SaveDocument {
            config,
            entry,
            editor,
        } => {
            let token = app.begin_request();
            executor.submit(token, move || Ok(save_document(&config, entry, editor)));
        }
        AppEffect::ManualLock => {
            clipboard_cancellations.cancel_all();
            if let Some(coordinator) = authentication.as_mut()
                && let Err(error) = coordinator.lock()
            {
                app.forced_relock("manual lock requested");
                app.report_status(format!("locked after secure-store cleanup failed: {error}"));
            }
        }
        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(ironstorage::command::CommandRequest::Git(request)) => {
            if git_requires_authentication(&request) {
                if let Some(coordinator) = authentication.as_mut() {
                    app.begin_git_operation(crate::command::operation_name(
                        &ironstorage::command::CommandRequest::Git(request.clone()),
                    ));
                    coordinator.request_git(request);
                } else {
                    app.authentication_failed(
                        "operating-system secure storage is unavailable".to_owned(),
                    );
                }
            } else if let Some(config) = app.config().cloned() {
                app.begin_git_operation(crate::command::operation_name(
                    &ironstorage::command::CommandRequest::Git(request.clone()),
                ));
                let token = app.begin_request();
                let control =
                    ironstorage::git::GitOperationControl::new(executor.progress_reporter(token));
                *git_control = Some(control.clone());
                executor.submit(token, move || execute_git(&config, request, None, &control));
            }
        }
        AppEffect::RunCommand(ironstorage::command::CommandRequest::Otp(
            ironstorage::command::OtpRequest::Validate { uri },
        )) => {
            let token = app.begin_request();
            executor.submit(token, move || {
                ironstorage::otp::OtpService::validate(&uri)
                    .map(|()| AsyncPayload::OtpValidated)
                    .map_err(|error| error.to_string())
            });
        }
        AppEffect::RunCommand(request) => {
            app.report_status(format!(
                "{} is not implemented by this terminal workflow yet",
                crate::command::operation_name(&request)
            ));
        }
        AppEffect::None => {}
    }
}

fn run_clipboard_presentation<B, S, W>(
    clipboard: Result<
        ironstorage::presentation::ClipboardManager<B>,
        ironstorage::presentation::ClipboardError,
    >,
    presentation: crate::app::ClipboardPresentationId,
    value: ironstorage::repository::SecretBytes,
    started: S,
    wait: W,
) -> AsyncPayload
where
    B: ironstorage::presentation::ClipboardBackend,
    S: FnOnce(crate::app::ClipboardPresentationId, Instant),
    W: FnOnce(Instant) -> ironstorage::presentation::ClipboardWait,
{
    let result = clipboard.and_then(|mut clipboard| {
        clipboard.copy_with(&value, |duration| {
            let deadline = Instant::now() + duration;
            started(presentation, deadline);
            wait(deadline)
        })
    });
    AsyncPayload::ClipboardFinished {
        presentation,
        result,
    }
}

fn is_dispatchable_key_kind(kind: KeyEventKind) -> bool {
    matches!(kind, KeyEventKind::Press | KeyEventKind::Repeat)
}

fn git_requires_authentication(request: &ironstorage::command::GitRequest) -> bool {
    matches!(
        request,
        ironstorage::command::GitRequest::Diff { .. }
            | ironstorage::command::GitRequest::Fetch { .. }
            | ironstorage::command::GitRequest::Pull { .. }
            | ironstorage::command::GitRequest::Push { .. }
            | ironstorage::command::GitRequest::Sync { .. }
    )
}

fn execute_git(
    config: &ironstorage::config::Config,
    request: ironstorage::command::GitRequest,
    mut credentials: Option<ironstorage::authentication::NativeAuthenticationHandle>,
    control: &ironstorage::git::GitOperationControl,
) -> Result<AsyncPayload, String> {
    use ironstorage::{
        command::{GitConfigRequest, GitRemoteRequest, GitRequest},
        git::{EmbeddedFetchTransport, GitError, GitIdentity, GitRepository, ReqwestGitTransport},
        repository::{Repository, SecretBytes},
    };

    control
        .report(ironstorage::git::GitProgressPhase::Validating)
        .map_err(|error| error.to_string())?;
    let repository = Repository::open(config.vault()).map_err(|error| error.to_string())?;
    let snapshot_remote = match &request {
        GitRequest::Fetch { remote }
        | GitRequest::Pull { remote, .. }
        | GitRequest::Push { remote, .. }
        | GitRequest::Sync { remote } => remote.clone(),
        _ => None,
    };
    let mut git = match request {
        GitRequest::Init => GitRepository::init(&repository, GitIdentity::ironstorage()),
        _ => GitRepository::open(&repository, GitIdentity::ironstorage()),
    }
    .map_err(|error| error.to_string())?;
    let mut changed_snapshot = false;
    let mut details = None;
    let operation = match request {
        GitRequest::Init => "Initialized embedded Git repository".to_owned(),
        GitRequest::Status => "Git status refreshed".to_owned(),
        GitRequest::Log { maximum } => {
            let log = git
                .log(maximum.map(std::num::NonZeroUsize::get))
                .map_err(|error| error.to_string())?;
            details = Some(SecretBytes::new(
                log.iter()
                    .map(|entry| {
                        format!(
                            "{}  {}\n",
                            &entry.id()[..entry.id().len().min(12)],
                            entry.message()
                        )
                    })
                    .collect::<String>()
                    .into_bytes(),
            ));
            format!("Git log: {} commit(s)", log.len())
        }
        GitRequest::Diff { paths } => {
            let handle = credentials
                .as_mut()
                .ok_or_else(|| "Git diff requires authentication".to_owned())?;
            let keys = ironstorage::crypto::KeyStore::load(config.key_material())
                .map_err(|error| error.to_string())?;
            details = Some(
                git.render_diff(
                    &paths.into_iter().map(PathBuf::from).collect::<Vec<_>>(),
                    &keys,
                    handle,
                )
                .map_err(|error| error.to_string())?,
            );
            "Rendered helper-free Git diff".to_owned()
        }
        GitRequest::Add { paths } => {
            git.stage(&paths.into_iter().map(PathBuf::from).collect::<Vec<_>>())
                .map_err(|error| error.to_string())?;
            "Staged Git paths".to_owned()
        }
        GitRequest::Commit { message } => {
            let id = git.commit(&message).map_err(|error| error.to_string())?;
            format!("Created commit {}", &id[..id.len().min(12)])
        }
        GitRequest::Remote(remote) => match remote {
            GitRemoteRequest::List => {
                details = Some(SecretBytes::new(git.remotes().join("\n").into_bytes()));
                "Listed Git remotes".to_owned()
            }
            GitRemoteRequest::GetUrl { name } => {
                details = Some(SecretBytes::new(
                    git.remote_url(&name)
                        .map_err(|error| error.to_string())?
                        .into_bytes(),
                ));
                format!("Git remote {name}")
            }
            GitRemoteRequest::Add { name, url } => {
                git.add_remote(&name, &url)
                    .map_err(|error| error.to_string())?;
                format!("Added Git remote {name}")
            }
            GitRemoteRequest::SetUrl { name, url } => {
                git.set_remote_url(&name, &url)
                    .map_err(|error| error.to_string())?;
                format!("Updated Git remote {name}")
            }
            GitRemoteRequest::Remove { name } => {
                git.remove_remote(&name)
                    .map_err(|error| error.to_string())?;
                format!("Removed Git remote {name}")
            }
        },
        GitRequest::Config(request) => match request {
            GitConfigRequest::Get { key } => {
                let value = git
                    .config_get(&key)
                    .map_err(|error| error.to_string())?
                    .unwrap_or_default();
                details = Some(SecretBytes::new(value.into_bytes()));
                format!("Git configuration {key}")
            }
            GitConfigRequest::Set { key, value } => {
                git.config_set(&key, &value)
                    .map_err(|error| error.to_string())?;
                format!("Updated Git configuration {key}")
            }
        },
        GitRequest::Fetch { remote } => {
            let configured = config
                .git_remote(remote.as_deref())
                .ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?;
            let handle = credentials
                .as_ref()
                .ok_or_else(|| "Git fetch requires authentication".to_owned())?;
            let outcome = git
                .fetch_with_transport_controlled(
                    configured,
                    handle,
                    &EmbeddedFetchTransport,
                    control,
                )
                .map_err(|error| error.to_string())?;
            format!(
                "Fetched {}{}",
                outcome.remote(),
                if outcome.received_pack() {
                    " (received objects)"
                } else {
                    " (up to date)"
                }
            )
        }
        GitRequest::Pull { remote, branch } => {
            let configured = config
                .git_remote(remote.as_deref())
                .ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?;
            let handle = credentials
                .as_ref()
                .ok_or_else(|| "Git pull requires authentication".to_owned())?;
            match git.pull_with_transport_controlled(
                configured,
                branch.as_deref(),
                handle,
                &EmbeddedFetchTransport,
                control,
            ) {
                Ok(outcome) => {
                    changed_snapshot = true;
                    format!("Git pull completed: {outcome:?}")
                }
                Err(GitError::MergeConflicts { conflicts }) => {
                    let snapshot = git
                        .snapshot(Some(configured), 10)
                        .map_err(|error| error.to_string())?;
                    return Ok(AsyncPayload::GitFinished {
                        snapshot: Box::new(snapshot),
                        tree: None,
                        message: format!(
                            "Git pull requires resolution of {} conflict(s)",
                            conflicts.len()
                        ),
                        conflicts,
                        details: None,
                    });
                }
                Err(error) => return Err(error.to_string()),
            }
        }
        GitRequest::Push { remote, branch } => {
            let configured = config
                .git_remote(remote.as_deref())
                .ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?;
            let handle = credentials
                .as_ref()
                .ok_or_else(|| "Git push requires authentication".to_owned())?;
            let outcome = git
                .push_with_transport_controlled(
                    configured,
                    branch.as_deref(),
                    handle,
                    &ReqwestGitTransport,
                    control,
                )
                .map_err(|error| error.to_string())?;
            format!(
                "Pushed {} {}",
                outcome.remote(),
                &outcome.new_id()[..outcome.new_id().len().min(12)]
            )
        }
        GitRequest::Sync { remote } => {
            let configured = config
                .git_remote(remote.as_deref())
                .ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?;
            let handle = credentials
                .as_ref()
                .ok_or_else(|| "Git synchronization requires authentication".to_owned())?;
            let pull = match git.pull_with_transport_controlled(
                configured,
                None,
                handle,
                &EmbeddedFetchTransport,
                control,
            ) {
                Ok(outcome) => outcome,
                Err(GitError::MergeConflicts { conflicts }) => {
                    let snapshot = git
                        .snapshot(Some(configured), 10)
                        .map_err(|error| error.to_string())?;
                    return Ok(AsyncPayload::GitFinished {
                        snapshot: Box::new(snapshot),
                        tree: None,
                        message: format!(
                            "Git synchronization requires resolution of {} conflict(s)",
                            conflicts.len()
                        ),
                        conflicts,
                        details: None,
                    });
                }
                Err(error) => return Err(error.to_string()),
            };
            let push = git
                .push_with_transport_controlled(
                    configured,
                    None,
                    handle,
                    &ReqwestGitTransport,
                    control,
                )
                .map_err(|error| error.to_string())?;
            changed_snapshot = true;
            format!(
                "Git synchronization completed: {pull:?}; pushed {}",
                &push.new_id()[..push.new_id().len().min(12)]
            )
        }
    };
    control
        .report(ironstorage::git::GitProgressPhase::Refreshing)
        .map_err(|error| error.to_string())?;
    let configured = config.git_remote(snapshot_remote.as_deref());
    let snapshot = git
        .snapshot(configured, 10)
        .map_err(|error| error.to_string())?;
    let tree = if changed_snapshot {
        Some(load_tree(config)?)
    } else {
        None
    };
    Ok(AsyncPayload::GitFinished {
        snapshot: Box::new(snapshot),
        tree,
        message: operation,
        conflicts: Vec::new(),
        details,
    })
}

fn execute_git_resolution(
    config: &ironstorage::config::Config,
    resolutions: &[ironstorage::git::GitConflictResolution],
    control: &ironstorage::git::GitOperationControl,
) -> Result<AsyncPayload, String> {
    use ironstorage::git::{GitIdentity, GitRepository};
    control
        .report(ironstorage::git::GitProgressPhase::Integrating)
        .map_err(|error| error.to_string())?;
    let configured = config
        .git_remote(None)
        .ok_or_else(|| "no HTTPS Git remote is configured".to_owned())?;
    let repository = ironstorage::repository::Repository::open(config.vault())
        .map_err(|error| error.to_string())?;
    let git = GitRepository::open(&repository, GitIdentity::ironstorage())
        .map_err(|error| error.to_string())?;
    git.resolve_fetched(configured, None, resolutions)
        .map_err(|error| error.to_string())?;
    control
        .report(ironstorage::git::GitProgressPhase::Refreshing)
        .map_err(|error| error.to_string())?;
    let snapshot = git
        .snapshot(Some(configured), 10)
        .map_err(|error| error.to_string())?;
    let tree = load_tree(config)?;
    Ok(AsyncPayload::GitFinished {
        snapshot: Box::new(snapshot),
        tree: Some(tree),
        message: "Merge conflicts resolved and committed".to_owned(),
        conflicts: Vec::new(),
        details: None,
    })
}

fn execute_otp_ui(
    config: &ironstorage::config::Config,
    request: crate::app::OtpUiRequest,
    mut provider: ironstorage::authentication::NativeAuthenticationHandle,
) -> Result<AsyncPayload, String> {
    use ironstorage::{
        command::{OtpRequest, OtpUriPresentation},
        otp::{OtpKind, OtpService},
        presentation::QrMatrix,
    };
    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 service = OtpService::new(&repository, &keys);
    let field = request.field;
    match request.request {
        OtpRequest::Code(code_request) => {
            let uri = service
                .uri(&code_request.entry, &mut provider)
                .map_err(|error| error.to_string())?;
            if uri.kind() == OtpKind::Hotp && !request.confirmed_hotp {
                return Err("HOTP generation requires explicit confirmation".to_owned());
            }
            let unix_seconds = current_unix_seconds()
                .map_err(|_| "the system clock is before the Unix epoch".to_owned())?;
            let outcome = service
                .code_automatic(&code_request.entry, unix_seconds, None, &mut provider)
                .map_err(|error| error.to_string())?;
            let validity = outcome.validity();
            let code = ironstorage::repository::SecretBytes::new(outcome.code().expose().to_vec());
            let tree = validity.counter().map(|_| load_tree(config)).transpose()?;
            Ok(AsyncPayload::OtpCodeFinished {
                entry: code_request.entry,
                field,
                code,
                validity,
                observed_at: unix_seconds,
                clipboard: code_request.clipboard,
                tree,
            })
        }
        OtpRequest::Uri(uri_request) => {
            let uri = service
                .uri(&uri_request.entry, &mut provider)
                .map_err(|error| error.to_string())?;
            let payload =
                ironstorage::repository::SecretBytes::new(uri.encoded().expose().to_vec());
            let (presentation, qr) = match uri_request.presentation {
                OtpUriPresentation::Terminal => (crate::app::OtpPresentationTarget::Terminal, None),
                OtpUriPresentation::Clipboard => {
                    (crate::app::OtpPresentationTarget::Clipboard, None)
                }
                OtpUriPresentation::QrCode => (
                    crate::app::OtpPresentationTarget::Qr,
                    Some(QrMatrix::encode(&payload).map_err(|error| error.to_string())?),
                ),
            };
            Ok(AsyncPayload::OtpUriFinished {
                entry: uri_request.entry,
                presentation,
                payload,
                qr,
            })
        }
        _ => Err("this OTP request is not a presentation operation".to_owned()),
    }
}

fn execute_show_presentation(
    config: &ironstorage::config::Config,
    request: ironstorage::command::ShowRequest,
    mut provider: impl ironstorage::crypto::SecretProvider,
) -> Result<AsyncPayload, String> {
    use ironstorage::{
        presentation::QrMatrix,
        read::{PresentationChannel, ShowOutput, VaultReader},
    };
    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 ShowOutput::Present(secret) = VaultReader::new(&repository, &keys)
        .execute_show(&request, &mut provider)
        .map_err(|error| error.to_string())?
    else {
        return Err("the show request did not select a presentation channel".to_owned());
    };
    let status = format!("Presented {} line {}", secret.entry(), secret.line());
    let presentation = match secret.channel() {
        PresentationChannel::Clipboard => crate::app::SecretPresentation::Clipboard(
            ironstorage::repository::SecretBytes::new(secret.contents().expose().to_vec()),
        ),
        PresentationChannel::QrCode => crate::app::SecretPresentation::Qr {
            title: "Entry QR".to_owned(),
            matrix: QrMatrix::encode(secret.contents()).map_err(|error| error.to_string())?,
        },
    };
    Ok(AsyncPayload::SecretPresented {
        status,
        presentation,
    })
}

fn load_startup() -> Result<StartupData, String> {
    let config = ironstorage::config::Config::load(None).map_err(|error| error.to_string())?;
    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 key_handle = keys
        .resolve(config.default_key().as_str())
        .map_err(|error| error.to_string())?;
    let key = keys
        .infos()
        .find(|key| key.fingerprint() == key_handle.fingerprint())
        .ok_or_else(|| "the configured OpenPGP key is unavailable".to_owned())?;
    let all_keys = keys.infos().collect::<Vec<_>>();
    let tree = ironstorage::read::VaultReader::new(&repository, &keys)
        .list(&ironstorage::repository::DirectoryPath::root())
        .map_err(|error| error.to_string())?;
    Ok(StartupData {
        config,
        tree,
        key,
        keys: all_keys,
    })
}

fn apply_authentication_event(
    app: &mut App,
    coordinator: &AuthenticationCoordinator,
    executor: &AsyncExecutor,
    git_control: &mut Option<ironstorage::git::GitOperationControl>,
    clipboard_cancellations: &mut ClipboardCancellations,
    event: AuthenticationEvent,
) {
    match event {
        AuthenticationEvent::Granted(AuthenticationTarget::Entry(entry)) => {
            if app.authentication_granted(entry.clone()) {
                let (Some(config), Some(handle)) = (app.config().cloned(), coordinator.handle())
                else {
                    app.authentication_failed(
                        "authentication completed without an active secure-store lease".to_owned(),
                    );
                    return;
                };
                let token = app.begin_latest_request();
                executor.submit(token, move || {
                    load_document(&config, &entry, handle).map(|document| {
                        AsyncPayload::DocumentLoaded {
                            entry,
                            document: Box::new(document),
                        }
                    })
                });
            }
        }
        AuthenticationEvent::Granted(AuthenticationTarget::Workflow(submission)) => {
            if !app.workflow_authentication_granted() {
                return;
            }
            let (Some(config), Some(mut handle)) = (app.config().cloned(), coordinator.handle())
            else {
                app.workflow_authentication_failed(
                    "authentication completed without an active secure-store lease".to_owned(),
                );
                return;
            };
            let token = app.begin_request();
            executor.submit(token, move || {
                Ok(AsyncPayload::WorkflowFinished(
                    execute_workflow(&config, *submission, &mut handle).map(Box::new),
                ))
            });
        }
        AuthenticationEvent::Granted(AuthenticationTarget::Git(request)) => {
            let (Some(config), Some(handle)) = (app.config().cloned(), coordinator.handle()) else {
                app.authentication_failed(
                    "authentication completed without an active secure-store lease".to_owned(),
                );
                return;
            };
            let token = app.begin_request();
            let control =
                ironstorage::git::GitOperationControl::new(executor.progress_reporter(token));
            *git_control = Some(control.clone());
            executor.submit(token, move || {
                execute_git(&config, request, Some(handle), &control)
            });
        }
        AuthenticationEvent::Granted(AuthenticationTarget::Otp(request)) => {
            let (Some(config), Some(handle)) = (app.config().cloned(), coordinator.handle()) else {
                app.authentication_failed(
                    "authentication completed without an active secure-store lease".to_owned(),
                );
                return;
            };
            let token = app.begin_request();
            executor.submit(token, move || execute_otp_ui(&config, request, handle));
        }
        AuthenticationEvent::Granted(AuthenticationTarget::Show(request)) => {
            let (Some(config), Some(handle)) = (app.config().cloned(), coordinator.handle()) else {
                app.authentication_failed(
                    "authentication completed without an active secure-store lease".to_owned(),
                );
                return;
            };
            let token = app.begin_request();
            executor.submit(token, move || {
                execute_show_presentation(&config, request, handle)
            });
        }
        AuthenticationEvent::Failed { workflow, message } => {
            if workflow {
                app.workflow_authentication_failed(message);
            } else {
                app.authentication_failed(message);
            }
        }
        AuthenticationEvent::Expired => {
            handle_authentication_expiry(app, clipboard_cancellations);
        }
    }
}

fn handle_authentication_expiry(
    app: &mut App,
    clipboard_cancellations: &mut ClipboardCancellations,
) {
    clipboard_cancellations.cancel_all();
    app.forced_relock("authentication lease expired");
}

fn load_document(
    config: &ironstorage::config::Config,
    entry: &str,
    mut handle: ironstorage::authentication::NativeAuthenticationHandle,
) -> Result<ironstorage::document::EntryDocument, String> {
    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())?;
    ironstorage::document::EntryDocumentService::new(&repository, &keys)
        .open(entry, &mut handle)
        .map_err(|error| error.to_string())
}

fn execute_workflow(
    config: &ironstorage::config::Config,
    submission: WorkflowSubmission,
    provider: &mut impl ironstorage::crypto::SecretProvider,
) -> Result<WorkflowSuccess, String> {
    use ironstorage::{
        generate::{GeneratorConfig, PasswordGenerator},
        git::{
            AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter, GitIdentity,
        },
        mutation::TreeMutator,
        otp::OtpService,
        recipient::RecipientPolicyManager,
        write::VaultWriter,
    };

    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 identity = GitIdentity::ironstorage();
    let mut selection = None;
    let mut grep = None;
    let mut presentation = None;
    let mut refresh_tree = true;
    let (entry, mut status) = match submission {
        WorkflowSubmission::Init(request) => {
            let directory = request.path.as_deref().unwrap_or_default();
            let mut committer =
                AutomaticPolicyCommitter::for_directory(&repository, directory, identity)
                    .map_err(|error| error.to_string())?;
            let outcome = RecipientPolicyManager::new(&repository, &keys)
                .apply_init(&request, None, provider, &mut committer)
                .map_err(|error| error.to_string())?;
            (
                None,
                format!(
                    "Initialized {} recipient(s) for {}",
                    outcome.recipients().len(),
                    outcome.directory()
                ),
            )
        }
        WorkflowSubmission::Insert {
            request,
            contents,
            overwrite,
        } => {
            let entry = request.entry.clone();
            let mut committer = AutomaticEntryCommitter::for_entry(&repository, &entry, identity)
                .map_err(|error| error.to_string())?;
            VaultWriter::new(&repository, &keys)
                .insert(&request, contents, overwrite, None, &mut committer)
                .map_err(|error| error.to_string())?;
            (Some(entry.clone()), format!("Inserted {entry}"))
        }
        WorkflowSubmission::Generate { request, overwrite } => {
            let entry = request.entry.clone();
            let mut committer = AutomaticEntryCommitter::for_entry(&repository, &entry, identity)
                .map_err(|error| error.to_string())?;
            let outcome =
                PasswordGenerator::new(&repository, &keys, GeneratorConfig::pass_defaults())
                    .generate(&request, overwrite, None, provider, &mut committer)
                    .map_err(|error| error.to_string())?;
            presentation = match outcome.channel() {
                ironstorage::generate::GeneratedChannel::Terminal => None,
                ironstorage::generate::GeneratedChannel::Clipboard => {
                    Some(crate::app::SecretPresentation::Clipboard(
                        ironstorage::repository::SecretBytes::new(
                            outcome.password().expose().to_vec(),
                        ),
                    ))
                }
                ironstorage::generate::GeneratedChannel::QrCode => {
                    Some(crate::app::SecretPresentation::Qr {
                        title: "Generated password QR".to_owned(),
                        matrix: ironstorage::presentation::QrMatrix::encode(outcome.password())
                            .map_err(|error| error.to_string())?,
                    })
                }
            };
            (
                Some(entry.clone()),
                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::Kdbx { request, password } => {
            let outcome = ironstorage::kdbx::KdbxImporter::new(&repository, &keys)
                .import(&request, password, provider, identity)
                .map_err(|error| error.to_string())?;
            (
                None,
                format!(
                    "KDBX import: {} added, {} updated, {} unchanged, {} skipped",
                    outcome.added(),
                    outcome.updated(),
                    outcome.unchanged(),
                    outcome.skipped()
                ),
            )
        }
        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}"))
        }
        WorkflowSubmission::OtpInsert { request, input } => {
            let service = OtpService::new(&repository, &keys);
            let plan = service
                .prepare_insert(&request, input)
                .map_err(|error| error.to_string())?;
            let path = plan.path().to_string();
            let mut committer =
                AutomaticEntryCommitter::for_entry(&repository, &path, GitIdentity::ironstorage())
                    .map_err(|error| error.to_string())?;
            let outcome = service
                .finish_insert(
                    plan,
                    ironstorage::write::OverwriteDecision::Allow,
                    ironstorage::write::OverwriteDecision::Allow,
                    None,
                    &mut committer,
                )
                .map_err(|error| error.to_string())?;
            selection = Some((outcome.path().to_string(), false));
            (None, format!("Inserted OTP URI at {}", outcome.path()))
        }
        WorkflowSubmission::OtpAppend { request, input } => {
            let service = OtpService::new(&repository, &keys);
            let session = service
                .begin_append(&request, provider)
                .map_err(|error| error.to_string())?;
            let path = session.path().to_string();
            let mut committer =
                AutomaticEntryCommitter::for_entry(&repository, &path, GitIdentity::ironstorage())
                    .map_err(|error| error.to_string())?;
            let outcome = service
                .finish_append(
                    session,
                    input,
                    ironstorage::write::OverwriteDecision::Allow,
                    None,
                    &mut committer,
                )
                .map_err(|error| error.to_string())?;
            selection = Some((outcome.path().to_string(), false));
            (None, format!("Updated OTP URI at {}", outcome.path()))
        }
        WorkflowSubmission::OtpValidate { uri } => {
            OtpService::validate_input(uri).map_err(|error| error.to_string())?;
            refresh_tree = false;
            (None, "OTP URI is valid".to_owned())
        }
    };
    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()
        })
        .flatten();
    let document = entry
        .as_deref()
        .map(|entry| {
            ironstorage::document::EntryDocumentService::new(&repository, &keys)
                .open(entry, provider)
                .map(Box::new)
                .map_err(|error| error.to_string())
        })
        .transpose()
        .map_err(|error| {
            status.push_str(&format!("; entry opening failed: {error}"));
        })
        .ok()
        .flatten();
    Ok(WorkflowSuccess {
        tree,
        selection,
        entry,
        document,
        grep,
        presentation,
        status,
    })
}

fn save_document(
    config: &ironstorage::config::Config,
    entry: String,
    editor: Box<editor::EntryEditor>,
) -> AsyncPayload {
    let result = save_document_inner(config, &entry, &editor);
    AsyncPayload::DocumentSaveFinished {
        entry,
        editor,
        result,
    }
}

fn save_document_inner(
    config: &ironstorage::config::Config,
    entry: &str,
    editor: &editor::EntryEditor,
) -> Result<ironstorage::write::WriteOutcome, EditorSaveFailure> {
    let repository =
        ironstorage::repository::Repository::open(config.vault()).map_err(|error| {
            EditorSaveFailure::new(EditorSaveFailureKind::Storage, error.to_string())
        })?;
    let keys = ironstorage::crypto::KeyStore::load(config.key_material()).map_err(|error| {
        EditorSaveFailure::new(EditorSaveFailureKind::Storage, error.to_string())
    })?;
    let identity = ironstorage::git::GitIdentity::ironstorage();
    let mut committer =
        ironstorage::git::AutomaticEntryCommitter::for_entry(&repository, entry, identity)
            .map_err(|error| {
                EditorSaveFailure::new(EditorSaveFailureKind::Storage, error.to_string())
            })?;
    ironstorage::document::EntryDocumentService::new(&repository, &keys)
        .save_recoverable(editor.document(), None, &mut committer)
        .map_err(|error| {
            let kind = if matches!(
                &error,
                ironstorage::document::DocumentError::Write(
                    ironstorage::write::WriteError::ConcurrentModification { .. }
                )
            ) {
                EditorSaveFailureKind::Conflict
            } else if matches!(
                &error,
                ironstorage::document::DocumentError::Write(
                    ironstorage::write::WriteError::Unchanged
                )
            ) {
                EditorSaveFailureKind::Unchanged
            } else {
                EditorSaveFailureKind::Storage
            };
            EditorSaveFailure::new(kind, error.to_string())
        })
}

fn load_tree(config: &ironstorage::config::Config) -> Result<ironstorage::read::TreeModel, String> {
    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())?;
    ironstorage::read::VaultReader::new(&repository, &keys)
        .list(&ironstorage::repository::DirectoryPath::root())
        .map_err(|error| error.to_string())
}

fn submit_filter(app: &mut App, executor: &AsyncExecutor) {
    let query = app.sidebar().filter_query().to_owned();
    if query.is_empty() {
        app.sidebar_mut().reset_filter_results();
        return;
    }
    let Some(config) = app.config().cloned() else {
        return;
    };
    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(std::slice::from_ref(&query))
            .map_err(|error| error.to_string())?;
        Ok(AsyncPayload::Filtered { query, results })
    });
}

fn handle_filter_key(app: &mut App, code: crossterm::event::KeyCode) -> bool {
    use crossterm::event::KeyCode;
    match code {
        KeyCode::Esc => {
            app.sidebar_mut().clear_filter_results();
            false
        }
        KeyCode::Enter => {
            app.sidebar_mut().finish_filter();
            false
        }
        KeyCode::Backspace => app.sidebar_mut().pop_filter_character(),
        KeyCode::Char(character) => app.sidebar_mut().push_filter_character(character),
        _ => false,
    }
}

#[cfg(test)]
mod tests {
    use std::{fs, path::Path};

    use ironstorage::{
        command::{
            CopyRequest, GenerateRequest, GeneratedPresentation, GrepRequest, InitRequest,
            InsertInput, InsertRequest, MoveRequest, Presentation, RemoveRequest, ShowRequest,
        },
        crypto::{KeyInfo, SecretProvider, SecretProviderError},
        repository::SecretBytes,
        write::{InsertContent, OverwriteDecision},
    };

    use super::*;
    use crate::{
        action::Action,
        editor::EntryEditor,
        viewer::test_support::{fixture_document, fixture_document_from},
    };

    #[test]
    fn press_and_terminal_repeat_events_dispatch_but_release_does_not() {
        assert!(is_dispatchable_key_kind(KeyEventKind::Press));
        assert!(is_dispatchable_key_kind(KeyEventKind::Repeat));
        assert!(!is_dispatchable_key_kind(KeyEventKind::Release));
    }

    #[test]
    fn production_tui_sources_preserve_the_presentation_only_boundary() {
        let sources = [
            ("action.rs", include_str!("action.rs")),
            ("app.rs", include_str!("app.rs")),
            ("command.rs", include_str!("command.rs")),
            ("editor.rs", include_str!("editor.rs")),
            ("lib.rs", include_str!("lib.rs")),
            ("runtime.rs", include_str!("runtime.rs")),
            ("search.rs", include_str!("search.rs")),
            ("sidebar.rs", include_str!("sidebar.rs")),
            ("terminal.rs", include_str!("terminal.rs")),
            ("ui.rs", include_str!("ui.rs")),
            ("viewer.rs", include_str!("viewer.rs")),
            ("workflow.rs", include_str!("workflow.rs")),
        ];
        for (name, source) in sources {
            let production = source.split("#[cfg(test)]").next().unwrap_or(source);
            let forbidden_tokens = [
                ["std::", "process"].concat(),
                ["process", "::Command"].concat(),
                ["Command", "::new("].concat(),
                ["std::", "fs::"].concat(),
                ["fs::", "read("].concat(),
                ["fs::", "write("].concat(),
                [".read", "_entry("].concat(),
                [".write", "_entry("].concat(),
                ["OtpUri", "::parse"].concat(),
                ["qrcode", "::QrCode"].concat(),
                ["unsafe", " {"].concat(),
            ];
            for forbidden in &forbidden_tokens {
                assert!(
                    !production.contains(forbidden),
                    "{name} crosses the TUI architecture boundary with {forbidden}"
                );
            }
        }
    }

    #[test]
    fn terminal_ownership_loss_cancels_presentations_and_forces_relock() {
        let mut app = App::new();
        app.open_test_document("email/personal", fixture_document("email/personal"));
        let presentation = match app.dispatch(Action::Copy) {
            AppEffect::CopyFocused { presentation, .. } => presentation,
            effect => panic!("unexpected effect: {effect:?}"),
        };
        let mut authentication = None;
        let mut git_control = None;
        let mut clipboard = ClipboardCancellations::default();
        let (first_cancel, first_cancelled) = std::sync::mpsc::channel();
        let (first_finished, first_completion) = std::sync::mpsc::channel();
        assert!(clipboard.replace(first_cancel, first_completion).is_none());
        let (cancel, cancelled) = std::sync::mpsc::channel();
        let (_finished, completion) = std::sync::mpsc::channel();
        let previous = clipboard
            .replace(cancel, completion)
            .expect("previous clipboard completion");
        first_cancelled
            .recv_timeout(std::time::Duration::from_secs(1))
            .expect("replacement cancels the previous clipboard presentation");
        first_finished.send(Ok(())).expect("finish first cleanup");
        previous
            .recv_timeout(std::time::Duration::from_secs(1))
            .expect("replacement waits for previous cleanup")
            .expect("previous cleanup succeeds");

        handle_terminal_ownership_lost(
            &mut app,
            &mut authentication,
            &mut git_control,
            &mut clipboard,
        );

        assert_eq!(app.mode(), crate::app::Mode::Locked);
        assert!(!app.clipboard_pending());
        assert!(app.status().contains("terminal ownership was lost"));
        cancelled
            .recv_timeout(std::time::Duration::from_secs(1))
            .expect("clipboard cancellation");
        assert_eq!(presentation.0, 0);
    }

    #[test]
    fn native_authentication_focus_handoff_waits_without_relocking() {
        let mut app = App::new();
        let mut authentication = None;
        let mut git_control = None;
        let mut clipboard = ClipboardCancellations::default();

        handle_terminal_focus_lost(
            &mut app,
            &mut authentication,
            &mut git_control,
            &mut clipboard,
            true,
        );

        assert_eq!(app.mode(), crate::app::Mode::Browser);
    }

    #[test]
    fn authentication_expiry_cancels_the_active_clipboard_presentation() {
        let mut app = App::new();
        app.open_test_document("email/personal", fixture_document("email/personal"));
        assert!(matches!(
            app.dispatch(Action::Copy),
            AppEffect::CopyFocused { .. }
        ));
        let mut clipboard = ClipboardCancellations::default();
        let (cancel, cancelled) = std::sync::mpsc::channel();
        let (_finished, completion) = std::sync::mpsc::channel();
        assert!(clipboard.replace(cancel, completion).is_none());

        handle_authentication_expiry(&mut app, &mut clipboard);

        cancelled
            .recv_timeout(Duration::from_secs(1))
            .expect("clipboard cancellation on expiry");
        assert_eq!(app.mode(), crate::app::Mode::Locked);
        assert!(!app.clipboard_pending());
    }

    #[derive(Default)]
    struct MemoryClipboard(Vec<u8>);

    impl ironstorage::presentation::ClipboardBackend for MemoryClipboard {
        fn read(
            &mut self,
        ) -> Result<
            ironstorage::presentation::ClipboardContent,
            ironstorage::presentation::ClipboardError,
        > {
            if self.0.is_empty() {
                Ok(ironstorage::presentation::ClipboardContent::EmptyOrNonText)
            } else {
                Ok(ironstorage::presentation::ClipboardContent::text(
                    self.0.clone(),
                ))
            }
        }

        fn write(
            &mut self,
            value: &SecretBytes,
        ) -> Result<(), ironstorage::presentation::ClipboardError> {
            self.0 = value.expose().to_vec();
            Ok(())
        }

        fn clear(&mut self) -> Result<(), ironstorage::presentation::ClipboardError> {
            self.0.clear();
            Ok(())
        }
    }

    #[test]
    fn clipboard_worker_reports_the_deadline_used_by_its_cleanup_wait() {
        let presentation = crate::app::ClipboardPresentationId(7);
        let timeout = ironstorage::presentation::ClipboardTimeout::new(Duration::from_secs(3))
            .expect("timeout");
        let mut started = None;
        let mut waited_until = None;
        let payload = run_clipboard_presentation(
            Ok(ironstorage::presentation::ClipboardManager::new(
                MemoryClipboard(b"previous".to_vec()),
                timeout,
            )),
            presentation,
            SecretBytes::new(b"copied".to_vec()),
            |reported, deadline| started = Some((reported, deadline)),
            |deadline| {
                waited_until = Some(deadline);
                ironstorage::presentation::ClipboardWait::Elapsed
            },
        );

        let (reported, deadline) = started.expect("clipboard start");
        assert_eq!(reported, presentation);
        assert_eq!(waited_until, Some(deadline));
        assert!(matches!(
            payload,
            AsyncPayload::ClipboardFinished {
                presentation: finished,
                result: Ok(ironstorage::presentation::ClipboardDisposition::RestoredPrevious),
            } if finished == presentation
        ));
    }

    #[test]
    fn show_and_generate_presentation_requests_reach_clipboard_and_qr_outputs() {
        let temporary = tempfile::tempdir().expect("temporary presentation 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 shown = execute_show_presentation(
            &config,
            ShowRequest {
                entry: Some("email/personal".to_owned()),
                presentation: Presentation::Clipboard {
                    line: std::num::NonZeroUsize::new(1).expect("nonzero"),
                },
            },
            FixtureSecrets,
        )
        .expect("show clipboard");
        assert!(matches!(
            shown,
            AsyncPayload::SecretPresented {
                presentation: crate::app::SecretPresentation::Clipboard(secret),
                ..
            } if secret.expose() == b"correct horse fixture"
        ));

        let shown_qr = execute_show_presentation(
            &config,
            ShowRequest {
                entry: Some("email/personal".to_owned()),
                presentation: Presentation::QrCode {
                    line: std::num::NonZeroUsize::new(1).expect("nonzero"),
                },
            },
            FixtureSecrets,
        )
        .expect("show QR");
        assert!(matches!(
            shown_qr,
            AsyncPayload::SecretPresented {
                presentation: crate::app::SecretPresentation::Qr { matrix, .. },
                ..
            } if matrix.width() > 0
        ));

        let generated = execute_workflow(
            &config,
            WorkflowSubmission::Generate {
                request: GenerateRequest {
                    entry: "generated/tui-clipboard".to_owned(),
                    length: Some(std::num::NonZeroUsize::new(24).expect("nonzero")),
                    no_symbols: true,
                    force: false,
                    in_place: false,
                    presentation: GeneratedPresentation::Clipboard,
                },
                overwrite: OverwriteDecision::Decline,
            },
            &mut FixtureSecrets,
        )
        .expect("generate clipboard");
        assert!(matches!(
            generated.presentation,
            Some(crate::app::SecretPresentation::Clipboard(secret))
                if secret.expose().len() == 24
        ));
    }

    #[test]
    fn editor_save_encrypts_and_automatically_commits_entirely_in_storage() {
        let temporary = tempfile::tempdir().expect("temporary editor 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();
        assert!(initial_commits >= 1);
        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 editor = EntryEditor::new(fixture_document_from(&store, "email/personal"));
        editor.begin_input();
        editor.insert_character('x');
        let outcome = save_document_inner(&config, "email/personal", &editor).expect("save");
        assert_eq!(outcome.path().to_string(), "email/personal");

        let reopened = fixture_document_from(&store, "email/personal");
        assert!(
            reopened
                .password()
                .expect("password")
                .value()
                .ends_with(b"x")
        );
        let git = ironstorage::git::GitRepository::open(&repository, identity).expect("reopen git");
        assert_eq!(git.log(None).expect("saved log").len(), initial_commits + 1);
    }

    struct FixtureSecrets;

    impl SecretProvider for FixtureSecrets {
        fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
            match key.fingerprint().as_str() {
                "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30" => {
                    Ok(SecretBytes::new(b"fixture-alice-passphrase".to_vec()))
                }
                "B37027B56FC406BD3F6A622B2AC03492B992D06F" => {
                    Ok(SecretBytes::new(b"fixture-bob-passphrase".to_vec()))
                }
                _ => Err(SecretProviderError::Unavailable),
            }
        }
    }

    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");
        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();
        ironstorage::git::GitRepository::init(&repository, identity.clone())
            .expect("initialize 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 initialized = execute_workflow(
            &config,
            WorkflowSubmission::Init(InitRequest {
                path: Some("nested/team".to_owned()),
                key_identities: vec![
                    "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30".to_owned(),
                    "B37027B56FC406BD3F6A622B2AC03492B992D06F".to_owned(),
                ],
            }),
            &mut secrets,
        )
        .expect("nested multi-recipient initialization");
        assert!(initialized.document.is_none());
        let policy = fs::read_to_string(store.join("nested/team/.gpg-id")).expect("policy");
        assert_eq!(policy.lines().count(), 2);

        let inserted = execute_workflow(
            &config,
            WorkflowSubmission::Insert {
                request: InsertRequest {
                    entry: "nested/team/account".to_owned(),
                    input: InsertInput::Multiline,
                    force: false,
                },
                contents: InsertContent::multiline(b"old-password\nuser: alice\n".to_vec()),
                overwrite: OverwriteDecision::Decline,
            },
            &mut secrets,
        )
        .expect("multiline insert");
        assert_eq!(inserted.entry.as_deref(), Some("nested/team/account"));
        assert!(inserted.document.is_some());

        let ciphertext = fs::read(store.join("nested/team/account.gpg")).expect("ciphertext");
        let git =
            ironstorage::git::GitRepository::open(&repository, identity.clone()).expect("open git");
        let commits_before_failure = git.log(None).expect("log").len();
        drop(git);
        let failure = execute_workflow(
            &config,
            WorkflowSubmission::Insert {
                request: InsertRequest {
                    entry: "nested/team/account".to_owned(),
                    input: InsertInput::EchoedLine,
                    force: false,
                },
                contents: InsertContent::echoed(b"must-not-win".to_vec()).expect("contents"),
                overwrite: OverwriteDecision::Decline,
            },
            &mut secrets,
        );
        assert!(failure.is_err());
        assert_eq!(
            fs::read(store.join("nested/team/account.gpg")).expect("unchanged ciphertext"),
            ciphertext
        );
        let git =
            ironstorage::git::GitRepository::open(&repository, identity.clone()).expect("open git");
        assert_eq!(git.log(None).expect("log").len(), commits_before_failure);
        drop(git);

        let new_generated = execute_workflow(
            &config,
            WorkflowSubmission::Generate {
                request: GenerateRequest {
                    entry: "nested/team/generated".to_owned(),
                    length: Some(std::num::NonZeroUsize::new(19).expect("nonzero")),
                    no_symbols: true,
                    force: false,
                    in_place: false,
                    presentation: GeneratedPresentation::Terminal,
                },
                overwrite: OverwriteDecision::Decline,
            },
            &mut secrets,
        )
        .expect("new generated entry");
        assert_eq!(
            new_generated
                .document
                .expect("generated document")
                .password()
                .expect("password")
                .value()
                .len(),
            19
        );

        let generated = execute_workflow(
            &config,
            WorkflowSubmission::Generate {
                request: GenerateRequest {
                    entry: "nested/team/account".to_owned(),
                    length: Some(std::num::NonZeroUsize::new(32).expect("nonzero")),
                    no_symbols: true,
                    force: false,
                    in_place: true,
                    presentation: GeneratedPresentation::Terminal,
                },
                overwrite: OverwriteDecision::Allow,
            },
            &mut secrets,
        )
        .expect("in-place generation");
        let document = generated.document.expect("generated document");
        assert_eq!(document.password().expect("password").value().len(), 32);
        assert!(
            document
                .fields()
                .iter()
                .any(|field| field.value() == b"alice")
        );
        let git = ironstorage::git::GitRepository::open(&repository, identity).expect("open git");
        assert_eq!(
            git.log(None).expect("log").len(),
            commits_before_failure + 2
        );
    }

    #[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") {
            let entry = entry.expect("directory entry");
            let target = destination.join(entry.file_name());
            if entry.file_type().expect("file type").is_dir() {
                copy_directory(&entry.path(), &target);
            } else {
                fs::copy(entry.path(), target).expect("copy fixture file");
            }
        }
    }
}
