Skip to main content

nmbrs_runtime/wrappers/
readout.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! `readout` wrapper (SRD-63) — opt-in per-op status visibility.
5//!
6//! When an op declares `readout: visible`, this wrapper wraps it so each
7//! execution reports an op-level lifecycle (start / complete / fail) to the
8//! active observer via [`crate::observer::global_observer`], nested under the
9//! op's parent phase ([`crate::execution_context::current_phase_node`]). The
10//! TUI renders these as indented op status lines with their own execution
11//! timer, so aggregate-few-ops phases (e.g. `finalize_index`: flush, compact,
12//! poll) reveal per-op timing.
13//!
14//! Zero cost by default: absent the `readout:` field the wrapper is never
15//! inserted into the op's shell stack (its `triggers` returns false), so the
16//! common path pays nothing.
17
18use std::sync::Arc;
19
20use crate::adapter::{ExecutionError, OpDispenser, OpResult, WrappingDispenser};
21use crate::wrapper_registry::{WrapperName, WrapperRegistration, WrapperSubject};
22
23/// SRD-32a wrapper name.
24pub const NAME: WrapperName = WrapperName::new("readout");
25
26/// Trigger: `readout: visible` on the op template. Any other value (or
27/// absence) leaves the wrapper off.
28fn triggers(s: WrapperSubject) -> bool {
29    let Some(template) = s.op() else {
30        return false;
31    };
32    template
33        .params
34        .get("readout")
35        .and_then(|v| v.as_str())
36        .map(|v| v.eq_ignore_ascii_case("visible"))
37        .unwrap_or(false)
38}
39
40fn describe_assignment(s: WrapperSubject) -> Option<String> {
41    let template = s.op()?;
42    let v = template.params.get("readout")?.as_str()?;
43    Some(format!("readout: {v} (op-level status line)"))
44}
45
46inventory::submit! {
47    WrapperRegistration {
48        name: NAME,
49        owned_fields: &["readout"],
50        triggers,
51        // Sits outside `traverse` like the other op wrappers so it observes
52        // the fully-resolved inner execution.
53        requires_inner: &[super::traverse::NAME],
54        forbids_outer: &[],
55        mutually_exclusive_with: &[],
56        describe_assignment,
57        levels: &[crate::wrapper_registry::WrapperLevel::Op],
58    }
59}
60
61/// Wraps an inner dispenser to report op-level lifecycle events so the op
62/// surfaces as its own timed status leaf under its phase. The wrapper times
63/// only the inner op's execution; it never alters the result or errors.
64pub struct ReadoutDispenser {
65    inner: Arc<dyn OpDispenser>,
66    op_name: String,
67    /// Optional `measure:` template — the op's key measurable, rendered against
68    /// the wires at completion. `None` when the op declares none, in which case
69    /// the leaf row shows only its duration, exactly as before.
70    measure: Option<String>,
71}
72
73impl ReadoutDispenser {
74    pub fn wrap(inner: Arc<dyn OpDispenser>, op_name: String) -> Arc<dyn OpDispenser> {
75        Self::wrap_with_measure(inner, op_name, None)
76    }
77
78    pub fn wrap_with_measure(
79        inner: Arc<dyn OpDispenser>,
80        op_name: String,
81        measure: Option<String>,
82    ) -> Arc<dyn OpDispenser> {
83        Arc::new(Self {
84            inner,
85            op_name,
86            measure,
87        })
88    }
89}
90
91impl WrappingDispenser for ReadoutDispenser {}
92
93impl OpDispenser for ReadoutDispenser {
94    fn execute<'a>(
95        &'a self,
96        cycle: u64,
97        ctx: &'a crate::fixture::ExecCtx<'a>,
98    ) -> std::pin::Pin<
99        Box<dyn std::future::Future<Output = Result<OpResult, ExecutionError>> + Send + 'a>,
100    > {
101        Box::pin(async move {
102            let parent = crate::execution_context::current_phase_node();
103            let obs = crate::observer::global_observer();
104            if let (Some(p), Some(o)) = (parent, obs.as_ref()) {
105                o.op_starting(p, &self.op_name);
106            }
107            let start = std::time::Instant::now();
108            let result = self.inner.execute(cycle, ctx).await;
109            let dur = start.elapsed().as_secs_f64();
110            if let (Some(p), Some(o)) = (parent, obs.as_ref()) {
111                // The op's key measurable, rendered against the wires the op just
112                // populated (captures included). Emitted before completion so the
113                // display has it when the leaf row settles.
114                if let Some(t) = self.measure.as_deref() {
115                    match crate::wires::substitute_via_wires(t, ctx.wires) {
116                        Ok(text) => o.op_measure(p, &self.op_name, &text),
117                        Err(e) => crate::diag!(
118                            crate::observer::LogLevel::Debug,
119                            "measure: substitution failed for '{t}': {e}"
120                        ),
121                    }
122                }
123                match &result {
124                    Ok(_) => o.op_completed(p, &self.op_name, dur),
125                    Err(e) => o.op_failed(p, &self.op_name, &format!("{e}")),
126                }
127            }
128            result
129        })
130    }
131
132    fn inner_dispenser(&self) -> Option<&dyn OpDispenser> {
133        Some(self.inner.as_ref())
134    }
135}