use std::io::Write; use anyhow::{bail, Error}; use serde_json::Value; use unicode_width::UnicodeWidthStr; use proxmox_lang::c_str; use proxmox_schema::{ObjectSchemaType, Schema, SchemaPropertyEntry}; /// allows to configure the default output fromat using environment vars pub const ENV_VAR_PROXMOX_OUTPUT_FORMAT: &str = "PROXMOX_OUTPUT_FORMAT"; /// if set, supress borders (and headers) when printing tables pub const ENV_VAR_PROXMOX_OUTPUT_NO_BORDER: &str = "PROXMOX_OUTPUT_NO_BORDER"; /// if set, supress headers when printing tables pub const ENV_VAR_PROXMOX_OUTPUT_NO_HEADER: &str = "PROXMOX_OUTPUT_NO_HEADER"; /// Helper to get output format from parameters or environment pub fn get_output_format(param: &Value) -> String { let mut output_format = None; if let Some(format) = param["output-format"].as_str() { output_format = Some(format.to_owned()); } else if let Ok(format) = std::env::var(ENV_VAR_PROXMOX_OUTPUT_FORMAT) { output_format = Some(format); } output_format.unwrap_or_else(|| String::from("text")) } /// Helper to get output format from parameters or environment /// and removing from parameters pub fn extract_output_format(param: &mut Value) -> String { let output_format = get_output_format(param); if let Some(param) = param.as_object_mut() { param.remove("output-format"); } output_format } /// Helper to get TableFormatOptions with default from environment pub fn default_table_format_options() -> TableFormatOptions { let no_border = std::env::var(ENV_VAR_PROXMOX_OUTPUT_NO_BORDER) .ok() .is_some(); let no_header = std::env::var(ENV_VAR_PROXMOX_OUTPUT_NO_HEADER) .ok() .is_some(); TableFormatOptions::new() .noborder(no_border) .noheader(no_header) } /// Render function /// /// Should convert the json `value` into a text string. `record` points to /// the surrounding data object. pub type RenderFunction = fn(/* value: */ &Value, /* record: */ &Value) -> Result; fn data_to_text(data: &Value, schema: &Schema) -> Result { if data.is_null() { return Ok(String::new()); } match schema { Schema::Null => { // makes no sense to display Null columns bail!("internal error"); } Schema::Boolean(_) => match data.as_bool() { Some(value) => Ok(String::from(if value { "1" } else { "0" })), None => bail!("got unexpected data (expected bool)."), }, Schema::Integer(_) => match data.as_i64() { Some(value) => Ok(format!("{}", value)), None => bail!("got unexpected data (expected integer)."), }, Schema::Number(_) => match data.as_f64() { Some(value) => Ok(format!("{}", value)), None => bail!("got unexpected data (expected number)."), }, Schema::String(_) => match data.as_str() { Some(value) => Ok(value.to_string()), None => bail!("got unexpected data (expected string)."), }, Schema::Object(_) => Ok(data.to_string()), Schema::Array(_) => Ok(data.to_string()), Schema::AllOf(_) => Ok(data.to_string()), } } struct TableBorders { column_separator: char, top: String, head: String, middle: String, bottom: String, } impl TableBorders { fn new(column_widths: I, ascii_delimiters: bool) -> Self where I: Iterator, { let mut top = String::new(); let mut head = String::new(); let mut middle = String::new(); let mut bottom = String::new(); let column_separator = if ascii_delimiters { '|' } else { '│' }; for (i, column_width) in column_widths.enumerate() { if ascii_delimiters { top.push('+'); head.push('+'); middle.push('+'); bottom.push('+'); } else if i == 0 { top.push('┌'); head.push('╞'); middle.push('├'); bottom.push('└'); } else { top.push('┬'); head.push('╪'); middle.push('┼'); bottom.push('┴'); } for _j in 0..(column_width + 2) { if ascii_delimiters { top.push('='); head.push('='); middle.push('-'); bottom.push('='); } else { top.push('─'); head.push('═'); middle.push('─'); bottom.push('─'); } } } if ascii_delimiters { top.push('+'); head.push('+'); middle.push('+'); bottom.push('+'); } else { top.push('┐'); head.push('╡'); middle.push('┤'); bottom.push('┘'); } Self { column_separator, top, head, middle, bottom, } } } /// Table Column configuration /// /// This structure can be used to set additional rendering information for a table column. pub struct ColumnConfig { pub name: String, pub header: Option, pub right_align: Option, pub renderer: Option, } impl ColumnConfig { pub fn new(name: &str) -> Self { Self { name: name.to_string(), header: None, right_align: None, renderer: None, } } pub fn right_align(mut self, right_align: bool) -> Self { self.right_align = Some(right_align); self } pub fn renderer(mut self, renderer: RenderFunction) -> Self { self.renderer = Some(renderer); self } pub fn header>(mut self, header: S) -> Self { self.header = Some(header.into()); self } } /// Get the current size of the terminal (for stdout). /// # Safety /// /// uses unsafe call to tty_ioctl, see man tty_ioctl(2). fn stdout_terminal_size() -> (usize, usize) { let mut winsize = libc::winsize { ws_row: 0, ws_col: 0, ws_xpixel: 0, ws_ypixel: 0, }; unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut winsize) }; (winsize.ws_row as usize, winsize.ws_col as usize) } /// Table formatter configuration #[derive(Default)] pub struct TableFormatOptions { /// Can be used to sort after a specific columns, if it isn't set /// we sort after the leftmost column (with no undef value in /// $data) this can be turned off by passing and empty array. The /// boolean argument specifies the sort order (false => ASC, true => DESC) pub sortkeys: Option>, /// Print without asciiart border. pub noborder: bool, /// Print without table header. pub noheader: bool, /// Limit output width. pub columns: Option, /// Use ascii characters for table delimiters (instead of utf8). pub ascii_delimiters: bool, /// Comumn configurations pub column_config: Vec, } impl TableFormatOptions { /// Create a new Instance with reasonable defaults for terminal output /// /// This tests if stdout is a TTY and sets the columns to the terminal width, /// and sets ascii_delimiters to true If the locale CODESET is not UTF-8. pub fn new() -> Self { let mut me = Self::default(); let is_tty = unsafe { libc::isatty(libc::STDOUT_FILENO) == 1 }; if is_tty { let (_rows, columns) = stdout_terminal_size(); if columns > 0 { me.columns = Some(columns); } } let empty_cstr = c_str!(""); use std::ffi::CStr; let encoding = unsafe { libc::setlocale(libc::LC_CTYPE, empty_cstr.as_ptr()); CStr::from_ptr(libc::nl_langinfo(libc::CODESET)) }; if encoding != c_str!("UTF-8") { me.ascii_delimiters = true; } me } pub fn disable_sort(mut self) -> Self { self.sortkeys = Some(Vec::new()); self } pub fn sortby>(mut self, key: S, sort_desc: bool) -> Self { let key = key.into(); match self.sortkeys { None => { self.sortkeys = Some(vec![(key, sort_desc)]); } Some(ref mut list) => { list.push((key, sort_desc)); } } self } pub fn noborder(mut self, noborder: bool) -> Self { self.noborder = noborder; self } pub fn noheader(mut self, noheader: bool) -> Self { self.noheader = noheader; self } pub fn ascii_delimiters(mut self, ascii_delimiters: bool) -> Self { self.ascii_delimiters = ascii_delimiters; self } pub fn columns(mut self, columns: Option) -> Self { self.columns = columns; self } pub fn column_config(mut self, column_config: Vec) -> Self { self.column_config = column_config; self } /// Add a single column configuration pub fn column(mut self, column_config: ColumnConfig) -> Self { self.column_config.push(column_config); self } fn lookup_column_info( &self, column_name: &str, ) -> (String, Option, Option) { let mut renderer = None; let header; let mut right_align = None; if let Some(column_config) = self.column_config.iter().find(|v| v.name == *column_name) { renderer = column_config.renderer; right_align = column_config.right_align; if let Some(ref h) = column_config.header { header = h.to_owned(); } else { header = column_name.to_string(); } } else { header = column_name.to_string(); } (header, right_align, renderer) } } struct TableCell { lines: Vec, } struct TableColumn { cells: Vec, width: usize, right_align: bool, } fn format_table( output: W, list: &mut Vec, schema: &dyn ObjectSchemaType, options: &TableFormatOptions, ) -> Result<(), Error> { let properties_to_print = if options.column_config.is_empty() { extract_properties_to_print(schema.properties()) } else { options .column_config .iter() .map(|v| v.name.clone()) .collect() }; let column_count = properties_to_print.len(); if column_count == 0 { return Ok(()); }; let sortkeys = if let Some(ref sortkeys) = options.sortkeys { sortkeys.clone() } else { vec![(properties_to_print[0].clone(), false)] // leftmost, ASC }; let mut sortinfo = Vec::new(); for (sortkey, sort_order) in sortkeys { let (_optional, sort_prop_schema) = match schema.lookup(&sortkey) { Some(tup) => tup, None => bail!("property {} does not exist in schema.", sortkey), }; let numeric_sort = matches!(sort_prop_schema, Schema::Integer(_) | Schema::Number(_)); sortinfo.push((sortkey, sort_order, numeric_sort)); } use std::cmp::Ordering; list.sort_unstable_by(move |a, b| { for &(ref sortkey, sort_desc, numeric) in &sortinfo { let res = if numeric { let (v1, v2) = if sort_desc { (b[&sortkey].as_f64(), a[&sortkey].as_f64()) } else { (a[&sortkey].as_f64(), b[&sortkey].as_f64()) }; match (v1, v2) { (None, None) => Ordering::Greater, (Some(_), None) => Ordering::Greater, (None, Some(_)) => Ordering::Less, (Some(a), Some(b)) => { #[allow(clippy::if_same_then_else)] if a.is_nan() { Ordering::Greater } else if b.is_nan() { Ordering::Less } else if a < b { Ordering::Less } else if a > b { Ordering::Greater } else { Ordering::Equal } } } } else { let (v1, v2) = if sort_desc { (b[sortkey].as_str(), a[sortkey].as_str()) } else { (a[sortkey].as_str(), b[sortkey].as_str()) }; v1.cmp(&v2) }; if res != Ordering::Equal { return res; } } Ordering::Equal }); let mut tabledata: Vec = Vec::new(); let mut column_names = Vec::new(); for name in properties_to_print.iter() { let (_optional, prop_schema) = match schema.lookup(name) { Some(tup) => tup, None => bail!("property {} does not exist in schema.", name), }; let is_numeric = matches!( prop_schema, Schema::Integer(_) | Schema::Number(_) | Schema::Boolean(_) ); let (header, right_align, renderer) = options.lookup_column_info(name); let right_align = right_align.unwrap_or(is_numeric); let mut max_width = if options.noheader || options.noborder { 0 } else { header.chars().count() }; column_names.push(header); let mut cells = Vec::new(); for entry in list.iter() { let result = if let Some(renderer) = renderer { (renderer)(&entry[name], entry) } else { data_to_text(&entry[name], prop_schema) }; let text = match result { Ok(text) => text, Err(err) => bail!("unable to format property {} - {}", name, err), }; let lines: Vec = text .lines() .map(|line| { let width = UnicodeWidthStr::width(line); if width > max_width { max_width = width; } line.to_string() }) .collect(); cells.push(TableCell { lines }); } tabledata.push(TableColumn { cells, width: max_width, right_align, }); } render_table(output, &tabledata, &column_names, options) } fn render_table( mut output: W, tabledata: &[TableColumn], column_names: &[String], options: &TableFormatOptions, ) -> Result<(), Error> { let mut write_line = |line: &str| -> Result<(), Error> { if let Some(columns) = options.columns { let line: String = line.chars().take(columns).collect(); output.write_all(line.as_bytes())?; } else { output.write_all(line.as_bytes())?; } output.write_all(b"\n")?; Ok(()) }; let column_widths = tabledata.iter().map(|d| d.width); let borders = TableBorders::new(column_widths, options.ascii_delimiters); if !options.noborder { write_line(&borders.top)?; } let mut header = String::new(); for (i, name) in column_names.iter().enumerate() { let column = &tabledata[i]; header.push(borders.column_separator); header.push(' '); if column.right_align { header.push_str(&format!("{:>width$}", name, width = column.width)); } else { header.push_str(&format!("{: max_lines { max_lines = lines.len(); } } for line_nr in 0..max_lines { let mut text = String::new(); let empty_string = String::new(); for (i, _name) in column_names.iter().enumerate() { let column = &tabledata[i]; let lines = &column.cells[pos].lines; let line = lines.get(line_nr).unwrap_or(&empty_string); if options.noborder { if i > 0 { text.push(' '); } } else { text.push(borders.column_separator); text.push(' '); } let padding = column.width - UnicodeWidthStr::width(line.as_str()); if column.right_align { text.push_str(&format!("{:>width$}{}", "", line, width = padding)); } else { text.push_str(&format!("{}{:( output: W, data: &Value, schema: &dyn ObjectSchemaType, options: &TableFormatOptions, ) -> Result<(), Error> { let properties_to_print = if options.column_config.is_empty() { extract_properties_to_print(schema.properties()) } else { options .column_config .iter() .map(|v| v.name.clone()) .collect() }; let row_count = properties_to_print.len(); if row_count == 0 { return Ok(()); }; const NAME_TITLE: &str = "Name"; const VALUE_TITLE: &str = "Value"; let mut max_name_width = if options.noheader || options.noborder { 0 } else { NAME_TITLE.len() }; let mut max_value_width = if options.noheader || options.noborder { 0 } else { VALUE_TITLE.len() }; let column_names = vec![NAME_TITLE.to_string(), VALUE_TITLE.to_string()]; let mut name_cells = Vec::new(); let mut value_cells = Vec::new(); let mut all_right_aligned = true; for name in properties_to_print.iter() { let (optional, prop_schema) = match schema.lookup(name) { Some(tup) => tup, None => bail!("property {} does not exist in schema.", name), }; let is_numeric = matches!( prop_schema, Schema::Integer(_) | Schema::Number(_) | Schema::Boolean(_) ); let (header, right_align, renderer) = options.lookup_column_info(name); let right_align = right_align.unwrap_or(is_numeric); if !right_align { all_right_aligned = false; } if optional { if let Some(object) = data.as_object() { if object.get(name).is_none() { continue; } } } let header_width = header.chars().count(); if header_width > max_name_width { max_name_width = header_width; } name_cells.push(TableCell { lines: vec![header], }); let result = if let Some(renderer) = renderer { (renderer)(&data[name], data) } else { data_to_text(&data[name], prop_schema) }; let text = match result { Ok(text) => text, Err(err) => bail!("unable to format property {} - {}", name, err), }; let lines: Vec = text .lines() .map(|line| { let width = line.chars().count(); if width > max_value_width { max_value_width = width; } line.to_string() }) .collect(); value_cells.push(TableCell { lines }); } let name_column = TableColumn { cells: name_cells, width: max_name_width, right_align: false, }; let value_column = TableColumn { cells: value_cells, width: max_value_width, right_align: all_right_aligned, }; let tabledata = vec![name_column, value_column]; render_table(output, &tabledata, &column_names, options) } fn extract_properties_to_print(properties: I) -> Vec where I: Iterator, { let mut result = Vec::new(); let mut opt_properties = Vec::new(); for (name, optional, _prop_schema) in properties { if *optional { opt_properties.push(name.to_string()); } else { result.push(name.to_string()); } } result.extend(opt_properties); result } /// Format data using TableFormatOptions pub fn value_to_text( output: W, data: &mut Value, schema: &Schema, options: &TableFormatOptions, ) -> Result<(), Error> { match schema { Schema::Null => { if *data != Value::Null { bail!("got unexpected data (expected null)."); } } Schema::Boolean(_boolean_schema) => { unimplemented!(); } Schema::Integer(_integer_schema) => { unimplemented!(); } Schema::Number(_number_schema) => { unimplemented!(); } Schema::String(_string_schema) => { unimplemented!(); } Schema::Object(object_schema) => { format_object(output, data, object_schema, options)?; } Schema::Array(array_schema) => { let list = match data.as_array_mut() { Some(list) => list, None => bail!("got unexpected data (expected array)."), }; if list.is_empty() { return Ok(()); } match array_schema.items { Schema::Object(object_schema) => { format_table(output, list, object_schema, options)?; } Schema::AllOf(all_of_schema) => { format_table(output, list, all_of_schema, options)?; } _ => { unimplemented!(); } } } Schema::AllOf(all_of_schema) => { format_object(output, data, all_of_schema, options)?; } } Ok(()) }