Skip to main content

openstack_cli_core/
output.rs

1// Licensed under the Apache License, Version 2.0 (the "License");
2// you may not use this file except in compliance with the License.
3// You may obtain a copy of the License at
4//
5//     http://www.apache.org/licenses/LICENSE-2.0
6//
7// Unless required by applicable law or agreed to in writing, software
8// distributed under the License is distributed on an "AS IS" BASIS,
9// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10// See the License for the specific language governing permissions and
11// limitations under the License.
12//
13// SPDX-License-Identifier: Apache-2.0
14
15//! Output processing module
16
17use std::collections::BTreeSet;
18use std::io::{self, Write};
19
20use comfy_table::{
21    Cell, Color, ColumnConstraint, ContentArrangement, Table, Width, presets::UTF8_FULL_CONDENSED,
22};
23use itertools::Itertools;
24use openstack_sdk_core::types::EntryStatus;
25use owo_colors::{OwoColorize, Stream::Stderr};
26use rand::prelude::*;
27use serde::de::DeserializeOwned;
28
29use structable::{OutputConfig, StructTable, StructTableOptions};
30
31use crate::cli::{CliArgs, OutputFormat, TableArrangement};
32use crate::config::ViewConfig;
33use crate::error::OpenStackCliError;
34
35/// Output target (human or machine) enum
36#[derive(Default, Clone)]
37pub enum OutputFor {
38    #[default]
39    Human,
40    Machine,
41}
42
43impl From<TableArrangement> for ContentArrangement {
44    fn from(value: TableArrangement) -> Self {
45        match value {
46            TableArrangement::Dynamic => Self::Dynamic,
47            TableArrangement::DynamicFullWidth => Self::DynamicFullWidth,
48            TableArrangement::Disabled => Self::Disabled,
49        }
50    }
51}
52
53/// Output Processor.
54#[derive(Default, Clone)]
55pub struct OutputProcessor {
56    /// Resource output configuration
57    pub config: Option<ViewConfig>,
58    /// Whether output is for human or for machine
59    pub target: OutputFor,
60    /// Table arrangement
61    pub table_arrangement: TableArrangement,
62    /// Fields requested
63    pub fields: BTreeSet<String>,
64    /// Wide mode
65    pub wide: bool,
66    /// Pretty mode
67    pub pretty: bool,
68    /// Command hints
69    hints: Option<Vec<String>>,
70}
71
72impl StructTableOptions for OutputProcessor {
73    fn wide_mode(&self) -> bool {
74        self.wide
75            || self
76                .config
77                .as_ref()
78                .is_some_and(|cfg| cfg.wide.is_some_and(|w| w))
79    }
80
81    fn pretty_mode(&self) -> bool {
82        self.pretty
83    }
84
85    fn should_return_field<S: AsRef<str>>(&self, field: S, is_wide_field: bool) -> bool {
86        let is_requested = self
87            .fields
88            .iter()
89            .any(|x| x.to_lowercase() == field.as_ref().to_lowercase())
90            || (self.fields.is_empty()
91                && self
92                    .config
93                    .as_ref()
94                    .map(|cfg| {
95                        cfg.default_fields
96                            .iter()
97                            .any(|x| x.to_lowercase() == field.as_ref().to_lowercase())
98                    })
99                    .is_some_and(|x| x));
100
101        if !is_wide_field {
102            // Return non wide field when no field filters passed or explicitly requested the field
103            is_requested
104                || (self.fields.is_empty()
105                    && self
106                        .config
107                        .as_ref()
108                        .is_none_or(|cfg| cfg.default_fields.is_empty()))
109        } else {
110            // The wide field is returned in wide mode when no filters passed or explicitly
111            // requested the field
112            (self.fields.is_empty() && self.wide_mode()) || is_requested
113        }
114    }
115
116    fn field_data_json_pointer<S: AsRef<str>>(&self, field: S) -> Option<String> {
117        if !self.wide_mode() {
118            self.config.as_ref().and_then(|config| {
119                config
120                    .fields
121                    .iter()
122                    .find(|x| x.name.to_lowercase() == field.as_ref().to_lowercase())
123                    .and_then(|field_config| field_config.json_pointer.clone())
124            })
125        } else {
126            None
127        }
128    }
129}
130
131impl OutputProcessor {
132    /// Get OutputConfig from passed arguments
133    pub fn from_args<C: CliArgs, R: AsRef<str>, A: AsRef<str>>(
134        args: &C,
135        resource_key: Option<R>,
136        action: Option<A>,
137    ) -> Self {
138        let target = match args.global_opts().output.output {
139            None => OutputFor::Human,
140            Some(OutputFormat::Wide) => OutputFor::Human,
141            _ => OutputFor::Machine,
142        };
143        let mut hints: Vec<String> = args.config().hints.clone();
144
145        if let (Some(resource_key), Some(action)) = (&resource_key, &action) {
146            args.config()
147                .command_hints
148                .get(resource_key.as_ref())
149                .and_then(|cmd_hints| {
150                    cmd_hints.get(action.as_ref()).map(|val| {
151                        hints.extend(val.clone());
152                    })
153                });
154        }
155
156        Self {
157            config: resource_key
158                .as_ref()
159                .and_then(|val| args.config().views.get(val.as_ref()).cloned()),
160            target,
161            table_arrangement: args.global_opts().output.table_arrangement,
162            fields: BTreeSet::from_iter(args.global_opts().output.fields.iter().cloned()),
163            wide: matches!(args.global_opts().output.output, Some(OutputFormat::Wide)),
164            pretty: args.global_opts().output.pretty,
165            hints: Some(hints),
166        }
167    }
168
169    /// Validate command arguments with respect to the output producing
170    pub fn validate_args<C: CliArgs>(&self, _args: &C) -> Result<(), OpenStackCliError> {
171        Ok(())
172    }
173
174    /// Re-sort table according to the configuration and determine column constraints
175    fn prepare_table(
176        &self,
177        headers: Vec<String>,
178        data: Vec<Vec<String>>,
179    ) -> (Vec<String>, Vec<Vec<String>>, Vec<Option<ColumnConstraint>>) {
180        let mut headers = headers;
181        let mut rows = data;
182        let mut column_constrains: Vec<Option<ColumnConstraint>> = vec![None; headers.len()];
183
184        if let Some(cfg) = &self.config {
185            // Offset from the current iteration pointer
186            if headers.len() > 1 {
187                let mut idx_offset: usize = 0;
188                for (default_idx, field) in cfg.default_fields.iter().unique().enumerate() {
189                    if let Some(curr_idx) = headers
190                        .iter()
191                        .position(|x| x.to_lowercase() == field.to_lowercase())
192                    {
193                        // Swap headers between current and should pos
194                        if default_idx - idx_offset < headers.len() {
195                            headers.swap(default_idx - idx_offset, curr_idx);
196                            for row in rows.iter_mut() {
197                                // Swap also data columns
198                                row.swap(default_idx - idx_offset, curr_idx);
199                            }
200                        }
201                    } else {
202                        // This column is not found in the data. Perhars structable returned some
203                        // other name. Move the column to the very end
204                        if default_idx - idx_offset < headers.len() {
205                            let curr_hdr = headers.remove(default_idx - idx_offset);
206                            headers.push(curr_hdr);
207                            for row in rows.iter_mut() {
208                                let curr_cell = row.remove(default_idx - idx_offset);
209                                row.push(curr_cell);
210                            }
211                            // Some unmatched field moved to the end. Our "current" index should respect
212                            // the offset
213                            idx_offset += 1;
214                        }
215                    }
216                }
217            }
218            // Find field configuration
219            for (idx, field) in headers.iter().enumerate() {
220                if let Some(field_config) = cfg
221                    .fields
222                    .iter()
223                    .find(|x| x.name.to_lowercase() == field.to_lowercase())
224                {
225                    let constraint = match (
226                        field_config.width,
227                        field_config.min_width,
228                        field_config.max_width,
229                    ) {
230                        (Some(fixed), _, _) => {
231                            Some(ColumnConstraint::Absolute(Width::Fixed(fixed as u16)))
232                        }
233                        (None, Some(lower), Some(upper)) => Some(ColumnConstraint::Boundaries {
234                            lower: Width::Fixed(lower as u16),
235                            upper: Width::Fixed(upper as u16),
236                        }),
237                        (None, Some(lower), None) => {
238                            Some(ColumnConstraint::LowerBoundary(Width::Fixed(lower as u16)))
239                        }
240                        (None, None, Some(upper)) => {
241                            Some(ColumnConstraint::UpperBoundary(Width::Fixed(upper as u16)))
242                        }
243                        _ => None,
244                    };
245                    column_constrains[idx] = constraint;
246                }
247            }
248        }
249        (headers, rows, column_constrains)
250    }
251
252    /// Output List of resources
253    pub fn output_list<T>(&self, data: Vec<serde_json::Value>) -> Result<(), OpenStackCliError>
254    where
255        T: StructTable,
256        T: DeserializeOwned,
257        for<'a> &'a T: StructTable,
258    {
259        match self.target {
260            OutputFor::Human => {
261                let table: Vec<T> = serde_json::from_value(serde_json::Value::Array(data.clone()))
262                    .map_err(|err| {
263                        OpenStackCliError::deserialize(
264                            err,
265                            serde_json::to_string(&serde_json::Value::Array(
266                                data.into_iter()
267                                    .filter(|item| {
268                                        serde_json::from_value::<T>(item.clone()).is_err()
269                                    })
270                                    .collect(),
271                            ))
272                            .unwrap_or_else(|v| format!("{v:?}")),
273                        )
274                    })?;
275
276                let data = structable::build_list_table(table.iter(), self);
277                let (headers, table_rows, table_constraints) = self.prepare_table(data.0, data.1);
278                let mut statuses: Vec<Option<String>> =
279                    table.iter().map(|item| item.status()).collect();
280
281                // Ensure we have as many statuses as rows to zip them properly
282                statuses.resize_with(table_rows.len(), Default::default);
283
284                let rows = table_rows
285                    .iter()
286                    .zip(statuses.iter())
287                    .map(|(data, status)| {
288                        let color = match EntryStatus::from(status.as_ref()) {
289                            EntryStatus::Error => Some(Color::Red),
290                            EntryStatus::Pending => Some(Color::Yellow),
291                            EntryStatus::Inactive => Some(Color::Cyan),
292                            _ => None,
293                        };
294                        data.iter().map(move |cell| {
295                            if let Some(color) = color {
296                                Cell::new(cell).fg(color)
297                            } else {
298                                Cell::new(cell)
299                            }
300                        })
301                    });
302                let mut table = Table::new();
303                table
304                    .load_preset(UTF8_FULL_CONDENSED)
305                    .set_content_arrangement(ContentArrangement::from(self.table_arrangement))
306                    .set_header(headers)
307                    .add_rows(rows);
308
309                for (idx, constraint) in table_constraints.iter().enumerate() {
310                    if let Some(constraint) = constraint
311                        && let Some(col) = table.column_mut(idx)
312                    {
313                        col.set_constraint(*constraint);
314                    }
315                }
316
317                println!("{table}");
318                Ok(())
319            }
320            _ => self.output_machine(serde_json::from_value(serde_json::Value::Array(data))?),
321        }
322    }
323
324    /// Output List of resources
325    pub fn output_single<T>(&self, data: serde_json::Value) -> Result<(), OpenStackCliError>
326    where
327        T: StructTable,
328        T: DeserializeOwned,
329    {
330        match self.target {
331            OutputFor::Human => {
332                let table: T = serde_json::from_value(data.clone()).map_err(|err| {
333                    OpenStackCliError::deserialize(
334                        err,
335                        serde_json::to_string(&data.clone()).unwrap_or_default(),
336                    )
337                })?;
338
339                self.output_human(&table)
340            }
341            _ => self.output_machine(serde_json::from_value(data)?),
342        }
343    }
344
345    /// Produce output for humans (table) for a single resource
346    pub fn output_human<T: StructTable>(&self, data: &T) -> Result<(), OpenStackCliError> {
347        let (headers, table_rows) = structable::build_table(data, &OutputConfig::default());
348
349        let mut table = Table::new();
350        table
351            .load_preset(UTF8_FULL_CONDENSED)
352            .set_content_arrangement(ContentArrangement::from(self.table_arrangement))
353            .set_header(headers)
354            .add_rows(table_rows);
355        println!("{table}");
356        Ok(())
357    }
358
359    /// Produce output for machine
360    /// Return machine readable output with the API side names
361    pub fn output_machine(&self, data: serde_json::Value) -> Result<(), OpenStackCliError> {
362        if self.pretty {
363            serde_json::to_writer_pretty(io::stdout(), &data)?;
364        } else {
365            serde_json::to_writer(io::stdout(), &data)?;
366        }
367        io::stdout().write_all(b"\n")?;
368        Ok(())
369    }
370
371    /// Show hints
372    pub fn show_command_hint(&self) -> Result<(), OpenStackCliError> {
373        if rand::random_bool(1.0 / 2.0) {
374            self.hints.as_ref().and_then(|hints| {
375                hints.choose(&mut rand::rng()).map(|hint| {
376                    eprintln!(
377                        "\n{} {}",
378                        "Hint:".if_supports_color(Stderr, |text| text.green()),
379                        hint.if_supports_color(Stderr, |text| text.blue())
380                    );
381                })
382            });
383        }
384        Ok(())
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use clap::Parser;
392    use std::io::Write;
393    use tempfile::Builder;
394
395    use crate::{cli::*, config::*};
396
397    #[test]
398    fn test_wide_mode() {
399        assert!(!OutputProcessor::default().wide_mode());
400        assert!(
401            OutputProcessor {
402                wide: true,
403                ..Default::default()
404            }
405            .wide_mode()
406        );
407        assert!(
408            OutputProcessor {
409                config: Some(ViewConfig {
410                    wide: Some(true),
411                    ..Default::default()
412                }),
413                ..Default::default()
414            }
415            .wide_mode()
416        );
417    }
418
419    #[test]
420    fn test_field_returned_no_selection() {
421        let out = OutputProcessor::default();
422
423        assert!(
424            out.should_return_field("dummy", false),
425            "default field returned in non-wide mode with empty fields selector"
426        );
427        assert!(
428            !out.should_return_field("dummy", true),
429            "wide field not returned in non-wide mode with empty fields selector"
430        );
431
432        let out = OutputProcessor {
433            wide: true,
434            ..Default::default()
435        };
436
437        assert!(
438            out.should_return_field("dummy", false),
439            "default field returned in wide mode with empty fields selector"
440        );
441        assert!(
442            out.should_return_field("dummy", true),
443            "wide field returned in wide mode with empty fields selector"
444        );
445    }
446
447    #[test]
448    fn test_field_returned_selection_no_config() {
449        let out = OutputProcessor {
450            fields: BTreeSet::from(["foo".to_string()]),
451            ..Default::default()
452        };
453
454        assert!(
455            !out.should_return_field("dummy", false),
456            "default field not returned in non-wide mode with mismatching fields selector"
457        );
458        assert!(
459            !out.should_return_field("dummy", true),
460            "wide field not returned in non-wide mode with mismatching fields selector"
461        );
462        assert!(
463            out.should_return_field("foo", false),
464            "default field returned in non-wide mode with matching fields selector"
465        );
466        assert!(
467            out.should_return_field("foo", true),
468            "wide field returned in non-wide mode with matching fields selector"
469        );
470
471        let out = OutputProcessor {
472            fields: BTreeSet::from(["foo".to_string()]),
473            wide: true,
474            ..Default::default()
475        };
476
477        assert!(
478            !out.should_return_field("dummy", false),
479            "default field not returned in wide mode with mismatching fields selector"
480        );
481        assert!(
482            !out.should_return_field("dummy", true),
483            "wide field not returned in wide mode with mismatching fields selector"
484        );
485    }
486
487    #[test]
488    fn test_field_returned_selection_empty_config() {
489        let out = OutputProcessor {
490            config: Some(ViewConfig::default()),
491            target: OutputFor::Human,
492            table_arrangement: TableArrangement::Disabled,
493            fields: BTreeSet::new(),
494            wide: false,
495            pretty: false,
496            ..Default::default()
497        };
498
499        assert!(
500            out.should_return_field("dummy", false),
501            "default field returned in non-wide mode with mismatching fields selector and empty config"
502        );
503        assert!(
504            !out.should_return_field("dummy", true),
505            "wide field not returned in non-wide mode with mismatching fields selector and empty config"
506        );
507    }
508
509    #[test]
510    fn test_field_returned_selection_with_config_with_filters() {
511        let out = OutputProcessor {
512            config: Some(ViewConfig {
513                default_fields: vec!["foo".to_string()],
514                ..Default::default()
515            }),
516            fields: BTreeSet::from(["bar".to_string()]),
517            ..Default::default()
518        };
519
520        assert!(
521            !out.should_return_field("dummy", false),
522            "default field not returned in non-wide mode with mismatching fields selector"
523        );
524        assert!(
525            !out.should_return_field("dummy", true),
526            "wide field not returned in non-wide mode with mismatching fields selector"
527        );
528        assert!(
529            !out.should_return_field("foo", false),
530            "default field not returned in non-wide mode with mismatching fields selector"
531        );
532        assert!(
533            !out.should_return_field("foo", true),
534            "wide field not returned in non-wide mode with mismatching fields selector"
535        );
536        assert!(
537            out.should_return_field("bar", false),
538            "default field returned in non-wide mode with matching fields selector"
539        );
540        assert!(
541            out.should_return_field("bar", true),
542            "wide field returned in non-wide mode with matching fields selector"
543        );
544
545        let out = OutputProcessor {
546            config: Some(ViewConfig {
547                default_fields: vec!["foo".to_string()],
548                ..Default::default()
549            }),
550            fields: BTreeSet::from(["bar".to_string()]),
551            wide: true,
552            ..Default::default()
553        };
554
555        assert!(
556            !out.should_return_field("dummy", false),
557            "default field not returned in wide mode with mismatching fields selector"
558        );
559        assert!(
560            !out.should_return_field("dummy", true),
561            "wide field not returned in wide mode with mismatching fields selector"
562        );
563        assert!(
564            !out.should_return_field("foo", false),
565            "config field not returned in wide mode with mismatching fields selector"
566        );
567        assert!(
568            !out.should_return_field("foo", true),
569            "wide config field not returned in wide mode with mismatching fields selector"
570        );
571        assert!(
572            out.should_return_field("bar", false),
573            "default field returned in wide mode with matching fields selector"
574        );
575        assert!(
576            out.should_return_field("bar", true),
577            "wide field returned in wide mode with matching fields selector"
578        );
579    }
580
581    #[test]
582    fn test_field_returned_selection_with_config_no_filters() {
583        let out = OutputProcessor {
584            config: Some(ViewConfig {
585                default_fields: vec!["foo".to_string()],
586                ..Default::default()
587            }),
588            ..Default::default()
589        };
590
591        assert!(
592            !out.should_return_field("dummy", false),
593            "default field not returned in non-wide mode with empty fields selector and not in config"
594        );
595        assert!(
596            out.should_return_field("foo", false),
597            "default field not returned in non-wide mode with empty fields selector, but in config"
598        );
599        assert!(
600            !out.should_return_field("dummy", true),
601            "wide field not returned in non-wide mode with empty fields selector and not in config"
602        );
603        assert!(
604            out.should_return_field("foo", true),
605            "wide field returned in non-wide mode with empty fields selector, but in config"
606        );
607
608        let out = OutputProcessor {
609            config: Some(ViewConfig {
610                default_fields: vec!["foo".to_string()],
611                ..Default::default()
612            }),
613            wide: true,
614            ..Default::default()
615        };
616
617        assert!(
618            !out.should_return_field("dummy", false),
619            "default field not returned in wide mode with empty fields selector and not in config"
620        );
621        assert!(
622            out.should_return_field("foo", false),
623            "default field returned in wide mode with empty fields selector, but in config"
624        );
625        assert!(
626            out.should_return_field("dummy", true),
627            "wide field returned in wide mode with empty fields selector and not in config"
628        );
629        assert!(
630            out.should_return_field("foo", true),
631            "wide field returned in wide mode with empty fields selector, but in config"
632        );
633    }
634
635    #[test]
636    fn test_prepare_table() {
637        let out = OutputProcessor {
638            config: Some(ViewConfig {
639                default_fields: vec![
640                    "foo".to_string(),
641                    "bar".to_string(),
642                    "baz".to_string(),
643                    "dummy".to_string(),
644                ],
645                fields: vec![FieldConfig {
646                    name: "bar".to_string(),
647                    min_width: Some(15),
648                    ..Default::default()
649                }],
650                ..Default::default()
651            }),
652            ..Default::default()
653        };
654        let (hdr, rows, constraints) = out.prepare_table(
655            vec![
656                "dummy".to_string(),
657                "bar".to_string(),
658                "foo".to_string(),
659                "baz".to_string(),
660            ],
661            vec![
662                vec![
663                    "11".to_string(),
664                    "12".to_string(),
665                    "13".to_string(),
666                    "14".to_string(),
667                ],
668                vec![
669                    "21".to_string(),
670                    "22".to_string(),
671                    "23".to_string(),
672                    "24".to_string(),
673                ],
674            ],
675        );
676        assert_eq!(
677            vec![
678                "foo".to_string(),
679                "bar".to_string(),
680                "baz".to_string(),
681                "dummy".to_string()
682            ],
683            hdr,
684            "headers in the correct sort order"
685        );
686        assert_eq!(
687            vec![
688                vec![
689                    "13".to_string(),
690                    "12".to_string(),
691                    "14".to_string(),
692                    "11".to_string(),
693                ],
694                vec![
695                    "23".to_string(),
696                    "22".to_string(),
697                    "24".to_string(),
698                    "21".to_string(),
699                ],
700            ],
701            rows,
702            "row columns sorted properly"
703        );
704        assert_eq![
705            vec![
706                None,
707                Some(ColumnConstraint::LowerBoundary(Width::Fixed(15))),
708                None,
709                None
710            ],
711            constraints
712        ];
713
714        let (hdr, rows, _constraints) = out.prepare_table(
715            vec![
716                "dummy".to_string(),
717                "bar2".to_string(),
718                "foo".to_string(),
719                "baz2".to_string(),
720            ],
721            vec![
722                vec![
723                    "11".to_string(),
724                    "12".to_string(),
725                    "13".to_string(),
726                    "14".to_string(),
727                ],
728                vec![
729                    "21".to_string(),
730                    "22".to_string(),
731                    "23".to_string(),
732                    "24".to_string(),
733                ],
734            ],
735        );
736        assert_eq!(
737            vec![
738                "foo".to_string(),
739                "dummy".to_string(),
740                "bar2".to_string(),
741                "baz2".to_string(),
742            ],
743            hdr,
744            "headers with unknown fields in the correct sort order"
745        );
746        assert_eq!(
747            vec![
748                vec![
749                    "13".to_string(),
750                    "11".to_string(),
751                    "12".to_string(),
752                    "14".to_string(),
753                ],
754                vec![
755                    "23".to_string(),
756                    "21".to_string(),
757                    "22".to_string(),
758                    "24".to_string(),
759                ],
760            ],
761            rows,
762            "row columns sorted properly"
763        );
764    }
765
766    #[test]
767    fn test_prepare_table_duplicated_values() {
768        let out = OutputProcessor {
769            config: Some(ViewConfig {
770                default_fields: vec![
771                    "foo".to_string(),
772                    "bar".to_string(),
773                    "foo".to_string(),
774                    "baz".to_string(),
775                ],
776                ..Default::default()
777            }),
778            ..Default::default()
779        };
780        let (hdr, rows, _constraints) = out.prepare_table(
781            vec!["bar".to_string(), "foo".to_string(), "baz".to_string()],
782            vec![
783                vec!["11".to_string(), "12".to_string(), "13".to_string()],
784                vec!["21".to_string(), "22".to_string(), "23".to_string()],
785            ],
786        );
787        assert_eq!(
788            vec!["foo".to_string(), "bar".to_string(), "baz".to_string(),],
789            hdr,
790            "headers in the correct sort order"
791        );
792        assert_eq!(
793            vec![
794                vec!["12".to_string(), "11".to_string(), "13".to_string(),],
795                vec!["22".to_string(), "21".to_string(), "23".to_string(),],
796            ],
797            rows,
798            "row columns sorted properly"
799        );
800    }
801
802    #[test]
803    fn test_prepare_table_missing_default_fields() {
804        let out = OutputProcessor {
805            config: Some(ViewConfig {
806                default_fields: vec![
807                    "foo".to_string(),
808                    "bar1".to_string(),
809                    "foo1".to_string(),
810                    "baz1".to_string(),
811                ],
812                ..Default::default()
813            }),
814            ..Default::default()
815        };
816        let (hdr, rows, _constraints) = out.prepare_table(
817            vec!["bar".to_string(), "foo".to_string(), "baz".to_string()],
818            vec![
819                vec!["11".to_string(), "12".to_string(), "13".to_string()],
820                vec!["21".to_string(), "22".to_string(), "23".to_string()],
821            ],
822        );
823        assert_eq!(
824            vec!["foo".to_string(), "baz".to_string(), "bar".to_string(),],
825            hdr,
826            "headers in the correct sort order"
827        );
828        assert_eq!(
829            vec![
830                vec!["12".to_string(), "13".to_string(), "11".to_string(),],
831                vec!["22".to_string(), "23".to_string(), "21".to_string(),],
832            ],
833            rows,
834            "row columns sorted properly"
835        );
836    }
837
838    #[test]
839    fn test_output_processor_from_args_hints() {
840        let mut config_file = Builder::new().suffix(".yaml").tempfile().unwrap();
841
842        const CONFIG_DATA: &str = r#"
843            views:
844              foo:
845                default_fields: ["a", "b", "c"]
846              bar:
847                fields:
848                  - name: "b"
849                    min_width: 1
850            command_hints:
851              res:
852                cmd:
853                  - cmd_hint1
854                  - cmd_hint2
855                cmd2: [cmd2_hint1]
856              res2:
857                cmd: []
858            hints:
859              - hint1
860              - hint2
861            enable_hints: true
862        "#;
863
864        write!(config_file, "{CONFIG_DATA}").unwrap();
865
866        #[derive(Parser)]
867        struct Cli {
868            #[command(flatten)]
869            global_opts: GlobalOpts,
870            #[arg(long("cli-config"), value_parser = parse_config, default_value_t = Config::new().unwrap())]
871            config: Config,
872        }
873
874        impl CliArgs for Cli {
875            fn global_opts(&self) -> &GlobalOpts {
876                &self.global_opts
877            }
878
879            fn config(&self) -> &Config {
880                &self.config
881            }
882        }
883
884        let op = OutputProcessor::from_args(
885            &Cli::parse_from([
886                "osc",
887                "--cli-config",
888                &config_file.path().as_os_str().to_string_lossy(),
889            ]),
890            Some("res"),
891            Some("cmd"),
892        );
893        assert_eq!(
894            Some(vec![
895                "hint1".to_string(),
896                "hint2".to_string(),
897                "cmd_hint1".to_string(),
898                "cmd_hint2".to_string()
899            ]),
900            op.hints
901        );
902    }
903}