citum_store 0.53.4

Citum style and locale storage layer — registry, cache, and resolver chain
Documentation
/*
SPDX-License-Identifier: MIT OR Apache-2.0
SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus
*/

//! Integration tests for `StoreResolver`.

use crate::{StoreFormat, StoreResolver};
use std::fs;
use tempfile::TempDir;

/// Returns a minimal valid Citum style as YAML bytes.
fn minimal_style_yaml() -> &'static [u8] {
    include_bytes!("../../../styles/alpha.yaml")
}

/// Returns a minimal installed locale fixture as YAML bytes.
fn en_us_locale_yaml() -> &'static [u8] {
    b"locale: en-US\n"
}

/// Creates a temporary store directory and a resolver pointing at it.
fn make_resolver(format: StoreFormat) -> (TempDir, StoreResolver) {
    let dir = TempDir::new().expect("tempdir");
    let resolver = StoreResolver::new(dir.path().to_path_buf(), format);
    (dir, resolver)
}

#[test]
fn list_styles_empty_on_fresh_store() {
    let (_dir, resolver) = make_resolver(StoreFormat::Yaml);
    let styles = resolver.list_styles().expect("list_styles");
    assert!(styles.is_empty());
}

#[test]
fn install_and_list_yaml_style() {
    let (dir, resolver) = make_resolver(StoreFormat::Yaml);

    // Write the fixture to a temp file
    let src = dir.path().join("alpha.yaml");
    fs::write(&src, minimal_style_yaml()).unwrap();

    let name = resolver.install_style(&src).expect("install_style");
    assert_eq!(name, "alpha");

    let styles = resolver.list_styles().expect("list_styles");
    assert_eq!(styles, vec!["alpha"]);
}

#[test]
fn resolve_installed_yaml_style() {
    let (dir, resolver) = make_resolver(StoreFormat::Yaml);

    let src = dir.path().join("alpha.yaml");
    fs::write(&src, minimal_style_yaml()).unwrap();
    resolver.install_style(&src).expect("install_style");

    let style = resolver.resolve_style("alpha").expect("resolve_style");
    assert_eq!(style.info.title.as_deref(), Some("Alpha (biblatex-alpha)"));
}

#[test]
fn remove_installed_style() {
    let (dir, resolver) = make_resolver(StoreFormat::Yaml);

    let src = dir.path().join("alpha.yaml");
    fs::write(&src, minimal_style_yaml()).unwrap();
    resolver.install_style(&src).expect("install_style");
    resolver.remove_style("alpha").expect("remove_style");

    let styles = resolver.list_styles().expect("list_styles");
    assert!(styles.is_empty());
}

#[test]
fn resolve_missing_style_returns_error() {
    let (_dir, resolver) = make_resolver(StoreFormat::Yaml);
    assert!(resolver.resolve_style("nonexistent").is_err());
}

#[test]
fn remove_missing_style_returns_error() {
    let (_dir, resolver) = make_resolver(StoreFormat::Yaml);
    assert!(resolver.remove_style("nonexistent").is_err());
}

#[test]
fn install_and_resolve_json_style() {
    let (dir, resolver) = make_resolver(StoreFormat::Json);

    // Convert the YAML fixture to JSON via citum_schema
    let style: citum_schema::Style =
        serde_yaml::from_slice(minimal_style_yaml()).expect("parse yaml");
    let json = serde_json::to_vec(&style).expect("to json");

    let src = dir.path().join("alpha.json");
    fs::write(&src, &json).unwrap();
    resolver.install_style(&src).expect("install_style");

    let resolved = resolver.resolve_style("alpha").expect("resolve_style");
    assert_eq!(
        resolved.info.title.as_deref(),
        Some("Alpha (biblatex-alpha)")
    );
}

#[test]
fn install_and_resolve_cbor_style() {
    let (dir, resolver) = make_resolver(StoreFormat::Cbor);

    let style: citum_schema::Style =
        serde_yaml::from_slice(minimal_style_yaml()).expect("parse yaml");
    let mut cbor = Vec::new();
    ciborium::ser::into_writer(&style, &mut cbor).expect("to cbor");

    let src = dir.path().join("alpha.cbor");
    fs::write(&src, &cbor).unwrap();
    resolver.install_style(&src).expect("install_style");

    let resolved = resolver.resolve_style("alpha").expect("resolve_style");
    assert_eq!(
        resolved.info.title.as_deref(),
        Some("Alpha (biblatex-alpha)")
    );
}

#[test]
fn resolver_fallback_finds_any_format() {
    // Resolver configured for CBOR but only a YAML file exists — should still find it.
    let (dir, resolver) = make_resolver(StoreFormat::Cbor);

    let src = dir.path().join("alpha.yaml");
    fs::write(&src, minimal_style_yaml()).unwrap();
    // Install directly (bypassing the resolver so it lands as .yaml)
    let styles_dir = dir.path().join("styles");
    fs::create_dir_all(&styles_dir).unwrap();
    fs::copy(&src, styles_dir.join("alpha.yaml")).unwrap();

    let resolved = resolver.resolve_style("alpha").expect("fallback resolve");
    assert_eq!(
        resolved.info.title.as_deref(),
        Some("Alpha (biblatex-alpha)")
    );
}

#[test]
fn resolve_installed_yaml_locale() {
    let (dir, resolver) = make_resolver(StoreFormat::Yaml);
    let locales_dir = dir.path().join("locales");
    fs::create_dir_all(&locales_dir).unwrap();
    fs::write(locales_dir.join("en-US.yaml"), en_us_locale_yaml()).unwrap();

    let locale = resolver.resolve_locale("en-US").expect("resolve_locale");

    assert_eq!(locale.locale, "en-US");
}

#[test]
fn list_styles_ignores_unsupported_files_and_deduplicates_formats() {
    let (dir, resolver) = make_resolver(StoreFormat::Yaml);
    let styles_dir = dir.path().join("styles");
    fs::create_dir_all(&styles_dir).unwrap();
    fs::write(styles_dir.join("alpha.yaml"), minimal_style_yaml()).unwrap();
    fs::write(styles_dir.join("alpha.json"), "{}").unwrap();
    fs::write(styles_dir.join("notes.txt"), "not a style").unwrap();

    let styles = resolver.list_styles().expect("list_styles");

    assert_eq!(styles, vec!["alpha"]);
}

#[cfg(feature = "http")]
#[test]
fn git_resolver_parses_git_uri() {
    use crate::GitResolver;

    // Test URI parsing
    let uri =
        "git+https://github.com/citum/citum-external-registry-prototype.git#styles/example.yaml";
    let (repo, file) = GitResolver::parse_git_uri(uri).expect("parse_git_uri");

    assert_eq!(
        repo,
        "https://github.com/citum/citum-external-registry-prototype.git"
    );
    assert_eq!(file, "styles/example.yaml");
}

#[cfg(feature = "http")]
#[test]
fn git_resolver_rejects_plain_http_uri() {
    use crate::GitResolver;

    let uri = "git+http://github.com/citum/example.git#styles/example.yaml";

    assert!(GitResolver::parse_git_uri(uri).is_none());
}

#[cfg(feature = "http")]
#[test]
fn git_resolver_rejects_unsafe_style_paths() {
    use crate::GitResolver;

    assert!(
        GitResolver::parse_git_uri("git+https://github.com/citum/example.git#/etc/passwd")
            .is_none()
    );
    assert!(
        GitResolver::parse_git_uri("git+https://github.com/citum/example.git#../style.yaml")
            .is_none()
    );
    assert!(GitResolver::parse_git_uri("git+https://github.com/citum/example.git#").is_none());
}

#[cfg(feature = "http")]
#[test]
fn verifying_resolver_passes_through_when_pin_matches() {
    use crate::cid::compute_style_cid;
    use crate::resolver::{StyleResolver, VerifyingResolver};
    use citum_schema::Style;

    struct FixedResolver(Style);
    impl StyleResolver for FixedResolver {
        type Style = Style;
        type Locale = citum_schema::Locale;

        fn resolve_style(&self, _uri: &str) -> Result<Style, crate::resolver::ResolverError> {
            Ok(self.0.clone())
        }
        fn resolve_locale(
            &self,
            id: &str,
        ) -> Result<citum_schema::Locale, crate::resolver::ResolverError> {
            Err(crate::resolver::ResolverError::LocaleNotFound(
                id.to_string().into(),
            ))
        }
    }

    let yaml = b"info:\n  title: Test\n";
    let style: Style = serde_yaml::from_slice(yaml).unwrap();
    let canonical = serde_yaml::to_string(&style).unwrap();
    let pin = compute_style_cid(canonical.as_bytes());

    let inner = FixedResolver(style.clone());
    let verifying = VerifyingResolver::new(inner, Some(pin));
    let resolved = verifying
        .resolve_style("file:///dev/null")
        .expect("verifying pass-through");
    assert_eq!(resolved.info.title.as_deref(), Some("Test"));
}

#[cfg(feature = "http")]
#[test]
fn verifying_resolver_rejects_when_pin_mismatches() {
    use crate::resolver::{StyleResolver, VerifyingResolver};
    use citum_schema::Style;

    struct FixedResolver(Style);
    impl StyleResolver for FixedResolver {
        type Style = Style;
        type Locale = citum_schema::Locale;

        fn resolve_style(&self, _uri: &str) -> Result<Style, crate::resolver::ResolverError> {
            Ok(self.0.clone())
        }
        fn resolve_locale(
            &self,
            id: &str,
        ) -> Result<citum_schema::Locale, crate::resolver::ResolverError> {
            Err(crate::resolver::ResolverError::LocaleNotFound(
                id.to_string().into(),
            ))
        }
    }

    let yaml = b"info:\n  title: Test\n";
    let style: Style = serde_yaml::from_slice(yaml).unwrap();
    // Wrong pin: hash of unrelated content.
    let bogus_pin = crate::cid::compute_style_cid(b"different bytes entirely");
    let verifying = VerifyingResolver::new(FixedResolver(style), Some(bogus_pin));
    let err = verifying
        .resolve_style("file:///dev/null")
        .expect_err("must reject");
    assert!(
        matches!(err, crate::resolver::ResolverError::IntegrityFailure { .. }),
        "expected IntegrityFailure, got {err:?}"
    );
}

#[test]
fn resolver_error_variants_format_distinctly() {
    use crate::resolver::ResolverError;

    let denied = ResolverError::Denied {
        uri: "https://evil.example.com/x.yaml".to_string(),
        reason: "host not in allowlist".to_string(),
    };
    assert_eq!(
        denied.to_string(),
        "host not in resolver allowlist: https://evil.example.com/x.yaml (host not in allowlist)"
    );

    let net = ResolverError::NetworkError {
        uri: "https://example.org/y.yaml".to_string(),
        reason: "connection refused".to_string(),
    };
    assert_eq!(
        net.to_string(),
        "network error fetching https://example.org/y.yaml: connection refused"
    );

    let version = ResolverError::VersionMismatch {
        uri: "https://example.org/z.yaml".to_string(),
        required: ">=99.0.0".to_string(),
        declared: "0.38.0".to_string(),
    };
    assert_eq!(
        version.to_string(),
        "engine version mismatch for https://example.org/z.yaml: engine requires >=99.0.0, style declares 0.38.0"
    );

    let integrity = ResolverError::IntegrityFailure {
        uri: "cid:bafkreiabc".to_string(),
        expected: "bafkreiabc".to_string(),
        actual: "bafkreidef".to_string(),
    };
    assert_eq!(
        integrity.to_string(),
        "integrity failure for cid:bafkreiabc: expected bafkreiabc, got bafkreidef"
    );
}