1use rhai::{Array, Dynamic, Map};
24
25use linesmith_plugin::PluginError;
26
27use crate::segments::{sanitize_control_chars, RenderedSegment, Separator};
28use crate::theme::{Color, Role, Style};
29
30pub fn validate_return(value: Dynamic, id: &str) -> Result<Option<RenderedSegment>, PluginError> {
34 if value.is_unit() {
35 return Ok(None);
36 }
37 let map = value
38 .try_cast::<Map>()
39 .ok_or_else(|| malformed(id, "render() must return `()` or a map"))?;
40
41 let runs = parse_runs(&map, id)?;
42 let (text, style) = parse_single_run(&runs, id)?;
43 let separator = match map.get("right_separator") {
44 Some(d) => Some(parse_right_separator(d.clone(), id)?),
45 None => None,
46 };
47
48 let rendered = match separator {
49 Some(sep) => RenderedSegment::with_separator(text, sep),
50 None => RenderedSegment::new(text),
51 };
52 Ok(Some(rendered.with_style(style)))
53}
54
55fn parse_runs(map: &Map, id: &str) -> Result<Array, PluginError> {
56 let runs_val = map
57 .get("runs")
58 .ok_or_else(|| malformed(id, "render() return map is missing `runs`"))?;
59 let arr = runs_val
60 .clone()
61 .try_cast::<Array>()
62 .ok_or_else(|| malformed(id, "`runs` must be an array"))?;
63 if arr.is_empty() {
64 return Err(malformed(id, "`runs` array must not be empty"));
65 }
66 Ok(arr)
67}
68
69fn parse_single_run(runs: &Array, id: &str) -> Result<(String, Style), PluginError> {
70 if runs.len() > 1 {
71 return Err(malformed(
72 id,
73 "linesmith currently supports exactly one styled run per render; multi-run output is deferred",
74 ));
75 }
76 let run = runs[0]
77 .clone()
78 .try_cast::<Map>()
79 .ok_or_else(|| malformed(id, "each entry in `runs` must be a map"))?;
80
81 let text = run
82 .get("text")
83 .ok_or_else(|| malformed(id, "run map is missing `text`"))?
84 .clone()
85 .try_cast::<String>()
86 .ok_or_else(|| malformed(id, "`text` must be a string"))?;
87
88 let style = parse_style(&run, id)?;
89 Ok((text, style))
90}
91
92fn parse_style(run: &Map, id: &str) -> Result<Style, PluginError> {
93 let mut style = Style::default();
94
95 if let Some(role_dyn) = run.get("role") {
96 let role_name = role_dyn
97 .clone()
98 .try_cast::<String>()
99 .ok_or_else(|| malformed(id, "`role` must be a string"))?;
100 style.role = Some(parse_role(&role_name, id)?);
101 }
102
103 if let Some(fg_dyn) = run.get("fg") {
104 let fg_hex = fg_dyn
105 .clone()
106 .try_cast::<String>()
107 .ok_or_else(|| malformed(id, "`fg` must be a hex color string"))?;
108 style.fg = Some(parse_hex_color(&fg_hex, id)?);
109 }
110
111 for (key, slot) in [
112 ("bold", &mut style.bold),
113 ("italic", &mut style.italic),
114 ("underline", &mut style.underline),
115 ("dim", &mut style.dim),
116 ] {
117 if let Some(dyn_val) = run.get(key) {
118 *slot = dyn_val
119 .clone()
120 .try_cast::<bool>()
121 .ok_or_else(|| malformed(id, &format!("`{key}` must be a bool")))?;
122 }
123 }
124
125 if let Some(link_dyn) = run.get("hyperlink") {
126 let url = link_dyn
127 .clone()
128 .try_cast::<String>()
129 .ok_or_else(|| malformed(id, "`hyperlink` must be a string"))?;
130 if !url.is_empty() {
131 style.hyperlink = Some(url);
132 }
133 }
134
135 Ok(style)
136}
137
138fn parse_role(name: &str, id: &str) -> Result<Role, PluginError> {
139 match name {
140 "foreground" => Ok(Role::Foreground),
141 "background" => Ok(Role::Background),
142 "muted" => Ok(Role::Muted),
143 "primary" => Ok(Role::Primary),
144 "accent" => Ok(Role::Accent),
145 "success" => Ok(Role::Success),
146 "warning" => Ok(Role::Warning),
147 "error" => Ok(Role::Error),
148 "info" => Ok(Role::Info),
149 "success_dim" => Ok(Role::SuccessDim),
150 "warning_dim" => Ok(Role::WarningDim),
151 "error_dim" => Ok(Role::ErrorDim),
152 "primary_dim" => Ok(Role::PrimaryDim),
153 "accent_dim" => Ok(Role::AccentDim),
154 "surface" => Ok(Role::Surface),
155 "border" => Ok(Role::Border),
156 other => Err(malformed(
157 id,
158 &format!("unknown role `{other}`; see plugin-api.md §Plugin return shape"),
159 )),
160 }
161}
162
163fn parse_hex_color(hex: &str, id: &str) -> Result<Color, PluginError> {
164 let body = hex
165 .strip_prefix('#')
166 .ok_or_else(|| malformed(id, &format!("hex color must start with `#`, got `{hex}`")))?;
167 if body.len() != 6 {
168 return Err(malformed(
169 id,
170 &format!("hex color must be `#rrggbb` (6 hex digits), got `{hex}`"),
171 ));
172 }
173 let r = u8::from_str_radix(&body[0..2], 16)
174 .map_err(|_| malformed(id, &format!("invalid hex color `{hex}`")))?;
175 let g = u8::from_str_radix(&body[2..4], 16)
176 .map_err(|_| malformed(id, &format!("invalid hex color `{hex}`")))?;
177 let b = u8::from_str_radix(&body[4..6], 16)
178 .map_err(|_| malformed(id, &format!("invalid hex color `{hex}`")))?;
179 Ok(Color::TrueColor { r, g, b })
180}
181
182fn parse_right_separator(sep: Dynamic, id: &str) -> Result<Separator, PluginError> {
183 let s = sep
184 .try_cast::<String>()
185 .ok_or_else(|| malformed(id, "`right_separator` must be a string"))?;
186 Ok(match s.as_str() {
187 "space" => Separator::Space,
188 "theme" => Separator::Theme,
189 "none" => Separator::None,
190 _ => Separator::Literal(sanitize_control_chars(s).into()),
196 })
197}
198
199fn malformed(id: &str, message: &str) -> PluginError {
200 PluginError::MalformedReturn {
201 id: id.to_string(),
202 message: message.to_string(),
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use crate::plugins::build_engine;
210 use rhai::Engine;
211
212 fn eval_and_validate(script: &str, id: &str) -> Result<Option<RenderedSegment>, PluginError> {
213 let engine: std::sync::Arc<Engine> = build_engine();
214 let value: Dynamic = engine.eval(script).expect("rhai eval ok");
215 validate_return(value, id)
216 }
217
218 #[test]
219 fn unit_return_hides_segment() {
220 assert_eq!(eval_and_validate("()", "t"), Ok(None));
221 }
222
223 #[test]
224 fn single_run_text_only() {
225 let rendered = eval_and_validate(r#"#{ runs: [#{ text: "hello" }] }"#, "t")
226 .unwrap()
227 .expect("rendered");
228 assert_eq!(rendered.text(), "hello");
229 assert_eq!(rendered.style(), &Style::default());
230 }
231
232 #[test]
233 fn single_run_with_role() {
234 let rendered = eval_and_validate(r#"#{ runs: [#{ text: "ok", role: "success" }] }"#, "t")
235 .unwrap()
236 .expect("rendered");
237 assert_eq!(rendered.style().role, Some(Role::Success));
238 }
239
240 #[test]
241 fn single_run_with_decorations() {
242 let rendered = eval_and_validate(
243 r#"#{ runs: [#{ text: "x", bold: true, italic: true, underline: true, dim: true }] }"#,
244 "t",
245 )
246 .unwrap()
247 .expect("rendered");
248 let s = rendered.style();
249 assert!(s.bold);
250 assert!(s.italic);
251 assert!(s.underline);
252 assert!(s.dim);
253 }
254
255 #[test]
256 fn single_run_with_hex_fg() {
257 let rendered = eval_and_validate(r##"#{ runs: [#{ text: "x", fg: "#ff8040" }] }"##, "t")
258 .unwrap()
259 .expect("rendered");
260 assert_eq!(
261 rendered.style().fg,
262 Some(Color::TrueColor {
263 r: 0xff,
264 g: 0x80,
265 b: 0x40
266 })
267 );
268 }
269
270 #[test]
271 fn right_separator_space() {
272 let rendered = eval_and_validate(
273 r#"#{ runs: [#{ text: "x" }], right_separator: "space" }"#,
274 "t",
275 )
276 .unwrap()
277 .expect("rendered");
278 assert_eq!(rendered.right_separator(), Some(&Separator::Space));
279 }
280
281 #[test]
282 fn right_separator_literal_preserves_user_string() {
283 let rendered = eval_and_validate(
284 r#"#{ runs: [#{ text: "x" }], right_separator: " | " }"#,
285 "t",
286 )
287 .unwrap()
288 .expect("rendered");
289 match rendered.right_separator() {
290 Some(Separator::Literal(s)) => assert_eq!(s, " | "),
291 other => panic!("expected literal separator, got {other:?}"),
292 }
293 }
294
295 #[test]
296 fn right_separator_empty_string_is_literal() {
297 let rendered =
298 eval_and_validate(r#"#{ runs: [#{ text: "x" }], right_separator: "" }"#, "t")
299 .unwrap()
300 .expect("rendered");
301 match rendered.right_separator() {
302 Some(Separator::Literal(s)) => assert_eq!(s, ""),
303 other => panic!("expected literal separator, got {other:?}"),
304 }
305 }
306
307 #[test]
308 fn non_map_non_unit_return_rejected() {
309 let err = eval_and_validate(r#""just a string""#, "t").unwrap_err();
310 assert!(matches!(err, PluginError::MalformedReturn { .. }));
311 }
312
313 #[test]
314 fn missing_runs_key_rejected() {
315 let err = eval_and_validate(r#"#{ width: 5 }"#, "t").unwrap_err();
316 let PluginError::MalformedReturn { message, .. } = err else {
317 panic!("expected MalformedReturn");
318 };
319 assert!(message.contains("runs"), "message: {message}");
320 }
321
322 #[test]
323 fn empty_runs_rejected() {
324 let err = eval_and_validate(r#"#{ runs: [] }"#, "t").unwrap_err();
325 let PluginError::MalformedReturn { message, .. } = err else {
326 panic!("expected MalformedReturn");
327 };
328 assert!(message.contains("empty"));
329 }
330
331 #[test]
332 fn multi_run_rejected_with_deferred_note() {
333 let err =
334 eval_and_validate(r#"#{ runs: [#{ text: "a" }, #{ text: "b" }] }"#, "t").unwrap_err();
335 let PluginError::MalformedReturn { message, .. } = err else {
336 panic!("expected MalformedReturn");
337 };
338 assert!(
339 message.contains("one") || message.contains("multi-run"),
340 "message should flag the single-run restriction: {message}"
341 );
342 }
343
344 #[test]
345 fn run_without_text_rejected() {
346 let err = eval_and_validate(r#"#{ runs: [#{ role: "primary" }] }"#, "t").unwrap_err();
347 assert!(matches!(err, PluginError::MalformedReturn { .. }));
348 }
349
350 #[test]
351 fn run_text_wrong_type_rejected() {
352 let err = eval_and_validate(r#"#{ runs: [#{ text: 42 }] }"#, "t").unwrap_err();
353 let PluginError::MalformedReturn { message, .. } = err else {
354 panic!("expected MalformedReturn");
355 };
356 assert!(message.contains("text"));
357 }
358
359 #[test]
360 fn unknown_role_rejected() {
361 let err =
362 eval_and_validate(r#"#{ runs: [#{ text: "x", role: "mystery" }] }"#, "t").unwrap_err();
363 let PluginError::MalformedReturn { message, .. } = err else {
364 panic!("expected MalformedReturn");
365 };
366 assert!(message.contains("mystery"));
367 }
368
369 #[test]
370 fn hex_color_missing_hash_rejected() {
371 let err =
372 eval_and_validate(r#"#{ runs: [#{ text: "x", fg: "ff0000" }] }"#, "t").unwrap_err();
373 let PluginError::MalformedReturn { message, .. } = err else {
374 panic!("expected MalformedReturn");
375 };
376 assert!(message.contains("start with"), "message: {message}");
377 }
378
379 #[test]
380 fn hex_color_wrong_length_rejected() {
381 let err =
382 eval_and_validate(r##"#{ runs: [#{ text: "x", fg: "#abc" }] }"##, "t").unwrap_err();
383 let PluginError::MalformedReturn { message, .. } = err else {
384 panic!("expected MalformedReturn");
385 };
386 assert!(message.contains("6 hex digits"), "message: {message}");
387 }
388
389 #[test]
390 fn hex_color_alpha_form_rejected() {
391 let err = eval_and_validate(r##"#{ runs: [#{ text: "x", fg: "#ff804080" }] }"##, "t")
394 .unwrap_err();
395 let PluginError::MalformedReturn { message, .. } = err else {
396 panic!("expected MalformedReturn");
397 };
398 assert!(message.contains("6 hex digits"), "message: {message}");
399 }
400
401 #[test]
402 fn hex_color_empty_body_rejected() {
403 let err = eval_and_validate(r##"#{ runs: [#{ text: "x", fg: "#" }] }"##, "t").unwrap_err();
404 let PluginError::MalformedReturn { message, .. } = err else {
405 panic!("expected MalformedReturn");
406 };
407 assert!(message.contains("6 hex digits"), "message: {message}");
408 }
409
410 #[test]
411 fn hex_color_uppercase_accepted() {
412 let rendered = eval_and_validate(r##"#{ runs: [#{ text: "x", fg: "#FF8040" }] }"##, "t")
413 .unwrap()
414 .expect("rendered");
415 assert_eq!(
416 rendered.style().fg,
417 Some(Color::TrueColor {
418 r: 0xff,
419 g: 0x80,
420 b: 0x40
421 })
422 );
423 }
424
425 #[test]
426 fn hex_color_non_hex_digits_rejected() {
427 let err =
428 eval_and_validate(r##"#{ runs: [#{ text: "x", fg: "#zzzzzz" }] }"##, "t").unwrap_err();
429 let PluginError::MalformedReturn { message, .. } = err else {
430 panic!("expected MalformedReturn");
431 };
432 assert!(message.contains("invalid hex"), "message: {message}");
433 }
434
435 #[test]
436 fn decoration_wrong_type_rejected() {
437 let err =
438 eval_and_validate(r#"#{ runs: [#{ text: "x", bold: "yes" }] }"#, "t").unwrap_err();
439 let PluginError::MalformedReturn { message, .. } = err else {
440 panic!("expected MalformedReturn");
441 };
442 assert!(message.contains("bold"));
443 }
444
445 #[test]
446 fn all_16_roles_parse_via_snake_case_token() {
447 let cases: &[(&str, Role)] = &[
450 ("foreground", Role::Foreground),
451 ("background", Role::Background),
452 ("muted", Role::Muted),
453 ("primary", Role::Primary),
454 ("accent", Role::Accent),
455 ("success", Role::Success),
456 ("warning", Role::Warning),
457 ("error", Role::Error),
458 ("info", Role::Info),
459 ("success_dim", Role::SuccessDim),
460 ("warning_dim", Role::WarningDim),
461 ("error_dim", Role::ErrorDim),
462 ("primary_dim", Role::PrimaryDim),
463 ("accent_dim", Role::AccentDim),
464 ("surface", Role::Surface),
465 ("border", Role::Border),
466 ];
467 for (token, expected) in cases {
468 let script = format!(r#"#{{ runs: [#{{ text: "x", role: "{token}" }}] }}"#);
469 let rendered = eval_and_validate(&script, "t")
470 .unwrap_or_else(|e| panic!("role `{token}` failed: {e}"))
471 .expect("rendered");
472 assert_eq!(
473 rendered.style().role,
474 Some(*expected),
475 "role token `{token}` should parse to {expected:?}"
476 );
477 }
478 }
479
480 #[test]
481 fn all_separator_strings_map_correctly() {
482 let cases: &[(&str, Separator)] = &[
483 ("space", Separator::Space),
484 ("theme", Separator::Theme),
485 ("none", Separator::None),
486 ];
487 for (token, expected) in cases {
488 let script = format!(r#"#{{ runs: [#{{ text: "x" }}], right_separator: "{token}" }}"#);
489 let rendered = eval_and_validate(&script, "t")
490 .unwrap_or_else(|e| panic!("separator `{token}` failed: {e}"))
491 .expect("rendered");
492 assert_eq!(rendered.right_separator(), Some(expected));
493 }
494 }
495
496 #[test]
497 fn control_chars_in_plugin_text_are_stripped() {
498 let rendered = eval_and_validate(r#"#{ runs: [#{ text: "evil\u001B[2Jafter" }] }"#, "t")
499 .unwrap()
500 .expect("rendered");
501 assert!(!rendered.text().contains('\x1b'), "ESC must be stripped");
502 assert!(rendered.text().contains("evil"));
503 assert!(rendered.text().contains("after"));
504 }
505
506 #[test]
507 fn control_chars_in_plugin_separator_are_stripped() {
508 let rendered = eval_and_validate(
511 r#"#{ runs: [#{ text: "x" }], right_separator: "\u001B[2J|" }"#,
512 "t",
513 )
514 .unwrap()
515 .expect("rendered");
516 match rendered.right_separator() {
517 Some(Separator::Literal(s)) => {
518 assert!(!s.contains('\x1b'), "ESC must be stripped, got {s:?}");
519 assert!(s.contains('|'), "surviving printable bytes kept: {s:?}");
520 }
521 other => panic!("expected literal separator, got {other:?}"),
522 }
523 }
524
525 #[test]
526 fn runs_field_non_array_rejected() {
527 let err = eval_and_validate(r#"#{ runs: "not an array" }"#, "t").unwrap_err();
528 let PluginError::MalformedReturn { message, .. } = err else {
529 panic!("expected MalformedReturn");
530 };
531 assert!(message.contains("runs"), "message: {message}");
532 assert!(message.to_lowercase().contains("array"));
533 }
534
535 #[test]
536 fn runs_element_non_map_rejected() {
537 let err = eval_and_validate(r#"#{ runs: [42] }"#, "t").unwrap_err();
538 let PluginError::MalformedReturn { message, .. } = err else {
539 panic!("expected MalformedReturn");
540 };
541 assert!(message.to_lowercase().contains("map"));
542 }
543
544 #[test]
545 fn right_separator_non_string_rejected() {
546 let err = eval_and_validate(r#"#{ runs: [#{ text: "x" }], right_separator: 42 }"#, "t")
547 .unwrap_err();
548 let PluginError::MalformedReturn { message, .. } = err else {
549 panic!("expected MalformedReturn");
550 };
551 assert!(message.contains("right_separator") || message.contains("string"));
552 }
553
554 #[test]
555 fn unsupported_bg_field_silently_ignored() {
556 let rendered = eval_and_validate(r##"#{ runs: [#{ text: "x", bg: "#000000" }] }"##, "t")
557 .unwrap()
558 .expect("rendered");
559 assert_eq!(rendered.text(), "x");
560 }
561
562 #[test]
563 fn hyperlink_field_threads_to_style() {
564 let rendered = eval_and_validate(
565 r#"#{ runs: [#{ text: "x", hyperlink: "https://example.com" }] }"#,
566 "t",
567 )
568 .unwrap()
569 .expect("rendered");
570 assert_eq!(rendered.text(), "x");
571 assert_eq!(
572 rendered.style().hyperlink.as_deref(),
573 Some("https://example.com")
574 );
575 }
576
577 #[test]
578 fn empty_hyperlink_string_does_not_set_link() {
579 let rendered = eval_and_validate(r#"#{ runs: [#{ text: "x", hyperlink: "" }] }"#, "t")
585 .unwrap()
586 .expect("rendered");
587 assert_eq!(rendered.style().hyperlink, None);
588 }
589
590 #[test]
591 fn non_string_hyperlink_rejected() {
592 let err =
593 eval_and_validate(r#"#{ runs: [#{ text: "x", hyperlink: 42 }] }"#, "t").unwrap_err();
594 let msg = format!("{err}");
595 assert!(msg.contains("hyperlink"), "got: {msg}");
596 }
597}