Skip to main content

Actions & Pipelines

What Is a Pipeline?​

When you write --threads 8 on the command line, the parser receives the raw string "8". Before that value can be stored as an int, three things must happen in order:

  1. Conversion — parse "8" into 8 (or fail with an error).
  2. Validation — verify the integer satisfies any constraints (e.g. 1–32).
  3. Pack — write the final value into the option's storage.

cli20 calls this sequence an action pipeline. A pipeline is a value of type Action<Fn1, Fn2, ...> where each Fn is a constexpr-constructible callable.

How Arg<> validates a pipeline​

When a pipeline is passed to cli::Arg<>, the ActionValue concept calls pipeline.validate() at compile time. Internally this runs validate_impl<Prev, Steps...>() as a compile-time recursion:

  1. Start with Prev = std::string_view.
  2. For each step Head in order:
    • Check Head::accepts_input<Prev> — propagate false if the type is incompatible.
    • Compute the next intermediate type: Head::after_type<Prev>.
  3. When the last step is reached, Head::storage_type<Prev> must not be void (a static_assert enforces this — the pipeline must end with a pack action).
  4. Return pair<bool, type_identity<StorageType>>. Arg<> extracts StorageType and uses it as the field's value_type.

The whole traversal is constexpr and has zero runtime cost.

Why the three traits instead of just operator()?​

For non-terminal steps the traits are redundant in theory:

  • accepts_input<Prev> ≈ std::invocable<Fn, ActionCtx<void>, ActionResult<Prev>>
  • after_type<Prev> ≈ the T in std::invoke_result_t<...> → ActionResult<T>

For terminal (pack) steps the story is different. A generic pack step like pack::set_once has the signature:

template <class Prev>
auto operator()(ActionCtx<Prev>&, ActionResult<Prev>) -> ActionResult<void>;

The storage type Prev is embedded in the first parameter, and Prev itself is not known until the pipeline is validated. There is no way to extract that type alias without an explicit storage_type<Prev> declaration — you would need to solve for the template parameter of ActionCtx<?>, which requires a trait or an additional overload.

So the explicit traits are load-bearing for pack steps. Once storage_type must exist anyway, keeping accepts_input and after_type explicit costs nothing and gives cleaner static-assert messages than substitution failures deep inside invoke_result_t.

Why type-checking is deferred to Arg<>​

The | operator simply appends a step to the list without checking type compatibility. Type checking happens when the pipeline is passed to cli::Arg<>, where the ActionValue concept calls pipeline.validate() at compile time.

This is a deliberate design decision. Validation steps like validation::range<1, 32> are intentionally polymorphic — after_type<Prev> = Prev means the same step works for int, long, unsigned, or any totally-ordered type. Encoding the "current output type" into Action at | time would fix the type at composition and destroy that polymorphism.

The tradeoff: type-mismatch errors appear at the cli::Arg<> declaration rather than at the | line. In practice this is the same translation unit and rarely more than a few lines away.

There is no runtime overhead from the composition itself.

string_view ──► [Conversion] ──► T ──► [Validation] ──► T ──► [Pack] ──► storage
│ │ │
on error on error on error
└──────────────────────┴─────────────────────► ParseError

ignore short-circuits the remaining steps in the chain. error still flows through downstream steps, which lets terminal actions preserve or reformat the failure instead of bypassing it.


Building Pipelines​

Pipelines are assembled with the | operator. The three families of building blocks live in cli::conversion, cli::validation, and cli::pack.

Conversion​

Conversions turn a std::string_view into a typed value. They must appear first.

ExpressionOutput type
conversion::integer<T>T (any integral type)
conversion::floating<T>T (any floating-point type)
conversion::stringstd::string
conversion::booleanbool (true/false/1/0)
conversion::pathstd::filesystem::path
conversion::negatable<"prefix">NegatableResult{name, enabled}
conversion::choice<T, mapper>T (mapped from string; fails on unknown)

Validation​

Validations filter the converted value, passing it through unchanged on success.

ExpressionCondition
validation::range<Min, Max>Min ≤ value ≤ Max
validation::min<N>value ≥ N
validation::max<N>value ≤ N
validation::positivevalue > 0
validation::non_negativevalue ≥ 0
validation::non_emptycontainer / string not empty
validation::not_blankstring not all-whitespace
validation::matches<"regex">full regex match
validation::one_of<A, B, ...>equals one of the listed constants
validation::existsstd::filesystem::path exists
validation::is_regular_filepath is a regular file
validation::is_directorypath is a directory
validation::parent_existsparent directory exists
validation::predicate<fn>fn(value) returns true

Pack​

Pack actions are terminal: they write the value into the option's storage and return ActionResult<void>. Every pipeline must end with exactly one pack action.

ExpressionStorage typeBehaviour
pack::set_onceTstores once; rejects duplicates
pack::pushstd::vector<T>appends on each occurrence
pack::push_uniquestd::vector<T>appends; rejects duplicate values
pack::insertstd::set<T>inserts into set
pack::insert_or_assignstd::map<K,V>inserts/updates map entry (used with negatable)
pack::set_trueboolalways stores true
pack::set_falseboolalways stores false
pack::toggleboolflips on each occurrence
pack::incrementstd::size_tincrements on each occurrence
pack::write_to<T>T*writes through a pointer
pack::callback<fn>std::monostatecalls fn with the value

Examples​

// Integer with range constraint, stored once.
constexpr auto port_action =
cli::conversion::integer<int>
| cli::validation::range<1, 65535>
| cli::pack::set_once;

// Case-insensitive profile name: validated by regex, collected into a vector.
constexpr auto profile_action =
cli::conversion::string
| cli::validation::matches<"[a-z][a-z0-9_-]*">
| cli::pack::push;

// Warning flags toggled on/off, stored in a map.
constexpr auto warning_action =
cli::conversion::negatable<"no-">
| cli::pack::insert_or_assign;

// Config file that must exist and be a regular file.
constexpr auto config_action =
cli::conversion::path
| cli::validation::exists
| cli::validation::is_regular_file
| cli::pack::set_once;

Use the pipeline with cli::Arg:

struct Args {
cli::Arg<"port", 'p', cli::conversion::integer<int>
| cli::validation::range<1, 65535>
| cli::pack::set_once>
port{{.help = "Port number", .presence = cli::required}};
};

Evaluating a Pipeline Standalone​

You can invoke a pipeline directly without going through the parser. This is useful for unit-testing custom actions. Note that invoke skips the explicit compile-time validate() check. The | composition itself does not fully type-check compatibility between steps, so mismatches are diagnosed when the pipeline is first used in a checking context such as validate(), cli::Arg<>, or invoke().

#include "cli/action.hh"

// Build a pipeline.
constexpr auto pipeline =
cli::conversion::integer<int>
| cli::validation::range<1, 32>
| cli::pack::set_once;

// pack::set_once writes into int, so the ctx must carry a reference to it.
int storage{};
cli::ActionCtx<int> ctx{.arg = std::ref(storage)};

auto result = pipeline.invoke(ctx, cli::ActionResult<std::string_view>::ok("8"));

if (result.has_value()) {
// The packed value is in storage, not in result.
// storage == 8
} else {
// result.error.code, .subject, .detail carry the failure reason
}

ActionCtx fields you may want to set:

FieldTypeMeaning
indexstd::size_targv position of the token (used in error messages)
occurrencesstd::size_thow many times this option token has been seen
invoke_countstd::size_thow many times this action has been invoked so far

For pack actions (which need the storage reference) use ActionCtx<StorageType>, initialised with designated initialisers:

int storage{};
cli::ActionCtx<int> ctx{.arg = std::ref(storage)};

Defining Custom Pipeline Steps​

For one-off transformations, use cli::action::custom<fn>. It wraps a constexpr callable as a non-terminal action step:

constexpr auto jobs_or_nproc =
[](cli::ActionCtx<void> ctx,
cli::ActionResult<std::optional<std::string_view>> input)
-> cli::ActionResult<int> {
if (!input.have_value()) return cli::fail<int>(input.error);

if (input.value.has_value()) {
return cli::conversion::integer<int>.invoke(
ctx, cli::ok(std::string_view(*input.value)));
}

return cli::ok(4); // stand-in for a runtime nproc query
};

cli::Arg<"jobs", 'j',
cli::action::custom<jobs_or_nproc>
| cli::validation::range<1, 64>
| cli::pack::set_once,
cli::nargs::zero_or_one>
jobs{{.help = "Parallel job count", .default_value = 1}};

The callable must have this shape:

ActionResult<U> fn(ActionCtx<void> ctx, ActionResult<T> input);

T is the current pipeline value and U becomes the next pipeline value. Since action::custom is non-terminal, it does not store anything by itself; it must eventually be followed by a pack action such as pack::set_once or pack::push.

Use a reusable struct when the action is part of your API, needs template parameters, or should be documented/tested independently. A pipeline step is a constexpr-constructible struct with an operator() and three type-level traits. cli20 provides two deduction helpers — cli::deduce_accepts_input and cli::deduce_after_type — that derive two of those traits from operator(), so you only write what is unique to your step.

Deduction helpers​

HelperWhat it deduces
cli::deduce_accepts_input<Step, Input>true when operator()(ActionCtx<void>, ActionResult<Input>) is callable
cli::deduce_after_type<Step, Input>T extracted from the return type ActionResult<T> — ill-formed if deduce_accepts_input is false

Use them in the three-trait declarations to eliminate boilerplate for conversion and validation steps:

struct MyStep {
template <class Input>
static constexpr bool accepts_input =
cli::deduce_accepts_input<MyStep, Input>;

template <class Input>
using after_type = cli::deduce_after_type<MyStep, Input>;

template <class Input>
using storage_type = void; // always void for non-terminal steps

// operator() ...
};

deduce_after_type is deliberately ill-formed for incompatible inputs (rather than silently falling back to void). Because validate_impl always checks accepts_input before accessing after_type, this never fires inside a valid pipeline — but it gives a clear error if the alias is instantiated incorrectly.

Custom Conversion​

// Parses "HH:MM" into std::pair<int,int>.
struct HHMMConversion {
template <class Input>
static constexpr bool accepts_input =
cli::deduce_accepts_input<HHMMConversion, Input>;

template <class Input>
using after_type = cli::deduce_after_type<HHMMConversion, Input>;

template <class Input>
using storage_type = void;

auto operator()(cli::ActionCtx<void> ctx,
cli::ActionResult<std::string_view> input) const
-> cli::ActionResult<std::pair<int, int>> {
auto sv = input.value;
auto colon = sv.find(':');
if (colon == sv.npos)
return cli::ActionResult<std::pair<int, int>>::fail(
cli::detail::invalid_value_error(
cli::ErrorKind::conversion, ctx.index,
std::string(sv), "expected HH:MM"));
// ... parse hours and minutes ...
return cli::ActionResult<std::pair<int, int>>::ok({h, m});
}
};

inline constexpr auto hhmm = cli::Action<HHMMConversion{}>{};
constexpr auto schedule_action = hhmm | cli::pack::push;

Validation steps follow the same pattern — a template operator() that passes the value through unchanged on success:

// Reject values not divisible by a compile-time divisor.
template <int Divisor>
struct DivisibleBy {
template <class Input>
static constexpr bool accepts_input =
cli::deduce_accepts_input<DivisibleBy, Input>;

template <class Input>
using after_type = cli::deduce_after_type<DivisibleBy, Input>;

template <class Input>
using storage_type = void;

template <class Prev>
auto operator()(cli::ActionCtx<void> ctx,
cli::ActionResult<Prev> input) const
-> cli::ActionResult<Prev> {
if (input.value % Divisor != 0)
return cli::ActionResult<Prev>::fail(
cli::detail::validation_failed_error(
ctx.index,
cli::detail::to_error_subject(input.value),
"must be divisible by " + std::to_string(Divisor)));
return input;
}
};

template <int D>
inline constexpr auto divisible_by = cli::Action<DivisibleBy<D>{}>{};

constexpr auto chunk_action =
cli::conversion::integer<int>
| divisible_by<4>
| cli::pack::set_once;

Custom Pack​

Pack steps are terminal: they write a value into storage and return ActionResult<void>. storage_type must always be declared explicitly — it is the only trait that cannot be derived from operator() (see Why storage_type cannot be inferred).

// Store only the maximum value seen across multiple occurrences.
struct StoreMax {
template <class Prev>
static constexpr bool accepts_input =
!std::same_as<std::remove_cvref_t<Prev>, void>;

template <class Prev>
using after_type = void;

template <class Prev>
using storage_type = std::optional<std::remove_cvref_t<Prev>>;

template <class Prev>
auto operator()(cli::ActionCtx<storage_type<Prev>> ctx,
cli::ActionResult<Prev> input) const
-> cli::ActionResult<void> {
auto& stored = ctx.arg.get();
if (!stored.has_value() || input.value > *stored)
stored = input.value;
return cli::ActionResult<void>::ok();
}
};

inline constexpr auto store_max = cli::Action<StoreMax{}>{};

constexpr auto latency_action =
cli::conversion::integer<int>
| cli::validation::positive
| store_max;

Why storage_type cannot be inferred​

deduce_accepts_input and deduce_after_type work because non-terminal steps always take ActionCtx<void> — a concrete type that can be used as a probe for std::invocable / std::invoke_result_t.

Pack steps take ActionCtx<storage_type<Prev>>. The template argument depends on Prev, and there is no way to recover storage_type<Prev> from the parameter type of operator() without reverse-engineering a template argument — not feasible for generic or constrained operators. Declaring storage_type explicitly is the minimal remaining obligation for pack steps.

Advanced: writing all three traits manually​

For cases where the deduction helpers don't fit — e.g. an operator() with constraints so complex that std::invocable gives the wrong answer — write all three traits explicitly:

struct MyStep {
template <class Prev>
static constexpr bool accepts_input = /* condition */;

template <class Prev>
using after_type = /* output type */;

template <class Prev>
using storage_type = void; // void for non-terminal; storage T for pack

// operator() ...
};

Utility Suggestions for Custom Actions​

PassThrough<Fn> — Validation Without Boilerplate​

Use deduce_accepts_input / deduce_after_type to build a reusable predicate-based validation step with no manual trait logic:

template <auto Fn, StringLiteral Msg>
struct PassThrough {
template <class Input>
static constexpr bool accepts_input =
cli::deduce_accepts_input<PassThrough, Input>;

template <class Input>
using after_type = cli::deduce_after_type<PassThrough, Input>;

template <class Input>
using storage_type = void;

template <class Prev>
auto operator()(cli::ActionCtx<void> ctx,
cli::ActionResult<Prev> input) const
-> cli::ActionResult<Prev> {
if (!Fn(input.value))
return cli::ActionResult<Prev>::fail(
cli::detail::validation_failed_error(
ctx.index,
cli::detail::to_error_subject(input.value),
std::string(Msg.view())));
return input;
}
};

MapConversion<Fn> — Stateless String Mapping​

For conversions that are a function from string_view → std::optional<T>:

template <auto Fn>
struct MapConversion {
using output_type = std::remove_cvref_t<
decltype(*std::invoke(Fn, std::string_view{}))>;

template <class Input>
static constexpr bool accepts_input =
cli::deduce_accepts_input<MapConversion, Input>;

template <class Input>
using after_type = cli::deduce_after_type<MapConversion, Input>;

template <class Input>
using storage_type = void;

auto operator()(cli::ActionCtx<void> ctx,
cli::ActionResult<std::string_view> input) const
-> cli::ActionResult<output_type> {
if (auto v = Fn(input.value))
return cli::ActionResult<output_type>::ok(std::move(*v));
return cli::ActionResult<output_type>::fail(
cli::detail::invalid_value_error(
cli::ErrorKind::conversion, ctx.index,
std::string(input.value)));
}
};

Error Helper Cheatsheet​

HelperError codeTypical use
detail::invalid_value_error(kind, index, subject, detail)invalid_valueconversion failure
detail::validation_failed_error(index, subject, detail)validation_failedconstraint not satisfied
detail::duplicate_argument_error(index, subject)duplicate_argumentpack rejects second occurrence
detail::invalid_choice_error(index, subject)invalid_choiceunknown enum/choice value
detail::to_error_subject(value)—format any value as a subject string

ErrorKind::conversion should be used in conversion steps; ErrorKind::validation in validation and pack steps.