alma 0.1.0

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! File-backed buffer state and persistence helpers.

mod edit;

use crate::{
    fs_utils::{self, FileReadError, FileWriteError, FilesystemConfig},
    text_stream::{TextByteStream, TextStreamError},
};
use bevy::prelude::Component;
use std::{
    error::Error,
    ffi::OsString,
    fmt::{Display, Formatter},
    io,
    path::PathBuf,
};

pub use edit::{BufferEdit, BufferEditReport};

/// Buffer metadata for an optional file on disk.
#[derive(Clone, Component, Debug, Default, Eq, PartialEq)]
pub struct BufferFile {
    /// Path backing the current buffer, when one was provided at launch.
    path: Option<PathBuf>,
    /// Stream revision that is known to have been written to disk.
    saved_revision: u64,
}

impl BufferFile {
    /// Creates scratch buffer metadata with no backing file.
    #[must_use]
    pub const fn scratch(saved_revision: u64) -> Self {
        Self {
            path: None,
            saved_revision,
        }
    }

    /// Creates file-backed buffer metadata.
    #[must_use]
    pub const fn backed_by(path: PathBuf, saved_revision: u64) -> Self {
        Self {
            path: Some(path),
            saved_revision,
        }
    }

    /// Returns the backing path, if one exists.
    #[must_use]
    pub const fn path(&self) -> Option<&PathBuf> {
        self.path.as_ref()
    }

    /// Returns whether the current stream has changes not known to be saved.
    #[must_use]
    pub const fn is_modified(&self, stream: &TextByteStream) -> bool {
        self.saved_revision != stream.revision()
    }

    /// Writes the stream to the backing path and updates the saved revision.
    ///
    /// # Errors
    ///
    /// Returns [`BufferWriteError::NoFileName`] when the buffer is scratch, or
    /// [`BufferWriteError::Io`] when the write fails.
    pub fn write(
        &mut self,
        stream: &TextByteStream,
        config: &FilesystemConfig,
    ) -> Result<BufferWriteReport, BufferWriteError> {
        let Some(path) = &self.path else {
            return Err(BufferWriteError::NoFileName);
        };

        let written_path = fs_utils::atomic_write(path, stream.as_bytes(), config)
            .map_err(BufferWriteError::File)?;
        self.saved_revision = stream.revision();

        Ok(BufferWriteReport::new(
            written_path,
            stream.as_str(),
            stream.as_bytes().len(),
        ))
    }
}

/// Summary of a successful buffer write.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BufferWriteReport {
    /// Path that was written.
    path: PathBuf,
    /// Count of logical buffer lines written.
    line_count: usize,
    /// Count of bytes written.
    byte_count: usize,
}

impl BufferWriteReport {
    /// Creates a write report from persisted text.
    fn new(path: PathBuf, text: &str, byte_count: usize) -> Self {
        Self {
            path,
            line_count: line_count(text),
            byte_count,
        }
    }

    /// Renders a compact Vim-like write message.
    #[must_use]
    pub fn status_text(&self) -> String {
        format!(
            "\"{}\" {}L, {}B written",
            self.path.display(),
            self.line_count,
            self.byte_count
        )
    }
}

/// Opens a launch buffer from a CLI file argument, creating the file when absent.
///
/// # Errors
///
/// Returns [`BufferOpenError`] when the file cannot be created/read or is not valid UTF-8.
pub fn open_launch_buffer(
    file_argument: Option<OsString>,
    fallback_text: &str,
    config: &FilesystemConfig,
) -> Result<(TextByteStream, BufferFile), BufferOpenError> {
    let Some(file_argument) = file_argument else {
        let stream = TextByteStream::new(fallback_text);
        return Ok((stream.clone(), BufferFile::scratch(stream.revision())));
    };

    let read_file = fs_utils::read_text_file(PathBuf::from(file_argument), config)
        .map_err(BufferOpenError::File)?;
    let stream =
        TextByteStream::from_bytes(read_file.bytes).map_err(|source| BufferOpenError::Text {
            path: read_file.path.clone(),
            source,
        })?;
    let buffer = BufferFile::backed_by(read_file.path, stream.revision());

    Ok((stream, buffer))
}

/// Selects the file argument from process arguments.
#[must_use]
pub fn launch_file_argument(arguments: impl IntoIterator<Item = OsString>) -> Option<OsString> {
    arguments.into_iter().next()
}

/// Errors that can occur while opening the initial buffer.
#[derive(Debug)]
pub enum BufferOpenError {
    /// The requested path failed filesystem policy or bounded file reading.
    File(FileReadError),
    /// The requested path could not be opened or created.
    Open {
        /// Path that failed to open.
        path: PathBuf,
        /// Underlying I/O error.
        source: io::Error,
    },
    /// The requested path could not be read.
    Read {
        /// Path that failed to read.
        path: PathBuf,
        /// Underlying I/O error.
        source: io::Error,
    },
    /// The requested file is not valid UTF-8.
    Text {
        /// Path containing invalid text bytes.
        path: PathBuf,
        /// Underlying text stream error.
        source: TextStreamError,
    },
}

impl Display for BufferOpenError {
    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::File(error) => error.fmt(formatter),
            Self::Open { path, source } => {
                write!(formatter, "failed to open {}: {source}", path.display())
            }
            Self::Read { path, source } => {
                write!(formatter, "failed to read {}: {source}", path.display())
            }
            Self::Text { path, source } => {
                write!(
                    formatter,
                    "{} is not valid editor text: {source}",
                    path.display()
                )
            }
        }
    }
}

impl Error for BufferOpenError {}

/// Errors that can occur while writing the current buffer.
#[derive(Debug)]
pub enum BufferWriteError {
    /// The buffer has no path to write to.
    NoFileName,
    /// The backing file failed filesystem policy or atomic persistence.
    File(FileWriteError),
    /// The backing file could not be written.
    Io(io::Error),
}

/// Counts lines the way an editor status message presents the whole buffer.
fn line_count(text: &str) -> usize {
    if text.is_empty() {
        0
    } else {
        text.lines().count()
    }
}

#[cfg(test)]
mod tests {
    use super::{BufferFile, BufferWriteReport, launch_file_argument};
    use crate::{fs_utils::FilesystemConfig, text_stream::TextByteStream};
    use std::{
        ffi::OsString,
        path::PathBuf,
        time::{SystemTime, UNIX_EPOCH},
    };

    /// Returns a unique temporary file path below a missing parent directory.
    fn missing_parent_file_path(name: &str) -> PathBuf {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("system time should be after unix epoch")
            .as_nanos();
        std::env::temp_dir()
            .join(format!("alma-missing-parent-{name}-{nanos}"))
            .join("buffer.txt")
    }

    #[test]
    fn launch_argument_uses_first_user_argument() {
        assert_eq!(
            launch_file_argument([OsString::from("alma"), OsString::from("file.txt")]),
            Some(OsString::from("alma"))
        );
        assert_eq!(
            launch_file_argument([OsString::from("file.txt")]),
            Some(OsString::from("file.txt"))
        );
        assert_eq!(launch_file_argument([]), None);
    }

    #[test]
    fn buffer_tracks_saved_revision() {
        let mut stream = TextByteStream::new("hello");
        let buffer = BufferFile::scratch(stream.revision());
        assert!(!buffer.is_modified(&stream));

        stream.replace_all("goodbye");
        assert!(buffer.is_modified(&stream));
    }

    #[test]
    fn failed_write_does_not_advance_saved_revision() {
        let mut stream = TextByteStream::new("hello");
        let mut buffer = BufferFile::backed_by(missing_parent_file_path("write-error"), 0);

        stream.replace_all("dirty");
        assert!(buffer.is_modified(&stream));
        assert!(buffer.write(&stream, &FilesystemConfig::default()).is_err());
        assert!(buffer.is_modified(&stream));
    }

    #[test]
    fn write_report_matches_vim_shape() {
        let report = BufferWriteReport::new(PathBuf::from("note.txt"), "one\ntwo\n", 8);

        assert_eq!(report.status_text(), "\"note.txt\" 2L, 8B written");
    }
}