# debhelper buildsystem for Rust crates
#
# SPDX-FileCopyrightText: 2022-2024  Jonas Smedegaard <dr@jones.dk>
# SPDX-FileCopyrightText: 2016  Josh Triplett <josh@joshtriplett.org>
# SPDX-FileCopyrightText: 2019-2021  Sylvestre Ledru <sylvestre@debian.org>
# SPDX-FileCopyrightText: 2018-2021  Ximin Luo <infinity0@debian.org>
#
# SPDX-License-Identifier: GPL-3-or-later and MIT
#
# This builds Debian rust crates to be installed into a system-level
# crate registry in /usr/share/cargo/registry containing crates that
# can be used and Build-Depended upon by other Debian packages.

package Debian::Debhelper::Buildsystem::rust;

use v5.36;
use strict;
use Cwd;
use Debian::Debhelper::Dh_Lib qw(
    complex_doit doit dpkg_architecture_value warning error escape_shell
    get_buildoption getpackages make_symlink_raw_target install_dir
    print_and_doit qx_cmd restore_file_on_clean tmpdir verbose_print );
use Dpkg::Control::Info;
use Dpkg::Deps;
use File::Spec;
use JSON::PP qw( decode_json );
use base 'Debian::Debhelper::Buildsystem';

use constant CARGO_SYSTEM_REGISTRY => '/usr/share/cargo/registry';

sub DESCRIPTION (@)
{
    "Rust";
}

sub DEFAULT_BUILD_DIRECTORY ( $this, @ )
{
    return $this->canonpath( $this->get_sourcepath("target") );
}

sub cargo_crates ( $root, $src, $default )
{
    my $manifest = decode_json qx_cmd(

        # bypass cargo wrapper: expects a local config, which is unneeded here
        qw(/usr/bin/cargo metadata --no-deps --format-version 1 --manifest-path),
        $src
    );
    my %crates;
    for my $key ( @{ $manifest->{packages} } ) {
        my %object = (
            cratename => join( '-', $key->{name}, $key->{version} ),
            cratespec => join( '_', $key->{name}, $key->{version} ),
            pkgid     => join( '@', $key->{name}, $key->{version} ),
        );
        $object{sourcepath} = File::Spec->abs2rel(
            $key->{manifest_path} =~ s{/Cargo\.toml$}{}r,
            $root,
        );
        $object{packagepath}
            = 'target/package/' . $object{cratename} . '.crate';
        $object{systempath}
            = CARGO_SYSTEM_REGISTRY . '/' . $object{cratename};

        # resolve crate from dh-cargo cratespec
        $crates{ $object{cratespec} } = \%object;

        # resolve topmost declared crate from package stems
        my $pkg_longstem = $object{cratename} =~ tr/_/-/r;
        $crates{$_} //= \%object
            for ( $pkg_longstem
            =~ /^((((([^+]+?)-[^+.-]+)?\.[^+.-]+)?\.[^+-]+)?[^+]*)?$/ );

        # resolve topmost declared crate from crate name
        $crates{_default} //= \%object
            if $key->{name} eq $default;
    }

    # resolve amount of local dependencies
    # TODO: use Graph to compute an always reliable order instead
    for my $key ( @{ $manifest->{packages} } ) {
        @{ $crates{ $key->{name} =~ tr/_/-/r }{dependencies} }
            = map { $_->{name} }
            grep  { exists $crates{ $_->{name} =~ tr/_/-/r } }
            @{ $key->{dependencies} };
    }
    return \%crates;
}

sub deb_host_rust_type ()
{
    my $value = qx_cmd qw( make --no-print-directory -s -E ),
        '$(eval include /usr/share/rustc/architecture.mk)$(eval dummy:)$(eval .SILENT:)$(info $(DEB_HOST_RUST_TYPE))';
    chomp $value;
    return $value;
}

sub check_auto_buildable ($this)
{
    if ( -f $this->get_sourcepath("Cargo.toml") ) {
        return 1;
    }
    return 0;
}

# fork of Debian::Debhelper::Buildsystem::doit_in_sourcedir()
# needed to work on individual members of a workspace crate
sub doit_in_somedir ( $this, $dir, @args )
{
    $this->_generic_doit_in_dir( $dir, \&print_and_doit, @args );
    return 1;
}

sub new ( $class, @params )
{
    my $this = $class->SUPER::new(@params);
    $this->prefer_out_of_source_building(@params);
    return $this;
}

sub pre_building_step ( $this, $step, @ )
{
    # Many files are coming from crates.io with incorrect timestamp
    # See https://github.com/rust-lang/crates.io/issues/3859
    complex_doit( "find . ! -newermt 'jan 01, 2000' -exec touch -d@"
            . $ENV{SOURCE_DATE_EPOCH}
            . " {} +" );

    $this->{cargo_command}  = "/usr/share/dh-rust/bin/cargo";
    $this->{cargo_home}     = Cwd::abs_path("debian/cargo_home");
    $this->{host_rust_type} = deb_host_rust_type;

    $ENV{'CARGO_PROFILE'} ||= get_buildoption("noopt") ? 'dev' : 'release';
SWITCH:
    for ( $ENV{'CARGO_PROFILE'} ) {
        if (/^(?:release|bench)$/) {
            $this->{cargo_profiledir} = 'release';
            last SWITCH;
        }
        if (/^(?:dev|test)$/) {
            $this->{cargo_profiledir} = 'debug';
            last SWITCH;
        }
        $this->{cargo_profiledir} = $ENV{'CARGO_PROFILE'};
    }

    my $control = Dpkg::Control::Info->new();

    my $source = $control->get_source();
    my $crate  = $source->{'X-Cargo-Crate'};
    if ( !$crate ) {
        $crate = $source->{Source};
        $crate =~ s/^ru[sz]t-//;
        $crate =~ s/-[0-9]+(\.[0-9]+)*$//;
    }
    $this->{crates} = cargo_crates(
        $this->{cwd}, $this->get_sourcepath("Cargo.toml"),
        $crate
    );

    $this->{libpkg}     = {};
    $this->{binpkg}     = {};
    $this->{featurepkg} = [];
    my @arch_packages = getpackages('arch');
    my $cratepkg_re
        = qr/^libru[sz]t-(?<stem>[^+]+?(?<fullversion>-[^+.-]+(?:\.[^+.]+){2})?)(?:\+(?<feature>.+))?-dev$/;
    foreach my $pkg ( getpackages() ) {
        if ( $pkg =~ /$cratepkg_re/ ) {
            if ( $+{feature} ) {
                push(
                    @{ $this->{featurepkg} },
                    {   name     => $pkg,
                        libcrate => $this->{crates}{ $+{stem} }
                    }
                );
                next;
            }
            $this->{libpkg}{$pkg}{name} = $pkg;
            my %fullnames;
            if ( $+{fullversion} ) {
                push @{ $this->{libpkg}{$pkg}{crates} },
                    $this->{crates}{ $+{stem} };
                $fullnames{"$+{stem}$+{fullversion}"} = undef;
            }
            deps_iterate(
                deps_parse(
                    deps_concat(
                        $control->get_pkg_by_name($pkg)->{'Provides'}
                    ),
                    virtual => 1
                ),
                sub {
                    $_[0]->{package} =~ /$cratepkg_re/;
                    if ( $+{fullversion}
                        and not exists $fullnames{"$+{stem}$+{fullversion}"} )
                    {
                        unless ( $this->{crates}{ $+{stem} } ) {
                            error(
                                "Failed to resolve crate \"$+{stem}\" in cargo metadata for virtual library package \"$pkg\"."
                            );
                        }
                        push @{ $this->{libpkg}{$pkg}{crates} },
                            $this->{crates}{ $+{stem} };
                        $fullnames{"$+{stem}$+{fullversion}"} = undef;
                    }
                    1;
                },
                )
                or error(
                "Failed to parse virtual crate library packages provided by package $pkg."
                );
        }
        elsif ( grep {$pkg} @arch_packages ) {
            deps_iterate(

                # TODO: parse as crate names (not package names)
                deps_parse(
                    deps_concat(
                        $control->get_pkg_by_name($pkg)
                            ->{'X-Cargo-Crates'}
                    )
                ),
                sub {
                    my $crate = $this->{crates}{ $_[0]->{package} };
                    if ($crate) {
                        if ( exists $crate->{binpkg} ) {
                            error(
                                "Crate $crate->{cratespec} is tied to multiple packages: $crate->{binpkg}{name} and $pkg."
                            );
                        }
                        push @{ $this->{binpkg}{$pkg}{crates} }, $crate;
                        $crate->{binpkg}{name} = $pkg;
                        verbose_print(
                            "[RUST] Binaries from crate $crate->{cratespec} tied with package $pkg"
                        );
                    }
                    else {
                        error(
                            "Failed to resolve crate \"$_[0]->{package}\" in cargo metadata for binary package \"$pkg\"."
                        );
                    }
                    1;
                },
                )
                or
                error("Failed to parse crates for binary package $pkg.");
            unless ( keys %{ $this->{binpkg} } ) {

               # fallback: use arch-specific package when name matches a crate
                my $crate = $this->{crates}{$pkg};
                if ( $crate and not $crate->{binpkg} ) {
                    push @{ $this->{binpkg}{$pkg}{crates} }, $crate;
                    $crate->{binpkg}{name} = $pkg;
                    verbose_print(
                        "[RUST] Binaries from crate $crate->{cratespec} tied with package $pkg"
                    );
                }
            }
        }
    }
    unless ( keys %{ $this->{libpkg} } or keys %{ $this->{binpkg} } ) {

        # default: tie a single arch-specific package with topmost crate
        if ( @arch_packages eq 1 ) {
            unless ( exists $this->{crates}{_default} ) {
                error(
                    "Failed to resolve a default crate from cargo metadata.");
            }
            push @{ $this->{binpkg}{ $arch_packages[0] }{crates} },
                $this->{crates}{_default};
            $this->{crates}{_default}{binpkg}{name} = $arch_packages[0];
        }
        else {
            error("Could not find any Cargo lib or bin packages to build.");
        }
    }
    foreach my $pkg ( values %{ $this->{libpkg} } ) {
        foreach my $crate ( @{ $pkg->{crates} } ) {
            if ( exists $crate->{libpkg} ) {
                error(
                    "Crate $crate->{cratespec} is tied to multiple packages: $crate->{libpkg}{name} and $pkg->{name}."
                );
            }
            verbose_print(
                "[RUST] Library from crate $crate->{cratespec} tied with package $pkg->{name}."
            );
            $crate->{libpkg} = $pkg;
        }
    }
    foreach my $pkg ( @{ $this->{featurepkg} } ) {
        unless ( exists $pkg->{libcrate}{libpkg} ) {
            error(
                "Found feature package $pkg->{name} but no matching lib package."
            );
        }
        push @{ $pkg->{libcrate}{libpkg}{featurepkg} }, $pkg->{name};
    }

    my $parallel = $this->get_parallel();
    $this->{j} = $parallel > 0 ? ["-j$parallel"] : [];

    $ENV{'CARGO_HOME'} ||= $this->{cargo_home};
    $ENV{'DEB_HOST_RUST_TYPE'} = $this->{host_rust_type};
    $ENV{'DEB_HOST_GNU_TYPE'}  = dpkg_architecture_value("DEB_HOST_GNU_TYPE");

    # remap non-registry crates
    $ENV{'RUSTFLAGS'} = join ' ',
        (
        map {
            (   "--remap-path-prefix",
                "$_->{cratespec}=$_->{systempath}",
            )
        } map { @{ $this->{libpkg}{$_}{crates} } }
            sort keys %{ $this->{libpkg} }
        ),
        ( $ENV{'RUSTFLAGS'} || '' );

    $this->SUPER::pre_building_step($step);
}

sub configure ( $this, @ )
{
    # use Cargo.lock if it exists, or debian/Cargo.lock if that also exists
    my $cargo_lock = $this->get_sourcepath('Cargo.lock');
    if ( -f $cargo_lock ) {
        restore_file_on_clean($cargo_lock);
        doit( qw(cp -f debian/Cargo.lock), $cargo_lock )
            if -f "debian/Cargo.lock";
        doit( qw(sed -i -e), '/^checksum / d', $cargo_lock );
    }
    my $registry_path
        = $this->_rel2rel( 'debian/cargo_registry', $this->get_sourcedir() );
    $this->doit_in_sourcedir(
        $this->{cargo_command}, "prepare-debian",
        $registry_path,
    );
    if ( -d 'debian/vendorlibs' ) {
        complex_doit(
            qw(find debian/cargo_registry -lname '../vendorlibs/*' -delete));

        # force to favor this crate over a dependency pulled from system
        complex_doit(
            qw(ln --symbolic --force --relative --target-directory=debian/cargo_registry debian/vendorlibs/*)
        );
    }
    $this->doit_in_sourcedir(qw(cargo update)) if -f $cargo_lock;
}

sub build ( $this, @params )
{
    # Compile the crate, if needed to build binaries.
    $this->doit_in_sourcedir( $this->{cargo_command}, "build", @params )
        if ( keys %{ $this->{binpkg} } );
}

sub test ( $this, @params )
{
    # Execute unit and integration tests.
    # This also checks that the thing compiles,
    # which might fail if e.g. the package
    # requires non-rust system dependencies.
    $this->doit_in_sourcedir( $this->{cargo_command}, "test", @params );
}

sub install ( $this, $destdir, @params )
{
    my @crates = map { @{ $_->{crates} } }
        sort values %{ $this->{libpkg} };
    my %pkgs = map { ( $_->{pkgid} =~ s/@.*//r ) => $_ } @crates;
    my @sorted;
    my $changes;
    my @cargo_extra_opts = ();
    do {
        $changes = 0;
    name: foreach my $name ( sort keys %pkgs ) {
            verbose_print "[RUST] considering $name...";
            foreach my $dep ( @{ $pkgs{$name}->{dependencies} } ) {
                next name if $pkgs{$dep} and not $name eq $dep;
            }
            verbose_print "[RUST] $name goes into build list.";
            push @sorted, $pkgs{$name};
            delete $pkgs{$name};
            $changes++;
            next name;
        }
    } while ( $changes > 0 );
    if ( scalar %pkgs ) {
        warning(    'Found dependency cycle among crates '
                . join( ', ', sort keys %pkgs )
                . '. Appending cargo option "--exclude-lockfile" '
                . '(supported since cargo 1.87).' );
        push @cargo_extra_opts, '--exclude-lockfile';
    }
    else {
        @crates = @sorted;
    }
    foreach my $crate (@crates) {
        my $target = tmpdir( $crate->{libpkg}{name} ) . $crate->{systempath};
        install_dir($target);
        $this->doit_in_somedir(
            $crate->{sourcepath},
            qw(cargo package --offline --allow-dirty --no-verify),
            @cargo_extra_opts,
            '--target-dir', cwd . '/target', '--package', $crate->{pkgid}
        );
        $this->doit_in_somedir(
            "$target/..",
            'tar',
            map( { ( '--exclude', $_ ) }
                qw(debian/* debian Cargo.toml.orig Cargo.lock COPYING* LICENSE*)
            ),
            '-xvf',
            $this->_rel2rel( $crate->{packagepath}, "$target/.." ),
            $crate->{cratename}
        );
        open( my $OUTFILE, ">", "$target/.cargo-checksum.json" );
        say $OUTFILE '{"package":"Could not get crate checksum","files":{}}';
        close $OUTFILE;

        # prevent an ftpmaster auto-reject regarding files with old dates.
        doit(
            "touch", "-d@" . $ENV{SOURCE_DATE_EPOCH},
            "$target/Cargo.toml"
        );

        # add crate to local registry, needed by some multi-crate workspaces
        # maybe related: <https://github.com/rust-lang/cargo/issues/10352>
        # force to favor this crate over a dependency pulled from system
        complex_doit(
            qw(ln --symbolic --force --relative --target-directory=debian/cargo_registry),
            $target
        );
    }
    foreach my $featurepkg ( @{ $this->{featurepkg} } ) {
        my $target = tmpdir( $featurepkg->{name} ) . "/usr/share/doc";
        install_dir($target);
        make_symlink_raw_target(
            $featurepkg->{libcrate}{libpkg}{name},
            "$target/$featurepkg->{name}"
        );
    }
    foreach my $crate (
        map { @{ $_->{crates} } }
        sort values %{ $this->{binpkg} }
        )
    {
        # Do the install
        my $destdir = $ENV{'DESTDIR'} || tmpdir( $crate->{binpkg}{name} );
        my @path_opts
            = $crate->{sourcepath} ne '.'
            ? ( '--path', $crate->{sourcepath} )
            : ();
        $this->doit_in_sourcedir(
            "env",
            "DEB_CARGO_CRATE=$crate->{cratespec}",
            "DEB_BUILDDIR=" . $this->get_buildpath(),
            "DESTDIR=$destdir",
            $this->{cargo_command}, "install", @path_opts, @params
        );

        # generate Built-Using fields
        complex_doit(
            "/usr/share/dh-rust/bin/dh-rust-built-using",
            join(
                '/', $this->get_buildpath(), $this->{host_rust_type},
                $this->{cargo_profiledir}
            ),
            $crate->{binpkg}{name}
        );
    }
}

sub clean ( $this, @params )
{
    doit(
        "touch", "--no-create", "-d@" . $ENV{SOURCE_DATE_EPOCH},
        ".cargo_vcs_info.json"
    );
    $this->doit_in_sourcedir( $this->{cargo_command}, "clean", @params );
    doit( "rm", "-rf", "debian/cargo_registry" );
}

1
