cotis-cli 0.1.0-alpha

Plugin host for Cotis build, check, and run routines
Documentation
use std::fs;
use std::process::ExitCode;
use std::sync::OnceLock;

use clap::{CommandFactory, Parser};
use directories::ProjectDirs;
use log::{debug, error, info, warn};

use cotis_cli::install::{install, normalize_cdylib_stem, parse_install_spec};
use cotis_cli::plugin::LoadedPlugin;
use cotis_cli::routines::{
    find_latest_installed_version, host_target_triple, installed_routine_names,
    plugin_library_path, routine_root,
};

use crate::cli::{Commands, CotisCli, InfoArgs, InstallArgs, RemoveArgs, RunArgs};

pub(crate) static PROJ_DIRS: OnceLock<ProjectDirs> = OnceLock::new();

mod cli;
mod interactive;

fn main() -> ExitCode {
    let cli = CotisCli::parse();

    let log_level = if cli.verbose {
        log::LevelFilter::Debug
    } else {
        log::LevelFilter::Info
    };
    env_logger::Builder::new().filter_level(log_level).init();

    if let Some(proj_dirs) = ProjectDirs::from("", "Cotis Layout", "cotis-cli") {
        PROJ_DIRS.get_or_init(|| proj_dirs);
    } else {
        error!("Could not resolve cotis-cli application directories");
        return ExitCode::FAILURE;
    }

    match &cli.command {
        Commands::Completions { shell } => {
            let mut cmd = CotisCli::command();
            clap_complete::generate(*shell, &mut cmd, "cotis-cli", &mut std::io::stdout());
            ExitCode::SUCCESS
        }
        Commands::List => {
            list_routines();
            ExitCode::SUCCESS
        }
        Commands::Install(args) | Commands::Update(args) => {
            let proj_dirs = proj_dirs_or_panic();
            let force = matches!(&cli.command, Commands::Update(..)) || cli.overwrite;
            exit_result(cmd_install(proj_dirs, args, force))
        }
        Commands::Run(args) | Commands::Build(args) => {
            exit_result(cmd_run(proj_dirs_or_panic(), args))
        }
        Commands::Remove(args) => exit_result(cmd_remove(proj_dirs_or_panic(), args)),
        Commands::Info(args) => exit_result(cmd_info(proj_dirs_or_panic(), args)),
        Commands::HelpRoutine(args) => exit_result(cmd_help_routine(proj_dirs_or_panic(), args)),
    }
}

fn proj_dirs_or_panic() -> &'static ProjectDirs {
    PROJ_DIRS
        .get()
        .expect("project dirs initialized before command dispatch")
}

fn exit_result(r: Result<(), ()>) -> ExitCode {
    if r.is_ok() {
        ExitCode::SUCCESS
    } else {
        ExitCode::FAILURE
    }
}

fn cmd_install(
    proj_dirs: &ProjectDirs,
    args: &InstallArgs,
    base_overwrite: bool,
) -> Result<(), ()> {
    let spec_from_tty;
    let spec_str = match &args.spec {
        Some(s) => {
            spec_from_tty = false;
            s.clone()
        }
        None => {
            spec_from_tty = true;
            match crate::interactive::prompt_install_spec() {
                Ok(s) => s,
                Err(e) => {
                    error!("{e}");
                    return Err(());
                }
            }
        }
    };

    let alias_override: Option<String> = if let Some(a) = &args.alias {
        Some(a.clone())
    } else if spec_from_tty {
        match crate::interactive::prompt_optional_alias() {
            Ok(a) => a,
            Err(e) => {
                error!("{e}");
                return Err(());
            }
        }
    } else {
        None
    };

    let overwrite = base_overwrite || spec_from_tty;
    let spec = parse_install_spec(&spec_str).map_err(|e| {
        error!("{e}");
    })?;
    let cdylib_override = match args.cdylib_name.as_deref() {
        Some(s) => Some(normalize_cdylib_stem(s).map_err(|e| {
            error!("{e}");
        })?),
        None => None,
    };
    install(
        proj_dirs,
        spec,
        alias_override.as_deref(),
        cdylib_override.as_deref(),
        overwrite,
    )
    .map_err(|e| {
        error!("{e}");
    })?;
    info!("Successfully installed routine");
    Ok(())
}

fn cmd_run(proj_dirs: &ProjectDirs, args: &RunArgs) -> Result<(), ()> {
    let routine = match &args.routine {
        Some(r) => r.clone(),
        None => {
            match crate::interactive::prompt_routine(proj_dirs, "Which routine do you want to run?")
            {
                Ok(r) => r,
                Err(e) => {
                    error!("{e}");
                    return Err(());
                }
            }
        }
    };

    let target = host_target_triple().map_err(|e| {
        error!("{e}");
    })?;

    let (name, version) = parse_routine_spec(&routine);
    let version = resolve_version(name, version.as_deref())?;
    run_routine(proj_dirs, name, &version, &target, &args.passthrough)
}

fn run_routine(
    proj_dirs: &ProjectDirs,
    routine: &str,
    version: &str,
    target: &str,
    passthrough: &[String],
) -> Result<(), ()> {
    let lib_path = plugin_library_path(proj_dirs, routine, version, target);
    debug!("Loading routine plugin {}", lib_path.display());
    // SAFETY: `cotis-cli run` mutates process env before loading one routine plugin on the main thread.
    unsafe {
        std::env::set_var(
            "COTIS_CLI_CACHE_DIR",
            proj_dirs.cache_dir().to_string_lossy().into_owned(),
        );
    }
    let plugin = unsafe {
        LoadedPlugin::load(&lib_path).map_err(|e| {
            error!("{e}");
        })?
    };
    plugin.ensure_compatible().map_err(|e| {
        error!("{e}");
    })?;
    let mut all_args = vec![routine.to_string()];
    all_args.extend_from_slice(passthrough);
    let rc = plugin.run_with_args(&all_args).map_err(|e| {
        error!("{e}");
    })?;
    if rc == 0 { Ok(()) } else { Err(()) }
}

fn cmd_help_routine(proj_dirs: &ProjectDirs, args: &InfoArgs) -> Result<(), ()> {
    let spec = match &args.spec {
        Some(s) => s.clone(),
        None => match crate::interactive::prompt_routine_spec(proj_dirs, "Which routine's help?") {
            Ok(s) => s,
            Err(e) => {
                error!("{e}");
                return Err(());
            }
        },
    };
    let (routine, version) = parse_routine_spec(&spec);
    let version = resolve_version(routine, version.as_deref())?;
    let target = host_target_triple().map_err(|e| {
        error!("{e}");
    })?;
    let lib_path = plugin_library_path(proj_dirs, routine, &version, &target);
    let plugin = unsafe {
        LoadedPlugin::load(&lib_path).map_err(|e| {
            error!("{e}");
        })?
    };
    plugin.ensure_compatible().map_err(|e| {
        error!("{e}");
    })?;
    let help = plugin.help().map_err(|e| {
        error!("{e}");
    })?;
    println!("{help}");
    Ok(())
}

fn list_routines() {
    let proj_dirs = match PROJ_DIRS.get() {
        Some(p) => p,
        None => {
            error!("Project directories not initialized");
            return;
        }
    };
    let names = installed_routine_names(proj_dirs);
    if names.is_empty() {
        println!("No routines installed.");
        return;
    }
    for name in names {
        println!("{name}");
    }
}

fn cmd_remove(proj_dirs: &ProjectDirs, args: &RemoveArgs) -> Result<(), ()> {
    let routine = match &args.routine {
        Some(r) => r.clone(),
        None => {
            match crate::interactive::prompt_routine(proj_dirs, "Which routine should be removed?")
            {
                Ok(r) => r,
                Err(e) => {
                    error!("{e}");
                    return Err(());
                }
            }
        }
    };
    let root = routine_root(proj_dirs, &routine);
    if !root.exists() {
        warn!("{routine} not installed");
        return Ok(());
    }
    fs::remove_dir_all(&root).map_err(|e| {
        error!("Failed to remove {}: {e}", root.display());
    })?;
    info!("Removed routine `{routine}`");
    Ok(())
}

fn cmd_info(proj_dirs: &ProjectDirs, args: &InfoArgs) -> Result<(), ()> {
    let spec = match &args.spec {
        Some(s) => s.clone(),
        None => match crate::interactive::prompt_routine_spec(proj_dirs, "Which routine's info?") {
            Ok(s) => s,
            Err(e) => {
                error!("{e}");
                return Err(());
            }
        },
    };
    let (routine, version) = parse_routine_spec(&spec);
    let version = resolve_version(routine, version.as_deref())?;
    let target = host_target_triple().map_err(|e| {
        error!("{e}");
    })?;
    let lib_path = plugin_library_path(proj_dirs, routine, &version, &target);
    let plugin = unsafe {
        LoadedPlugin::load(&lib_path).map_err(|e| {
            error!("{e}");
        })?
    };
    plugin.ensure_compatible().map_err(|e| {
        error!("{e}");
    })?;
    let descriptor = plugin.descriptor_json().map_err(|e| {
        error!("{e}");
    })?;
    println!("{descriptor}");
    Ok(())
}

fn parse_routine_spec(spec: &str) -> (&str, Option<String>) {
    match spec.split_once('@') {
        Some((name, version)) => (name, Some(version.to_string())),
        None => (spec, None),
    }
}

fn resolve_version(routine: &str, requested: Option<&str>) -> Result<String, ()> {
    if let Some(v) = requested {
        return Ok(v.to_string());
    }
    find_latest_installed_version(PROJ_DIRS.get().unwrap(), routine).ok_or_else(|| {
        error!("No installed versions found for {routine}");
    })
}