portaki-cli 8.10.0

Portaki module CLI (portaki) — init, build, test, hosted sandbox, OCI publish and registry announcement
//! Où la CLI parle : une seule base d'API, choisie une fois pour toute la commande.
//!
//! `dev`, `logs` et `sdk upgrade` lisaient `PORTAKI_DEV_URL` avant `PORTAKI_API_URL`, `login` et
//! `publish` l'inverse : deux variables qui divergent envoyaient une commande vers une plateforme
//! avec la session d'une autre (« the stored session belongs to… »). Il n'y a plus qu'une base.
//!
//! Dans l'ordre : `--url` d'une commande (alias caché, ancien), `--api`, `--env`,
//! `PORTAKI_API_URL`, `PORTAKI_DEV_URL` (déprécié), la production.
//!
//! Profils : `prod`, `staging` et `local` sont connus ; `~/.config/portaki/config.toml` en
//! ajoute ou en redéfinit :
//!
//! ```toml
//! [env.preprod]
//! api = "https://api-preprod.example"
//! ```

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::OnceLock;

use anyhow::{Context, Result};

/// La plateforme de production.
pub const PRODUCTION: &str = "https://api.portaki.app";

const BUILT_IN: [(&str, &str); 3] = [
    ("prod", PRODUCTION),
    ("staging", "https://api-staging.portaki.app"),
    ("local", "http://localhost:8080"),
];

/// `--api` ou l'URL du profil `--env`, fixé une fois au démarrage.
static CHOSEN: OnceLock<Option<String>> = OnceLock::new();

/// L'avertissement sur `PORTAKI_DEV_URL` ne se répète pas à chaque appel.
static WARNED: AtomicBool = AtomicBool::new(false);

/// Retient `--api` / `--env`. Un profil inconnu est une erreur d'usage : partir en production à
/// la place d'un `--env stagging` mal tapé serait la pire des lectures.
pub fn select(api: Option<&str>, env: Option<&str>) -> Result<()> {
    let chosen = match (non_blank(api), non_blank(env)) {
        (Some(api), _) => Some(api.to_string()),
        (None, Some(name)) => Some(profile_url(name, &profiles()?)?),
        (None, None) => None,
    };
    let _ = CHOSEN.set(chosen);
    Ok(())
}

/// La base d'API de cette commande, sans barre finale.
pub fn api_url(explicit: Option<&str>) -> String {
    let dev = std::env::var("PORTAKI_DEV_URL").ok();
    if non_blank(dev.as_deref()).is_some() && !WARNED.swap(true, Ordering::Relaxed) {
        crate::ui::warn(
            "PORTAKI_DEV_URL is deprecated — set PORTAKI_API_URL (or --api / --env) instead",
        );
    }
    resolve(
        explicit,
        CHOSEN.get().and_then(Option::as_deref),
        std::env::var("PORTAKI_API_URL").ok().as_deref(),
        dev.as_deref(),
    )
}

/// La règle seule, sans l'environnement, pour être vérifiable.
///
/// Une valeur vide ou blanche vaut « non définie » : une action de CI qui passe une entrée
/// facultative non renseignée exporte une variable vide.
fn resolve(
    explicit: Option<&str>,
    chosen: Option<&str>,
    api_var: Option<&str>,
    dev_var: Option<&str>,
) -> String {
    [explicit, chosen, api_var, dev_var]
        .into_iter()
        .find_map(non_blank)
        .unwrap_or(PRODUCTION)
        .trim_end_matches('/')
        .to_string()
}

fn non_blank(value: Option<&str>) -> Option<&str> {
    value.map(str::trim).filter(|value| !value.is_empty())
}

/// Les profils connus : ceux de la CLI, puis ceux du fichier, qui l'emportent.
pub fn profiles() -> Result<Vec<(String, String)>> {
    let path = crate::auth::config_dir()?.join("config.toml");
    let raw = match std::fs::read_to_string(&path) {
        Ok(raw) => raw,
        Err(missing) if missing.kind() == std::io::ErrorKind::NotFound => String::new(),
        Err(failure) => return Err(failure).with_context(|| format!("read {}", path.display())),
    };
    parse_profiles(&raw).with_context(|| format!("read {}", path.display()))
}

fn parse_profiles(raw: &str) -> Result<Vec<(String, String)>> {
    let mut found: Vec<(String, String)> = BUILT_IN
        .iter()
        .map(|(name, url)| (name.to_string(), url.to_string()))
        .collect();
    let document: toml_edit::DocumentMut = raw.parse().context("not valid TOML")?;
    let Some(envs) = document.get("env").and_then(toml_edit::Item::as_table_like) else {
        return Ok(found);
    };
    for (name, table) in envs.iter() {
        let url = table
            .get("api")
            .and_then(toml_edit::Item::as_str)
            .with_context(|| format!("[env.{name}] needs api = \"https://…\""))?;
        found.retain(|(known, _)| known != name);
        found.push((name.to_string(), url.trim_end_matches('/').to_string()));
    }
    Ok(found)
}

fn profile_url(name: &str, profiles: &[(String, String)]) -> Result<String> {
    profiles
        .iter()
        .find(|(known, _)| known == name)
        .map(|(_, url)| url.clone())
        .ok_or_else(|| {
            let names: Vec<&str> = profiles.iter().map(|(known, _)| known.as_str()).collect();
            crate::exit::usage(format!(
                "unknown environment: {name} — known: {} (add one under [env.{name}] in ~/.config/portaki/config.toml)",
                names.join(", ")
            ))
        })
}

/// La commande qui ouvre une session sur `url` — ce qu'une commande sans session doit dire.
pub fn login_command(url: &str) -> String {
    let origin = crate::auth::origin_of(url);
    let named = profiles().ok().and_then(|profiles| {
        profiles
            .into_iter()
            .find(|(_, known)| crate::auth::origin_of(known) == origin)
            .map(|(name, _)| name)
    });
    match named.as_deref() {
        Some("prod") => "portaki login".to_string(),
        Some(name) => format!("portaki login --env {name}"),
        None => format!("portaki login --api {}", url.trim_end_matches('/')),
    }
}

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

    #[test]
    fn precedence_is_flag_then_profile_then_variables_then_production() {
        let staging = "https://api-staging.portaki.app";
        assert_eq!(
            resolve(Some("https://x.example/"), Some(staging), None, None),
            "https://x.example"
        );
        assert_eq!(
            resolve(None, Some(staging), Some("https://env.example"), None),
            staging
        );
        assert_eq!(
            resolve(
                None,
                None,
                Some("https://env.example"),
                Some("https://dev.example")
            ),
            "https://env.example"
        );
        // L'alias déprécié sert encore quand il est seul.
        assert_eq!(
            resolve(None, None, None, Some("https://dev.example")),
            "https://dev.example"
        );
        assert_eq!(resolve(None, None, None, None), PRODUCTION);
    }

    #[test]
    fn a_blank_value_means_unset() {
        assert_eq!(resolve(Some(" "), None, Some(""), Some("  ")), PRODUCTION);
    }

    #[test]
    fn the_file_adds_and_overrides_profiles() {
        let profiles = parse_profiles(
            "[env.preprod]\napi = \"https://pre.example/\"\n[env.local]\napi = \"http://localhost:9000\"\n",
        )
        .unwrap();

        assert_eq!(
            profile_url("preprod", &profiles).unwrap(),
            "https://pre.example"
        );
        assert_eq!(
            profile_url("local", &profiles).unwrap(),
            "http://localhost:9000"
        );
        assert_eq!(profile_url("prod", &profiles).unwrap(), PRODUCTION);
    }

    #[test]
    fn an_unknown_profile_is_a_usage_error_that_lists_the_known_ones() {
        let profiles = parse_profiles("").unwrap();
        let failure = profile_url("stagging", &profiles).unwrap_err();

        assert_eq!(crate::exit::code(&failure), 2);
        assert!(
            failure.to_string().contains("prod, staging, local"),
            "{failure}"
        );
    }

    #[test]
    fn a_profile_without_an_api_says_what_it_needs() {
        let failure = parse_profiles("[env.x]\nurl = \"https://x\"\n").unwrap_err();
        assert!(failure.to_string().contains("api ="), "{failure}");
    }
}