diff --git a/docs/docs/examples/advanced.mdx b/docs/docs/examples/advanced.mdx index d704e5f..7db1be6 100644 --- a/docs/docs/examples/advanced.mdx +++ b/docs/docs/examples/advanced.mdx @@ -1,6 +1,6 @@ --- -sidebar_position: 7 -title: 6. Advanced +sidebar_position: 8 +title: 7. Advanced --- import {ExampleSource} from '@site/src/components/ExampleDoc'; diff --git a/docs/docs/examples/bind.mdx b/docs/docs/examples/bind.mdx index d3c19fa..9e26586 100644 --- a/docs/docs/examples/bind.mdx +++ b/docs/docs/examples/bind.mdx @@ -1,6 +1,6 @@ --- -sidebar_position: 5 -title: 4. Bind +sidebar_position: 6 +title: 5. Bind --- import {ExampleSource} from '@site/src/components/ExampleDoc'; diff --git a/docs/docs/examples/callback-entry-point.mdx b/docs/docs/examples/callback-entry-point.mdx index d320d4d..2d22e90 100644 --- a/docs/docs/examples/callback-entry-point.mdx +++ b/docs/docs/examples/callback-entry-point.mdx @@ -1,6 +1,6 @@ --- -sidebar_position: 6 -title: 5. Callback Entry Point +sidebar_position: 7 +title: 6. Callback Entry Point --- import {ExampleSource} from '@site/src/components/ExampleDoc'; diff --git a/docs/docs/examples/compiler.mdx b/docs/docs/examples/compiler.mdx index f3b8273..a06aeae 100644 --- a/docs/docs/examples/compiler.mdx +++ b/docs/docs/examples/compiler.mdx @@ -1,6 +1,6 @@ --- -sidebar_position: 9 -title: 9. Compiler Wrapper +sidebar_position: 11 +title: 10. Compiler Wrapper --- import {ExampleSource} from '@site/src/components/ExampleDoc'; diff --git a/docs/docs/examples/custom-action.mdx b/docs/docs/examples/custom-action.mdx index 705941c..568b9df 100644 --- a/docs/docs/examples/custom-action.mdx +++ b/docs/docs/examples/custom-action.mdx @@ -1,6 +1,6 @@ --- -sidebar_position: 7 -title: 7. Custom Action +sidebar_position: 9 +title: 8. Custom Action --- import {ExampleSource} from '@site/src/components/ExampleDoc'; diff --git a/docs/docs/examples/distribution-and-platform.mdx b/docs/docs/examples/distribution-and-platform.mdx index aac0f6b..b3aba3c 100644 --- a/docs/docs/examples/distribution-and-platform.mdx +++ b/docs/docs/examples/distribution-and-platform.mdx @@ -1,6 +1,6 @@ --- -sidebar_position: 10 -title: 10. Distribution and Platform Examples +sidebar_position: 12 +title: 11. Distribution and Platform Examples --- import {ExampleSource} from '@site/src/components/ExampleDoc'; diff --git a/docs/docs/examples/env-fallback.mdx b/docs/docs/examples/env-fallback.mdx index d849a20..24baeda 100644 --- a/docs/docs/examples/env-fallback.mdx +++ b/docs/docs/examples/env-fallback.mdx @@ -1,6 +1,6 @@ --- -sidebar_position: 11 -title: 11. Env Fallback +sidebar_position: 13 +title: 12. Env Fallback --- import {ExampleSource} from '@site/src/components/ExampleDoc'; diff --git a/docs/docs/examples/git.mdx b/docs/docs/examples/git.mdx index 2ee0bac..2469e29 100644 --- a/docs/docs/examples/git.mdx +++ b/docs/docs/examples/git.mdx @@ -1,6 +1,6 @@ --- -sidebar_position: 8 -title: 8. Git +sidebar_position: 10 +title: 9. Git --- import {ExampleSource} from '@site/src/components/ExampleDoc'; diff --git a/docs/docs/examples/index.mdx b/docs/docs/examples/index.mdx index cd0c31a..ad97cfc 100644 --- a/docs/docs/examples/index.mdx +++ b/docs/docs/examples/index.mdx @@ -18,21 +18,23 @@ Each one is small enough to copy into a project, but specific enough to answer Nested commands with their own help output and behavior. 3. [Validation](./validation) Parse-time checks for numbers, names, and filesystem arguments. -4. [Bind](./bind) +4. [Relations](./relations) + Schema-level relationships such as groups, conflicts, and depends_on. +5. [Bind](./bind) Write values directly into existing variables. -5. [Callback Entry Point](./callback-entry-point) +6. [Callback Entry Point](./callback-entry-point) Fill application state during parsing, then call the real entry point. -6. [Advanced](./advanced) +7. [Advanced](./advanced) Lower-level composition with explicit conversion, validation, and packing steps. -7. [Custom Action](./custom-action) +8. [Custom Action](./custom-action) Full struct-based action definition with `accepts_input`, `after_type`, and `storage_type`. -8. [Git](./git) +9. [Git](./git) A command-heavy CLI inspired by clap's git example, including nested subcommands. -9. [Compiler Wrapper](./compiler) +10. [Compiler Wrapper](./compiler) Short-cluster flags, negatable key-value options, and `std::map` storage with `insert_or_assign`. -10. [Distribution and Platform Examples](./distribution-and-platform) +11. [Distribution and Platform Examples](./distribution-and-platform) Single-header usage, C++20 modules, and the Windows Unicode sample used by CI. -11. [Env Fallback](./env-fallback) +12. [Env Fallback](./env-fallback) Populate options from environment variables with the same conversion and validation pipeline. ## Building diff --git a/docs/docs/examples/relations.mdx b/docs/docs/examples/relations.mdx new file mode 100644 index 0000000..a4c98f4 --- /dev/null +++ b/docs/docs/examples/relations.mdx @@ -0,0 +1,53 @@ +--- +sidebar_position: 5 +title: 4. Relations +--- + +import {ExampleSource} from '@site/src/components/ExampleDoc'; + +# Relations + +This example shows schema-level relationships between options. Use this pattern +when the rule can be described from occurrence alone and should also appear in +generated help. Relation metadata is rendered inline on affected options, while +named groups get their own `Option groups:` entry. + +## Source + + + +## What This Example Shows + +The example defines three static relations: + +- `user` and `password` are a named `legacy_auth` group and must be provided together. +- `json` conflicts with `markdown`. +- the named `legacy_auth` group depends on `profile`. + +The parser enforces these rules after normal option parsing and before the +program uses the parsed values. + +## Try It + +```bash +./build/examples/relations --json +./build/examples/relations --user alice --password secret --profile prod +./build/examples/relations --markdown --profile prod +``` + +Failure cases: + +- `--user alice` fails because `password` is missing from the `legacy_auth` group. +- `--json --markdown` fails because the two output formats conflict. +- `--user alice --password secret` fails because `legacy_auth` depends on `profile`. +- `--help` shows relation metadata next to `--user`, `--password`, `--json`, + `--markdown`, and `--profile`. + +## Related Examples + +- [Validation](/docs/examples/validation) +- [Advanced](/docs/examples/advanced) diff --git a/docs/docs/relations.mdx b/docs/docs/relations.mdx new file mode 100644 index 0000000..0ca0535 --- /dev/null +++ b/docs/docs/relations.mdx @@ -0,0 +1,103 @@ +--- +sidebar_position: 4 +title: Relations +--- + +# Relations + +Use `static constexpr auto relations` when a relationship between fields should +be part of the typed schema, generated help, and parse-time validation. The +help generator renders short relation metadata inline on affected options, and +named groups in an `Option groups:` section. + +This is intentionally metadata, not a replacement for `constraints()`. +Relations cover simple occurrence-based rules that are independent of runtime +values. Keep `constraints()` for value-dependent logic such as "`--type foo` +requires `--foo-config`". + +## API + +```cpp +struct Args { + cli::StringOption<"user"> user; + cli::StringOption<"password"> password; + cli::Flag<"json"> json; + cli::Flag<"markdown"> markdown; + cli::StringOption<"profile"> profile; + + static constexpr auto relations = cli::relations( + cli::group({.name = "legacy_auth", .members = {"user", "password"}}), + cli::conflicts("json", "markdown"), + cli::depends_on("legacy_auth", "profile")); +}; +``` + +Names are written without the command-line prefix: use `"json"`, not +`"--json"`. + +## Relation Types + +- `cli::group({.name = "...", .members = {...}})` requires all members in the + named group to be provided together, or none of them. +- `cli::conflicts("a", "b")` rejects input when both operands are provided. +- `cli::depends_on("a", "b")` rejects input when `a` is provided but `b` is not. + +All checks are based on `provided()`: defaults do not count as CLI occurrence. + +## Named Groups + +`group` creates a named group that can be reused as an operand in another +relation: + +```cpp +static constexpr auto relations = cli::relations( + cli::group({.name = "legacy_auth", .members = {"user", "password"}}), + cli::depends_on("legacy_auth", "profile")); +``` + +This means `--user` and `--password` must appear together, and that legacy auth +requires `--profile`. + +The generated help shows the rule where users look for it: + +```text +--user User name [requires: --profile] +--password Password [requires: --profile] +--profile Profile [required by: legacy_auth] + +Option groups: + legacy_auth: + --user, --password must be used together + requires: --profile +``` + +## Well-Formed Schema Rules + +The relation schema is checked at compile time as part of `ArgumentSpec`. + +- Every field or group name used by a relation must exist. +- A `group` needs a non-empty unique name and at least two members. +- A group name must not collide with an option or command name. +- Each field may participate in only one direct relation. +- `conflicts("a", "a")` and `depends_on("a", "a")` are invalid. + +These rules are deliberately strict. They keep help text and parse-time +validation unambiguous. + +## Relation Metadata vs `constraints()` + +Use relations when the rule is static and occurrence-based: + +- `--json` conflicts with `--markdown` +- `legacy_auth` depends on `--profile` +- `--user` and `--password` must be provided together + +Use `constraints()` when the rule depends on parsed values: + +- `--type s3` requires `--bucket` +- `--mode fast` rejects `--exact` +- one numeric option must be greater than another + +## Example + +See [Relations Example](./examples/relations) for a complete program. diff --git a/docs/package-lock.json b/docs/package-lock.json index 3a3b5b2..2942081 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -13,6 +13,7 @@ "@docusaurus/plugin-google-gtag": "^3.10.1", "@docusaurus/preset-classic": "3.10.1", "@mdx-js/react": "^3.0.0", + "ansi_up": "^6.0.6", "clsx": "^2.0.0", "prism-react-renderer": "^2.3.0", "react": "^19.0.0", @@ -6796,6 +6797,15 @@ "algoliasearch": ">= 3.1 < 6" } }, + "node_modules/ansi_up": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/ansi_up/-/ansi_up-6.0.6.tgz", + "integrity": "sha512-yIa1x3Ecf8jWP4UWEunNjqNX6gzE4vg2gGz+xqRGY+TBSucnYp6RRdPV4brmtg6bQ1ljD48mZ5iGSEj7QEpRKA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/ansi-align": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", diff --git a/docs/package.json b/docs/package.json index 94ea4af..4c04551 100644 --- a/docs/package.json +++ b/docs/package.json @@ -22,6 +22,7 @@ "@docusaurus/plugin-google-gtag": "^3.10.1", "@docusaurus/preset-classic": "3.10.1", "@mdx-js/react": "^3.0.0", + "ansi_up": "^6.0.6", "clsx": "^2.0.0", "prism-react-renderer": "^2.3.0", "react": "^19.0.0", diff --git a/docs/sidebars.ts b/docs/sidebars.ts index ba892d3..2880a57 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -5,6 +5,7 @@ const sidebars: SidebarsConfig = { 'intro', 'tutorial', 'actions', + 'relations', { type: 'category', label: 'Examples', diff --git a/docs/src/components/ExamplePlayground.tsx b/docs/src/components/ExamplePlayground.tsx index 44c27e1..0bd2ca2 100644 --- a/docs/src/components/ExamplePlayground.tsx +++ b/docs/src/components/ExamplePlayground.tsx @@ -1,5 +1,13 @@ import React, {useEffect, useRef, useState} from 'react'; import useBaseUrl from '@docusaurus/useBaseUrl'; +import {AnsiUp} from 'ansi_up'; + +const ansi = new AnsiUp(); +ansi.escape_html = true; + +function ansiToHtml(text: string): string { + return ansi.ansi_to_html(text); +} function splitArgs(input: string): string[] { const args: string[] = []; @@ -361,7 +369,11 @@ export function ExamplePlayground({ )} {entry.output && ( -
{entry.output}
+
               )}
             
           ))}
diff --git a/examples/relations.cc b/examples/relations.cc
new file mode 100644
index 0000000..e660600
--- /dev/null
+++ b/examples/relations.cc
@@ -0,0 +1,36 @@
+#include 
+
+#include "cli/argument.hh"
+#include "cli/parser.hh"
+
+struct Args {
+  cli::Description description{
+      "Relation metadata example covering groups, conflicts, and depends_on."};
+  cli::Help<> help;
+
+  cli::StringOption<"user"> user{{.help = "Legacy auth user name"}};
+  cli::StringOption<"password"> password{{.help = "Legacy auth password"}};
+
+  cli::Flag<"json"> json{{.help = "Write JSON output"}};
+  cli::Flag<"markdown"> markdown{{.help = "Write Markdown output"}};
+
+  cli::StringOption<"profile"> profile{{.help = "Deployment profile"}};
+
+  static constexpr auto relations = cli::relations(
+      cli::group({.name = "legacy_auth", .members = {"user", "password"}}),
+      cli::conflicts("json", "markdown"),
+      cli::depends_on("legacy_auth", "profile"));
+};
+
+auto main(int argc, char* argv[]) -> int {
+  const auto args = cli::parse_or_exit(argc, argv);
+
+  std::cout << "format: " << (args.json ? "json" : "markdown") << '\n';
+  if (args.user) {
+    std::cout << "legacy auth: " << *args.user << '\n';
+  }
+  if (args.profile) {
+    std::cout << "profile: " << *args.profile << '\n';
+  }
+  return 0;
+}
diff --git a/flake.nix b/flake.nix
index c25bf3f..518f6ef 100644
--- a/flake.nix
+++ b/flake.nix
@@ -102,7 +102,7 @@
               npmDeps = pkgs.fetchNpmDeps {
                 name = "cli20-docs-0.0.0-npm-deps";
                 src = ./docs;
-                hash = "sha256-DOI2KP2qq8zbUjX3Kktgfe16n567pNkkGz5ABGSgSdo=";
+                hash = "sha256-ntnQTTWdih6fLx6QLqarpJQ6pIOdDWRxmysDQQVWswQ=";
               };
               nativeBuildInputs = [
                 pkgs.emscripten
diff --git a/include/cli/argument.hh b/include/cli/argument.hh
index 19d1990..be8dee3 100644
--- a/include/cli/argument.hh
+++ b/include/cli/argument.hh
@@ -9,332 +9,14 @@
 #include 
 
 #include "cli/action.hh"
+#include "cli/argument_base.hh"
+#include "cli/argument_validation.hh"
 #include "cli/meta.hh"
+#include "cli/relation.hh"
 #include "cli/string_literal.hh"
 
 namespace cli {
 
-/** @brief Base tag for all members that may appear in an argument specification
- * struct. */
-struct SpecMemberTag {};
-
-/** @brief Tag base class for command-line option/flag fields. */
-struct OptionTag : SpecMemberTag {};
-
-/** @brief Tag base class for positional argument fields. */
-struct PositionalTag : SpecMemberTag {};
-
-/** @brief Tag base class for subcommand fields. */
-struct CommandTag : SpecMemberTag {};
-
-/** @brief Tag base class for the `Description` field that provides the CLI's
- * help text. */
-struct DescriptionTag : SpecMemberTag {};
-
-/**
- * @brief Specifies the minimum and maximum number of values an option or
- * positional accepts.
- *
- * The default `{0, -1}` is equivalent to `nargs::zero_or_more`.
- *
- * **Valid ranges:**
- * - `min >= 0`
- * - `max >= 1`, or `max == -1` which means *unlimited* (no upper bound)
- * - `min <= max` (when `max != -1`)
- *
- * Prefer the predefined constants in the `cli::nargs` namespace over
- * constructing `Nargs` directly.
- */
-struct Nargs {
-  int min = 0;   ///< Minimum number of values (>= 0).
-  int max = -1;  ///< Maximum number of values; -1 means unlimited.
-};
-
-/**
- * @brief Controls whether an option or positional argument is mandatory.
- *
- * When `required`, the parser returns `ErrorCode::missing_required` if the
- * argument is absent. When `optional` (the default), absence is allowed.
- */
-enum class Presence { required, optional };
-
-/** @brief Convenience constant for `Presence::required`. */
-inline constexpr auto required = Presence::required;
-
-/** @brief Convenience constant for `Presence::optional`. */
-inline constexpr auto optional = Presence::optional;
-
-/**
- * @brief Predefined `Nargs` constants for common value-count patterns.
- */
-namespace nargs {
-
-/** @brief Accepts no values; used for boolean flags. `{0, 0}` */
-inline constexpr Nargs none{.min = 0, .max = 0};
-
-/** @brief Accepts exactly one value. `{1, 1}` */
-inline constexpr Nargs one{.min = 1, .max = 1};
-
-/** @brief Accepts zero or one value. `{0, 1}` */
-inline constexpr Nargs zero_or_one{.min = 0, .max = 1};
-
-/** @brief Accepts zero or more values. `{0, -1}` */
-inline constexpr Nargs zero_or_more{.min = 0, .max = -1};
-
-/** @brief Accepts one or more values. `{1, -1}` */
-inline constexpr Nargs one_or_more{.min = 1, .max = -1};
-
-/**
- * @brief Accepts exactly `N` values.
- * @tparam N The required number of values.
- */
-template 
-inline constexpr Nargs exactly{.min = N, .max = N};
-
-/**
- * @brief Accepts between `Min` and `Max` values (inclusive).
- * @tparam Min Minimum number of values.
- * @tparam Max Maximum number of values.
- */
-template 
-inline constexpr Nargs between{.min = Min, .max = Max};
-
-}  // namespace nargs
-
-namespace detail {
-
-/**
- * @brief Checks that every member of aggregate type `T` derives from
- * `SpecMemberTag`.
- *
- * @tparam T The argument specification type to validate.
- * @return `true` if all members inherit from `SpecMemberTag`.
- */
-template 
-consteval auto members_are_derived_from_valid_cli_class() -> bool {
-  return [](std::type_identity>) consteval
-             -> auto {
-    return (std::derived_from, SpecMemberTag> && ...);
-  }(std::type_identity<
-                 std::remove_cvref_t()))>>{});
-}
-
-/**
- * @brief Checks that all `OptionTag` members of `T` have distinct long names.
- *
- * @tparam T The argument specification type to validate.
- * @return `true` if no two options share the same long name.
- */
-template 
-consteval auto options_have_unique_long_name() -> bool {
-  std::vector names;
-  [&](std::type_identity>) consteval
-      -> auto {
-    (
-        [&]() consteval -> auto {
-          if constexpr (std::derived_from,
-                                          OptionTag>) {
-            names.push_back(std::remove_cvref_t::name.view());
-          }
-        }(),
-        ...);
-  }(std::type_identity<
-          std::remove_cvref_t()))>>{});
-  std::ranges::sort(names);
-  return std::ranges::adjacent_find(names) == names.end();
-}
-
-/**
- * @brief Checks that all `OptionTag` members of `T` have distinct short names.
- *
- * Options with no short name (`'\0'`) are excluded from the uniqueness check.
- *
- * @tparam T The argument specification type to validate.
- * @return `true` if no two options share the same non-null short name.
- */
-template 
-consteval auto options_have_unique_short_name() -> bool {
-  std::vector names;
-  [&](std::type_identity>) consteval
-      -> auto {
-    (
-        [&]() consteval -> auto {
-          if constexpr (std::derived_from,
-                                          OptionTag>) {
-            if constexpr (std::remove_cvref_t::short_name != '\0') {
-              names.push_back(std::remove_cvref_t::short_name);
-            }
-          }
-        }(),
-        ...);
-  }(std::type_identity<
-          std::remove_cvref_t()))>>{});
-  std::ranges::sort(names);
-  return std::ranges::adjacent_find(names) == names.end();
-}
-
-/**
- * @brief Checks that all `CommandTag` members of `T` have distinct names.
- *
- * @tparam T The argument specification type to validate.
- * @return `true` if no two subcommands share the same name.
- */
-template 
-consteval auto commands_have_unique_long_name() -> bool {
-  std::vector names;
-  [&](std::type_identity>) consteval
-      -> auto {
-    (
-        [&]() consteval -> auto {
-          if constexpr (std::derived_from,
-                                          CommandTag>) {
-            names.push_back(std::remove_cvref_t::name.view());
-          }
-        }(),
-        ...);
-  }(std::type_identity<
-          std::remove_cvref_t()))>>{});
-  std::ranges::sort(names);
-  return std::ranges::adjacent_find(names) == names.end();
-}
-
-/**
- * @brief Checks that any variadic positional appears after all fixed
- * positionals.
- *
- * A variadic positional is one where `nargs.min != nargs.max`. The constraint
- * ensures that token dispatch is unambiguous.
- *
- * @tparam T The argument specification type to validate.
- * @return `true` if no fixed positional follows a variadic positional.
- */
-template 
-consteval auto positionals_have_variadic_at_end() {
-  bool found_variadic = false;
-  bool found_positional_after_variadic = false;
-  [&](std::type_identity>) consteval
-      -> auto {
-    (
-        [&]() consteval -> auto {
-          if constexpr (std::derived_from,
-                                          PositionalTag>) {
-            if (found_variadic) {
-              found_positional_after_variadic = true;
-            }
-            if (std::remove_cvref_t::nargs.max !=
-                std::remove_cvref_t::nargs.min) {
-              found_variadic = true;
-            }
-          }
-        }(),
-        ...);
-  }(std::type_identity<
-          std::remove_cvref_t()))>>{});
-  return !found_positional_after_variadic;
-}
-
-/**
- * @brief Validates the syntax of a long option name at compile time.
- *
- * Rules:
- * - Must be non-empty.
- * - Must start with a lowercase ASCII letter.
- * - May contain lowercase letters, digits, and single hyphens.
- * - Must not contain consecutive hyphens or end with a hyphen.
- *
- * @tparam Name The option name to validate (without the `--` prefix).
- * @return `true` if `Name` is a valid long option name.
- */
-template 
-[[nodiscard]]
-constexpr auto is_valid_long_option_name() noexcept -> bool {
-  constexpr auto size = Name.size();
-
-  if constexpr (size == 0) {
-    return false;
-  }
-  auto is_alpha = [](char c) constexpr -> bool {
-    return ('a' <= c && c <= 'z');
-  };
-  auto is_digit = [](char c) constexpr -> bool { return '0' <= c && c <= '9'; };
-  auto is_alnum = [&](char c) constexpr -> bool {
-    return is_alpha(c) || is_digit(c);
-  };
-
-  if (!is_alpha(Name[0])) {
-    return false;
-  }
-  bool previous_is_hyphen = false;
-  for (std::size_t i = 1; i < size; ++i) {
-    const char c = Name[i];
-    if (c == '-') {
-      if (previous_is_hyphen) {
-        return false;
-      }
-      previous_is_hyphen = true;
-      continue;
-    }
-    if (!is_alnum(c)) {
-      return false;
-    }
-    previous_is_hyphen = false;
-  }
-
-  return !previous_is_hyphen;
-}
-
-/**
- * @brief Validates a short option name character at compile time.
- *
- * Accepts ASCII letters (upper or lower case) or `'\0'` (meaning no short name).
- *
- * @param Name The short option character to validate.
- * @return `true` if `Name` is a valid short option name.
- */
-constexpr auto is_valid_short_option_name(char Name) noexcept -> bool {
-  return ('a' <= Name && Name <= 'z') || ('A' <= Name && Name <= 'Z') ||
-         Name == '\0';
-}
-
-/**
- * @brief Validates the syntax of a subcommand name at compile time.
- *
- * Applies the same rules as `is_valid_long_option_name`.
- *
- * @tparam Name The command name to validate.
- * @return `true` if `Name` is a valid command name.
- */
-template 
-[[nodiscard]]
-consteval auto is_valid_command_name() noexcept {
-  return is_valid_long_option_name();
-}
-
-/**
- * @brief Validates that a `Nargs` value satisfies the basic range constraints.
- *
- * Checks that `min >= 0` and that `max` is either -1 (unlimited) or
- * satisfies `max >= min`. The `ArgImpl` and `PositionalImpl` template
- * `requires` clauses enforce these same constraints at the point of
- * instantiation.
- *
- * @tparam NargsValue The `Nargs` value to validate.
- * @return `true` if `NargsValue` is a well-formed nargs specification.
- */
-template 
-[[nodiscard]]
-consteval auto is_valid_nargs() noexcept -> bool {
-  if (NargsValue.min < 0) {
-    return false;
-  }
-  if (NargsValue.max != -1 && NargsValue.max < NargsValue.min) {
-    return false;
-  }
-  return true;
-}
-
-}  // namespace detail
-
 /**
  * @brief Concept that validates an aggregate type as a well-formed CLI argument
  * specification.
@@ -358,6 +40,7 @@ concept ArgumentSpec = requires {
   requires detail::options_have_unique_short_name();
   requires detail::commands_have_unique_long_name();
   requires detail::positionals_have_variadic_at_end();
+  requires detail::relations_are_well_formed();
 };
 
 template 
@@ -388,6 +71,9 @@ struct ArgParameter {
       on_parse{};          ///< Callback invoked once after parsing completes.
   std::string_view env{};  ///< Environment variable name to fall back to when
                            ///< the option is absent from the command line.
+  bool hidden{};  ///< Whether this argument is omitted from generated help.
+  std::string_view
+      deprecated{};  ///< Optional deprecation message shown in generated help.
 };
 
 namespace detail {
@@ -461,6 +147,15 @@ struct ArgImpl : public OptionTag {
   [[nodiscard]] auto help_text() const -> const std::string_view {
     return param_.help;
   }
+  [[nodiscard]] auto hidden() const -> bool { return param_.hidden; }
+  [[nodiscard]] auto deprecated() const -> std::string_view {
+    return param_.deprecated;
+  }
+  [[nodiscard]] auto presence() const -> Presence { return param_.presence; }
+  [[nodiscard]] auto env() const -> std::string_view { return param_.env; }
+  [[nodiscard]] auto default_value() const -> const std::optional& {
+    return param_.default_value;
+  }
 
  protected:
   static constexpr bool entry_is_optional =
@@ -613,6 +308,15 @@ struct PositionalImpl : public PositionalTag {
   [[nodiscard]] auto help_text() const -> const std::string_view {
     return param_.help;
   }
+  [[nodiscard]] auto hidden() const -> bool { return param_.hidden; }
+  [[nodiscard]] auto deprecated() const -> std::string_view {
+    return param_.deprecated;
+  }
+  [[nodiscard]] auto presence() const -> Presence { return param_.presence; }
+  [[nodiscard]] auto env() const -> std::string_view { return param_.env; }
+  [[nodiscard]] auto default_value() const -> const std::optional& {
+    return param_.default_value;
+  }
 
  protected:
   [[nodiscard]] auto get_param() const -> const ArgParameter& {
@@ -678,7 +382,10 @@ struct Description : public std::string, DescriptionTag {};
  */
 struct CommandParameter {
   std::string_view
-      help{};  ///< Brief description shown in the parent command's help.
+      help{};     ///< Brief description shown in the parent command's help.
+  bool hidden{};  ///< Whether this command is omitted from generated help.
+  std::string_view
+      deprecated{};  ///< Optional deprecation message shown in generated help.
 };
 
 /**
@@ -699,17 +406,26 @@ struct Command : public T, public CommandTag {
   using argument_type = T;
 
   Command() = default;
-  Command(CommandParameter param) : help_(param.help) {}
+  Command(CommandParameter param)
+      : help_(param.help),
+        hidden_(param.hidden),
+        deprecated_(param.deprecated) {}
   static constexpr auto name = Name;
   static constexpr auto command_name() -> std::string_view {
     return Name.view();
   }
   [[nodiscard]] auto provided() const -> bool { return provided_; }
   [[nodiscard]] auto help_text() const -> std::string_view { return help_; }
+  [[nodiscard]] auto hidden() const -> bool { return hidden_; }
+  [[nodiscard]] auto deprecated() const -> std::string_view {
+    return deprecated_;
+  }
   auto mark_provided() -> void { provided_ = true; }
 
  private:
   std::string_view help_{};
+  bool hidden_{};
+  std::string_view deprecated_{};
   bool provided_{};
 };
 
diff --git a/include/cli/argument_base.hh b/include/cli/argument_base.hh
new file mode 100644
index 0000000..06d5484
--- /dev/null
+++ b/include/cli/argument_base.hh
@@ -0,0 +1,69 @@
+#pragma once
+
+namespace cli {
+
+/** @brief Base tag for all members that may appear in an argument specification
+ * struct. */
+struct SpecMemberTag {};
+
+/** @brief Tag base class for command-line option/flag fields. */
+struct OptionTag : SpecMemberTag {};
+
+/** @brief Tag base class for positional argument fields. */
+struct PositionalTag : SpecMemberTag {};
+
+/** @brief Tag base class for subcommand fields. */
+struct CommandTag : SpecMemberTag {};
+
+/** @brief Tag base class for the `Description` field that provides the CLI's
+ * help text. */
+struct DescriptionTag : SpecMemberTag {};
+
+/**
+ * @brief Specifies the minimum and maximum number of values an option or
+ * positional accepts.
+ *
+ * The default `{0, -1}` is equivalent to `nargs::zero_or_more`.
+ */
+struct Nargs {
+  int min = 0;   ///< Minimum number of values (>= 0).
+  int max = -1;  ///< Maximum number of values; -1 means unlimited.
+};
+
+/**
+ * @brief Controls whether an option or positional argument is mandatory.
+ */
+enum class Presence { required, optional };
+
+/** @brief Convenience constant for `Presence::required`. */
+inline constexpr auto required = Presence::required;
+
+/** @brief Convenience constant for `Presence::optional`. */
+inline constexpr auto optional = Presence::optional;
+
+namespace nargs {
+
+/** @brief Accepts no values; used for boolean flags. `{0, 0}` */
+inline constexpr Nargs none{.min = 0, .max = 0};
+
+/** @brief Accepts exactly one value. `{1, 1}` */
+inline constexpr Nargs one{.min = 1, .max = 1};
+
+/** @brief Accepts zero or one value. `{0, 1}` */
+inline constexpr Nargs zero_or_one{.min = 0, .max = 1};
+
+/** @brief Accepts zero or more values. `{0, -1}` */
+inline constexpr Nargs zero_or_more{.min = 0, .max = -1};
+
+/** @brief Accepts one or more values. `{1, -1}` */
+inline constexpr Nargs one_or_more{.min = 1, .max = -1};
+
+template 
+inline constexpr Nargs exactly{.min = N, .max = N};
+
+template 
+inline constexpr Nargs between{.min = Min, .max = Max};
+
+}  // namespace nargs
+
+}  // namespace cli
diff --git a/include/cli/argument_validation.hh b/include/cli/argument_validation.hh
new file mode 100644
index 0000000..3cadc73
--- /dev/null
+++ b/include/cli/argument_validation.hh
@@ -0,0 +1,315 @@
+#pragma once
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include "cli/argument_base.hh"
+#include "cli/meta.hh"
+#include "cli/relation.hh"
+#include "cli/string_literal.hh"
+
+namespace cli::detail {
+
+template 
+consteval auto members_are_derived_from_valid_cli_class() -> bool {
+  return [](std::type_identity>) consteval
+             -> auto {
+    return (std::derived_from, SpecMemberTag> && ...);
+  }(std::type_identity<
+                 std::remove_cvref_t()))>>{});
+}
+
+template 
+consteval auto options_have_unique_long_name() -> bool {
+  std::vector names;
+  [&](std::type_identity>) consteval
+      -> auto {
+    (
+        [&]() consteval -> auto {
+          if constexpr (std::derived_from,
+                                          OptionTag>) {
+            names.push_back(std::remove_cvref_t::name.view());
+          }
+        }(),
+        ...);
+  }(std::type_identity<
+          std::remove_cvref_t()))>>{});
+  std::ranges::sort(names);
+  return std::ranges::adjacent_find(names) == names.end();
+}
+
+template 
+consteval auto options_have_unique_short_name() -> bool {
+  std::vector names;
+  [&](std::type_identity>) consteval
+      -> auto {
+    (
+        [&]() consteval -> auto {
+          if constexpr (std::derived_from,
+                                          OptionTag>) {
+            if constexpr (std::remove_cvref_t::short_name != '\0') {
+              names.push_back(std::remove_cvref_t::short_name);
+            }
+          }
+        }(),
+        ...);
+  }(std::type_identity<
+          std::remove_cvref_t()))>>{});
+  std::ranges::sort(names);
+  return std::ranges::adjacent_find(names) == names.end();
+}
+
+template 
+consteval auto commands_have_unique_long_name() -> bool {
+  std::vector names;
+  [&](std::type_identity>) consteval
+      -> auto {
+    (
+        [&]() consteval -> auto {
+          if constexpr (std::derived_from,
+                                          CommandTag>) {
+            names.push_back(std::remove_cvref_t::name.view());
+          }
+        }(),
+        ...);
+  }(std::type_identity<
+          std::remove_cvref_t()))>>{});
+  std::ranges::sort(names);
+  return std::ranges::adjacent_find(names) == names.end();
+}
+
+template 
+consteval auto positionals_have_variadic_at_end() {
+  bool found_variadic = false;
+  bool found_positional_after_variadic = false;
+  [&](std::type_identity>) consteval
+      -> auto {
+    (
+        [&]() consteval -> auto {
+          if constexpr (std::derived_from,
+                                          PositionalTag>) {
+            if (found_variadic) {
+              found_positional_after_variadic = true;
+            }
+            if (std::remove_cvref_t::nargs.max !=
+                std::remove_cvref_t::nargs.min) {
+              found_variadic = true;
+            }
+          }
+        }(),
+        ...);
+  }(std::type_identity<
+          std::remove_cvref_t()))>>{});
+  return !found_positional_after_variadic;
+}
+
+template 
+concept HasRelations = requires {
+  { T::relations } -> RelationSetLike;
+};
+
+template 
+consteval auto relation_target_name() -> std::string_view {
+  using F = std::remove_cvref_t;
+  if constexpr (std::derived_from ||
+                std::derived_from) {
+    return F::name.view();
+  } else {
+    return {};
+  }
+}
+
+template 
+consteval auto named_relation_targets() {
+  return [&](std::type_identity>) consteval
+             -> auto {
+    return std::array{
+        relation_target_name()...};
+  }(std::type_identity<
+                 std::remove_cvref_t()))>>{});
+}
+
+template 
+consteval auto contains_name(const Array& names, std::string_view name) -> bool {
+  for (auto candidate : names) {
+    if (!candidate.empty() && candidate == name) return true;
+  }
+  return false;
+}
+
+template 
+consteval auto group_name_exists_before(std::string_view name) -> bool {
+  if constexpr (I >= Limit) {
+    return false;
+  } else {
+    constexpr auto rel = std::get(T::relations.items);
+    using R = std::remove_cvref_t;
+    if constexpr (std::same_as) {
+      if (rel.name == name) return true;
+    }
+    return group_name_exists_before(name);
+  }
+}
+
+template 
+consteval auto group_name_exists(std::string_view name) -> bool {
+  constexpr auto size =
+      std::tuple_size_v>;
+  return group_name_exists_before(name);
+}
+
+template 
+consteval auto operand_exists(std::string_view name) -> bool {
+  constexpr auto field_names = named_relation_targets();
+  return contains_name(field_names, name) || group_name_exists(name);
+}
+
+template 
+consteval auto relation_membership_count(std::string_view name) -> std::size_t {
+  constexpr auto size =
+      std::tuple_size_v>;
+  if constexpr (I >= size) {
+    return 0;
+  } else {
+    constexpr auto rel = std::get(T::relations.items);
+    using R = std::remove_cvref_t;
+    std::size_t current = 0;
+    if constexpr (std::same_as) {
+      for (auto member : rel.members) {
+        if (member == name) ++current;
+      }
+    } else if constexpr (std::same_as) {
+      if (rel.left == name) ++current;
+      if (rel.right == name) ++current;
+    } else if constexpr (std::same_as) {
+      if (rel.source == name) ++current;
+      if (rel.target == name) ++current;
+    }
+    return current + relation_membership_count(name);
+  }
+}
+
+template 
+consteval auto field_relation_memberships_are_unique() -> bool {
+  constexpr auto field_names = named_relation_targets();
+  for (auto name : field_names) {
+    if (!name.empty() && relation_membership_count(name) > 1) {
+      return false;
+    }
+  }
+  return true;
+}
+
+template 
+consteval auto relation_items_are_well_formed() -> bool {
+  constexpr auto size =
+      std::tuple_size_v>;
+  if constexpr (I >= size) {
+    return true;
+  } else {
+    constexpr auto rel = std::get(T::relations.items);
+    using R = std::remove_cvref_t;
+    constexpr auto field_names = named_relation_targets();
+    if constexpr (std::same_as) {
+      if (rel.name.empty() || rel.members.size < 2 ||
+          contains_name(field_names, rel.name) ||
+          group_name_exists_before(rel.name)) {
+        return false;
+      }
+      for (auto member : rel.members) {
+        if (!contains_name(field_names, member)) return false;
+      }
+    } else if constexpr (std::same_as) {
+      if (rel.left.empty() || rel.right.empty() || rel.left == rel.right ||
+          !operand_exists(rel.left) || !operand_exists(rel.right)) {
+        return false;
+      }
+    } else if constexpr (std::same_as) {
+      if (rel.source.empty() || rel.target.empty() || rel.source == rel.target ||
+          !operand_exists(rel.source) || !operand_exists(rel.target)) {
+        return false;
+      }
+    } else {
+      return false;
+    }
+    return relation_items_are_well_formed();
+  }
+}
+
+template 
+consteval auto relations_are_well_formed() -> bool {
+  if constexpr (!HasRelations) {
+    return true;
+  } else {
+    return relation_items_are_well_formed() &&
+           field_relation_memberships_are_unique();
+  }
+}
+
+template 
+[[nodiscard]]
+constexpr auto is_valid_long_option_name() noexcept -> bool {
+  constexpr auto size = Name.size();
+
+  if constexpr (size == 0) {
+    return false;
+  }
+  auto is_alpha = [](char c) constexpr -> bool {
+    return ('a' <= c && c <= 'z');
+  };
+  auto is_digit = [](char c) constexpr -> bool { return '0' <= c && c <= '9'; };
+  auto is_alnum = [&](char c) constexpr -> bool {
+    return is_alpha(c) || is_digit(c);
+  };
+
+  if (!is_alpha(Name[0])) {
+    return false;
+  }
+  bool previous_is_hyphen = false;
+  for (std::size_t i = 1; i < size; ++i) {
+    const char c = Name[i];
+    if (c == '-') {
+      if (previous_is_hyphen) {
+        return false;
+      }
+      previous_is_hyphen = true;
+      continue;
+    }
+    if (!is_alnum(c)) {
+      return false;
+    }
+    previous_is_hyphen = false;
+  }
+
+  return !previous_is_hyphen;
+}
+
+constexpr auto is_valid_short_option_name(char Name) noexcept -> bool {
+  return ('a' <= Name && Name <= 'z') || ('A' <= Name && Name <= 'Z') ||
+         Name == '\0';
+}
+
+template 
+[[nodiscard]]
+consteval auto is_valid_command_name() noexcept {
+  return is_valid_long_option_name();
+}
+
+template 
+[[nodiscard]]
+consteval auto is_valid_nargs() noexcept -> bool {
+  if (NargsValue.min < 0) {
+    return false;
+  }
+  if (NargsValue.max != -1 && NargsValue.max < NargsValue.min) {
+    return false;
+  }
+  return true;
+}
+
+}  // namespace cli::detail
diff --git a/include/cli/color.hh b/include/cli/color.hh
index 779b2b7..8316e97 100644
--- a/include/cli/color.hh
+++ b/include/cli/color.hh
@@ -13,11 +13,30 @@ namespace cli {
 /**
  * @brief Controls whether ANSI escape codes are emitted by `format_help()`.
  *
- * - `auto_`  : emit codes only when stdout is connected to a TTY (default).
+ * - `detect`  : emit codes only when stdout is connected to a TTY (default).
  * - `never`  : always produce plain text.
  * - `always` : always emit ANSI codes regardless of terminal type.
  */
-enum class ColorMode { auto_, never, always };
+enum class ColorMode { detect, never, always };
+
+/**
+ * @brief ANSI palette used by generated help output.
+ *
+ * Each field is an ANSI SGR sequence. `format_help(ColorMode::never, ...)`
+ * suppresses these sequences regardless of the palette contents.
+ */
+struct HelpPalette {
+  std::string_view usage{"\033[1m"};
+  std::string_view heading{"\033[1;4;36m"};
+  std::string_view option{"\033[1;32m"};
+  std::string_view metavar{"\033[36m"};
+  std::string_view command{"\033[1;33m"};
+  std::string_view metadata{"\033[2m"};
+  std::string_view group{"\033[1;33m"};
+  std::string_view reset{"\033[0m"};
+};
+
+inline constexpr HelpPalette default_help_palette{};
 
 /**
  * @brief Tag type passed to `format_help()` to request recursive sub-command
@@ -46,6 +65,7 @@ namespace detail {
 struct AnsiStyle {
   /** @brief Whether ANSI codes should be emitted. */
   bool enabled;
+  HelpPalette palette{};
 
   /**
    * @brief Returns the ANSI bold sequence, or an empty string when disabled.
@@ -72,7 +92,35 @@ struct AnsiStyle {
    * @return `"\033[0m"` if enabled, `""` otherwise.
    */
   [[nodiscard]] constexpr auto reset() const noexcept -> std::string_view {
-    return enabled ? "\033[0m" : "";
+    return enabled ? palette.reset : "";
+  }
+
+  [[nodiscard]] constexpr auto usage() const noexcept -> std::string_view {
+    return enabled ? palette.usage : "";
+  }
+
+  [[nodiscard]] constexpr auto heading() const noexcept -> std::string_view {
+    return enabled ? palette.heading : "";
+  }
+
+  [[nodiscard]] constexpr auto option() const noexcept -> std::string_view {
+    return enabled ? palette.option : "";
+  }
+
+  [[nodiscard]] constexpr auto metavar() const noexcept -> std::string_view {
+    return enabled ? palette.metavar : "";
+  }
+
+  [[nodiscard]] constexpr auto command() const noexcept -> std::string_view {
+    return enabled ? palette.command : "";
+  }
+
+  [[nodiscard]] constexpr auto metadata() const noexcept -> std::string_view {
+    return enabled ? palette.metadata : "";
+  }
+
+  [[nodiscard]] constexpr auto group() const noexcept -> std::string_view {
+    return enabled ? palette.group : "";
   }
 };
 
@@ -97,15 +145,17 @@ inline auto is_tty() noexcept -> bool {
  *
  * - `ColorMode::always` → `AnsiStyle{true}`
  * - `ColorMode::never`  → `AnsiStyle{false}`
- * - `ColorMode::auto_`  → `AnsiStyle{is_tty()}`
+ * - `ColorMode::detect`  → `AnsiStyle{is_tty()}`
  *
  * @param mode The requested color mode.
  * @return An `AnsiStyle` with `enabled` set appropriately.
  */
-inline auto resolve_color(ColorMode mode) noexcept -> AnsiStyle {
+inline auto resolve_color(ColorMode mode,
+                          HelpPalette palette = default_help_palette) noexcept
+    -> AnsiStyle {
   const bool on =
-      (mode == ColorMode::always) || (mode == ColorMode::auto_ && is_tty());
-  return AnsiStyle{on};
+      (mode == ColorMode::always) || (mode == ColorMode::detect && is_tty());
+  return AnsiStyle{.enabled = on, .palette = palette};
 }
 
 }  // namespace detail
diff --git a/include/cli/help.hh b/include/cli/help.hh
index cac02e2..a4b9c40 100644
--- a/include/cli/help.hh
+++ b/include/cli/help.hh
@@ -2,6 +2,9 @@
 
 #include 
 #include 
+#include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -152,11 +155,11 @@ auto command_usage_suffix(T& value) -> std::string {
             [&]() -> auto {
               using F = std::remove_cvref_t;
               if constexpr (std::derived_from) {
-                has_options = true;
+                if (!fields.hidden()) has_options = true;
               } else if constexpr (std::derived_from) {
-                has_positionals = true;
+                if (!fields.hidden()) has_positionals = true;
               } else if constexpr (std::derived_from) {
-                has_commands = true;
+                if (!fields.hidden()) has_commands = true;
               }
             }(),
             ...);
@@ -211,6 +214,39 @@ auto field_usage_label() -> std::string {
   }
 }
 
+template 
+[[nodiscard]]
+auto styled_field_usage_label(const AnsiStyle& style) -> std::string {
+  using F = std::remove_cvref_t;
+  const auto reset = style.reset();
+  if constexpr (std::derived_from) {
+    std::string out;
+    if constexpr (F::short_name != '\0') {
+      out += style.option();
+      out += std::format("-{}", F::short_name);
+      out += reset;
+      out += ", ";
+    }
+    out += style.option();
+    out += std::format("--{}", F::name.view());
+    out += reset;
+    if constexpr (!(F::nargs.min == 0 && F::nargs.max == 0)) {
+      out += " ";
+      out += style.metavar();
+      out += metavar_for();
+      out += reset;
+    }
+    return out;
+  } else if constexpr (std::derived_from) {
+    return std::format("{}{}{}", style.metavar(),
+                       metavar_for(), reset);
+  } else if constexpr (std::derived_from) {
+    return std::format("{}{}{}", style.command(), F::command_name(), reset);
+  } else {
+    return std::string{};
+  }
+}
+
 /**
  * @brief Splits a string on newline characters.
  *
@@ -339,13 +375,244 @@ auto append_section(std::string& out, std::string_view title,
  * @return The formatted row string.
  */
 template 
-auto render_help_row(Field& field, std::string_view label, std::size_t width)
-    -> std::string {
+consteval auto relation_field_name() -> std::string_view {
+  using F = std::remove_cvref_t;
+  if constexpr (std::derived_from ||
+                std::derived_from) {
+    return F::name.view();
+  } else {
+    return {};
+  }
+}
+
+inline auto relation_name_list(NameList<> names) -> std::string {
+  std::string out;
+  for (std::size_t i = 0; i < names.size; ++i) {
+    if (i != 0) out += ", ";
+    out += "--";
+    out += names.values[i];
+  }
+  return out;
+}
+
+template 
+constexpr auto relation_group(std::string_view name) -> NameList<> {
+  NameList<> result;
+  if constexpr (detail::HasRelations) {
+    std::apply(
+        [&](auto... rels) -> auto {
+          (
+              [&]() -> auto {
+                using R = std::remove_cvref_t;
+                if constexpr (std::same_as) {
+                  if (rels.name == name) result = rels.members;
+                }
+              }(),
+              ...);
+        },
+        T::relations.items);
+  }
+  return result;
+}
+
+template 
+constexpr auto relation_operand_contains(std::string_view operand,
+                                         std::string_view field_name) -> bool {
+  if (operand == field_name) return true;
+  const auto group = relation_group(operand);
+  for (auto member : group) {
+    if (member == field_name) return true;
+  }
+  return false;
+}
+
+template 
+auto relation_operand_label(std::string_view operand) -> std::string {
+  if constexpr (detail::HasRelations) {
+    if (!relation_group(operand).empty()) {
+      return std::string(operand);
+    }
+  }
+  return std::format("--{}", operand);
+}
+
+template 
+auto relation_help_metadata() -> std::vector {
+  std::vector metadata;
+  constexpr auto field_name = relation_field_name();
+  if constexpr (field_name.empty()) {
+    return metadata;
+  } else if constexpr (detail::HasRelations) {
+    std::apply(
+        [&](auto... rels) -> auto {
+          (
+              [&]() -> auto {
+                using R = std::remove_cvref_t;
+                if constexpr (std::same_as) {
+                  (void)field_name;
+                } else if constexpr (std::same_as) {
+                  if (relation_operand_contains(rels.left, field_name)) {
+                    metadata.emplace_back(std::format(
+                        "conflicts: {}", relation_operand_label(rels.right)));
+                  }
+                  if (relation_operand_contains(rels.right, field_name)) {
+                    metadata.emplace_back(std::format(
+                        "conflicts: {}", relation_operand_label(rels.left)));
+                  }
+                } else if constexpr (std::same_as) {
+                  if (relation_operand_contains(rels.source, field_name)) {
+                    metadata.emplace_back(std::format(
+                        "requires: {}", relation_operand_label(rels.target)));
+                  }
+                  if (relation_operand_contains(rels.target, field_name)) {
+                    metadata.emplace_back(
+                        std::format("required by: {}",
+                                    relation_operand_label(rels.source)));
+                  }
+                }
+              }(),
+              ...);
+        },
+        T::relations.items);
+    return metadata;
+  } else {
+    return metadata;
+  }
+}
+
+template 
+auto help_metadata(Field& field) -> std::vector {
+  std::vector metadata;
+  if constexpr (std::derived_from, OptionTag> ||
+                std::derived_from, PositionalTag>) {
+    if (field.presence() == Presence::required) {
+      metadata.emplace_back("required");
+    }
+    if (!field.env().empty()) {
+      metadata.emplace_back(std::format("env: {}", field.env()));
+    }
+    if (const auto& default_value = field.default_value()) {
+      using Value = std::remove_cvref_t;
+      if constexpr (requires(std::ostream& os, const Value& value) {
+                      os << value;
+                    }) {
+        std::ostringstream out;
+        out << *default_value;
+        metadata.emplace_back(std::format("default: {}", out.str()));
+      }
+    }
+  }
+  if (!field.deprecated().empty()) {
+    metadata.emplace_back(std::format("deprecated: {}", field.deprecated()));
+  }
+  auto relation_metadata = relation_help_metadata();
+  metadata.insert(metadata.end(),
+                  std::make_move_iterator(relation_metadata.begin()),
+                  std::make_move_iterator(relation_metadata.end()));
+  return metadata;
+}
+
+inline auto append_help_metadata(std::string& out,
+                                 const std::vector& metadata,
+                                 const AnsiStyle& style) -> void {
+  if (metadata.empty()) {
+    return;
+  }
+  out += style.metadata();
+  out += " [";
+  for (std::size_t i = 0; i < metadata.size(); ++i) {
+    if (i != 0) {
+      out += "] [";
+    }
+    out += metadata[i];
+  }
+  out += "]";
+  out += style.reset();
+}
+
+inline auto append_row_description(std::string& out,
+                                   std::string_view description,
+                                   const std::vector& metadata,
+                                   const AnsiStyle& style, std::size_t indent)
+    -> void {
+  auto lines = split_lines(description);
+  out += lines.front();
+  append_help_metadata(out, metadata, style);
+  out += '\n';
+  for (std::size_t i = 1; i < lines.size(); ++i) {
+    out.append(indent, ' ');
+    out += lines[i];
+    out += '\n';
+  }
+}
+
+template 
+auto append_group_requirements(std::string& row, std::string_view group_name)
+    -> void {
+  if constexpr (detail::HasRelations) {
+    std::apply(
+        [&](auto... rels) -> auto {
+          (
+              [&]() -> auto {
+                using R = std::remove_cvref_t;
+                if constexpr (std::same_as) {
+                  if (rels.source == group_name) {
+                    row += std::format("    requires: {}\n",
+                                       relation_operand_label(rels.target));
+                  }
+                }
+              }(),
+              ...);
+        },
+        T::relations.items);
+  }
+}
+
+template 
+auto relation_option_group_row(const GroupRelation& group,
+                               const AnsiStyle& style) -> std::string {
+  std::string row = "  ";
+  row += style.group();
+  row += group.name;
+  row += style.reset();
+  row += ":\n    ";
+  row += relation_name_list(group.members);
+  row += " must be used together\n";
+  append_group_requirements(row, group.name);
+  return row;
+}
+
+template 
+auto relation_option_group_rows(const AnsiStyle& style)
+    -> std::vector {
+  std::vector rows;
+  if constexpr (detail::HasRelations) {
+    std::apply(
+        [&](auto... rels) -> auto {
+          (
+              [&]() -> auto {
+                using R = std::remove_cvref_t;
+                if constexpr (std::same_as) {
+                  rows.push_back(relation_option_group_row(rels, style));
+                }
+              }(),
+              ...);
+        },
+        T::relations.items);
+  }
+  return rows;
+}
+
+template 
+auto render_help_row(Field& field, std::string_view label,
+                     std::string_view styled_label, std::size_t width,
+                     const AnsiStyle& style) -> std::string {
   std::string out = "  ";
-  out += label;
-  if (!field.help_text().empty()) {
+  out += styled_label;
+  const auto metadata = help_metadata(field);
+  if (!field.help_text().empty() || !metadata.empty()) {
     out.append(width - label.size() + 2, ' ');
-    append_wrapped_description(out, field.help_text(), width + 4);
+    append_row_description(out, field.help_text(), metadata, style, width + 4);
   } else {
     out += '\n';
   }
@@ -408,11 +675,11 @@ auto find_description(T& value) -> std::string_view {
 template 
 auto format_help_impl(T& value, std::string_view program_name,
                       ColorMode color_mode, bool recurse,
-                      std::string_view command_path) -> std::string {
-  const auto style = resolve_color(color_mode);
-  const std::string heading =
-      std::string(style.bold()) + std::string(style.underline());
-  const std::string option_color = std::string(style.bold());
+                      std::string_view command_path,
+                      HelpPalette palette = default_help_palette)
+    -> std::string {
+  const auto style = resolve_color(color_mode, palette);
+  const std::string heading = std::string(style.heading());
   const std::string reset = std::string(style.reset());
 
   std::string full_program = std::string(program_name);
@@ -421,8 +688,8 @@ auto format_help_impl(T& value, std::string_view program_name,
     full_program += command_path;
   }
 
-  std::string out =
-      std::format("Usage: {}{}\n", full_program, command_usage_suffix(value));
+  std::string out = std::format("{}Usage:{} {}{}\n", style.usage(), reset,
+                                full_program, command_usage_suffix(value));
 
   if (const auto description = find_description(value); !description.empty()) {
     out += '\n';
@@ -430,9 +697,9 @@ auto format_help_impl(T& value, std::string_view program_name,
     out += '\n';
   }
 
-  std::vector> option_rows;
-  std::vector> positional_rows;
-  std::vector> command_rows;
+  std::vector> option_rows;
+  std::vector> positional_rows;
+  std::vector> command_rows;
   std::size_t option_width = 0;
   std::size_t positional_width = 0;
   std::size_t command_width = 0;
@@ -443,18 +710,20 @@ auto format_help_impl(T& value, std::string_view program_name,
             [&]() -> auto {
               using F = std::remove_cvref_t;
               if constexpr (std::derived_from) {
+                if (fields.hidden()) return;
                 auto label = field_usage_label();
                 option_width = std::max(option_width, label.size());
-                option_rows.emplace_back(std::move(label), fields.help_text());
+                option_rows.emplace_back(std::move(label), std::string{});
               } else if constexpr (std::derived_from) {
+                if (fields.hidden()) return;
                 auto label = field_usage_label();
                 positional_width = std::max(positional_width, label.size());
-                positional_rows.emplace_back(std::move(label),
-                                             fields.help_text());
+                positional_rows.emplace_back(std::move(label), std::string{});
               } else if constexpr (std::derived_from) {
+                if (fields.hidden()) return;
                 auto label = field_usage_label();
                 command_width = std::max(command_width, label.size());
-                command_rows.emplace_back(std::move(label), fields.help_text());
+                command_rows.emplace_back(std::move(label), std::string{});
               }
             }(),
             ...);
@@ -463,8 +732,7 @@ auto format_help_impl(T& value, std::string_view program_name,
 
   auto append_rows =
       [&](std::string_view title,
-          const std::vector>& rows,
-          std::size_t width) -> auto {
+          const std::vector>& rows) -> auto {
     if (rows.empty()) {
       return;
     }
@@ -473,23 +741,62 @@ auto format_help_impl(T& value, std::string_view program_name,
     out += title;
     out += reset;
     out += '\n';
-    for (const auto& [label, description] : rows) {
-      out += "  ";
-      out += option_color;
-      out += label;
-      out += reset;
-      if (!description.empty()) {
-        out.append(width - label.size() + 2, ' ');
-        append_wrapped_description(out, description, width + 4);
-      } else {
-        out += '\n';
-      }
+    for (const auto& [label, row] : rows) {
+      (void)label;
+      out += row;
     }
   };
 
-  append_rows("Options:", option_rows, option_width);
-  append_rows("Positional arguments:", positional_rows, positional_width);
-  append_rows("Commands:", command_rows, command_width);
+  option_rows.clear();
+  positional_rows.clear();
+  command_rows.clear();
+  std::apply(
+      [&](auto&... fields) -> auto {
+        (
+            [&]() -> auto {
+              using F = std::remove_cvref_t;
+              if constexpr (std::derived_from) {
+                if (fields.hidden()) return;
+                auto label = field_usage_label();
+                auto styled_label = styled_field_usage_label(style);
+                option_rows.emplace_back(
+                    label, render_help_row(fields, label, styled_label,
+                                              option_width, style));
+              } else if constexpr (std::derived_from) {
+                if (fields.hidden()) return;
+                auto label = field_usage_label();
+                auto styled_label = styled_field_usage_label(style);
+                positional_rows.emplace_back(
+                    label, render_help_row(fields, label, styled_label,
+                                              positional_width, style));
+              } else if constexpr (std::derived_from) {
+                if (fields.hidden()) return;
+                auto label = field_usage_label();
+                auto styled_label = styled_field_usage_label(style);
+                command_rows.emplace_back(
+                    label, render_help_row(fields, label, styled_label,
+                                              command_width, style));
+              }
+            }(),
+            ...);
+      },
+      as_tuple(value));
+
+  append_rows("Options:", option_rows);
+  append_rows("Positional arguments:", positional_rows);
+  append_rows("Commands:", command_rows);
+
+  const auto option_group_rows = relation_option_group_rows(style);
+  if (!option_group_rows.empty()) {
+    out += '\n';
+    out += heading;
+    out += "Option groups:";
+    out += reset;
+    out += '\n';
+    for (const auto& row : option_group_rows) {
+      out += row;
+    }
+  }
 
   if (recurse && !command_rows.empty()) {
     std::apply(
@@ -498,10 +805,12 @@ auto format_help_impl(T& value, std::string_view program_name,
               [&]() -> auto {
                 using F = std::remove_cvref_t;
                 if constexpr (std::derived_from) {
+                  if (fields.hidden()) return;
                   out += '\n';
                   out += format_help_impl(
                       static_cast(fields),
-                      program_name, color_mode, true, F::command_name());
+                      program_name, color_mode, true, F::command_name(),
+                      palette);
                 }
               }(),
               ...);
@@ -524,17 +833,17 @@ auto format_help_impl(T& value, std::string_view program_name,
  * @param value        An instance of the argument specification.
  * @param program_name The program name to display in the usage line. Default:
  * `"program"`.
- * @param color_mode   Controls ANSI color output. Default: `ColorMode::auto_`.
+ * @param color_mode   Controls ANSI color output. Default: `ColorMode::detect`.
  * @param recurse      If `true`, also append help for each subcommand. Default:
  * `false`.
  * @return The formatted help string.
  */
 template 
 auto format_help(T& value, std::string_view program_name = "program",
-                 ColorMode color_mode = ColorMode::auto_, bool recurse = false)
-    -> std::string {
+                 ColorMode color_mode = ColorMode::detect, bool recurse = false,
+                 HelpPalette palette = default_help_palette) -> std::string {
   return detail::format_help_impl(value, program_name, color_mode, recurse,
-                                     {});
+                                     {}, palette);
 }
 
 }  // namespace cli
diff --git a/include/cli/parser.hh b/include/cli/parser.hh
index 3c8896c..2bc7d3b 100644
--- a/include/cli/parser.hh
+++ b/include/cli/parser.hh
@@ -751,6 +751,11 @@ struct Parser {
       return;
     }
 
+    if (auto err = validate_relations(result.value); err.has_error()) {
+      result.error = std::move(err.error);
+      return;
+    }
+
     if constexpr (requires(T& t) {
                     { t.constraints() } -> std::same_as;
                   }) {
@@ -764,11 +769,13 @@ struct Parser {
   /**
    * @brief Generates a help string for the argument specification.
    *
-   * @param color_mode Controls ANSI color output. Default: `ColorMode::auto_`.
+   * @param color_mode Controls ANSI color output. Default: `ColorMode::detect`.
    * @return The formatted help string.
    */
-  auto format_help(ColorMode color_mode = ColorMode::auto_) -> std::string {
-    return cli::format_help(scratch_, program_name_, color_mode, false);
+  auto format_help(ColorMode color_mode = ColorMode::detect,
+                   HelpPalette palette = default_help_palette) -> std::string {
+    return cli::format_help(scratch_, program_name_, color_mode, false,
+                               palette);
   }
 
   /**
@@ -777,7 +784,11 @@ struct Parser {
    * @return The formatted help string with all subcommand sections appended.
    */
   auto format_help(RecurseHelpTag) -> std::string {
-    return format_help(ColorMode::auto_, recurse_help);
+    return format_help(ColorMode::detect, recurse_help);
+  }
+
+  auto format_help(HelpPalette palette) -> std::string {
+    return format_help(ColorMode::detect, palette);
   }
 
   /**
@@ -791,6 +802,12 @@ struct Parser {
     return cli::format_help(scratch_, program_name_, color_mode, true);
   }
 
+  auto format_help(ColorMode color_mode, HelpPalette palette, RecurseHelpTag)
+      -> std::string {
+    return cli::format_help(scratch_, program_name_, color_mode, true,
+                               palette);
+  }
+
  private:
   T scratch_{};
 
@@ -984,6 +1001,143 @@ struct Parser {
     return res;
   }
 
+  static auto relation_name_list(NameList<> names) -> std::string {
+    std::string out;
+    for (std::size_t i = 0; i < names.size; ++i) {
+      if (i != 0) out += ", ";
+      out += "--";
+      out += names.values[i];
+    }
+    return out;
+  }
+
+  static auto relation_group_exists(std::string_view name) -> bool {
+    if constexpr (!detail::HasRelations) {
+      return false;
+    } else {
+      bool found = false;
+      std::apply(
+          [&](auto... rels) -> auto {
+            (
+                [&]() -> auto {
+                  using R = std::remove_cvref_t;
+                  if constexpr (std::same_as) {
+                    if (rels.name == name) found = true;
+                  }
+                }(),
+                ...);
+          },
+          T::relations.items);
+      return found;
+    }
+  }
+
+  static auto relation_operand_label(std::string_view operand) -> std::string {
+    if (relation_group_exists(operand)) {
+      return std::string(operand);
+    }
+    return "--" + std::string(operand);
+  }
+
+  static auto relation_operand_present(T& val, std::string_view name) -> bool {
+    bool found_field = false;
+    bool present = false;
+    for_each_field(val, [&](auto& f) -> auto {
+      using F = std::remove_cvref_t;
+      if constexpr (std::derived_from ||
+                    std::derived_from) {
+        if (!found_field && F::name.view() == name) {
+          found_field = true;
+          present = f.provided();
+        }
+      }
+    });
+    if (found_field) return present;
+
+    if constexpr (detail::HasRelations) {
+      bool found_group = false;
+      bool group_present = false;
+      std::apply(
+          [&](auto... rels) -> auto {
+            (
+                [&]() -> auto {
+                  using R = std::remove_cvref_t;
+                  if constexpr (std::same_as) {
+                    if (!found_group && rels.name == name) {
+                      found_group = true;
+                      group_present = true;
+                      for (auto member : rels.members) {
+                        group_present = group_present &&
+                                        relation_operand_present(val, member);
+                      }
+                    }
+                  }
+                }(),
+                ...);
+          },
+          T::relations.items);
+      return group_present;
+    }
+    return false;
+  }
+
+  static auto validate_relations(T& val) -> ActionResult {
+    if constexpr (!detail::HasRelations) {
+      return ActionResult::ok();
+    } else {
+      ActionResult res = ActionResult::ok();
+      std::apply(
+          [&](auto... rels) -> auto {
+            (
+                [&]() -> auto {
+                  if (res.has_error()) return;
+                  using R = std::remove_cvref_t;
+                  if constexpr (std::same_as) {
+                    std::size_t count = 0;
+                    for (auto member : rels.members) {
+                      if (relation_operand_present(val, member)) ++count;
+                    }
+                    if (count != 0 && count != rels.members.size) {
+                      res = ActionResult::fail(ParseError{
+                          .code = ErrorCode::dependency_missing,
+                          .kind = ErrorKind::validation,
+                          .subject = std::string(rels.name),
+                          .detail = "arguments must be provided together: " +
+                                    relation_name_list(rels.members),
+                      });
+                    }
+                  } else if constexpr (std::same_as) {
+                    if (relation_operand_present(val, rels.left) &&
+                        relation_operand_present(val, rels.right)) {
+                      res = ActionResult::fail(ParseError{
+                          .code = ErrorCode::mutually_exclusive,
+                          .kind = ErrorKind::validation,
+                          .subject = relation_operand_label(rels.left) + ", " +
+                                     relation_operand_label(rels.right),
+                          .detail =
+                              "these arguments cannot be provided together",
+                      });
+                    }
+                  } else if constexpr (std::same_as) {
+                    if (relation_operand_present(val, rels.source) &&
+                        !relation_operand_present(val, rels.target)) {
+                      res = ActionResult::fail(ParseError{
+                          .code = ErrorCode::dependency_missing,
+                          .kind = ErrorKind::validation,
+                          .subject = relation_operand_label(rels.source),
+                          .detail =
+                              "requires " + relation_operand_label(rels.target),
+                      });
+                    }
+                  }
+                }(),
+                ...);
+          },
+          T::relations.items);
+      return res;
+    }
+  }
+
   auto apply_env_fallback(T& val) -> ActionResult {
     ActionResult res = ActionResult::ok();
     for_each_field(val, [&](auto& f) -> auto {
@@ -1013,7 +1167,7 @@ struct Parser {
 
   auto finalize_special_error(ParseError error) -> ParseError {
     if (error.code == ErrorCode::help_requested && error.detail.empty()) {
-      error.detail = format_help(ColorMode::never);
+      error.detail = format_help(ColorMode::detect);
     }
     return error;
   }
diff --git a/include/cli/relation.hh b/include/cli/relation.hh
new file mode 100644
index 0000000..60c12ba
--- /dev/null
+++ b/include/cli/relation.hh
@@ -0,0 +1,89 @@
+#pragma once
+
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace cli {
+
+template 
+struct NameList {
+  std::array values{};
+  std::size_t size{};
+
+  constexpr NameList() = default;
+  constexpr NameList(std::initializer_list names) {
+    if (names.size() > Max) {
+      throw "cli::NameList capacity exceeded";
+    }
+    size = names.size();
+    auto it = names.begin();
+    for (std::size_t i = 0; i < size; ++i, ++it) {
+      values[i] = *it;
+    }
+  }
+
+  [[nodiscard]] constexpr auto begin() const { return values.begin(); }
+  [[nodiscard]] constexpr auto end() const { return values.begin() + size; }
+  [[nodiscard]] constexpr auto empty() const -> bool { return size == 0; }
+};
+
+struct GroupParams {
+  std::string_view name;
+  NameList<> members;
+};
+
+struct GroupRelation {
+  std::string_view name;
+  NameList<> members;
+};
+
+struct ConflictsRelation {
+  std::string_view left;
+  std::string_view right;
+};
+
+struct DependsOnRelation {
+  std::string_view source;
+  std::string_view target;
+};
+
+template 
+struct RelationSet {
+  std::tuple items;
+};
+
+template 
+struct IsRelationSet : std::false_type {};
+
+template 
+struct IsRelationSet> : std::true_type {};
+
+template 
+concept RelationSetLike = IsRelationSet>::value;
+
+[[nodiscard]] constexpr auto group(GroupParams params) -> GroupRelation {
+  return {.name = params.name, .members = params.members};
+}
+
+[[nodiscard]] constexpr auto conflicts(std::string_view left,
+                                       std::string_view right)
+    -> ConflictsRelation {
+  return {.left = left, .right = right};
+}
+
+[[nodiscard]] constexpr auto depends_on(std::string_view source,
+                                        std::string_view target)
+    -> DependsOnRelation {
+  return {.source = source, .target = target};
+}
+
+template 
+[[nodiscard]] constexpr auto relations(Relations... rels)
+    -> RelationSet {
+  return {.items = std::tuple{rels...}};
+}
+
+}  // namespace cli
diff --git a/tests/test_help.cc b/tests/test_help.cc
index 3a574b8..bba4561 100644
--- a/tests/test_help.cc
+++ b/tests/test_help.cc
@@ -39,6 +39,13 @@ struct OptionsOnlyArgs {
       {.help = "Enable verbose logging", .presence = cli::optional}};
 };
 
+struct MultilineHelpArgs {
+  cli::StringOption<"mode"> mode{{
+      .help = "First line\nSecond line",
+      .default_value = std::string{"auto"},
+  }};
+};
+
 struct CommandsOnlyArgs {
   cli::Command<"build", BuildArgs> build{{.help = "Run the build step"}};
 };
@@ -56,6 +63,39 @@ struct ExitSuccessArgs {
       {.help = "Print version and exit", .presence = cli::optional}};
 };
 
+struct MetadataHelpArgs {
+  cli::Flag<"visible"> visible{{.help = "Visible flag"}};
+  cli::Flag<"hidden"> hidden{{.help = "Hidden flag", .hidden = true}};
+  cli::Flag<"old"> old{{
+      .help = "Old flag",
+      .deprecated = "use --visible instead",
+  }};
+  cli::StringOption<"output", 'o'> output{{
+      .help = "Output file",
+      .presence = cli::required,
+      .default_value = std::string{"out.txt"},
+      .env = "APP_OUTPUT",
+  }};
+  cli::StringOption<"user"> user{{.help = "User name"}};
+  cli::StringOption<"password"> password{{.help = "Password"}};
+  cli::Flag<"json"> json{{.help = "JSON output"}};
+  cli::Flag<"markdown"> markdown{{.help = "Markdown output"}};
+  cli::Flag<"deploy"> deploy{{.help = "Deploy"}};
+  cli::StringOption<"profile"> profile{{.help = "Deploy profile"}};
+  cli::Command<"legacy", BuildArgs> legacy{{
+      .help = "Legacy command",
+      .deprecated = "use build",
+  }};
+  cli::Command<"internal", BuildArgs> internal{{
+      .help = "Internal command",
+      .hidden = true,
+  }};
+
+  static constexpr auto relations = cli::relations(
+      cli::group({.name = "legacy_auth", .members = {"user", "password"}}),
+      cli::conflicts("json", "markdown"), cli::depends_on("deploy", "profile"));
+};
+
 }  // namespace
 
 TEST(Help, UsageContainsProgramAndMajorGroups) {
@@ -104,6 +144,15 @@ TEST(Help, FlagDoesNotShowMetavar) {
   EXPECT_EQ(line.find(""), std::string::npos);
 }
 
+TEST(Help, MultilineHelpTextKeepsContinuationIndented) {
+  cli::Parser parser;
+
+  const auto help = parser.format_help(cli::ColorMode::never);
+  EXPECT_NE(help.find("--mode   First line [default: auto]\n"
+                      "                   Second line\n"),
+            std::string::npos);
+}
+
 TEST(Help, EmptySectionsAreOmitted) {
   cli::Parser options_only;
   const auto options_help = options_only.format_help(cli::ColorMode::never);
@@ -139,9 +188,31 @@ TEST(Help, ColorAlwaysEmitsAnsiHeadingsAndLabels) {
 
   const auto help = parser.format_help(cli::ColorMode::always);
   EXPECT_NE(help.find("\033[1m"), std::string::npos);
-  EXPECT_NE(help.find("\033[4m"), std::string::npos);
+  EXPECT_NE(help.find("\033[1;4;36mOptions:"), std::string::npos);
   EXPECT_NE(help.find("\033[0m"), std::string::npos);
-  EXPECT_NE(help.find("\033[1m-v, --verbose"), std::string::npos);
+  EXPECT_NE(help.find("\033[1;32m-v\033[0m, \033[1;32m--verbose"),
+            std::string::npos);
+  EXPECT_NE(help.find("\033[36m\033[0m"), std::string::npos);
+}
+
+TEST(Help, CustomPaletteIsUsed) {
+  cli::Parser parser;
+  constexpr cli::HelpPalette palette{
+      .usage = "\033[95m",
+      .heading = "\033[94m",
+      .option = "\033[93m",
+      .metavar = "\033[92m",
+      .command = "\033[91m",
+      .metadata = "\033[90m",
+      .group = "\033[96m",
+      .reset = "\033[39m",
+  };
+
+  const auto help = parser.format_help(cli::ColorMode::always, palette);
+  EXPECT_NE(help.find("\033[95mUsage:\033[39m"), std::string::npos);
+  EXPECT_NE(help.find("\033[94mOptions:\033[39m"), std::string::npos);
+  EXPECT_NE(help.find("\033[93m-v\033[39m, \033[93m--verbose"),
+            std::string::npos);
 }
 
 TEST(Help, ResolveColorRespectsMode) {
@@ -155,6 +226,8 @@ TEST(Help, ResolveColorRespectsMode) {
   EXPECT_EQ(always.bold(), "\033[1m");
   EXPECT_EQ(always.underline(), "\033[4m");
   EXPECT_EQ(always.reset(), "\033[0m");
+  EXPECT_EQ(always.heading(), "\033[1;4;36m");
+  EXPECT_EQ(always.option(), "\033[1;32m");
 }
 
 TEST(Help, HelpFlagTriggersHelpRequested) {
@@ -226,3 +299,34 @@ TEST(Help, ExitSuccessActionTriggersDedicatedCode) {
   EXPECT_TRUE(result.error.use_stdout());
   EXPECT_EQ(result.error.exit_code(), 0);
 }
+
+TEST(Help, MetadataIsRenderedAndHiddenFieldsAreOmitted) {
+  cli::Parser parser;
+
+  const auto help = parser.format_help(cli::ColorMode::never);
+  EXPECT_NE(help.find("--visible"), std::string::npos);
+  EXPECT_EQ(help.find("--hidden"), std::string::npos);
+  EXPECT_NE(help.find("[deprecated: use --visible instead]"), std::string::npos);
+  EXPECT_NE(help.find("[required]"), std::string::npos);
+  EXPECT_NE(help.find("[env: APP_OUTPUT]"), std::string::npos);
+  EXPECT_NE(help.find("[default: out.txt]"), std::string::npos);
+  EXPECT_NE(help.find("legacy"), std::string::npos);
+  EXPECT_NE(help.find("[deprecated: use build]"), std::string::npos);
+  EXPECT_EQ(help.find("internal"), std::string::npos);
+}
+
+TEST(Help, RelationsAreRendered) {
+  cli::Parser parser;
+
+  const auto help = parser.format_help(cli::ColorMode::never);
+  EXPECT_EQ(help.find("Relations:"), std::string::npos);
+  EXPECT_NE(help.find("Option groups:"), std::string::npos);
+  EXPECT_NE(help.find("--user"), std::string::npos);
+  EXPECT_NE(help.find("legacy_auth:"), std::string::npos);
+  EXPECT_NE(help.find("--user, --password must be used together"),
+            std::string::npos);
+  EXPECT_NE(help.find("[conflicts: --markdown]"), std::string::npos);
+  EXPECT_NE(help.find("[conflicts: --json]"), std::string::npos);
+  EXPECT_NE(help.find("[requires: --profile]"), std::string::npos);
+  EXPECT_NE(help.find("[required by: --deploy]"), std::string::npos);
+}
diff --git a/tests/test_parse.cc b/tests/test_parse.cc
index 6cada92..529ef3a 100644
--- a/tests/test_parse.cc
+++ b/tests/test_parse.cc
@@ -3,6 +3,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 
 #ifdef _WIN32
@@ -81,6 +82,65 @@ struct TotalValuesArgs {
       mode;
 };
 
+struct RelationArgs {
+  cli::StringOption<"user"> user;
+  cli::StringOption<"password"> password;
+  cli::Flag<"json"> json;
+  cli::Flag<"markdown"> markdown;
+  cli::Flag<"deploy"> deploy;
+  cli::StringOption<"profile"> profile;
+
+  static constexpr auto relations = cli::relations(
+      cli::group({.name = "legacy_auth", .members = {"user", "password"}}),
+      cli::conflicts("json", "markdown"), cli::depends_on("deploy", "profile"));
+};
+
+struct GroupOperandRelationArgs {
+  cli::StringOption<"user"> user;
+  cli::StringOption<"password"> password;
+  cli::Flag<"token"> token;
+  cli::StringOption<"profile"> profile;
+
+  static constexpr auto relations = cli::relations(
+      cli::group({.name = "legacy_auth", .members = {"user", "password"}}),
+      cli::conflicts("legacy_auth", "token"),
+      cli::depends_on("legacy_auth", "profile"));
+};
+
+struct UnknownRelationTargetArgs {
+  cli::Flag<"json"> json;
+
+  static constexpr auto relations =
+      cli::relations(cli::conflicts("json", "xml"));
+};
+
+struct DuplicateRelationMembershipArgs {
+  cli::Flag<"user"> user;
+  cli::Flag<"password"> password;
+  cli::Flag<"json"> json;
+
+  static constexpr auto relations = cli::relations(
+      cli::group({.name = "legacy_auth", .members = {"user", "password"}}),
+      cli::conflicts("user", "json"));
+};
+
+struct RelationGroupNameCollisionArgs {
+  cli::Flag<"legacy_auth"> legacy_auth;
+  cli::Flag<"user"> user;
+  cli::Flag<"password"> password;
+
+  static constexpr auto relations = cli::relations(
+      cli::group({.name = "legacy_auth", .members = {"user", "password"}}));
+};
+
+static_assert(cli::detail::relations_are_well_formed());
+static_assert(
+    !cli::detail::relations_are_well_formed());
+static_assert(
+    !cli::detail::relations_are_well_formed());
+static_assert(
+    !cli::detail::relations_are_well_formed());
+
 auto argv(std::initializer_list values)
     -> std::vector {
   return {values};
@@ -221,6 +281,71 @@ TEST(Parse, MissingOptionalEntryDoesNotIncrementTotalValues) {
   EXPECT_EQ(seen, (std::vector{0, 0}));
 }
 
+TEST(Parse, RelationGroupRequiresAllMembers) {
+  auto args = argv({"prog", "--user", "alice"});
+  auto result = cli::Parser{}.parse(
+      std::span(args), 1);
+
+  ASSERT_TRUE(result.has_error());
+  EXPECT_EQ(result.error.code, cli::ErrorCode::dependency_missing);
+  EXPECT_EQ(result.error.subject, "legacy_auth");
+}
+
+TEST(Parse, RelationConflictsRejectsBothArguments) {
+  auto args = argv({"prog", "--json", "--markdown"});
+  auto result = cli::Parser{}.parse(
+      std::span(args), 1);
+
+  ASSERT_TRUE(result.has_error());
+  EXPECT_EQ(result.error.code, cli::ErrorCode::mutually_exclusive);
+}
+
+TEST(Parse, RelationDependsOnRequiresTarget) {
+  auto args = argv({"prog", "--deploy"});
+  auto result = cli::Parser{}.parse(
+      std::span(args), 1);
+
+  ASSERT_TRUE(result.has_error());
+  EXPECT_EQ(result.error.code, cli::ErrorCode::dependency_missing);
+  EXPECT_EQ(result.error.subject, "--deploy");
+}
+
+TEST(Parse, RelationConflictFormatsGroupOperandWithoutOptionPrefix) {
+  auto args =
+      argv({"prog", "--user", "alice", "--password", "secret", "--token"});
+  auto result = cli::Parser{}.parse(
+      std::span(args), 1);
+
+  ASSERT_TRUE(result.has_error());
+  EXPECT_EQ(result.error.code, cli::ErrorCode::mutually_exclusive);
+  EXPECT_EQ(result.error.subject, "legacy_auth, --token");
+}
+
+TEST(Parse, RelationDependsOnFormatsGroupOperandWithoutOptionPrefix) {
+  auto args = argv({"prog", "--user", "alice", "--password", "secret"});
+  auto result = cli::Parser{}.parse(
+      std::span(args), 1);
+
+  ASSERT_TRUE(result.has_error());
+  EXPECT_EQ(result.error.code, cli::ErrorCode::dependency_missing);
+  EXPECT_EQ(result.error.subject, "legacy_auth");
+  EXPECT_EQ(result.error.detail, "requires --profile");
+}
+
+TEST(Parse, RelationsPassWhenSatisfied) {
+  auto args = argv({"prog", "--user", "alice", "--password", "secret",
+                    "--deploy", "--profile", "prod", "--json"});
+  auto result = cli::Parser{}.parse(
+      std::span(args), 1);
+
+  ASSERT_TRUE(result.has_value());
+}
+
+TEST(Parse, NameListRejectsTooManyNames) {
+  EXPECT_ANY_THROW(
+      [] { std::ignore = cli::NameList<2>({"one", "two", "three"}); }());
+}
+
 #ifdef _WIN32
 namespace {