pw_toolchain_bazel: Implement PwActionConfigInfo

This CL makes action configs use labels rather than strings, making it
more type-safe.
It also adds several tests and validation to ensure the toolchain is
valid (eg. disallow multiple action configs for the same name).

Bug: 322872628
Test: bazel build //cc_toolchain/tests/...
Change-Id: Ib775e092a3511169a5e46b4eb355d7e5ef31524f
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/189990
Reviewed-by: Armando Montanez <amontanez@google.com>
Commit-Queue: Matt Stark <msta@google.com>
diff --git a/pw_toolchain_bazel/api.rst b/pw_toolchain_bazel/api.rst
index 192ede2..0718f9c 100644
--- a/pw_toolchain_bazel/api.rst
+++ b/pw_toolchain_bazel/api.rst
@@ -506,8 +506,8 @@
           name = "ar",
           action_names = ["@pw_toolchain//actions:all_ar_actions"],
           implies = [
-              "archiver_flags",
-              "linker_param_file",
+              "@pw_toolchain//features/legacy:archiver_flags",
+              "@pw_toolchain//features/legacy:linker_param_file",
           ],
           tools = [":ar_tool"],
       )
diff --git a/pw_toolchain_bazel/build_external/gcc_arm_none_eabi.BUILD b/pw_toolchain_bazel/build_external/gcc_arm_none_eabi.BUILD
index 72d8b7a..3424342 100644
--- a/pw_toolchain_bazel/build_external/gcc_arm_none_eabi.BUILD
+++ b/pw_toolchain_bazel/build_external/gcc_arm_none_eabi.BUILD
@@ -42,8 +42,8 @@
     name = "arm-none-eabi-ar",
     action_names = ["@pw_toolchain//actions:all_ar_actions"],
     implies = [
-        "archiver_flags",
-        "linker_param_file",
+        "@pw_toolchain//features/legacy:archiver_flags",
+        "@pw_toolchain//features/legacy:linker_param_file",
     ],
     tools = [":arm-none-eabi-ar_tool"],
 )
diff --git a/pw_toolchain_bazel/build_external/llvm_clang.BUILD b/pw_toolchain_bazel/build_external/llvm_clang.BUILD
index cc1743b..87353c3 100644
--- a/pw_toolchain_bazel/build_external/llvm_clang.BUILD
+++ b/pw_toolchain_bazel/build_external/llvm_clang.BUILD
@@ -41,8 +41,8 @@
     name = "ar",
     action_names = ["@pw_toolchain//actions:all_ar_actions"],
     implies = [
-        "archiver_flags",
-        "linker_param_file",
+        "@pw_toolchain//features/legacy:archiver_flags",
+        "@pw_toolchain//features/legacy:linker_param_file",
     ],
     tools = [":ar_tool"],
 )
diff --git a/pw_toolchain_bazel/cc_toolchain/defs.bzl b/pw_toolchain_bazel/cc_toolchain/defs.bzl
index d055a16..d168020 100644
--- a/pw_toolchain_bazel/cc_toolchain/defs.bzl
+++ b/pw_toolchain_bazel/cc_toolchain/defs.bzl
@@ -60,5 +60,5 @@
 # TODO(b/322872628): Remove this.
 # DO NOT USE. This is a temporary variable to allow users to migrate to
 # action_config implies labels without breaking their build.
-ARCHIVER_FLAGS = "archiver_flags"
-LINKER_PARAM_FILE = "linker_param_file"
+ARCHIVER_FLAGS = "@pw_toolchain//features/legacy:archiver_flags"
+LINKER_PARAM_FILE = "@pw_toolchain//features/legacy:linker_param_file"
diff --git a/pw_toolchain_bazel/cc_toolchain/private/action_config.bzl b/pw_toolchain_bazel/cc_toolchain/private/action_config.bzl
index cd4de40..dbc08b5 100644
--- a/pw_toolchain_bazel/cc_toolchain/private/action_config.bzl
+++ b/pw_toolchain_bazel/cc_toolchain/private/action_config.bzl
@@ -15,33 +15,36 @@
 
 load(
     "@bazel_tools//tools/cpp:cc_toolchain_config_lib.bzl",
-    "action_config",
     config_lib_tool = "tool",  # This is renamed to reduce name aliasing.
 )
 load(
     ":providers.bzl",
-    "PwActionConfigListInfo",
+    "PwActionConfigInfo",
+    "PwActionConfigSetInfo",
     "PwActionNameSetInfo",
+    "PwFeatureSetInfo",
     "PwFlagSetInfo",
     "PwToolInfo",
 )
-load(":utils.bzl", "actionless_flag_set", "to_untyped_flag_set")
 
 def _pw_cc_tool_impl(ctx):
     """Implementation for pw_cc_tool."""
 
     # Remaps empty strings to `None` to match behavior of the default values.
-    tool = ctx.executable.tool if ctx.executable.tool else None
-    path = ctx.attr.path if ctx.attr.path else None
+    tool = ctx.executable.tool or None
+    path = ctx.attr.path or None
+
+    files = ctx.files.additional_files
+    if tool != None:
+        files = files + [tool]
+
     return [
         config_lib_tool(
             tool = tool,
             path = path,
             execution_requirements = ctx.attr.execution_requirements,
         ),
-        DefaultInfo(
-            files = depset(ctx.files.additional_files + [tool]),
-        ),
+        DefaultInfo(files = depset(files)),
     ]
 
 pw_cc_tool = rule(
@@ -114,18 +117,17 @@
 """,
 )
 
-def _generate_action_config(ctx, action_name):
+def _generate_action_config(ctx, action_name, **kwargs):
     flag_sets = []
     for fs in ctx.attr.flag_sets:
-        provided_fs = to_untyped_flag_set(fs[PwFlagSetInfo])
+        provided_fs = fs[PwFlagSetInfo]
         if action_name in provided_fs.actions:
-            flag_sets.append(actionless_flag_set(provided_fs))
-    return action_config(
+            flag_sets.append(provided_fs)
+
+    return PwActionConfigInfo(
         action_name = action_name,
-        enabled = ctx.attr.enabled,
-        tools = [tool[PwToolInfo] for tool in ctx.attr.tools],
-        flag_sets = flag_sets,
-        implies = ctx.attr.implies,
+        flag_sets = tuple(flag_sets),
+        **kwargs
     )
 
 def _pw_cc_action_config_impl(ctx):
@@ -143,7 +145,7 @@
     # Check that the listed flag sets apply to at least one action in this group
     # of action configs.
     for fs in ctx.attr.flag_sets:
-        provided_fs = to_untyped_flag_set(fs[PwFlagSetInfo])
+        provided_fs = fs[PwFlagSetInfo]
         flag_set_applies = False
         for action in action_names:
             if action in provided_fs.actions:
@@ -153,10 +155,27 @@
                 fs.label,
                 ctx.label,
             ))
+    tools = tuple([tool[PwToolInfo] for tool in ctx.attr.tools])
+
+    common_kwargs = dict(
+        label = ctx.label,
+        tools = tools,
+        implies_features = depset(transitive = [
+            ft_set[PwFeatureSetInfo].features
+            for ft_set in ctx.attr.implies
+        ]),
+        implies_action_configs = depset([]),
+        enabled = ctx.attr.enabled,
+    )
+    action_configs = [
+        _generate_action_config(ctx, action, **common_kwargs)
+        for action in action_names
+    ]
 
     return [
-        PwActionConfigListInfo(
-            action_configs = [_generate_action_config(ctx, action) for action in action_names],
+        PwActionConfigSetInfo(
+            label = ctx.label,
+            action_configs = depset(action_configs),
         ),
         DefaultInfo(
             files = depset(None, transitive = [dep[DefaultInfo].files for dep in ctx.attr.tools]),
@@ -202,18 +221,12 @@
 `pw_cc_flag_set`'s `actions`, the flag will not be applied to that action.
 """,
         ),
-        "implies": attr.string_list(
-            doc = """Names of features that should be automatically enabled when
-this tool is used.
-
-WARNING: If this action config implies an unknown feature, this action config
-will silently be disabled. This behavior is native to Bazel itself, and there's
-no way to detect this and emit an error instead. For this reason, be very
-cautious when listing implied features!
-""",
+        "implies": attr.label_list(
+            providers = [PwFeatureSetInfo],
+            doc = "Features that should be enabled when this action is used.",
         ),
     },
-    provides = [PwActionConfigListInfo],
+    provides = [PwActionConfigSetInfo],
     doc = """Declares the configuration and selection of `pw_cc_tool` rules.
 
 Action configs are bound to a toolchain through `action_configs`, and are the
@@ -231,8 +244,8 @@
         name = "ar",
         action_names = ["@pw_toolchain//actions:all_ar_actions"],
         implies = [
-            "archiver_flags",
-            "linker_param_file",
+            "@pw_toolchain//features/legacy:archiver_flags",
+            "@pw_toolchain//features/legacy:linker_param_file",
         ],
         tools = [":ar_tool"],
     )
diff --git a/pw_toolchain_bazel/cc_toolchain/private/action_config_files.bzl b/pw_toolchain_bazel/cc_toolchain/private/action_config_files.bzl
index ce28f72..61c24d9 100644
--- a/pw_toolchain_bazel/cc_toolchain/private/action_config_files.bzl
+++ b/pw_toolchain_bazel/cc_toolchain/private/action_config_files.bzl
@@ -19,8 +19,7 @@
 
 load(
     ":providers.bzl",
-    "PwActionConfigInfo",
-    "PwActionConfigListInfo",
+    "PwActionConfigSetInfo",
     "PwActionNameSetInfo",
 )
 
@@ -38,11 +37,7 @@
 
     all_file_depsets = []
     for dep in ctx.attr.all_action_configs:
-        action_names = []
-        if PwActionConfigInfo in dep:
-            action_names.append(dep[PwActionConfigInfo].action_name)
-        if PwActionConfigListInfo in dep:
-            action_names.extend([ac.action_name for ac in dep[PwActionConfigListInfo].action_configs])
+        action_names = [ac.action_name for ac in dep[PwActionConfigSetInfo].action_configs.to_list()]
 
         # NOTE: This intentionally doesn't do a check to ensure that the
         # items in `action_names` are `pw_cc_action_config`s because the
@@ -74,7 +69,7 @@
 pw_cc_action_config_file_collector = rule(
     implementation = _pw_cc_action_config_file_collector_impl,
     attrs = {
-        "all_action_configs": attr.label_list(default = []),
+        "all_action_configs": attr.label_list(default = [], providers = [PwActionConfigSetInfo]),
         "collect_files_from_actions": attr.label_list(
             providers = [PwActionNameSetInfo],
             doc = """Collects files from tools that apply to the listed action names.
diff --git a/pw_toolchain_bazel/cc_toolchain/private/cc_toolchain.bzl b/pw_toolchain_bazel/cc_toolchain/private/cc_toolchain.bzl
index 478a27e..b316a2d 100644
--- a/pw_toolchain_bazel/cc_toolchain/private/cc_toolchain.bzl
+++ b/pw_toolchain_bazel/cc_toolchain/private/cc_toolchain.bzl
@@ -16,7 +16,6 @@
 load("@bazel_tools//tools/build_defs/cc:action_names.bzl", "ACTION_NAMES")
 load(
     "@bazel_tools//tools/cpp:cc_toolchain_config_lib.bzl",
-    "action_config",
     "feature",
     "flag_group",
     "flag_set",
@@ -29,8 +28,7 @@
 load("//features:builtin_features.bzl", "BUILTIN_FEATURES")
 load(
     ":providers.bzl",
-    "PwActionConfigInfo",
-    "PwActionConfigListInfo",
+    "PwActionConfigSetInfo",
     "PwFeatureInfo",
     "PwFeatureSetInfo",
     "PwFlagSetInfo",
@@ -38,9 +36,7 @@
 load(
     ":utils.bzl",
     "ALL_FILE_GROUPS",
-    "actionless_flag_set",
     "to_untyped_config",
-    "to_untyped_flag_set",
 )
 
 # These attributes of pw_cc_toolchain are deprecated.
@@ -57,7 +53,7 @@
 
 PW_CC_TOOLCHAIN_CONFIG_ATTRS = {
     "action_configs": "List of `pw_cc_action_config` labels that bind tools to the appropriate actions",
-    "action_config_flag_sets": "List of `pw_cc_flag_set`s to apply to their respective action configs",
+    "unconditional_flag_sets": "List of `pw_cc_flag_set`s to apply to their respective action configs",
     "toolchain_features": "List of `pw_cc_feature`s that this toolchain supports",
 
     # Attributes originally part of create_cc_toolchain_config_info.
@@ -157,52 +153,6 @@
         ],
     )
 
-def _extend_action_set_flags(action, flag_sets_by_action):
-    extended_flags = flag_sets_by_action.get(action.action_name, default = [])
-    for x in extended_flags:
-        for y in action.flag_sets:
-            if x == y:
-                # TODO: b/311679764 - Propagate labels so we can raise the label
-                # as part of the warning.
-                fail("Flag set in `action_config_flag_sets` is already bound to the `{}` tool".format(action.action_name))
-    return action_config(
-        action_name = action.action_name,
-        enabled = action.enabled,
-        tools = action.tools,
-        flag_sets = action.flag_sets + extended_flags,
-        implies = action.implies,
-    )
-
-def _collect_action_configs(ctx, flag_sets_by_action):
-    known_actions = {}
-    action_configs = []
-    for ac_dep in ctx.attr.action_configs:
-        temp_actions = []
-        if PwActionConfigInfo in ac_dep:
-            temp_actions.append(ac_dep[PwActionConfigInfo])
-        if PwActionConfigListInfo in ac_dep:
-            temp_actions.extend([ac for ac in ac_dep[PwActionConfigListInfo].action_configs])
-        if PwActionConfigListInfo not in ac_dep and PwActionConfigInfo not in ac_dep:
-            fail(
-                "{} in `action_configs` is not a `pw_cc_action_config`".format(
-                    ac_dep.label,
-                ),
-            )
-        for action in temp_actions:
-            if action.action_name in known_actions:
-                fail("In {} both {} and {} implement `{}`".format(
-                    ctx.label,
-                    ac_dep.label,
-                    known_actions[action.action_name],
-                    action.action_name,
-                ))
-
-            # Track which labels implement each action name for better error
-            # reporting.
-            known_actions[action.action_name] = ac_dep.label
-            action_configs.append(_extend_action_set_flags(action, flag_sets_by_action))
-    return action_configs
-
 def _archiver_flags(is_mac):
     """Returns flags for llvm-ar."""
     if is_mac:
@@ -210,28 +160,6 @@
     else:
         return ["rcsD"]
 
-def _create_action_flag_set_map(flag_sets):
-    """Creates a mapping of action names to flag sets.
-
-    Args:
-        flag_sets: the flag sets to expand.
-
-    Returns:
-        Dictionary mapping action names to lists of PwFlagSetInfo providers.
-    """
-    flag_sets_by_action = {}
-    for fs in flag_sets:
-        handled_actions = {}
-        for action in fs.actions:
-            if action not in flag_sets_by_action:
-                flag_sets_by_action[action] = []
-
-            # Dedupe action set list.
-            if action not in handled_actions:
-                handled_actions[action] = True
-                flag_sets_by_action[action].append(actionless_flag_set(fs))
-    return flag_sets_by_action
-
 def _pw_cc_toolchain_config_impl(ctx):
     """Rule that provides a CcToolchainConfigInfo.
 
@@ -241,11 +169,6 @@
     Returns:
         CcToolchainConfigInfo
     """
-    flag_sets_by_action = _create_action_flag_set_map([
-        to_untyped_flag_set(dep[PwFlagSetInfo], known = {})
-        for dep in ctx.attr.action_config_flag_sets
-    ])
-    all_actions = _collect_action_configs(ctx, flag_sets_by_action)
     builtin_include_dirs = ctx.attr.cxx_builtin_include_directories if ctx.attr.cxx_builtin_include_directories else []
     sysroot_dir = ctx.attr.builtin_sysroot if ctx.attr.builtin_sysroot else None
 
@@ -256,14 +179,22 @@
             for feature_set in ctx.attr.toolchain_features
         ],
     ))
-    out = to_untyped_config(feature_set)
+    action_config_set = PwActionConfigSetInfo(
+        label = ctx.label,
+        action_configs = depset(transitive = [
+            acs[PwActionConfigSetInfo].action_configs
+            for acs in ctx.attr.action_configs
+        ]),
+    )
+    flag_sets = [fs[PwFlagSetInfo] for fs in ctx.attr.unconditional_flag_sets]
+    out = to_untyped_config(feature_set, action_config_set, flag_sets)
 
     # TODO: b/297413805 - This could be externalized.
     out.features.append(_archiver_flags_feature(ctx.attr.target_libc == "macosx"))
 
     return cc_common.create_cc_toolchain_config_info(
         ctx = ctx,
-        action_configs = all_actions,
+        action_configs = out.action_configs,
         features = out.features,
         cxx_builtin_include_directories = builtin_include_dirs,
         toolchain_identifier = ctx.attr.toolchain_identifier,
@@ -282,8 +213,8 @@
     implementation = _pw_cc_toolchain_config_impl,
     attrs = {
         # Attributes new to this rule.
-        "action_configs": attr.label_list(),
-        "action_config_flag_sets": attr.label_list(providers = [PwFlagSetInfo]),
+        "action_configs": attr.label_list(providers = [PwActionConfigSetInfo]),
+        "unconditional_flag_sets": attr.label_list(providers = [PwFlagSetInfo]),
         "toolchain_features": attr.label_list(providers = [PwFeatureSetInfo]),
 
         # Attributes from create_cc_toolchain_config_info.
@@ -403,7 +334,7 @@
     )
     return file_group_name
 
-def pw_cc_toolchain(**kwargs):
+def pw_cc_toolchain(action_config_flag_sets = None, **kwargs):
     """A suite of cc_toolchain, pw_cc_toolchain_config, and *_files rules.
 
     Generated rules:
@@ -418,9 +349,14 @@
             configs not associated with any other *_files group.
 
     Args:
+        action_config_flag_sets: Deprecated. Do not use.
         **kwargs: All attributes supported by either cc_toolchain or pw_cc_toolchain_config.
     """
 
+    # TODO(b/322872628): Remove this once it's no longer in use.
+    if action_config_flag_sets != None:
+        kwargs["unconditional_flag_sets"] = action_config_flag_sets
+
     _check_args(native.package_relative_label(kwargs["name"]), kwargs)
 
     # Generate *_files groups.
diff --git a/pw_toolchain_bazel/cc_toolchain/private/providers.bzl b/pw_toolchain_bazel/cc_toolchain/private/providers.bzl
index f52fa61..91deb5a 100644
--- a/pw_toolchain_bazel/cc_toolchain/private/providers.bzl
+++ b/pw_toolchain_bazel/cc_toolchain/private/providers.bzl
@@ -15,7 +15,6 @@
 
 load(
     "@bazel_tools//tools/cpp:cc_toolchain_config_lib.bzl",
-    "ActionConfigInfo",
     "EnvEntryInfo",
     "EnvSetInfo",
     "FlagGroupInfo",
@@ -30,16 +29,6 @@
 # provider, and convert them to a tuple in the constructor to ensure
 # immutability.
 
-# To reduce the number of require pw_cc_action_config rules, a
-# pw_cc_action_config provides a list of ActionConfigInfo providers rather than
-# a simpler 1:1 mapping.
-PwActionConfigListInfo = provider(
-    doc = "A provider containing a list of ActionConfigInfo providers.",
-    fields = {
-        "action_configs": "List[ActionConfigInfo]: A list of ActionConfigInfo providers.",
-    },
-)
-
 PwActionNameInfo = ActionNameInfo
 PwActionNameSetInfo = ActionNameSetInfo
 
@@ -92,5 +81,24 @@
     fields = {},
 )
 
-PwActionConfigInfo = ActionConfigInfo
+PwActionConfigInfo = provider(
+    doc = "A type-safe version of @bazel_tools's ActionConfigInfo",
+    fields = {
+        "label": "Label: The label that defined this action config. Put this in error messages for easy debugging",
+        "action_name": "str: The name of the action",
+        "enabled": "bool: If True, this action is enabled unless a rule type explicitly marks it as unsupported",
+        "tools": "Sequence[ToolInfo]: The tool applied to the action will be the first tool in the sequence with a feature set that matches the feature configuration",
+        "flag_sets": "Sequence[FlagSetInfo]: Set of flag sets the action sets",
+        "implies_features": "depset[FeatureInfo]: Set of features implied by this action config",
+        "implies_action_configs": "depset[ActionConfigInfo]: Set of action configs enabled by this action config",
+    },
+)
+
+PwActionConfigSetInfo = provider(
+    doc = "A set of action configs",
+    fields = {
+        "label": "Label: The label that defined this action config set. Put this in error messages for easy debugging",
+        "action_configs": "depset[ActionConfigInfo]: A set of action configs",
+    },
+)
 PwToolInfo = ToolInfo
diff --git a/pw_toolchain_bazel/cc_toolchain/private/utils.bzl b/pw_toolchain_bazel/cc_toolchain/private/utils.bzl
index 3e8a1c3..c0d5624 100644
--- a/pw_toolchain_bazel/cc_toolchain/private/utils.bzl
+++ b/pw_toolchain_bazel/cc_toolchain/private/utils.bzl
@@ -15,11 +15,13 @@
 
 load(
     "@bazel_tools//tools/cpp:cc_toolchain_config_lib.bzl",
+    rules_cc_action_config = "action_config",
     rules_cc_feature = "feature",
     rules_cc_feature_set = "feature_set",
     rules_cc_flag_set = "flag_set",
     rules_cc_with_feature_set = "with_feature_set",
 )
+load(":providers.bzl", "PwFlagSetInfo")
 
 visibility(["//cc_toolchain/tests/..."])
 
@@ -34,19 +36,6 @@
     "strip_files": ["@pw_toolchain//actions:strip"],
 }
 
-def actionless_flag_set(flag_set_to_copy):
-    """Copies a flag_set, stripping `actions`.
-
-    Args:
-        flag_set_to_copy: The base flag_set to copy.
-    Returns:
-        flag_set with empty `actions` list.
-    """
-    return rules_cc_flag_set(
-        with_features = flag_set_to_copy.with_features,
-        flag_groups = flag_set_to_copy.flag_groups,
-    )
-
 def _ensure_fulfillable(any_of, known, label, fail = fail):
     # Requirements can be fulfilled if there are no requirements.
     fulfillable = not any_of
@@ -63,7 +52,7 @@
     if not fulfillable:
         fail("%s cannot possibly be enabled (none of the constraints it requires fully exist). Either remove it from your toolchain, or add the requirements." % label)
 
-def to_untyped_flag_set(flag_set, known, fail = fail):
+def _to_untyped_flag_set(flag_set, known, fail = fail):
     """Converts a PwFlagSet to rules_cc's flag set."""
     _ensure_fulfillable(
         any_of = [constraint.all_of for constraint in flag_set.requires_any_of],
@@ -122,7 +111,7 @@
         name = feature.name,
         enabled = feature.enabled,
         flag_sets = [
-            to_untyped_flag_set(flag_set, known, fail = fail)
+            _to_untyped_flag_set(flag_set, known, fail = fail)
             for flag_set in feature.flag_sets.to_list()
         ],
         env_sets = [
@@ -134,16 +123,51 @@
         provides = list(feature.provides),
     )
 
-def to_untyped_config(feature_set, fail = fail):
+def _to_untyped_action_config(action_config, extra_flag_sets, known, fail = fail):
+    # De-dupe, in case the same flag set was specified for both unconditional
+    # and for a specific action config.
+    flag_sets = depset(
+        list(action_config.flag_sets) + extra_flag_sets,
+        order = "preorder",
+    ).to_list()
+    return rules_cc_action_config(
+        action_name = action_config.action_name,
+        enabled = action_config.enabled,
+        tools = list(action_config.tools),
+        flag_sets = [
+            _to_untyped_flag_set(
+                # Make the flag sets actionless.
+                PwFlagSetInfo(
+                    label = flag_set.label,
+                    actions = tuple(),
+                    requires_any_of = flag_set.requires_any_of,
+                    flag_groups = flag_set.flag_groups,
+                ),
+                known = known,
+                fail = fail,
+            )
+            for flag_set in flag_sets
+        ],
+        implies = _to_untyped_implies(action_config, known, fail = fail),
+    )
+
+def to_untyped_config(feature_set, action_config_set, flag_sets, fail = fail):
     """Converts Pigweed providers into a format suitable for rules_cc.
 
     Args:
         feature_set: PwFeatureSetInfo: Features available in the toolchain
+        action_config_set: ActionConfigSetInfo: Set of defined action configs
+        flag_sets: Flag sets that are unconditionally applied
         fail: The fail function. Only change this during testing.
     Returns:
         A struct containing parameters suitable to pass to
           cc_common.create_cc_toolchain_config_info.
     """
+    flag_sets_by_action = {}
+    for flag_set in flag_sets:
+        for action in flag_set.actions:
+            flag_sets_by_action.setdefault(action, []).append(flag_set)
+
     known_labels = {}
     known_feature_names = {}
     feature_list = feature_set.features.to_list()
@@ -160,6 +184,27 @@
         untyped_feature = _to_untyped_feature(feature, known = known_labels, fail = fail)
         if untyped_feature != None:
             untyped_features.append(untyped_feature)
+
+    acs = action_config_set.action_configs.to_list()
+    known_actions = {}
+    untyped_acs = []
+    for ac in acs:
+        if ac.action_name in known_actions:
+            fail("In %s, both %s and %s implement %s" % (
+                action_config_set.label,
+                ac.label,
+                known_actions[ac.action_name],
+                ac.action_name,
+            ))
+        known_actions[ac.action_name] = ac.label
+        untyped_acs.append(_to_untyped_action_config(
+            ac,
+            extra_flag_sets = flag_sets_by_action.get(ac.action_name, []),
+            known = known_labels,
+            fail = fail,
+        ))
+
     return struct(
         features = untyped_features,
+        action_configs = untyped_acs,
     )
diff --git a/pw_toolchain_bazel/cc_toolchain/tests/action_configs/BUILD.bazel b/pw_toolchain_bazel/cc_toolchain/tests/action_configs/BUILD.bazel
new file mode 100644
index 0000000..aae465a
--- /dev/null
+++ b/pw_toolchain_bazel/cc_toolchain/tests/action_configs/BUILD.bazel
@@ -0,0 +1,44 @@
+# Copyright 2024 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+
+load(
+    "//cc_toolchain:defs.bzl",
+    "pw_cc_action_config",
+    "pw_cc_tool",
+)
+load(":test_action_configs.bzl", "test_action_configs")
+
+package(default_visibility = ["//cc_toolchain/tests:__subpackages__"])
+
+pw_cc_tool(
+    name = "system_clang",
+    path = "/usr/bin/clang",
+)
+
+pw_cc_action_config(
+    name = "all_c_compile",
+    action_names = ["//actions:all_c_compiler_actions"],
+    implies = ["//cc_toolchain/tests/features:foo"],
+    tools = [":system_clang"],
+)
+
+pw_cc_action_config(
+    name = "c_compile",
+    action_names = ["//actions:c_compile"],
+    tools = [":system_clang"],
+)
+
+test_action_configs(
+    name = "test_action_configs",
+)
diff --git a/pw_toolchain_bazel/cc_toolchain/tests/action_configs/test_action_configs.bzl b/pw_toolchain_bazel/cc_toolchain/tests/action_configs/test_action_configs.bzl
new file mode 100644
index 0000000..bbfa3af
--- /dev/null
+++ b/pw_toolchain_bazel/cc_toolchain/tests/action_configs/test_action_configs.bzl
@@ -0,0 +1,68 @@
+# Copyright 2024 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""Tests for pw_cc_feature and pw_cc_feature_set."""
+
+load(
+    "//cc_toolchain/tests:utils.bzl",
+    "assert_eq",
+    "assert_fail",
+    "generate_test_rule",
+)
+
+visibility("private")
+
+def _test_action_configs_impl(_ctx, action_configs, features, flag_sets, to_untyped_config, **_):
+    def get_action_configs(**kwargs):
+        action_configs = to_untyped_config(**kwargs).action_configs
+        actions = [(action.action_name, action) for action in action_configs]
+        deduped_actions = dict(actions)
+
+        # Verify no duplicates
+        assert_eq(sorted([x[0] for x in actions]), sorted(deduped_actions))
+        return deduped_actions
+
+    # Verify that we validate that features with duplicate action names are not
+    # permitted
+    assert_fail(
+        to_untyped_config,
+        action_configs = [action_configs.all_c_compile, action_configs.c_compile],
+        features = [features.foo],
+    )
+
+    # Verify that the validation on implied features works (foo must exist for
+    # an action config that implies foo).
+    assert_fail(to_untyped_config, action_configs = [action_configs.all_c_compile])
+    assert_eq(
+        get_action_configs(
+            action_configs = [action_configs.all_c_compile],
+            features = [features.foo],
+        )["c-compile"].implies,
+        ["foo"],
+    )
+
+    # Verify that flag sets get added iff they match the action.
+    acs = get_action_configs(
+        action_configs = [action_configs.all_c_compile],
+        features = [features.foo],
+        flag_sets = [flag_sets.bar],
+    )
+    assert_eq(
+        {k: [fs.flag_groups for fs in v.flag_sets] for k, v in acs.items()},
+        {
+            "c-compile": [list(flag_sets.bar.flag_groups)],
+            "cc-flags-make-variable": [],
+        },
+    )
+
+test_action_configs = generate_test_rule(_test_action_configs_impl)
diff --git a/pw_toolchain_bazel/cc_toolchain/tests/utils.bzl b/pw_toolchain_bazel/cc_toolchain/tests/utils.bzl
index d306231..c856711 100644
--- a/pw_toolchain_bazel/cc_toolchain/tests/utils.bzl
+++ b/pw_toolchain_bazel/cc_toolchain/tests/utils.bzl
@@ -15,6 +15,7 @@
 
 load(
     "//cc_toolchain/private:providers.bzl",
+    "PwActionConfigSetInfo",
     "PwFeatureConstraintInfo",
     "PwFeatureInfo",
     "PwFeatureSetInfo",
@@ -92,9 +93,12 @@
     PwFeatureConstraintInfo: "feature_constraints",
     PwFeatureInfo: "features",
     PwFeatureSetInfo: "feature_sets",
+    PwActionConfigSetInfo: "action_configs",
 }
 
 _PROVIDERS = {
+    "//cc_toolchain/tests/action_configs:all_c_compile": [PwActionConfigSetInfo],
+    "//cc_toolchain/tests/action_configs:c_compile": [PwActionConfigSetInfo],
     "//cc_toolchain/tests/features:bar": [PwFeatureInfo, PwFeatureSetInfo],
     "//cc_toolchain/tests/features:baz": [PwFeatureInfo, PwFeatureSetInfo],
     "//cc_toolchain/tests/features:conflict": [PwFeatureInfo, PwFeatureSetInfo],
@@ -131,12 +135,19 @@
             for provider in _PROVIDERS["//%s:%s" % (pkg, name)]:
                 providers[_RULES[provider]][name] = target[provider]
 
-        def to_untyped_config(features = [], feature_sets = [], fail = fail):
+        def to_untyped_config(features = [], feature_sets = [], action_configs = [], flag_sets = [], fail = fail):
             feature_set = PwFeatureSetInfo(features = depset(
                 features + [ft[PwFeatureInfo] for ft in ctx.attr.builtin_features],
                 transitive = [fs.features for fs in feature_sets],
             ))
-            return _to_untyped_config(feature_set, fail = fail)
+            action_config_set = PwActionConfigSetInfo(
+                label = ctx.label,
+                action_configs = depset(transitive = [
+                    acs.action_configs
+                    for acs in action_configs
+                ]),
+            )
+            return _to_untyped_config(feature_set, action_config_set, flag_sets, fail = fail)
 
         kwargs = {k: struct(**v) for k, v in providers.items()}
         return implementation(ctx, to_untyped_config = to_untyped_config, **kwargs)