cleanlib-client 0.1.1

HTTP client SDK for the CleanLibrary verdict API — VerdictEnvelopeV1 types, derive_status logic, transport, config, and risk-acceptance YAML emitter shared between cleanlib-cli and other CleanLibrary consumers.
Documentation
//! Per-ecosystem proxy-config emit per [per-ecosystem-proxy-config-emit-format
//! decision 2026-05-20]. Phase 1 Tier A: npm + pypi + go.
//!
//! Each emit function returns a [`ProxyConfig`] holding the config-blob text +
//! the canonical local path where the file would be written. Callers
//! (typically [`cleanlib config init`]) decide whether to write or print.

use std::path::PathBuf;

use thiserror::Error;

/// Locked vocabulary per matrix §8 — ecosystem identifiers are always lowercase.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ecosystem {
    Npm,
    Pypi,
    Go,
}

impl Ecosystem {
    pub fn parse(s: &str) -> Option<Self> {
        match s {
            "npm" => Some(Self::Npm),
            "pypi" => Some(Self::Pypi),
            "go" => Some(Self::Go),
            _ => None,
        }
    }
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Npm => "npm",
            Self::Pypi => "pypi",
            Self::Go => "go",
        }
    }
}

/// Emitted config: blob is the literal file/shell content; canonical_location
/// is the default write path (or empty for ecosystems without a single
/// canonical file like Go's `GOPROXY` env).
#[derive(Debug)]
pub struct ProxyConfig {
    pub ecosystem: Ecosystem,
    pub config_blob: String,
    pub canonical_location: PathBuf,
}

#[derive(Debug, Error)]
pub enum ProxyConfigError {
    #[error("home directory not discoverable; cannot resolve canonical location for {0:?}")]
    HomeDirUnavailable(Ecosystem),
    /// CLEANLIB-129 / Jira CLEANLIB-28: refuse `inline_token=true` when the
    /// provided `api_key` is missing or empty/whitespace-only. Pre-fix
    /// behaviour emitted `_authToken=` (empty) → broken `.npmrc`.
    #[error(
        "inline_token requires a non-empty api_key (would emit broken auth header for {0:?})"
    )]
    InlineTokenEmpty(Ecosystem),
}

/// Options controlling emit shape.
pub struct EmitOptions {
    /// Base App endpoint, e.g., `https://cleanapp.clnstrt.dev`. Trailing slash
    /// is tolerated.
    pub endpoint: String,
    /// Optional npm scope (e.g., `@my-org`). Only applies to npm emit.
    pub scope: Option<String>,
    /// If `true`, embed `api_key` literal in the emitted config (use for CI
    /// runners without env-var support). If `false`, emit
    /// `${CLEANLIBRARY_API_KEY}` shell-expansion (default; preferred).
    pub inline_token: bool,
    /// API-key value to embed when `inline_token = true`. Ignored otherwise.
    pub api_key: Option<String>,
}

pub fn emit(ecosystem: Ecosystem, opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
    // CLEANLIB-129 / Jira CLEANLIB-28 defense-in-depth: even when the CLI
    // forgot to validate, never let the proxy emit `_authToken=` (empty)
    // out the back. Sister of the `cleanlib config init --inline-token`
    // pre-check in `cleanlib-cli/src/commands/config_init.rs`.
    if opts.inline_token {
        let has_usable_key = opts
            .api_key
            .as_deref()
            .map(|k| !k.trim().is_empty())
            .unwrap_or(false);
        if !has_usable_key {
            return Err(ProxyConfigError::InlineTokenEmpty(ecosystem));
        }
    }
    match ecosystem {
        Ecosystem::Npm => emit_npm(opts),
        Ecosystem::Pypi => emit_pypi(opts),
        Ecosystem::Go => emit_go(opts),
    }
}

fn token_expression(opts: &EmitOptions) -> String {
    if opts.inline_token {
        // `emit` guarantees `api_key` is Some(non_empty) when
        // `inline_token=true`; this default is dead code on the success
        // path and only reachable via direct internal calls.
        opts.api_key.clone().unwrap_or_default()
    } else {
        "${CLEANLIBRARY_API_KEY}".to_string()
    }
}

fn endpoint_host(endpoint: &str) -> &str {
    endpoint
        .trim_end_matches('/')
        .trim_start_matches("https://")
        .trim_start_matches("http://")
}

fn emit_npm(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
    let home = dirs::home_dir().ok_or(ProxyConfigError::HomeDirUnavailable(Ecosystem::Npm))?;
    let endpoint = opts.endpoint.trim_end_matches('/');
    let registry_url = format!("{}/npm/", endpoint);
    let host = endpoint_host(endpoint);
    let token = token_expression(opts);

    let config_blob = match opts.scope.as_deref() {
        Some(scope) => format!(
            "{scope}:registry={registry_url}\n//{host}/npm/:_authToken={token}\nalways-auth=true\n",
        ),
        None => format!(
            "registry={registry_url}\n//{host}/npm/:_authToken={token}\nalways-auth=true\n",
        ),
    };

    Ok(ProxyConfig {
        ecosystem: Ecosystem::Npm,
        config_blob,
        canonical_location: home.join(".npmrc"),
    })
}

fn emit_pypi(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
    let home = dirs::home_dir().ok_or(ProxyConfigError::HomeDirUnavailable(Ecosystem::Pypi))?;
    let endpoint = opts.endpoint.trim_end_matches('/');
    let host = endpoint_host(endpoint);
    let token = token_expression(opts);

    let config_blob = format!(
        "[global]\nindex-url = https://{token}@{host}/pypi/simple/\nextra-index-url =\n\n[install]\ntrusted-host = {host}\n",
    );

    Ok(ProxyConfig {
        ecosystem: Ecosystem::Pypi,
        config_blob,
        canonical_location: home.join(".config").join("pip").join("pip.conf"),
    })
}

fn emit_go(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
    let endpoint = opts.endpoint.trim_end_matches('/');
    let token = token_expression(opts);

    // Go has no single canonical config file; emit shell-snippet to be sourced
    // by ~/.bashrc / ~/.zshrc OR run as `go env -w` invocations.
    let config_blob = format!(
        "# CleanLibrary Go proxy — append to your shell config (~/.bashrc, ~/.zshrc, fish config)\n# or run the equivalent `go env -w GOPROXY=...` / `go env -w GOAUTH=...` invocations.\nexport GOPROXY={endpoint}/go,direct\nexport GOAUTH=\"Authorization: Bearer {token}\"\n",
    );

    Ok(ProxyConfig {
        ecosystem: Ecosystem::Go,
        config_blob,
        // Empty path = no canonical file; caller prints or asks user where to write.
        canonical_location: PathBuf::new(),
    })
}

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

    fn opts(endpoint: &str) -> EmitOptions {
        EmitOptions {
            endpoint: endpoint.to_string(),
            scope: None,
            inline_token: false,
            api_key: None,
        }
    }

    #[test]
    fn ecosystem_parse_vocab_locked() {
        assert_eq!(Ecosystem::parse("npm"), Some(Ecosystem::Npm));
        assert_eq!(Ecosystem::parse("pypi"), Some(Ecosystem::Pypi));
        assert_eq!(Ecosystem::parse("go"), Some(Ecosystem::Go));
        // Matrix §8 forbids these uppercase / off-vocab spellings
        assert_eq!(Ecosystem::parse("NPM"), None);
        assert_eq!(Ecosystem::parse("PyPI"), None);
        assert_eq!(Ecosystem::parse("pip"), None);
        assert_eq!(Ecosystem::parse("golang"), None);
    }

    #[test]
    fn npm_emit_shell_expansion_default() {
        let blob = emit_npm(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
        assert!(blob.contains("registry=https://cleanapp.clnstrt.dev/npm/"));
        assert!(blob.contains("//cleanapp.clnstrt.dev/npm/:_authToken=${CLEANLIBRARY_API_KEY}"));
        assert!(blob.contains("always-auth=true"));
    }

    #[test]
    fn npm_emit_with_scope() {
        let mut o = opts("https://cleanapp.clnstrt.dev");
        o.scope = Some("@my-org".to_string());
        let blob = emit_npm(&o).unwrap().config_blob;
        assert!(blob.contains("@my-org:registry=https://cleanapp.clnstrt.dev/npm/"));
    }

    #[test]
    fn npm_emit_inline_token() {
        let mut o = opts("https://cleanapp.clnstrt.dev");
        o.inline_token = true;
        o.api_key = Some("cs_live_smoke".to_string());
        let blob = emit_npm(&o).unwrap().config_blob;
        assert!(blob.contains("_authToken=cs_live_smoke"));
        assert!(!blob.contains("${CLEANLIBRARY_API_KEY}"));
    }

    #[test]
    fn pypi_emit_index_url_with_token_in_url() {
        let blob = emit_pypi(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
        // pip's index-url puts the token in the URL userinfo position
        assert!(blob.contains("index-url = https://${CLEANLIBRARY_API_KEY}@cleanapp.clnstrt.dev/pypi/simple/"));
        assert!(blob.contains("trusted-host = cleanapp.clnstrt.dev"));
    }

    #[test]
    fn go_emit_env_form() {
        let blob = emit_go(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
        assert!(blob.contains("export GOPROXY=https://cleanapp.clnstrt.dev/go,direct"));
        assert!(blob.contains("export GOAUTH=\"Authorization: Bearer ${CLEANLIBRARY_API_KEY}\""));
    }

    #[test]
    fn go_emit_has_no_canonical_location() {
        let cfg = emit_go(&opts("https://cleanapp.clnstrt.dev")).unwrap();
        assert!(cfg.canonical_location.as_os_str().is_empty());
    }

    #[test]
    fn endpoint_trailing_slash_tolerated() {
        let blob = emit_npm(&opts("https://cleanapp.clnstrt.dev/")).unwrap().config_blob;
        // Should NOT have double slash
        assert!(blob.contains("registry=https://cleanapp.clnstrt.dev/npm/"));
        assert!(!blob.contains("//npm/"));
    }

    // CLEANLIB-129 / Jira CLEANLIB-28 — defense-in-depth `emit`-level rejection
    // of `inline_token=true` when `api_key` is missing or empty/whitespace.

    #[test]
    fn emit_rejects_inline_token_with_none_api_key() {
        let mut o = opts("https://cleanapp.clnstrt.dev");
        o.inline_token = true;
        o.api_key = None;
        let err = emit(Ecosystem::Npm, &o).unwrap_err();
        assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Npm)));
    }

    #[test]
    fn emit_rejects_inline_token_with_empty_string_api_key() {
        let mut o = opts("https://cleanapp.clnstrt.dev");
        o.inline_token = true;
        o.api_key = Some(String::new());
        let err = emit(Ecosystem::Pypi, &o).unwrap_err();
        assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Pypi)));
    }

    #[test]
    fn emit_rejects_inline_token_with_whitespace_only_api_key() {
        let mut o = opts("https://cleanapp.clnstrt.dev");
        o.inline_token = true;
        o.api_key = Some("   \t\n".to_string());
        let err = emit(Ecosystem::Go, &o).unwrap_err();
        assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Go)));
    }

    #[test]
    fn emit_accepts_inline_token_with_valid_api_key() {
        let mut o = opts("https://cleanapp.clnstrt.dev");
        o.inline_token = true;
        o.api_key = Some("std_001".to_string());
        let blob = emit(Ecosystem::Npm, &o).unwrap().config_blob;
        // Verify the legit key reaches the rendered _authToken line.
        assert!(blob.contains("_authToken=std_001"));
        // Ensure we did NOT regress to the broken `_authToken=` empty form.
        assert!(!blob.contains("_authToken=\n"));
        assert!(!blob.contains("_authToken= "));
    }

    #[test]
    fn emit_shell_expansion_path_unaffected_by_empty_key() {
        // When `inline_token=false`, an empty api_key is fine — the
        // placeholder resolves at runtime from CLEANLIBRARY_API_KEY env.
        let mut o = opts("https://cleanapp.clnstrt.dev");
        o.inline_token = false;
        o.api_key = None;
        assert!(emit(Ecosystem::Npm, &o).is_ok());
        assert!(emit(Ecosystem::Pypi, &o).is_ok());
        assert!(emit(Ecosystem::Go, &o).is_ok());
    }
}