App-Ariza.git | lib/App/Ariza/ | Versions.rakumod


use Config::TOML;

use App::Ariza::Resources;

unit class App::Ariza::Versions;

has Str $.sqlcipher;
has Str $.rakudo-version;
has Str $.rakudo-revision;
has     $.warnings = ();
has IO::Path $.path;

my sub die-with-key(Str:D $key, Str:D $expected) {
    die "ariza: $key must be $expected";
}

my sub as-str(Str:D $key, $value --> Str) {
    die-with-key($key, 'a string') unless $value.defined && $value ~~ Str;
    $value.Str
}

my sub as-hash(Str:D $key, $value) {
    die-with-key($key, 'a table') unless $value.defined && $value ~~ Associative;
    $value
}

my sub parse-rakudo($value, %attrs, @warnings) {
    my %obj = as-hash('rakudo', $value);
    for %obj.kv -> $key, $v {
        next if $key.starts-with('//');
        given $key {
            when 'version'  { %attrs<rakudo-version>  = as-str('rakudo.version', $v); }
            when 'revision' { %attrs<rakudo-revision> = as-str('rakudo.revision', $v); }
            default {
                @warnings.push("unknown key 'rakudo.$key' in versions.toml (ignored)");
            }
        }
    }
}

#| Load the pin file. With no argument this is ariza's own shipped
#| `resources/versions.toml`, which is the only sensible source in
#| production; the argument exists for tests and for anyone wanting to
#| render against an alternate pin set.
method load(App::Ariza::Versions:U: IO() $path = resource('versions.toml') --> App::Ariza::Versions) {
    die "ariza: no versions file at $path" unless $path.f;

    my $content = do {
        CATCH { default { die "ariza: could not read $path: {.message}" } }
        $path.slurp;
    };

    # An empty document is valid TOML — an empty table — but Config::TOML
    # rejects it outright, which would report a perfectly legal (if
    # useless) pin file as malformed. Short-circuit before it gets there.
    my $data = $content.trim
        ?? do {
            CATCH { default { die "ariza: malformed TOML in $path: {.message}" } }
            from-toml($content);
        }
        !! {};

    die "ariza: $path must contain a TOML table"
        unless $data.defined && $data ~~ Associative;

    my @warnings;
    my %attrs;

    for $data.kv -> $key, $value {
        next if $key.starts-with('//');
        given $key {
            when 'sqlcipher' { %attrs<sqlcipher> = as-str($key, $value); }
            when 'rakudo'    { parse-rakudo($value, %attrs, @warnings); }
            default {
                @warnings.push("unknown key '$key' in versions.toml (ignored)");
            }
        }
    }

    App::Ariza::Versions.new(|%attrs, :$path, :warnings(@warnings.List));
}

#| The embedded Rakudo runtime's full identity, `version-revision` — e.g.
#| `2026.07-01`. Undefined `Str` if either half is missing, since half an
#| identity names nothing.
method rakudo-tag(--> Str) {
    return Str unless $!rakudo-version.defined && $!rakudo-revision.defined;
    "{$!rakudo-version}-{$!rakudo-revision}"
}

=begin pod

=head1 NAME

App::Ariza::Versions - the pinned component versions every ariza artefact is built against

=head1 SYNOPSIS

=begin code :lang<raku>

use App::Ariza::Versions;

my $v = App::Ariza::Versions.load;          # ariza's shipped resources/versions.toml

say $v.rakudo-version;                       # 2026.07   (bundled runtime)
say $v.rakudo-revision;                      # 01
say $v.rakudo-tag;                           # 2026.07-01
say $v.sqlcipher;                            # 4.14.0    (advisory — see below)

.note for $v.warnings;                       # unrecognised keys, if any

# An alternate pin set (tests, experiments):
my $alt = App::Ariza::Versions.load('/tmp/versions.toml'.IO);

=end code

=head1 DESCRIPTION

Everything ariza produces — a bundle, a generated installer, a CI
workflow — quotes version numbers of software ariza does not own: a
Rakudo release, a SQLCipher build. Those numbers live in exactly one
file, C<resources/versions.toml>, so that bumping a pin regenerates
every app's artefacts in lockstep and nothing can drift.

This class is that file, parsed. It holds no logic beyond parsing:
consumers ask it for a string and put the string where it belongs.

=head1 THE FILE

=begin code :lang<toml>

sqlcipher = "4.14.0"

[rakudo]
version  = "2026.07"
revision = "01"

=end code

=head2 The SQLCipher pin is advisory

C<sqlcipher> is the version ariza B<expects>, not one it can enforce. A
bundle's SQLCipher comes from the build machine's package manager
(L<App::Ariza::Native>) — there is no ariza-operated mirror, because
SQLCipher's ABI does not move often enough to justify release
infrastructure of its own — and a package manager ships what it ships.
So a staged library whose version differs from this pin B<warns> and the
build continues, and the manifest records the version that was actually
staged with the pin alongside it.

=head2 One flat pin, one nested

C<sqlcipher> and C<[rakudo]> are not shaped the same, and the shapes are
not interchangeable:

=item1 C<sqlcipher> is a flat pin — a single string, compared against
whatever the build machine's package manager actually staged. See above.

=item1 C<[rakudo]> is a table, because the runtime a bundle embeds needs
two coordinates, C<version> and C<revision>, not one. C<revision>
disambiguates rebuilds of the same upstream release — a repackaged
runtime, a patched MoarVM — without pretending upstream cut a new
version. C<rakudo-tag> joins the two as C<version-revision>.

=head2 Unknown keys warn; wrong types die

The house rule, shared with L<App::Ariza::Config> and
L<App::Shigur::Config>: an unrecognised key at any level is collected
into C<warnings> and parsing continues, so a pin file written for a
newer ariza still loads in an older one (and vice versa). A key whose
I<value> is the wrong type dies immediately, naming the dotted path and
the expected shape — C<"ariza: rakudo.version must be a string"> —
because a mistyped pin would otherwise be silently baked into an
artefact.

Keys beginning with C<//> are ignored entirely, without a warning. TOML
has real comments, so this convention is redundant here; it is kept
because every ariza config file behaves the same way, and a config
author should never have to remember which file format they are in.

=head1 METHODS

=head2 load(IO() $path = resource('versions.toml') --> App::Ariza::Versions)

Parse a pin file. The default is ariza's own shipped copy, located
through L<App::Ariza::Resources> so it works installed and from a
checkout alike.

Dies if the file is missing, unreadable, not valid TOML, or not a TOML
table — each with its own message naming the path, so an unreadable
file is never confused with a malformed one.

An empty (or whitespace-only) file is B<not> an error: an empty
document is a valid, empty TOML table, and every pin comes back
undefined. Config::TOML rejects it outright, so C<load> short-circuits
before handing it over rather than reporting a legal file as malformed.

=head2 warnings(--> List)

Human-readable strings naming every unrecognised key found while
loading. Empty when the file was clean.

=head2 path(--> IO::Path)

The file this instance was loaded from. Useful in error messages from
consumers ("pin X missing from {$v.path}").

=head2 sqlcipher

The flat top-level pin, as a string. Undefined C<Str> if absent from the
file.

=head2 rakudo-version / rakudo-revision / rakudo-tag

The C<[rakudo]> table's two keys, and the C<version-revision> join of
them. C<rakudo-tag> is the undefined C<Str> unless both halves are
present.

=head1 AUTHOR

Matt Doughty

=head1 COPYRIGHT AND LICENSE

Copyright 2026 Matt Doughty

This library is free software; you can redistribute it and/or modify it
under the Artistic License 2.0.

=end pod