#!/usr/bin/env python3
#
# OBS Source Service to vendor all crates.io and dependencies for a
# Rust project locally.
#
# (C) 2019 SUSE LLC
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
# See http://www.gnu.org/licenses/gpl-2.0.html for full license text.
#
# The following code is a derivative work of the code from obs-service-go_modules,
# available at: https://github.com/openSUSE/obs-service-go_modules
"""\
OBS Source Service to vendor all crates.io and dependencies for a
Rust project locally, by calling:

cargo vendor  <path/to/project/vendor>

obs-service-cargo_vendor will a create vendor tarball, compressed with
the specified method (default to "gz"), containing the
vendor/ directory populated by cargo vendor.

See README.md for additional documentation.
"""

import logging
import argparse
import re
import tarfile
import os
import shutil

from pathlib import Path
from subprocess import check_output
from subprocess import CalledProcessError

service_name = "obs-service-cargo_vendor"

description = __doc__

logging.basicConfig(level=logging.DEBUG)
log = logging.getLogger(service_name)

DEFAULT_COMPRESSION = "gz"

parser = argparse.ArgumentParser(
    description=description, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--strategy", default="vendor")
parser.add_argument("--archive")
parser.add_argument("--outdir")
parser.add_argument("--compression")
args = parser.parse_args()

outdir = args.outdir


def get_archive_extension():
    if args.compression not in tarfile.TarFile.OPEN_METH:
        log.error(f"The specified compression mode is not supported: \"{args.compression}\"")
        exit(1)

    if args.compression == "tar":
        return "tar"

    return "tar." + (args.compression or DEFAULT_COMPRESSION)


archive_ext = get_archive_extension()
vendor_tarname = f"vendor.{archive_ext}"


def archive_autodetect():
    """ Find the most likely candidate file that contains a Rust project.
        For most Rust applications this will be app-x.y.z.tar[.<tar compression>].
        Use the name of the .spec file as the stem for the archive to detect.
    """
    log.info(f"Autodetecting archive since no archive param provided in _service")
    cwd = Path.cwd()
    # first .spec under cwd or None
    spec = next(reversed(sorted(Path(cwd).glob("*.spec"))), None)
    if not spec:
        log.error(f"Archive autodetection found no spec file under {cwd}")
        exit(1)
    else:
        spec_dir = spec.parent  # typically the same as cwd
        # highest sorted archive under spec_dir
        pattern = f"{spec.stem}*.{archive_ext}"
        archive = next(reversed(sorted(Path(spec_dir).glob(pattern))), None)
    if not archive:
        log.error(f"Archive autodetection found no matching archive under {cwd}")
        exit(1)
    else:
        log.info(f"Archive autodetected at {archive}")
        # Check that app.spec Version: directive value
        # is a substring of detected archive filename
        # Warn if there is disagreement between the versions.
        pattern = re.compile(r"^Version:\s+([\S]+)$", re.IGNORECASE)
        with spec.open(encoding="utf-8") as f:
            for line in f:
                versionmatch = pattern.match(line)
                if versionmatch:
                    version = versionmatch.groups(0)[0]
            if not version:
                log.warning(f"Version not found in {spec.name}")
            else:
                if not (version in archive.name):
                    log.warning(
                        f"Version {version} in {spec.name} does not match {archive.name}"
                    )
        return str(archive.name)  # return string not PosixPath


archive = args.archive or archive_autodetect()
log.info(f"Using archive {archive}")

basename = archive.replace(f".{archive_ext}", "")


def extract(filename, dir):
    if filename.endswith(f".{archive_ext}"):
        tar = tarfile.open(filename)
        tar.extractall(path=dir)
        tar.close()
    else:
        log.info(f"Unsupported archive file format for {filename}")
        exit(1)


def find_file(path, filename):
    for root, dirs, files in os.walk(path):
        if filename in files:
            return os.path.join(root, filename)


def run_cargo(runDirectory, command, argsList=[]):
    try:
        log.info(f"Running cargo {command} in directory: {runDirectory}")
        output = check_output(["cargo", command] + argsList, cwd=runDirectory).decode("utf-8").strip()
        if output:
            log.info(output)
        return True
    except CalledProcessError as e:
        error = e.output.decode("utf-8").strip()
        if error:
            log.info(error)
        return False


def cargo_vendor(appDirectory, argsList=[]):
    vendor_dir = os.path.join(appDirectory, "vendor")
    log.info(f"Vendoring Cargo.toml deps to {vendor_dir}")
    run_cargo(appDirectory, "vendor", argsList + ["--", vendor_dir])
    return vendor_dir


def main():
    log.info(f"Running OBS Source Service: {service_name}")

    log.info(f"Extracting {archive} to {outdir}")
    extract(archive, outdir)

    cargo_toml_path = find_file(outdir, "Cargo.toml")
    if cargo_toml_path:
        app_dir = os.path.dirname(cargo_toml_path)
        log.info(f"Detected Rust app directory: {app_dir}")
    else:
        log.error(f"No Rust app using Cargo.toml found under {outdir}")
        exit(1)

    if args.strategy == "vendor":
        vendor_dir = cargo_vendor(appDirectory=app_dir)
        vendor_tarfile = os.path.join(outdir, vendor_tarname)
        with tarfile.open(vendor_tarfile, "w:" + args.compression) as tar:
            tar.add(vendor_dir, arcname=("vendor"))

        # remove extracted Rust application source
        try:
            to_remove = os.path.join(outdir, basename)
            shutil.rmtree(to_remove)
        except FileNotFoundError:
            log.error(f"Could not remove directory not found {to_remove}")
    else:
        log.error(f"Not a valid strategy : \"{args.strategy}\"")
        exit(1)


if __name__ == "__main__":
    main()
