majordomus_cli/capability/benchmark.rs
1//! Benchmark identity is the capability's identity: every executable capability is a
2//! benchmark target for each transport it is exposed on, and the only thing a declaration
3//! may have to add is a representative input. That input comes from the input type
4//! itself, through [`BenchmarkCases`], so that one typed case feeds the direct, MCP and
5//! HTTP runners through the real serialisers, and a capability whose input type has no
6//! cases does not compile.
7
8use serde::Serialize;
9use serde_json::Value;
10
11use crate::index::Index;
12
13/// One representative input, named so that results and baselines can refer to it.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct NamedCase<T> {
16 /// A short stable name: `default`, `kind-rule`, `first-object`.
17 pub name: &'static str,
18 /// The input.
19 pub input: T,
20}
21
22impl<T> NamedCase<T> {
23 /// A named case.
24 pub fn new(name: &'static str, input: T) -> Self {
25 NamedCase { name, input }
26 }
27}
28
29/// What a case provider may look at: the repository's index, so that a case can name an
30/// object that exists (`objects.get` needs a real URI) and skip itself when the
31/// repository holds nothing it could ask for.
32pub struct CaseContext<'a> {
33 /// The index of the repository the benchmark runs against.
34 pub index: &'a Index,
35}
36
37impl<'a> CaseContext<'a> {
38 /// The case context of a live context: what a caller that holds a
39 /// [`crate::capability::Context`] hands to a case provider.
40 ///
41 /// It exists so that reaching a provider does not require reaching into the index at
42 /// the call site. A benchmark case is declared against the index by design — the
43 /// vocabulary above says why — and a caller that only wants to *run* the declaration
44 /// should not have to open the index to do it, because a page that opens the index has
45 /// no way of being told apart from a page that reads it (ADR 0012).
46 ///
47 /// ```
48 /// use majordomus_cli::capability::CaseContext;
49 /// use majordomus_cli::synthetic::SyntheticRepository;
50 /// let repo = SyntheticRepository::small().expect("a synthetic repository");
51 /// let ctx = repo.context().expect("a context");
52 /// let cases = CaseContext::of(&ctx);
53 /// assert_eq!(cases.index.objects.len(), ctx.index.objects.len());
54 /// ```
55 pub fn of(ctx: &'a crate::capability::Context) -> Self {
56 CaseContext { index: &ctx.index }
57 }
58}
59
60/// Representative inputs of a capability's input type. Implemented once per input type;
61/// the `capability!` macro requires it, so a new capability without a benchmark case is a
62/// compile error, not a coverage report.
63///
64/// ```
65/// use majordomus_cli::capability::{BenchmarkCases, NamedCase, CaseContext};
66/// #[derive(serde::Serialize)]
67/// struct Input { limit: u64 }
68/// impl BenchmarkCases for Input {
69/// fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
70/// vec![NamedCase::new("default", Input { limit: 10 })]
71/// }
72/// }
73/// ```
74pub trait BenchmarkCases: Sized + Serialize {
75 /// The cases, in a stable order. An empty list means "nothing to benchmark in this
76 /// repository", which the coverage check reports as missing.
77 fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>>;
78
79 /// The cases as JSON, the shape every runner serialises from.
80 fn benchmark_cases_json(ctx: &CaseContext<'_>) -> Vec<NamedCase<Value>> {
81 Self::benchmark_cases(ctx)
82 .into_iter()
83 .map(|c| NamedCase {
84 name: c.name,
85 input: serde_json::to_value(&c.input).unwrap_or(Value::Null),
86 })
87 .collect()
88 }
89}
90
91/// The type-erased case provider an executable carries.
92pub type CaseProvider = fn(&CaseContext<'_>) -> Vec<NamedCase<Value>>;