cotis-cli 0.1.0-alpha

Plugin host for Cotis build, check, and run routines
Documentation
//! Cache path helpers and installed-routine discovery.
//!
//! Routines are stored under `ProjectDirs::cache_dir()`:
//!
//! ```text
//! cache/routines/<name>/<version>/<target_triple>/plugin/<library>
//! cache/routines/<name>/<version>/<target_triple>/descriptor.json
//! ```
//!
//! ## Version resolution
//!
//! [`find_latest_installed_version`] and default `run` behavior use **lexicographic** sorting on
//! version directory names. The last sorted entry is treated as "latest". This is **not**
//! semver-aware (e.g. `1.9.0` sorts after `1.10.0`). Pin versions with `run <name>@<version>`.

use std::path::PathBuf;
use std::process::{Command, Stdio};

use directories::ProjectDirs;

/// Root cache directory (`proj_dirs.cache_dir()`).
pub fn cache_root(proj_dirs: &ProjectDirs) -> PathBuf {
    proj_dirs.cache_dir().to_path_buf()
}

/// `cache/routines` — parent of all installed routine names.
pub fn routines_root(proj_dirs: &ProjectDirs) -> PathBuf {
    cache_root(proj_dirs).join("routines")
}

/// `cache/routines/<name>`.
pub fn routine_root(proj_dirs: &ProjectDirs, name: &str) -> PathBuf {
    routines_root(proj_dirs).join(name)
}

/// `cache/routines/<name>/<version>`.
pub fn routine_version_root(proj_dirs: &ProjectDirs, name: &str, version: &str) -> PathBuf {
    routine_root(proj_dirs, name).join(version)
}

/// `cache/routines/<name>/<version>/<target_triple>`.
pub fn routine_target_root(
    proj_dirs: &ProjectDirs,
    name: &str,
    version: &str,
    target_triple: &str,
) -> PathBuf {
    routine_version_root(proj_dirs, name, version).join(target_triple)
}

/// `cache/routines/<name>/<version>/<target_triple>/plugin`.
pub fn plugin_dir(
    proj_dirs: &ProjectDirs,
    name: &str,
    version: &str,
    target_triple: &str,
) -> PathBuf {
    routine_target_root(proj_dirs, name, version, target_triple).join("plugin")
}

/// Path to `descriptor.json` for an installed routine version and target.
pub fn descriptor_path(
    proj_dirs: &ProjectDirs,
    name: &str,
    version: &str,
    target_triple: &str,
) -> PathBuf {
    routine_target_root(proj_dirs, name, version, target_triple).join("descriptor.json")
}

/// Platform-specific dynamic library filename for a routine name stem.
///
/// Same convention as [`crate::cdylib_filename`].
pub fn platform_library_filename(crate_name: &str) -> String {
    if cfg!(windows) {
        format!("{crate_name}.dll")
    } else if cfg!(target_os = "macos") {
        format!("lib{crate_name}.dylib")
    } else {
        format!("lib{crate_name}.so")
    }
}

/// Host target triple from `rustc -vV` (`host:` line).
///
/// # Errors
///
/// Returns an error if `rustc` is missing, exits non-zero, or output cannot be parsed.
///
/// # Examples
///
/// ```ignore
/// let triple = host_target_triple().unwrap();
/// assert!(!triple.is_empty());
/// ```
pub fn host_target_triple() -> Result<String, String> {
    let out = Command::new("rustc")
        .args(["-vV"])
        .stderr(Stdio::inherit())
        .output()
        .map_err(|e| format!("Failed to run rustc: {e}"))?;
    if !out.status.success() {
        return Err("rustc -vV failed".to_string());
    }
    let stdout = String::from_utf8_lossy(&out.stdout);
    for line in stdout.lines() {
        if let Some(rest) = line.strip_prefix("host: ") {
            return Ok(rest.trim().to_string());
        }
    }
    Err("Failed to parse rustc host triple".to_string())
}

/// Sorted routine names that have at least one installed version directory.
pub fn installed_routine_names(proj_dirs: &ProjectDirs) -> Vec<String> {
    let root = routines_root(proj_dirs);
    if !root.exists() {
        return Vec::new();
    }
    let mut names: Vec<String> = std::fs::read_dir(&root)
        .into_iter()
        .flatten()
        .filter_map(|e| e.ok())
        .filter(|e| e.path().is_dir())
        .filter_map(|e| e.file_name().to_str().map(|s| s.to_string()))
        .collect();
    names.sort();
    names
}

/// Version directory names for a routine, sorted lexicographically.
///
/// "Latest" is often the last entry after sort; see [module-level docs](self#version-resolution).
pub fn installed_versions_for_routine(proj_dirs: &ProjectDirs, name: &str) -> Vec<String> {
    let root = routine_root(proj_dirs, name);
    if !root.exists() {
        return Vec::new();
    }
    let mut versions: Vec<String> = std::fs::read_dir(&root)
        .into_iter()
        .flatten()
        .filter_map(|e| e.ok())
        .filter(|e| e.path().is_dir())
        .filter_map(|e| e.file_name().to_str().map(|s| s.to_string()))
        .collect();
    versions.sort();
    versions
}

/// Lexicographically greatest installed version directory name, if any.
///
/// Used when `run <name>` omits `@version`. Not semver-aware.
pub fn find_latest_installed_version(proj_dirs: &ProjectDirs, name: &str) -> Option<String> {
    let root = routine_root(proj_dirs, name);
    let mut versions: Vec<String> = std::fs::read_dir(&root)
        .ok()?
        .filter_map(|e| e.ok())
        .filter(|e| e.path().is_dir())
        .filter_map(|e| e.file_name().to_str().map(|s| s.to_string()))
        .collect();
    versions.sort();
    versions.pop()
}

/// Full path to the installed plugin dynamic library file.
pub fn plugin_library_path(
    proj_dirs: &ProjectDirs,
    name: &str,
    version: &str,
    target_triple: &str,
) -> PathBuf {
    plugin_dir(proj_dirs, name, version, target_triple).join(platform_library_filename(name))
}

/// Whether the plugin library file exists for the given routine, version, and target.
pub fn is_installed_for_target(
    proj_dirs: &ProjectDirs,
    name: &str,
    version: &str,
    target_triple: &str,
) -> bool {
    plugin_library_path(proj_dirs, name, version, target_triple).exists()
}

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

    fn test_proj_dirs() -> ProjectDirs {
        ProjectDirs::from("com", "cotis", "cotis-cli-test").expect("valid qualifier/org/app")
    }

    #[test]
    fn platform_library_filename_matches_host() {
        if cfg!(windows) {
            assert_eq!(platform_library_filename("my_routine"), "my_routine.dll");
        } else if cfg!(target_os = "macos") {
            assert_eq!(
                platform_library_filename("my_routine"),
                "libmy_routine.dylib"
            );
        } else {
            assert_eq!(platform_library_filename("my_routine"), "libmy_routine.so");
        }
    }

    #[test]
    fn descriptor_path_follows_cache_layout() {
        let proj_dirs = test_proj_dirs();
        let path = descriptor_path(&proj_dirs, "web_builder", "0.1.0", "x86_64-pc-windows-msvc");
        assert!(
            path.ends_with(
                PathBuf::from("routines")
                    .join("web_builder")
                    .join("0.1.0")
                    .join("x86_64-pc-windows-msvc")
                    .join("descriptor.json")
            )
        );
    }

    #[test]
    fn plugin_dir_is_under_target_root() {
        let proj_dirs = test_proj_dirs();
        let path = plugin_dir(
            &proj_dirs,
            "web_builder",
            "dev",
            "aarch64-unknown-linux-gnu",
        );
        assert!(
            path.ends_with(
                PathBuf::from("routines")
                    .join("web_builder")
                    .join("dev")
                    .join("aarch64-unknown-linux-gnu")
                    .join("plugin")
            )
        );
    }
}