geneos_toolkit/
dataview.rs

1use std::collections::HashMap;
2use std::error::Error;
3use std::fmt;
4
5#[derive(Debug)]
6pub enum DataviewError {
7    MissingRowHeader,
8    MissingValue,
9}
10
11impl fmt::Display for DataviewError {
12    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
13        match self {
14            DataviewError::MissingRowHeader => write!(f, "The Dataview must have a row header"),
15            DataviewError::MissingValue => write!(f, "The Dataview must have at least one value"),
16        }
17    }
18}
19
20impl Error for DataviewError {}
21
22/// A Geneos Dataview object.
23///
24/// This struct represents a Dataview, which is a structured representation of data
25/// with a row header, headlines, and values.
26///
27/// Example Dataview format:
28/// ```text
29/// row_header,column1,column2
30/// <!>headline1,value1
31/// <!>headline2,value2
32/// row1,value1,value2
33/// row2,value1,value2
34/// ```
35///
36/// Example with data:
37/// ```text
38/// cpu,percentUtilisation,percentIdle
39/// <!>numOnlineCpus,2
40/// <!>loadAverage1Min,0.32
41/// <!>loadAverage5Min,0.45
42/// <!>loadAverage15Min,0.38
43/// <!>HyperThreadingStatus,ENABLED
44/// Average_cpu,3.75 %,96.25 %
45/// cpu_0,3.25 %,96.75 %
46/// cpu_0_logical#1,2.54 %,97.46 %
47/// cpu_0_logical#2,2.54 %,97.46 %
48/// ```
49#[derive(Debug, Default, Clone, Eq, PartialEq)]
50pub struct Dataview {
51    row_header: String,
52    headlines: HashMap<String, String>,
53    headline_order: Vec<String>,
54    values: HashMap<(String, String), String>,
55    column_order: Vec<String>,
56    row_order: Vec<String>,
57}
58
59impl Dataview {
60    /// Returns the row header label for this dataview.
61    ///
62    /// # Example
63    /// ```
64    /// use geneos_toolkit::dataview::DataviewBuilder;
65    /// let view = DataviewBuilder::new()
66    ///     .set_row_header("Process")
67    ///     .add_value("proc1", "Status", "Running")
68    ///     .build()
69    ///     .unwrap();
70    /// assert_eq!(view.row_header(), "Process");
71    /// ```
72    pub fn row_header(&self) -> &str {
73        &self.row_header
74    }
75
76    /// Returns a headline value by key, if present.
77    pub fn headline(&self, key: &str) -> Option<&String> {
78        self.headlines.get(key)
79    }
80
81    /// Returns the headline keys in display order.
82    pub fn headline_order(&self) -> &[String] {
83        &self.headline_order
84    }
85
86    /// Returns a cell value for the given row/column, if present.
87    pub fn value(&self, row: &str, column: &str) -> Option<&String> {
88        self.values.get(&(row.to_string(), column.to_string()))
89    }
90
91    /// Returns the column names in display order.
92    pub fn column_order(&self) -> &[String] {
93        &self.column_order
94    }
95
96    /// Returns the row names in display order.
97    pub fn row_order(&self) -> &[String] {
98        &self.row_order
99    }
100}
101
102fn escape_commas(s: &str) -> String {
103    s.replace(",", "\\,")
104}
105
106fn write_header_row(
107    f: &mut fmt::Formatter<'_>,
108    row_header: &str,
109    columns: &[String],
110) -> fmt::Result {
111    write!(f, "{}", escape_commas(row_header))?;
112    for col in columns {
113        write!(f, ",{}", escape_commas(col))?;
114    }
115    writeln!(f)
116}
117
118fn write_headlines(
119    f: &mut fmt::Formatter<'_>,
120    headline_order: &[String],
121    headlines: &HashMap<String, String>,
122) -> fmt::Result {
123    for name in headline_order {
124        if let Some(value) = headlines.get(name) {
125            writeln!(f, "<!>{},{}", escape_commas(name), escape_commas(value))?;
126        }
127    }
128    Ok(())
129}
130
131fn write_data_rows(
132    f: &mut fmt::Formatter<'_>,
133    rows: &[String],
134    columns: &[String],
135    values: &HashMap<(String, String), String>,
136) -> fmt::Result {
137    let number_of_rows = rows.len();
138    for (i, row) in rows.iter().enumerate() {
139        write!(f, "{}", escape_commas(row))?;
140        for col in columns {
141            write!(f, ",")?;
142            if let Some(value) = values.get(&(row.to_string(), col.to_string())) {
143                write!(f, "{}", escape_commas(value))?;
144            }
145        }
146
147        // Only write newline if this isn't the last row
148        if i < number_of_rows - 1 {
149            writeln!(f)?;
150        }
151    }
152
153    Ok(())
154}
155
156impl fmt::Display for Dataview {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        write_header_row(f, &self.row_header, &self.column_order)?;
159        write_headlines(f, &self.headline_order, &self.headlines)?;
160        write_data_rows(f, &self.row_order, &self.column_order, &self.values)
161    }
162}
163
164impl Dataview {
165    /// Creates a new DataviewBuilder instance
166    ///
167    /// This allows users to create a Dataview without explicitly importing DataviewBuilder
168    ///
169    /// # Example
170    ///
171    /// ```
172    /// use geneos_toolkit::prelude::*;
173    /// let dataview = Dataview::builder()
174    ///     .set_row_header("ID")
175    ///     .add_headline("Total", "42")
176    ///     .add_value("1", "Name", "Alice")
177    ///     .build();
178    /// ```
179    pub fn builder() -> DataviewBuilder {
180        DataviewBuilder::new()
181    }
182}
183
184/// A helper struct to build a row of data.
185///
186/// This allows constructing a row with multiple columns before adding it to the Dataview.
187#[derive(Debug, Clone, Default)]
188pub struct Row {
189    name: String,
190    cells: Vec<(String, String)>,
191}
192
193impl Row {
194    /// Creates a new Row with the given name (row identifier).
195    pub fn new(name: impl ToString) -> Self {
196        Self {
197            name: name.to_string(),
198            cells: Vec::new(),
199        }
200    }
201
202    /// Adds a cell (column and value) to the row, preserving insertion order.
203    pub fn add_cell(mut self, column: impl ToString, value: impl ToString) -> Self {
204        self.cells.push((column.to_string(), value.to_string()));
205        self
206    }
207}
208
209/// A Builder for the `Dataview` struct.
210#[derive(Debug, Default, Clone)]
211pub struct DataviewBuilder {
212    row_header: Option<String>,
213    headlines: Option<HashMap<String, String>>,
214    values: Option<HashMap<(String, String), String>>,
215    headline_order: Vec<String>, // for the purpose of ordering the headlines
216    column_order: Vec<String>,   // for the purpose of ordering the columns
217    row_order: Vec<String>,      // for the purpose of ordering the rows
218}
219
220impl DataviewBuilder {
221    /// Creates a new, empty builder.
222    pub fn new() -> Self {
223        Self::default()
224    }
225
226    /// Sets the mandatory row header label.
227    pub fn set_row_header(mut self, row_header: &str) -> Self {
228        self.row_header = Some(row_header.to_string());
229        self
230    }
231
232    /// Adds or replaces a headline value. Order is preserved by first insert.
233    pub fn add_headline<T: ToString>(mut self, key: &str, value: T) -> Self {
234        let mut headlines: HashMap<String, String> = self.headlines.unwrap_or_default();
235
236        let key_string = key.to_string();
237        if !self.headline_order.contains(&key_string) {
238            self.headline_order.push(key_string.clone());
239        }
240
241        headlines.insert(key_string, value.to_string());
242        self.headlines = Some(headlines);
243        self
244    }
245
246    /// Adds a single cell value at `row`/`column`, recording insertion order.
247    pub fn add_value<T: ToString>(mut self, row: &str, column: &str, value: T) -> Self {
248        let mut values: HashMap<(String, String), String> = self.values.unwrap_or_default();
249
250        // Track columns in order of insertion (if new)
251        let column_string = column.to_string();
252        if !self.column_order.contains(&column_string) {
253            self.column_order.push(column_string.clone());
254        }
255
256        // Track rows in order of insertion (if new)
257        let row_string = row.to_string();
258        if !self.row_order.contains(&row_string) {
259            self.row_order.push(row_string.clone());
260        }
261
262        values.insert((row_string, column_string), value.to_string());
263        self.values = Some(values);
264        self
265    }
266
267    /// Adds a complete row to the Dataview.
268    ///
269    /// This is a convenience method to add multiple values for the same row at once.
270    ///
271    /// # Example
272    /// ```
273    /// use geneos_toolkit::prelude::*;
274    ///
275    /// let row = Row::new("process1")
276    ///     .add_cell("Status", "Running")
277    ///     .add_cell("CPU", "2.5%");
278    ///
279    /// let dataview = Dataview::builder()
280    ///     .set_row_header("Process")
281    ///     .add_row(row)
282    ///     .build();
283    /// ```
284    pub fn add_row(mut self, row: Row) -> Self {
285        for (col, val) in row.cells {
286            self = self.add_value(&row.name, &col, &val);
287        }
288        self
289    }
290
291    /// Sorts rows in ascending order by row name. Opt-in; default is insertion order.
292    /// Sorts rows in ascending order by row name. Opt-in; default is insertion order.
293    pub fn sort_rows(mut self) -> Self {
294        self.row_order.sort();
295        self
296    }
297
298    /// Sorts rows using a key selector. Opt-in; default is insertion order.
299    pub fn sort_rows_by<K, F>(mut self, mut f: F) -> Self
300    where
301        K: Ord,
302        F: FnMut(&str) -> K,
303    {
304        self.row_order.sort_by_key(|row| f(row));
305        self
306    }
307
308    /// Sorts rows using a custom comparator. Opt-in; default is insertion order.
309    pub fn sort_rows_with<F>(mut self, mut cmp: F) -> Self
310    where
311        F: FnMut(&str, &str) -> std::cmp::Ordering,
312    {
313        self.row_order.sort_by(|a, b| cmp(a, b));
314        self
315    }
316
317    /// Builds the `Dataview`, consuming the builder.
318    ///
319    /// The `row_header` must be set before the build or a panic will occur.
320    /// There must be at least one value.
321    /// Headlines are optional.
322    ///
323    /// The order of the columns and rows is determined by the order in which they are added through
324    /// values using the `add_value` method.
325    ///
326    /// The order of headlines is determined by the order in which they are added through the
327    /// `add_headline` method.
328    ///
329    /// Example:
330    /// ```rust
331    /// use geneos_toolkit::prelude::*;
332    ///
333    /// let view: Dataview = Dataview::builder()
334    ///     .set_row_header("Name")
335    ///     .add_headline("AverageAge", "30")
336    ///     .add_value("Anna", "Age", "30")
337    ///     .add_value("Bertil", "Age", "20")
338    ///     .add_value("Caesar", "Age", "40")
339    ///     .build()
340    ///     .unwrap();
341    ///
342    /// ```
343    pub fn build(self) -> Result<Dataview, DataviewError> {
344        let row_header = self.row_header.ok_or(DataviewError::MissingRowHeader)?;
345
346        let values = self.values.ok_or(DataviewError::MissingValue)?;
347
348        Ok(Dataview {
349            row_header,
350            headlines: self.headlines.unwrap_or_default(),
351            headline_order: self.headline_order,
352            values,
353            column_order: self.column_order,
354            row_order: self.row_order,
355        })
356    }
357}
358
359/// Prints the result of a Dataview operation and exits the program.
360///
361/// # Arguments
362/// - `dataview`: The `Result` of a Dataview operation, holding either a `Dataview` or a `DataviewError`.
363///
364/// # Returns
365/// - Exits the program with a status code of 0 if successful, or 1 if an error occurred.
366///
367/// # Example
368/// ```rust
369/// use geneos_toolkit::prelude::*;
370///
371/// let dataview = Dataview::builder()
372///    .set_row_header("ID")
373///    .add_headline("Total", "42")
374///    .add_value("1", "Name", "Alice")
375///    .build();
376///
377/// print_result_and_exit(dataview)
378/// ```
379/// Prints the dataview on success or an error on failure, then exits the process.
380pub fn print_result_and_exit(dataview: Result<Dataview, DataviewError>) -> ! {
381    match dataview {
382        Ok(v) => {
383            println!("{v}");
384            std::process::exit(0)
385        }
386        Err(e) => {
387            eprintln!("ERROR: {e}");
388            std::process::exit(1)
389        }
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use pretty_assertions::assert_eq;
397
398    /// Helper function to create a basic dataview for testing
399    fn create_basic_dataview() -> Result<Dataview, DataviewError> {
400        DataviewBuilder::new()
401            .set_row_header("ID")
402            .add_headline("AverageAge", "30")
403            .add_value("1", "Name", "Alice")
404            .add_value("1", "Age", "30")
405            .build()
406    }
407
408    #[test]
409    fn test_dataview_builder_single_row() -> Result<(), DataviewError> {
410        let dataview = create_basic_dataview()?;
411
412        // Test row header
413        assert_eq!(dataview.row_header(), "ID");
414
415        // Test headline
416        assert_eq!(dataview.headline("AverageAge"), Some(&"30".to_string()));
417
418        // Test values
419        assert_eq!(dataview.value("1", "Name"), Some(&"Alice".to_string()));
420        assert_eq!(dataview.value("1", "Age"), Some(&"30".to_string()));
421
422        // Test structure
423        assert_eq!(dataview.row_order().len(), 1);
424        assert_eq!(dataview.column_order().len(), 2);
425        assert!(dataview.column_order().contains(&"Name".to_string()));
426        assert!(dataview.column_order().contains(&"Age".to_string()));
427
428        Ok(())
429    }
430
431    #[test]
432    fn test_dataview_display_format() -> Result<(), DataviewError> {
433        // Test basic display
434        let dataview = create_basic_dataview()?;
435        assert_eq!(
436            dataview.to_string(),
437            "\
438ID,Name,Age
439<!>AverageAge,30
4401,Alice,30"
441        );
442
443        // Test multiple rows and columns
444        let multi_row_dataview = DataviewBuilder::new()
445            .set_row_header("id")
446            // Ensure that headlines appear in the order in which they were added.
447            .add_headline("Baz", "Foo")
448            .add_headline("AlertDetails", "this is red alert")
449            .add_value("001", "name", "agila")
450            .add_value("001", "status", "up")
451            .add_value("001", "Value", "97")
452            .add_value("002", "name", "lawin")
453            .add_value("002", "status", "down")
454            .add_value("002", "Value", "85")
455            .build()?;
456
457        let expected_output = "\
458id,name,status,Value
459<!>Baz,Foo
460<!>AlertDetails,this is red alert
461001,agila,up,97
462002,lawin,down,85";
463
464        assert_eq!(multi_row_dataview.to_string(), expected_output);
465
466        Ok(())
467    }
468
469    #[test]
470    fn test_special_characters_escaping() -> Result<(), DataviewError> {
471        // Test comma escaping in row header, columns, values
472        let dataview = DataviewBuilder::new()
473            .set_row_header("queue,id")
474            .add_value("queue3", "number,code", "7,331")
475            .add_value("queue3", "count", "45,000")
476            .add_value("queue3", "ratio", "0.16")
477            .add_value("queue3", "status", "online")
478            .build()?;
479
480        let expected_output = "\
481queue\\,id,number\\,code,count,ratio,status
482queue3,7\\,331,45\\,000,0.16,online";
483
484        assert_eq!(dataview.to_string(), expected_output);
485
486        // Test other special characters
487        let dataview_special = DataviewBuilder::new()
488            .set_row_header("special")
489            .add_headline("special,headline", "headline value with, comma")
490            .add_value("special_case", "state", "testing: \"quotes\" & <symbols>")
491            .add_value("special_case", "data", "multi-line\ntext")
492            .build()?;
493
494        let output = dataview_special.to_string();
495        assert!(output.contains("special"));
496        assert!(output.contains("<!>special\\,headline,headline value with\\, comma"));
497        assert!(output.contains("testing: \"quotes\" & <symbols>"));
498        assert!(output.contains("multi-line\ntext"));
499
500        Ok(())
501    }
502
503    #[test]
504    fn test_empty_and_missing_values() -> Result<(), DataviewError> {
505        // Test with some missing values
506        let dataview = DataviewBuilder::new()
507            .set_row_header("item")
508            .add_value("item1", "col1", "value1")
509            .add_value("item1", "col2", "value2")
510            .add_value("item2", "col1", "value3")
511            // Deliberately missing item2/col2
512            .add_value("item3", "col3", "value4") // New column not in other rows
513            .build()?;
514
515        let output = dataview.to_string();
516
517        // Verify output format has empty cells where expected
518        assert!(output.contains("item1,value1,value2,"));
519        assert!(output.contains("item2,value3,,"));
520        assert!(output.contains("item3,,,value4"));
521
522        // Test accessing missing values
523        assert_eq!(dataview.value("item2", "col2"), None);
524        assert_eq!(dataview.value("nonexistent", "col1"), None);
525
526        Ok(())
527    }
528
529    #[test]
530    fn test_dataview_complex() -> Result<(), DataviewError> {
531        // This test creates a more realistic Dataview with many rows, columns and headlines
532        let dataview = DataviewBuilder::new()
533            .set_row_header("cpu")
534            // Add multiple headlines
535            .add_headline("numOnlineCpus", "4")
536            .add_headline("loadAverage1Min", "0.32")
537            .add_headline("loadAverage5Min", "0.45")
538            .add_headline("loadAverage15Min", "0.38")
539            .add_headline("HyperThreadingStatus", "ENABLED")
540            // CPU average row
541            .add_value("Average_cpu", "percentUtilisation", "3.75 %")
542            .add_value("Average_cpu", "percentUserTime", "2.15 %")
543            .add_value("Average_cpu", "percentKernelTime", "1.25 %")
544            .add_value("Average_cpu", "percentIdle", "96.25 %")
545            // CPU 0 with values in all columns
546            .add_value("cpu_0", "type", "GenuineIntel Intel(R)")
547            .add_value("cpu_0", "state", "on-line")
548            .add_value("cpu_0", "clockSpeed", "2500.00 MHz")
549            .add_value("cpu_0", "percentUtilisation", "3.25 %")
550            .add_value("cpu_0", "percentUserTime", "1.95 %")
551            .add_value("cpu_0", "percentKernelTime", "1.30 %")
552            .add_value("cpu_0", "percentIdle", "96.75 %")
553            // CPU 1 with same structure
554            .add_value("cpu_1", "type", "GenuineIntel Intel(R)")
555            .add_value("cpu_1", "state", "on-line")
556            .add_value("cpu_1", "clockSpeed", "2500.00 MHz")
557            .add_value("cpu_1", "percentUtilisation", "4.25 %")
558            .add_value("cpu_1", "percentUserTime", "2.35 %")
559            .add_value("cpu_1", "percentKernelTime", "1.20 %")
560            .add_value("cpu_1", "percentIdle", "95.75 %")
561            // cpu_2 with a comma in one value (needs escaping) and some missing values
562            .add_value("cpu_2", "type", "GenuineIntel, Intel(R)")
563            .add_value("cpu_2", "state", "on-line")
564            .add_value("cpu_2", "clockSpeed", "2,500.00 MHz")
565            // Add another logical CPU
566            .add_value("cpu_0_logical#1", "type", "logical")
567            .add_value("cpu_0_logical#1", "state", "on-line")
568            .add_value("cpu_0_logical#1", "clockSpeed", "2500.00 MHz")
569            .add_value("cpu_0_logical#1", "percentUtilisation", "2.54 %")
570            .build()?;
571
572        // Get the output
573        let output = dataview.to_string();
574
575        // Check structure
576        assert_eq!(dataview.row_order().len(), 5); // 5 rows
577        assert_eq!(dataview.row_order()[0], "Average_cpu".to_string());
578        assert_eq!(dataview.row_order()[1], "cpu_0".to_string());
579        assert_eq!(dataview.row_order()[2], "cpu_1".to_string());
580        assert_eq!(dataview.row_order()[3], "cpu_2".to_string());
581        assert_eq!(dataview.row_order()[4], "cpu_0_logical#1".to_string());
582
583        assert_eq!(dataview.headlines.len(), 5); // 5 headlines
584
585        // Assert column ordering is preserved
586        let expected_columns = [
587            "percentUtilisation",
588            "percentUserTime",
589            "percentKernelTime",
590            "percentIdle",
591            "type",
592            "state",
593            "clockSpeed",
594        ];
595        for (idx, col) in expected_columns.iter().enumerate() {
596            if idx < dataview.column_order().len() {
597                assert!(dataview.column_order().contains(&col.to_string()));
598            }
599        }
600
601        // Basic format checks
602        assert!(output.starts_with("cpu,"));
603        assert!(output.contains("<!>numOnlineCpus,4\n"));
604        assert!(output.contains("<!>loadAverage1Min,0.32\n"));
605        assert!(output.contains("<!>HyperThreadingStatus,ENABLED\n"));
606
607        // Check comma escaping
608        assert!(output.contains("GenuineIntel\\, Intel(R)"));
609        assert!(output.contains("2\\,500.00 MHz"));
610
611        Ok(())
612    }
613
614    #[test]
615    fn test_error_conditions() -> Result<(), ()> {
616        // Test missing row header
617        let result = DataviewBuilder::new()
618            .add_value("row1", "col1", "value1")
619            .build();
620
621        assert!(matches!(result, Err(DataviewError::MissingRowHeader)));
622
623        // Test missing values
624        let result = DataviewBuilder::new().set_row_header("header").build();
625
626        assert!(matches!(result, Err(DataviewError::MissingValue)));
627
628        // Ensure headlines alone are not enough
629        let result = DataviewBuilder::new()
630            .set_row_header("header")
631            .add_headline("headline1", "value1")
632            .build();
633
634        assert!(matches!(result, Err(DataviewError::MissingValue)));
635
636        Ok(())
637    }
638
639    #[test]
640    fn test_row_builder() -> Result<(), DataviewError> {
641        let row1 = Row::new("process1")
642            .add_cell("Status", "Running")
643            .add_cell("CPU", "2.5%");
644
645        let row2 = Row::new("process2")
646            .add_cell("Status", "Stopped")
647            .add_cell("CPU", "0.0%");
648
649        let dataview = Dataview::builder()
650            .set_row_header("Process")
651            .add_row(row1)
652            .add_row(row2)
653            .build()?;
654
655        let output = dataview.to_string();
656
657        assert!(output.contains("Process,Status,CPU"));
658        assert!(output.contains("process1,Running,2.5%"));
659        assert!(output.contains("process2,Stopped,0.0%"));
660
661        Ok(())
662    }
663
664    #[test]
665    fn test_row_sorting_methods() -> Result<(), DataviewError> {
666        // Default: insertion order preserved
667        let default = Dataview::builder()
668            .set_row_header("id")
669            .add_value("b", "col", "1")
670            .add_value("a", "col", "1")
671            .add_value("c", "col", "1")
672            .build()?;
673        assert_eq!(default.row_order(), &["b", "a", "c"]);
674
675        // sort_rows: ascending by row name
676        let sorted = Dataview::builder()
677            .set_row_header("id")
678            .add_value("b", "col", "1")
679            .add_value("a", "col", "1")
680            .add_value("c", "col", "1")
681            .sort_rows()
682            .build()?;
683        assert_eq!(sorted.row_order(), &["a", "b", "c"]);
684
685        // sort_rows_by: custom key (length)
686        let by_len = Dataview::builder()
687            .set_row_header("id")
688            .add_row(Row::new("long").add_cell("v", "1"))
689            .add_row(Row::new("mid").add_cell("v", "1"))
690            .add_row(Row::new("s").add_cell("v", "1"))
691            .sort_rows_by(|name| name.len())
692            .build()?;
693        assert_eq!(by_len.row_order(), &["s", "mid", "long"]);
694
695        // sort_rows_with: custom comparator (reverse lexicographic)
696        let reversed = Dataview::builder()
697            .set_row_header("id")
698            .add_row(Row::new("alpha").add_cell("v", "1"))
699            .add_row(Row::new("beta").add_cell("v", "1"))
700            .add_row(Row::new("gamma").add_cell("v", "1"))
701            .sort_rows_with(|a, b| b.cmp(a))
702            .build()?;
703        assert_eq!(reversed.row_order(), &["gamma", "beta", "alpha"]);
704
705        Ok(())
706    }
707}