#!/usr/bin/env python3
# Copyright 2022 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

import argparse
import os
import sys
import json
import subprocess
import tempfile
from dataclasses import dataclass
from typing import List


@dataclass
class Fragment:
    """PlaSA Fragment metadata.

    All fields in this data class (save for the "cts_path" field) is pulled
    directly from the PlaSA manifest file.

    "cts_path" points to the location of this fragment file on the host
    machine.
    """
    file: str
    kind: str
    path: str
    dest: str
    cts_path: str


@dataclass
class Match:
    """Data class used to match corresponding fragments between manifest files.

    This also stores match result information in the form of an error message
    and a compatibility status bit.
    """
    before: Fragment
    after: Fragment
    message: str
    is_compatible: bool

    def get_message(self):
        """Small helper method to make the output more useful info for errors.

        Returns:
            A string representing the Fragment key and match error message.
        """
        return '{}\n{}'.format(self.before.path, self.message)


@dataclass
class PlasaDiffer:
    """Class that actually performs the PlaSA manifest diffing operation.

    Throws an exception if a compatibility issue is detected, or some other
    processing error occurs.
    """
    before_manifest: str
    after_manifest: str
    kinds: List[str]
    utils_dir: str

    def __post_init__(self):
        if not self.before_manifest:
            raise ('before manifest must not be empty.')
        if not self.after_manifest:
            raise ('after manifest must not be empty.')
        if self.kinds == None or len(self.kinds) == 0:
            raise ValueError('kinds list cannot be None or empty.')
        if not os.path.exists(self.utils_dir):
            raise ValueError('utils_dir path cannot be empty and must exist.')

    def load_manifest(self, manifest):
        """Load the PlaSA Manifest JSON file into a dictionary.

        The 'path' field of each fragment acts as a unique key, and
        allow us to map to the same fragment in other PlaSA manifest files.

        Returns:
            A dict mapping the fragment 'path' field to the fragment object.
        """
        fragments_root = os.path.dirname(manifest)
        d = {}
        with open(manifest) as f:
            data = json.load(f)
            for entry in data:
                # We may want to run this script in separate processes to allow
                # parallel verification. Filter out fragment types that we
                # don't care about in this run.
                if entry['kind'] in self.kinds:
                    cts_path = os.path.join(fragments_root, entry['path'])
                    if not os.path.exists(cts_path):
                        raise ValueError(
                            'Fragment path {} does not exist'.format(cts_path))
                    entry['cts_path'] = cts_path
                    d[entry['path']] = Fragment(**entry)
        return d

    def diff_fragments(self, before, after):
        """Diff two fragment files against each other.

        Returns:
            A string message containing any useful output of the diff tool.
            A boolean value: True if the two fragment interfaces are compatible.
        """
        if before.kind != after.kind:
            return 'Left fragment kind ({}) does not match Right fragment kind ({}).'.format(
                before.kind, after.kind), False

        if before.kind == 'api_fidl':
            return self._diff_fidl(before, after)
        else:
            return 'Diffing for fragments of type {} is not yet supported.'.format(
                before.kind), False

    def _diff_fidl(self, before, after):
        """Diff two api_fidl fragment files against each other.

        The above method calls this method to diff FIDL fragment files.
        """

        tool = os.path.join(self.utils_dir, 'fidl_api_diff')
        out_file = tempfile.NamedTemporaryFile()
        out_file.close()
        args = [
            tool,
            '--before-file',
            before.cts_path,
            '--after-file',
            after.cts_path,
            '--api-diff-file',
            out_file.name,
        ]

        try:
            p = subprocess.run(args, check=True, capture_output=True, text=True)
            # TODO(jcecil): Make fidl_api_diff throw an exception
            # when API breaking changes are detected.
            with open(out_file.name, 'r') as result:
                data = result.read()
                result.close()
            if 'APIBreaking' in data:
                return data, False
            return data, True

        except subprocess.CalledProcessError as e:
            message = 'Return Code {}'.format(e.returncode)
            if e.output:
                message += "\n{}".format(e.output)
            if e.stdout:
                message += "\n{}".format(e.stdout)
            if e.stderr:
                message += "\n{}".format(e.stderr)
            return message, False

    def run(self):
        """Diff two PlaSA manifest files.

        Raises:
            RuntimeError if incompatible changes are detected.
        """

        before_fragments = self.load_manifest(self.before_manifest)
        after_fragments = self.load_manifest(self.after_manifest)

        matches = {}

        # Match fragments from the before and after PlaSA manifest files,
        # and diff them using the provided tools.
        # Delete them from the dictionaries so we can detect discrepancies.
        for key, before in list(before_fragments.items()):
            del before_fragments[key]
            is_compatible = False
            message = ""

            try:
                after = after_fragments[key]
                del after_fragments[key]
                message, is_compatible = self.diff_fragments(before, after)

            except KeyError:
                after = None
                message = "PlaSA element was deleted."
                is_compatible = False

            finally:
                matches[key] = Match(before, after, message, is_compatible)

        # PlaSA elements that are newly introduced in the after manifest
        # are identified and captured here.
        for key, after in list(after_fragments.items()):
            del after_fragments[key]
            before = None
            message = "PlaSA element was added."

            # TODO(jcecil): Consider flagging new additions to the PlaSA
            # manifest here, to ensure they have sufficient CTS test coverage.
            is_compatible = True

            matches[key] = Match(before, after, message, is_compatible)

        issues = ""
        found_issue = False
        for m in matches.values():
            if not m.is_compatible:
                issues = '{}\n-----\n{}'.format(issues, m.get_message())
                found_issue = True

        if found_issue:
            raise RuntimeError(
                'Found compatibility issue(s):\n\n{}'.format(issues))


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument(
        '--before_manifest',
        help='Path to the old PlaSA manifest file, from the CTS release.',
        required=True)
    parser.add_argument(
        '--after_manifest',
        help='Path to the new PlaSA manifest file, from an SDK release.',
        required=True)
    parser.add_argument(
        '--kinds',
        nargs='+',
        help='Type of PlaSA Fragments to diff.',
        required=True)
    parser.add_argument(
        '--utils_dir',
        help='Path to directory holding all the diffing tools.',
        required=True)
    args = parser.parse_args()

    pd = PlasaDiffer(**vars(args))
    pd.run()


if __name__ == "__main__":
    sys.exit(main())

#!/usr/bin/env python3
# Copyright 2022 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

import argparse
import os
import sys
import json
import subprocess
import tempfile
from dataclasses import dataclass
from typing import List


@dataclass
class Fragment:
    """PlaSA Fragment metadata.

    All fields in this data class (save for the "cts_path" field) is pulled
    directly from the PlaSA manifest file.

    "cts_path" points to the location of this fragment file on the host
    machine.
    """
    file: str
    kind: str
    path: str
    dest: str
    cts_path: str


@dataclass
class Match:
    """Data class used to match corresponding fragments between manifest files.

    This also stores match result information in the form of an error message
    and a compatibility status bit.
    """
    before: Fragment
    after: Fragment
    message: str
    is_compatible: bool

    def get_message(self):
        """Small helper method to make the output more useful info for errors.

        Returns:
            A string representing the Fragment key and match error message.
        """
        return '{}\n{}'.format(self.before.path, self.message)


@dataclass
class PlasaDiffer:
    """Class that actually performs the PlaSA manifest diffing operation.

    Throws an exception if a compatibility issue is detected, or some other
    processing error occurs.
    """
    before_manifest: str
    after_manifest: str
    kinds: List[str]
    utils_dir: str

    def __post_init__(self):
        if not self.before_manifest:
            raise ('before manifest must not be empty.')
        if not self.after_manifest:
            raise ('after manifest must not be empty.')
        if self.kinds == None or len(self.kinds) == 0:
            raise ValueError('kinds list cannot be None or empty.')
        if not os.path.exists(self.utils_dir):
            raise ValueError('utils_dir path cannot be empty and must exist.')

    def load_manifest(self, manifest):
        """Load the PlaSA Manifest JSON file into a dictionary.

        The 'path' field of each fragment acts as a unique key, and
        allow us to map to the same fragment in other PlaSA manifest files.

        Returns:
            A dict mapping the fragment 'path' field to the fragment object.
        """
        fragments_root = os.path.dirname(manifest)
        d = {}
        with open(manifest) as f:
            data = json.load(f)
            for entry in data:
                # We may want to run this script in separate processes to allow
                # parallel verification. Filter out fragment types that we
                # don't care about in this run.
                if entry['kind'] in self.kinds:
                    cts_path = os.path.join(fragments_root, entry['path'])
                    if not os.path.exists(cts_path):
                        raise ValueError(
                            'Fragment path {} does not exist'.format(cts_path))
                    entry['cts_path'] = cts_path
                    d[entry['path']] = Fragment(**entry)
        return d

    def diff_fragments(self, before, after):
        """Diff two fragment files against each other.

        Returns:
            A string message containing any useful output of the diff tool.
            A boolean value: True if the two fragment interfaces are compatible.
        """
        if before.kind != after.kind:
            return 'Left fragment kind ({}) does not match Right fragment kind ({}).'.format(
                before.kind, after.kind), False

        if before.kind == 'api_fidl':
            return self._diff_fidl(before, after)
        else:
            return 'Diffing for fragments of type {} is not yet supported.'.format(
                before.kind), False

    def _diff_fidl(self, before, after):
        """Diff two api_fidl fragment files against each other.

        The above method calls this method to diff FIDL fragment files.
        """

        tool = os.path.join(self.utils_dir, 'fidl_api_diff')
        out_file = tempfile.NamedTemporaryFile()
        out_file.close()
        args = [
            tool,
            '--before-file',
            before.cts_path,
            '--after-file',
            after.cts_path,
            '--api-diff-file',
            out_file.name,
        ]

        try:
            p = subprocess.run(args, check=True, capture_output=True, text=True)
            # TODO(jcecil): Make fidl_api_diff throw an exception
            # when API breaking changes are detected.
            with open(out_file.name, 'r') as result:
                data = result.read()
                result.close()
            if 'APIBreaking' in data:
                return data, False
            return data, True

        except subprocess.CalledProcessError as e:
            message = 'Return Code {}'.format(e.returncode)
            if e.output:
                message += "\n{}".format(e.output)
            if e.stdout:
                message += "\n{}".format(e.stdout)
            if e.stderr:
                message += "\n{}".format(e.stderr)
            return message, False

    def run(self):
        """Diff two PlaSA manifest files.

        Raises:
            RuntimeError if incompatible changes are detected.
        """

        before_fragments = self.load_manifest(self.before_manifest)
        after_fragments = self.load_manifest(self.after_manifest)

        matches = {}

        # Match fragments from the before and after PlaSA manifest files,
        # and diff them using the provided tools.
        # Delete them from the dictionaries so we can detect discrepancies.
        for key, before in list(before_fragments.items()):
            del before_fragments[key]
            is_compatible = False
            message = ""

            try:
                after = after_fragments[key]
                del after_fragments[key]
                message, is_compatible = self.diff_fragments(before, after)

            except KeyError:
                after = None
                message = "PlaSA element was deleted."
                is_compatible = False

            finally:
                matches[key] = Match(before, after, message, is_compatible)

        # PlaSA elements that are newly introduced in the after manifest
        # are identified and captured here.
        for key, after in list(after_fragments.items()):
            del after_fragments[key]
            before = None
            message = "PlaSA element was added."

            # TODO(jcecil): Consider flagging new additions to the PlaSA
            # manifest here, to ensure they have sufficient CTS test coverage.
            is_compatible = True

            matches[key] = Match(before, after, message, is_compatible)

        issues = ""
        found_issue = False
        for m in matches.values():
            if not m.is_compatible:
                issues = '{}\n-----\n{}'.format(issues, m.get_message())
                found_issue = True

        if found_issue:
            raise RuntimeError(
                'Found compatibility issue(s):\n\n{}'.format(issues))


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument(
        '--before_manifest',
        help='Path to the old PlaSA manifest file, from the CTS release.',
        required=True)
    parser.add_argument(
        '--after_manifest',
        help='Path to the new PlaSA manifest file, from an SDK release.',
        required=True)
    parser.add_argument(
        '--kinds',
        nargs='+',
        help='Type of PlaSA Fragments to diff.',
        required=True)
    parser.add_argument(
        '--utils_dir',
        help='Path to directory holding all the diffing tools.',
        required=True)
    args = parser.parse_args()

    pd = PlasaDiffer(**vars(args))
    pd.run()


if __name__ == "__main__":
    sys.exit(main())

#!/usr/bin/env python3 # Copyright 2022 The Fuchsia Authors. All rights reserved. # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. PlaSA Fragment metadata.      All fields in this data class (save for the "cts_path" field) is pulled     directly from the PlaSA manifest file.      "cts_path" points to the location of this fragment file on the host     machine. Data class used to match corresponding fragments between manifest files.      This also stores match result information in the form of an error message     and a compatibility status bit. Small helper method to make the output more useful info for errors.          Returns:             A string representing the Fragment key and match error message. Class that actually performs the PlaSA manifest diffing operation.      Throws an exception if a compatibility issue is detected, or some other     processing error occurs. Load the PlaSA Manifest JSON file into a dictionary.          The 'path' field of each fragment acts as a unique key, and         allow us to map to the same fragment in other PlaSA manifest files.          Returns:             A dict mapping the fragment 'path' field to the fragment object. # We may want to run this script in separate processes to allow # parallel verification. Filter out fragment types that we # don't care about in this run. Diff two fragment files against each other.          Returns:             A string message containing any useful output of the diff tool.             A boolean value: True if the two fragment interfaces are compatible. Diff two api_fidl fragment files against each other.          The above method calls this method to diff FIDL fragment files. # TODO(jcecil): Make fidl_api_diff throw an exception # when API breaking changes are detected. Diff two PlaSA manifest files.          Raises:             RuntimeError if incompatible changes are detected. # Match fragments from the before and after PlaSA manifest files, # and diff them using the provided tools. # Delete them from the dictionaries so we can detect discrepancies. # PlaSA elements that are newly introduced in the after manifest # are identified and captured here. # TODO(jcecil): Consider flagging new additions to the PlaSA # manifest here, to ensure they have sufficient CTS test coverage.