Skip to main content

youplot/
lib.rs

1//! Domain library for the `uplot` CLI: DSV parsing, data format dispatch,
2//! and command routing into [`unicode_plot`] constructors.
3//!
4//! This crate bridges raw delimiter-separated input and the `unicode-plot`
5//! rendering library. It handles CSV/TSV parsing via [`parse_dsv`], data format
6//! interpretation via [`dispatch()`], and value counting via the [`Command::Count`]
7//! path.
8
9mod colors;
10mod dispatch;
11mod dsv;
12mod types;
13
14pub use dispatch::{DispatchError, DispatchOutput, ProcessingError, dispatch};
15pub use dsv::{Data, DsvError, DsvOptions, parse_dsv, parse_dsv_reader};
16pub use types::{
17    Command, CountedValues, Format, MultiFormat, MultiSeriesData, Options, Parameters, SeriesXY,
18    SingleFormat, SingleSeriesData,
19};
20
21#[cfg(test)]
22pub(crate) use colors::ColorsRenderable;
23#[cfg(test)]
24pub(crate) use dispatch::{
25    count_values, dispatch_multi_series, dispatch_single_series, header_at,
26    infer_multi_series_xlabel, parse_numeric_series_lenient,
27};
28#[cfg(test)]
29pub(crate) use dsv::transpose_jagged;
30
31#[cfg(test)]
32mod tests {
33    use std::io;
34
35    use super::{
36        ColorsRenderable, Command, Data, DispatchError, DsvOptions, Format, MultiFormat,
37        MultiSeriesData, Options, Parameters, SeriesXY, SingleFormat, SingleSeriesData,
38        count_values, dispatch, dispatch_multi_series, dispatch_single_series, header_at,
39        infer_multi_series_xlabel, parse_dsv, parse_dsv_reader, parse_numeric_series_lenient,
40        transpose_jagged,
41    };
42    use unicode_plot::ClosedInterval;
43    use unicode_plot::border::BorderType;
44    use unicode_plot::canvas::{CanvasType, Scale};
45    use unicode_plot::color::{ColorMode, NamedColor, TermColor};
46
47    fn parse_with(input: &str, headers: bool, transpose: bool) -> Data {
48        parse_dsv(
49            input,
50            DsvOptions {
51                delimiter: b',',
52                headers,
53                transpose,
54            },
55        )
56        .expect("parsing test fixture input should succeed")
57    }
58
59    fn some_rows(rows: &[&[&str]]) -> Vec<Vec<Option<String>>> {
60        rows.iter()
61            .map(|row| row.iter().map(|value| Some((*value).to_owned())).collect())
62            .collect()
63    }
64
65    #[test]
66    fn transpose_jagged_square() {
67        let rows = some_rows(&[&["a", "b", "c"], &["d", "e", "f"], &["g", "h", "i"]]);
68        let transposed = transpose_jagged(&rows);
69        let expected = some_rows(&[&["a", "d", "g"], &["b", "e", "h"], &["c", "f", "i"]]);
70        assert_eq!(transposed, expected);
71    }
72
73    #[test]
74    fn transpose_jagged_decreasing_widths() {
75        let rows = vec![
76            vec![
77                Some(String::from("a")),
78                Some(String::from("b")),
79                Some(String::from("c")),
80            ],
81            vec![Some(String::from("d")), Some(String::from("e"))],
82            vec![Some(String::from("f"))],
83        ];
84        let transposed = transpose_jagged(&rows);
85        assert_eq!(
86            transposed,
87            vec![
88                vec![
89                    Some(String::from("a")),
90                    Some(String::from("d")),
91                    Some(String::from("f"))
92                ],
93                vec![Some(String::from("b")), Some(String::from("e")), None],
94                vec![Some(String::from("c")), None, None],
95            ]
96        );
97    }
98
99    #[test]
100    fn transpose_jagged_increasing_widths() {
101        let rows = vec![
102            vec![Some(String::from("a"))],
103            vec![Some(String::from("b")), Some(String::from("c"))],
104            vec![
105                Some(String::from("d")),
106                Some(String::from("e")),
107                Some(String::from("f")),
108            ],
109        ];
110        let transposed = transpose_jagged(&rows);
111        assert_eq!(
112            transposed,
113            vec![
114                vec![
115                    Some(String::from("a")),
116                    Some(String::from("b")),
117                    Some(String::from("d"))
118                ],
119                vec![None, Some(String::from("c")), Some(String::from("e"))],
120                vec![None, None, Some(String::from("f"))],
121            ]
122        );
123    }
124
125    #[test]
126    fn headers_matrix_matches_expected_cases() {
127        let dataset = "h1,h2,h3\na,b,c\nd,e\nf\n";
128        let cases = [
129            (dataset, false, false, None),
130            (dataset, false, true, None),
131            (
132                dataset,
133                true,
134                false,
135                Some(vec![
136                    Some(String::from("h1")),
137                    Some(String::from("h2")),
138                    Some(String::from("h3")),
139                ]),
140            ),
141            (
142                dataset,
143                true,
144                true,
145                Some(vec![
146                    Some(String::from("h1")),
147                    Some(String::from("a")),
148                    Some(String::from("d")),
149                    Some(String::from("f")),
150                ]),
151            ),
152            ("", false, false, None),
153            ("", false, true, None),
154            ("", true, false, Some(vec![])),
155            ("", true, true, Some(vec![])),
156            (
157                "colA,colB\n1,2\n",
158                true,
159                false,
160                Some(vec![Some(String::from("colA")), Some(String::from("colB"))]),
161            ),
162        ];
163
164        for (input, headers, transpose, expected_headers) in cases {
165            let parsed = parse_with(input, headers, transpose);
166            assert_eq!(
167                parsed.headers, expected_headers,
168                "headers mismatch for input={input:?}, headers={headers}, transpose={transpose}",
169            );
170        }
171    }
172
173    #[test]
174    fn series_matrix_main_cases() {
175        let dataset = "h1,h2,h3\na,b,c\nd,e\nf\n";
176        let cases = [
177            (
178                false,
179                false,
180                vec![
181                    vec![
182                        Some(String::from("h1")),
183                        Some(String::from("a")),
184                        Some(String::from("d")),
185                        Some(String::from("f")),
186                    ],
187                    vec![
188                        Some(String::from("h2")),
189                        Some(String::from("b")),
190                        Some(String::from("e")),
191                        None,
192                    ],
193                    vec![
194                        Some(String::from("h3")),
195                        Some(String::from("c")),
196                        None,
197                        None,
198                    ],
199                ],
200            ),
201            (
202                false,
203                true,
204                vec![
205                    vec![
206                        Some(String::from("h1")),
207                        Some(String::from("h2")),
208                        Some(String::from("h3")),
209                    ],
210                    vec![
211                        Some(String::from("a")),
212                        Some(String::from("b")),
213                        Some(String::from("c")),
214                    ],
215                    vec![Some(String::from("d")), Some(String::from("e"))],
216                    vec![Some(String::from("f"))],
217                ],
218            ),
219            (
220                true,
221                false,
222                vec![
223                    vec![
224                        Some(String::from("a")),
225                        Some(String::from("d")),
226                        Some(String::from("f")),
227                    ],
228                    vec![Some(String::from("b")), Some(String::from("e")), None],
229                    vec![Some(String::from("c")), None, None],
230                ],
231            ),
232            (
233                true,
234                true,
235                vec![
236                    vec![Some(String::from("h2")), Some(String::from("h3"))],
237                    vec![Some(String::from("b")), Some(String::from("c"))],
238                    vec![Some(String::from("e"))],
239                    vec![],
240                ],
241            ),
242        ];
243
244        for (headers, transpose, expected_series) in cases {
245            let parsed = parse_with(dataset, headers, transpose);
246            assert_eq!(
247                parsed.series, expected_series,
248                "series mismatch for headers={headers}, transpose={transpose}",
249            );
250        }
251    }
252
253    #[test]
254    fn series_matrix_empty_input_cases() {
255        for (headers, transpose) in [(false, false), (false, true), (true, false), (true, true)] {
256            let parsed = parse_with("", headers, transpose);
257            assert_eq!(parsed.series, Vec::<Vec<Option<String>>>::new());
258        }
259    }
260
261    #[test]
262    fn series_matrix_jagged_cases() {
263        let parsed = parse_with("1,2\n3\n", false, false);
264        assert_eq!(
265            parsed.series,
266            vec![
267                vec![Some(String::from("1")), Some(String::from("3"))],
268                vec![Some(String::from("2")), None],
269            ]
270        );
271
272        let parsed = parse_with("1,2\n3\n", true, false);
273        assert_eq!(parsed.series, vec![vec![Some(String::from("3"))]]);
274    }
275
276    #[test]
277    fn series_matrix_transpose_header_cases() {
278        let parsed = parse_with("a,b\nc,d\n", true, true);
279        assert_eq!(
280            parsed.series,
281            vec![vec![Some(String::from("b"))], vec![Some(String::from("d"))]],
282        );
283
284        let parsed = parse_with("x\ny\nz\n", false, false);
285        assert_eq!(
286            parsed.series,
287            vec![vec![
288                Some(String::from("x")),
289                Some(String::from("y")),
290                Some(String::from("z")),
291            ]],
292        );
293    }
294
295    #[test]
296    fn parse_dsv_uses_tab_delimiter_by_default() {
297        let parsed = parse_dsv("a\tb\n1\t2\n", DsvOptions::default())
298            .expect("default-tab parsing should succeed");
299        assert_eq!(
300            parsed.series,
301            vec![
302                vec![Some(String::from("a")), Some(String::from("1"))],
303                vec![Some(String::from("b")), Some(String::from("2"))],
304            ],
305        );
306    }
307
308    #[test]
309    fn parse_dsv_respects_custom_delimiter() {
310        let parsed = parse_dsv(
311            "a;b\n1;2\n",
312            DsvOptions {
313                delimiter: b';',
314                ..DsvOptions::default()
315            },
316        )
317        .expect("custom-delimiter parsing should succeed");
318        assert_eq!(
319            parsed.series,
320            vec![
321                vec![Some(String::from("a")), Some(String::from("1"))],
322                vec![Some(String::from("b")), Some(String::from("2"))],
323            ],
324        );
325    }
326
327    #[test]
328    fn parse_dsv_reports_csv_errors() {
329        struct FailingReader;
330
331        impl io::Read for FailingReader {
332            fn read(&mut self, _buf: &mut [u8]) -> io::Result<usize> {
333                Err(io::Error::other("injected read error"))
334            }
335        }
336
337        let result = parse_dsv_reader(FailingReader, DsvOptions::default());
338        assert!(
339            result.is_err(),
340            "reader failures should surface as parser errors"
341        );
342    }
343
344    fn some_option_strings(values: &[&str]) -> Vec<Option<String>> {
345        values
346            .iter()
347            .map(|value| Some((*value).to_owned()))
348            .collect()
349    }
350
351    fn render_plain(command: Command, data: &Data, params: &Parameters) -> String {
352        let output = dispatch(command, data, params).expect("dispatch should succeed");
353        let mut buffer = Vec::new();
354        output
355            .render_with_mode(&mut buffer, ColorMode::Never, false)
356            .expect("rendering should succeed");
357        String::from_utf8(buffer).expect("rendered output should be valid UTF-8")
358    }
359
360    #[test]
361    fn count_values_sorts_descending_then_alphabetical() {
362        let values = some_option_strings(&["a", "a", "a", "b", "b", "c"]);
363        let counted = count_values(&values, false);
364        assert_eq!(counted.labels, vec!["a", "b", "c"]);
365        assert_eq!(counted.counts, vec![3, 2, 1]);
366    }
367
368    #[test]
369    fn count_values_non_tally_equivalent_behavior() {
370        let values = some_option_strings(&["c", "a", "b", "a", "c", "c"]);
371        let counted = count_values(&values, false);
372        assert_eq!(counted.labels, vec!["c", "a", "b"]);
373        assert_eq!(counted.counts, vec![3, 2, 1]);
374    }
375
376    #[test]
377    fn count_values_reverse_reverses_sorted_order() {
378        let values = some_option_strings(&["a", "a", "a", "b", "b", "c"]);
379        let counted = count_values(&values, true);
380        assert_eq!(counted.labels, vec!["c", "b", "a"]);
381        assert_eq!(counted.counts, vec![1, 2, 3]);
382    }
383
384    #[test]
385    fn count_values_tie_breaks_alphabetically() {
386        let values = some_option_strings(&["b", "a", "c", "c", "b", "a"]);
387        let counted = count_values(&values, false);
388        assert_eq!(counted.labels, vec!["a", "b", "c"]);
389        assert_eq!(counted.counts, vec![2, 2, 2]);
390    }
391
392    #[test]
393    fn dispatch_single_series_single_column_is_implicit_y() {
394        let data = Data {
395            headers: None,
396            series: vec![some_option_strings(&["1.0", "2.5", "3.25"])],
397        };
398
399        let result = dispatch_single_series(&data, SingleFormat::Xy)
400            .expect("single-column dispatch should succeed");
401        assert_eq!(result, SingleSeriesData::ImplicitY(vec![1.0, 2.5, 3.25]));
402    }
403
404    #[test]
405    fn dispatch_single_series_two_column_xy_maps_labels_and_values() {
406        let data = Data {
407            headers: None,
408            series: vec![
409                some_option_strings(&["alpha", "beta", "gamma"]),
410                some_option_strings(&["1.0", "2.0", "3.0"]),
411            ],
412        };
413
414        let result =
415            dispatch_single_series(&data, SingleFormat::Xy).expect("xy dispatch should succeed");
416        assert_eq!(
417            result,
418            SingleSeriesData::ExplicitXY {
419                labels: vec![
420                    String::from("alpha"),
421                    String::from("beta"),
422                    String::from("gamma"),
423                ],
424                values: vec![1.0, 2.0, 3.0],
425            }
426        );
427    }
428
429    #[test]
430    fn dispatch_single_series_two_column_yx_swaps_columns() {
431        let data = Data {
432            headers: None,
433            series: vec![
434                some_option_strings(&["1.0", "2.0", "3.0"]),
435                some_option_strings(&["alpha", "beta", "gamma"]),
436            ],
437        };
438
439        let result =
440            dispatch_single_series(&data, SingleFormat::Yx).expect("yx dispatch should succeed");
441        assert_eq!(
442            result,
443            SingleSeriesData::ExplicitXY {
444                labels: vec![
445                    String::from("alpha"),
446                    String::from("beta"),
447                    String::from("gamma"),
448                ],
449                values: vec![1.0, 2.0, 3.0],
450            }
451        );
452    }
453
454    #[test]
455    fn dispatch_multi_series_xyy_uses_shared_x() {
456        let data = Data {
457            headers: Some(some_option_strings(&["x", "y1", "y2"])),
458            series: vec![
459                some_option_strings(&["1.0", "2.0", "3.0"]),
460                some_option_strings(&["10.0", "20.0", "30.0"]),
461                some_option_strings(&["100.0", "200.0", "300.0"]),
462            ],
463        };
464
465        let result =
466            dispatch_multi_series(&data, MultiFormat::Xyy).expect("xyy dispatch should succeed");
467        assert_eq!(
468            result,
469            MultiSeriesData::SharedX {
470                x: vec![1.0, 2.0, 3.0],
471                ys: vec![vec![10.0, 20.0, 30.0], vec![100.0, 200.0, 300.0]],
472                names: vec![Some(String::from("y1")), Some(String::from("y2"))],
473            }
474        );
475    }
476
477    #[test]
478    fn dispatch_multi_series_xyxy_builds_paired_series() {
479        let data = Data {
480            headers: Some(some_option_strings(&["x1", "s1", "x2", "s2"])),
481            series: vec![
482                some_option_strings(&["1.0", "2.0", "3.0"]),
483                some_option_strings(&["10.0", "20.0", "30.0"]),
484                some_option_strings(&["4.0", "5.0", "6.0"]),
485                some_option_strings(&["40.0", "50.0", "60.0"]),
486            ],
487        };
488
489        let result =
490            dispatch_multi_series(&data, MultiFormat::Xyxy).expect("xyxy dispatch should succeed");
491        assert_eq!(
492            result,
493            MultiSeriesData::PairedXY(vec![
494                SeriesXY {
495                    x: vec![1.0, 2.0, 3.0],
496                    y: vec![10.0, 20.0, 30.0],
497                    name: Some(String::from("s1")),
498                },
499                SeriesXY {
500                    x: vec![4.0, 5.0, 6.0],
501                    y: vec![40.0, 50.0, 60.0],
502                    name: Some(String::from("s2")),
503                },
504            ])
505        );
506    }
507
508    #[test]
509    fn dispatch_multi_series_requires_enough_columns_for_xyy() {
510        let data = Data {
511            headers: None,
512            series: vec![some_option_strings(&["1.0", "2.0"])],
513        };
514
515        let error = dispatch_multi_series(&data, MultiFormat::Xyy)
516            .expect_err("xyy should reject single-column input");
517        assert!(matches!(
518            error,
519            super::ProcessingError::NotEnoughColumns {
520                required: 2,
521                found: 1
522            }
523        ));
524    }
525
526    #[test]
527    fn dispatch_multi_series_xyxy_requires_even_number_of_columns() {
528        let data = Data {
529            headers: None,
530            series: vec![
531                some_option_strings(&["1.0", "2.0"]),
532                some_option_strings(&["10.0", "20.0"]),
533                some_option_strings(&["3.0", "4.0"]),
534            ],
535        };
536
537        let error = dispatch_multi_series(&data, MultiFormat::Xyxy)
538            .expect_err("xyxy should reject odd column count");
539        assert!(matches!(
540            error,
541            super::ProcessingError::OddSeriesCount { column_count: 3 }
542        ));
543    }
544
545    #[test]
546    fn dispatch_single_series_rejects_missing_labels_with_user_indexes() {
547        let data = Data {
548            headers: None,
549            series: vec![
550                some_option_strings(&["1.0", "2.0"]),
551                vec![Some(String::from("2.0")), None],
552            ],
553        };
554
555        let error = dispatch_single_series(&data, SingleFormat::Yx)
556            .expect_err("missing labels should be rejected");
557        assert!(matches!(
558            error,
559            super::ProcessingError::MissingValue { column: 2, row: 2 }
560        ));
561    }
562
563    #[test]
564    fn options_default_matches_expected_shape() {
565        let options = Options::default();
566        assert_eq!(options.delimiter, b'\t');
567        assert!(!options.transpose);
568        assert!(!options.headers);
569        assert_eq!(options.encoding, None);
570    }
571
572    #[test]
573    fn parameter_conversion_maps_barplot_fields() {
574        let params = Parameters {
575            title: Some(String::from("Title")),
576            width: Some(64),
577            border: Some(BorderType::Corners),
578            color: Some(TermColor::Named(NamedColor::Cyan)),
579            symbol: Some('@'),
580            xscale: Some(Scale::Log10),
581            labels: Some(false),
582            ..Parameters::default()
583        };
584
585        let options = params.to_barplot_options();
586        assert_eq!(options.title.as_deref(), Some("Title"));
587        assert_eq!(options.width, 64);
588        assert_eq!(options.border, BorderType::Corners);
589        assert_eq!(options.color, TermColor::Named(NamedColor::Cyan));
590        assert_eq!(options.symbol, Some('@'));
591        assert_eq!(options.xscale, Scale::Log10);
592        assert!(!options.labels);
593    }
594
595    #[test]
596    fn parameter_conversion_maps_grid_plot_fields() {
597        let params = Parameters {
598            width: Some(32),
599            height: Some(12),
600            xlim: Some((-1.0, 10.0)),
601            ylim: Some((0.0, 100.0)),
602            canvas: Some(CanvasType::Dot),
603            grid: Some(false),
604            closed: Some(ClosedInterval::Right),
605            ..Parameters::default()
606        };
607
608        let line = params.to_lineplot_options();
609        assert_eq!(line.width, 32);
610        assert_eq!(line.height, 12);
611        assert_eq!(line.xlim, (-1.0, 10.0));
612        assert_eq!(line.ylim, (0.0, 100.0));
613        assert_eq!(line.canvas, CanvasType::Dot);
614        assert!(!line.grid);
615
616        let histogram = params.to_histogram_options();
617        assert_eq!(histogram.closed, ClosedInterval::Right);
618    }
619
620    #[test]
621    fn dispatch_barplot_rejects_single_column_data() {
622        let data = Data {
623            headers: None,
624            series: vec![some_option_strings(&["1.0", "2.0", "3.0"])],
625        };
626
627        let Err(error) = dispatch(Command::Bar, &data, &Parameters::default()) else {
628            panic!("barplot command should reject single-column input");
629        };
630        assert!(matches!(
631            error,
632            DispatchError::ExplicitSeriesRequired { command: "barplot" }
633        ));
634    }
635
636    #[test]
637    fn dispatch_multi_series_commands_reject_single_column_data() {
638        let data = Data {
639            headers: None,
640            series: vec![some_option_strings(&["1.0", "2.0", "3.0"])],
641        };
642
643        for command in [Command::Lineplots, Command::Scatter, Command::Density] {
644            let Err(error) = dispatch(command, &data, &Parameters::default()) else {
645                panic!("multi-series command should reject single-column input");
646            };
647            assert!(matches!(error, DispatchError::MultiSeriesRequired { .. }));
648        }
649    }
650
651    #[test]
652    fn dispatch_respects_format_command_compatibility() {
653        let data = Data {
654            headers: None,
655            series: vec![
656                some_option_strings(&["x1", "x2"]),
657                some_option_strings(&["1.0", "2.0"]),
658            ],
659        };
660
661        let params = Parameters {
662            fmt: Some(Format::Xyy),
663            ..Parameters::default()
664        };
665        let Err(error) = dispatch(Command::Bar, &data, &params) else {
666            panic!("barplot should reject multi-series format markers");
667        };
668        assert!(matches!(
669            error,
670            DispatchError::FormatMismatch {
671                command: Command::Bar,
672                format: Format::Xyy,
673            }
674        ));
675    }
676
677    #[test]
678    fn count_dispatch_overwrites_explicit_title_with_header() {
679        let data = Data {
680            headers: Some(vec![Some(String::from("Species"))]),
681            series: vec![some_option_strings(&["setosa", "setosa", "virginica"])],
682        };
683        let params = Parameters {
684            title: Some(String::from("EXPLICIT")),
685            ..Parameters::default()
686        };
687
688        let rendered = render_plain(Command::Count, &data, &params);
689        assert!(
690            rendered.contains("Species"),
691            "count title should be overwritten by first header"
692        );
693        assert!(
694            !rendered.contains("EXPLICIT"),
695            "explicit title should not be preserved for count"
696        );
697    }
698
699    #[test]
700    fn bar_dispatch_keeps_explicit_title() {
701        let data = Data {
702            headers: Some(vec![
703                Some(String::from("Name")),
704                Some(String::from("Value")),
705            ]),
706            series: vec![
707                some_option_strings(&["a", "b", "c"]),
708                some_option_strings(&["1.0", "2.0", "3.0"]),
709            ],
710        };
711        let params = Parameters {
712            title: Some(String::from("EXPLICIT")),
713            ..Parameters::default()
714        };
715
716        let rendered = render_plain(Command::Bar, &data, &params);
717        assert!(
718            rendered.contains("EXPLICIT"),
719            "barplot should preserve explicit title"
720        );
721    }
722
723    #[test]
724    fn lenient_parsing_coerces_non_numeric_to_zero() {
725        let mixed = some_option_strings(&["1.5", "hello", "3.0"]);
726        assert_eq!(parse_numeric_series_lenient(&mixed), vec![1.5, 0.0, 3.0]);
727
728        let with_none: Vec<Option<String>> = vec![Some("2.0".into()), None, Some("4.0".into())];
729        assert_eq!(
730            parse_numeric_series_lenient(&with_none),
731            vec![2.0, 0.0, 4.0]
732        );
733
734        assert_eq!(
735            parse_numeric_series_lenient(&[]),
736            Vec::<f64>::new(),
737            "empty input should return empty output"
738        );
739
740        let sci = some_option_strings(&["1e3", "2.5e-1"]);
741        assert_eq!(parse_numeric_series_lenient(&sci), vec![1000.0, 0.25]);
742    }
743
744    #[test]
745    fn lenient_parsing_maps_non_finite_to_zero() {
746        let non_finite = some_option_strings(&["NaN", "inf", "-inf", "42.0"]);
747        assert_eq!(
748            parse_numeric_series_lenient(&non_finite),
749            vec![0.0, 0.0, 0.0, 42.0],
750            "NaN/Inf/-Inf should coerce to 0.0 matching Ruby's String#to_f"
751        );
752
753        let whitespace_only = some_option_strings(&[" ", ""]);
754        assert_eq!(
755            parse_numeric_series_lenient(&whitespace_only),
756            vec![0.0, 0.0],
757            "whitespace-only and empty strings should coerce to 0.0"
758        );
759    }
760
761    #[test]
762    fn infer_multi_series_xlabel_respects_existing_value_and_format() {
763        let data = Data {
764            headers: Some(some_option_strings(&["x_col", "y1", "y2"])),
765            series: vec![
766                some_option_strings(&["1.0"]),
767                some_option_strings(&["2.0"]),
768                some_option_strings(&["3.0"]),
769            ],
770        };
771
772        let mut params = Parameters {
773            xlabel: Some("EXPLICIT".into()),
774            ..Parameters::default()
775        };
776        infer_multi_series_xlabel(&data, MultiFormat::Xyy, &mut params);
777        assert_eq!(
778            params.xlabel.as_deref(),
779            Some("EXPLICIT"),
780            "explicit xlabel should not be overwritten by header"
781        );
782
783        let mut params2 = Parameters::default();
784        infer_multi_series_xlabel(&data, MultiFormat::Xyxy, &mut params2);
785        assert_eq!(
786            params2.xlabel, None,
787            "xyxy format should not set xlabel from headers"
788        );
789
790        let mut params3 = Parameters::default();
791        infer_multi_series_xlabel(&data, MultiFormat::Xyy, &mut params3);
792        assert_eq!(
793            params3.xlabel.as_deref(),
794            Some("x_col"),
795            "xyy format should set xlabel from headers[0] when xlabel is None"
796        );
797
798        assert_eq!(
799            header_at(&data, 0),
800            Some("x_col".into()),
801            "header_at should return the header at the given index"
802        );
803        assert_eq!(
804            header_at(&data, 99),
805            None,
806            "header_at should return None for out-of-bounds index"
807        );
808
809        let no_headers = Data {
810            headers: None,
811            series: vec![],
812        };
813        assert_eq!(
814            header_at(&no_headers, 0),
815            None,
816            "header_at should return None when no headers present"
817        );
818    }
819
820    #[test]
821    fn renderable_dispatch_output_renders_without_color() {
822        let data = Data {
823            headers: None,
824            series: vec![
825                some_option_strings(&["a", "b"]),
826                some_option_strings(&["1.0", "2.0"]),
827            ],
828        };
829
830        let output = dispatch(Command::Bar, &data, &Parameters::default())
831            .expect("dispatch should return a renderable output");
832        let mut buffer = Vec::new();
833        output
834            .render_with_mode(&mut buffer, ColorMode::Never, false)
835            .expect("rendering dispatched output should succeed");
836        assert!(!buffer.is_empty());
837    }
838
839    #[test]
840    fn colors_output_contains_all_named_entries_and_256_palette() {
841        let output = ColorsRenderable::render_string();
842
843        assert!(
844            output.ends_with('\n'),
845            "colors output should end with a newline"
846        );
847
848        let lines: Vec<_> = output.lines().collect();
849        assert_eq!(
850            lines.len(),
851            1,
852            "colors output should be exactly one line, got {}",
853            lines.len()
854        );
855
856        for name in &[
857            "black",
858            "red",
859            "green",
860            "yellow",
861            "blue",
862            "magenta",
863            "cyan",
864            "white",
865            "gray",
866            "light_black",
867            "light_red",
868            "light_green",
869            "light_yellow",
870            "light_blue",
871            "light_magenta",
872            "light_cyan",
873            "normal",
874            "default",
875            "bold",
876            "underline",
877            "blink",
878            "reverse",
879            "hidden",
880            "nothing",
881        ] {
882            assert!(
883                output.contains(name),
884                "colors output should contain named entry {name:?}"
885            );
886        }
887
888        // Verify representative name-escape pairings to catch mismatches at the unit level.
889        assert!(
890            output.contains("\x1b[31mred\t"),
891            "red should be preceded by its ANSI escape"
892        );
893        assert!(
894            output.contains("\x1b[90mlight_black\t"),
895            "light_black should be preceded by bright-black escape"
896        );
897        assert!(
898            output.contains("nothing\t"),
899            "nothing entry should have no escape prefix"
900        );
901
902        assert!(
903            output.contains("\x1b[38;5;0m0\t"),
904            "colors output should contain 256-color palette entry 0"
905        );
906        assert!(
907            output.contains("\x1b[38;5;255m255\t"),
908            "colors output should contain 256-color palette entry 255"
909        );
910        assert!(
911            output.contains("\x1b[38;5;128m128\t"),
912            "colors output should contain 256-color palette entry 128"
913        );
914    }
915
916    #[test]
917    fn colors_output_entry_count_matches_ruby() {
918        let output = ColorsRenderable::render_string();
919
920        let bullet = "\u{25CF}";
921        let entry_count = output.matches(bullet).count();
922        assert_eq!(
923            entry_count, 280,
924            "colors output should have 280 entries (24 named + 256 palette), got {entry_count}"
925        );
926    }
927
928    #[test]
929    fn colors_dispatch_renders_identically_regardless_of_color_mode() {
930        let empty_data = Data {
931            headers: None,
932            series: vec![],
933        };
934        let output = dispatch(Command::Colors, &empty_data, &Parameters::default())
935            .expect("colors dispatch should succeed");
936
937        let mut always_buf = Vec::new();
938        output
939            .render_with_mode(&mut always_buf, ColorMode::Always, true)
940            .expect("colors always render should succeed");
941
942        let mut never_buf = Vec::new();
943        output
944            .render_with_mode(&mut never_buf, ColorMode::Never, false)
945            .expect("colors never render should succeed");
946
947        assert_eq!(
948            always_buf, never_buf,
949            "colors output should be identical regardless of color mode"
950        );
951    }
952}