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:
- Conversion — parse
"8"into8(or fail with an error). - Validation — verify the integer satisfies any constraints (e.g. 1–32).
- 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:
- Start with
Prev = std::string_view. - For each step
Headin order:- Check
Head::accepts_input<Prev>— propagatefalseif the type is incompatible. - Compute the next intermediate type:
Head::after_type<Prev>.
- Check
- When the last step is reached,
Head::storage_type<Prev>must not bevoid(astatic_assertenforces this — the pipeline must end with a pack action). - Return
pair<bool, type_identity<StorageType>>.Arg<>extractsStorageTypeand uses it as the field'svalue_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>≈ theTinstd::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.
| Expression | Output type |
|---|---|
conversion::integer<T> | T (any integral type) |
conversion::floating<T> | T (any floating-point type) |
conversion::string | std::string |
conversion::boolean | bool (true/false/1/0) |
conversion::path | std::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.
| Expression | Condition |
|---|---|
validation::range<Min, Max> | Min ≤ value ≤ Max |
validation::min<N> | value ≥ N |
validation::max<N> | value ≤ N |
validation::positive | value > 0 |
validation::non_negative | value ≥ 0 |
validation::non_empty | container / string not empty |
validation::not_blank | string not all-whitespace |
validation::matches<"regex"> | full regex match |
validation::one_of<A, B, ...> | equals one of the listed constants |
validation::exists | std::filesystem::path exists |
validation::is_regular_file | path is a regular file |
validation::is_directory | path is a directory |
validation::parent_exists | parent 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.
| Expression | Storage type | Behaviour |
|---|---|---|
pack::set_once | T | stores once; rejects duplicates |
pack::push | std::vector<T> | appends on each occurrence |
pack::push_unique | std::vector<T> | appends; rejects duplicate values |
pack::insert | std::set<T> | inserts into set |
pack::insert_or_assign | std::map<K,V> | inserts/updates map entry (used with negatable) |
pack::set_true | bool | always stores true |
pack::set_false | bool | always stores false |
pack::toggle | bool | flips on each occurrence |
pack::increment | std::size_t | increments on each occurrence |
pack::write_to<T> | T* | writes through a pointer |
pack::callback<fn> | std::monostate | calls 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:
| Field | Type | Meaning |
|---|---|---|
index | std::size_t | argv position of the token (used in error messages) |
occurrences | std::size_t | how many times this option token has been seen |
invoke_count | std::size_t | how 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
| Helper | What 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
| Helper | Error code | Typical use |
|---|---|---|
detail::invalid_value_error(kind, index, subject, detail) | invalid_value | conversion failure |
detail::validation_failed_error(index, subject, detail) | validation_failed | constraint not satisfied |
detail::duplicate_argument_error(index, subject) | duplicate_argument | pack rejects second occurrence |
detail::invalid_choice_error(index, subject) | invalid_choice | unknown 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.