geam-cli 0.2.3

Standalone command implementation for Geam
Documentation
use super::package::EmbeddingProject;
use crate::error::CliError;
use camino::{Utf8Path, Utf8PathBuf};
use std::fs;
use std::io::Write;

pub(super) fn initialize(project: &EmbeddingProject) -> Result<(), CliError> {
    let config = project.project_root().join("gleam.toml");
    match fs::read(&config) {
        Ok(_) => return project.validate_gleam_config(),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
        Err(error) => {
            return Err(CliError::FileRead {
                path: config,
                error,
            });
        }
    }
    let source_directory = project.project_root().join("src");
    let source = source_directory.join(format!("{}.gleam", project.root_module()));
    let files = [
        (
            project.project_root().join(".gitignore"),
            "/build/\n".to_owned(),
        ),
        (
            source,
            "pub fn double(value: Int) -> Int {\n  value * 2\n}\n".to_owned(),
        ),
        (
            config,
            format!(
                "name = \"{}\"\nversion = \"0.1.0\"\ntarget = \"erlang\"\n",
                project.root_module()
            ),
        ),
    ];
    let mut missing = Vec::new();
    for (path, expected) in files {
        match fs::read(&path) {
            Ok(current) if current == expected.as_bytes() => {}
            Ok(_) => return Err(CliError::EmbeddingFileConflict {
                path,
                reason: "initialization would replace an existing file; keep a valid conventional Gleam project instead".to_owned(),
            }),
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => missing.push((path, expected)),
            Err(error) => return Err(CliError::FileRead { path, error }),
        }
    }
    fs::create_dir_all(&source_directory).map_err(|error| CliError::FileWrite {
        path: source_directory,
        error,
    })?;
    create_files(missing)
}

fn create_files(files: Vec<(Utf8PathBuf, String)>) -> Result<(), CliError> {
    for (path, source) in files {
        create_file(&path, source.as_bytes())?;
    }
    Ok(())
}

fn create_file(path: &Utf8Path, source: &[u8]) -> Result<(), CliError> {
    fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(path)
        .and_then(|mut file| file.write_all(source))
        .map_err(|error| CliError::FileWrite {
            path: path.to_path_buf(),
            error,
        })
}

#[cfg(test)]
mod tests {
    use super::{create_files, initialize};
    use crate::embedding::package::EmbeddingProject;
    use crate::error::CliError;
    use camino::Utf8PathBuf;
    use std::fs;
    use tempfile::tempdir;

    #[test]
    fn preserves_initialization_io_failures_and_completes_partial_starters() {
        let directory = tempdir().expect("fixture directory");
        let root = Utf8PathBuf::from_path_buf(directory.path().to_owned()).expect("UTF-8 fixture");
        fs::create_dir(root.join("src")).expect("Rust source directory");
        fs::write(root.join("src/lib.rs"), "").expect("Rust source");
        fs::write(
            root.join("Cargo.toml"),
            "[package]\nname = 'retry'\nversion = '0.1.0'\n[workspace]\n",
        )
        .expect("Cargo manifest");
        let project = EmbeddingProject::load(&root).expect("select project");
        fs::create_dir_all(root.join("gleam/gleam.toml")).expect("conflicting config directory");
        assert!(
            matches!(initialize(&project), Err(CliError::FileRead { path, .. }) if path == project.project_root().join("gleam.toml"))
        );
        fs::remove_dir(root.join("gleam/gleam.toml")).expect("remove conflicting directory");
        fs::create_dir(root.join("gleam/.gitignore")).expect("conflicting ignore directory");
        assert!(
            matches!(initialize(&project), Err(CliError::FileRead { path, .. }) if path == project.project_root().join(".gitignore"))
        );
        fs::remove_dir(root.join("gleam/.gitignore")).expect("remove conflicting directory");
        fs::write(root.join("gleam/.gitignore"), "/build/\n").expect("partial starter ignore rule");
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            fs::set_permissions(root.join("gleam"), fs::Permissions::from_mode(0o500))
                .expect("read-only source directory");
            let error = initialize(&project).expect_err("source directory creation should fail");
            fs::set_permissions(root.join("gleam"), fs::Permissions::from_mode(0o700))
                .expect("restore source permissions");
            assert!(
                matches!(error, CliError::FileWrite { path, .. } if path == project.project_root().join("src"))
            );
            assert!(!root.join("gleam/gleam.toml").exists());
        }
        initialize(&project).expect("complete partial starter");
        assert_eq!(
            fs::read_to_string(root.join("gleam/src/retry.gleam")).expect("completed source"),
            "pub fn double(value: Int) -> Int {\n  value * 2\n}\n"
        );
        assert!(root.join("gleam/gleam.toml").is_file());
    }

    #[test]
    fn creates_a_pure_conventional_library_and_keeps_handwritten_source_on_repeat() {
        let directory = tempdir().expect("fixture directory");
        let root = Utf8PathBuf::from_path_buf(directory.path().to_owned()).expect("UTF-8 fixture");
        fs::create_dir(root.join("src")).expect("Rust source directory");
        fs::write(root.join("src/lib.rs"), "// application\n").expect("Rust source");
        fs::write(
            root.join("Cargo.toml"),
            "[package]\nname = 'initial-app'\nversion = '0.1.0'\n[workspace]\n",
        )
        .expect("Cargo manifest");
        let project = EmbeddingProject::load(&root).expect("select project");
        initialize(&project).expect("initialize project");
        assert_eq!(
            fs::read_to_string(root.join("gleam/gleam.toml")).expect("Gleam config"),
            "name = \"initial_app\"\nversion = \"0.1.0\"\ntarget = \"erlang\"\n"
        );
        assert_eq!(
            fs::read_to_string(root.join("gleam/.gitignore")).expect("ignore rule"),
            "/build/\n"
        );
        let source = root.join("gleam/src/initial_app.gleam");
        assert_eq!(
            fs::read_to_string(&source).expect("starter"),
            "pub fn double(value: Int) -> Int {\n  value * 2\n}\n"
        );
        fs::write(&source, "pub fn changed() { 42 }\n").expect("user source");
        initialize(&project).expect("existing valid project");
        assert_eq!(
            fs::read_to_string(&source).expect("preserved source"),
            "pub fn changed() { 42 }\n"
        );
        assert_eq!(
            fs::read_to_string(root.join("src/lib.rs")).expect("Rust source"),
            "// application\n"
        );
        fs::write(root.join("gleam/gleam.toml"), "name = 'other'\n").expect("conflicting config");
        assert_eq!(
            initialize(&project)
                .expect_err("conflicting project name")
                .to_string(),
            format!(
                "invalid Rust embedding project for package initial-app at {}: {}/gleam.toml declares Gleam package `other`; expected `initial_app` from the Cargo package name",
                project.manifest(),
                project.project_root()
            )
        );
    }

    #[test]
    fn rejects_conflicting_initial_files_without_creating_a_configuration() {
        let directory = tempdir().expect("fixture directory");
        let root = Utf8PathBuf::from_path_buf(directory.path().to_owned()).expect("UTF-8 fixture");
        fs::create_dir(root.join("src")).expect("Rust source directory");
        fs::write(root.join("src/lib.rs"), "").expect("Rust source");
        fs::write(
            root.join("Cargo.toml"),
            "[package]\nname = 'conflict'\nversion = '0.1.0'\n[workspace]\n",
        )
        .expect("Cargo manifest");
        fs::create_dir_all(root.join("gleam/src")).expect("Gleam directory");
        fs::write(root.join("gleam/src/conflict.gleam"), "// mine\n").expect("existing source");
        let project = EmbeddingProject::load(&root).expect("select project");
        let error = initialize(&project).expect_err("existing source conflict");
        assert_eq!(
            error.to_string(),
            format!(
                "refusing to replace existing embedding file {}: initialization would replace an existing file; keep a valid conventional Gleam project instead",
                project.project_root().join("src/conflict.gleam")
            )
        );
        assert!(!root.join("gleam/gleam.toml").exists());
        assert_eq!(
            fs::read_to_string(root.join("gleam/src/conflict.gleam")).expect("source remains"),
            "// mine\n"
        );
        assert!(
            matches!(create_files(vec![(root.join("src/lib.rs"), "replacement".to_owned())]), Err(CliError::FileWrite { error, .. }) if error.kind() == std::io::ErrorKind::AlreadyExists)
        );
    }
}