cleanlib-cli 0.1.1

Terminal interface to CleanLibrary — query dependency verdicts and scan package manifests for ALLOW / DENY / WARN signals from the terminal or CI pipelines.
//! `cleanlib login` (cycle-7 Cli2). Migrates `cmd_login` from `main.rs` and
//! extends it with optional keyring-storage (Cli6) via `--keyring`.
//!
//! Default behavior remains the cycle-4 contract — writes the bearer to
//! `~/.cleanlibrary/config.toml` for backwards-compat with existing scripts
//! that read that file. The new `--keyring` flag opts into the cycle-7
//! 3-tier auth substrate (env → keyring → gcloud).

use anyhow::Result;
use cleanlib_client::config;

use crate::auth;

/// CLEANLIB-129 / Jira CLEANLIB-27: reject empty / whitespace-only API key
/// at the CLI boundary BEFORE we write anything to disk or keyring. Sister
/// of `[[feedback_substrate_state_fresh_read_before_banking]]` at the
/// runtime-validation axis — fail loud, not silent fail-open.
///
/// Returned as a plain `anyhow::Error` so `main.rs` propagates it to the
/// process exit code (sister of CLEANLIB-130 exit-code semantics — auth
/// failures land on exit 1, the same code as DENY).
fn validate_api_key(api_key: &str) -> Result<()> {
    if api_key.is_empty() {
        anyhow::bail!(
            "CLIENT_BEARER_EMPTY — API key must not be empty.\n\
             Pass a non-empty value via `cleanlib login --api-key <KEY>` or\n\
             set CLEANLIB_ENRICH_BEARER in your environment."
        );
    }
    if api_key.trim().is_empty() {
        anyhow::bail!(
            "CLIENT_BEARER_WHITESPACE — API key must not be whitespace-only.\n\
             Pass the literal API key value to `cleanlib login --api-key <KEY>`."
        );
    }
    Ok(())
}

pub fn run(api_key: String, use_keyring: bool) -> Result<()> {
    // CLEANLIB-27 close: reject empty bearer up-front, before any side
    // effect (no keyring write, no config write, no false-positive UX in
    // `cleanlib status`).
    validate_api_key(&api_key)?;

    if use_keyring {
        auth::bearer::store_in_keyring(&api_key)?;
        eprintln!(
            "api key stored in keyring (service={}, user={})",
            auth::KEYRING_SERVICE,
            auth::KEYRING_USER
        );
        return Ok(());
    }

    let path = config::default_path()
        .ok_or_else(|| anyhow::anyhow!("home directory not discoverable; cannot write config"))?;

    let mut cfg = if path.exists() {
        config::load(&path)?
    } else {
        config::Config::default()
    };

    cfg.auth.api_key = Some(api_key);
    config::save(&cfg, &path)?;

    eprintln!("api key stored in {}", path.display());
    Ok(())
}

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

    #[test]
    fn rejects_empty_api_key() {
        let err = validate_api_key("").unwrap_err();
        assert!(err.to_string().contains("CLIENT_BEARER_EMPTY"));
    }

    #[test]
    fn rejects_whitespace_only_api_key() {
        let err = validate_api_key("   ").unwrap_err();
        assert!(err.to_string().contains("CLIENT_BEARER_WHITESPACE"));
    }

    #[test]
    fn rejects_tab_and_newline_only() {
        assert!(validate_api_key("\t").is_err());
        assert!(validate_api_key("\n").is_err());
        assert!(validate_api_key(" \t\n ").is_err());
    }

    #[test]
    fn accepts_valid_tier_keyed_bearer_shape() {
        // Per [[reference_cleanlib_internal_test_keys]] mock-cdp fixture
        // key names (e.g. std_001 / ent_001 / free_001).
        assert!(validate_api_key("std_001").is_ok());
        assert!(validate_api_key("clk_xyz_long_token_value_12345").is_ok());
    }
}