greentic-bundle 1.1.0

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

use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

use super::cache;
use super::client::{CatalogArtifactClient, DistributorCatalogClient};
use super::registry::{CatalogEntry, parse_catalog};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CatalogResolveOptions {
    pub offline: bool,
    pub write_cache: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CatalogLockEntry {
    pub requested_ref: String,
    pub resolved_ref: String,
    pub digest: String,
    pub source: String,
    pub item_count: usize,
    pub item_ids: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cache_path: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CatalogResolution {
    pub entries: Vec<CatalogLockEntry>,
    pub cache_writes: Vec<String>,
    pub discovered_items: Vec<CatalogEntry>,
}

impl CatalogResolution {
    pub fn empty() -> Self {
        Self {
            entries: Vec::new(),
            cache_writes: Vec::new(),
            discovered_items: Vec::new(),
        }
    }
}

pub fn resolve_catalogs(
    root: &Path,
    references: &[String],
    options: &CatalogResolveOptions,
) -> Result<CatalogResolution> {
    resolve_catalogs_with_client(root, references, options, &DistributorCatalogClient)
}

pub fn resolve_catalogs_with_client(
    root: &Path,
    references: &[String],
    options: &CatalogResolveOptions,
    client: &dyn CatalogArtifactClient,
) -> Result<CatalogResolution> {
    let mut entries = Vec::new();
    let mut cache_writes = Vec::new();
    let mut discovered_items = Vec::new();

    let mut refs = references.to_vec();
    refs.sort();
    refs.dedup();

    let total = refs.len();
    for (index, reference) in refs.iter().enumerate() {
        eprintln!("  [{}/{}] Resolving: {reference}", index + 1, total);
        let resolved = resolve_one(root, reference, options, client)?;
        cache_writes.extend(resolved.1);
        discovered_items.extend(resolved.2);
        entries.push(resolved.0);
    }

    cache_writes.sort();
    cache_writes.dedup();
    discovered_items.sort_by(|left, right| {
        left.id
            .cmp(&right.id)
            .then(left.reference.cmp(&right.reference))
    });
    discovered_items
        .dedup_by(|left, right| left.id == right.id && left.reference == right.reference);

    Ok(CatalogResolution {
        entries,
        cache_writes,
        discovered_items,
    })
}

fn resolve_one(
    root: &Path,
    reference: &str,
    options: &CatalogResolveOptions,
    client: &dyn CatalogArtifactClient,
) -> Result<(CatalogLockEntry, Vec<String>, Vec<CatalogEntry>)> {
    if let Some(local_path) = parse_local_reference(root, reference) {
        let resolved_path = if local_path.is_absolute() {
            local_path
        } else {
            root.join(local_path)
        };
        let bytes = std::fs::read(&resolved_path)
            .with_context(|| format!("read catalog {}", resolved_path.display()))?;
        let digest = digest_hex(&bytes);
        let source = resolved_path.display().to_string();
        let parsed = parse_catalog(&bytes, &source)?;
        let cache_paths = if options.write_cache {
            cache::cache_catalog_bytes(root, reference, &digest, &bytes)?
        } else {
            Vec::new()
        };
        return Ok((
            CatalogLockEntry {
                requested_ref: reference.to_string(),
                resolved_ref: source,
                digest,
                source: "local_file".to_string(),
                item_count: parsed.summary.item_count,
                item_ids: parsed.summary.item_ids,
                cache_path: cache::resolve_cached_path(root, reference)?
                    .map(|path| relative_display(root, &path)),
            },
            cache_paths
                .into_iter()
                .map(|path| relative_display(root, &path))
                .collect(),
            parsed.entries,
        ));
    }

    if let Some(cached_path) = cache::resolve_cached_path(root, reference)? {
        let bytes = std::fs::read(&cached_path)
            .with_context(|| format!("read cached catalog {}", cached_path.display()))?;
        let digest = digest_hex(&bytes);
        let source = cached_path.display().to_string();
        let parsed = parse_catalog(&bytes, &source)?;
        return Ok((
            CatalogLockEntry {
                requested_ref: reference.to_string(),
                resolved_ref: reference.to_string(),
                digest,
                source: "workspace_cache".to_string(),
                item_count: parsed.summary.item_count,
                item_ids: parsed.summary.item_ids,
                cache_path: Some(relative_display(root, &cached_path)),
            },
            Vec::new(),
            parsed.entries,
        ));
    }

    if options.offline {
        bail!(
            "catalog {reference} is not cached in {} and offline mode is enabled; seed the workspace-local cache first or rerun without --offline",
            root.join(super::CACHE_ROOT_DIR).display()
        );
    }

    let fetched = client.fetch_catalog(root, reference)?;
    let parsed = parse_catalog(&fetched.bytes, reference)?;
    let cache_paths = if options.write_cache {
        cache::cache_catalog_bytes(root, reference, &fetched.digest, &fetched.bytes)?
    } else {
        Vec::new()
    };
    Ok((
        CatalogLockEntry {
            requested_ref: reference.to_string(),
            resolved_ref: fetched.resolved_ref,
            digest: fetched.digest,
            source: "remote".to_string(),
            item_count: parsed.summary.item_count,
            item_ids: parsed.summary.item_ids,
            cache_path: cache::resolve_cached_path(root, reference)?
                .map(|path| relative_display(root, &path)),
        },
        cache_paths
            .into_iter()
            .map(|path| relative_display(root, &path))
            .collect(),
        parsed.entries,
    ))
}

fn parse_local_reference(root: &Path, reference: &str) -> Option<PathBuf> {
    if let Some(path) = reference.strip_prefix("file://") {
        let trimmed = path.trim();
        if trimmed.is_empty() {
            return None;
        }
        return Some(PathBuf::from(trimmed));
    }
    if reference.contains("://") {
        return None;
    }
    let candidate = PathBuf::from(reference);
    if candidate.is_absolute() || candidate.exists() || root.join(&candidate).exists() {
        return Some(candidate);
    }
    None
}

fn digest_hex(bytes: &[u8]) -> String {
    let mut hasher = Sha256::new();
    hasher.update(bytes);
    let digest = hasher.finalize();
    let mut out = String::from("sha256:");
    for byte in digest {
        out.push_str(&format!("{byte:02x}"));
    }
    out
}

fn relative_display(root: &Path, path: &Path) -> String {
    path.strip_prefix(root)
        .unwrap_or(path)
        .display()
        .to_string()
}