greentic-dev 1.1.0-dev.25540695147

Developer CLI and local tooling for Greentic flows, packs, and components
Documentation
use std::collections::HashMap;
use std::time::Duration;

use anyhow::{Context, Result, bail};
use reqwest::blocking::Client;
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};

use crate::config::{
    DefaultProfileSelection, DistributorProfileConfig, GreenticConfig, LoadedGreenticConfig,
};

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DevIntent {
    Dev,
    Runtime,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DevArtifactKind {
    Component,
    Pack,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DevResolveRequest {
    pub coordinate: String,
    pub intent: DevIntent,
    pub platform: Option<String>,
    pub features: Vec<String>,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DevLicenseType {
    Free,
    Commercial,
    Trial,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DevLicenseInfo {
    pub license_type: DevLicenseType,
    pub id: Option<String>,
    pub requires_acceptance: bool,
    pub checkout_url: Option<String>,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DevResolveResponse {
    pub kind: DevArtifactKind,
    pub name: String,
    pub version: String,
    pub coordinate: String,
    pub artifact_id: String,
    pub artifact_download_path: String,
    pub digest: Option<String>,
    pub license: DevLicenseInfo,
    pub metadata: serde_json::Value,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DevLicenseRequiredErrorBody {
    pub error: String,
    pub coordinate: String,
    pub message: String,
    pub checkout_url: String,
}

#[derive(Debug)]
pub enum DevDistributorError {
    Http(reqwest::Error),
    LicenseRequired(DevLicenseRequiredErrorBody),
    Status(reqwest::StatusCode, Option<String>),
    InvalidResponse(anyhow::Error),
}

impl std::fmt::Display for DevDistributorError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            DevDistributorError::Http(err) => write!(f, "http error: {err}"),
            DevDistributorError::LicenseRequired(body) => {
                write!(f, "{} (checkout: {})", body.message, body.checkout_url)
            }
            DevDistributorError::Status(code, body) => {
                if let Some(body) = body {
                    write!(f, "unexpected status {code}: {body}")
                } else {
                    write!(f, "unexpected status {code}")
                }
            }
            DevDistributorError::InvalidResponse(err) => write!(f, "invalid response: {err}"),
        }
    }
}

impl std::error::Error for DevDistributorError {}

impl From<reqwest::Error> for DevDistributorError {
    fn from(value: reqwest::Error) -> Self {
        DevDistributorError::Http(value)
    }
}

#[derive(Debug, Clone)]
pub struct DistributorProfile {
    pub name: String,
    pub url: String,
    pub token: Option<String>,
    pub tenant_id: String,
    pub environment_id: String,
    pub headers: Option<HashMap<String, String>>,
}

impl DistributorProfile {
    fn from_pair(name: &str, cfg: &DistributorProfileConfig) -> Result<Self> {
        let token = resolve_token(cfg.token.clone())?;
        let base_url = cfg
            .base_url
            .as_ref()
            .or(cfg.url.as_ref())
            .map(|s| s.trim_end_matches('/').to_string())
            .unwrap_or_else(|| "http://localhost:8080".to_string());
        let tenant_id = cfg.tenant_id.clone().unwrap_or_else(|| "local".to_string());
        let environment_id = cfg
            .environment_id
            .clone()
            .unwrap_or_else(|| "dev".to_string());
        Ok(Self {
            name: cfg.name.clone().unwrap_or_else(|| name.to_string()),
            url: base_url,
            token,
            tenant_id,
            environment_id,
            headers: cfg.headers.clone(),
        })
    }
}

pub fn resolve_profile(
    config: &LoadedGreenticConfig,
    profile_arg: Option<&str>,
) -> Result<DistributorProfile> {
    let env_profile = std::env::var("GREENTIC_DISTRIBUTOR_PROFILE").ok();
    let map: HashMap<String, DistributorProfileConfig> = config.config.distributor_profiles();
    let selection = select_profile(profile_arg, env_profile.as_deref(), &config.config);

    match selection {
        ProfileSelection::Inline(cfg) => {
            let name = cfg.name.clone().unwrap_or_else(|| "default".to_string());
            DistributorProfile::from_pair(&name, &cfg)
        }
        ProfileSelection::Named(profile_name) => {
            let Some(profile_cfg) = map.get(&profile_name) else {
                let mut available_profiles = map.keys().cloned().collect::<Vec<_>>();
                available_profiles.sort();
                let available = if available_profiles.is_empty() {
                    "<none>".to_string()
                } else {
                    available_profiles.join(", ")
                };
                let loaded = config
                    .loaded_from
                    .as_ref()
                    .map(|p| p.display().to_string())
                    .unwrap_or_else(|| "(no config file loaded)".to_string());
                let attempted = if config.attempted_paths.is_empty() {
                    "(none)".to_string()
                } else {
                    config
                        .attempted_paths
                        .iter()
                        .map(|p| p.display().to_string())
                        .collect::<Vec<_>>()
                        .join(", ")
                };
                bail!(
                    "distributor profile `{profile_name}` not found in {} (available: {}). searched config paths: {}. Override with --profile, GREENTIC_DISTRIBUTOR_PROFILE, or GREENTIC_DEV_CONFIG_FILE.",
                    loaded,
                    available,
                    attempted
                );
            };
            DistributorProfile::from_pair(&profile_name, profile_cfg)
        }
    }
}

fn resolve_token(raw: Option<String>) -> Result<Option<String>> {
    let Some(raw) = raw else {
        return Ok(None);
    };
    if let Some(rest) = raw.strip_prefix("env:") {
        let value = std::env::var(rest)
            .with_context(|| format!("failed to resolve env var {rest} for distributor token"))?;
        Ok(Some(value))
    } else {
        Ok(Some(raw))
    }
}

fn select_profile(
    arg: Option<&str>,
    env: Option<&str>,
    config: &GreenticConfig,
) -> ProfileSelection {
    if let Some(arg) = arg {
        return ProfileSelection::Named(arg.to_string());
    }
    if let Some(env) = env {
        return ProfileSelection::Named(env.to_string());
    }
    if let Some(default) = &config.distributor.default_profile {
        return match default {
            DefaultProfileSelection::Name(name) => ProfileSelection::Named(name.clone()),
            DefaultProfileSelection::Inline(cfg) => ProfileSelection::Inline(cfg.clone()),
        };
    }
    ProfileSelection::Named("default".to_string())
}

enum ProfileSelection {
    Named(String),
    Inline(DistributorProfileConfig),
}

#[derive(Debug, Clone)]
pub struct DevDistributorClient {
    base_url: String,
    auth_token: Option<String>,
    http: Client,
}

impl DevDistributorClient {
    pub fn from_profile(profile: DistributorProfile) -> Result<Self> {
        let client = Client::builder()
            .timeout(Duration::from_secs(30))
            .build()
            .context("failed to build HTTP client")?;
        Ok(Self {
            base_url: profile.url,
            auth_token: profile.token,
            http: client,
        })
    }

    pub fn resolve(
        &self,
        req: &DevResolveRequest,
    ) -> Result<DevResolveResponse, DevDistributorError> {
        let url = format!("{}/v1/resolve", self.base_url);
        let mut builder = self.http.post(url).header(CONTENT_TYPE, "application/json");
        if let Some(token) = &self.auth_token {
            builder = builder.header(AUTHORIZATION, format!("Bearer {token}"));
        }
        let response = builder.json(req).send()?;
        if response.status().as_u16() == 402 {
            let body: DevLicenseRequiredErrorBody = response
                .json()
                .map_err(|err| DevDistributorError::InvalidResponse(err.into()))?;
            return Err(DevDistributorError::LicenseRequired(body));
        }
        if !response.status().is_success() {
            let status = response.status();
            let body = response.text().ok();
            return Err(DevDistributorError::Status(status, body));
        }
        response
            .json::<DevResolveResponse>()
            .map_err(|err| DevDistributorError::InvalidResponse(err.into()))
    }

    pub fn download_artifact(
        &self,
        download_path: &str,
    ) -> Result<bytes::Bytes, DevDistributorError> {
        let trimmed_base = self.base_url.trim_end_matches('/');
        let trimmed_path = download_path.trim_start_matches('/');
        let url = format!("{trimmed_base}/{trimmed_path}");
        let mut builder = self.http.get(url);
        if let Some(token) = &self.auth_token {
            builder = builder.header(AUTHORIZATION, format!("Bearer {token}"));
        }
        let response = builder.send()?;
        if !response.status().is_success() {
            let status = response.status();
            let body = response.text().ok();
            return Err(DevDistributorError::Status(status, body));
        }
        response.bytes().map_err(DevDistributorError::Http)
    }
}