greentic-bundle 1.1.0

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

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

use super::CACHE_ROOT_DIR;

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct CatalogCacheIndex {
    #[serde(default)]
    pub refs: BTreeMap<String, String>,
}

pub fn ref_cache_path(root: &Path, reference: &str) -> PathBuf {
    root.join(CACHE_ROOT_DIR)
        .join("by-ref")
        .join(format!("{}.json", slug(reference)))
}

pub fn digest_cache_path(root: &Path, digest: &str) -> PathBuf {
    root.join(CACHE_ROOT_DIR)
        .join("by-digest")
        .join(format!("{digest}.json"))
}

pub fn index_path(root: &Path) -> PathBuf {
    root.join(CACHE_ROOT_DIR).join("index.json")
}

pub fn load_index(root: &Path) -> Result<CatalogCacheIndex> {
    let path = index_path(root);
    if !path.exists() {
        return Ok(CatalogCacheIndex::default());
    }
    let raw = std::fs::read_to_string(&path)
        .with_context(|| format!("read catalog cache index {}", path.display()))?;
    serde_json::from_str(&raw)
        .with_context(|| format!("parse catalog cache index {}", path.display()))
}

pub fn write_index(root: &Path, index: &CatalogCacheIndex) -> Result<()> {
    let path = index_path(root);
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::write(&path, format!("{}\n", serde_json::to_string_pretty(index)?))
        .with_context(|| format!("write catalog cache index {}", path.display()))?;
    Ok(())
}

pub fn cache_catalog_bytes(
    root: &Path,
    reference: &str,
    digest: &str,
    bytes: &[u8],
) -> Result<Vec<PathBuf>> {
    let digest_path = digest_cache_path(root, digest);
    let ref_path = ref_cache_path(root, reference);
    for path in [&digest_path, &ref_path] {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        std::fs::write(path, bytes).with_context(|| format!("write {}", path.display()))?;
    }

    let mut index = load_index(root)?;
    index.refs.insert(reference.to_string(), digest.to_string());
    write_index(root, &index)?;

    Ok(vec![digest_path, ref_path, index_path(root)])
}

pub fn resolve_cached_path(root: &Path, reference: &str) -> Result<Option<PathBuf>> {
    let by_ref = ref_cache_path(root, reference);
    if by_ref.exists() {
        return Ok(Some(by_ref));
    }
    let index = load_index(root)?;
    let Some(digest) = index.refs.get(reference) else {
        return Ok(None);
    };
    let by_digest = digest_cache_path(root, digest);
    if by_digest.exists() {
        return Ok(Some(by_digest));
    }
    Ok(None)
}

pub fn slug(value: &str) -> String {
    let mut out = String::new();
    let mut prev_dash = false;
    for ch in value.chars() {
        if ch.is_ascii_alphanumeric() {
            out.push(ch.to_ascii_lowercase());
            prev_dash = false;
        } else if !prev_dash {
            out.push('-');
            prev_dash = true;
        }
    }
    let out = out.trim_matches('-').to_string();
    if out.is_empty() {
        "catalog".to_string()
    } else {
        out
    }
}