greentic-bundle 1.1.0

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

use greentic_distributor_client::{
    DistClient, DistOptions, ReleaseChannel, ReleaseIndex, ReleaseResolutionContext,
};

const STABLE_OCI_SOURCES: &[&str] = &[
    include_str!("../registries/providers.json"),
    include_str!("../src/catalog/registry.rs"),
    include_str!("../src/cli/info/pack_probe.rs"),
    include_str!("../src/project/mod.rs"),
    include_str!("../src/wizard/mod.rs"),
    include_str!("../crates/cards2pack-core/src/emit.rs"),
];

#[test]
fn stable_oci_refs_used_by_code_resolve_from_local_distribution_cache() {
    let refs = stable_oci_refs_used_by_code();
    assert!(!refs.is_empty(), "expected at least one stable OCI ref");

    let opts = DistOptions {
        offline: true,
        ..DistOptions::default()
    };
    let release_indexes = stable_release_indexes(&opts.cache_dir);
    if release_indexes.is_empty() {
        let release_index_dir = opts
            .cache_dir
            .join("release-index")
            .join("v1")
            .join("stable");
        if stable_oci_cache_test_is_required() {
            panic!(
                "no stable release index is present in the local distribution cache at {}",
                release_index_dir.display()
            );
        }
        eprintln!(
            "skipping stable OCI cache validation: no stable release index is present at {}",
            release_index_dir.display()
        );
        return;
    }

    let client = DistClient::new(opts);
    let runtime = tokio::runtime::Runtime::new().expect("create tokio runtime");

    let mut failures = Vec::new();
    for reference in refs {
        let mut resolved = None;
        let mut resolve_errors = Vec::new();
        for (ctx, path) in &release_indexes {
            match runtime.block_on(client.resolve_oci_ref_with_context(&reference, ctx)) {
                Ok(descriptor) => {
                    resolved = Some(descriptor);
                    break;
                }
                Err(err) => resolve_errors.push(format!(
                    "{reference} via {} ({}): {err}",
                    ctx.release,
                    path.display()
                )),
            }
        }

        let Some(descriptor) = resolved else {
            failures.push(format!(
                "{reference} could not be resolved from the local stable release index:\n{}",
                resolve_errors.join("\n")
            ));
            continue;
        };

        if !descriptor.canonical_ref.contains("@sha256:") {
            failures.push(format!(
                "{reference} resolved to non-digest-pinned ref {}",
                descriptor.canonical_ref
            ));
            continue;
        }

        match client.open_cached(&descriptor.digest) {
            Ok(artifact) => {
                let path = artifact.cache_path.as_ref().unwrap_or(&artifact.local_path);
                match std::fs::metadata(path) {
                    Ok(metadata) if metadata.len() > 0 => {}
                    Ok(_) => failures.push(format!(
                        "{reference} cached artifact is empty at {}",
                        path.display()
                    )),
                    Err(err) => failures.push(format!(
                        "{reference} cached artifact is missing at {}: {err}",
                        path.display()
                    )),
                }
            }
            Err(err) => failures.push(format!(
                "{reference} resolved to {}, but the artifact could not be opened from the local cache: {err}",
                descriptor.canonical_ref
            )),
        }
    }

    assert!(
        failures.is_empty(),
        "stable OCI refs missing from local distribution cache/release index:\n{}",
        failures.join("\n\n")
    );
}

fn stable_release_indexes(cache_dir: &Path) -> Vec<(ReleaseResolutionContext, PathBuf)> {
    let release_index_dir = cache_dir.join("release-index").join("v1").join("stable");
    let Ok(entries) = std::fs::read_dir(&release_index_dir) else {
        return Vec::new();
    };

    let mut indexes = entries
        .filter_map(Result::ok)
        .map(|entry| entry.path())
        .filter(|path| {
            path.extension()
                .is_some_and(|extension| extension == "json")
        })
        .filter_map(|path| {
            let bytes = std::fs::read(&path).ok()?;
            let index = serde_json::from_slice::<ReleaseIndex>(&bytes).ok()?;
            if index.channel != ReleaseChannel::Stable {
                return None;
            }
            Some((
                ReleaseResolutionContext {
                    release: index.release,
                    channel: ReleaseChannel::Stable,
                },
                path,
            ))
        })
        .collect::<Vec<_>>();

    indexes
        .sort_by(|(left, _), (right, _)| compare_release_versions(&right.release, &left.release));
    indexes
}

fn compare_release_versions(left: &str, right: &str) -> std::cmp::Ordering {
    let left_segments = numeric_version_segments(left);
    let right_segments = numeric_version_segments(right);
    left_segments
        .cmp(&right_segments)
        .then_with(|| left.cmp(right))
}

fn numeric_version_segments(version: &str) -> Vec<u64> {
    version
        .split('.')
        .map(|segment| segment.parse::<u64>().unwrap_or(0))
        .collect()
}

fn stable_oci_cache_test_is_required() -> bool {
    std::env::var("GREENTIC_REQUIRE_STABLE_OCI_CACHE").is_ok_and(|value| value == "1")
}

fn stable_oci_refs_used_by_code() -> BTreeSet<String> {
    STABLE_OCI_SOURCES
        .iter()
        .flat_map(|source| stable_oci_refs_in(source))
        .filter(|reference| reference.starts_with("oci://ghcr.io/greenticai/"))
        .collect()
}

fn stable_oci_refs_in(source: &str) -> BTreeSet<String> {
    let mut refs = BTreeSet::new();
    let mut rest = source;
    while let Some(start) = rest.find("oci://") {
        rest = &rest[start..];
        let end = rest
            .find(|c: char| {
                c.is_whitespace() || matches!(c, '"' | '\'' | '`' | '<' | '>' | ')' | ']' | '}')
            })
            .unwrap_or(rest.len());
        let reference = rest[..end]
            .trim_end_matches([',', '.', ';', ':'])
            .to_string();
        if reference.ends_with(":stable") {
            refs.insert(reference);
        }
        rest = &rest[end..];
    }
    refs
}