Skip to main content

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>>;