geam-cli 0.2.3

Standalone command implementation for Geam
Documentation
use super::manifest::{ProviderSelection, ProviderSource, read_provider_selections};
use crate::error::CliError;
use camino::Utf8Path;
use std::io::Write;

const GLEAM_HEADER: &str = "GLEAM PACKAGE";
const CRATE_HEADER: &str = "CARGO CRATE";

pub(super) fn write(project_root: &Utf8Path, writer: &mut dyn Write) -> Result<(), CliError> {
    let providers = read_provider_selections(project_root)?;
    let output = render(&providers);
    writer
        .write_all(output.as_bytes())
        .and_then(|()| writer.flush())
        .map_err(CliError::ProviderListIo)
}

fn render(providers: &[ProviderSelection]) -> String {
    if providers.is_empty() {
        return "No external providers are selected.\n".to_owned();
    }

    let gleam_width = providers
        .iter()
        .map(|provider| provider.gleam_package().len())
        .max()
        .unwrap_or(0)
        .max(GLEAM_HEADER.len());
    let crate_width = providers
        .iter()
        .map(|provider| provider.crate_name().len())
        .max()
        .unwrap_or(0)
        .max(CRATE_HEADER.len());
    let mut output =
        format!("{GLEAM_HEADER:<gleam_width$}  {CRATE_HEADER:<crate_width$}  SOURCE\n");
    for provider in providers {
        output.push_str(&format!(
            "{:<gleam_width$}  {:<crate_width$}  {}\n",
            provider.gleam_package(),
            provider.crate_name(),
            render_source(provider.source()),
        ));
    }
    output
}

fn render_source(source: &ProviderSource) -> String {
    match source {
        ProviderSource::Registry { version } => format!("crates.io {version}"),
        ProviderSource::Path { path } => format!("path {}", quoted(path.as_str())),
        ProviderSource::Git { url, rev } => match rev {
            Some(rev) => format!("git {} rev {}", quoted(url), quoted(rev)),
            None => format!("git {}", quoted(url)),
        },
    }
}

fn quoted(value: &str) -> String {
    format!("{value:?}")
}

#[cfg(test)]
mod tests {
    use super::write;
    use crate::error::CliError;
    use crate::provider::manifest::MANAGED_HEADER;
    use camino::Utf8PathBuf;
    use std::fs;
    use std::io::{self, Write};
    use tempfile::tempdir;

    #[test]
    fn lists_managed_sources_in_package_order_with_fixed_columns() {
        let project = tempdir().expect("temporary project should be created");
        let root = utf8_root(&project);
        let manifest = format!(
            r#"{MANAGED_HEADER}
[package.metadata.geam.runner]
schema = 1

[dependencies]
toml = "0.9"
geam_provider_search = {{ package = "geam-search", git = "https://example.com/search.git", rev = "abc123" }}
geam_provider_catalog = {{ package = "geam-catalog", path = '/workspace/catalog "draft"' }}
geam_provider_example_text_pattern = {{ package = "geam-example-text-pattern", version = "=0.1.0" }}
geam_provider_websocket = {{ package = "geam-websocket", git = "https://example.com/websocket.git" }}
"#,
        );
        fs::write(root.join("Cargo.toml"), &manifest).expect("managed manifest should be written");

        let mut output = Vec::new();
        write(&root, &mut output).expect("provider list should be written");

        assert_eq!(
            String::from_utf8(output).expect("provider list should be UTF-8"),
            concat!(
                "GLEAM PACKAGE         CARGO CRATE                SOURCE\n",
                "catalog               geam-catalog               path \"/workspace/catalog \\\"draft\\\"\"\n",
                "example_text_pattern  geam-example-text-pattern  crates.io 0.1.0\n",
                "search                geam-search                git \"https://example.com/search.git\" rev \"abc123\"\n",
                "websocket             geam-websocket             git \"https://example.com/websocket.git\"\n",
            ),
        );
        assert_eq!(
            fs::read_to_string(root.join("Cargo.toml"))
                .expect("managed manifest should remain readable"),
            manifest,
        );
    }

    #[test]
    fn reports_an_empty_selection_without_creating_a_manifest() {
        let project = tempdir().expect("temporary project should be created");
        let root = utf8_root(&project);
        let mut output = Vec::new();

        write(&root, &mut output).expect("empty provider list should be written");

        assert_eq!(output, b"No external providers are selected.\n");
        assert!(!root.join("Cargo.toml").exists());
    }

    #[test]
    fn preserves_managed_manifest_ownership_errors() {
        let project = tempdir().expect("temporary project should be created");
        let root = utf8_root(&project);
        fs::write(
            root.join("Cargo.toml"),
            "[package]\nname = \"application\"\n",
        )
        .expect("user-owned manifest should be written");

        let error = write(&root, &mut Vec::new()).expect_err("user-owned manifest should fail");

        assert!(matches!(
            error,
            CliError::UserOwnedCargoManifest { path } if path == root.join("Cargo.toml")
        ));
    }

    #[test]
    fn preserves_write_and_flush_failures() {
        for (write_failure, expected_kind) in [
            (Some(io::ErrorKind::BrokenPipe), io::ErrorKind::BrokenPipe),
            (None, io::ErrorKind::WriteZero),
        ] {
            let project = tempdir().expect("temporary project should be created");
            let root = utf8_root(&project);
            let error = write(&root, &mut FailingWriter { write_failure })
                .expect_err("provider list output should fail");

            assert_eq!(error.to_string(), "failed to write provider list");
            assert!(matches!(
                error,
                CliError::ProviderListIo(error) if error.kind() == expected_kind
            ));
        }
    }

    struct FailingWriter {
        write_failure: Option<io::ErrorKind>,
    }

    impl Write for FailingWriter {
        fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
            match self.write_failure {
                Some(kind) => Err(io::Error::from(kind)),
                None => Ok(buffer.len()),
            }
        }

        fn flush(&mut self) -> io::Result<()> {
            Err(io::Error::from(io::ErrorKind::WriteZero))
        }
    }

    fn utf8_root(project: &tempfile::TempDir) -> Utf8PathBuf {
        Utf8PathBuf::from_path_buf(project.path().to_path_buf())
            .expect("temporary path should be valid UTF-8")
    }
}