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
131impl OutputProcessor {
132 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 pub fn validate_args<C: CliArgs>(&self, _args: &C) -> Result<(), OpenStackCliError> {
171 Ok(())
172 }
173
174 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 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 if default_idx - idx_offset < headers.len() {
195 headers.swap(default_idx - idx_offset, curr_idx);
196 for row in rows.iter_mut() {
197 row.swap(default_idx - idx_offset, curr_idx);
199 }
200 }
201 } else {
202 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 idx_offset += 1;
214 }
215 }
216 }
217 }
218 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 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 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 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 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 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 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}