alma 0.1.1

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! Bounded file reads under workspace policy.

use super::{
    config::FilesystemConfig,
    error::FileReadError,
    path::{resolve_buffer_path, resolve_buffer_path_from},
    platform::open_existing_file,
};
use std::{
    io::Read,
    path::{Path, PathBuf},
};

/// A policy-accepted text file read from disk.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReadTextFile {
    /// Policy-resolved path backing the buffer.
    pub path: PathBuf,
    /// File bytes, bounded by [`FilesystemConfig::max_file_bytes`].
    pub bytes: Vec<u8>,
}

/// Reads an existing local file as bounded bytes under the configured workspace root.
///
/// Unlike [`read_text_file`], this rejects missing targets instead of treating them as empty
/// editor buffers. Plugin manifests and components use this boundary because absence is a load
/// failure, not a new-buffer state.
///
/// # Errors
///
/// Returns [`FileReadError`] when the path is denied, missing, not a regular file, too large, or
/// cannot be read.
pub fn read_existing_file(
    requested_path: impl AsRef<Path>,
    config: &FilesystemConfig,
    max_file_bytes: u64,
) -> Result<ReadTextFile, FileReadError> {
    let resolved =
        resolve_buffer_path(requested_path.as_ref(), config).map_err(FileReadError::Policy)?;

    if !resolved.exists {
        return Err(FileReadError::Missing {
            path: resolved.path,
        });
    }

    read_resolved_existing_file(resolved.path, config, max_file_bytes)
}

/// Reads an existing workspace-relative file as bounded bytes under the configured root.
///
/// Relative paths are resolved from [`FilesystemConfig::workspace_root`], not the process working
/// directory. This is the correct boundary for static plugin component and manifest paths.
///
/// # Errors
///
/// Returns [`FileReadError`] when the path is denied, missing, not a regular file, too large, or
/// cannot be read.
pub fn read_existing_workspace_file(
    workspace_relative_path: impl AsRef<Path>,
    config: &FilesystemConfig,
    max_file_bytes: u64,
) -> Result<ReadTextFile, FileReadError> {
    let resolved = resolve_buffer_path_from(
        workspace_relative_path.as_ref(),
        config,
        &config.workspace_root,
    )
    .map_err(FileReadError::Policy)?;

    if !resolved.exists {
        return Err(FileReadError::Missing {
            path: resolved.path,
        });
    }

    read_resolved_existing_file(resolved.path, config, max_file_bytes)
}

/// Reads a local file as bounded bytes under the configured workspace root.
///
/// Missing files are accepted as empty buffers when their parent directory satisfies policy.
///
/// # Errors
///
/// Returns [`FileReadError`] when the path is denied, is not a regular file, is too large, or
/// cannot be read.
pub fn read_text_file(
    requested_path: impl AsRef<Path>,
    config: &FilesystemConfig,
) -> Result<ReadTextFile, FileReadError> {
    let resolved =
        resolve_buffer_path(requested_path.as_ref(), config).map_err(FileReadError::Policy)?;

    if !resolved.exists {
        return Ok(ReadTextFile {
            path: resolved.path,
            bytes: Vec::new(),
        });
    }

    read_resolved_existing_file(resolved.path, config, config.max_file_bytes)
}

/// Reads a policy-resolved existing file with a caller-provided byte cap.
fn read_resolved_existing_file(
    path: PathBuf,
    config: &FilesystemConfig,
    max_file_bytes: u64,
) -> Result<ReadTextFile, FileReadError> {
    let (mut file, metadata) = open_existing_file(&path, config)?;

    if !metadata.is_file() {
        return Err(FileReadError::UnsupportedFileType { path });
    }

    if metadata.len() > max_file_bytes {
        return Err(FileReadError::TooLarge {
            path,
            size: metadata.len(),
            max_size: max_file_bytes,
        });
    }

    let mut bytes = Vec::new();
    let mut bounded = (&mut file).take(max_file_bytes.saturating_add(1));
    let _bytes_read = bounded
        .read_to_end(&mut bytes)
        .map_err(|source| FileReadError::Read {
            path: path.clone(),
            source,
        })?;

    if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > max_file_bytes {
        return Err(FileReadError::TooLarge {
            path,
            size: u64::try_from(bytes.len()).unwrap_or(u64::MAX),
            max_size: max_file_bytes,
        });
    }

    Ok(ReadTextFile { path, bytes })
}