numi-core 0.2.2

Core parsing, normalization, rendering, and output orchestration for Numi.
Documentation
use super::super::super::{WriteOutcome, compute_generation_fingerprint, generate};
use super::super::make_temp_dir;
use std::fs;

fn write_extensionless_l10n_job_config(config_path: &std::path::Path) {
    std::fs::write(
        config_path,
        r#"
version = 1

[jobs.l10n]
output = "Generated/L10n.swift"

[[jobs.l10n.inputs]]
type = "strings"
path = "Resources/Localization"

[jobs.l10n.template]
path = "Templates/l10n"
"#,
    )
    .expect("config should be written");
}

#[test]
fn generate_accepts_strings_with_escaped_apostrophes_via_langcodec() {
    let temp_dir = make_temp_dir("pipeline-strings-apostrophe");
    let config_path = temp_dir.join("swiftgen.toml");
    let localization_root = temp_dir.join("Resources/Localization/en.lproj");
    fs::create_dir_all(&localization_root).expect("localization dir should exist");
    fs::write(
        localization_root.join("Localizable.strings"),
        "\"invite.accept\" = \"Can\\'t accept the invitation\";\n",
    )
    .expect("strings file should be written");
    fs::write(
        &config_path,
        r#"
version = 1

[jobs.l10n]
output = "Generated/L10n.swift"

[[jobs.l10n.inputs]]
type = "strings"
path = "Resources/Localization"

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

    let report = generate(&config_path, None).expect("generation should succeed");
    let generated_path = temp_dir.join("Generated/L10n.swift");
    let generated = fs::read_to_string(&generated_path).expect("generated output should exist");

    assert!(report.warnings.is_empty());
    assert_eq!(
        generated,
        r#"import Foundation

internal enum L10n {
    internal enum Localizable {
        internal static let inviteAccept = tr("Localizable", "invite.accept")
    }
}

private func tr(_ table: String, _ key: String) -> String {
    NSLocalizedString(key, tableName: table, bundle: .main, value: "", comment: "")
}
"#
    );

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

#[test]
fn generate_renders_custom_template_includes_from_config_root() {
    let temp_dir = make_temp_dir("custom-template-shared-include");
    let config_path = temp_dir.join("numi.toml");
    let localization_root = temp_dir.join("Resources/Localization");
    let templates_dir = temp_dir.join("Templates");
    let generated_path = temp_dir.join("Generated/L10n.swift");

    fs::create_dir_all(localization_root.join("en.lproj")).expect("localization dir should exist");
    fs::create_dir_all(&templates_dir).expect("templates dir should exist");
    fs::create_dir_all(temp_dir.join("partials")).expect("shared partial dir should exist");

    fs::write(
        localization_root.join("en.lproj/Localizable.strings"),
        "\"profile.title\" = \"Profile\";\n",
    )
    .expect("strings file should be written");
    fs::write(
        templates_dir.join("main.jinja"),
        "{% include \"partials/header.jinja\" %}|{{ job.swiftIdentifier }}|{{ modules[0].name }}\n",
    )
    .expect("template should be written");
    fs::write(temp_dir.join("partials/header.jinja"), "SHARED")
        .expect("shared include should be written");
    fs::write(
        &config_path,
        r#"
version = 1

[jobs.l10n]
output = "Generated/L10n.swift"

[[jobs.l10n.inputs]]
type = "strings"
path = "Resources/Localization"

[jobs.l10n.template]
path = "Templates/main.jinja"
"#,
    )
    .expect("config should be written");

    let report = generate(&config_path, None).expect("generation should succeed");
    let rendered = fs::read_to_string(&generated_path).expect("output should be written");

    assert_eq!(report.jobs.len(), 1);
    assert_eq!(report.jobs[0].outcome, WriteOutcome::Created);
    assert_eq!(rendered, "SHARED|L10n|Localizable\n");

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

#[test]
fn generate_resolves_extensionless_template_path_to_jinja_file() {
    let temp_dir = make_temp_dir("pipeline-extensionless-template-path");
    let config_path = temp_dir.join("numi.toml");
    let localization_root = temp_dir.join("Resources/Localization/en.lproj");
    let template_path = temp_dir.join("Templates/l10n.jinja");
    let generated_path = temp_dir.join("Generated/L10n.swift");

    fs::create_dir_all(&localization_root).expect("localization dir should exist");
    fs::create_dir_all(
        template_path
            .parent()
            .expect("template path should have parent"),
    )
    .expect("template dir should exist");
    fs::write(
        localization_root.join("Localizable.strings"),
        "\"profile.title\" = \"Profile\";\n",
    )
    .expect("strings file should be written");
    fs::write(
        &template_path,
        "{{ job.swiftIdentifier }}|{{ modules[0].name }}\n",
    )
    .expect("template should be written");
    write_extensionless_l10n_job_config(&config_path);

    let report = generate(&config_path, None).expect("generation should succeed");
    let rendered = fs::read_to_string(&generated_path).expect("output should be written");

    assert_eq!(report.jobs.len(), 1);
    assert_eq!(report.jobs[0].outcome, WriteOutcome::Created);
    assert_eq!(rendered, "L10n|Localizable\n");

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

#[test]
fn generate_writes_builtin_files_accessors() {
    let temp_dir = make_temp_dir("pipeline-files-generate");
    let config_path = temp_dir.join("numi.toml");
    let files_root = temp_dir.join("Resources/Fixtures");
    let generated_path = temp_dir.join("Generated/Files.swift");

    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 = generate(&config_path, None).expect("generation should succeed");
    let rendered = fs::read_to_string(&generated_path).expect("output should be written");

    assert_eq!(report.jobs.len(), 1);
    assert_eq!(report.jobs[0].outcome, WriteOutcome::Created);
    assert_eq!(
        rendered,
        r#"import Foundation

internal enum Files {
    internal enum Onboarding {
        internal static let welcomeVideoMp4 = file("Onboarding/welcome-video.mp4")
    }
    internal static let faqPdf = file("faq.pdf")
}

private func resourceBundle() -> Bundle {
    Bundle.module
}

private func file(_ path: String) -> URL {
    guard let url = resourceBundle().url(forResource: path, withExtension: nil) else {
        fatalError("Missing file resource: \(path)")
    }
    return url
}
"#
    );

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

#[test]
fn generate_writes_objc_builtin_files_accessors() {
    let temp_dir = make_temp_dir("pipeline-objc-files-generate");
    let config_path = temp_dir.join("numi.toml");
    let files_root = temp_dir.join("Resources/Fixtures");
    let generated_path = temp_dir.join("Generated/Files.h");

    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.h"

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

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

    let report = generate(&config_path, None).expect("generation should succeed");
    let rendered = fs::read_to_string(&generated_path).expect("output should be written");

    assert_eq!(report.jobs.len(), 1);
    assert_eq!(report.jobs[0].outcome, WriteOutcome::Created);
    assert!(!rendered.contains("@implementation"));
    assert!(
        rendered.contains("NS_INLINE NSURL *Files__Fixtures__Onboarding__WelcomeVideoMp4(void)")
    );
    assert!(rendered.contains("SWIFTPM_MODULE_BUNDLE"));
    assert!(!rendered.contains("bundleForClass:"));

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

#[test]
fn generation_fingerprint_changes_when_builtin_language_changes() {
    let temp_dir = make_temp_dir("pipeline-fingerprint-builtin-language");
    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]
[jobs.files.template.builtin]
language = "swift"
name = "files"
"#,
    )
    .expect("config should be written");

    let loaded = numi_config::load_from_path(&config_path).expect("config should load");
    let config_dir = config_path.parent().expect("config should have parent");
    let selected_jobs = vec!["files".to_string()];
    let swift_jobs = numi_config::resolve_selected_jobs(&loaded.config, Some(&selected_jobs))
        .expect("files job should resolve");
    let swift_job = swift_jobs
        .into_iter()
        .next()
        .expect("files job should exist");
    let swift_fingerprint =
        compute_generation_fingerprint(config_dir, &loaded.config.defaults, swift_job)
            .expect("swift builtin fingerprint should compute");

    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 = "objc"
name = "files"
"#,
    )
    .expect("objc config should be written");

    let loaded = numi_config::load_from_path(&config_path).expect("objc config should load");
    let objc_jobs = numi_config::resolve_selected_jobs(&loaded.config, Some(&selected_jobs))
        .expect("files job should resolve");
    let objc_job = objc_jobs
        .into_iter()
        .next()
        .expect("files job should exist");
    let objc_fingerprint =
        compute_generation_fingerprint(config_dir, &loaded.config.defaults, objc_job)
            .expect("objc builtin fingerprint should compute");

    assert_ne!(swift_fingerprint.fingerprint, objc_fingerprint.fingerprint);

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