acorn-schema 0.3.1

Portable ACORN schema, validation, and codecs
Documentation
use super::kitfile::*;
use acorn_core::Location;
use serde::Deserialize;
use std::collections::BTreeSet;
use std::fs::{read_dir, read_to_string};
use std::path::PathBuf;

const DIFF_ID: &str = "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
const DIGEST: &str = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ParameterFixture {
    kitfile_yaml: String,
    kitfile_json: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ValidationFixture {
    kitfile: String,
    err_regexp: String,
}

fn fixture_paths(kind: &str) -> Vec<PathBuf> {
    read_dir(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/modelkit").join(kind))
        .unwrap()
        .map(|entry| entry.unwrap().path())
        .collect::<BTreeSet<_>>()
        .into_iter()
        .collect()
}

fn upstream_validation_code(name: &str) -> Option<&'static str> {
    match name {
        | value if value.starts_with("duplicate-path") => Some("duplicate_path"),
        | value if value.starts_with("http-path") || value.starts_with("s3-path") => Some("path_kind"),
        | "invalid-model-part-type-length.yaml" => Some("model_part_type_length"),
        | "invalid-model-part-type.yaml" => Some("model_part_type"),
        | "mcpserver-duplicate-name.yaml" => Some("duplicate_name"),
        | "mcpserver-missing-name.yaml" | "remote-dataset-requires-hash.yaml" => Some("required"),
        | "mcpserver-non-mcpb-path.yaml" => Some("mcp_bundle"),
        | "modelkit-ref-for-code.yaml"
        | "modelkit-ref-for-datasets.yaml"
        | "modelkit-ref-for-docs.yaml"
        | "modelkit-ref-for-modelpart.yaml"
        | "modelkit-ref-for-prompts.yaml" => Some("path_kind"),
        | value if value.starts_with("remote-dataset-collides") => Some("remote_dataset_collision"),
        | "remote-dataset-must-use-s3.yaml" => Some("remote_path"),
        | _ => None,
    }
}

#[test]
fn test_authoring_yaml_rejects_derived_and_unknown_fields() {
    assert!(Kitfile::from_yaml(&format!("manifestVersion: v1.0.0\nmodel:\n  path: model.gguf\n  digest: {DIGEST}\n")).is_err());
    assert!(Kitfile::from_yaml("manifestVersion: v1.0.0\nmodel:\n  path: model.gguf\n  surprise: true\n").is_err());
    assert!(Kitfile::from_resolved_json(br#"{"manifestVersion":"v1.0.0","docs":[{"path":"README.md","extra":1}]}"#).is_err());
}
#[test]
fn test_blank_placeholder_paths_do_not_count_as_content() {
    let placeholders = [
        "manifestVersion: v1.0.0\nmodel: {}\n",
        "manifestVersion: v1.0.0\ncode:\n  - {}\n",
        "manifestVersion: v1.0.0\ndocs:\n  - path: '   '\n",
    ];
    placeholders.into_iter().for_each(|input| {
        let error = Kitfile::from_yaml(input).unwrap_err().to_string();
        assert!(error.contains("content") || error.contains("path"), "{error}");
    });
    assert!(Kitfile::from_yaml("manifestVersion: v1.0.0\nmodel:\n  parts:\n    - path: model.part\n").is_ok());
}
#[test]
fn test_codecs_reject_duplicate_keys_trailing_data_and_yaml_only_values() {
    assert!(Kitfile::from_yaml("manifestVersion: v1.0.0\nmanifestVersion: v1.0.0\ndocs:\n  - path: README.md\n").is_err());
    assert!(Kitfile::from_yaml("manifestVersion: v1.0.0\ndocs:\n  - path: README.md\n---\nmanifestVersion: v1.0.0\n").is_err());
    assert!(Kitfile::from_yaml("manifestVersion: v1.0.0\nmodel:\n  path: model.gguf\n  parameters: !!binary SGVsbG8=\n").is_err());
    assert!(Kitfile::from_resolved_json(br#"{"manifestVersion":"v1.0.0","docs":[{"path":"README.md"}]} trailing"#).is_err());
}
#[test]
fn test_complete_kitfile_and_modelkit_fixtures_validate() {
    let kitfile = Kitfile::from_yaml(include_str!("../../../../tests/fixtures/modelkit/Kitfile")).unwrap();
    let modelkit = Kitfile::from_resolved_json(include_bytes!("../../../../tests/fixtures/modelkit/modelkit.json")).unwrap();
    assert!(!kitfile.is_empty());
    assert_eq!(kitfile.model.as_ref().unwrap().parts.len(), 1);
    assert_eq!(kitfile.code.len(), 1);
    assert_eq!(kitfile.datasets.len(), 1);
    assert_eq!(kitfile.docs.len(), 1);
    assert_eq!(kitfile.prompts.len(), 1);
    assert_eq!(kitfile.mcp_servers.len(), 1);
    assert_eq!(
        modelkit.layer_info_completeness().unwrap(),
        LayerInfoCompleteness {
            digests: true,
            diff_ids: true
        }
    );
}
#[test]
fn test_full_authoring_kitfile_round_trips_without_layer_identity() {
    let input = r#"manifestVersion: v1.0.0
package:
  name: demo
  version: 2.0.0
  description: Full fixture
  license: Apache-2.0
  authors: [Ada, Grace]
model:
  name: model
  path: weights/model.gguf
  license: MIT
  framework: llama.cpp
  format: GGUF
  version: 1.0.0
  description: Primary model
  parameters:
    nested:
      value: 255
  parts:
    - name: projector
      path: weights/mmproj.gguf
      license: MIT
      type: projector.v1
datasets:
  - name: data
    path: data
    remotePath: s3://example/data
    remoteHash: sha256:test
    description: Training data
    license: CC0-1.0
    parameters:
      split: train
code:
  - path: src
    description: Runtime
    license: MIT
docs:
  - path: README.md
    description: Guide
prompts:
  - name: system
    path: prompts/system.txt
    description: System prompt
mcpServers:
  - name: filesystem
    path: bundles/filesystem.mcpb
    description: Filesystem server
"#;
    let parsed = Kitfile::from_yaml(input).unwrap();
    assert_eq!(parsed.package.license, "Apache-2.0");
    assert_eq!(parsed.model.as_ref().unwrap().format, ModelFormat::Gguf);
    assert!(parsed.to_yaml().unwrap().contains("authors:"));
    assert!(!parsed.to_yaml().unwrap().contains("digest:"));
    assert_eq!(Kitfile::from_yaml(&parsed.to_yaml().unwrap()).unwrap(), parsed);
}
#[test]
fn test_layer_identity_is_validated_and_complete_independently() {
    let complete = Kitfile::from_resolved_json(
        format!(
            r#"{{"manifestVersion":"v1.0.0","model":{{"path":"model.gguf","digest":"{DIGEST}"}},"docs":[{{"path":"README.md","digest":"{DIGEST}"}}]}}"#
        )
        .as_bytes(),
    )
    .unwrap();
    assert_eq!(
        complete.layer_info_completeness().unwrap(),
        LayerInfoCompleteness {
            digests: true,
            diff_ids: false
        }
    );
    assert!(complete.warnings().iter().any(|warning| warning.contains("omit diffId")));
    let incomplete = format!(r#"{{"manifestVersion":"v1.0.0","model":{{"path":"model.gguf","digest":"{DIGEST}"}},"docs":[{{"path":"README.md"}}]}}"#);
    assert!(Kitfile::from_resolved_json(incomplete.as_bytes()).is_err());
    let malformed = br#"{"manifestVersion":"v1.0.0","model":{"path":"model.gguf","digest":"bad"}}"#;
    assert!(Kitfile::from_resolved_json(malformed).is_err());
}
#[test]
fn test_mcp_identity_and_prompt_paths_cover_intentional_kitops_differences() {
    let mcp_identity = Kitfile::from_resolved_json(
        format!(
            r#"{{"manifestVersion":"v1.0.0","model":{{"path":"model.gguf","digest":"{DIGEST}"}},"mcpServers":[{{"name":"server","path":"server.mcpb"}}]}}"#
        )
        .as_bytes(),
    )
    .unwrap_err()
    .to_string();
    assert!(mcp_identity.contains("compressed digest"));
    let prompt_duplicate = Kitfile::from_yaml("manifestVersion: v1.0.0\nmodel:\n  path: shared\nprompts:\n  - path: shared\n")
        .unwrap_err()
        .to_string();
    assert!(prompt_duplicate.contains("use the same path shared"));
}
#[test]
fn test_model_free_content_and_version_warning_are_supported() {
    let kitfile = Kitfile::from_yaml("manifestVersion: 1.0.0\ndocs:\n  - path: README.md\n").unwrap();
    assert!(kitfile.model.is_none());
    assert_eq!(kitfile.warnings(), ["Unrecognized manifestVersion 1.0.0: treating Kitfile as v1.0.0"]);
    assert!(Kitfile::from_yaml("manifestVersion: v1.0.0\npackage:\n  name: empty\n").is_err());
}
#[test]
fn test_model_metadata_and_remote_paths_use_field_validators() {
    let invalid_version = Kitfile::from_yaml("manifestVersion: v1.0.0\nmodel:\n  path: model.gguf\n  version: v1\n").unwrap_err();
    assert!(invalid_version.to_string().contains("semantic version"));
    let invalid_license = Kitfile::from_yaml("manifestVersion: v1.0.0\ncode:\n  - path: src\n    license: ' '\n").unwrap_err();
    assert!(invalid_license.to_string().contains("SPDX license"));
    let invalid_remote =
        Kitfile::from_yaml("manifestVersion: v1.0.0\ndatasets:\n  - path: data\n    remotePath: https://example.org/data\n").unwrap_err();
    assert!(invalid_remote.to_string().contains("S3 URLs and ModelKit references"));
    let blank_remote_hash =
        Kitfile::from_yaml("manifestVersion: v1.0.0\ndatasets:\n  - path: data\n    remotePath: s3://example/data\n    remoteHash: '   '\n")
            .unwrap_err();
    assert!(blank_remote_hash.to_string().contains("remoteHash cannot be blank"));
    let blank_remote_path = Kitfile::from_yaml("manifestVersion: v1.0.0\ndatasets:\n  - path: data\n    remotePath: '   '\n").unwrap_err();
    assert!(blank_remote_path.to_string().contains("S3 URLs and ModelKit references"));
    let custom_format = Kitfile::from_yaml("manifestVersion: v1.0.0\nmodel:\n  path: model.bin\n  format: Custom\n").unwrap();
    assert_eq!(custom_format.model.unwrap().format, ModelFormat::Other("Custom".to_string()));
}
#[test]
fn test_model_part_type_and_mcp_name_limits_match_kitops() {
    let long_kind = "a".repeat(65);
    let input = format!(
        "manifestVersion: v1.0.0\nmodel:\n  path: model.gguf\n  parts:\n    - path: part.gguf\n      type: {long_kind}\nmcpServers:\n  - path: first.zip\n  - name: same\n    path: one.mcpb\n  - name: same\n    path: two.mcpb\n"
    );
    let error = Kitfile::from_yaml(&input).unwrap_err().to_string();
    assert!(error.contains("at most 64 bytes"));
    assert!(error.contains("must have a name"));
    assert!(error.contains("Duplicate MCP server name"));
    assert!(error.contains("single .mcpb file"));
}
#[test]
fn test_parameters_use_json_values_and_deterministic_key_order() {
    let kitfile =
        Kitfile::from_yaml("manifestVersion: v1.0.0\nmodel:\n  path: model.gguf\n  parameters:\n    z: 0xFF\n    a: [true, null, 1.2e+3]\n").unwrap();
    let json = serde_json::to_string(&kitfile).unwrap();
    assert!(json.contains(r#""a":[true,null,1200.0]"#));
    assert!(json.contains(r#""z":255"#));
    assert!(json.find(r#""a""#) < json.find(r#""z""#));
}
#[test]
fn test_path_kinds_match_pinned_kitops_cases() {
    assert!(Location::from("model.gguf").is_path());
    assert!(Location::from("docker.io/org/model:latest").is_oci_reference());
    assert!(Location::from("org/model:latest").is_oci_reference());
    assert!(Location::from("localhost:5000/org/model").is_oci_reference());
    assert!(Location::from("testrepo:ignored@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef").is_oci_reference());
    assert!(Location::from("s3://bucket/key").is_s3());
    assert!(!Location::from("https://example.org/model").is_local());
    assert!(!Location::from("ftp://example.org/model").is_local());
}
#[test]
fn test_path_remote_dataset_part_and_mcp_rules_are_aggregated() {
    let input = r#"manifestVersion: v1.0.0
model:
  path: /absolute/model.gguf
  parts:
    - name: bad
      path: s3://bucket/part
      type: -invalid
datasets:
  - path: data
    remotePath: registry.example.org/data/base:latest
  - path: other-data
    remotePath: s3://bucket/data
code:
  - path: data/code
mcpServers:
  - name: duplicate
    path: server.zip
  - name: duplicate
    path: second.mcpb
"#;
    let error = Kitfile::from_yaml(input).unwrap_err();
    assert!(matches!(&error, ModelKitError::Validation(_)));
    let report = match error {
        | ModelKitError::Validation(report) => Some(report),
        | ModelKitError::Codec(_) => None,
    };
    let report = report.unwrap();
    let codes = report.iter().map(|value| value.error.code.as_str()).collect::<Vec<_>>();
    assert!(codes.contains(&"absolute_path"));
    assert!(codes.contains(&"model_part_type"));
    assert!(codes.contains(&"required"));
    assert!(codes.contains(&"remote_dataset_collision"));
    assert!(codes.contains(&"mcp_bundle"));
}
#[test]
fn test_paths_are_cleaned_portably_for_duplicates_and_absolutes() {
    let duplicate = Kitfile::from_yaml("manifestVersion: v1.0.0\ndocs:\n  - path: docs/../README.md\n  - path: README.md\n").unwrap_err();
    assert!(duplicate.to_string().contains("same path"));
    let windows = Kitfile::from_yaml("manifestVersion: v1.0.0\ncode:\n  - path: 'C:\\\\models\\\\run.py'\n").unwrap_err();
    assert!(windows.to_string().contains("Absolute paths"));
    let sibling = Kitfile::from_yaml(
        "manifestVersion: v1.0.0\ndatasets:\n  - path: data/base\n    remotePath: registry.example.org/org/data:latest\ncode:\n  - path: data/sibling\n",
    );
    assert!(sibling.is_ok());
}
#[test]
fn test_pinned_kitops_fixtures_match_authoring_and_resolved_codecs() {
    let parameter_paths = fixture_paths("parameters");
    assert_eq!(parameter_paths.len(), 5);
    parameter_paths.into_iter().for_each(|path| {
        let parameters = serde_norway::from_str::<ParameterFixture>(&read_to_string(&path).unwrap()).unwrap();
        let parsed = Kitfile::from_yaml(&parameters.kitfile_yaml).unwrap();
        if !parameters.kitfile_json.is_empty() {
            assert_eq!(serde_json::to_string(&parsed).unwrap(), parameters.kitfile_json, "{}", path.display());
        }
        assert_eq!(Kitfile::from_yaml(&parsed.to_yaml().unwrap()).unwrap(), parsed, "{}", path.display());
    });
    let validation_paths = fixture_paths("validation");
    assert_eq!(validation_paths.len(), 39);
    validation_paths.into_iter().for_each(|path| {
        let fixture = serde_norway::from_str::<ValidationFixture>(&read_to_string(&path).unwrap()).unwrap();
        let result = Kitfile::from_yaml(&fixture.kitfile);
        let name = path.file_name().unwrap().to_string_lossy();
        let expected_failure = !fixture.err_regexp.is_empty();
        match result {
            | Ok(_) => {
                assert!(!expected_failure, "{} should fail with {}", path.display(), fixture.err_regexp);
                assert!(upstream_validation_code(&name).is_none(), "{}", path.display());
            }
            | Err(error) => {
                assert!(expected_failure, "{} should pass: {error}", path.display());
                assert!(
                    matches!(&error, ModelKitError::Validation(_)),
                    "{} should report validation errors: {error}",
                    path.display()
                );
                let expected = upstream_validation_code(&name).unwrap();
                if let ModelKitError::Validation(report) = error {
                    assert!(
                        report.iter().any(|issue| issue.error.code == expected),
                        "{} expected code {expected}: {report}",
                        path.display()
                    );
                }
            }
        }
    });
}
#[test]
fn test_remote_path_kinds_are_restricted_by_content_role() {
    let invalid = [
        "model:\n  path: https://example.org/model.gguf",
        "model:\n  path: s3://bucket/model.gguf",
        "model:\n  path: model.gguf\n  parts:\n    - path: registry.example.org/org/part:latest",
        "code:\n  - path: registry.example.org/org/code:latest",
        "datasets:\n  - path: s3://bucket/data",
        "docs:\n  - path: https://example.org/docs",
        "prompts:\n  - path: s3://bucket/prompt",
        "mcpServers:\n  - name: remote\n    path: registry.example.org/org/server:latest",
    ];
    invalid.into_iter().for_each(|body| {
        assert!(Kitfile::from_yaml(&format!("manifestVersion: v1.0.0\n{body}\n")).is_err(), "{body}");
    });
    assert!(Kitfile::from_yaml("manifestVersion: v1.0.0\nmodel:\n  path: registry.example.org/org/model:latest\n").is_ok());
    let remote_dataset = Kitfile::from_yaml(
        "manifestVersion: v1.0.0\ndatasets:\n  - path: data\n    remotePath: registry.example.org/org/data:latest\n    remoteHash: ignored\n",
    )
    .unwrap();
    assert_eq!(remote_dataset.warnings().len(), 1);
}
#[test]
fn test_resolved_json_preserves_identity_and_yaml_projection_removes_it() {
    let input = format!(r#"{{"manifestVersion":"v1.0.0","model":{{"path":"model.gguf","digest":"{DIGEST}","diffId":"{DIFF_ID}"}}}}"#);
    let parsed = Kitfile::from_resolved_json(input.as_bytes()).unwrap();
    assert_eq!(parsed.model.as_ref().unwrap().layer.digest.as_deref(), Some(DIGEST));
    let json = parsed.to_resolved_json().unwrap();
    assert!(json.contains(DIGEST));
    let yaml = parsed.to_yaml().unwrap();
    assert!(!yaml.contains("digest"));
    assert!(!yaml.contains("diffId"));
}
#[test]
fn test_resolved_json_requires_digests_but_treats_diff_ids_as_advisory() {
    let missing = br#"{"manifestVersion":"v1.0.0","docs":[{"path":"README.md"}]}"#;
    let error = Kitfile::from_resolved_json(missing).unwrap_err().to_string();
    assert!(error.contains("compressed digest"));
    let diff_id_only = format!(r#"{{"manifestVersion":"v1.0.0","docs":[{{"path":"README.md","diffId":"{DIFF_ID}"}}]}}"#);
    assert!(Kitfile::from_resolved_json(diff_id_only.as_bytes()).is_err());
    let partial_diff_ids = format!(
        r#"{{"manifestVersion":"v1.0.0","model":{{"path":"model.gguf","digest":"{DIGEST}","diffId":"{DIFF_ID}"}},"docs":[{{"path":"README.md","digest":"{DIGEST}"}}]}}"#
    );
    let parsed = Kitfile::from_resolved_json(partial_diff_ids.as_bytes()).unwrap();
    assert!(parsed.warnings().iter().any(|warning| warning.contains("omit diffId")));

    let authoring = Kitfile::from_yaml("manifestVersion: v1.0.0\ndocs:\n  - path: README.md\n").unwrap();
    assert!(authoring.to_resolved_json().is_err());
}
#[test]
fn test_schema_contains_both_wire_identity_fields() {
    let schema = schemars::schema_for!(Kitfile);
    let json = serde_json::to_string(&schema).unwrap();
    assert!(json.contains("manifestVersion"));
    assert!(json.contains("diffId"));
    assert!(json.contains("mcpServers"));
}