geneos_toolkit/
dataview.rs

1use std::collections::HashMap;
2use std::fmt;
3use thiserror::Error;
4
5#[derive(Error, Debug)]
6pub enum DataviewError {
7    #[error("The Dataview must have a row header")]
8    MissingRowHeader,
9    #[error("The Dataview must have at least one value")]
10    MissingValue,
11}
12
13/// A Geneos Dataview object.
14///
15/// This struct represents a Dataview, which is a structured representation of data
16/// with a row header, headlines, and values.
17///
18/// Example Dataview format:
19/// ```text
20/// row_header,column1,column2
21/// <!>headline1,value1
22/// <!>headline2,value2
23/// row1,value1,value2
24/// row2,value1,value2
25/// ```
26///
27/// Example with data:
28/// ```text
29/// cpu,percentUtilisation,percentIdle
30/// <!>numOnlineCpus,2
31/// <!>loadAverage1Min,0.32
32/// <!>loadAverage5Min,0.45
33/// <!>loadAverage15Min,0.38
34/// <!>HyperThreadingStatus,ENABLED
35/// Average_cpu,3.75 %,96.25 %
36/// cpu_0,3.25 %,96.75 %
37/// cpu_0_logical#1,2.54 %,97.46 %
38/// cpu_0_logical#2,2.54 %,97.46 %
39/// ```
40#[derive(Debug, Default, Clone, Eq, PartialEq)]
41pub struct Dataview {
42    row_header: String,
43    headlines: HashMap<String, String>,
44    headline_order: Vec<String>,
45    values: HashMap<(String, String), String>,
46    column_order: Vec<String>,
47    row_order: Vec<String>,
48}
49
50impl Dataview {
51    pub fn row_header(&self) -> &str {
52        &self.row_header
53    }
54
55    pub fn headline(&self, key: &str) -> Option<&String> {
56        self.headlines.get(key)
57    }
58
59    pub fn headline_order(&self) -> &[String] {
60        &self.headline_order
61    }
62
63    pub fn value(&self, row: &str, column: &str) -> Option<&String> {
64        self.values.get(&(row.to_string(), column.to_string()))
65    }
66
67    pub fn column_order(&self) -> &[String] {
68        &self.column_order
69    }
70
71    pub fn row_order(&self) -> &[String] {
72        &self.row_order
73    }
74}
75
76fn escape_commas(s: &str) -> String {
77    s.replace(",", "\\,")
78}
79
80fn write_header_row(
81    f: &mut fmt::Formatter<'_>,
82    row_header: &str,
83    columns: &[String],
84) -> fmt::Result {
85    write!(f, "{}", escape_commas(row_header))?;
86    for col in columns {
87        write!(f, ",{}", escape_commas(col))?;
88    }
89    writeln!(f)
90}
91
92fn write_headlines(
93    f: &mut fmt::Formatter<'_>,
94    headline_order: &[String],
95    headlines: &HashMap<String, String>,
96) -> fmt::Result {
97    for name in headline_order {
98        if let Some(value) = headlines.get(name) {
99            writeln!(f, "<!>{},{}", escape_commas(name), escape_commas(value))?;
100        }
101    }
102    Ok(())
103}
104
105fn write_data_rows(
106    f: &mut fmt::Formatter<'_>,
107    rows: &[String],
108    columns: &[String],
109    values: &HashMap<(String, String), String>,
110) -> fmt::Result {
111    let number_of_rows = rows.len();
112    for (i, row) in rows.iter().enumerate() {
113        write!(f, "{}", escape_commas(row))?;
114        for col in columns {
115            write!(f, ",")?;
116            if let Some(value) = values.get(&(row.to_string(), col.to_string())) {
117                write!(f, "{}", escape_commas(value))?;
118            }
119        }
120
121        // Only write newline if this isn't the last row
122        if i < number_of_rows - 1 {
123            writeln!(f)?;
124        }
125    }
126
127    Ok(())
128}
129
130impl fmt::Display for Dataview {
131    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132        write_header_row(f, &self.row_header, &self.column_order)?;
133        write_headlines(f, &self.headline_order, &self.headlines)?;
134        write_data_rows(f, &self.row_order, &self.column_order, &self.values)
135    }
136}
137
138impl Dataview {
139    /// Creates a new DataviewBuilder instance
140    ///
141    /// This allows users to create a Dataview without explicitly importing DataviewBuilder
142    ///
143    /// # Example
144    ///
145    /// ```
146    /// use geneos_toolkit::prelude::*;
147    /// let dataview = Dataview::builder()
148    ///     .set_row_header("ID")
149    ///     .add_headline("Total", "42")
150    ///     .add_value("1", "Name", "Alice")
151    ///     .build();
152    /// ```
153    pub fn builder() -> DataviewBuilder {
154        DataviewBuilder::new()
155    }
156}
157
158/// A Builder for the `Dataview` struct.
159#[derive(Debug, Default, Clone)]
160pub struct DataviewBuilder {
161    row_header: Option<String>,
162    headlines: Option<HashMap<String, String>>,
163    values: Option<HashMap<(String, String), String>>,
164    headline_order: Vec<String>, // for the purpose of ordering the headlines
165    column_order: Vec<String>,   // for the purpose of ordering the columns
166    row_order: Vec<String>,      // for the purpose of ordering the rows
167}
168
169impl DataviewBuilder {
170    pub fn new() -> Self {
171        Self::default()
172    }
173
174    pub fn set_row_header(mut self, row_header: &str) -> Self {
175        self.row_header = Some(row_header.to_string());
176        self
177    }
178
179    pub fn add_headline<T: ToString>(mut self, key: &str, value: T) -> Self {
180        let mut headlines: HashMap<String, String> = self.headlines.unwrap_or_default();
181
182        let key_string = key.to_string();
183        if !self.headline_order.contains(&key_string) {
184            self.headline_order.push(key_string.clone());
185        }
186
187        headlines.insert(key_string, value.to_string());
188        self.headlines = Some(headlines);
189        self
190    }
191
192    pub fn add_value<T: ToString>(mut self, row: &str, column: &str, value: T) -> Self {
193        let mut values: HashMap<(String, String), String> = self.values.unwrap_or_default();
194
195        // Track columns in order of insertion (if new)
196        let column_string = column.to_string();
197        if !self.column_order.contains(&column_string) {
198            self.column_order.push(column_string.clone());
199        }
200
201        // Track rows in order of insertion (if new)
202        let row_string = row.to_string();
203        if !self.row_order.contains(&row_string) {
204            self.row_order.push(row_string.clone());
205        }
206
207        values.insert((row_string, column_string), value.to_string());
208        self.values = Some(values);
209        self
210    }
211
212    /// Builds the `Dataview`, consuming the builder.
213    ///
214    /// The `row_header` must be set before the build or a panic will occur.
215    /// There must be at least one value.
216    /// Headlines are optional.
217    ///
218    /// The order of the columns and rows is determined by the order in which they are added through
219    /// values using the `add_value` method.
220    ///
221    /// The order of headlines is determined by the order in which they are added through the
222    /// `add_headline` method.
223    ///
224    /// Example:
225    /// ```rust
226    /// use geneos_toolkit::prelude::*;
227    ///
228    /// let view: Dataview = Dataview::builder()
229    ///     .set_row_header("Name")
230    ///     .add_headline("AverageAge", "30")
231    ///     .add_value("Anna", "Age", "30")
232    ///     .add_value("Bertil", "Age", "20")
233    ///     .add_value("Caesar", "Age", "40")
234    ///     .build()
235    ///     .unwrap();
236    ///
237    /// ```
238    pub fn build(self) -> Result<Dataview, DataviewError> {
239        let row_header = self.row_header.ok_or(DataviewError::MissingRowHeader)?;
240
241        let values = self.values.ok_or(DataviewError::MissingValue)?;
242
243        Ok(Dataview {
244            row_header,
245            headlines: self.headlines.unwrap_or_default(),
246            headline_order: self.headline_order,
247            values,
248            column_order: self.column_order,
249            row_order: self.row_order,
250        })
251    }
252}
253
254/// Prints the result of a Dataview operation and exits the program.
255///
256/// # Arguments
257/// - `dataview`: The `Result` of a Dataview operation, holding either a `Dataview` or a `DataviewError`.
258///
259/// # Returns
260/// - Exits the program with a status code of 0 if successful, or 1 if an error occurred.
261///
262/// # Example
263/// ```rust
264/// use geneos_toolkit::prelude::*;
265///
266/// let dataview = Dataview::builder()
267///    .set_row_header("ID")
268///    .add_headline("Total", "42")
269///    .add_value("1", "Name", "Alice")
270///    .build();
271///
272/// print_result_and_exit(dataview)
273/// ```
274pub fn print_result_and_exit(dataview: Result<Dataview, DataviewError>) -> ! {
275    match dataview {
276        Ok(v) => {
277            println!("{v}");
278            std::process::exit(0)
279        }
280        Err(e) => {
281            eprintln!("ERROR: {e}");
282            std::process::exit(1)
283        }
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use pretty_assertions::assert_eq;
291
292    /// Helper function to create a basic dataview for testing
293    fn create_basic_dataview() -> Result<Dataview, DataviewError> {
294        DataviewBuilder::new()
295            .set_row_header("ID")
296            .add_headline("AverageAge", "30")
297            .add_value("1", "Name", "Alice")
298            .add_value("1", "Age", "30")
299            .build()
300    }
301
302    #[test]
303    fn test_dataview_builder_single_row() -> Result<(), DataviewError> {
304        let dataview = create_basic_dataview()?;
305
306        // Test row header
307        assert_eq!(dataview.row_header(), "ID");
308
309        // Test headline
310        assert_eq!(dataview.headline("AverageAge"), Some(&"30".to_string()));
311
312        // Test values
313        assert_eq!(dataview.value("1", "Name"), Some(&"Alice".to_string()));
314        assert_eq!(dataview.value("1", "Age"), Some(&"30".to_string()));
315
316        // Test structure
317        assert_eq!(dataview.row_order().len(), 1);
318        assert_eq!(dataview.column_order().len(), 2);
319        assert!(dataview.column_order().contains(&"Name".to_string()));
320        assert!(dataview.column_order().contains(&"Age".to_string()));
321
322        Ok(())
323    }
324
325    #[test]
326    fn test_dataview_display_format() -> Result<(), DataviewError> {
327        // Test basic display
328        let dataview = create_basic_dataview()?;
329        assert_eq!(
330            dataview.to_string(),
331            "\
332ID,Name,Age
333<!>AverageAge,30
3341,Alice,30"
335        );
336
337        // Test multiple rows and columns
338        let multi_row_dataview = DataviewBuilder::new()
339            .set_row_header("id")
340            // Ensure that headlines appear in the order in which they were added.
341            .add_headline("Baz", "Foo")
342            .add_headline("AlertDetails", "this is red alert")
343            .add_value("001", "name", "agila")
344            .add_value("001", "status", "up")
345            .add_value("001", "Value", "97")
346            .add_value("002", "name", "lawin")
347            .add_value("002", "status", "down")
348            .add_value("002", "Value", "85")
349            .build()?;
350
351        let expected_output = "\
352id,name,status,Value
353<!>Baz,Foo
354<!>AlertDetails,this is red alert
355001,agila,up,97
356002,lawin,down,85";
357
358        assert_eq!(multi_row_dataview.to_string(), expected_output);
359
360        Ok(())
361    }
362
363    #[test]
364    fn test_special_characters_escaping() -> Result<(), DataviewError> {
365        // Test comma escaping in row header, columns, values
366        let dataview = DataviewBuilder::new()
367            .set_row_header("queue,id")
368            .add_value("queue3", "number,code", "7,331")
369            .add_value("queue3", "count", "45,000")
370            .add_value("queue3", "ratio", "0.16")
371            .add_value("queue3", "status", "online")
372            .build()?;
373
374        let expected_output = "\
375queue\\,id,number\\,code,count,ratio,status
376queue3,7\\,331,45\\,000,0.16,online";
377
378        assert_eq!(dataview.to_string(), expected_output);
379
380        // Test other special characters
381        let dataview_special = DataviewBuilder::new()
382            .set_row_header("special")
383            .add_headline("special,headline", "headline value with, comma")
384            .add_value("special_case", "state", "testing: \"quotes\" & <symbols>")
385            .add_value("special_case", "data", "multi-line\ntext")
386            .build()?;
387
388        let output = dataview_special.to_string();
389        assert!(output.contains("special"));
390        assert!(output.contains("<!>special\\,headline,headline value with\\, comma"));
391        assert!(output.contains("testing: \"quotes\" & <symbols>"));
392        assert!(output.contains("multi-line\ntext"));
393
394        Ok(())
395    }
396
397    #[test]
398    fn test_empty_and_missing_values() -> Result<(), DataviewError> {
399        // Test with some missing values
400        let dataview = DataviewBuilder::new()
401            .set_row_header("item")
402            .add_value("item1", "col1", "value1")
403            .add_value("item1", "col2", "value2")
404            .add_value("item2", "col1", "value3")
405            // Deliberately missing item2/col2
406            .add_value("item3", "col3", "value4") // New column not in other rows
407            .build()?;
408
409        let output = dataview.to_string();
410
411        // Verify output format has empty cells where expected
412        assert!(output.contains("item1,value1,value2,"));
413        assert!(output.contains("item2,value3,,"));
414        assert!(output.contains("item3,,,value4"));
415
416        // Test accessing missing values
417        assert_eq!(dataview.value("item2", "col2"), None);
418        assert_eq!(dataview.value("nonexistent", "col1"), None);
419
420        Ok(())
421    }
422
423    #[test]
424    fn test_dataview_complex() -> Result<(), DataviewError> {
425        // This test creates a more realistic Dataview with many rows, columns and headlines
426        let dataview = DataviewBuilder::new()
427            .set_row_header("cpu")
428            // Add multiple headlines
429            .add_headline("numOnlineCpus", "4")
430            .add_headline("loadAverage1Min", "0.32")
431            .add_headline("loadAverage5Min", "0.45")
432            .add_headline("loadAverage15Min", "0.38")
433            .add_headline("HyperThreadingStatus", "ENABLED")
434            // CPU average row
435            .add_value("Average_cpu", "percentUtilisation", "3.75 %")
436            .add_value("Average_cpu", "percentUserTime", "2.15 %")
437            .add_value("Average_cpu", "percentKernelTime", "1.25 %")
438            .add_value("Average_cpu", "percentIdle", "96.25 %")
439            // CPU 0 with values in all columns
440            .add_value("cpu_0", "type", "GenuineIntel Intel(R)")
441            .add_value("cpu_0", "state", "on-line")
442            .add_value("cpu_0", "clockSpeed", "2500.00 MHz")
443            .add_value("cpu_0", "percentUtilisation", "3.25 %")
444            .add_value("cpu_0", "percentUserTime", "1.95 %")
445            .add_value("cpu_0", "percentKernelTime", "1.30 %")
446            .add_value("cpu_0", "percentIdle", "96.75 %")
447            // CPU 1 with same structure
448            .add_value("cpu_1", "type", "GenuineIntel Intel(R)")
449            .add_value("cpu_1", "state", "on-line")
450            .add_value("cpu_1", "clockSpeed", "2500.00 MHz")
451            .add_value("cpu_1", "percentUtilisation", "4.25 %")
452            .add_value("cpu_1", "percentUserTime", "2.35 %")
453            .add_value("cpu_1", "percentKernelTime", "1.20 %")
454            .add_value("cpu_1", "percentIdle", "95.75 %")
455            // cpu_2 with a comma in one value (needs escaping) and some missing values
456            .add_value("cpu_2", "type", "GenuineIntel, Intel(R)")
457            .add_value("cpu_2", "state", "on-line")
458            .add_value("cpu_2", "clockSpeed", "2,500.00 MHz")
459            // Add another logical CPU
460            .add_value("cpu_0_logical#1", "type", "logical")
461            .add_value("cpu_0_logical#1", "state", "on-line")
462            .add_value("cpu_0_logical#1", "clockSpeed", "2500.00 MHz")
463            .add_value("cpu_0_logical#1", "percentUtilisation", "2.54 %")
464            .build()?;
465
466        // Get the output
467        let output = dataview.to_string();
468
469        // Check structure
470        assert_eq!(dataview.row_order().len(), 5); // 5 rows
471        assert_eq!(dataview.row_order()[0], "Average_cpu".to_string());
472        assert_eq!(dataview.row_order()[1], "cpu_0".to_string());
473        assert_eq!(dataview.row_order()[2], "cpu_1".to_string());
474        assert_eq!(dataview.row_order()[3], "cpu_2".to_string());
475        assert_eq!(dataview.row_order()[4], "cpu_0_logical#1".to_string());
476
477        assert_eq!(dataview.headlines.len(), 5); // 5 headlines
478
479        // Assert column ordering is preserved
480        let expected_columns = [
481            "percentUtilisation",
482            "percentUserTime",
483            "percentKernelTime",
484            "percentIdle",
485            "type",
486            "state",
487            "clockSpeed",
488        ];
489        for (idx, col) in expected_columns.iter().enumerate() {
490            if idx < dataview.column_order().len() {
491                assert!(dataview.column_order().contains(&col.to_string()));
492            }
493        }
494
495        // Basic format checks
496        assert!(output.starts_with("cpu,"));
497        assert!(output.contains("<!>numOnlineCpus,4\n"));
498        assert!(output.contains("<!>loadAverage1Min,0.32\n"));
499        assert!(output.contains("<!>HyperThreadingStatus,ENABLED\n"));
500
501        // Check comma escaping
502        assert!(output.contains("GenuineIntel\\, Intel(R)"));
503        assert!(output.contains("2\\,500.00 MHz"));
504
505        Ok(())
506    }
507
508    #[test]
509    fn test_error_conditions() -> Result<(), ()> {
510        // Test missing row header
511        let result = DataviewBuilder::new()
512            .add_value("row1", "col1", "value1")
513            .build();
514
515        assert!(matches!(result, Err(DataviewError::MissingRowHeader)));
516
517        // Test missing values
518        let result = DataviewBuilder::new().set_row_header("header").build();
519
520        assert!(matches!(result, Err(DataviewError::MissingValue)));
521
522        // Ensure headlines alone are not enough
523        let result = DataviewBuilder::new()
524            .set_row_header("header")
525            .add_headline("headline1", "value1")
526            .build();
527
528        assert!(matches!(result, Err(DataviewError::MissingValue)));
529
530        Ok(())
531    }
532}