alma 0.1.1

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! Filesystem policy and persistence helpers for editor buffers.

mod config;
mod display;
mod error;
mod path;
mod platform;
mod read;
mod write;

pub use config::{DEFAULT_MAX_FILE_BYTES, FilesystemConfig};
pub use display::EscapedDisplayText;
pub use error::{FileReadError, FileWriteError, FilesystemConfigError, PathPolicyError};
pub use read::{ReadTextFile, read_existing_file, read_existing_workspace_file, read_text_file};
pub use write::{atomic_write, atomic_write_workspace_file};

#[cfg(test)]
mod tests {
    use super::{
        FileReadError, FileWriteError, FilesystemConfig, PathPolicyError, atomic_write,
        config::discover_workspace_root,
        path::{resolve_buffer_path, resolve_buffer_path_from},
        read_text_file,
    };
    use std::{
        fs,
        path::{Path, PathBuf},
        time::{SystemTime, UNIX_EPOCH},
    };

    /// Returns a unique temporary directory path.
    fn temp_dir_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-fs-utils-{name}-{}-{nanos}",
            std::process::id()
        ))
    }

    /// Creates a unique temporary directory.
    fn temp_dir(name: &str) -> PathBuf {
        let path = temp_dir_path(name);
        fs::create_dir(&path).expect("temporary directory should be created");
        path
    }

    /// Returns a test filesystem config rooted at `root`.
    fn config(root: &Path) -> FilesystemConfig {
        FilesystemConfig {
            workspace_root: root.canonicalize().expect("root should canonicalize"),
            max_file_bytes: 32,
        }
    }

    #[test]
    fn filesystem_errors_escape_control_characters_in_paths() {
        let error = PathPolicyError::MissingParent {
            path: PathBuf::from("bad\nname\tbidi\u{202E}.txt"),
        };
        let text = error.to_string();

        assert_eq!(
            text,
            "bad\\nname\\tbidi\\u{202E}.txt has no parent directory"
        );
        assert!(!text.contains('\n'));
        assert!(!text.contains('\t'));
        assert!(!text.contains('\u{202E}'));
    }

    #[test]
    fn discovers_nearest_git_workspace_root() {
        let root = temp_dir("git-root");
        let nested = root.join("a/b");
        fs::create_dir_all(&nested).expect("nested directories should be created");
        fs::create_dir(root.join(".git")).expect(".git directory should be created");

        assert_eq!(
            discover_workspace_root(&nested).expect("workspace root should be found"),
            root.canonicalize().expect("root should canonicalize")
        );

        let _cleanup = fs::remove_dir_all(root);
    }

    #[test]
    fn discovers_git_file_workspace_root() {
        let root = temp_dir("git-file-root");
        let nested = root.join("a");
        fs::create_dir_all(&nested).expect("nested directory should be created");
        fs::write(root.join(".git"), "gitdir: ../repo.git").expect(".git file should be written");

        assert_eq!(
            discover_workspace_root(&nested).expect("workspace root should be found"),
            root.canonicalize().expect("root should canonicalize")
        );

        let _cleanup = fs::remove_dir_all(root);
    }

    #[test]
    fn falls_back_to_current_directory_without_git() {
        let root = temp_dir("no-git-root");

        assert_eq!(
            discover_workspace_root(&root).expect("fallback root should be selected"),
            root.canonicalize().expect("root should canonicalize")
        );

        let _cleanup = fs::remove_dir_all(root);
    }

    #[test]
    fn path_policy_accepts_regular_files_and_new_files_under_root() {
        let root = temp_dir("path-accept");
        let cfg = config(&root);
        let existing = root.join("existing.txt");
        fs::write(&existing, "hello").expect("existing file should be written");
        let new_file = root.join("new.txt");

        assert_eq!(
            resolve_buffer_path(&existing, &cfg)
                .expect("existing path should resolve")
                .path,
            existing
                .canonicalize()
                .expect("existing should canonicalize")
        );
        assert_eq!(
            resolve_buffer_path(&new_file, &cfg)
                .expect("new path should resolve")
                .path,
            cfg.workspace_root.join("new.txt")
        );

        let _cleanup = fs::remove_dir_all(root);
    }

    #[test]
    fn relative_paths_resolve_from_launch_directory_before_workspace_policy() {
        let root = temp_dir("relative-root");
        let nested = root.join("src");
        fs::create_dir(&nested).expect("nested directory should be created");
        let cfg = config(&root);
        let file = nested.join("main.rs");
        fs::write(&file, "fn main() {}").expect("nested file should be written");

        assert_eq!(
            resolve_buffer_path_from(Path::new("main.rs"), &cfg, &nested)
                .expect("relative path should resolve from base directory")
                .path,
            file.canonicalize().expect("file should canonicalize")
        );
        assert!(matches!(
            resolve_buffer_path_from(Path::new("../../escape.txt"), &cfg, &nested),
            Err(PathPolicyError::OutsideWorkspace { .. })
        ));

        let _cleanup = fs::remove_dir_all(root);
    }

    #[test]
    fn path_policy_rejects_outside_workspace_and_traversal() {
        let root = temp_dir("path-reject");
        let outside = temp_dir("path-reject-outside");
        let cfg = config(&root);
        let outside_file = outside.join("outside.txt");
        fs::write(&outside_file, "nope").expect("outside file should be written");

        assert!(matches!(
            resolve_buffer_path(&outside_file, &cfg),
            Err(PathPolicyError::OutsideWorkspace { .. })
        ));
        assert!(matches!(
            resolve_buffer_path(Path::new("../escape.txt"), &cfg),
            Err(PathPolicyError::OutsideWorkspace { .. })
        ));

        let _cleanup = fs::remove_dir_all(root);
        let _cleanup = fs::remove_dir_all(outside);
    }

    #[cfg(unix)]
    #[test]
    fn path_policy_rejects_dangling_symlink_as_unresolvable() {
        use std::os::unix::fs::symlink;

        let root = temp_dir("dangling-symlink");
        let cfg = config(&root);
        let link = root.join("dangling.txt");
        symlink(root.join("missing-target.txt"), &link)
            .expect("dangling symlink should be created");

        assert!(matches!(
            resolve_buffer_path(&link, &cfg),
            Err(PathPolicyError::Unresolvable { .. })
        ));
        assert!(matches!(
            atomic_write(&link, b"new", &cfg),
            Err(FileWriteError::Policy(PathPolicyError::Unresolvable { .. }))
        ));
        assert!(
            fs::symlink_metadata(&link)
                .expect("dangling symlink should remain")
                .file_type()
                .is_symlink()
        );

        let _cleanup = fs::remove_dir_all(root);
    }

    #[cfg(unix)]
    #[test]
    fn path_policy_rejects_symlink_loop_as_unresolvable() {
        use std::os::unix::fs::symlink;

        let root = temp_dir("symlink-loop");
        let cfg = config(&root);
        let first = root.join("first");
        let second = root.join("second");
        symlink(&second, &first).expect("first symlink should be created");
        symlink(&first, &second).expect("second symlink should be created");

        assert!(matches!(
            resolve_buffer_path(&first, &cfg),
            Err(PathPolicyError::Unresolvable { .. })
        ));

        let _cleanup = fs::remove_dir_all(root);
    }

    #[test]
    fn read_rejects_directories_and_large_files() {
        let root = temp_dir("read-reject");
        let cfg = config(&root);
        let large_file = root.join("large.txt");
        fs::write(&large_file, vec![b'a'; 33]).expect("large file should be written");

        assert!(matches!(
            read_text_file(&root, &cfg),
            Err(FileReadError::UnsupportedFileType { .. })
        ));
        assert!(matches!(
            read_text_file(&large_file, &cfg),
            Err(FileReadError::TooLarge { .. })
        ));

        let _cleanup = fs::remove_dir_all(root);
    }

    #[test]
    fn read_accepts_missing_files_as_empty_buffers() {
        let root = temp_dir("missing-open");
        let cfg = config(&root);
        let missing = root.join("new.txt");

        let read =
            read_text_file(&missing, &cfg).expect("missing file should open as empty buffer");

        assert_eq!(read.path, cfg.workspace_root.join("new.txt"));
        assert!(read.bytes.is_empty());

        let _cleanup = fs::remove_dir_all(root);
    }

    #[test]
    fn atomic_write_writes_and_replaces_contents() {
        let root = temp_dir("atomic-write");
        let cfg = config(&root);
        let file = root.join("file.txt");
        fs::write(&file, "old").expect("file should be written");

        let written_path = atomic_write(&file, b"new", &cfg).expect("write should succeed");

        assert_eq!(
            written_path,
            file.canonicalize().expect("file should canonicalize")
        );
        assert_eq!(
            fs::read_to_string(&file).expect("file should be readable"),
            "new"
        );
        assert!(
            fs::read_dir(&root)
                .expect("root should be readable")
                .all(|entry| !entry
                    .expect("directory entry should be readable")
                    .file_name()
                    .to_string_lossy()
                    .contains("alma-tmp"))
        );

        let _cleanup = fs::remove_dir_all(root);
    }

    #[cfg(unix)]
    #[test]
    fn atomic_write_preserves_existing_file_permissions() {
        use std::os::unix::fs::PermissionsExt;

        let root = temp_dir("atomic-write-mode");
        let cfg = config(&root);
        let file = root.join("private.txt");
        fs::write(&file, "old").expect("file should be written");
        fs::set_permissions(&file, fs::Permissions::from_mode(0o600))
            .expect("file permissions should be restricted");

        let _written_path = atomic_write(&file, b"new", &cfg).expect("write should succeed");

        let mode = fs::metadata(&file)
            .expect("file metadata should be readable")
            .permissions()
            .mode()
            & 0o777;
        assert_eq!(mode, 0o600);
        assert_eq!(
            fs::read_to_string(&file).expect("file should be readable"),
            "new"
        );

        let _cleanup = fs::remove_dir_all(root);
    }

    #[cfg(unix)]
    #[test]
    fn atomic_write_uses_resolved_symlink_target_permissions() {
        use std::os::unix::{fs::PermissionsExt, fs::symlink};

        let root = temp_dir("atomic-write-symlink-mode");
        let cfg = config(&root);
        let target = root.join("private.txt");
        let link = root.join("link.txt");
        fs::write(&target, "old").expect("target should be written");
        fs::set_permissions(&target, fs::Permissions::from_mode(0o600))
            .expect("target permissions should be restricted");
        symlink(&target, &link).expect("symlink should be created");

        let written_path = atomic_write(&link, b"new", &cfg).expect("write should succeed");

        assert_eq!(
            written_path,
            target.canonicalize().expect("target should canonicalize")
        );
        let mode = fs::metadata(&target)
            .expect("target metadata should be readable")
            .permissions()
            .mode()
            & 0o777;
        assert_eq!(mode, 0o600);
        assert_eq!(
            fs::read_to_string(&target).expect("target should be readable"),
            "new"
        );
        assert!(
            fs::symlink_metadata(&link)
                .expect("link metadata should be readable")
                .file_type()
                .is_symlink()
        );

        let _cleanup = fs::remove_dir_all(root);
    }

    #[cfg(unix)]
    #[test]
    fn symlinks_are_allowed_only_when_target_stays_under_root() {
        use std::os::unix::fs::symlink;

        let root = temp_dir("symlink-root");
        let outside = temp_dir("symlink-outside");
        let cfg = config(&root);
        let inside_file = root.join("inside.txt");
        let outside_file = outside.join("outside.txt");
        fs::write(&inside_file, "inside").expect("inside file should be written");
        fs::write(&outside_file, "outside").expect("outside file should be written");
        let inside_link = root.join("inside-link.txt");
        let outside_link = root.join("outside-link.txt");
        symlink(&inside_file, &inside_link).expect("inside symlink should be created");
        symlink(&outside_file, &outside_link).expect("outside symlink should be created");

        assert_eq!(
            resolve_buffer_path(&inside_link, &cfg)
                .expect("inside symlink should resolve")
                .path,
            inside_file
                .canonicalize()
                .expect("inside should canonicalize")
        );
        assert!(matches!(
            resolve_buffer_path(&outside_link, &cfg),
            Err(PathPolicyError::OutsideWorkspace { .. })
        ));

        let _cleanup = fs::remove_dir_all(root);
        let _cleanup = fs::remove_dir_all(outside);
    }
}