feat: Add StructSubject

StructSubject is a subject to wrap structs and return their values as
other subjects. This makes it easier to test ad-hoc struct values, such
as ones returned by helper functions, because a dedicated subject
implementation doesn't need to be written. All that needs to be provided
are the attribute names and factory functions to handle them.

Fixes https://github.com/bazelbuild/rules_testing/issues/53
diff --git a/docgen/BUILD b/docgen/BUILD
index 774964a..7c11e2a 100644
--- a/docgen/BUILD
+++ b/docgen/BUILD
@@ -43,6 +43,7 @@
         "//lib/private:run_environment_info_subject_bzl",
         "//lib/private:runfiles_subject_bzl",
         "//lib/private:str_subject_bzl",
+        "//lib/private:struct_subject_bzl",
         "//lib/private:target_subject_bzl",
     ],
     tags = ["docs"],
diff --git a/docs/crossrefs.md b/docs/crossrefs.md
index 59d6be1..8c2106f 100644
--- a/docs/crossrefs.md
+++ b/docs/crossrefs.md
@@ -19,7 +19,9 @@
 [`Ordered`]: /api/ordered
 [`RunfilesSubject`]: /api/runfiles_subject
 [`str`]: https://bazel.build/rules/lib/string
+[`struct`]: https://bazel.build/rules/lib/builtins/struct
 [`StrSubject`]: /api/str_subject
+[`StructSubject`]: /api/struct_subject
 [`Target`]: https://bazel.build/rules/lib/Target
 [`TargetSubject`]: /api/target_subject
 [target-name]: https://bazel.build/concepts/labels#target-names
diff --git a/lib/BUILD b/lib/BUILD
index 6ecb821..1495a2d 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -46,6 +46,7 @@
         "//lib/private:int_subject_bzl",
         "//lib/private:label_subject_bzl",
         "//lib/private:matching_bzl",
+        "//lib/private:struct_subject_bzl",
     ],
 )
 
diff --git a/lib/private/BUILD b/lib/private/BUILD
index 7e0235f..bc9963f 100644
--- a/lib/private/BUILD
+++ b/lib/private/BUILD
@@ -216,6 +216,11 @@
 )
 
 bzl_library(
+    name = "struct_subject_bzl",
+    srcs = ["struct_subject.bzl"],
+)
+
+bzl_library(
     name = "target_subject_bzl",
     srcs = ["target_subject.bzl"],
     deps = [
@@ -247,6 +252,7 @@
         ":file_subject_bzl",
         ":int_subject_bzl",
         ":str_subject_bzl",
+        ":struct_subject_bzl",
         ":target_subject_bzl",
     ],
 )
diff --git a/lib/private/expect.bzl b/lib/private/expect.bzl
index 0f0ef5a..ab90fd9 100644
--- a/lib/private/expect.bzl
+++ b/lib/private/expect.bzl
@@ -23,6 +23,7 @@
 load(":file_subject.bzl", "FileSubject")
 load(":int_subject.bzl", "IntSubject")
 load(":str_subject.bzl", "StrSubject")
+load(":struct_subject.bzl", "StructSubject")
 load(":target_subject.bzl", "TargetSubject")
 
 def _expect_new_from_env(env):
@@ -78,6 +79,7 @@
         that_file = lambda *a, **k: _expect_that_file(self, *a, **k),
         that_int = lambda *a, **k: _expect_that_int(self, *a, **k),
         that_str = lambda *a, **k: _expect_that_str(self, *a, **k),
+        that_struct = lambda *a, **k: _expect_that_struct(self, *a, **k),
         that_target = lambda *a, **k: _expect_that_target(self, *a, **k),
         where = lambda *a, **k: _expect_where(self, *a, **k),
         # keep sorted end
@@ -207,6 +209,18 @@
     """
     return StrSubject.new(value, self.meta.derive("string"))
 
+def _expect_that_struct(self, value):
+    """Creates a subject for asserting a `struct`.
+
+    Args:
+        self: implicitly added.
+        value: ([`struct`]) the value to check against.
+
+    Returns:
+        [`StructSubject`] object.
+    """
+    return StructSubject.new(value, self.meta.derive("string"))
+
 def _expect_that_target(self, target):
     """Creates a subject for asserting a `Target`.
 
@@ -257,6 +271,7 @@
 # We use this name so it shows up nice in docs.
 # buildifier: disable=name-conventions
 Expect = struct(
+    # keep sorted start
     new_from_env = _expect_new_from_env,
     new = _expect_new,
     that_action = _expect_that_action,
@@ -267,6 +282,8 @@
     that_file = _expect_that_file,
     that_int = _expect_that_int,
     that_str = _expect_that_str,
+    that_struct = _expect_that_struct,
     that_target = _expect_that_target,
     where = _expect_where,
+    # keep sorted end
 )
diff --git a/lib/private/struct_subject.bzl b/lib/private/struct_subject.bzl
new file mode 100644
index 0000000..3a3c71a
--- /dev/null
+++ b/lib/private/struct_subject.bzl
@@ -0,0 +1,108 @@
+# Copyright 2023 The Bazel Authors. All rights reserved.
+#
+# 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
+#
+#     http://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.
+"""# StructSubject
+
+A subject for arbitrary structs. This is most useful when wrapping an ad-hoc
+struct (e.g. a struct specific to a particular function). Such ad-hoc structs
+are usually just plain data objects, so they don't need special functionality
+that writing a full custom subject allows. If a struct would benefit from
+custom accessors or asserts, write a custom subject instead.
+
+This subject is usually used as a helper to a more formally defined subject that
+knows the shape of the struct it needs to wrap. For example, a `FooInfoSubject`
+implementation might use it to handle `FooInfo.struct_with_a_couple_fields`.
+
+Note the resulting subject object is not a direct replacement for the struct
+being wrapped:
+    * Structs wrapped by this subject have the attributes exposed as functions,
+      not as plain attributes. This matches the other subject classes and defers
+      converting an attribute to a subject unless necessary.
+    * The attribute name `actual` is reserved.
+
+
+## Example usages
+
+To use it as part of a custom subject returning a sub-value, construct it using
+`subjects.struct()` like so:
+
+```starlark
+load("@rules_testing//lib:truth.bzl", "subjects")
+
+def _my_subject_foo(self):
+    return subjects.struct(
+        self.actual.foo,
+        meta = self.meta.derive("foo()",
+        attrs = dict(a=subjects.int, b=subjects.str),
+    )
+```
+
+If you're checking a struct directly in a test, then you can use
+`Expect.that_struct`. You'll still have to pass the `attrs` arg so it knows how
+to map the attributes to the matching subject factories.
+
+```starlark
+def _foo_test(env):
+    actual = env.expect.that_struct(
+        struct(a=1, b="x"),
+        attrs = dict(a=subjects.int, b=subjects.str)
+    )
+    actual.a().equals(1)
+    actual.b().quals("x")
+```
+"""
+
+def _struct_subject_new(actual, *, meta, attrs):
+    """Creates a `StructSubject`, which is a thin wrapper around a [`struct`].
+
+    Args:
+        actual: ([`struct`]) the struct to wrap.
+        meta: ([`ExpectMeta`]) object of call context information.
+        attrs: ([`dict`] of [`str`] to [`callable`]) the functions to convert
+            attributes to subjects. The keys are attribute names that must
+            exist on `actual`. The values are functions with the signature
+            `def factory(value, *, meta)`, where `value` is the actual attribute
+            value of the struct, and `meta` is an [`ExpectMeta`] object.
+
+    Returns:
+        [`StructSubject`] object, which is a struct with the following shape:
+          * `actual` attribute, the underlying struct that was wrapped.
+          * A callable attribute for each `attrs` entry; it takes no args
+            and returns what the corresponding factory from `attrs` returns.
+    """
+    attr_accessors = {}
+    for name, factory in attrs.items():
+        if not hasattr(actual, name):
+            fail("Struct missing attribute: '{}' (from expression {})".format(
+                name,
+                meta.current_expr(),
+            ))
+        attr_accessors[name] = _make_attr_accessor(actual, name, factory, meta)
+
+    public = struct(actual = actual, **attr_accessors)
+    return public
+
+def _make_attr_accessor(actual, name, factory, meta):
+    # A named function is used instead of a lambda so stack traces are easier to
+    # grok.
+    def attr_accessor():
+        return factory(getattr(actual, name), meta = meta.derive(name + "()"))
+
+    return attr_accessor
+
+# buildifier: disable=name-conventions
+StructSubject = struct(
+    # keep sorted start
+    new = _struct_subject_new,
+    # keep sorted end
+)
diff --git a/lib/private/truth_common.bzl b/lib/private/truth_common.bzl
index c7e6b60..ce249d6 100644
--- a/lib/private/truth_common.bzl
+++ b/lib/private/truth_common.bzl
@@ -1,3 +1,17 @@
+# Copyright 2023 The Bazel Authors. All rights reserved.
+#
+# 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
+#
+#     http://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.
+
 """Common code used by truth."""
 
 load("@bazel_skylib//lib:types.bzl", "types")
@@ -16,6 +30,8 @@
     value_str = str(value)
     if not value_str:
         return "<empty string ∅>"
+    elif "\n" in value_str:
+        return '"""{}""" <sans triple-quotes; note newlines and whitespace>'.format(value_str)
     elif value_str != value_str.strip():
         return '"{}" <sans quotes; note whitespace within>'.format(value_str)
     else:
diff --git a/lib/truth.bzl b/lib/truth.bzl
index 51e8093..e1736e9 100644
--- a/lib/truth.bzl
+++ b/lib/truth.bzl
@@ -54,6 +54,7 @@
 load("//lib/private:str_subject.bzl", "StrSubject")
 load("//lib/private:target_subject.bzl", "TargetSubject")
 load("//lib/private:matching.bzl", _matching = "matching")
+load("//lib/private:struct_subject.bzl", "StructSubject")
 
 # Rather than load many symbols, just load this symbol, and then all the
 # asserts will be available.
@@ -75,6 +76,7 @@
     label = LabelSubject.new,
     runfiles = RunfilesSubject.new,
     str = StrSubject.new,
+    struct = StructSubject.new,
     target = TargetSubject.new,
     # keep sorted end
 )
diff --git a/tests/struct_subject/BUILD.bazel b/tests/struct_subject/BUILD.bazel
new file mode 100644
index 0000000..17c9864
--- /dev/null
+++ b/tests/struct_subject/BUILD.bazel
@@ -0,0 +1,3 @@
+load(":struct_subject_tests.bzl", "struct_subject_test_suite")
+
+struct_subject_test_suite(name = "struct_subject_tests")
diff --git a/tests/struct_subject/struct_subject_tests.bzl b/tests/struct_subject/struct_subject_tests.bzl
new file mode 100644
index 0000000..58d18ff
--- /dev/null
+++ b/tests/struct_subject/struct_subject_tests.bzl
@@ -0,0 +1,53 @@
+# Copyright 2023 The Bazel Authors. All rights reserved.
+#
+# 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
+#
+#     http://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 StructSubject"""
+
+load("//lib:truth.bzl", "subjects")
+load("//lib:test_suite.bzl", "test_suite")
+load("//tests:test_util.bzl", "test_util")
+
+_tests = []
+
+def _struct_subject_test(env):
+    fake_meta = test_util.fake_meta(env)
+    actual = subjects.struct(
+        struct(n = 1, x = "foo"),
+        meta = fake_meta,
+        attrs = dict(
+            n = subjects.int,
+            x = subjects.str,
+        ),
+    )
+    actual.n().equals(1)
+    test_util.expect_no_failures(env, fake_meta, "struct.n()")
+
+    actual.n().equals(99)
+    test_util.expect_failures(
+        env,
+        fake_meta,
+        "struct.n() failure",
+        "expected: 99",
+    )
+
+    actual.x().equals("foo")
+    test_util.expect_no_failures(env, fake_meta, "struct.foo()")
+
+    actual.x().equals("not-foo")
+    test_util.expect_failures(env, fake_meta, "struct.foo() failure", "expected: not-foo")
+
+_tests.append(_struct_subject_test)
+
+def struct_subject_test_suite(name):
+    test_suite(name = name, basic_tests = _tests)
diff --git a/tests/test_util.bzl b/tests/test_util.bzl
new file mode 100644
index 0000000..837f23c
--- /dev/null
+++ b/tests/test_util.bzl
@@ -0,0 +1,96 @@
+# Copyright 2023 The Bazel Authors. All rights reserved.
+#
+# 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
+#
+#     http://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.
+
+"""Utilities for testing rules_testing code."""
+
+# buildifier: disable=bzl-visibility
+load("//lib/private:expect_meta.bzl", "ExpectMeta")
+load("//lib:truth.bzl", "matching")
+
+def _fake_meta(real_env):
+    """Create a fake ExpectMeta object for testing.
+
+    The fake ExpectMeta object copies a real ExpectMeta object, except:
+      * Failures are only recorded and don't cause a failure in `real_env`.
+      * `failures` attribute is added; this is a list of failures seen.
+      * `reset` attribute is added; this clears the failures list.
+
+    Args:
+        real_env: A real env object from the rules_testing framework.
+
+    Returns:
+        struct, a fake ExpectMeta object.
+    """
+    failures = []
+    fake_env = struct(
+        ctx = real_env.ctx,
+        fail = lambda msg: failures.append(msg),
+        failures = failures,
+    )
+    meta_impl = ExpectMeta.new(fake_env)
+    meta_impl_kwargs = {
+        attr: getattr(meta_impl, attr)
+        for attr in dir(meta_impl)
+        if attr not in ("to_json", "to_proto")
+    }
+    fake_meta = struct(
+        failures = failures,
+        reset = lambda: failures.clear(),
+        **meta_impl_kwargs
+    )
+    return fake_meta
+
+def _expect_no_failures(env, fake_meta, case):
+    """Check that a fake meta object had no failures.
+
+    NOTE: This clears the list of failures after checking. This is done
+    so that an earlier failure is only reported once.
+
+    Args:
+        env: Real `Expect` object to perform asserts.
+        fake_meta: A fake meta object that had failures recorded.
+        case: str, a description of the case that was tested.
+    """
+    env.expect.that_collection(
+        fake_meta.failures,
+        expr = case,
+    ).contains_exactly([])
+    fake_meta.reset()
+
+def _expect_failures(env, fake_meta, case, *errors):
+    """Check that a fake meta object has matching error strings.
+
+    NOTE: This clears the list of failures after checking. This is done
+    so that an earlier failure is only reported once.
+
+    Args:
+        env: Real `Expect` object to perform asserts.
+        fake_meta: A fake meta object that had failures recorded.
+        case: str, a description of the case that was tested.
+        *errors: list of strings. These are patterns to match, as supported
+            by `matching.str_matches` (e.g. `*`-style patterns)
+    """
+    env.expect.that_collection(
+        fake_meta.failures,
+        expr = case,
+    ).contains_at_least_predicates(
+        [matching.str_matches(e) for e in errors],
+    )
+    fake_meta.reset()
+
+test_util = struct(
+    fake_meta = _fake_meta,
+    expect_no_failures = _expect_no_failures,
+    expect_failures = _expect_failures,
+)