cufflink-cli 0.20.0

CLI for the Cufflink CRUD microservice platform — deploy, init, and manage services
//! Clients from openapi-generator's `typescript-fetch`, run through npx with
//! the properties Mantis's clients are generated with.

use std::path::Path;
use std::process::Command;

/// The npm launcher, pinned so a release keeps generating the same way.
pub(crate) const GENERATOR_PACKAGE: &str = "@openapitools/openapi-generator-cli@2.41.0";

/// The generator version Mantis pins in its `openapitools.json`, and CI's.
pub(crate) const DEFAULT_GENERATOR_VERSION: &str = "7.21.0";

/// Mantis's `typescript-fetch` properties.
const DEFAULT_PROPERTIES: [(&str, &str); 5] = [
    ("supportsES6", "true"),
    ("nullSafeAdditionalProps", "true"),
    ("modelPropertyNaming", "camelCase"),
    ("enumPropertyNaming", "camelCase"),
    ("removeEnumValuePrefix", "true"),
];

/// The `--additional-properties` value: the defaults, then `npmName`, then
/// each `K=V` of `overrides`, a later value replacing an earlier one of the
/// same key.
pub(super) fn properties(npm_name: Option<&str>, overrides: Option<&str>) -> eyre::Result<String> {
    let overrides = overrides
        .into_iter()
        .flat_map(|list| list.split(','))
        .map(str::trim)
        .filter(|pair| !pair.is_empty())
        .map(|pair| {
            pair.split_once('=')
                .filter(|(key, _)| !key.is_empty())
                .ok_or_else(|| eyre::eyre!("--additional-properties takes K=V pairs, got '{pair}'"))
        })
        .collect::<eyre::Result<Vec<_>>>()?;
    let merged = DEFAULT_PROPERTIES
        .into_iter()
        .chain(npm_name.map(|name| ("npmName", name)))
        .chain(overrides)
        .fold(Vec::<(&str, &str)>::new(), |mut merged, (key, value)| {
            match merged.iter_mut().find(|(existing, _)| *existing == key) {
                Some(entry) => entry.1 = value,
                None => merged.push((key, value)),
            }
            merged
        });
    Ok(merged
        .iter()
        .map(|(key, value)| format!("{key}={value}"))
        .collect::<Vec<_>>()
        .join(","))
}

/// Fail before anything is written when npx is not on `PATH`.
pub(super) fn require_npx() -> eyre::Result<()> {
    let found = std::env::var_os("PATH").is_some_and(|path| {
        std::env::split_paths(&path).any(|dir| {
            ["npx", "npx.cmd"]
                .iter()
                .any(|name| dir.join(name).is_file())
        })
    });
    if found {
        Ok(())
    } else {
        eyre::bail!(
            "--generator openapi-generator runs {GENERATOR_PACKAGE} through npx, which is not on \
             PATH; install Node.js (and a Java runtime, which the generator needs)"
        )
    }
}

/// Generate the client for `spec` into `output` with generator `version`,
/// pinned by an `openapitools.json` written there, streaming the
/// generator's output.
pub(super) fn run(spec: &Path, output: &Path, properties: &str, version: &str) -> eyre::Result<()> {
    let config = output.join("openapitools.json");
    std::fs::write(
        &config,
        serde_json::json!({"generator-cli": {"version": version}}).to_string(),
    )?;
    let status = Command::new("npx")
        .args(["--yes", GENERATOR_PACKAGE, "--openapitools"])
        .arg(&config)
        .args(["generate", "-i"])
        .arg(spec)
        .args(["-g", "typescript-fetch", "-o"])
        .arg(output)
        .args(["--additional-properties", properties])
        .status()?;
    if status.success() {
        println!(
            "Generated the typescript-fetch client in {} (openapi-generator {version})",
            output.display()
        );
        Ok(())
    } else {
        eyre::bail!("openapi-generator failed ({status})")
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn properties_are_mantis_defaults_with_the_package_and_overrides() {
        assert_eq!(
            properties(Some("@mantis-crm/notes-service-client"), None).expect("properties"),
            "supportsES6=true,nullSafeAdditionalProps=true,modelPropertyNaming=camelCase,\
             enumPropertyNaming=camelCase,removeEnumValuePrefix=true,\
             npmName=@mantis-crm/notes-service-client"
        );
        assert_eq!(
            properties(None, Some("supportsES6=false, withInterfaces=true")).expect("properties"),
            "supportsES6=false,nullSafeAdditionalProps=true,modelPropertyNaming=camelCase,\
             enumPropertyNaming=camelCase,removeEnumValuePrefix=true,withInterfaces=true"
        );
        for bad in ["noequals", "=value"] {
            assert!(properties(None, Some(bad)).is_err(), "{bad}");
        }
    }
}