alma 0.1.1

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! Command execution at the buffer persistence owner boundary.

use crate::{
    StatusInfoText,
    buffer::{BufferFile, BufferWriteError},
    ecs::{
        components::buffer::{BufferText, EditorBuffer, ViewEntity},
        events::{
            command::{CommandFailed, CommandRejected, CommandRejectionReason, CommandRequested},
            lifecycle::{LifecycleRequested, ShutdownRequest},
            status::{StatusMessage, StatusMessageRequested},
        },
    },
    fs_utils::FilesystemConfig,
    presentation::{ResolvedTheme, TransparencyMode},
    text_stream::TextByteStream,
    vim::{QuitPolicy, VimCommand, VimError, WriteQuitPolicy},
};
use bevy::{
    ecs::system::SystemParam,
    prelude::{MessageReader, MessageWriter, Query, Res, ResMut, With},
    window::{PrimaryWindow, Window},
};

use super::error::BufferCommandError;

/// Applies parsed editor commands at the buffer/persistence owner boundary.
#[allow(clippy::needless_pass_by_value)]
pub(super) fn apply_command_requests(
    mut requests: MessageReader<CommandRequested>,
    mut state: CommandRequestState,
) {
    let filesystem_config = state.filesystem_config.as_deref();
    for request in requests.read() {
        let Ok((buffer_text, mut buffer_file)) = state.buffers.get_mut(request.target.get()) else {
            let _sent = state.rejected.write(CommandRejected {
                target: request.target,
                source: request.source,
                command: request.command,
                reason: CommandRejectionReason::MissingTarget {
                    target: request.target,
                },
            });
            continue;
        };

        match execute_buffer_command(
            request.command,
            &buffer_text.stream,
            &mut buffer_file,
            filesystem_config,
        ) {
            Ok(outcome) => {
                apply_command_outcome(
                    request.source,
                    &outcome,
                    &mut state.theme,
                    &mut state.windows,
                    &mut state.status_requests,
                    &mut state.lifecycle_requests,
                );
            }
            Err(error) => {
                let _sent = state.failed.write(CommandFailed {
                    target: request.target,
                    source: request.source,
                    command: request.command,
                    diagnostic: error.diagnostic(),
                });
                let _sent = state.status_requests.write(StatusMessageRequested {
                    target: request.source,
                    message: StatusMessage::Error(error.status_error()),
                });
            }
        }
    }
}

/// Bevy state needed by command execution.
#[derive(SystemParam)]
pub(super) struct CommandRequestState<'w, 's> {
    /// Failed command notifications.
    failed: MessageWriter<'w, CommandFailed>,
    /// Rejected command notifications.
    rejected: MessageWriter<'w, CommandRejected>,
    /// Status side-effect requests.
    status_requests: MessageWriter<'w, StatusMessageRequested>,
    /// Lifecycle side-effect requests.
    lifecycle_requests: MessageWriter<'w, LifecycleRequested>,
    /// Filesystem policy for persistence commands.
    filesystem_config: Option<Res<'w, FilesystemConfig>>,
    /// Presentation theme for style commands.
    theme: Option<ResMut<'w, ResolvedTheme>>,
    /// Primary window for platform transparency sync.
    windows: Query<'w, 's, &'static mut Window, With<PrimaryWindow>>,
    /// Buffer owner query for command targets.
    buffers: Query<'w, 's, (&'static BufferText, &'static mut BufferFile), With<EditorBuffer>>,
}

/// Outcome from executing an editor command at the ECS owner boundary.
#[derive(Clone, Debug, Eq, PartialEq)]
enum CommandOutcome {
    /// Continue running after applying a status directive.
    Continue(StatusDirective),
    /// Begin terminal app shutdown after applying a status directive.
    Shutdown {
        /// Status directive to apply before shutdown.
        status: StatusDirective,
        /// Typed shutdown request for the lifecycle owner.
        request: ShutdownRequest,
    },
    /// Apply presentation state while continuing to run.
    Presentation {
        /// New transparency mode.
        transparency_mode: TransparencyMode,
        /// Status directive to apply after changing presentation state.
        status: StatusDirective,
    },
}

impl CommandOutcome {
    /// Creates an outcome that keeps the app running.
    fn continue_with(status_text: Option<String>) -> Self {
        Self::Continue(StatusDirective::from_status_text(status_text))
    }

    /// Creates an outcome that quits the app.
    fn shutdown(status_text: Option<String>) -> Self {
        Self::Shutdown {
            status: StatusDirective::from_status_text(status_text),
            request: ShutdownRequest::successful_user_command(),
        }
    }

    /// Returns the status directive carried by this outcome.
    const fn status(&self) -> &StatusDirective {
        match self {
            Self::Continue(status)
            | Self::Shutdown { status, .. }
            | Self::Presentation { status, .. } => status,
        }
    }
}

/// Status side effect produced by command execution.
#[derive(Clone, Debug, Eq, PartialEq)]
enum StatusDirective {
    /// Clear the current status line.
    Clear,
    /// Render generated informational status text.
    Info(String),
}

impl StatusDirective {
    /// Builds a status directive from optional generated text.
    fn from_status_text(status_text: Option<String>) -> Self {
        status_text.map_or(Self::Clear, Self::Info)
    }
}

/// Executes a parsed editor command against a buffer owner.
fn execute_buffer_command(
    command: VimCommand,
    stream: &TextByteStream,
    buffer: &mut BufferFile,
    config: Option<&FilesystemConfig>,
) -> Result<CommandOutcome, BufferCommandError> {
    match command {
        VimCommand::Write => {
            write_buffer(stream, buffer, config).map(CommandOutcome::continue_with)
        }
        VimCommand::Quit(QuitPolicy::PreserveChanges) if buffer.is_modified(stream) => {
            Err(BufferCommandError::Vim(VimError::NoWriteSinceLastChange))
        }
        VimCommand::Quit(QuitPolicy::PreserveChanges | QuitPolicy::Force) => {
            Ok(CommandOutcome::shutdown(None))
        }
        VimCommand::WriteQuit(WriteQuitPolicy::AlwaysWrite) => {
            write_buffer(stream, buffer, config).map(CommandOutcome::shutdown)
        }
        VimCommand::WriteQuit(WriteQuitPolicy::WriteIfModified) => {
            if buffer.is_modified(stream) {
                write_buffer(stream, buffer, config).map(CommandOutcome::shutdown)
            } else {
                Ok(CommandOutcome::shutdown(None))
            }
        }
        VimCommand::SetTransparency(transparency_mode) => Ok(CommandOutcome::Presentation {
            transparency_mode,
            status: StatusDirective::Info(transparency_status_text(transparency_mode)),
        }),
    }
}

/// Returns generated status text for a transparency command.
fn transparency_status_text(transparency_mode: TransparencyMode) -> String {
    match transparency_mode {
        TransparencyMode::Opaque => String::from("transparency=opaque"),
        TransparencyMode::Transparent => String::from("transparency=transparent"),
    }
}

/// Writes the buffer and maps persistence errors into Vim status errors.
fn write_buffer(
    stream: &TextByteStream,
    buffer: &mut BufferFile,
    config: Option<&FilesystemConfig>,
) -> Result<Option<String>, BufferCommandError> {
    if buffer.path().is_none() {
        return Err(BufferCommandError::Vim(VimError::NoFileName));
    }

    let Some(config) = config else {
        return Err(BufferCommandError::FilesystemUnavailable);
    };

    buffer
        .write(stream, config)
        .map(|report| Some(report.status_text()))
        .map_err(|error| match error {
            BufferWriteError::NoFileName => BufferCommandError::Vim(VimError::NoFileName),
            BufferWriteError::File(source) => BufferCommandError::Write(source),
        })
}

/// Converts command outcomes into status and lifecycle requests.
fn apply_command_outcome(
    target: ViewEntity,
    outcome: &CommandOutcome,
    theme: &mut Option<ResMut<ResolvedTheme>>,
    windows: &mut Query<&mut Window, With<PrimaryWindow>>,
    status_requests: &mut MessageWriter<StatusMessageRequested>,
    lifecycle_requests: &mut MessageWriter<LifecycleRequested>,
) {
    if let CommandOutcome::Presentation {
        transparency_mode, ..
    } = outcome
    {
        if let Some(theme) = theme.as_deref_mut() {
            theme.transparency_mode = *transparency_mode;
        }
        if let Ok(mut window) = windows.single_mut() {
            window.transparent = transparency_mode.is_window_transparent();
        }
    }

    match outcome.status() {
        StatusDirective::Clear => {
            let _sent = status_requests.write(StatusMessageRequested {
                target,
                message: StatusMessage::Clear,
            });
        }
        StatusDirective::Info(status_text) => {
            let _sent = status_requests.write(StatusMessageRequested {
                target,
                message: StatusMessage::Info(StatusInfoText::trusted_generated(
                    status_text.to_owned(),
                )),
            });
        }
    }

    if let CommandOutcome::Shutdown { request, .. } = outcome {
        let _sent = lifecycle_requests.write(LifecycleRequested::Shutdown(*request));
    }
}