cargo-teaql 0.1.5

Rust CLI for TeaQL service code generation workflows
Documentation
pub mod cli;
pub mod config;
pub mod generator;
pub mod service;

use std::{
    ffi::OsString,
    fs,
    path::{Path, PathBuf},
};

use anyhow::{Context, Result, bail};
use clap::Parser;
use cli::{Cli, Commands, GenerateArgs, InstallLinksArgs, ServiceArgs};
use config::{ConfigOverrides, EnvConfig, TeaqlConfig, config_file_path};

pub fn run_from_env() -> Result<()> {
    let args: Vec<OsString> = std::env::args_os().collect();
    run_with_args(args)
}

pub fn run_with_args<I, T>(args: I) -> Result<()>
where
    I: IntoIterator<Item = T>,
    T: Into<OsString>,
{
    let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
    let argv = rewrite_args_for_alias(args);
    run_cli(Cli::parse_from(argv))
}

pub fn run_cli(cli: Cli) -> Result<()> {
    match cli.command {
        Commands::Config => {
            let config_path = config_file_path()?;
            let existing = TeaqlConfig::load()?;
            let updated = config::run_wizard(existing)?;
            updated.save(&config_path)?;
            println!("saved config to {}", config_path.display());
        }
        Commands::ShowConfig => {
            let config_path = config_file_path()?;
            let config = TeaqlConfig::load()?;
            println!("config_path: {}", config_path.display());
            println!("{}", serde_yaml::to_string(&config)?);
        }
        Commands::InstallLinks(args) => install_links(args)?,
        Commands::GenCode(args) => run_generate(args, Some("rust-lib"), cli.cwd)?,
        Commands::GenDoc(args) => run_generate(args, Some("doc"), cli.cwd)?,
        Commands::GenModel(args) => run_generate(args, Some("frontend"), cli.cwd)?,
        Commands::Version(args) => run_version(args, cli.cwd)?,
        Commands::Ping(args) => run_ping(args, cli.cwd)?,
    }

    Ok(())
}

fn run_generate(args: GenerateArgs, scope: Option<&str>, cwd: PathBuf) -> Result<()> {
    let config = TeaqlConfig::load()?;
    let env = EnvConfig::from_env();
    let overrides = ConfigOverrides {
        endpoint_prefix: args.endpoint_prefix,
        service_url: args.service_url,
        license_file: args.license_file,
        build_dir: args.output,
        timeout_seconds: args.timeout_seconds,
    };
    let resolved = config.resolve(overrides, &env, &cwd);
    generator::generate(&args.input, scope, &resolved)
}

fn run_version(args: ServiceArgs, cwd: PathBuf) -> Result<()> {
    let config = TeaqlConfig::load()?;
    let env = EnvConfig::from_env();
    let overrides = ConfigOverrides {
        endpoint_prefix: args.endpoint_prefix,
        service_url: args.service_url,
        license_file: None,
        build_dir: None,
        timeout_seconds: args.timeout_seconds,
    };
    let resolved = config.resolve(overrides, &env, &cwd);
    service::print_version(&resolved)
}

fn run_ping(args: ServiceArgs, cwd: PathBuf) -> Result<()> {
    let config = TeaqlConfig::load()?;
    let env = EnvConfig::from_env();
    let overrides = ConfigOverrides {
        endpoint_prefix: args.endpoint_prefix,
        service_url: args.service_url,
        license_file: args.license_file,
        build_dir: Some(std::env::temp_dir().join("teaql-ping")),
        timeout_seconds: args.timeout_seconds,
    };
    let resolved = config.resolve(overrides, &env, &cwd);
    service::ping(&resolved)
}

fn rewrite_args_for_alias(mut args: Vec<OsString>) -> Vec<OsString> {
    let alias_name = args
        .first()
        .and_then(|arg| Path::new(arg).file_name())
        .and_then(|name| name.to_str())
        .map(String::from);

    if let Some(ref program_name) = alias_name {
        if let Some(subcommand) = alias_subcommand(program_name) {
            args[0] = OsString::from("teaql");
            args.insert(1, OsString::from(subcommand));
            // Cargo passes the subcommand name (without the "cargo-" prefix)
            // as the second argument, e.g.:
            //   "cargo teaql-version" → argv[1] = "teaql-version"
            // After rewriting, this becomes redundant; strip it.
            let cargo_arg = program_name.strip_prefix("cargo-").unwrap_or(program_name);
            if args.len() > 2 && args[2] == cargo_arg {
                args.remove(2);
            }
        }
    }
    args
}

fn alias_subcommand(program_name: &str) -> Option<&'static str> {
    match program_name {
        "cargo-teaql-gen-code" => Some("gen-code"),
        "cargo-teaql-gen-doc" => Some("gen-doc"),
        "cargo-teaql-gen-model" => Some("gen-model"),
        "cargo-teaql-version" => Some("version"),
        "cargo-teaql-ping" => Some("ping"),
        "cargo-teaql-show-config" => Some("show-config"),
        "cargo-teaql-config" => Some("config"),
        _ => None,
    }
}

fn install_links(args: InstallLinksArgs) -> Result<()> {
    #[cfg(not(unix))]
    {
        let _ = args;
        bail!("install-links currently supports Unix-style symlinks only");
    }

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

        let current_exe = std::env::current_exe().context("failed to locate current executable")?;
        let target = fs::canonicalize(&current_exe)
            .with_context(|| format!("failed to resolve {}", current_exe.display()))?;
        let install_dir = match args.dir {
            Some(dir) => dir,
            None => current_exe
                .parent()
                .context("current executable has no parent directory")?
                .to_path_buf(),
        };

        fs::create_dir_all(&install_dir)
            .with_context(|| format!("failed to create {}", install_dir.display()))?;

        for alias in link_names() {
            let link_path = install_dir.join(alias);
            if link_path.exists() || symlink_metadata_exists(&link_path) {
                if points_to_target(&link_path, &target)? {
                    println!("exists {}", link_path.display());
                    continue;
                }

                if !args.force {
                    bail!(
                        "refusing to overwrite existing path without --force: {}",
                        link_path.display()
                    );
                }

                fs::remove_file(&link_path)
                    .with_context(|| format!("failed to remove {}", link_path.display()))?;
            }

            symlink(&target, &link_path).with_context(|| {
                format!(
                    "failed to create symlink {} -> {}",
                    link_path.display(),
                    target.display()
                )
            })?;
            println!("linked {} -> {}", link_path.display(), target.display());
        }
    }

    Ok(())
}

fn link_names() -> &'static [&'static str] {
    &[
        "teaql",
        "cargo-teaql-gen-code",
        "cargo-teaql-gen-doc",
        "cargo-teaql-gen-model",
        "cargo-teaql-version",
        "cargo-teaql-show-config",
        "cargo-teaql-ping",
        "cargo-teaql-config",
    ]
}

fn symlink_metadata_exists(path: &Path) -> bool {
    fs::symlink_metadata(path).is_ok()
}

fn points_to_target(link_path: &Path, target: &Path) -> Result<bool> {
    let metadata = match fs::symlink_metadata(link_path) {
        Ok(metadata) => metadata,
        Err(_) => return Ok(false),
    };
    if !metadata.file_type().is_symlink() {
        return Ok(false);
    }

    let linked = fs::canonicalize(link_path)
        .with_context(|| format!("failed to resolve {}", link_path.display()))?;
    Ok(linked == target)
}

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

    #[test]
    fn rewrites_alias_binary_name_to_subcommand() {
        let args = vec![
            OsString::from("/tmp/bin/cargo-teaql-gen-code"),
            OsString::from("model.yml"),
            OsString::from("--cwd"),
            OsString::from("/workspace"),
        ];

        let rewritten = rewrite_args_for_alias(args);

        assert_eq!(rewritten[0], OsString::from("teaql"));
        assert_eq!(rewritten[1], OsString::from("gen-code"));
        assert_eq!(rewritten[2], OsString::from("model.yml"));
        assert_eq!(rewritten[3], OsString::from("--cwd"));
        assert_eq!(rewritten[4], OsString::from("/workspace"));
    }

    #[test]
    fn strips_cargo_injected_subcommand_name() {
        // Cargo strips the "cargo-" prefix when passing the subcommand name:
        // "cargo teaql-version" → argv = ["/path/to/cargo-teaql-version", "teaql-version"]
        let args = vec![
            OsString::from("/tmp/bin/cargo-teaql-version"),
            OsString::from("teaql-version"),
        ];

        let rewritten = rewrite_args_for_alias(args);

        assert_eq!(rewritten[0], OsString::from("teaql"));
        assert_eq!(rewritten[1], OsString::from("version"));
        assert_eq!(rewritten.len(), 2, "cargo-injected arg should be stripped");
    }

    #[test]
    fn strips_cargo_injected_arg_for_gen_code_with_input() {
        // "cargo teaql-gen-code model.xml"
        // cargo passes: argv = ["/path/to/cargo-teaql-gen-code", "teaql-gen-code", "model.xml"]
        let args = vec![
            OsString::from("/tmp/bin/cargo-teaql-gen-code"),
            OsString::from("teaql-gen-code"),
            OsString::from("model.xml"),
        ];

        let rewritten = rewrite_args_for_alias(args);

        assert_eq!(rewritten[0], OsString::from("teaql"));
        assert_eq!(rewritten[1], OsString::from("gen-code"));
        assert_eq!(rewritten[2], OsString::from("model.xml"));
        assert_eq!(rewritten.len(), 3);
    }

    #[test]
    fn leaves_primary_binary_name_unchanged() {
        let args = vec![OsString::from("cargo-teaql"), OsString::from("show-config")];

        let rewritten = rewrite_args_for_alias(args.clone());

        assert_eq!(rewritten, args);
    }

    #[test]
    fn link_names_cover_all_aliases() {
        assert!(link_names().contains(&"teaql"));
        assert!(link_names().contains(&"cargo-teaql-gen-code"));
        assert!(link_names().contains(&"cargo-teaql-gen-doc"));
        assert!(link_names().contains(&"cargo-teaql-gen-model"));
        assert!(link_names().contains(&"cargo-teaql-version"));
        assert!(link_names().contains(&"cargo-teaql-show-config"));
        assert!(link_names().contains(&"cargo-teaql-ping"));
        assert!(link_names().contains(&"cargo-teaql-config"));
    }
}