Skip to main content

nmbrs_runtime/wrappers/
fields.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Fields wrapper — prints the rendered op text per cycle.
5//!
6//! Two modes, distinguished by whether op_fields are attached at
7//! wrap time:
8//! - **Adapter-agnostic field render** (`fields: true` on op template):
9//!   resolves the op_fields the template carries via the
10//!   per-fiber wires, prints each value verbatim, then forwards
11//!   the call to inner. This is the canonical "render what the
12//!   adapter would send" surface — under `dryrun=fields` the
13//!   DRYRUN wrapper short-circuits inner, so this wrapper is the
14//!   only surface that prints.
15//! - **Body+wire dump** (no op_fields at wrap): falls back to
16//!   the inner result body + wire snapshot. Kept as the
17//!   historical surface for callers that want post-execute
18//!   introspection rather than pre-execute rendering.
19
20use std::sync::Arc;
21
22use crate::adapter::WrappingDispenser;
23use crate::adapter::{ExecutionError, OpDispenser, OpResult};
24use crate::wrapper_registry::{WrapperName, WrapperRegistration, WrapperSubject};
25
26/// SRD-32a wrapper name.
27pub const NAME: WrapperName = WrapperName::new("fields");
28
29/// Trigger: op carries `fields: true` (bool, or string "true").
30fn triggers(s: WrapperSubject) -> bool {
31    let Some(template) = s.op() else {
32        return false;
33    };
34    template
35        .params
36        .get("fields")
37        .map(|v| {
38            v.as_bool()
39                .unwrap_or_else(|| v.as_str().map(|s| s == "true").unwrap_or(false))
40        })
41        .unwrap_or(false)
42}
43
44fn describe_assignment(_: WrapperSubject) -> Option<String> {
45    Some("fields: rendered op text to stdout".into())
46}
47
48inventory::submit! {
49    WrapperRegistration {
50        name: NAME,
51        owned_fields: &["fields"],
52        triggers,
53        // SRD-32a's table lists `fields.requires_inner = [result]`,
54        // but the cascade composes `result` OUTSIDE `fields`
55        // (innermost-first list ends `..., fields, result,
56        // metrics`). Declaring `requires_inner = [result]` would
57        // make `result` innermore than `fields`, which contradicts
58        // the cascade and breaks the byte-identical-output test
59        // bar in §"Migration".
60        requires_inner: &[],
61        forbids_outer: &[],
62        mutually_exclusive_with: &[],
63        describe_assignment,
64        levels: &[crate::wrapper_registry::WrapperLevel::Op],
65    }
66}
67
68/// Wraps any adapter's dispenser and prints the result body to stdout
69/// as JSON after each execution. Adapter-agnostic — works with CQL,
70/// HTTP, stdout, or any adapter that returns a ResultBody.
71///
72/// Enabled by wrapping at init time when `dryrun=fields` is active
73/// or when the op has `fields: true`.
74pub struct FieldsDispenser {
75    inner: Arc<dyn OpDispenser>,
76    op_name: String,
77    /// Op-field key/value pairs cloned from the op template at
78    /// wrap time. When non-empty, this wrapper resolves them via wires
79    /// at each cycle and prints the value strings — that's the
80    /// pre-execute "render what would have been sent" surface.
81    /// When empty, falls back to the post-execute body
82    /// + wires dump for callers that want introspection only.
83    op_fields: Vec<(String, serde_json::Value)>,
84}
85
86impl FieldsDispenser {
87    pub fn wrap(inner: Arc<dyn OpDispenser>, op_name: &str) -> Arc<dyn OpDispenser> {
88        Arc::new(Self {
89            inner,
90            op_name: op_name.to_string(),
91            op_fields: Vec::new(),
92        })
93    }
94
95    /// Same as [`Self::wrap`], but seeds the dispenser with the
96    /// op_fields it should resolve + print on each cycle. Used
97    /// at activity init when the op template's `op:` map is
98    /// known and we want the rendered op text on stdout (e.g.
99    /// under `dryrun=fields`).
100    pub fn wrap_with_op_fields(
101        inner: Arc<dyn OpDispenser>,
102        op_name: &str,
103        op_fields: Vec<(String, serde_json::Value)>,
104    ) -> Arc<dyn OpDispenser> {
105        Arc::new(Self {
106            inner,
107            op_name: op_name.to_string(),
108            op_fields,
109        })
110    }
111}
112
113impl WrappingDispenser for FieldsDispenser {}
114
115impl OpDispenser for FieldsDispenser {
116    fn execute<'a>(
117        &'a self,
118        cycle: u64,
119        ctx: &'a crate::fixture::ExecCtx<'a>,
120    ) -> std::pin::Pin<
121        Box<dyn std::future::Future<Output = Result<OpResult, ExecutionError>> + Send + 'a>,
122    > {
123        Box::pin(async move {
124            // Pre-execute rendering mode — resolve the
125            // captured op_fields and print their rendered
126            // values. This runs BEFORE inner.execute so the
127            // surface lands the rendered op text whether or
128            // not inner short-circuits (DRYRUN under
129            // dryrun=fields, etc).
130            if !self.op_fields.is_empty() {
131                match crate::wires::resolve_op_fields_via_wires(&self.op_fields, ctx.wires) {
132                    Ok(resolved) => {
133                        for s in resolved.strings().iter() {
134                            println!("{s}");
135                        }
136                    }
137                    Err(msg) => {
138                        eprintln!("[{}@{}] fields-render failed: {msg}", self.op_name, cycle);
139                    }
140                }
141            }
142
143            let result = self.inner.execute(cycle, ctx).await?;
144
145            // Post-execute introspection — only fires when
146            // this wrapper was wrapped without op_fields (the
147            // body+wires fallback surface). With op_fields the
148            // pre-execute render is the authoritative surface
149            // and the post-execute dump is omitted to keep
150            // stdout terse.
151            if self.op_fields.is_empty() {
152                if let Some(ref body) = result.body {
153                    let json = body.to_json();
154                    println!(
155                        "[{}@{}] {} rows: {}",
156                        self.op_name,
157                        cycle,
158                        body.element_count(),
159                        serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string())
160                    );
161                } else {
162                    println!("[{}@{}] (no result body)", self.op_name, cycle);
163                }
164                for name in ctx.wires.names() {
165                    if let Some(value) = ctx.wires.get(&name) {
166                        println!("  wire {name} = {}", value.to_display_string());
167                    }
168                }
169            }
170
171            Ok(result)
172        })
173    }
174    fn inner_dispenser(&self) -> Option<&dyn OpDispenser> {
175        Some(self.inner.as_ref())
176    }
177}