Skip to main content

vergen_pretty/pretty/feature/
rkyv_support.rs

1// Copyright (c) 2022 vergen developers
2//
3// Licensed under the Apache License, Version 2.0
4// <LICENSE-APACHE or https://www.apache.org/licenses/LICENSE-2.0> or the MIT
5// license <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your
6// option. All files in the project carrying such notice may not be copied,
7// modified, or distributed except according to those terms.
8
9// rkyv ArchiveWith wrappers for console::Style and tracing::Level
10
11#[cfg(feature = "color")]
12use console::Style;
13use rkyv::{
14    Place, SerializeUnsized,
15    rancor::{Fallible, Source},
16    string::{ArchivedString, StringResolver},
17    with::{ArchiveWith, DeserializeWith, SerializeWith},
18};
19#[cfg(feature = "trace")]
20use tracing::Level;
21
22#[cfg(feature = "color")]
23/// rkyv [`ArchiveWith`] wrapper for [`console::Style`].
24///
25/// Serializes a [`Style`] as its dotted attribute string (e.g.
26/// `"bold.red.on_blue"`), reconstructable via [`Style::from_dotted_str`].
27///
28/// The conversion works by forcing ANSI escape code emission on a clone of
29/// the style, extracting the codes from the output, and mapping them back to
30/// their named dotted-format equivalents.  The roundtrip is functionally
31/// lossless: the deserialized style produces identical terminal output, though
32/// the internal representation may differ for edge cases such as 256-color
33/// indices 8–15 which overlap with bright basic colors.
34///
35/// Use with `#[rkyv(with = rkyv::with::Map<StyleWith>)]` on `Option<Style>`
36/// fields.
37#[cfg(feature = "color")]
38#[derive(Clone, Copy, Debug)]
39pub struct StyleWith;
40
41#[cfg(feature = "color")]
42/// Convert a [`Style`] to its dotted attribute string representation.
43///
44/// Clones and forces styling so that ANSI escape sequences are always emitted
45/// regardless of the current terminal, then parses those sequences back into
46/// the `"bold.red.on_blue"` format understood by [`Style::from_dotted_str`].
47fn style_to_dotted(style: &Style) -> String {
48    #[allow(clippy::items_after_statements)]
49    let raw = style.clone().force_styling(true).apply_to("").to_string();
50    if raw.is_empty() {
51        return String::new();
52    }
53    let mut parts: Vec<String> = Vec::new();
54    let mut chars = raw.chars();
55    while let Some(ch) = chars.next() {
56        if ch != '\x1b' {
57            continue;
58        }
59        if chars.next() != Some('[') {
60            continue;
61        }
62        let mut code = String::new();
63        for c in chars.by_ref() {
64            if c == 'm' {
65                break;
66            }
67            code.push(c);
68        }
69        // "0" is the reset marker that terminates the style prefix.
70        if code == "0" {
71            break;
72        }
73        push_dotted_parts(&code, &mut parts);
74    }
75    parts.join(".")
76}
77
78#[cfg(feature = "color")]
79/// Parse one ANSI SGR parameter string (e.g. `"31"`, `"38;5;196"`,
80/// `"48;2;255;0;128"`) and push the corresponding dotted format part(s).
81fn push_dotted_parts(code: &str, parts: &mut Vec<String>) {
82    #[allow(clippy::items_after_statements)]
83    let segs: Vec<&str> = code.split(';').collect();
84    match segs.as_slice() {
85        // Single numeric code
86        [n_str] => {
87            let Ok(n) = n_str.parse::<u8>() else { return };
88            match n {
89                // Attributes: Bold=1 … StrikeThrough=9
90                1..=9 => {
91                    const ATTRS: [&str; 9] = [
92                        "bold",
93                        "dim",
94                        "italic",
95                        "underlined",
96                        "blink",
97                        "blink_fast",
98                        "reverse",
99                        "hidden",
100                        "strikethrough",
101                    ];
102                    parts.push(ATTRS[(n - 1) as usize].to_string());
103                }
104                // Basic foreground colors: 30 (Black) … 37 (White)
105                30..=37 => {
106                    const FG: [&str; 8] = [
107                        "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
108                    ];
109                    parts.push(FG[(n - 30) as usize].to_string());
110                }
111                // Basic background colors: 40 (Black) … 47 (White)
112                40..=47 => {
113                    const BG: [&str; 8] = [
114                        "on_black",
115                        "on_red",
116                        "on_green",
117                        "on_yellow",
118                        "on_blue",
119                        "on_magenta",
120                        "on_cyan",
121                        "on_white",
122                    ];
123                    parts.push(BG[(n - 40) as usize].to_string());
124                }
125                _ => {}
126            }
127        }
128        // Foreground 256-color or bright-basic: ESC[38;5;Nm
129        // Bright-basic (8–15) will be stored as their 256-color index, which
130        // round-trips to the same visual output via `from_dotted_str`.
131        ["38", "5", n_str] => {
132            if let Ok(n) = n_str.parse::<u8>() {
133                parts.push(n.to_string());
134            }
135        }
136        // Foreground true-color: ESC[38;2;R;G;Bm  →  "#RRGGBB"
137        ["38", "2", r_str, g_str, b_str] => {
138            if let (Ok(r), Ok(g), Ok(b)) = (
139                r_str.parse::<u8>(),
140                g_str.parse::<u8>(),
141                b_str.parse::<u8>(),
142            ) {
143                parts.push(format!("#{r:02X}{g:02X}{b:02X}"));
144            }
145        }
146        // Background 256-color or bright-basic: ESC[48;5;Nm  →  "on_N"
147        ["48", "5", n_str] => {
148            if let Ok(n) = n_str.parse::<u8>() {
149                parts.push(format!("on_{n}"));
150            }
151        }
152        // Background true-color: ESC[48;2;R;G;Bm  →  "on_#RRGGBB"
153        ["48", "2", r_str, g_str, b_str] => {
154            if let (Ok(r), Ok(g), Ok(b)) = (
155                r_str.parse::<u8>(),
156                g_str.parse::<u8>(),
157                b_str.parse::<u8>(),
158            ) {
159                parts.push(format!("on_#{r:02X}{g:02X}{b:02X}"));
160            }
161        }
162        _ => {}
163    }
164}
165
166#[cfg(feature = "color")]
167impl ArchiveWith<Style> for StyleWith {
168    type Archived = ArchivedString;
169    type Resolver = StringResolver;
170
171    fn resolve_with(field: &Style, resolver: Self::Resolver, out: Place<Self::Archived>) {
172        ArchivedString::resolve_from_str(&style_to_dotted(field), resolver, out);
173    }
174}
175
176#[cfg(feature = "color")]
177impl<S> SerializeWith<Style, S> for StyleWith
178where
179    S: Fallible + ?Sized,
180    S::Error: Source,
181    str: SerializeUnsized<S>,
182{
183    fn serialize_with(field: &Style, serializer: &mut S) -> Result<Self::Resolver, S::Error> {
184        ArchivedString::serialize_from_str(&style_to_dotted(field), serializer)
185    }
186}
187
188#[cfg(feature = "color")]
189impl<D> DeserializeWith<ArchivedString, Style, D> for StyleWith
190where
191    D: Fallible + ?Sized,
192{
193    fn deserialize_with(field: &ArchivedString, _: &mut D) -> Result<Style, D::Error> {
194        Ok(Style::from_dotted_str(field.as_str()))
195    }
196}
197
198// ── LevelWith ────────────────────────────────────────────────────────────────
199
200/// rkyv [`ArchiveWith`] wrapper for [`tracing::Level`].
201///
202/// Serializes a [`Level`] as its uppercase name string (`"TRACE"`, `"DEBUG"`,
203/// `"INFO"`, `"WARN"`, `"ERROR"`), reconstructable via a simple match.
204///
205/// Use with `#[rkyv(with = LevelWith)]` on `Level` fields.
206#[cfg(feature = "trace")]
207#[derive(Clone, Copy, Debug)]
208pub(crate) struct LevelWith;
209
210#[cfg(feature = "trace")]
211impl ArchiveWith<Level> for LevelWith {
212    type Archived = ArchivedString;
213    type Resolver = StringResolver;
214
215    fn resolve_with(field: &Level, resolver: Self::Resolver, out: Place<Self::Archived>) {
216        ArchivedString::resolve_from_str(field.as_str(), resolver, out);
217    }
218}
219
220#[cfg(feature = "trace")]
221impl<S> SerializeWith<Level, S> for LevelWith
222where
223    S: Fallible + ?Sized,
224    S::Error: Source,
225    str: SerializeUnsized<S>,
226{
227    fn serialize_with(field: &Level, serializer: &mut S) -> Result<Self::Resolver, S::Error> {
228        ArchivedString::serialize_from_str(field.as_str(), serializer)
229    }
230}
231
232#[cfg(feature = "trace")]
233impl<D: Fallible + ?Sized> DeserializeWith<ArchivedString, Level, D> for LevelWith {
234    fn deserialize_with(field: &ArchivedString, _: &mut D) -> Result<Level, D::Error> {
235        Ok(match field.as_str() {
236            "TRACE" => Level::TRACE,
237            "DEBUG" => Level::DEBUG,
238            "WARN" => Level::WARN,
239            "ERROR" => Level::ERROR,
240            _ => Level::INFO,
241        })
242    }
243}
244
245// ── Tests ─────────────────────────────────────────────────────────────────────
246
247#[cfg(test)]
248mod tests {
249    #[cfg(feature = "color")]
250    use super::style_to_dotted;
251    #[cfg(feature = "color")]
252    use console::Style;
253
254    #[cfg(feature = "color")]
255    #[test]
256    fn empty_style_round_trips() {
257        let s = Style::new();
258        assert_eq!(style_to_dotted(&s), "");
259        let _ = Style::from_dotted_str(&style_to_dotted(&s));
260    }
261
262    #[cfg(feature = "color")]
263    #[test]
264    fn basic_fg_color_round_trips() {
265        for (style, expected) in [
266            (Style::new().black(), "black"),
267            (Style::new().red(), "red"),
268            (Style::new().green(), "green"),
269            (Style::new().yellow(), "yellow"),
270            (Style::new().blue(), "blue"),
271            (Style::new().magenta(), "magenta"),
272            (Style::new().cyan(), "cyan"),
273            (Style::new().white(), "white"),
274        ] {
275            assert_eq!(style_to_dotted(&style), expected);
276        }
277    }
278
279    #[cfg(feature = "color")]
280    #[test]
281    fn basic_bg_color_round_trips() {
282        assert_eq!(style_to_dotted(&Style::new().on_red()), "on_red");
283        assert_eq!(style_to_dotted(&Style::new().on_blue()), "on_blue");
284    }
285
286    #[cfg(feature = "color")]
287    #[test]
288    fn attrs_round_trips() {
289        assert_eq!(style_to_dotted(&Style::new().bold()), "bold");
290        assert_eq!(style_to_dotted(&Style::new().underlined()), "underlined");
291        assert_eq!(style_to_dotted(&Style::new().italic()), "italic");
292        assert_eq!(
293            style_to_dotted(&Style::new().strikethrough()),
294            "strikethrough"
295        );
296    }
297
298    #[cfg(feature = "color")]
299    #[test]
300    fn compound_style_round_trips() {
301        // bold + green; serialized as "green.bold" (fg first, attrs second)
302        let s = Style::new().bold().green();
303        let dotted = style_to_dotted(&s);
304        assert!(dotted.contains("green"));
305        assert!(dotted.contains("bold"));
306        let restored = Style::from_dotted_str(&dotted);
307        // both styles should produce the same ANSI output
308        assert_eq!(
309            s.force_styling(true).apply_to("x").to_string(),
310            restored.force_styling(true).apply_to("x").to_string(),
311        );
312    }
313
314    #[cfg(feature = "color")]
315    #[test]
316    fn true_color_round_trips() {
317        let s = Style::new().true_color(0xFF, 0x00, 0x80);
318        let dotted = style_to_dotted(&s);
319        assert_eq!(dotted, "#FF0080");
320        let restored = Style::from_dotted_str(&dotted);
321        assert_eq!(
322            s.force_styling(true).apply_to("x").to_string(),
323            restored.force_styling(true).apply_to("x").to_string(),
324        );
325    }
326
327    #[cfg(feature = "color")]
328    #[test]
329    fn color256_round_trips() {
330        let s = Style::new().color256(200);
331        let dotted = style_to_dotted(&s);
332        assert_eq!(dotted, "200");
333        let restored = Style::from_dotted_str(&dotted);
334        assert_eq!(
335            s.force_styling(true).apply_to("x").to_string(),
336            restored.force_styling(true).apply_to("x").to_string(),
337        );
338    }
339
340    #[cfg(feature = "trace")]
341    #[test]
342    fn level_as_str_round_trips() {
343        use tracing::Level;
344        for (level, expected) in [
345            (Level::TRACE, "TRACE"),
346            (Level::DEBUG, "DEBUG"),
347            (Level::INFO, "INFO"),
348            (Level::WARN, "WARN"),
349            (Level::ERROR, "ERROR"),
350        ] {
351            assert_eq!(level.as_str(), expected);
352        }
353    }
354
355    #[cfg(feature = "trace")]
356    #[test]
357    fn level_default_fallback() {
358        use super::LevelWith;
359        use tracing::Level;
360        let levels = [
361            Level::TRACE,
362            Level::DEBUG,
363            Level::INFO,
364            Level::WARN,
365            Level::ERROR,
366        ];
367        for level in levels {
368            assert_eq!(level.as_str().parse::<Level>().unwrap(), level);
369        }
370        let _ = LevelWith;
371    }
372
373    // ── Additional branch-coverage tests ────────────────────────────────────
374
375    #[cfg(feature = "color")]
376    #[test]
377    fn all_bg_colors_round_trips() {
378        for (style, expected) in [
379            (Style::new().on_black(), "on_black"),
380            (Style::new().on_green(), "on_green"),
381            (Style::new().on_yellow(), "on_yellow"),
382            (Style::new().on_magenta(), "on_magenta"),
383            (Style::new().on_cyan(), "on_cyan"),
384            (Style::new().on_white(), "on_white"),
385        ] {
386            assert_eq!(style_to_dotted(&style), expected);
387        }
388    }
389
390    #[cfg(feature = "color")]
391    #[test]
392    fn remaining_attrs_round_trips() {
393        for (style, expected) in [
394            (Style::new().dim(), "dim"),
395            (Style::new().blink(), "blink"),
396            (Style::new().blink_fast(), "blink_fast"),
397            (Style::new().reverse(), "reverse"),
398            (Style::new().hidden(), "hidden"),
399        ] {
400            assert_eq!(style_to_dotted(&style), expected);
401        }
402    }
403
404    #[cfg(feature = "color")]
405    #[test]
406    fn bg_color256_round_trips() {
407        let s = Style::new().on_color256(196);
408        let dotted = style_to_dotted(&s);
409        assert_eq!(dotted, "on_196");
410        let restored = Style::from_dotted_str(&dotted);
411        assert_eq!(
412            s.force_styling(true).apply_to("x").to_string(),
413            restored.force_styling(true).apply_to("x").to_string(),
414        );
415    }
416
417    #[cfg(feature = "color")]
418    #[test]
419    fn bg_true_color_round_trips() {
420        let s = Style::new().on_true_color(0x12, 0x34, 0x56);
421        let dotted = style_to_dotted(&s);
422        assert_eq!(dotted, "on_#123456");
423        let restored = Style::from_dotted_str(&dotted);
424        assert_eq!(
425            s.force_styling(true).apply_to("x").to_string(),
426            restored.force_styling(true).apply_to("x").to_string(),
427        );
428    }
429
430    /// Directly exercises the `_ => {}` arms in [`push_dotted_parts`] that
431    /// silently discard unrecognised / out-of-range SGR codes.
432    #[cfg(feature = "color")]
433    #[test]
434    fn push_dotted_parts_unrecognised_codes_are_ignored() {
435        use super::push_dotted_parts;
436
437        let mut parts: Vec<String> = Vec::new();
438
439        // Single numeric code outside the handled ranges (1-9, 30-37, 40-47)
440        // → hits the inner `_ => {}` in `match n`
441        push_dotted_parts("10", &mut parts); // between attrs and fg basic
442        push_dotted_parts("28", &mut parts); // between attrs and fg basic
443        push_dotted_parts("50", &mut parts); // between bg basic and 256
444        assert!(parts.is_empty(), "unexpected parts: {parts:?}");
445
446        // Multi-segment slices that don't match any known pattern
447        // → hit the outer `_ => {}` arm
448        push_dotted_parts("38;5", &mut parts); // incomplete fg-256 (missing N)
449        push_dotted_parts("99;99;99", &mut parts); // 3 segs, not 38;2 or 48;2
450        push_dotted_parts("38;5;196;extra", &mut parts); // 4 segs
451        assert!(parts.is_empty(), "unexpected parts: {parts:?}");
452    }
453
454    // ── End-to-end rkyv round-trip tests ─────────────────────────────────────
455
456    /// A minimal struct that exercises `StyleWith` through rkyv's derive
457    /// machinery: `resolve_with`, `serialize_with`, and `deserialize_with` are
458    /// all exercised by the serialize + deserialize cycle below.
459    #[cfg(feature = "color")]
460    #[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
461    struct StyleWrap {
462        #[rkyv(with = rkyv::with::Map<super::StyleWith>)]
463        style: Option<Style>,
464    }
465
466    #[cfg(feature = "color")]
467    #[test]
468    fn style_with_rkyv_round_trip_some() {
469        let original = StyleWrap {
470            style: Some(Style::new().bold().red()),
471        };
472        let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&original).unwrap();
473        let restored = rkyv::from_bytes::<StyleWrap, rkyv::rancor::Error>(&bytes).unwrap();
474        let orig_ansi = original
475            .style
476            .as_ref()
477            .unwrap()
478            .clone()
479            .force_styling(true)
480            .apply_to("x")
481            .to_string();
482        let rest_ansi = restored
483            .style
484            .as_ref()
485            .unwrap()
486            .clone()
487            .force_styling(true)
488            .apply_to("x")
489            .to_string();
490        assert_eq!(orig_ansi, rest_ansi);
491    }
492
493    #[cfg(feature = "color")]
494    #[test]
495    fn style_with_rkyv_round_trip_none() {
496        let original = StyleWrap { style: None };
497        let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&original).unwrap();
498        let restored = rkyv::from_bytes::<StyleWrap, rkyv::rancor::Error>(&bytes).unwrap();
499        assert!(restored.style.is_none());
500    }
501
502    /// A minimal struct that exercises `LevelWith` through rkyv's derive
503    /// machinery.
504    ///
505    /// `Level::INFO` serialises to `"INFO"` which hits the `_ => Level::INFO`
506    /// fallback arm in `deserialize_with`, so running all five levels gives
507    /// full branch coverage.
508    #[cfg(feature = "trace")]
509    #[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
510    struct LevelWrap {
511        #[rkyv(with = super::LevelWith)]
512        level: tracing::Level,
513    }
514
515    #[cfg(feature = "trace")]
516    #[test]
517    fn level_with_rkyv_round_trip() {
518        use tracing::Level;
519        for level in [
520            Level::TRACE,
521            Level::DEBUG,
522            Level::INFO, // falls to `_ => Level::INFO` in deserialize_with
523            Level::WARN,
524            Level::ERROR,
525        ] {
526            let original = LevelWrap { level };
527            let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&original).unwrap();
528            let restored = rkyv::from_bytes::<LevelWrap, rkyv::rancor::Error>(&bytes).unwrap();
529            assert_eq!(restored.level, level);
530        }
531    }
532
533    // ── Derived-trait coverage ────────────────────────────────────────────────
534
535    #[cfg(feature = "color")]
536    #[test]
537    fn style_with_clone_and_debug() {
538        use super::StyleWith;
539        let sw = StyleWith;
540        let cloned = sw;
541        let _unused = format!("{cloned:?}");
542    }
543
544    #[cfg(feature = "trace")]
545    #[test]
546    fn level_with_clone_and_debug() {
547        use super::LevelWith;
548        let lw = LevelWith;
549        let cloned = lw;
550        let _unused = format!("{cloned:?}");
551    }
552}