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}