[fidldev] Move fidldev from fuchsia.git

Also fixes tests, and updates paths in the README

Change-Id: Ia98c7fa04979e6f899ac055c1516f7dba49ed4a9
Reviewed-on: https://fuchsia-review.googlesource.com/c/fidl-misc/+/392739
Reviewed-by: Mitchell Kember <mkember@google.com>
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..01589f0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+*.pyc
+__pycache__
+
diff --git a/fidldev/OWNERS b/fidldev/OWNERS
new file mode 100644
index 0000000..aefdd75
--- /dev/null
+++ b/fidldev/OWNERS
@@ -0,0 +1,11 @@
+apang@google.com
+bprosnitz@google.com
+fcz@google.com
+godtamit@google.com
+ianloic@google.com
+mkember@google.com
+pascallouis@google.com
+peterjohnston@google.com
+yifeit@google.com
+
+# COMPONENT: FIDL
diff --git a/fidldev/README.md b/fidldev/README.md
new file mode 100644
index 0000000..03e6bcf
--- /dev/null
+++ b/fidldev/README.md
@@ -0,0 +1,25 @@
+# fidldev
+
+`fidldev` is a FIDL development workflow tool. Its goal is to automate
+repetitive processes while working on FIDL code, like running tests based on
+changed files, and regenerating golden files. It is also meant to be the
+source of truth for FIDL code locations and tests/regen commands.
+
+## Running fidldev:
+
+    $FIDLMISC_DIR/fidldev/fidldev.py --help
+
+This can be aliased for convenienced:
+
+    alias fidldev=$FIDLMISC_DIR/fidldev/fidldev.py
+
+Note that `fidldev` should be run from a valid repo (e.g. fuchsia.git, topaz,
+or third_party/go) in order to correctly detect changes, similar to
+`fx format-code`.
+
+## Testing fidldev:
+
+    python3 $FIDLMISC_DIR/fidldev/fidldev_test.py -b
+
+The `-b` flag will separate stdout output when printing test results. It can
+be removed when using debugging print statements in the test itself.
diff --git a/fidldev/env.py b/fidldev/env.py
new file mode 100644
index 0000000..f34ecfc
--- /dev/null
+++ b/fidldev/env.py
@@ -0,0 +1,24 @@
+"""
+Constants that come from the environment. These are in a separate module so
+that they can be mocked by tests if necessary.
+"""
+import os
+from pathlib import Path
+import sys
+
+FUCHSIA_DIR = Path(os.environ["FUCHSIA_DIR"])
+assert FUCHSIA_DIR.exists()
+
+with open(FUCHSIA_DIR / ".fx-build-dir") as f:
+    BUILD_DIR = f.read().strip()
+
+if sys.platform.startswith('linux'):
+    PLATFORM = 'linux'
+elif sys.platform == 'darwin':
+    PLATFORM = 'mac'
+else:
+    print("Unsupported platform: " + sys.platform)
+    sys.exit(1)
+
+with open(FUCHSIA_DIR / (BUILD_DIR + '.zircon') / 'args.gn') as f:
+    MODE = 'asan' if 'asan' in f.read() else 'clang'
diff --git a/fidldev/fidldev.py b/fidldev/fidldev.py
new file mode 100755
index 0000000..3bd6c06
--- /dev/null
+++ b/fidldev/fidldev.py
@@ -0,0 +1,166 @@
+#!/usr/bin/env python3
+
+import argparse
+import collections
+import enum
+import subprocess
+import sys
+
+import regen
+import test_
+import util
+
+EXAMPLES = """
+Examples:
+
+Regen goldens and checked in bindings based on changed files in the current
+repo
+
+    fidldev regen
+
+Explicitly specify regen scripts:
+
+    fidldev regen fidlc fidlgen_dart
+    fidldev regen all
+
+Check which regen commands should be run:
+
+    fidldev regen --dry-run --no-build
+
+Run tests based on changed files in the current repo:
+
+    fidldev test
+
+Explicitly specify tests:
+
+    fidldev test fidlc hlcpp llcpp c
+
+Interactively filter test targets:
+
+    fidldev test --interactive
+
+Check which tests should be run:
+
+    fidldev test --dry-run --no-build --no-regen
+
+Pass flags to invocations of fx test:
+
+    fidldev test --fx-test-args "-v -o --dry"
+
+"""
+
+
+def test(args):
+    success = True
+    if args.targets:
+        if not args.no_regen:
+            util.print_warning(
+                'explicit test targets provided, skipping regen...')
+        success = test_.test_explicit(
+            args.targets, not args.no_build, args.dry_run, args.interactive,
+            args.fx_test_args)
+    else:
+        changed_files = util.get_changed_files()
+        if not args.no_regen:
+            regen.regen_changed(changed_files, not args.no_build, args.dry_run)
+            changed_files = util.get_changed_files()
+        success = test_.test_changed(
+            changed_files, not args.no_build, args.dry_run, args.interactive,
+            args.fx_test_args)
+        if args.dry_run:
+            print_dryrun_warning()
+    if not success:
+        sys.exit(1)
+
+
+def regen_cmd(args):
+    if args.targets:
+        regen.regen_explicit(args.targets, not args.no_build, args.dry_run)
+    else:
+        changed_files = util.get_changed_files()
+        regen.regen_changed(changed_files, not args.no_build, args.dry_run)
+        if args.dry_run:
+            print_dryrun_warning()
+
+
+def print_dryrun_warning():
+    print(
+        'NOTE: dry run is conservative and assumes that regen will '
+        'always change files. If goldens do not change during an actual '
+        'run, fewer tests/regen scripts may be run.')
+
+
+parser = argparse.ArgumentParser(
+    description="FIDL development workflow tool",
+    formatter_class=argparse.RawDescriptionHelpFormatter,
+    epilog=EXAMPLES)
+subparsers = parser.add_subparsers()
+
+test_parser = subparsers.add_parser("test", help="Test your FIDL changes")
+test_parser.set_defaults(func=test)
+test_targets = [name for (name, _) in test_.TEST_GROUPS] + ['all']
+test_parser.add_argument(
+    'targets',
+    metavar='target',
+    nargs='*',
+    help=
+    "Manually specify targets to regen, where a target is one of {}. Omit positional arguments to test based on changed files"
+    .format(test_targets))
+test_parser.add_argument(
+    "--dry-run",
+    "-n",
+    help="Print out test commands without running",
+    action="store_true",
+)
+test_parser.add_argument(
+    "--no-build",
+    "-b",
+    help="Don't rebuild targets used for testing",
+    action="store_true",
+)
+test_parser.add_argument(
+    "--no-regen",
+    "-r",
+    help="Don't regen goldens before running tests",
+    action="store_true",
+)
+test_parser.add_argument(
+    "--interactive",
+    "-i",
+    help="Interactively filter tests to be run",
+    action="store_true",
+)
+test_parser.add_argument(
+    "--fx-test-args",
+    "-t",
+    help=
+    "Extra flags and arguments to pass to any invocations of fx test. The flag value is passed verbatim. By default, only '-v' is used.",
+    default='-v',
+)
+
+regen_parser = subparsers.add_parser("regen", help="Run regen commands")
+regen_parser.set_defaults(func=regen_cmd)
+regen_targets = [name for (name, _) in regen.REGEN_TARGETS] + ['all']
+regen_parser.add_argument(
+    'targets',
+    metavar='target',
+    nargs='*',
+    help=
+    "Manually specify targets to regen, where a target is one of {}. Omit positional arguments to regen based on changed files"
+    .format(regen_targets))
+regen_parser.add_argument(
+    "--dry-run",
+    "-n",
+    help="Print out commands without running them",
+    action="store_true",
+)
+regen_parser.add_argument(
+    "--no-build",
+    "-b",
+    help="Don't rebuild targets used for regen",
+    action="store_true",
+)
+
+if __name__ == '__main__':
+    args = parser.parse_args()
+    args.func(args)
diff --git a/fidldev/fidldev_test.py b/fidldev/fidldev_test.py
new file mode 100644
index 0000000..0dd557a
--- /dev/null
+++ b/fidldev/fidldev_test.py
@@ -0,0 +1,414 @@
+import itertools
+import os
+import unittest
+
+from test_util import get_commands, MOCK_FUCHSIA_DIR, MOCK_BUILD_DIR
+import regen
+import util
+
+
+# Many mocks contain 3 return values, because the regen script can call
+# get_changed_files up to 3 times:
+#   1. get the initial set of changed files
+#   2. get changed files after fidlc regen, to check if fidlgen needs regen
+#   3. get changed files after fidlgen regen, to check if go needs regen
+class TestFidlDevRegen(unittest.TestCase):
+
+    def test_basic_regen(self):
+        mocks = {
+            'get_changed_files':
+                itertools.repeat(['zircon/tools/fidl/lib/flat_ast.cc']),
+        }
+        command = ['regen']
+        expected = [
+            util.BUILD_FIDLC,
+            regen.path_to_regen_command(regen.FIDLC_REGEN),
+        ]
+        self.assertListEqual(get_commands(mocks, command), expected)
+
+    def test_ir_changed(self):
+        mocks = {
+            'get_changed_files':
+                [
+                    ['zircon/tools/fidl/lib/parser.cc'],
+                    [
+                        'zircon/tools/fidl/lib/parser.cc',
+                        'zircon/tools/fidl/goldens/bits.test.json.golden'
+                    ],
+                    [
+                        'zircon/tools/fidl/lib/parser.cc',
+                        'zircon/tools/fidl/goldens/bits.test.json.golden'
+                    ],
+                ]
+        }
+        command = ['regen']
+        expected = [
+            util.BUILD_FIDLC,
+            regen.path_to_regen_command(regen.FIDLC_REGEN), util.BUILD_FIDLGEN,
+            regen.path_to_regen_command(regen.FIDLGEN_REGEN),
+            util.BUILD_FIDLGEN_DART,
+            regen.path_to_regen_command(regen.FIDLGEN_DART_REGEN)
+        ]
+        self.assertListEqual(get_commands(mocks, command), expected)
+
+    def test_tables_changed(self):
+        mocks = {
+            'get_changed_files':
+                [
+                    ['zircon/tools/fidl/lib/parser.cc'],
+                    [
+                        'zircon/tools/fidl/lib/parser.cc',
+                        'zircon/tools/fidl/goldens/bits.test.tables.c.golden'
+                    ],
+                    [
+                        'zircon/tools/fidl/lib/parser.cc',
+                        'zircon/tools/fidl/goldens/bits.test.tables.c.golden'
+                    ],
+                ]
+        }
+        command = ['regen']
+        expected = [
+            util.BUILD_FIDLC,
+            regen.path_to_regen_command(regen.FIDLC_REGEN),
+        ]
+        self.assertListEqual(get_commands(mocks, command), expected)
+
+    def test_go_goldens_changed(self):
+        mocks = {
+            'get_changed_files':
+                [
+                    ['zircon/tools/fidl/lib/parser.cc'],
+                    [
+                        'zircon/tools/fidl/lib/parser.cc',
+                        'zircon/tools/fidl/goldens/bits.test.json.golden'
+                    ],
+                    [
+                        'zircon/tools/fidl/lib/parser.cc',
+                        'zircon/tools/fidl/goldens/bits.test.json.golden',
+                        'garnet/go/src/fidl/compiler/backend/goldens/union.test.json.go.golden'
+                    ],
+                ]
+        }
+        command = ['regen']
+        expected = [
+            util.BUILD_FIDLC,
+            regen.path_to_regen_command(regen.FIDLC_REGEN),
+            util.BUILD_FIDLGEN,
+            regen.path_to_regen_command(regen.FIDLGEN_REGEN),
+            util.BUILD_FIDLGEN_DART,
+            regen.path_to_regen_command(regen.FIDLGEN_DART_REGEN),
+            regen.path_to_regen_command(regen.GO_BINDINGS_REGEN),
+        ]
+        self.assertListEqual(get_commands(mocks, command), expected)
+
+    def test_rust_goldens_changed(self):
+        mocks = {
+            'get_changed_files':
+                [
+                    ['zircon/tools/fidl/lib/parser.cc'],
+                    [
+                        'zircon/tools/fidl/lib/parser.cc',
+                        'zircon/tools/fidl/goldens/bits.test.json.golden'
+                    ],
+                    [
+                        'zircon/tools/fidl/lib/parser.cc',
+                        'zircon/tools/fidl/goldens/bits.test.json.golden',
+                        'garnet/go/src/fidl/compiler/backend/goldens/union.test.json.rs.golden'
+                    ],
+                ]
+        }
+        command = ['regen']
+        expected = [
+            util.BUILD_FIDLC,
+            regen.path_to_regen_command(regen.FIDLC_REGEN),
+            util.BUILD_FIDLGEN,
+            regen.path_to_regen_command(regen.FIDLGEN_REGEN),
+            util.BUILD_FIDLGEN_DART,
+            regen.path_to_regen_command(regen.FIDLGEN_DART_REGEN),
+        ]
+        self.assertListEqual(get_commands(mocks, command), expected)
+
+    def test_fidlgen_go_changed(self):
+        mocks = {
+            'get_changed_files':
+                itertools.repeat(['tools/fidl/fidlgen_go/ir/ir.go'])
+        }
+        command = ['regen']
+        expected = [
+            util.BUILD_FIDLGEN,
+            regen.path_to_regen_command(regen.FIDLGEN_REGEN),
+            regen.path_to_regen_command(regen.GO_BINDINGS_REGEN),
+        ]
+        self.assertListEqual(get_commands(mocks, command), expected)
+
+    def test_fidlgen_changed(self):
+        mocks = {
+            'get_changed_files':
+                itertools.repeat(
+                    ['tools/fidl/fidlgen_syzkaller/templates/struct.tmpl.go'])
+        }
+        command = ['regen']
+        expected = [
+            util.BUILD_FIDLGEN,
+            regen.path_to_regen_command(regen.FIDLGEN_REGEN),
+        ]
+        self.assertListEqual(get_commands(mocks, command), expected)
+
+    def test_fidlgen_dart_changed(self):
+        mocks = {
+            'get_changed_files':
+                itertools.repeat(['topaz/bin/fidlgen_dart/fidlgen_dart.go'])
+        }
+        command = ['regen']
+        expected = [
+            util.BUILD_FIDLGEN_DART,
+            regen.path_to_regen_command(regen.FIDLGEN_DART_REGEN),
+        ]
+        self.assertListEqual(get_commands(mocks, command), expected)
+
+    def test_regen_all(self):
+        command = ['regen', 'all']
+        expected = [
+            util.BUILD_FIDLC,
+            regen.path_to_regen_command(regen.FIDLC_REGEN),
+            util.BUILD_FIDLGEN,
+            regen.path_to_regen_command(regen.FIDLGEN_REGEN),
+            util.BUILD_FIDLGEN_DART,
+            regen.path_to_regen_command(regen.FIDLGEN_DART_REGEN),
+            regen.path_to_regen_command(regen.GO_BINDINGS_REGEN),
+        ]
+        self.assertListEqual(get_commands({}, command), expected)
+
+    def test_regen_no_build(self):
+        command = ['regen', 'all', '--no-build']
+        expected = [
+            regen.path_to_regen_command(regen.FIDLC_REGEN),
+            regen.path_to_regen_command(regen.FIDLGEN_REGEN),
+            regen.path_to_regen_command(regen.FIDLGEN_DART_REGEN),
+            regen.path_to_regen_command(regen.GO_BINDINGS_REGEN),
+        ]
+        self.assertListEqual(get_commands({}, command), expected)
+
+    def test_regen_fidlgen(self):
+        command = ['regen', 'fidlgen']
+        expected = [
+            util.BUILD_FIDLGEN,
+            regen.path_to_regen_command(regen.FIDLGEN_REGEN),
+        ]
+        self.assertListEqual(get_commands({}, command), expected)
+
+
+class TestFidlDevTest(unittest.TestCase):
+
+    def test_no_changes(self):
+        mocks = {'get_changed_files': [[]]}
+        command = ['test', '--no-regen']
+        self.assertListEqual(get_commands(mocks, command), [])
+
+    def test_fidlc_changed(self):
+        mocks = {'get_changed_files': [['zircon/tools/fidl/lib/parser.cc']]}
+        command = ['test', '--no-regen']
+        expected = [util.BUILD_FIDLC_TESTS, util.TEST_FIDLC]
+        self.assertListEqual(get_commands(mocks, command), expected)
+
+    def test_ir_changed_zircon(self):
+        mocks = {
+            'get_changed_files':
+                [['zircon/tools/fidl/goldens/bits.test.json.golden']]
+        }
+        command = ['test', '--no-regen']
+        expected = [util.BUILD_FIDLC_TESTS, util.TEST_FIDLC]
+        self.assertListEqual(get_commands(mocks, command), expected)
+
+    def test_ir_changed(self):
+        mocks = {
+            'get_changed_files':
+                [
+                    [
+                        'garnet/go/src/fidl/compiler/backend/goldens/struct.test.json',
+                    ]
+                ]
+        }
+        command = ['test', '--no-regen']
+        actual = get_commands(mocks, command)
+        expected = set(util.FIDLGEN_TEST_TARGETS)
+        self.assertEqual(len(actual), 2)
+        self.assertEqual(actual[0], util.BUILD_FIDLGEN)
+        self.assertTestsRun(actual[1], expected)
+
+    def test_fidlgen_util_changed(self):
+        mocks = {
+            'get_changed_files':
+                [[
+                    'garnet/go/src/fidl/compiler/backend/types/types.go',
+                ]]
+        }
+        command = ['test', '--no-regen']
+        actual = get_commands(mocks, command)
+        expected = set(util.FIDLGEN_TEST_TARGETS)
+        self.assertEqual(len(actual), 2)
+        self.assertEqual(actual[0], util.BUILD_FIDLGEN)
+        self.assertTestsRun(actual[1], expected)
+
+    def test_fidlgen_backend_changed(self):
+        mocks = {
+            'get_changed_files':
+                [[
+                    'tools/fidl/fidlgen_rust/templates/enum.tmpl.go',
+                ]]
+        }
+        command = ['test', '--no-regen']
+        actual = get_commands(mocks, command)
+        expected = set(util.FIDLGEN_TEST_TARGETS)
+        self.assertEqual(len(actual), 2)
+        self.assertEqual(actual[0], util.BUILD_FIDLGEN)
+        self.assertTestsRun(actual[1], expected)
+
+    def test_fidlgen_golden_changed(self):
+        mocks = {
+            'get_changed_files':
+                [
+                    [
+                        'garnet/go/src/fidl/compiler/backend/goldens/union.test.json.cc.golden',
+                    ]
+                ]
+        }
+        command = ['test', '--no-regen']
+        actual = get_commands(mocks, command)
+        expected = set(util.FIDLGEN_TEST_TARGETS) | {util.HLCPP_TEST_TARGET}
+        self.assertEqual(len(actual), 2)
+        self.assertEqual(actual[0], util.BUILD_FIDLGEN)
+        self.assertTestsRun(actual[1], expected)
+
+    def test_fidlgen_dart_changed(self):
+        mocks = {
+            'get_changed_files': [[
+                'topaz/bin/fidlgen_dart/backend/ir/ir.go',
+            ]]
+        }
+        command = ['test', '--no-regen']
+        actual = get_commands(mocks, command)
+        expected = {util.FIDLGEN_DART_TEST_TARGET}
+        self.assertEqual(len(actual), 2)
+        self.assertEqual(actual[0], util.BUILD_FIDLGEN_DART)
+        self.assertTestsRun(actual[1], expected)
+
+    def test_fidlgen_dart_golden_changed(self):
+        mocks = {
+            'get_changed_files':
+                [
+                    [
+                        'topaz/bin/fidlgen_dart/goldens/handles.test.json_async.dart.golden',
+                    ]
+                ]
+        }
+        command = ['test', '--no-regen']
+        actual = get_commands(mocks, command)
+        expected = {
+            util.DART_TEST_TARGET,
+            util.FIDLGEN_DART_TEST_TARGET,
+        }
+        self.assertEqual(len(actual), 2)
+        self.assertEqual(actual[0], util.BUILD_FIDLGEN_DART)
+        self.assertTestsRun(actual[1], expected)
+
+    def test_c_runtime_changed(self):
+        mocks = {
+            'get_changed_files': [[
+                'zircon/system/ulib/fidl/txn_header.c',
+            ]]
+        }
+        command = ['test', '--no-regen']
+        actual = get_commands(mocks, command)
+        expected = {
+            util.HLCPP_TEST_TARGET,
+            util.LLCPP_TEST_TARGET,
+            util.C_TEST_TARGET,
+        }
+        self.assertEqual(len(actual), 1)
+        self.assertTestsRun(actual[0], expected)
+
+    def test_coding_tables_changed(self):
+        mocks = {
+            'get_changed_files':
+                [[
+                    'zircon/tools/fidl/goldens/union.test.tables.c.golden',
+                ]]
+        }
+        command = ['test', '--no-regen']
+        actual = get_commands(mocks, command)
+        expected = {
+            util.HLCPP_TEST_TARGET,
+            util.LLCPP_TEST_TARGET,
+            util.C_TEST_TARGET,
+        }
+        self.assertEqual(actual[1], util.TEST_FIDLC)
+        self.assertTestsRun(actual[2], expected)
+
+    def test_go_runtime_changed(self):
+        mocks = {
+            'get_changed_files':
+                [[
+                    'third_party/go/src/syscall/zx/fidl/encoding_new.go',
+                ]]
+        }
+        command = ['test', '--no-regen']
+        actual = get_commands(mocks, command)
+        expected = {
+            util.GO_CONFORMANCE_TEST_TARGET,
+            util.GO_TEST_TARGET,
+        }
+        self.assertEqual(len(actual), 1)
+        self.assertTestsRun(actual[0], expected)
+
+    def test_dart_runtime_changed(self):
+        mocks = {
+            'get_changed_files':
+                [
+                    [
+                        'topaz/public/dart/fidl/lib/src/types.dart',
+                        'topaz/public/dart/fidl/lib/src/message.dart',
+                    ]
+                ]
+        }
+        command = ['test', '--no-regen']
+        actual = get_commands(mocks, command)
+        expected = {util.DART_TEST_TARGET}
+        self.assertEqual(len(actual), 1)
+        self.assertTestsRun(actual[0], expected)
+
+    def test_gidl_changed(self):
+        mocks = {
+            'get_changed_files':
+                [
+                    [
+                        'tools/fidl/gidl/main.go ',
+                        'tools/fidl/gidl/rust/benchmarks.go',
+                        'tools/fidl/gidl/rust/conformance.go',
+                    ]
+                ]
+        }
+        command = ['test', '--no-regen']
+        actual = get_commands(mocks, command)
+        expected = {
+            util.GIDL_TEST_TARGET,
+            util.GO_CONFORMANCE_TEST_TARGET,
+            util.HLCPP_CONFORMANCE_TEST_TARGET,
+            util.HLCPP_HOST_CONFORMANCE_TEST_TARGET,
+            util.LLCPP_CONFORMANCE_TEST_TARGET,
+            util.RUST_CONFORMANCE_TEST_TARGET,
+            util.DART_TEST_TARGET,
+        }
+        self.assertEqual(len(actual), 1)
+        self.assertTestsRun(actual[0], expected)
+
+    def assertTestsRun(self, raw_command, expected):
+        self.assertEqual(raw_command[0], 'fx')
+        self.assertEqual(raw_command[1], 'test')
+        self.assertEqual(raw_command[2], '-v')
+        tests = set(raw_command[3:])
+        self.assertSetEqual(tests, expected)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/fidldev/regen.py b/fidldev/regen.py
new file mode 100644
index 0000000..55dc63d
--- /dev/null
+++ b/fidldev/regen.py
@@ -0,0 +1,106 @@
+import os
+
+import util
+
+FIDLC_REGEN = 'zircon/tools/fidl/testdata/regen.sh'
+FIDLGEN_REGEN = 'garnet/go/src/fidl/compiler/backend/typestest/regen.sh'
+FIDLGEN_DART_REGEN = 'topaz/bin/fidlgen_dart/regen.sh'
+GO_BINDINGS_REGEN = 'third_party/go/regen-fidl'
+
+
+def regen_fidlc_goldens(build_first, dry_run):
+    if build_first:
+        util.run(util.BUILD_FIDLC, dry_run, exit_on_failure=True)
+    util.run(path_to_regen_command(FIDLC_REGEN), dry_run, exit_on_failure=True)
+
+
+def regen_fidlgen_goldens(build_first, dry_run):
+    if build_first:
+        util.run(util.BUILD_FIDLGEN, dry_run, exit_on_failure=True)
+    util.run(
+        path_to_regen_command(FIDLGEN_REGEN), dry_run, exit_on_failure=True)
+
+
+def regen_fidlgendart_goldens(build_first, dry_run):
+    if build_first:
+        util.run(util.BUILD_FIDLGEN_DART, dry_run, exit_on_failure=True)
+    util.run(
+        path_to_regen_command(FIDLGEN_DART_REGEN),
+        dry_run,
+        exit_on_failure=True)
+
+
+def regen_go_bindings(build_first, dry_run, exit_on_failure=True):
+    util.run(
+        path_to_regen_command(GO_BINDINGS_REGEN), dry_run, exit_on_failure=True)
+
+
+def path_to_regen_command(path):
+    return ['fx', 'exec', os.path.join(util.FUCHSIA_DIR, path)]
+
+
+def is_ir_changed():
+    for path in util.get_changed_files():
+        if path.startswith(
+                util.FIDLC_DIR) and path.endswith('.test.json.golden'):
+            return True
+    return False
+
+
+def is_go_bindings_changed():
+    for path in util.get_changed_files():
+        if path.startswith(
+                util.FIDLGEN_DIR) and path.endswith('.test.json.go.golden'):
+            return True
+    return False
+
+
+REGEN_TARGETS = [
+    ('fidlc', regen_fidlc_goldens),
+    ('fidlgen', regen_fidlgen_goldens),
+    ('fidlgen_dart', regen_fidlgendart_goldens),
+    ('go', regen_go_bindings),
+]
+
+
+def regen_explicit(targets, build_first, dry_run):
+    for target, func in REGEN_TARGETS:
+        if target in targets or 'all' in targets:
+            func(build_first, dry_run)
+
+
+def regen_changed(changed_files, build_first, dry_run):
+    regen_fidlc = False
+    regen_fidlgen = False
+    regen_fidlgendart = False
+    regen_go = False
+    for file_ in changed_files:
+        if file_.startswith(util.FIDLC_DIR):
+            regen_fidlc = True
+        if file_.startswith(util.FIDLGEN_DIR) or is_in_fidlgen_backend(file_):
+            regen_fidlgen = True
+        if file_.startswith(util.FIDLGEN_DART_DIR):
+            regen_fidlgendart = True
+        if file_.startswith(util.FIDLGEN_GO_DIR):
+            regen_go = True
+
+    if regen_fidlc:
+        regen_fidlc_goldens(build_first, dry_run)
+        if dry_run or is_ir_changed():
+            regen_fidlgen = True
+            regen_fidlgendart = True
+
+    if regen_fidlgen:
+        regen_fidlgen_goldens(build_first, dry_run)
+        if dry_run or is_go_bindings_changed():
+            regen_go = True
+
+    if regen_fidlgendart:
+        regen_fidlgendart_goldens(build_first, dry_run)
+
+    if regen_go:
+        regen_go_bindings(build_first, dry_run)
+
+
+def is_in_fidlgen_backend(path):
+    return any(path.startswith(b) for b in util.FIDLGEN_BACKEND_DIRS)
diff --git a/fidldev/test_.py b/fidldev/test_.py
new file mode 100644
index 0000000..ba438b7
--- /dev/null
+++ b/fidldev/test_.py
@@ -0,0 +1,201 @@
+import functools
+import pprint
+import util
+import regen
+
+
+def startswith(prefix):
+    return lambda s: s.startswith(prefix)
+
+
+def endswith(suffix):
+    return lambda s: s.endswith(suffix)
+
+
+# List of test groups. Each test group is of the following structure:
+#   (NAME, (PREDICATES, TARGETS, BUILD COMMAND))
+# where:
+#   - NAME is a name for the group of tests. This name is used to explicitly
+#     invoke this test group on the command line (e.g. fidldev test foo would call
+#     fx test on the TARGETS for group foo)
+#   - PREDICATES is a list of predicates P such that P(file) returns true if the
+#     test group should run when |file| is changed
+#   - TARGETS is a list of test names as supported by `fx test`, e.g.
+#     fully-formed Fuchsia Package URLs, package names, or directories
+#   - BUILD COMMAND is any command that should be run prior to running the test
+#     group. It can be None if no build step is required, and is skipped if the
+#     --no-build flag is passed in. It currently needs to be a List - run_tests
+#     needs to be updated to support strings
+TEST_GROUPS = [
+    (
+        'fidlc', (
+            [startswith('zircon/tools/fidl')], [util.TEST_FIDLC],
+            util.BUILD_FIDLC_TESTS)),
+
+    # it's possible to be more selective on which changes map to which tests,
+    # but since fidlgen tests are fast to build and run, just do a blanket
+    # check.
+    (
+        'fidlgen', (
+            [startswith(util.FIDLGEN_DIR)] +
+            [startswith(p) for p in util.FIDLGEN_BACKEND_DIRS],
+            util.FIDLGEN_TEST_TARGETS, util.BUILD_FIDLGEN)),
+    (
+        'fidlgen_dart', (
+            [startswith(util.FIDLGEN_DART_DIR)],
+            [util.FIDLGEN_DART_TEST_TARGET], util.BUILD_FIDLGEN_DART)),
+    (
+        'hlcpp', (
+            [
+                endswith('test.json.cc.golden'),
+                endswith('test.json.h.golden'),
+                endswith('test.tables.c.golden'),
+                startswith(util.HLCPP_RUNTIME),
+                startswith(util.C_RUNTIME),
+            ], [util.HLCPP_TEST_TARGET], None)),
+    (
+        'llcpp', (
+            [
+                endswith('test.json.llcpp.cc.golden'),
+                endswith('test.json.llcpp.h.golden'),
+                endswith('test.tables.c.golden'),
+                startswith(util.LLCPP_RUNTIME),
+                startswith(util.C_RUNTIME)
+            ], [util.LLCPP_TEST_TARGET], None)),
+    (
+        'c',
+        (
+            # normally, changes to the generated bindings are detected by looking at the
+            # goldens. Since we can't do this for C, we look at the coding table goldens
+            # and the c_generator instead.
+            [
+                endswith('test.tables.c.golden'),
+                startswith('zircon/tools/fidl/include/fidl/c_generator.h'),
+                startswith('zircon/tools/fidl/lib/c_generator.cc'),
+                startswith(util.C_RUNTIME),
+            ],
+            # NOTE: fidl-test should also run, but this script only supports component
+            # tests
+            [util.C_TEST_TARGET],
+            None)),
+    (
+        'go', (
+            [endswith('test.json.go.golden'),
+             startswith(util.GO_RUNTIME)],
+            [util.GO_TEST_TARGET, util.GO_CONFORMANCE_TEST_TARGET], None)),
+    (
+        'rust', (
+            [endswith('test.json.rs.golden'),
+             startswith(util.RUST_RUNTIME)], [util.RUST_TEST_TARGET], None)),
+    (
+        'dart', (
+            [
+                endswith('test.json_async.dart.golden'),
+                endswith('test.json_test.dart.golden'),
+                startswith(util.DART_RUNTIME)
+            ], [util.DART_TEST_TARGET], None)),
+    (
+        'gidl',
+        (
+            [startswith('tools/fidl/gidl')],
+            [
+                util.GIDL_TEST_TARGET,
+                util.GO_CONFORMANCE_TEST_TARGET,
+                util.HLCPP_CONFORMANCE_TEST_TARGET,
+                util.HLCPP_HOST_CONFORMANCE_TEST_TARGET,
+                util.LLCPP_CONFORMANCE_TEST_TARGET,
+                util.RUST_CONFORMANCE_TEST_TARGET,
+                # dart conformance is bundled into the rest of the tests
+                util.DART_TEST_TARGET
+            ],
+            None)),
+]
+
+
+def test_explicit(targets, build_first, dry_run, interactive, fx_test_args):
+    """ Test an explicit set of test groups """
+    tests = []
+    for name, test in TEST_GROUPS:
+        if name in targets or 'all' in targets:
+            tests.append(test)
+    return run_tests(tests, build_first, dry_run, interactive, fx_test_args)
+
+
+def test_changed(
+        changed_files, build_first, dry_run, interactive, fx_test_args):
+    """ Test relevant test groups given a set of changed files """
+    tests = []
+    for _, test in TEST_GROUPS:
+        (predicates, _, _) = test
+        for file_ in changed_files:
+            if any(p(file_) for p in predicates):
+                tests.append(test)
+    return run_tests(tests, build_first, dry_run, interactive, fx_test_args)
+
+
+def run_tests(tests, build_first, dry_run, interactive, fx_test_args):
+    already_built = set()
+    test_targets = set()
+    manual_tests = set()
+    for name, targets, build in tests:
+        if build_first and build is not None and tuple(
+                build) not in already_built:
+            already_built.add(tuple(build))
+            util.run(build, dry_run, exit_on_failure=True)
+
+        for target in targets:
+            if is_manual_test(target):
+                manual_tests.add(target)
+            else:
+                test_targets.add(target)
+
+    manual_tests = list(manual_tests)
+    test_targets = list(test_targets)
+    if interactive:
+        print('all tests: ')
+        pprint.pprint(manual_tests + test_targets)
+        manual_tests = interactive_filter(manual_tests)
+        test_targets = interactive_filter(test_targets)
+
+    success = True
+    for cmd in manual_tests:
+        success = success and util.run(cmd, dry_run)
+        # print test line that can be copied into a commit message
+        # the absolute FUCHSIA_DIR paths are stripped for readability and
+        # because they are user specific
+        print('Test: ' + cmd.replace(str(util.FUCHSIA_DIR) + '/', ''))
+
+    if test_targets:
+        cmd = ['fx', 'test'] + fx_test_args.split()
+        if not build_first:
+            cmd.append('--no-build')
+        # group all tests into a single `fx test` invocation so that the summary
+        # prints all results
+        cmd.extend(test_targets)
+        success = success and util.run(cmd, dry_run)
+        print('Test: ' + ' '.join(cmd))
+
+    return success
+
+
+def interactive_filter(test_targets):
+    if not test_targets:
+        return []
+    filtered = []
+    for test in test_targets:
+        if input('run {}? (Y/n) '.format(test)) == 'n':
+            continue
+        filtered.append(test)
+    return filtered
+
+
+def is_manual_test(test):
+    """
+    Return whether this is meant to be called with fx test or used as a
+    standalone test command.
+    """
+    # currently fidlc is the only test that doesn't use fx test, since it
+    # uses some fidlc/fidl-compiler-test binary that is not built with the
+    # usual build commands (like fx build zircon/tools, fx ninja -C out/default
+    # host_x64/fidlc)
+    return test == util.TEST_FIDLC
diff --git a/fidldev/test_util.py b/fidldev/test_util.py
new file mode 100644
index 0000000..c70ca7c
--- /dev/null
+++ b/fidldev/test_util.py
@@ -0,0 +1,62 @@
+import argparse
+import contextlib
+import pprint
+
+import env
+
+# mock out these environment consts before importing anything else
+MOCK_FUCHSIA_DIR = 'fuchsia_dir'
+MOCK_BUILD_DIR = 'out/default'
+env.FUCHSIA_DIR = MOCK_FUCHSIA_DIR
+env.BUILD_DIR = MOCK_BUILD_DIR
+env.PLATFORM = 'linux'
+env.MODE = 'clang'
+
+import fidldev
+import util
+
+
+@contextlib.contextmanager
+def mocked_func(func_name, mocked_func):
+    """ Patch util.[func_name] with mocked_func within the specified context. """
+    original = getattr(util, func_name)
+    try:
+        setattr(util, func_name, mocked_func)
+        yield
+    finally:
+        setattr(util, func_name, original)
+
+
+def create_fixed_func(return_values):
+    """ Returns a function that successively returns each of the provided |return_values|. """
+    return_values = iter(return_values)
+
+    def mocked(*args, **kwargs):
+        return next(return_values)
+
+    return mocked
+
+
+def get_commands(mocks, test_cmd):
+    """ Run |test_cmd| with the provided |mocks|, and return the commands that fidldev would have run. """
+    mocked_funcs = [
+        mocked_func(name, create_fixed_func(values))
+        for name, values in mocks.items()
+    ]
+
+    commands = []
+
+    # The arguments and return value of this function need to be kept up to date with util.run
+    def mocked_run(command, dry_run, exit_on_failure=False):
+        commands.append(command)
+        return True
+
+    mocked_funcs.append(mocked_func('run', mocked_run))
+
+    with contextlib.ExitStack() as stack:
+        for func in mocked_funcs:
+            stack.enter_context(func)
+        args = fidldev.parser.parse_args(test_cmd)
+        args.func(args)
+
+    return commands
diff --git a/fidldev/util.py b/fidldev/util.py
new file mode 100644
index 0000000..5baac54
--- /dev/null
+++ b/fidldev/util.py
@@ -0,0 +1,117 @@
+import os
+import subprocess
+
+from env import FUCHSIA_DIR, BUILD_DIR, MODE, PLATFORM
+
+TOPAZ_DIR = os.path.join(FUCHSIA_DIR, 'topaz')
+GO_DIR = os.path.join(FUCHSIA_DIR, 'third_party/go')
+
+FIDLC_DIR = 'zircon/tools/fidl'
+FIDLGEN_DIR = 'garnet/go/src/fidl/compiler'
+FIDLGEN_DART_DIR = 'topaz/bin/fidlgen_dart'
+FIDLGEN_GO_DIR = 'tools/fidl/fidlgen_go'
+FIDLGEN_BACKEND_DIRS = [
+    'garnet/go/src/fidl/compiler/llcpp_backend',
+    FIDLGEN_GO_DIR,
+    'tools/fidl/fidlgen_hlcpp',
+    'tools/fidl/fidlgen_libfuzzer',
+    'tools/fidl/fidlgen_rust',
+    'tools/fidl/fidlgen_syzkaller',
+]
+
+TEST_FIDLC = os.path.join(FUCHSIA_DIR, BUILD_DIR, 'host_x64/fidl-compiler')
+FIDLGEN_TEST_TARGETS = [
+    '//garnet/go/src/fidl',
+    '//tools/fidl/fidlgen_hlcpp',
+    '//tools/fidl/fidlgen_go',
+    '//tools/fidl/fidlgen_libfuzzer',
+    '//tools/fidl/fidlgen_rust',
+    '//tools/fidl/fidlgen_syzkaller',
+]
+FIDLGEN_DART_TEST_TARGET = '//topaz/bin/fidlgen_dart'
+HLCPP_TEST_TARGET = '//sdk/lib/fidl'
+LLCPP_TEST_TARGET = '//src/lib/fidl/llcpp'
+C_TEST_TARGET = '//src/lib/fidl/c'
+GO_TEST_TARGET = 'go_fidl_test'
+RUST_TEST_TARGET = '//src/lib/fidl/rust'
+DART_TEST_TARGET = 'fidl_bindings_test'
+GIDL_TEST_TARGET = '//tools/fidl/gidl'
+
+HLCPP_CONFORMANCE_TEST_TARGET = 'conformance_test'
+HLCPP_HOST_CONFORMANCE_TEST_TARGET = 'fidl_cpp_host_conformance_test'
+LLCPP_CONFORMANCE_TEST_TARGET = 'fidl_llcpp_conformance_test'
+GO_CONFORMANCE_TEST_TARGET = 'fidl_go_conformance'
+RUST_CONFORMANCE_TEST_TARGET = 'fidl_conformance_tests'
+
+HLCPP_RUNTIME = 'sdk/lib/fidl'
+LLCPP_RUNTIME = 'src/lib/fidl/llcpp'
+C_RUNTIME = 'zircon/system/ulib/fidl'
+GO_RUNTIME = 'third_party/go/src/syscall/zx/fidl'
+RUST_RUNTIME = 'src/lib/fidl/rust'
+DART_RUNTIME = 'topaz/public/dart/fidl/lib'
+
+BUILD_FIDLC = ['fx', 'build', 'zircon/tools']
+BUILD_FIDLC_TESTS = ['fx', 'ninja', '-C', BUILD_DIR, 'host_x64/fidl-compiler']
+BUILD_FIDLGEN = ['fx', 'build', 'garnet/go/src/fidl']
+BUILD_FIDLGEN_DART = ['fx', 'ninja', '-C', BUILD_DIR, 'host_x64/fidlgen_dart']
+
+
+def run(command, dry_run, exit_on_failure=False):
+    """
+    Run the given command, returning True if it completed successfuly. If
+    dry_run is true, just prints rather than running. If exit_on_failure is
+    true, exits instead of returning False.
+    """
+    if dry_run:
+        print('would run: {}'.format(command))
+        return True
+    retcode = subprocess.call(command)
+    success = retcode == 0
+    if exit_on_failure and not success:
+        print_err(
+            'Error: command failed with status {}! {}'.format(retcode, command))
+        exit(1)
+    return success
+
+
+def get_changed_files():
+    """
+    Return a List of paths relative to FUCHSIA_DIR of changed files relative to
+    the parent. This uses the same logic as fx format-code.
+    """
+    upstream = "origin/master"
+    local_commit = subprocess.check_output(
+        "git rev-list HEAD ^{} -- 2>/dev/null | tail -1".format(upstream),
+        shell=True).strip().decode()
+    diff_base = subprocess.check_output(
+        ['git', 'rev-parse', local_commit +
+         '^']).strip().decode() if local_commit else "HEAD"
+    files = subprocess.check_output(['git', 'diff', '--name-only',
+                                     diff_base]).strip().decode().split('\n')
+
+    repo = subprocess.check_output(['git', 'rev-parse',
+                                    '--show-toplevel']).strip().decode()
+    # add prefixes so that all and targets can be specified relative to FUCHSIA_DIR
+    if repo.endswith('topaz'):
+        files = [os.path.join('topaz', p) for p in files]
+    elif repo.endswith('third_party/go'):
+        files = [os.path.join('third_party/go', p) for p in files]
+
+    return files
+
+
+RED = '\033[1;31m'
+YELLOW = '\033[1;33m'
+NC = '\033[0m'
+
+
+def print_err(s):
+    print_color(s, RED)
+
+
+def print_warning(s):
+    print_color(s, YELLOW)
+
+
+def print_color(s, color):
+    print('{}{}{}'.format(color, s, NC))