1use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13use crate::capability::{CachePolicy, CapabilityKind, CapabilityRegistry, CaseContext, Context};
14
15use super::system::SystemTarget;
16
17#[derive(
19 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
20)]
21#[serde(rename_all = "snake_case")]
22pub enum Transport {
23 Direct,
25 Mcp,
27 Http,
29}
30
31impl Transport {
32 pub const ALL: [Transport; 3] = [Transport::Direct, Transport::Mcp, Transport::Http];
34
35 pub fn name(self) -> &'static str {
37 match self {
38 Transport::Direct => "direct",
39 Transport::Mcp => "mcp",
40 Transport::Http => "http",
41 }
42 }
43
44 pub fn parse(text: &str) -> Option<Self> {
46 match text {
47 "direct" => Some(Transport::Direct),
48 "mcp" => Some(Transport::Mcp),
49 "http" => Some(Transport::Http),
50 _ => None,
51 }
52 }
53}
54
55#[derive(
62 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
63)]
64#[serde(rename_all = "snake_case")]
65pub enum View {
66 Pinned,
68 Live,
71}
72
73impl View {
74 pub fn name(self) -> &'static str {
83 match self {
84 View::Pinned => "pinned",
85 View::Live => "live",
86 }
87 }
88}
89
90#[allow(clippy::large_enum_variant)]
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
94#[serde(tag = "kind", rename_all = "snake_case")]
95pub enum TargetKind {
96 Capability {
98 id: String,
100 module: String,
102 transport: Transport,
104 case: String,
106 input: Value,
108 cache: CachePolicy,
110 #[serde(skip_serializing_if = "Option::is_none")]
112 tool: Option<String>,
113 #[serde(skip_serializing_if = "Option::is_none")]
115 route: Option<(String, String)>,
116 },
117 System {
119 target: SystemTarget,
121 },
122 Page {
124 route: String,
126 view: View,
128 deferred: bool,
132 },
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
137pub struct BenchmarkTarget {
138 pub key: String,
140 pub kind: TargetKind,
142}
143
144impl BenchmarkTarget {
145 pub fn transport(&self) -> Transport {
147 match &self.kind {
148 TargetKind::Capability { transport, .. } => *transport,
149 TargetKind::System { target } => target.transport(),
150 TargetKind::Page { .. } => Transport::Http,
151 }
152 }
153
154 pub fn capability_id(&self) -> Option<&str> {
156 match &self.kind {
157 TargetKind::Capability { id, .. } => Some(id),
158 TargetKind::System { .. } | TargetKind::Page { .. } => None,
159 }
160 }
161
162 pub fn is_deferred(&self) -> bool {
178 matches!(self.kind, TargetKind::Page { deferred: true, .. })
179 }
180}
181
182fn ui_pages() -> Vec<BenchmarkTarget> {
186 let page = |route: &str, view: View, deferred: bool| BenchmarkTarget {
187 key: page_key(route, view),
188 kind: TargetKind::Page {
189 route: route.to_string(),
190 view,
191 deferred,
192 },
193 };
194 let mut pages = Vec::new();
195 for view in [View::Pinned, View::Live] {
196 pages.extend(
197 crate::ui::SCREENS
198 .iter()
199 .map(|screen| page(screen.route, view, false)),
200 );
201 }
202 pages.extend(
203 crate::ui::PANELS
204 .iter()
205 .map(|route| page(route, View::Pinned, true)),
206 );
207 pages
208}
209
210fn page_key(route: &str, view: View) -> String {
219 let below = route
220 .strip_prefix(crate::ui::PREFIX)
221 .unwrap_or(route)
222 .trim_matches('/');
223 let name = if below.is_empty() {
224 "index".to_string()
225 } else {
226 below.replace('/', ".").replace('-', "_")
227 };
228 match view {
229 View::Pinned => format!("system.http.ui.{name}"),
230 View::Live => format!("system.http.live.ui.{name}"),
231 }
232}
233
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
236pub struct BenchmarkProjection {
237 pub targets: Vec<BenchmarkTarget>,
240}
241
242impl BenchmarkProjection {
243 pub fn from_context(ctx: &Context) -> Self {
245 let registry: &CapabilityRegistry = &ctx.registry;
246 let case_ctx = CaseContext { index: &ctx.index };
247 let mut targets = Vec::new();
248 for c in registry.iter() {
249 if !c.kind.is_executable() || !c.stability.executable() {
250 continue;
251 }
252 if !c.benchmark.is_target() {
253 continue;
254 }
255 let Some(provider) = registry.cases(c.id.as_str()) else {
256 continue;
257 };
258 let cases = provider(&case_ctx);
259 for transport in Transport::ALL {
260 let tool = c.exposure.mcp.as_ref().and_then(|m| m.tool.clone());
261 let route = c
262 .exposure
263 .http
264 .as_ref()
265 .map(|h| (h.method.as_str().to_string(), h.path.clone()));
266 let exposed = match transport {
267 Transport::Direct => true,
268 Transport::Mcp => tool.is_some(),
269 Transport::Http => route.is_some(),
270 };
271 if !exposed {
272 continue;
273 }
274 for case in &cases {
275 targets.push(BenchmarkTarget {
276 key: format!("{}|{}|{}", c.id, transport.name(), case.name),
277 kind: TargetKind::Capability {
278 id: c.id.to_string(),
279 module: c.module.as_str().to_string(),
280 transport,
281 case: case.name.to_string(),
282 input: case.input.clone(),
283 cache: c.cache,
284 tool: (transport == Transport::Mcp)
285 .then(|| tool.clone())
286 .flatten(),
287 route: (transport == Transport::Http)
288 .then(|| route.clone())
289 .flatten(),
290 },
291 });
292 }
293 }
294 }
295 for s in SystemTarget::ALL {
296 targets.push(BenchmarkTarget {
297 key: s.key().to_string(),
298 kind: TargetKind::System { target: s },
299 });
300 }
301 targets.extend(ui_pages());
302 BenchmarkProjection { targets }
303 }
304
305 pub fn pages(&self) -> impl Iterator<Item = &BenchmarkTarget> {
316 self.targets
317 .iter()
318 .filter(|t| matches!(t.kind, TargetKind::Page { .. }))
319 }
320
321 pub fn by_transport(&self, transport: Transport) -> impl Iterator<Item = &BenchmarkTarget> {
323 self.targets
324 .iter()
325 .filter(move |t| t.transport() == transport)
326 }
327
328 pub fn of_capability<'a>(&'a self, id: &'a str) -> impl Iterator<Item = &'a BenchmarkTarget> {
330 self.targets
331 .iter()
332 .filter(move |t| t.capability_id() == Some(id))
333 }
334
335 pub fn covers(&self, id: &str, transport: Transport) -> bool {
337 self.of_capability(id).any(|t| t.transport() == transport)
338 }
339
340 pub fn is_command(registry: &CapabilityRegistry, id: &str) -> bool {
343 registry
344 .get(id)
345 .is_some_and(|c| c.kind == CapabilityKind::Command)
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[test]
356 fn every_ui_screen_and_panel_is_a_page_target_and_only_they_are() {
357 let pages = ui_pages();
358 for screen in crate::ui::SCREENS {
359 for view in [View::Pinned, View::Live] {
360 assert!(
361 pages.iter().any(|t| t.kind
362 == TargetKind::Page {
363 route: screen.route.to_string(),
364 view,
365 deferred: false,
366 }),
367 "{} over {}",
368 screen.route,
369 view.name()
370 );
371 }
372 }
373 for panel in crate::ui::PANELS {
374 let of: Vec<_> = pages
375 .iter()
376 .filter(|t| matches!(&t.kind, TargetKind::Page { route, .. } if route == panel))
377 .collect();
378 assert_eq!(of.len(), 1, "{panel}: once, pinned");
379 assert!(of[0].is_deferred(), "{panel} is deferred");
380 }
381 assert_eq!(
382 pages.len(),
383 2 * crate::ui::SCREENS.len() + crate::ui::PANELS.len()
384 );
385 let mut keys: Vec<&str> = pages.iter().map(|t| t.key.as_str()).collect();
386 keys.sort_unstable();
387 keys.dedup();
388 assert_eq!(keys.len(), pages.len(), "a key per page");
389 assert!(pages.iter().all(|t| t.transport() == Transport::Http));
390 assert!(pages.iter().all(|t| t.capability_id().is_none()));
391 }
392
393 #[test]
394 fn a_page_key_names_its_view_and_its_route() {
395 assert_eq!(page_key("/ui", View::Pinned), "system.http.ui.index");
396 assert_eq!(page_key("/ui/", View::Live), "system.http.live.ui.index");
397 assert_eq!(
398 page_key("/ui/panels/pull-requests", View::Pinned),
399 "system.http.ui.panels.pull_requests"
400 );
401 assert_eq!(
402 page_key("/ui/roadmap", View::Live),
403 "system.http.live.ui.roadmap"
404 );
405 }
406}