1use std::sync::Arc;
40
41use crate::adapter::WrappingDispenser;
42use crate::adapter::{ExecutionError, OpDispenser, OpResult};
43use crate::wrapper_registry::{WrapperName, WrapperRegistration, WrapperSubject};
44
45pub const NAME: WrapperName = WrapperName::new("gutter");
46
47#[derive(Debug, Clone, PartialEq)]
51pub enum GutterSpec {
52 Text(String),
55 Bar(f64),
57 Spark(f64),
59 Labeled { name: String, value: String },
65}
66
67fn triggers(s: WrapperSubject) -> bool {
70 let Some(template) = s.op() else {
71 return false;
72 };
73 template
74 .params
75 .get("gutter")
76 .map(|v| v.is_string() || v.is_object())
77 .unwrap_or(false)
78}
79
80fn describe_assignment(s: WrapperSubject) -> Option<String> {
81 let template = s.op()?;
82 let v = template.params.get("gutter")?;
83 if let Some(s) = v.as_str() {
84 if s.is_empty() {
85 return None;
86 }
87 Some(format!("gutter: \"{s}\" (layout)"))
88 } else if let Some(obj) = v.as_object() {
89 let mut parts: Vec<String> = Vec::new();
90 for key in ["bar", "spark", "text"] {
91 if let Some(t) = obj.get(key).and_then(|x| x.as_str()) {
92 parts.push(format!("{key} \"{t}\""));
93 }
94 }
95 match obj.get("final") {
96 Some(f) if f.is_string() => {
97 parts.push(format!("final \"{}\"", f.as_str().unwrap_or("")))
98 }
99 Some(f) if f.is_object() => parts.push("final {…}".to_string()),
100 _ => {}
101 }
102 if parts.is_empty() {
103 None
104 } else {
105 Some(format!("gutter: {}", parts.join(" / ")))
106 }
107 } else {
108 None
109 }
110}
111
112inventory::submit! {
113 WrapperRegistration {
114 name: NAME,
115 owned_fields: &["gutter"],
120 triggers,
121 requires_inner: &[super::traverse::NAME],
122 forbids_outer: &[],
123 mutually_exclusive_with: &[],
124 describe_assignment,
125 levels: &[crate::wrapper_registry::WrapperLevel::Op],
126 }
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum GutterKind {
132 Text,
133 Bar,
134 Spark,
135 Labeled,
139}
140
141pub fn parse_specs(
147 v: Option<&serde_json::Value>,
148) -> (Option<(GutterKind, String)>, Option<(GutterKind, String)>) {
149 let parse_forms =
150 |obj: &serde_json::Map<String, serde_json::Value>| -> Option<(GutterKind, String)> {
151 if let Some(m) = obj.get("labeled").and_then(|x| x.as_object()) {
152 let name = m
153 .get("name")
154 .and_then(|x| x.as_str())
155 .unwrap_or("")
156 .to_string();
157 let value = m
158 .get("value")
159 .and_then(|x| x.as_str())
160 .unwrap_or("")
161 .to_string();
162 return Some((GutterKind::Labeled, format!("{name}{}{value}", '\u{1f}')));
163 }
164 if let Some(t) = obj.get("bar").and_then(|x| x.as_str()) {
165 Some((GutterKind::Bar, t.to_string()))
166 } else if let Some(t) = obj.get("spark").and_then(|x| x.as_str()) {
167 Some((GutterKind::Spark, t.to_string()))
168 } else {
169 obj.get("text")
170 .and_then(|x| x.as_str())
171 .map(|t| (GutterKind::Text, t.to_string()))
172 }
173 };
174 match v {
175 Some(serde_json::Value::String(s)) if !s.is_empty() => {
176 (Some((GutterKind::Text, s.clone())), None)
177 }
178 Some(serde_json::Value::Object(obj)) => {
179 let during = parse_forms(obj);
180 let fin = match obj.get("final") {
181 Some(serde_json::Value::String(s)) if !s.is_empty() => {
182 Some((GutterKind::Text, s.clone()))
183 }
184 Some(serde_json::Value::Object(fobj)) => parse_forms(fobj),
185 _ => None,
186 };
187 (during, fin)
188 }
189 _ => (None, None),
190 }
191}
192
193pub(crate) fn render_spec(
197 kind: GutterKind,
198 template: &str,
199 wires: &dyn crate::wires::WireSource,
200) -> Option<GutterSpec> {
201 let rendered = match crate::wires::substitute_via_wires(template, wires) {
202 Ok(s) => s,
203 Err(e) => {
204 crate::diag!(
205 crate::observer::LogLevel::Debug,
206 "gutter: substitution failed for '{template}': {e}"
207 );
208 return None;
209 }
210 };
211 match kind {
212 GutterKind::Labeled => {
213 let (name, value) = rendered
214 .split_once('\u{1f}')
215 .unwrap_or(("", rendered.as_str()));
216 Some(GutterSpec::Labeled {
217 name: name.to_string(),
218 value: value.to_string(),
219 })
220 }
221 GutterKind::Text => Some(GutterSpec::Text(rendered)),
222 GutterKind::Bar | GutterKind::Spark => match rendered.trim().parse::<f64>() {
223 Ok(v) if kind == GutterKind::Bar => Some(GutterSpec::Bar(v.clamp(0.0, 1.0))),
224 Ok(v) => Some(GutterSpec::Spark(v)),
225 Err(_) => {
226 crate::diag!(
227 crate::observer::LogLevel::Debug,
228 "gutter: '{template}' rendered to non-numeric '{rendered}'"
229 );
230 None
231 }
232 },
233 }
234}
235
236pub struct GutterDispenser {
245 inner: Arc<dyn OpDispenser>,
246 kind: GutterKind,
247 template: String,
248 gutter_state: Arc<arc_swap::ArcSwapOption<GutterSpec>>,
252}
253
254impl GutterDispenser {
255 pub fn wrap(
256 inner: Arc<dyn OpDispenser>,
257 kind: GutterKind,
258 template: String,
259 gutter_state: Arc<arc_swap::ArcSwapOption<GutterSpec>>,
260 ) -> Arc<dyn OpDispenser> {
261 Arc::new(Self {
262 inner,
263 kind,
264 template,
265 gutter_state,
266 })
267 }
268
269 fn publish(&self, wires: &dyn crate::wires::WireSource) {
270 if let Some(spec) = render_spec(self.kind, &self.template, wires) {
271 self.gutter_state.store(Some(Arc::new(spec)));
272 }
273 }
274}
275
276impl WrappingDispenser for GutterDispenser {}
277
278impl OpDispenser for GutterDispenser {
279 fn execute<'a>(
280 &'a self,
281 cycle: u64,
282 ctx: &'a crate::fixture::ExecCtx<'a>,
283 ) -> std::pin::Pin<
284 Box<dyn std::future::Future<Output = Result<OpResult, ExecutionError>> + Send + 'a>,
285 > {
286 Box::pin(async move {
287 let result = self.inner.execute(cycle, ctx).await?;
288 self.publish(ctx.wires);
289 Ok(result)
290 })
291 }
292
293 fn inner_dispenser(&self) -> Option<&dyn OpDispenser> {
294 Some(self.inner.as_ref())
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use crate::adapter::{AdapterError, ExecutionError, OpResult};
302 use crate::fixture::{ExecCtx, ResolvedPulls};
303
304 struct FakeInner {
305 error: Option<&'static str>,
306 }
307
308 impl OpDispenser for FakeInner {
309 fn execute<'a>(
310 &'a self,
311 _cycle: u64,
312 _ctx: &'a ExecCtx<'a>,
313 ) -> std::pin::Pin<
314 Box<dyn std::future::Future<Output = Result<OpResult, ExecutionError>> + Send + 'a>,
315 > {
316 Box::pin(async move {
317 if let Some(msg) = self.error {
318 return Err(ExecutionError::Op(AdapterError {
319 error_name: "test".into(),
320 message: msg.into(),
321 retryable: false,
322 }));
323 }
324 Ok(OpResult {
325 body: None,
326 skipped: false,
327 })
328 })
329 }
330 }
331
332 fn empty_ctx() -> (crate::adapter::ResolvedFields, ResolvedPulls) {
333 let fields = crate::adapter::ResolvedFields::new(vec![], vec![]);
334 let pulls = ResolvedPulls::empty();
335 (fields, pulls)
336 }
337
338 #[tokio::test]
339 async fn text_form_publishes_rendered_layout() {
340 let state = Arc::new(arc_swap::ArcSwapOption::empty());
341 let inner = Arc::new(FakeInner { error: None });
342 let d = GutterDispenser::wrap(
343 inner,
344 GutterKind::Text,
345 "queue depth ok".into(),
346 state.clone(),
347 );
348 let (fields, pulls) = empty_ctx();
349 let ctx = ExecCtx::new(&fields, &pulls);
350 let _ = d.execute(0, &ctx).await.expect("inner ok");
351 assert_eq!(
352 state.load().as_deref(),
353 Some(&GutterSpec::Text("queue depth ok".into()))
354 );
355 }
356
357 #[tokio::test]
358 async fn bar_form_parses_and_clamps_fraction() {
359 let state = Arc::new(arc_swap::ArcSwapOption::empty());
360 let inner = Arc::new(FakeInner { error: None });
361 let d = GutterDispenser::wrap(inner, GutterKind::Bar, "1.7".into(), state.clone());
362 let (fields, pulls) = empty_ctx();
363 let ctx = ExecCtx::new(&fields, &pulls);
364 let _ = d.execute(0, &ctx).await.expect("inner ok");
365 assert_eq!(
366 state.load().as_deref(),
367 Some(&GutterSpec::Bar(1.0)),
368 "fraction must clamp to 0..=1"
369 );
370 }
371
372 #[tokio::test]
373 async fn numeric_parse_failure_leaves_slot_untouched() {
374 let state: Arc<arc_swap::ArcSwapOption<GutterSpec>> =
375 Arc::new(arc_swap::ArcSwapOption::empty());
376 state.store(Some(Arc::new(GutterSpec::Spark(3.0))));
377 let inner = Arc::new(FakeInner { error: None });
378 let d = GutterDispenser::wrap(
379 inner,
380 GutterKind::Spark,
381 "not-a-number".into(),
382 state.clone(),
383 );
384 let (fields, pulls) = empty_ctx();
385 let ctx = ExecCtx::new(&fields, &pulls);
386 let _ = d.execute(0, &ctx).await.expect("inner ok");
387 assert_eq!(
388 state.load().as_deref(),
389 Some(&GutterSpec::Spark(3.0)),
390 "unparseable render must not clobber the last good value"
391 );
392 }
393
394 #[tokio::test]
395 async fn does_not_publish_on_inner_error() {
396 let state: Arc<arc_swap::ArcSwapOption<GutterSpec>> =
397 Arc::new(arc_swap::ArcSwapOption::empty());
398 let inner = Arc::new(FakeInner {
399 error: Some("boom"),
400 });
401 let d = GutterDispenser::wrap(inner, GutterKind::Text, "never".into(), state.clone());
402 let (fields, pulls) = empty_ctx();
403 let ctx = ExecCtx::new(&fields, &pulls);
404 assert!(d.execute(0, &ctx).await.is_err());
405 assert!(
406 state.load().is_none(),
407 "gutter must not publish on inner error"
408 );
409 }
410}