App-Ariza.git | t/ | 04-config.rakutest


use v6.d;
use Test;

use App::Ariza::Config;
use App::Ariza::Resources;

plan 14;

sub tmp-dir(--> IO::Path) {
    my $dir = $*TMPDIR.add("ariza-config-{$*PID}-{(^1_000_000).pick}");
    $dir.mkdir;
    $dir;
}

sub rm-rf(IO::Path $d) {
    return unless $d.d;
    .&rm-rf for $d.dir.grep(*.d);
    .unlink for $d.dir.grep(*.f);
    $d.rmdir;
}

sub write-config(IO::Path $dir, Str $content --> IO::Path) {
    my $path = $dir.add('ariza.toml');
    $path.spurt($content);
    $path;
}

my constant FULL = q:to/TOML/;
[app]
name = "App::ExampleApp"
exec = "exampleapp"
display = "Example App"

[bundle]
platforms = ["macos-arm64", "linux-x86_64-glibc", "windows-x86_64"]
native = ["notcurses", "sqlcipher"]
smoke = "{exec} --version"

[installer]
repo = "example-org/App-ExampleApp"
TOML

my constant MINIMAL = q:to/TOML/;
[app]
name = "App::Kelpie"
exec = "kelpie"
display = "Kelpie"
TOML

subtest 'the v1 schema, in full', {
    plan 9;
    my $dir = tmp-dir;
    LEAVE { rm-rf($dir) }
    write-config($dir, FULL);

    my $cfg = App::Ariza::Config.load($dir);
    is $cfg.app-name, 'App::ExampleApp', 'app.name is the dist name';
    is $cfg.app-exec, 'exampleapp', 'app.exec is the launcher name';
    is $cfg.app-display, 'Example App', 'app.display is the human name';
    is-deeply $cfg.bundle-platforms.List,
        ('macos-arm64', 'linux-x86_64-glibc', 'windows-x86_64'),
        'bundle.platforms keeps its order';
    is-deeply $cfg.bundle-native.List, ('notcurses', 'sqlcipher'),
        'bundle.native';
    is $cfg.bundle-smoke, '{exec} --version', 'bundle.smoke is stored raw';
    is $cfg.smoke-command, 'exampleapp --version', '{exec} expands to app.exec';
    is $cfg.installer-repo, 'example-org/App-ExampleApp',
        'installer.repo is where the generated installers download from';
    is-deeply $cfg.warnings.List, (), 'a clean manifest warns about nothing';
};

subtest 'App-ExampleApp ships the first real manifest', {
    plan 6;

    # App-ExampleApp is checked out beside App-Ariza on a development box;
    # skip rather than fail if this checkout is somewhere else entirely.
    my $repo = checkout-root().parent.add('App-ExampleApp');
    if $repo.add('ariza.toml').f {
        my $cfg = App::Ariza::Config.load($repo);
        is $cfg.app-name, 'App::ExampleApp', 'names the distribution';
        is $cfg.app-exec, 'exampleapp', 'names the launcher';
        is $cfg.app-display, 'Example App', 'names the product';
        is $cfg.smoke-command, 'exampleapp --version', 'has a usable smoke command';
        like $cfg.installer-repo, / ^ <-[/]>+ '/' <-[/]>+ $ /,
            'and names the repository its installers download from';
        is-deeply $cfg.warnings.List, (), 'and parses without warnings';
    } else {
        skip 'App-ExampleApp is not a sibling of this checkout', 6;
    }
};

subtest 'bundle.smoke: a string, a list, or argv arrays', {
    plan 7;
    my $dir = tmp-dir;
    LEAVE { rm-rf($dir) }

    my $one = App::Ariza::Config.load-file(write-config($dir, FULL));
    is-deeply $one.smoke-argvs, (('{exec}', '--version'),),
        'a string command is split on whitespace';

    my $many = App::Ariza::Config.load-file(write-config($dir, q:to/TOML/));
    [app]
    name = "A"
    exec = "k"
    display = "K"
    [bundle]
    smoke = ["{exec} --version", "{exec} --help"]
    TOML
    is +$many.smoke-argvs, 2, 'a list of strings is several commands';
    is $many.smoke-command, 'k --version',
        'and smoke-command shows the first one, for a human';

    # The shape that exists so a command can carry a whole program
    # without a shell-quoting layer to get wrong.
    my $argv = App::Ariza::Config.load-file(write-config($dir, q:to/TOML/));
    [app]
    name = "A"
    exec = "k"
    display = "K"
    [bundle]
    smoke = [
        ["{exec}", "--version"],
        ["{raku}", "-e", "say 'a b  c'", "{tmp}"],
    ]
    TOML
    is-deeply $argv.smoke-argvs[1],
        ('{raku}', '-e', "say 'a b  c'", '{tmp}'),
        'an argv array keeps spaces and quotes exactly as written';

    my @expanded = $argv.smoke-commands(:raku</b/raku>, :tmp("/a dir/tmp"));
    is-deeply @expanded[0], ('k', '--version'), '{exec} defaults to app.exec';
    is-deeply @expanded[1], ('/b/raku', '-e', "say 'a b  c'", '/a dir/tmp'),
        'and every other placeholder comes from the caller';

    # A hole in a smoke command does not fail — it runs something else.
    throws-like { $argv.smoke-commands(:raku</b/raku>) }, Exception,
        message => /'unknown placeholder'/,
        'an unsupplied placeholder is an error, not an empty string';
};

subtest 'bundle.smoke rejects shapes it cannot run', {
    plan 3;
    my $dir = tmp-dir;
    LEAVE { rm-rf($dir) }
    my $head = "[app]\nname = \"A\"\nexec = \"k\"\ndisplay = \"K\"\n[bundle]\n";

    throws-like {
        App::Ariza::Config.load-file(write-config($dir, $head ~ "smoke = 1\n"))
    }, Exception, message => /'bundle.smoke must be'/, 'a number';

    throws-like {
        App::Ariza::Config.load-file(write-config($dir, $head ~ "smoke = [[1, 2]]\n"))
    }, Exception, message => /'bundle.smoke must be'/, 'an argv of non-strings';

    throws-like {
        App::Ariza::Config.load-file(write-config($dir, $head ~ "smoke = [[]]\n"))
    }, Exception, message => /'empty command'/,
        'and an empty command, which would silently pass by doing nothing';
};

subtest '[bundle] is optional', {
    plan 8;
    my $dir = tmp-dir;
    LEAVE { rm-rf($dir) }
    write-config($dir, MINIMAL);

    my $cfg = App::Ariza::Config.load($dir);
    is $cfg.app-name, 'App::Kelpie', 'the app section still loads';
    is-deeply $cfg.bundle-platforms.List, (), 'platforms defaults to empty';
    is-deeply $cfg.bundle-native.List, (), 'native defaults to empty';
    is $cfg.smoke-command, Str, 'no smoke command is undefined, not empty';

    # Every [licensing] value is empty-but-defined for a manifest that
    # omits the table, so nothing downstream has to test for it — an app
    # that says nothing about licensing still gets a complete
    # THIRD-PARTY.md, built out of what ariza can read from the bundle.
    is $cfg.licensing-strict, False, 'licensing is not strict unless asked';
    is-deeply $cfg.licensing-app.Hash, {}, 'the app row defaults to nothing';
    is-deeply $cfg.licensing-third-party.List, (),
        'and so do the rows for what ariza cannot see';
    is-deeply $cfg.licensing-dists.List, (), 'and the dist corrections';
};

subtest 'unknown keys warn, they never die', {
    plan 4;
    my $dir = tmp-dir;
    LEAVE { rm-rf($dir) }

    my $path = write-config($dir, q:to/TOML/);
    [app]
    name = "App::Kelpie"
    exec = "kelpie"
    display = "Kelpie"
    icon = "kelpie.png"

    [bundle]
    signing = "developer-id"

    [notarize]
    team = "ABCD1234"
    TOML

    my $cfg = App::Ariza::Config.load-file($path);
    is $cfg.app-name, 'App::Kelpie', 'the recognised keys still load';
    is +$cfg.warnings, 3, 'each unknown key warned once';
    ok $cfg.warnings.first(/"'app.icon'"/), 'a nested unknown key is dotted';
    ok $cfg.warnings.first(/"'notarize'"/), 'an unknown top-level table is named';
};

subtest '//-prefixed keys are ignored silently', {
    plan 2;
    my $dir = tmp-dir;
    LEAVE { rm-rf($dir) }

    my $path = write-config($dir, q:to/TOML/);
    "// note" = "manifest for the nightly channel"

    [app]
    "// why" = "the launcher is not named after the dist"
    name = "App::Kelpie"
    exec = "kelpie"
    display = "Kelpie"
    TOML

    my $cfg = App::Ariza::Config.load-file($path);
    is-deeply $cfg.warnings.List, (), 'no warnings for comment keys';
    is $cfg.app-exec, 'kelpie', 'and the real keys are unaffected';
};

subtest 'wrong types die naming the key', {
    plan 5;
    my $dir = tmp-dir;
    LEAVE { rm-rf($dir) }

    throws-like {
        App::Ariza::Config.load-file(write-config($dir,
            "[app]\nname = 42\nexec = \"k\"\ndisplay = \"K\"\n"))
    }, Exception, message => /'app.name must be a string'/, 'app.name';

    throws-like {
        App::Ariza::Config.load-file(write-config($dir,
            "[app]\nname = \"A\"\nexec = \"k\"\ndisplay = \"K\"\n"
            ~ "[bundle]\nplatforms = \"macos-arm64\"\n"))
    }, Exception, message => /'bundle.platforms must be an array of strings'/,
        'a bare string where a list belongs';

    throws-like {
        App::Ariza::Config.load-file(write-config($dir,
            "[app]\nname = \"A\"\nexec = \"k\"\ndisplay = \"K\"\n"
            ~ "[bundle]\nnative = [1, 2]\n"))
    }, Exception, message => /'bundle.native must be an array of strings'/,
        'a list of the wrong element type';

    throws-like {
        App::Ariza::Config.load-file(write-config($dir,
            "[app]\nname = \"A\"\nexec = \"k\"\ndisplay = \"K\"\n"
            ~ "[bundle]\nsmoke = 1\n"))
    }, Exception, message => /'bundle.smoke must be a string'/, 'bundle.smoke';

    throws-like {
        App::Ariza::Config.load-file(write-config($dir, "app = \"App::Kelpie\"\n"))
    }, Exception, message => /'app must be a table'/, 'a scalar where a table belongs';
};

subtest 'an unknown platform slug is a hard error', {
    plan 3;
    my $dir = tmp-dir;
    LEAVE { rm-rf($dir) }

    my $typo = "[app]\nname = \"A\"\nexec = \"k\"\ndisplay = \"K\"\n"
             ~ "[bundle]\nplatforms = [\"macos-arm64\", \"macos-aarch64\"]\n";

    throws-like { App::Ariza::Config.load-file(write-config($dir, $typo)) },
        Exception, message => /'unknown platform' .* 'macos-aarch64'/,
        'the offending slug is named';
    throws-like { App::Ariza::Config.load-file(write-config($dir, $typo)) },
        Exception, message => /'macos-arm64'/,
        'and the supported set is listed, so the fix is obvious';

    # Contrast: an unknown *key* is forward-compatible and only warns.
    my $future = "[app]\nname = \"A\"\nexec = \"k\"\ndisplay = \"K\"\n"
               ~ "[bundle]\nchannels = [\"nightly\"]\n";
    lives-ok { App::Ariza::Config.load-file(write-config($dir, $future)) },
        'while an unknown key in the same table only warns';
};

subtest 'the [installer] table', {
    plan 5;
    my $dir = tmp-dir;
    LEAVE { rm-rf($dir) }
    my $head = "[app]\nname = \"A\"\nexec = \"k\"\ndisplay = \"K\"\n[installer]\n";

    is App::Ariza::Config.load-file(write-config($dir, MINIMAL)).installer-repo,
        Str, 'an app that publishes nowhere has no repo, not an empty string';

    # The value is interpolated straight into three release URLs, so its
    # shape is closed for the same reason a platform slug's is: a typo
    # cannot be a future feature, and the 404 lands on a stranger.
    throws-like {
        App::Ariza::Config.load-file(write-config($dir,
            $head ~ "repo = \"https://github.com/o/n\"\n"))
    }, Exception, message => /"must look like 'owner/name'"/,
        'a full URL is rejected';

    throws-like {
        App::Ariza::Config.load-file(write-config($dir, $head ~ "repo = \"justaname\"\n"))
    }, Exception, message => /"must look like 'owner/name'"/,
        'and so is a bare name';

    throws-like {
        App::Ariza::Config.load-file(write-config($dir, $head ~ "repo = 42\n"))
    }, Exception, message => /'installer.repo must be a string'/,
        'a wrong type dies naming the dotted path, as everywhere else';

    my $future = App::Ariza::Config.load-file(write-config($dir,
        $head ~ "repo = \"o/n\"\nchannel = \"nightly\"\n"));
    ok $future.warnings.first(/"'installer.channel'"/),
        'while an unknown key in the table only warns';
};

subtest 'the [updates] table', {
    plan 9;
    my $dir = tmp-dir;
    LEAVE { rm-rf($dir) }
    my $head = "[app]\nname = \"A\"\nexec = \"k\"\ndisplay = \"K\"\n";

    is App::Ariza::Config.load-file(write-config($dir, MINIMAL)).updates-enabled,
        False, 'updates are off when the table is omitted';
    is App::Ariza::Config.load-file(write-config($dir,
        $head ~ "[updates]\nenabled = false\n")).updates-enabled,
        False, 'and explicit false is off';

    # TOML preserves table order. This places [updates] before [installer]
    # to prove the cross-field requirement is checked only after every table
    # has been parsed.
    my $enabled = App::Ariza::Config.load-file(write-config($dir,
        $head ~ "[updates]\nenabled = true\n[installer]\nrepo = \"o/n\"\n"));
    ok $enabled.updates-enabled, 'true enables update-aware bundles';

    throws-like {
        App::Ariza::Config.load-file(write-config($dir,
            $head ~ "[updates]\nenabled = true\n"))
    }, Exception, message => /'updates.enabled requires installer.repo'/,
        'enabled updates require a release repository';

    throws-like {
        App::Ariza::Config.load-file(write-config($dir,
            $head ~ "[updates]\nenabled = \"yes\"\n"))
    }, Exception, message => /'updates.enabled must be true or false'/,
        'a string is not a boolean';
    throws-like {
        App::Ariza::Config.load-file(write-config($dir,
            $head ~ "[updates]\nenabled = 1\n"))
    }, Exception, message => /'updates.enabled must be true or false'/,
        'a number is not a boolean';

    my $future = App::Ariza::Config.load-file(write-config($dir,
        $head ~ "[installer]\nrepo = \"o/n\"\n[updates]\nenabled = true\n"
      ~ "cadence = 60\n"));
    ok $future.warnings.first(/"'updates.cadence'"/),
        'unknown dotted keys warn';

    my $comment = App::Ariza::Config.load-file(write-config($dir,
        $head ~ "[installer]\nrepo = \"o/n\"\n[updates]\n"
      ~ "\"// note\" = \"manual cadence is not configurable\"\n"
      ~ "enabled = true\n"));
    is-deeply $comment.warnings.List, (), 'comment keys remain silent';
    ok $comment.updates-enabled, 'a comment key does not affect enablement';
};

subtest 'installer.warm: what the installers run once, or nothing', {
    plan 9;
    my $dir = tmp-dir;
    LEAVE { rm-rf($dir) }
    my $head = "[app]\nname = \"A\"\nexec = \"k\"\ndisplay = \"K\"\n[installer]\n";

    sub warm-of(Str $tail --> List) {
        App::Ariza::Config.load-file(write-config($dir, $head ~ $tail)).warm-argv
    }

    is warm-of(''), ('--version',),
        'an app that says nothing gets --version: the one invocation every'
      ~ ' bundled app answers, and returns from';
    is warm-of("warm = true\n"), ('--version',),
        'and so does one that asks for the default out loud';
    is warm-of("warm = \"--check --quiet\"\n"), ('--check', '--quiet'),
        'a string is a command line, split on whitespace';
    is warm-of(qq{warm = ["--path", "with space"]\n}), ('--path', 'with space'),
        'an array is taken word for word, which is how an argument keeps its space';

    is warm-of("warm = false\n"), (),
        'false is no warm-up at all';
    nok App::Ariza::Config.load-file(write-config($dir, $head ~ "warm = false\n"))
        .warm-enabled, 'which warm-enabled says in one word';
    ok App::Ariza::Config.load-file(write-config($dir, MINIMAL)).warm-enabled,
        'while an app with no [installer] table at all still gets warmed';

    # Neither "the default" nor "off": running the launcher with no
    # arguments starts the application, and a full-screen application
    # does not return. Both intentions have a spelling; this is not one.
    throws-like {
        App::Ariza::Config.load-file(write-config($dir, $head ~ "warm = \"\"\n"))
    }, Exception, message => /'installer.warm is empty' .* 'false'/,
        'an empty string is refused, and told which spelling was meant';

    throws-like {
        App::Ariza::Config.load-file(write-config($dir, $head ~ "warm = [1, 2]\n"))
    }, Exception, message => /'installer.warm must be'/,
        'and a wrong type dies naming the dotted path, as everywhere else';
};

subtest 'missing files and missing required keys', {
    plan 6;
    my $dir = tmp-dir;
    LEAVE { rm-rf($dir) }

    throws-like { App::Ariza::Config.load($dir) }, Exception,
        message => /'no ariza.toml at'/, 'no manifest at all';

    throws-like { App::Ariza::Config.load-file(write-config($dir, "= = =\n")) },
        Exception, message => /'malformed TOML in'/, 'a file that is not TOML';

    throws-like { App::Ariza::Config.load-file(write-config($dir, '')) },
        Exception, message => /'app.name is required'/,
        'an empty manifest fails on its missing keys, not as malformed';

    throws-like {
        App::Ariza::Config.load-file(write-config($dir,
            "[app]\nexec = \"k\"\ndisplay = \"K\"\n"))
    }, Exception, message => /'app.name is required'/, 'app.name is required';

    throws-like {
        App::Ariza::Config.load-file(write-config($dir,
            "[app]\nname = \"A\"\ndisplay = \"K\"\n"))
    }, Exception, message => /'app.exec is required'/, 'app.exec is required';

    throws-like {
        App::Ariza::Config.load-file(write-config($dir,
            "[app]\nname = \"A\"\nexec = \"k\"\n"))
    }, Exception, message => /'app.display is required'/, 'app.display is required';
};

subtest 'the [licensing] table', {
    plan 10;
    my $dir = tmp-dir;
    LEAVE { rm-rf($dir) }

    my $path = write-config($dir, q:to/TOML/);
    [app]
    name = "App::Kelpie"
    exec = "kelpie"
    display = "Kelpie"

    [licensing]
    strict = true
    unheard-of = "value"

    [licensing.app]
    copyright = "Copyright 2026 A Person"
    made-up = "value"

    [[licensing.third-party]]
    name = "Inter"
    version = "4.0"
    spdx-license = "OFL-1.1"
    license-files = ["licenses/OFL-1.1.txt"]
    files = ["resources/fonts/Inter-*.ttf"]

    [[licensing.dists]]
    name = "Some::Module"
    spdx-license = "Artistic-2.0"
    TOML

    my $cfg = App::Ariza::Config.load-file($path);
    is $cfg.licensing-strict, True, 'strict is read as a boolean';
    is $cfg.licensing-app<copyright>, 'Copyright 2026 A Person',
        'the app can say what it is not derivable from its META6';
    is +$cfg.licensing-third-party, 1, 'one declared third-party row';
    is $cfg.licensing-third-party[0]<name>, 'Inter', 'with its name';
    is-deeply $cfg.licensing-third-party[0]<license-files>.List,
        ('licenses/OFL-1.1.txt',),
        'and the paths in its own repository the texts live at';
    is $cfg.licensing-dists[0]<name>, 'Some::Module',
        'a dist correction names the distribution it corrects';

    # The house rule, unchanged: an unrecognised key at any level is a
    # warning, so one manifest can serve several ariza versions.
    is +$cfg.warnings, 2, 'unknown keys warn rather than dying';
    ok $cfg.warnings.first(/"'licensing.app.made-up'"/),
        'naming the dotted path inside a row table';

    # A wrong type is a different thing entirely: it cannot be a future
    # feature, it is a mistake that would ship a document with a hole.
    throws-like {
        App::Ariza::Config.load-file(write-config($dir.add('two').&{ .mkdir; $_ },
            "[app]\nname = \"A\"\nexec = \"a\"\ndisplay = \"A\"\n"
          ~ "\n[licensing]\nstrict = \"yes\"\n"))
    }, Exception, message => /'licensing.strict must be true or false'/,
        'a strict flag that is not a boolean dies naming the key';

    # A row without a licence is not a row: it is the absence this whole
    # feature exists to make impossible.
    throws-like {
        App::Ariza::Config.load-file(write-config($dir.add('three').&{ .mkdir; $_ },
            "[app]\nname = \"A\"\nexec = \"a\"\ndisplay = \"A\"\n"
          ~ "\n[[licensing.third-party]]\nname = \"Inter\"\n"))
    }, Exception, message => /'licensing.third-party.spdx-license is required'/,
        'and a declared component with no licence dies naming what is missing';
};