alma 0.1.1

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! Workspace-root path policy and resolution.

use super::{config::FilesystemConfig, error::PathPolicyError};
use std::{
    ffi::OsStr,
    fs, io,
    path::{Component, Path, PathBuf},
};

/// Path accepted by policy.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct ResolvedBufferPath {
    /// Canonical target path for existing files, or normalized parent plus file name for new files.
    pub(super) path: PathBuf,
    /// Whether the target exists.
    pub(super) exists: bool,
}

/// Resolves a user-provided path under the configured workspace root.
pub(super) fn resolve_buffer_path(
    requested_path: &Path,
    config: &FilesystemConfig,
) -> Result<ResolvedBufferPath, PathPolicyError> {
    let current_dir =
        std::env::current_dir().map_err(|source| PathPolicyError::CurrentDir { source })?;
    resolve_buffer_path_from(requested_path, config, &current_dir)
}

/// Resolves a user-provided path from `base_dir` under the configured workspace root.
pub(super) fn resolve_buffer_path_from(
    requested_path: &Path,
    config: &FilesystemConfig,
    base_dir: &Path,
) -> Result<ResolvedBufferPath, PathPolicyError> {
    let absolute_path = if requested_path.is_absolute() {
        requested_path.to_owned()
    } else {
        base_dir.join(requested_path)
    };

    match absolute_path.canonicalize() {
        Ok(canonical) => {
            require_under_root(&canonical, config)?;
            Ok(ResolvedBufferPath {
                path: canonical,
                exists: true,
            })
        }
        Err(error) => {
            reject_existing_unresolvable_path(&absolute_path, error)?;
            resolve_new_buffer_path(&absolute_path, config)
        }
    }
}

/// Rejects paths that exist but failed canonicalization.
fn reject_existing_unresolvable_path(
    absolute_path: &Path,
    canonicalize_error: io::Error,
) -> Result<(), PathPolicyError> {
    match fs::symlink_metadata(absolute_path) {
        Ok(_metadata) => Err(PathPolicyError::Unresolvable {
            path: absolute_path.to_owned(),
            source: canonicalize_error,
        }),
        Err(metadata_error) if metadata_error.kind() == io::ErrorKind::NotFound => Ok(()),
        Err(metadata_error) => Err(PathPolicyError::Unresolvable {
            path: absolute_path.to_owned(),
            source: metadata_error,
        }),
    }
}

/// Resolves a not-yet-existing target path by validating its parent directory.
fn resolve_new_buffer_path(
    absolute_path: &Path,
    config: &FilesystemConfig,
) -> Result<ResolvedBufferPath, PathPolicyError> {
    let parent = absolute_path
        .parent()
        .ok_or_else(|| PathPolicyError::MissingParent {
            path: absolute_path.to_owned(),
        })?;
    let canonical_parent = parent
        .canonicalize()
        .map_err(|source| PathPolicyError::Parent {
            path: absolute_path.to_owned(),
            source,
        })?;
    require_under_root(&canonical_parent, config)?;
    let file_name = absolute_path
        .file_name()
        .ok_or_else(|| PathPolicyError::MissingParent {
            path: absolute_path.to_owned(),
        })?;
    require_normal_file_name(file_name, absolute_path)?;

    Ok(ResolvedBufferPath {
        path: canonical_parent.join(file_name),
        exists: false,
    })
}

/// Requires a new file leaf to be exactly one normal path component.
fn require_normal_file_name(
    file_name: &OsStr,
    absolute_path: &Path,
) -> Result<(), PathPolicyError> {
    let mut components = Path::new(file_name).components();
    if matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none() {
        Ok(())
    } else {
        Err(PathPolicyError::MissingParent {
            path: absolute_path.to_owned(),
        })
    }
}

/// Requires `path` to live under the configured workspace root.
pub(super) fn require_under_root(
    path: &Path,
    config: &FilesystemConfig,
) -> Result<(), PathPolicyError> {
    if path.starts_with(&config.workspace_root) {
        Ok(())
    } else {
        Err(PathPolicyError::OutsideWorkspace {
            path: path.to_owned(),
            workspace_root: config.workspace_root.clone(),
        })
    }
}