geam-cli 0.2.3

Standalone command implementation for Geam
Documentation
use super::select_package;
use crate::cargo::{
    CargoMetadataLoader, CargoMetadataMode, SystemCargoMetadata, canonical_manifest,
};
use crate::error::CliError;
use crate::project::read_package_config;
use camino::{Utf8Path, Utf8PathBuf};
use gleam_core::config::PackageConfig;
use serde_json::json;

#[derive(Debug)]
pub(in crate::embedding) struct EmbeddingProject {
    pub(super) package_name: String,
    pub(super) manifest: Utf8PathBuf,
    pub(super) project_root: Utf8PathBuf,
    pub(super) root_module: String,
    pub(super) output_directory: Utf8PathBuf,
    pub(super) output_path: Utf8PathBuf,
    pub(super) dependencies: Vec<cargo_metadata::Dependency>,
}

impl EmbeddingProject {
    pub(in crate::embedding) fn load(current_directory: &Utf8Path) -> Result<Self, CliError> {
        Self::load_with(current_directory, &SystemCargoMetadata)
    }

    pub(in crate::embedding) fn manifest(&self) -> &Utf8Path {
        &self.manifest
    }

    pub(in crate::embedding) fn project_root(&self) -> &Utf8Path {
        &self.project_root
    }

    pub(in crate::embedding) fn root_module(&self) -> &str {
        &self.root_module
    }

    pub(in crate::embedding) fn output_path(&self) -> &Utf8Path {
        &self.output_path
    }

    pub(in crate::embedding) fn prepare_features(&self, features: &[&str]) -> Result<(), CliError> {
        super::manifest::prepare_features(self, features)
    }

    pub(super) fn load_with(
        current_directory: &Utf8Path,
        loader: &dyn CargoMetadataLoader,
    ) -> Result<Self, CliError> {
        let manifest = find_manifest(current_directory)?;
        let metadata = loader.load(
            current_directory,
            &manifest,
            CargoMetadataMode::Workspace,
            &mut crate::progress::Progress::Hidden,
        )?;
        let package = select_package(&metadata, &manifest)?;
        let package_name = package.name.to_string();
        if package
            .metadata
            .get("geam")
            .and_then(|geam| geam.get("embedding"))
            .is_some()
        {
            return Err(CliError::InvalidEmbeddingProject {
                package: package_name,
                manifest,
                reason: "remove obsolete [package.metadata.geam.embedding]; embedding uses gleam/ and the Cargo package name".to_owned(),
            });
        }
        let root_module = package_name.replace('-', "_");
        serde_json::from_value::<PackageConfig>(json!({ "name": root_module })).map_err(
            |error| CliError::InvalidEmbeddingProject {
                package: package_name.clone(),
                manifest: manifest.clone(),
                reason: format!("Cargo package name cannot name a Gleam package: {error}"),
            },
        )?;
        let root = manifest.with_file_name("");
        let output_directory = root.join("src");
        let output_path = output_directory.join("geam_bindings.rs");
        Ok(Self {
            package_name,
            manifest,
            project_root: root.join("gleam"),
            root_module,
            output_directory,
            output_path,
            dependencies: package.dependencies.clone(),
        })
    }

    pub(in crate::embedding) fn validate_gleam_config(&self) -> Result<(), CliError> {
        let config = read_package_config(&self.project_root)?;
        if config.name.as_str() != self.root_module {
            return Err(CliError::InvalidEmbeddingProject {
                package: self.package_name.clone(),
                manifest: self.manifest.clone(),
                reason: format!(
                    "{} declares Gleam package `{}`; expected `{}` from the Cargo package name",
                    self.project_root.join("gleam.toml"),
                    config.name,
                    self.root_module,
                ),
            });
        }
        Ok(())
    }
}

fn find_manifest(start: &Utf8Path) -> Result<Utf8PathBuf, CliError> {
    for directory in start.ancestors() {
        match canonical_manifest(directory.join("Cargo.toml")) {
            Err(CliError::FileRead { error, .. })
                if error.kind() == std::io::ErrorKind::NotFound => {}
            result => return result,
        }
    }
    Err(CliError::CargoManifestNotFound {
        start: start.to_path_buf(),
    })
}

#[cfg(test)]
mod tests {
    use super::EmbeddingProject;
    use crate::cargo::SystemCargoMetadata;
    use crate::error::CliError;
    use camino::Utf8PathBuf;
    use std::fs;
    use tempfile::{TempDir, tempdir};

    #[test]
    fn selects_the_cargo_name_before_dependency_resolution_or_gleam_initialization() {
        let fixture = ProjectFixture::new(
            r#"[package]
name = "inventory-app"
version = "0.1.0"

[dependencies]
geam-unresolved-embedding-fixture = "=99.0.0"

[workspace]
"#,
        );
        fs::create_dir_all(fixture.root.join("src/nested"))
            .expect("nested source directory should be created");
        let project =
            EmbeddingProject::load_with(&fixture.root.join("src/nested"), &SystemCargoMetadata)
                .expect("initial selection should not resolve dependencies");
        assert_eq!(project.package_name, "inventory-app");
        assert_eq!(project.root_module, "inventory_app");
        assert_eq!(project.project_root, fixture.root.join("gleam"));
        assert_eq!(project.output_directory, fixture.root.join("src"));
        assert_eq!(
            project.output_path,
            fixture.root.join("src/geam_bindings.rs")
        );
        assert!(!fixture.root.join("Cargo.lock").exists());
        assert!(!fixture.root.join("gleam").exists());

        let error = project
            .validate_gleam_config()
            .expect_err("missing config should fail");
        assert!(matches!(error, CliError::FileRead { path, error }
            if path == fixture.root.join("gleam/gleam.toml")
                && error.kind() == std::io::ErrorKind::NotFound));
    }

    #[test]
    fn validates_the_actual_gleam_package_name_and_preserves_project_bytes() {
        let fixture = ProjectFixture::new(
            "[package]\nname = \"inventory_app2\"\nversion = \"0.1.0\"\n[workspace]\n",
        );
        let project = EmbeddingProject::load_with(&fixture.root, &SystemCargoMetadata)
            .expect("an existing valid Gleam name should be unchanged");
        assert_eq!(project.root_module, "inventory_app2");
        fs::create_dir(&project.project_root).expect("Gleam directory should be created");
        let config = project.project_root.join("gleam.toml");
        fs::write(&config, "name = \"other\"\n").expect("mismatched config should be written");
        let error = project
            .validate_gleam_config()
            .expect_err("different Gleam name should fail");
        assert_eq!(
            error.to_string(),
            format!(
                "invalid Rust embedding project for package inventory_app2 at {}: {} declares Gleam package `other`; expected `inventory_app2` from the Cargo package name",
                fixture.root.join("Cargo.toml"),
                config,
            )
        );
        assert_eq!(
            fs::read_to_string(&config).expect("config should remain readable"),
            "name = \"other\"\n"
        );
        fs::write(&config, "invalid").expect("invalid config should be written");
        assert!(
            matches!(project.validate_gleam_config(), Err(CliError::InvalidToml { path, .. }) if path == config)
        );
        fs::write(&config, "name = \"inventory_app2\"\n")
            .expect("matching config should be written");
        project
            .validate_gleam_config()
            .expect("matching package should be accepted");
        assert_eq!(
            fs::read_to_string(&config).expect("config should remain readable"),
            "name = \"inventory_app2\"\n"
        );
    }

    #[test]
    fn rejects_cargo_names_that_gleam_cannot_use_without_inventing_a_name() {
        let fixture = ProjectFixture::new(
            "[package]\nname = \"Inventory\"\nversion = \"0.1.0\"\n[workspace]\n",
        );
        let error = EmbeddingProject::load_with(&fixture.root, &SystemCargoMetadata)
            .expect_err("uppercase Cargo name cannot name the Gleam package");
        assert_eq!(
            error.to_string(),
            format!(
                "invalid Rust embedding project for package Inventory at {}: Cargo package name cannot name a Gleam package: Package names may only contain lowercase letters, numbers, and underscores",
                fixture.root.join("Cargo.toml"),
            )
        );
        assert!(!fixture.root.join("gleam").exists());
    }

    #[test]
    fn rejects_obsolete_selector_metadata_before_resolving_or_writing_files() {
        let fixture = ProjectFixture::new(
            r#"[package]
name = "inventory"
version = "0.1.0"
[package.metadata.geam.embedding]
project = "another-project"
module = "another_module"
[workspace]
"#,
        );
        let before =
            fs::read(fixture.root.join("Cargo.toml")).expect("manifest should be readable");
        let error = EmbeddingProject::load_with(&fixture.root, &SystemCargoMetadata)
            .expect_err("obsolete selectors must not be silently ignored");
        assert_eq!(
            error.to_string(),
            format!(
                "invalid Rust embedding project for package inventory at {}: remove obsolete [package.metadata.geam.embedding]; embedding uses gleam/ and the Cargo package name",
                fixture.root.join("Cargo.toml"),
            )
        );
        assert_eq!(
            fs::read(fixture.root.join("Cargo.toml")).expect("manifest should remain readable"),
            before
        );
        assert!(!fixture.root.join("Cargo.lock").exists());
    }

    #[test]
    fn requires_a_member_directory_at_a_virtual_workspace_root() {
        let fixture =
            ProjectFixture::new("[workspace]\nmembers = [\"member\"]\nresolver = \"3\"\n");
        fs::create_dir_all(fixture.root.join("member/src")).expect("member should be created");
        fs::write(
            fixture.root.join("member/Cargo.toml"),
            "[package]\nname = \"inventory\"\nversion = \"0.1.0\"\n",
        )
        .expect("member manifest should be written");
        fs::write(fixture.root.join("member/src/lib.rs"), "")
            .expect("member source should be written");
        let error = EmbeddingProject::load_with(&fixture.root, &SystemCargoMetadata)
            .expect_err("virtual root must not pick a member implicitly");
        assert_eq!(
            error.to_string(),
            format!(
                "cannot select an embedding package from {}: the manifest is not a Cargo package; run from a workspace member directory",
                fixture.root.join("Cargo.toml"),
            )
        );
        let member =
            EmbeddingProject::load_with(&fixture.root.join("member/src"), &SystemCargoMetadata)
                .expect("member directory should select its own package");
        assert_eq!(member.manifest, fixture.root.join("member/Cargo.toml"));
        assert_eq!(member.project_root, fixture.root.join("member/gleam"));
    }

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

        let directory = tempdir().expect("temporary directory should be created");
        let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf())
            .expect("temporary path should be UTF-8");
        let manifest = root.join("Cargo.toml");
        symlink("Cargo.toml", &manifest).expect("cyclic manifest link should be created");
        let expected = fs::canonicalize(&manifest)
            .expect_err("cyclic manifest link should fail canonicalization");
        let error = EmbeddingProject::load_with(&root, &SystemCargoMetadata)
            .expect_err("lookup failure should be reported before Cargo runs");
        assert!(matches!(error, CliError::FileRead { path, error }
            if path == manifest && error.to_string() == expected.to_string()));
    }

    struct ProjectFixture {
        _directory: TempDir,
        root: Utf8PathBuf,
    }

    impl ProjectFixture {
        fn new(manifest: &str) -> Self {
            let directory = tempdir().expect("temporary directory should be created");
            let root = Utf8PathBuf::from_path_buf(
                fs::canonicalize(directory.path()).expect("temporary path should canonicalize"),
            )
            .expect("path should be UTF-8");
            fs::create_dir(root.join("src")).expect("source directory should be created");
            fs::write(root.join("src/lib.rs"), "").expect("Rust source should be written");
            fs::write(root.join("Cargo.toml"), manifest).expect("Cargo manifest should be written");
            Self {
                _directory: directory,
                root,
            }
        }
    }
}