numi-core 0.2.4

Core parsing, normalization, rendering, and output orchestration for Numi.
Documentation
use super::super::{
    GENERATION_FINGERPRINT_SCHEMA_VERSION, GenerationFingerprintRecord,
    GenerationTemplateFingerprintRecord, dump_context, sort_entries_for_assets,
    swiftgen_file_sort_key,
};
use super::{entry, make_temp_dir, seed_cached_parse, write_xcstrings_job_config};
use crate::{
    parse_cache::{CacheKind, CachedParseData},
    parse_l10n::LocalizationTable,
};
use blake3::Hasher;
use camino::Utf8PathBuf;
use numi_ir::{EntryKind, Metadata, ModuleKind, RawEntry};
use serde_json::{Value, json};
use std::{
    fs,
    path::{Path, PathBuf},
};

fn cache_record_path(kind: CacheKind, input_path: &Path) -> PathBuf {
    let canonical = fs::canonicalize(input_path).expect("input path should canonicalize");
    let mut hasher = Hasher::new();
    hasher.update(
        match kind {
            CacheKind::Xcassets => "xcassets",
            CacheKind::Strings => "strings",
            CacheKind::Xcstrings => "xcstrings",
            CacheKind::Files => "files",
        }
        .as_bytes(),
    );
    hasher.update(b"\0");
    hasher.update(canonical.as_os_str().as_encoded_bytes());

    std::env::temp_dir()
        .join("numi-cache")
        .join("parsed-v1")
        .join(format!("{}.json", hasher.finalize().to_hex()))
}

#[test]
fn file_sort_keys_match_case_insensitive_name_ordering() {
    let sibling_names = [
        "YouTubePlayer.html",
        "youtube_embed.html",
        "backgroundMusic.mp3",
        "backHome.mp3",
        "miniSlot",
        "Spy",
        "greedy_drawing.mp3",
        "greedy_drawing_end.MP3",
        "jackpot_select.mp3",
        "jackpot_select_luxury.mp3",
        "play_center_list_item_new_tag.svga",
        "play_center_list_item_new_tag_ar.svga",
    ]
    .into_iter()
    .map(str::to_string)
    .collect::<Vec<_>>();

    let mut ordered = sibling_names
        .iter()
        .map(|name| (name.as_str(), swiftgen_file_sort_key(name, &sibling_names)))
        .collect::<Vec<_>>();
    ordered.sort_by(|left, right| left.1.cmp(&right.1).then_with(|| left.0.cmp(right.0)));

    assert_eq!(
        ordered
            .into_iter()
            .map(|(name, _)| name)
            .collect::<Vec<_>>(),
        vec![
            "backgroundMusic.mp3",
            "backHome.mp3",
            "greedy_drawing.mp3",
            "greedy_drawing_end.MP3",
            "jackpot_select.mp3",
            "jackpot_select_luxury.mp3",
            "miniSlot",
            "play_center_list_item_new_tag.svga",
            "play_center_list_item_new_tag_ar.svga",
            "Spy",
            "youtube_embed.html",
            "YouTubePlayer.html",
        ]
    );
}

#[test]
fn assets_sort_only_moves_nine_patch_before_base() {
    let mut entries = vec![
        entry("bet_bubble tips_up", EntryKind::Image),
        entry("bet_bubble_tips", EntryKind::Image),
        entry("bet_bubble_tips_down", EntryKind::Image),
        entry("room_task_list_bg", EntryKind::Image),
        entry("room_task_list_bg.9", EntryKind::Image),
    ];

    sort_entries_for_assets(&mut entries);

    let ids = entries
        .iter()
        .map(|entry| entry.id.as_str())
        .collect::<Vec<_>>();
    assert_eq!(
        ids,
        vec![
            "bet_bubble tips_up",
            "bet_bubble_tips",
            "bet_bubble_tips_down",
            "room_task_list_bg.9",
            "room_task_list_bg",
        ]
    );
}

#[test]
fn builtin_template_fingerprint_record_includes_language_and_name() {
    let record = GenerationFingerprintRecord {
        schema_version: GENERATION_FINGERPRINT_SCHEMA_VERSION,
        job_name: "assets".to_string(),
        output: "Generated/Assets.swift".to_string(),
        access_level: "internal".to_string(),
        bundle_mode: "module".to_string(),
        bundle_identifier: None,
        variables: Default::default(),
        inputs: Vec::new(),
        template: GenerationTemplateFingerprintRecord::Builtin {
            language: "objc".to_string(),
            name: "assets".to_string(),
            fingerprint: "fingerprint".to_string(),
        },
    };

    let serialized = serde_json::to_value(&record).expect("record should serialize");

    assert_eq!(serialized["template"]["kind"], "Builtin");
    assert_eq!(serialized["template"]["language"], "objc");
    assert_eq!(serialized["template"]["name"], "assets");
    assert_eq!(serialized["template"]["fingerprint"], "fingerprint");
    assert_eq!(serialized["variables"], json!({}));
}

#[test]
fn dump_context_builds_files_module_surface() {
    let temp_dir = make_temp_dir("pipeline-files-context");
    let config_path = temp_dir.join("numi.toml");
    let files_root = temp_dir.join("Resources/Fixtures");

    fs::create_dir_all(files_root.join("Onboarding")).expect("files directory should exist");
    fs::write(files_root.join("faq.pdf"), "faq").expect("faq file should be written");
    fs::write(files_root.join("Onboarding/welcome-video.mp4"), "video")
        .expect("video file should be written");
    fs::write(
        &config_path,
        r#"
version = 1

[jobs.files]
output = "Generated/Files.swift"

[[jobs.files.inputs]]
type = "files"
path = "Resources/Fixtures"

[jobs.files.template]
[jobs.files.template.builtin]
language = "swift"
name = "files"
"#,
    )
    .expect("config should be written");

    let report = dump_context(&config_path, "files").expect("dump context should succeed");
    let json: Value = serde_json::from_str(&report.json).expect("json should parse");

    assert_eq!(json["modules"][0]["kind"], "files");
    assert_eq!(json["modules"][0]["name"], "Fixtures");
    assert_eq!(json["modules"][0]["entries"][0]["kind"], "namespace");
    assert_eq!(
        json["modules"][0]["entries"][0]["children"][0]["properties"]["relativePath"],
        "Onboarding/welcome-video.mp4"
    );
    assert_eq!(json["modules"][0]["entries"][1]["kind"], "data");
    assert_eq!(
        json["modules"][0]["entries"][1]["properties"]["fileName"],
        "faq.pdf"
    );

    fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
}

#[test]
fn dump_context_includes_job_variables() {
    let temp_dir = make_temp_dir("pipeline-context-job-variables");
    let config_path = temp_dir.join("numi.toml");
    let files_root = temp_dir.join("Resources/Fixtures");

    fs::create_dir_all(&files_root).expect("files directory should exist");
    fs::write(files_root.join("faq.pdf"), "faq").expect("faq file should be written");
    fs::write(
        &config_path,
        r#"
version = 1

[jobs.files]
output = "Generated/Files.swift"

[[jobs.files.inputs]]
type = "files"
path = "Resources/Fixtures"

[jobs.files.template]
path = "Templates/files.jinja"

[jobs.files.variables]
enum_name = "AppFiles"
imports = ["Foundation"]

[jobs.files.variables.options]
bundle_accessor = "Bundle.module"
"#,
    )
    .expect("config should be written");

    let report = dump_context(&config_path, "files").expect("dump context should succeed");
    let json: Value = serde_json::from_str(&report.json).expect("json should parse");

    assert_eq!(json["variables"]["enum_name"], "AppFiles");
    assert_eq!(
        json["variables"]["imports"],
        serde_json::json!(["Foundation"])
    );
    assert_eq!(
        json["variables"]["options"],
        serde_json::json!({"bundle_accessor": "Bundle.module"})
    );

    fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
}

#[test]
fn dump_context_uses_cached_xcstrings_parse_and_keeps_json_stable() {
    let temp_dir = make_temp_dir("pipeline-xcstrings-context-cache-hit");
    let config_path = temp_dir.join("numi.toml");
    let localization_root = temp_dir.join("Resources/Localization");
    let xcstrings_path = localization_root.join("Localizable.xcstrings");

    fs::create_dir_all(&localization_root).expect("localization directory should exist");
    fs::write(
            &xcstrings_path,
            r#"{"version":"1.0","sourceLanguage":"en","strings":{"profile.title":{"localizations":{"en":{"stringUnit":{"state":"translated","value":"Profile"}}}}}}"#,
        )
        .expect("xcstrings file should be written");
    write_xcstrings_job_config(&config_path);

    let cached_source = Utf8PathBuf::from_path_buf(xcstrings_path.clone())
        .expect("cached source path should be utf8");
    let cached_tables = vec![LocalizationTable {
        table_name: "Localizable".to_string(),
        source_path: cached_source.clone(),
        module_kind: ModuleKind::Xcstrings,
        entries: vec![RawEntry {
            path: "cached.banner".to_string(),
            source_path: cached_source,
            kind: EntryKind::StringKey,
            properties: Metadata::from([
                ("key".to_string(), json!("cached.banner")),
                ("translation".to_string(), json!("Cached banner")),
            ]),
        }],
        warnings: Vec::new(),
    }];
    seed_cached_parse(
        CacheKind::Xcstrings,
        &localization_root,
        CachedParseData::Xcstrings(cached_tables),
    )
    .expect("xcstrings cache should be seeded");

    let first = dump_context(&config_path, "l10n").expect("first dump should succeed");
    let second = dump_context(&config_path, "l10n").expect("second dump should succeed");
    let json: Value = serde_json::from_str(&first.json).expect("json should parse");

    assert_eq!(first.json, second.json);
    assert_eq!(json["modules"][0]["kind"], "xcstrings");
    assert_eq!(
        json["modules"][0]["entries"][0]["properties"]["key"],
        "cached.banner"
    );

    fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
}

#[test]
fn dump_context_degrades_when_cached_record_is_invalid() {
    let temp_dir = make_temp_dir("pipeline-cache-degrade-dump-context");
    let config_path = temp_dir.join("numi.toml");
    let localization_root = temp_dir.join("Resources/Localization");
    let xcstrings_path = localization_root.join("Localizable.xcstrings");

    fs::create_dir_all(&localization_root).expect("localization directory should exist");
    fs::write(
            &xcstrings_path,
            r#"{"version":"1.0","sourceLanguage":"en","strings":{"profile.title":{"localizations":{"en":{"stringUnit":{"state":"translated","value":"Profile"}}}}}}"#,
        )
        .expect("xcstrings file should be written");
    write_xcstrings_job_config(&config_path);

    let cache_path = cache_record_path(CacheKind::Xcstrings, &localization_root);
    fs::create_dir_all(
        cache_path
            .parent()
            .expect("cache path should have a parent directory"),
    )
    .expect("cache directory should exist");
    fs::write(&cache_path, "not-json").expect("invalid cache record should be written");

    let report = dump_context(&config_path, "l10n")
        .expect("dump context should succeed with invalid cache record");
    let json: Value = serde_json::from_str(&report.json).expect("json should parse");

    assert_eq!(json["modules"][0]["kind"], "xcstrings");
    assert_eq!(
        json["modules"][0]["entries"][0]["properties"]["key"],
        "profile.title"
    );

    fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
}