greentic-bundle 1.1.0

Greentic bundle authoring CLI scaffold with embedded i18n and answer-document contracts.
Documentation
use std::path::Path;

use anyhow::{Result, bail};
use greentic_distributor_client::oci_packs::DefaultRegistryClient;
use greentic_distributor_client::{OciPackFetcher, PackFetchOptions};
use tokio::runtime::Runtime;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FetchedCatalog {
    pub resolved_ref: String,
    pub digest: String,
    pub bytes: Vec<u8>,
}

pub trait CatalogArtifactClient {
    fn fetch_catalog(&self, root: &Path, reference: &str) -> Result<FetchedCatalog>;
}

#[derive(Debug, Default, Clone, Copy)]
pub struct CacheOnlyCatalogClient;

impl CatalogArtifactClient for CacheOnlyCatalogClient {
    fn fetch_catalog(&self, _root: &Path, reference: &str) -> Result<FetchedCatalog> {
        bail!(
            "catalog {reference} is not cached locally and this build does not yet provide a remote fetch backend; seed the workspace-local cache first or rerun with a local file:// catalog"
        )
    }
}

#[derive(Debug, Default, Clone, Copy)]
pub struct DistributorCatalogClient;

impl CatalogArtifactClient for DistributorCatalogClient {
    fn fetch_catalog(&self, root: &Path, reference: &str) -> Result<FetchedCatalog> {
        let mapped = map_remote_catalog_reference(reference)?;
        let fetcher: OciPackFetcher<DefaultRegistryClient> =
            OciPackFetcher::new(PackFetchOptions {
                allow_tags: true,
                offline: false,
                cache_dir: root
                    .join(crate::catalog::CACHE_ROOT_DIR)
                    .join("distributor"),
                ..PackFetchOptions::default()
            });
        let runtime = Runtime::new()?;
        let resolved = runtime.block_on(fetcher.fetch_pack_to_cache(&mapped.oci_reference))?;
        let bytes = std::fs::read(&resolved.path)?;
        Ok(FetchedCatalog {
            resolved_ref: format!("oci://{}", mapped.oci_reference),
            digest: resolved.resolved_digest,
            bytes,
        })
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteCatalogRef {
    pub oci_reference: String,
    pub source_kind: RemoteCatalogSourceKind,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemoteCatalogSourceKind {
    Oci,
    GhcrWellKnown,
}

pub fn map_remote_catalog_reference(reference: &str) -> Result<RemoteCatalogRef> {
    if let Some(raw) = reference.strip_prefix("oci://") {
        let trimmed = raw.trim();
        if trimmed.is_empty() {
            bail!("catalog reference {reference} is missing an OCI path");
        }
        return Ok(RemoteCatalogRef {
            oci_reference: trimmed.to_string(),
            source_kind: RemoteCatalogSourceKind::Oci,
        });
    }

    if let Some(raw) = reference.strip_prefix("ghcr://") {
        let trimmed = raw.trim().trim_start_matches('/');
        if trimmed.is_empty() {
            bail!("catalog reference {reference} is missing a GHCR path");
        }
        return Ok(RemoteCatalogRef {
            oci_reference: format!("ghcr.io/greenticai/{}", default_ghcr_tag(trimmed)),
            source_kind: RemoteCatalogSourceKind::GhcrWellKnown,
        });
    }

    bail!(
        "catalog {reference} is not a supported remote catalog ref; use file://, oci://<registry>/<repo>[:tag|@sha256:...], or ghcr://<path>[:tag|@sha256:...] for ghcr.io/greenticai"
    )
}

fn default_ghcr_tag(reference: &str) -> String {
    if reference.contains('@')
        || reference
            .rsplit('/')
            .next()
            .unwrap_or_default()
            .contains(':')
    {
        reference.to_string()
    } else {
        format!("{reference}:latest")
    }
}

#[cfg(test)]
mod tests {
    use super::{RemoteCatalogSourceKind, map_remote_catalog_reference};

    #[test]
    fn maps_ghcr_shortcut_to_greenticai_namespace() {
        let mapped = map_remote_catalog_reference("ghcr://catalogs/well-known").expect("mapped");
        assert_eq!(
            mapped.oci_reference,
            "ghcr.io/greenticai/catalogs/well-known:latest"
        );
        assert_eq!(mapped.source_kind, RemoteCatalogSourceKind::GhcrWellKnown);
    }

    #[test]
    fn preserves_explicit_digest_for_ghcr_shortcut() {
        let mapped = map_remote_catalog_reference("ghcr://catalogs/well-known@sha256:abc123")
            .expect("mapped");
        assert_eq!(
            mapped.oci_reference,
            "ghcr.io/greenticai/catalogs/well-known@sha256:abc123"
        );
    }

    #[test]
    fn preserves_raw_oci_reference() {
        let mapped =
            map_remote_catalog_reference("oci://ghcr.io/example/catalogs/demo:1").expect("mapped");
        assert_eq!(mapped.oci_reference, "ghcr.io/example/catalogs/demo:1");
        assert_eq!(mapped.source_kind, RemoteCatalogSourceKind::Oci);
    }

    #[test]
    fn preserves_explicit_tag_for_ghcr_shortcut() {
        let mapped = map_remote_catalog_reference("ghcr://catalogs/well-known:2").expect("mapped");
        assert_eq!(
            mapped.oci_reference,
            "ghcr.io/greenticai/catalogs/well-known:2"
        );
    }

    #[test]
    fn rejects_empty_remote_catalog_paths() {
        let oci_error = map_remote_catalog_reference("oci://").expect_err("expected oci error");
        assert!(oci_error.to_string().contains("missing an OCI path"));

        let ghcr_error = map_remote_catalog_reference("ghcr:///").expect_err("expected ghcr error");
        assert!(ghcr_error.to_string().contains("missing a GHCR path"));
    }

    #[test]
    fn rejects_unsupported_remote_catalog_scheme() {
        let error = map_remote_catalog_reference("https://example.com/catalog.json")
            .expect_err("expected unsupported scheme error");
        assert!(
            error
                .to_string()
                .contains("not a supported remote catalog ref")
        );
    }
}