greentic-bundle 1.2.0

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

use anyhow::Result;
use serde_json::Value;
use tempfile::TempDir;

fn bundle_bin() -> &'static str {
    env!("CARGO_BIN_EXE_greentic-bundle")
}

#[test]
fn local_catalog_resolution_writes_deterministic_lock_and_cache() {
    let temp = TempDir::new().expect("tempdir");
    let root = temp.path().join("bundle");
    let catalog = temp.path().join("providers.json");
    fs::write(
        &catalog,
        r#"[{"id":"alpha","label":"Alpha","reference":"repo://packs/alpha@1"},{"id":"beta","label":"Beta","reference":"repo://packs/beta@1"}]"#,
    )
    .expect("catalog");

    let answers = temp.path().join("answers.json");
    fs::write(
        &answers,
        format!(
            r#"{{
  "wizard_id":"greentic-bundle.wizard.run",
  "schema_id":"greentic-bundle.wizard.answers",
  "schema_version":"1.0.0",
  "locale":"en",
  "answers":{{
    "mode":"create",
    "bundle_name":"Demo Bundle",
    "bundle_id":"demo-bundle",
    "output_dir":"{}",
    "advanced_setup":true,
    "app_packs":["pack-b","pack-a"],
    "extension_providers":["provider-b","provider-a"],
    "remote_catalogs":["file://{}"],
    "setup_execution_intent":false,
    "export_intent":false
  }},
  "locks":{{}}
}}"#,
            root.display(),
            catalog.display()
        ),
    )
    .expect("answers");

    let output = Command::new(bundle_bin())
        .args(["wizard", "apply", "--answers"])
        .arg(&answers)
        .output()
        .expect("wizard apply");
    assert!(
        output.status.success(),
        "stdout={} stderr={}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    let lock = read_json(&root.join("bundle.lock.json"));
    assert_eq!(
        lock.get("bundle_id").and_then(Value::as_str),
        Some("demo-bundle")
    );
    assert_eq!(
        lock.pointer("/catalogs/0/item_ids/0")
            .and_then(Value::as_str),
        Some("alpha")
    );
    assert_eq!(
        lock.pointer("/catalogs/0/item_ids/1")
            .and_then(Value::as_str),
        Some("beta")
    );
    assert_eq!(
        lock.pointer("/app_packs/0/reference")
            .and_then(Value::as_str),
        Some("pack-a")
    );
    assert_eq!(
        lock.pointer("/app_packs/1/reference")
            .and_then(Value::as_str),
        Some("pack-b")
    );
    assert_eq!(
        lock.pointer("/extension_providers/0/reference")
            .and_then(Value::as_str),
        Some("provider-a")
    );
    let cache_path = lock
        .pointer("/catalogs/0/cache_path")
        .and_then(Value::as_str)
        .expect("cache path");
    assert!(root.join(cache_path).exists());
    assert!(root.join("state/cache/catalogs/index.json").exists());
}

#[test]
fn offline_replay_uses_workspace_cached_catalog() {
    let temp = TempDir::new().expect("tempdir");
    let root = temp.path().join("bundle");
    let reference = "oci://ghcr.io/greentic/catalogs/demo:1";
    let digest = "sha256:abc123";
    let digest_cache = root
        .join("state/cache/catalogs/by-digest")
        .join(format!("{digest}.json"));
    fs::create_dir_all(digest_cache.parent().expect("parent")).expect("mkdir");
    fs::write(
        &digest_cache,
        r#"[{"id":"cached","label":"Cached","reference":"repo://packs/cached@1"}]"#,
    )
    .expect("digest cache");
    fs::create_dir_all(root.join("state/cache/catalogs")).expect("mkdir cache");
    fs::write(
        root.join("state/cache/catalogs/index.json"),
        format!(r#"{{"refs":{{"{reference}":"{digest}"}}}}"#),
    )
    .expect("index");

    let resolution = greentic_bundle::catalog::resolve::resolve_catalogs(
        &root,
        &[reference.to_string()],
        &greentic_bundle::catalog::resolve::CatalogResolveOptions {
            offline: true,
            write_cache: false,
        },
    )
    .expect("resolve from cache");

    assert_eq!(resolution.entries.len(), 1);
    assert_eq!(resolution.entries[0].source, "workspace_cache");
    assert_eq!(resolution.entries[0].item_ids, vec!["cached".to_string()]);
    assert!(resolution.cache_writes.is_empty());
}

#[test]
fn offline_without_cache_returns_recovery_hint() {
    let temp = TempDir::new().expect("tempdir");
    let root = temp.path().join("bundle");
    let error = greentic_bundle::catalog::resolve::resolve_catalogs(
        &root,
        &["oci://ghcr.io/greentic/catalogs/missing:1".to_string()],
        &greentic_bundle::catalog::resolve::CatalogResolveOptions {
            offline: true,
            write_cache: false,
        },
    )
    .expect_err("expected offline error");

    let message = error.to_string();
    assert!(message.contains("seed the workspace-local cache first"));
    assert!(message.contains("offline mode is enabled"));
}

#[test]
fn ghcr_shortcut_remote_ref_uses_catalog_client_seam() {
    #[derive(Debug)]
    struct FakeClient;

    impl greentic_bundle::catalog::client::CatalogArtifactClient for FakeClient {
        fn fetch_catalog(
            &self,
            _root: &Path,
            reference: &str,
        ) -> Result<greentic_bundle::catalog::client::FetchedCatalog> {
            let mapped = greentic_bundle::catalog::client::map_remote_catalog_reference(reference)?;
            Ok(greentic_bundle::catalog::client::FetchedCatalog {
                resolved_ref: mapped.oci_reference,
                digest: "sha256:feedbeef".to_string(),
                bytes: br#"[{"id":"well-known","label":"Well Known","reference":"repo://packs/well-known@1"}]"#
                    .to_vec(),
            })
        }
    }

    let temp = TempDir::new().expect("tempdir");
    let root = temp.path().join("bundle");
    let resolution = greentic_bundle::catalog::resolve::resolve_catalogs_with_client(
        &root,
        &["ghcr://catalogs/well-known".to_string()],
        &greentic_bundle::catalog::resolve::CatalogResolveOptions {
            offline: false,
            write_cache: true,
        },
        &FakeClient,
    )
    .expect("resolve");

    assert_eq!(resolution.entries.len(), 1);
    assert_eq!(
        resolution.entries[0].resolved_ref,
        "ghcr.io/greenticai/catalogs/well-known:latest"
    );
    assert_eq!(resolution.entries[0].source, "remote");
    assert_eq!(
        resolution.entries[0].item_ids,
        vec!["well-known".to_string()]
    );
}

#[test]
fn inspect_prints_sorted_lock_output() {
    let temp = TempDir::new().expect("tempdir");
    let root = temp.path().join("bundle");
    fs::create_dir_all(&root).expect("mkdir");
    fs::write(
        root.join("bundle.yaml"),
        "schema_version: 1\nbundle_id: demo-bundle\nbundle_name: Demo Bundle\nlocale: en\nmode: create\napp_packs: []\nextension_providers: []\nremote_catalogs: []\n",
    )
    .expect("bundle yaml");
    fs::write(
        root.join("bundle.lock.json"),
        r#"{
  "schema_version": 1,
  "bundle_id": "demo-bundle",
  "requested_mode": "create",
  "execution": "execute",
  "cache_policy": "workspace-local",
  "tool_version": "0.1.0",
  "build_format_version": "bundle-lock-v1",
  "workspace_root": "bundle.yaml",
  "lock_file": "bundle.lock.json",
  "catalogs": [],
  "app_packs": [],
  "extension_providers": [],
  "setup_state_files": []
}"#,
    )
    .expect("lock");

    let output = Command::new(bundle_bin())
        .args(["inspect", "--root"])
        .arg(&root)
        .output()
        .expect("inspect");
    assert!(output.status.success());

    let printed: Value = serde_json::from_slice(&output.stdout).expect("printed inspect json");
    assert_eq!(
        printed.pointer("/lock/bundle_id").and_then(Value::as_str),
        Some("demo-bundle")
    );
    assert_eq!(
        printed
            .pointer("/manifest/bundle_id")
            .and_then(Value::as_str),
        Some("demo-bundle")
    );
}

fn read_json(path: &Path) -> Value {
    serde_json::from_slice(&fs::read(path).expect("read json")).expect("parse json")
}