1use 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#[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#[derive(Default, Clone)]
55pub struct OutputProcessor {
56 pub config: Option<ViewConfig>,
58 pub target: OutputFor,
60 pub table_arrangement: TableArrangement,
62 pub fields: BTreeSet<String>,
64 pub wide: bool,
66 pub pretty: bool,
68 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 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 (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 fn field_data_json_pointer_fallback<S: AsRef<str>>(&self, field: S) -> Option<String> {
131 if !self.wide_mode() {
132 self.config.as_ref().and_then(|config| {
133 config
134 .fields
135 .iter()
136 .find(|x| x.name.to_lowercase() == field.as_ref().to_lowercase())
137 .and_then(|field_config| field_config.json_pointer_fallback.clone())
138 })
139 } else {
140 None
141 }
142 }
143}
144
145impl OutputProcessor {
146 pub fn from_args<C: CliArgs, R: AsRef<str>, A: AsRef<str>>(
148 args: &C,
149 resource_key: Option<R>,
150 action: Option<A>,
151 ) -> Self {
152 let target = match args.global_opts().output.output {
153 None => OutputFor::Human,
154 Some(OutputFormat::Wide) => OutputFor::Human,
155 _ => OutputFor::Machine,
156 };
157 let mut hints: Vec<String> = args.config().hints.clone();
158
159 if let (Some(resource_key), Some(action)) = (&resource_key, &action) {
160 args.config()
161 .command_hints
162 .get(resource_key.as_ref())
163 .and_then(|cmd_hints| {
164 cmd_hints.get(action.as_ref()).map(|val| {
165 hints.extend(val.clone());
166 })
167 });
168 }
169
170 Self {
171 config: resource_key
172 .as_ref()
173 .and_then(|val| args.config().views.get(val.as_ref()).cloned()),
174 target,
175 table_arrangement: args.global_opts().output.table_arrangement,
176 fields: BTreeSet::from_iter(args.global_opts().output.fields.iter().cloned()),
177 wide: matches!(args.global_opts().output.output, Some(OutputFormat::Wide)),
178 pretty: args.global_opts().output.pretty,
179 hints: Some(hints),
180 }
181 }
182
183 pub fn validate_args<C: CliArgs>(&self, _args: &C) -> Result<(), OpenStackCliError> {
185 Ok(())
186 }
187
188 fn prepare_table(
190 &self,
191 headers: Vec<String>,
192 data: Vec<Vec<String>>,
193 ) -> (Vec<String>, Vec<Vec<String>>, Vec<Option<ColumnConstraint>>) {
194 let mut headers = headers;
195 let mut rows = data;
196 let mut column_constrains: Vec<Option<ColumnConstraint>> = vec![None; headers.len()];
197
198 if let Some(cfg) = &self.config {
199 if headers.len() > 1 {
201 let mut idx_offset: usize = 0;
202 for (default_idx, field) in cfg.default_fields.iter().unique().enumerate() {
203 if let Some(curr_idx) = headers
204 .iter()
205 .position(|x| x.to_lowercase() == field.to_lowercase())
206 {
207 if default_idx - idx_offset < headers.len() {
209 headers.swap(default_idx - idx_offset, curr_idx);
210 for row in rows.iter_mut() {
211 row.swap(default_idx - idx_offset, curr_idx);
213 }
214 }
215 } else {
216 if default_idx - idx_offset < headers.len() {
219 let curr_hdr = headers.remove(default_idx - idx_offset);
220 headers.push(curr_hdr);
221 for row in rows.iter_mut() {
222 let curr_cell = row.remove(default_idx - idx_offset);
223 row.push(curr_cell);
224 }
225 idx_offset += 1;
228 }
229 }
230 }
231 }
232 for (idx, field) in headers.iter().enumerate() {
234 if let Some(field_config) = cfg
235 .fields
236 .iter()
237 .find(|x| x.name.to_lowercase() == field.to_lowercase())
238 {
239 let constraint = match (
240 field_config.width,
241 field_config.min_width,
242 field_config.max_width,
243 ) {
244 (Some(fixed), _, _) => {
245 Some(ColumnConstraint::Absolute(Width::Fixed(fixed as u16)))
246 }
247 (None, Some(lower), Some(upper)) => Some(ColumnConstraint::Boundaries {
248 lower: Width::Fixed(lower as u16),
249 upper: Width::Fixed(upper as u16),
250 }),
251 (None, Some(lower), None) => {
252 Some(ColumnConstraint::LowerBoundary(Width::Fixed(lower as u16)))
253 }
254 (None, None, Some(upper)) => {
255 Some(ColumnConstraint::UpperBoundary(Width::Fixed(upper as u16)))
256 }
257 _ => None,
258 };
259 column_constrains[idx] = constraint;
260 }
261 }
262 }
263 (headers, rows, column_constrains)
264 }
265
266 pub fn output_list<T>(&self, data: Vec<serde_json::Value>) -> Result<(), OpenStackCliError>
268 where
269 T: StructTable,
270 T: DeserializeOwned,
271 for<'a> &'a T: StructTable,
272 {
273 match self.target {
274 OutputFor::Human => {
275 let table: Vec<T> = serde_json::from_value(serde_json::Value::Array(data.clone()))
276 .map_err(|err| {
277 OpenStackCliError::deserialize(
278 err,
279 serde_json::to_string(&serde_json::Value::Array(
280 data.into_iter()
281 .filter(|item| {
282 serde_json::from_value::<T>(item.clone()).is_err()
283 })
284 .collect(),
285 ))
286 .unwrap_or_else(|v| format!("{v:?}")),
287 )
288 })?;
289
290 let data = structable::build_list_table(table.iter(), self);
291 let (headers, table_rows, table_constraints) = self.prepare_table(data.0, data.1);
292 let mut statuses: Vec<Option<String>> =
293 table.iter().map(|item| item.status()).collect();
294
295 statuses.resize_with(table_rows.len(), Default::default);
297
298 let rows = table_rows
299 .iter()
300 .zip(statuses.iter())
301 .map(|(data, status)| {
302 let color = match EntryStatus::from(status.as_ref()) {
303 EntryStatus::Error => Some(Color::Red),
304 EntryStatus::Pending => Some(Color::Yellow),
305 EntryStatus::Inactive => Some(Color::Cyan),
306 _ => None,
307 };
308 data.iter().map(move |cell| {
309 if let Some(color) = color {
310 Cell::new(cell).fg(color)
311 } else {
312 Cell::new(cell)
313 }
314 })
315 });
316 let mut table = Table::new();
317 table
318 .load_style(UTF8_FULL_CONDENSED)
319 .set_content_arrangement(ContentArrangement::from(self.table_arrangement))
320 .set_header(headers)
321 .add_rows(rows);
322
323 for (idx, constraint) in table_constraints.iter().enumerate() {
324 if let Some(constraint) = constraint
325 && let Some(col) = table.column_mut(idx)
326 {
327 col.set_constraint(*constraint);
328 }
329 }
330
331 println!("{table}");
332 Ok(())
333 }
334 _ => self.output_machine(serde_json::from_value(serde_json::Value::Array(data))?),
335 }
336 }
337
338 pub fn output_single<T>(&self, data: serde_json::Value) -> Result<(), OpenStackCliError>
340 where
341 T: StructTable,
342 T: DeserializeOwned,
343 {
344 match self.target {
345 OutputFor::Human => {
346 let table: T = serde_json::from_value(data.clone()).map_err(|err| {
347 OpenStackCliError::deserialize(
348 err,
349 serde_json::to_string(&data.clone()).unwrap_or_default(),
350 )
351 })?;
352
353 self.output_human(&table)
354 }
355 _ => self.output_machine(serde_json::from_value(data)?),
356 }
357 }
358
359 pub fn output_human<T: StructTable>(&self, data: &T) -> Result<(), OpenStackCliError> {
361 let (headers, table_rows) = structable::build_table(data, &OutputConfig::default());
362
363 let mut table = Table::new();
364 table
365 .load_style(UTF8_FULL_CONDENSED)
366 .set_content_arrangement(ContentArrangement::from(self.table_arrangement))
367 .set_header(headers)
368 .add_rows(table_rows);
369 println!("{table}");
370 Ok(())
371 }
372
373 pub fn output_machine(&self, data: serde_json::Value) -> Result<(), OpenStackCliError> {
376 if self.pretty {
377 serde_json::to_writer_pretty(io::stdout(), &data)?;
378 } else {
379 serde_json::to_writer(io::stdout(), &data)?;
380 }
381 io::stdout().write_all(b"\n")?;
382 Ok(())
383 }
384
385 pub fn show_command_hint(&self) -> Result<(), OpenStackCliError> {
387 if rand::random_bool(1.0 / 2.0) {
388 self.hints.as_ref().and_then(|hints| {
389 hints.choose(&mut rand::rng()).map(|hint| {
390 eprintln!(
391 "\n{} {}",
392 "Hint:".if_supports_color(Stderr, |text| text.green()),
393 hint.if_supports_color(Stderr, |text| text.blue())
394 );
395 })
396 });
397 }
398 Ok(())
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use clap::Parser;
406 use std::io::Write;
407 use tempfile::Builder;
408
409 use crate::{cli::*, config::*};
410
411 #[test]
412 fn test_wide_mode() {
413 assert!(!OutputProcessor::default().wide_mode());
414 assert!(
415 OutputProcessor {
416 wide: true,
417 ..Default::default()
418 }
419 .wide_mode()
420 );
421 assert!(
422 OutputProcessor {
423 config: Some(ViewConfig {
424 wide: Some(true),
425 ..Default::default()
426 }),
427 ..Default::default()
428 }
429 .wide_mode()
430 );
431 }
432
433 #[test]
434 fn test_field_returned_no_selection() {
435 let out = OutputProcessor::default();
436
437 assert!(
438 out.should_return_field("dummy", false),
439 "default field returned in non-wide mode with empty fields selector"
440 );
441 assert!(
442 !out.should_return_field("dummy", true),
443 "wide field not returned in non-wide mode with empty fields selector"
444 );
445
446 let out = OutputProcessor {
447 wide: true,
448 ..Default::default()
449 };
450
451 assert!(
452 out.should_return_field("dummy", false),
453 "default field returned in wide mode with empty fields selector"
454 );
455 assert!(
456 out.should_return_field("dummy", true),
457 "wide field returned in wide mode with empty fields selector"
458 );
459 }
460
461 #[test]
462 fn test_field_returned_selection_no_config() {
463 let out = OutputProcessor {
464 fields: BTreeSet::from(["foo".to_string()]),
465 ..Default::default()
466 };
467
468 assert!(
469 !out.should_return_field("dummy", false),
470 "default field not returned in non-wide mode with mismatching fields selector"
471 );
472 assert!(
473 !out.should_return_field("dummy", true),
474 "wide field not returned in non-wide mode with mismatching fields selector"
475 );
476 assert!(
477 out.should_return_field("foo", false),
478 "default field returned in non-wide mode with matching fields selector"
479 );
480 assert!(
481 out.should_return_field("foo", true),
482 "wide field returned in non-wide mode with matching fields selector"
483 );
484
485 let out = OutputProcessor {
486 fields: BTreeSet::from(["foo".to_string()]),
487 wide: true,
488 ..Default::default()
489 };
490
491 assert!(
492 !out.should_return_field("dummy", false),
493 "default field not returned in wide mode with mismatching fields selector"
494 );
495 assert!(
496 !out.should_return_field("dummy", true),
497 "wide field not returned in wide mode with mismatching fields selector"
498 );
499 }
500
501 #[test]
502 fn test_field_returned_selection_empty_config() {
503 let out = OutputProcessor {
504 config: Some(ViewConfig::default()),
505 target: OutputFor::Human,
506 table_arrangement: TableArrangement::Disabled,
507 fields: BTreeSet::new(),
508 wide: false,
509 pretty: false,
510 ..Default::default()
511 };
512
513 assert!(
514 out.should_return_field("dummy", false),
515 "default field returned in non-wide mode with mismatching fields selector and empty config"
516 );
517 assert!(
518 !out.should_return_field("dummy", true),
519 "wide field not returned in non-wide mode with mismatching fields selector and empty config"
520 );
521 }
522
523 #[test]
524 fn test_field_returned_selection_with_config_with_filters() {
525 let out = OutputProcessor {
526 config: Some(ViewConfig {
527 default_fields: vec!["foo".to_string()],
528 ..Default::default()
529 }),
530 fields: BTreeSet::from(["bar".to_string()]),
531 ..Default::default()
532 };
533
534 assert!(
535 !out.should_return_field("dummy", false),
536 "default field not returned in non-wide mode with mismatching fields selector"
537 );
538 assert!(
539 !out.should_return_field("dummy", true),
540 "wide field not returned in non-wide mode with mismatching fields selector"
541 );
542 assert!(
543 !out.should_return_field("foo", false),
544 "default field not returned in non-wide mode with mismatching fields selector"
545 );
546 assert!(
547 !out.should_return_field("foo", true),
548 "wide field not returned in non-wide mode with mismatching fields selector"
549 );
550 assert!(
551 out.should_return_field("bar", false),
552 "default field returned in non-wide mode with matching fields selector"
553 );
554 assert!(
555 out.should_return_field("bar", true),
556 "wide field returned in non-wide mode with matching fields selector"
557 );
558
559 let out = OutputProcessor {
560 config: Some(ViewConfig {
561 default_fields: vec!["foo".to_string()],
562 ..Default::default()
563 }),
564 fields: BTreeSet::from(["bar".to_string()]),
565 wide: true,
566 ..Default::default()
567 };
568
569 assert!(
570 !out.should_return_field("dummy", false),
571 "default field not returned in wide mode with mismatching fields selector"
572 );
573 assert!(
574 !out.should_return_field("dummy", true),
575 "wide field not returned in wide mode with mismatching fields selector"
576 );
577 assert!(
578 !out.should_return_field("foo", false),
579 "config field not returned in wide mode with mismatching fields selector"
580 );
581 assert!(
582 !out.should_return_field("foo", true),
583 "wide config field not returned in wide mode with mismatching fields selector"
584 );
585 assert!(
586 out.should_return_field("bar", false),
587 "default field returned in wide mode with matching fields selector"
588 );
589 assert!(
590 out.should_return_field("bar", true),
591 "wide field returned in wide mode with matching fields selector"
592 );
593 }
594
595 #[test]
596 fn test_field_returned_selection_with_config_no_filters() {
597 let out = OutputProcessor {
598 config: Some(ViewConfig {
599 default_fields: vec!["foo".to_string()],
600 ..Default::default()
601 }),
602 ..Default::default()
603 };
604
605 assert!(
606 !out.should_return_field("dummy", false),
607 "default field not returned in non-wide mode with empty fields selector and not in config"
608 );
609 assert!(
610 out.should_return_field("foo", false),
611 "default field not returned in non-wide mode with empty fields selector, but in config"
612 );
613 assert!(
614 !out.should_return_field("dummy", true),
615 "wide field not returned in non-wide mode with empty fields selector and not in config"
616 );
617 assert!(
618 out.should_return_field("foo", true),
619 "wide field returned in non-wide mode with empty fields selector, but in config"
620 );
621
622 let out = OutputProcessor {
623 config: Some(ViewConfig {
624 default_fields: vec!["foo".to_string()],
625 ..Default::default()
626 }),
627 wide: true,
628 ..Default::default()
629 };
630
631 assert!(
632 !out.should_return_field("dummy", false),
633 "default field not returned in wide mode with empty fields selector and not in config"
634 );
635 assert!(
636 out.should_return_field("foo", false),
637 "default field returned in wide mode with empty fields selector, but in config"
638 );
639 assert!(
640 out.should_return_field("dummy", true),
641 "wide field returned in wide mode with empty fields selector and not in config"
642 );
643 assert!(
644 out.should_return_field("foo", true),
645 "wide field returned in wide mode with empty fields selector, but in config"
646 );
647 }
648
649 #[test]
650 fn test_prepare_table() {
651 let out = OutputProcessor {
652 config: Some(ViewConfig {
653 default_fields: vec![
654 "foo".to_string(),
655 "bar".to_string(),
656 "baz".to_string(),
657 "dummy".to_string(),
658 ],
659 fields: vec![FieldConfig {
660 name: "bar".to_string(),
661 min_width: Some(15),
662 ..Default::default()
663 }],
664 ..Default::default()
665 }),
666 ..Default::default()
667 };
668 let (hdr, rows, constraints) = out.prepare_table(
669 vec![
670 "dummy".to_string(),
671 "bar".to_string(),
672 "foo".to_string(),
673 "baz".to_string(),
674 ],
675 vec![
676 vec![
677 "11".to_string(),
678 "12".to_string(),
679 "13".to_string(),
680 "14".to_string(),
681 ],
682 vec![
683 "21".to_string(),
684 "22".to_string(),
685 "23".to_string(),
686 "24".to_string(),
687 ],
688 ],
689 );
690 assert_eq!(
691 vec![
692 "foo".to_string(),
693 "bar".to_string(),
694 "baz".to_string(),
695 "dummy".to_string()
696 ],
697 hdr,
698 "headers in the correct sort order"
699 );
700 assert_eq!(
701 vec![
702 vec![
703 "13".to_string(),
704 "12".to_string(),
705 "14".to_string(),
706 "11".to_string(),
707 ],
708 vec![
709 "23".to_string(),
710 "22".to_string(),
711 "24".to_string(),
712 "21".to_string(),
713 ],
714 ],
715 rows,
716 "row columns sorted properly"
717 );
718 assert_eq![
719 vec![
720 None,
721 Some(ColumnConstraint::LowerBoundary(Width::Fixed(15))),
722 None,
723 None
724 ],
725 constraints
726 ];
727
728 let (hdr, rows, _constraints) = out.prepare_table(
729 vec![
730 "dummy".to_string(),
731 "bar2".to_string(),
732 "foo".to_string(),
733 "baz2".to_string(),
734 ],
735 vec![
736 vec![
737 "11".to_string(),
738 "12".to_string(),
739 "13".to_string(),
740 "14".to_string(),
741 ],
742 vec![
743 "21".to_string(),
744 "22".to_string(),
745 "23".to_string(),
746 "24".to_string(),
747 ],
748 ],
749 );
750 assert_eq!(
751 vec![
752 "foo".to_string(),
753 "dummy".to_string(),
754 "bar2".to_string(),
755 "baz2".to_string(),
756 ],
757 hdr,
758 "headers with unknown fields in the correct sort order"
759 );
760 assert_eq!(
761 vec![
762 vec![
763 "13".to_string(),
764 "11".to_string(),
765 "12".to_string(),
766 "14".to_string(),
767 ],
768 vec![
769 "23".to_string(),
770 "21".to_string(),
771 "22".to_string(),
772 "24".to_string(),
773 ],
774 ],
775 rows,
776 "row columns sorted properly"
777 );
778 }
779
780 #[test]
781 fn test_prepare_table_duplicated_values() {
782 let out = OutputProcessor {
783 config: Some(ViewConfig {
784 default_fields: vec![
785 "foo".to_string(),
786 "bar".to_string(),
787 "foo".to_string(),
788 "baz".to_string(),
789 ],
790 ..Default::default()
791 }),
792 ..Default::default()
793 };
794 let (hdr, rows, _constraints) = out.prepare_table(
795 vec!["bar".to_string(), "foo".to_string(), "baz".to_string()],
796 vec![
797 vec!["11".to_string(), "12".to_string(), "13".to_string()],
798 vec!["21".to_string(), "22".to_string(), "23".to_string()],
799 ],
800 );
801 assert_eq!(
802 vec!["foo".to_string(), "bar".to_string(), "baz".to_string(),],
803 hdr,
804 "headers in the correct sort order"
805 );
806 assert_eq!(
807 vec![
808 vec!["12".to_string(), "11".to_string(), "13".to_string(),],
809 vec!["22".to_string(), "21".to_string(), "23".to_string(),],
810 ],
811 rows,
812 "row columns sorted properly"
813 );
814 }
815
816 #[test]
817 fn test_prepare_table_missing_default_fields() {
818 let out = OutputProcessor {
819 config: Some(ViewConfig {
820 default_fields: vec![
821 "foo".to_string(),
822 "bar1".to_string(),
823 "foo1".to_string(),
824 "baz1".to_string(),
825 ],
826 ..Default::default()
827 }),
828 ..Default::default()
829 };
830 let (hdr, rows, _constraints) = out.prepare_table(
831 vec!["bar".to_string(), "foo".to_string(), "baz".to_string()],
832 vec![
833 vec!["11".to_string(), "12".to_string(), "13".to_string()],
834 vec!["21".to_string(), "22".to_string(), "23".to_string()],
835 ],
836 );
837 assert_eq!(
838 vec!["foo".to_string(), "baz".to_string(), "bar".to_string(),],
839 hdr,
840 "headers in the correct sort order"
841 );
842 assert_eq!(
843 vec![
844 vec!["12".to_string(), "13".to_string(), "11".to_string(),],
845 vec!["22".to_string(), "23".to_string(), "21".to_string(),],
846 ],
847 rows,
848 "row columns sorted properly"
849 );
850 }
851
852 #[test]
853 fn test_output_processor_from_args_hints() {
854 let mut config_file = Builder::new().suffix(".yaml").tempfile().unwrap();
855
856 const CONFIG_DATA: &str = r#"
857 views:
858 foo:
859 default_fields: ["a", "b", "c"]
860 bar:
861 fields:
862 - name: "b"
863 min_width: 1
864 command_hints:
865 res:
866 cmd:
867 - cmd_hint1
868 - cmd_hint2
869 cmd2: [cmd2_hint1]
870 res2:
871 cmd: []
872 hints:
873 - hint1
874 - hint2
875 enable_hints: true
876 "#;
877
878 write!(config_file, "{CONFIG_DATA}").unwrap();
879
880 #[derive(Parser)]
881 struct Cli {
882 #[command(flatten)]
883 global_opts: GlobalOpts,
884 #[arg(long("cli-config"), value_parser = parse_config, default_value_t = Config::new().unwrap())]
885 config: Config,
886 }
887
888 impl CliArgs for Cli {
889 fn global_opts(&self) -> &GlobalOpts {
890 &self.global_opts
891 }
892
893 fn config(&self) -> &Config {
894 &self.config
895 }
896 }
897
898 let op = OutputProcessor::from_args(
899 &Cli::parse_from([
900 "osc",
901 "--cli-config",
902 &config_file.path().as_os_str().to_string_lossy(),
903 ]),
904 Some("res"),
905 Some("cmd"),
906 );
907 assert_eq!(
908 Some(vec![
909 "hint1".to_string(),
910 "hint2".to_string(),
911 "cmd_hint1".to_string(),
912 "cmd_hint2".to_string()
913 ]),
914 op.hints
915 );
916 }
917}