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 helper struct to build a row of data.
159///
160/// This allows constructing a row with multiple columns before adding it to the Dataview.
161#[derive(Debug, Clone, Default)]
162pub struct Row {
163    name: String,
164    cells: Vec<(String, String)>,
165}
166
167impl Row {
168    /// Creates a new Row with the given name (which becomes the row identifier).
169    pub fn new(name: impl ToString) -> Self {
170        Self {
171            name: name.to_string(),
172            cells: Vec::new(),
173        }
174    }
175
176    /// Adds a cell (column and value) to the row.
177    pub fn add_cell(mut self, column: impl ToString, value: impl ToString) -> Self {
178        self.cells.push((column.to_string(), value.to_string()));
179        self
180    }
181}
182
183/// A Builder for the `Dataview` struct.
184#[derive(Debug, Default, Clone)]
185pub struct DataviewBuilder {
186    row_header: Option<String>,
187    headlines: Option<HashMap<String, String>>,
188    values: Option<HashMap<(String, String), String>>,
189    headline_order: Vec<String>, // for the purpose of ordering the headlines
190    column_order: Vec<String>,   // for the purpose of ordering the columns
191    row_order: Vec<String>,      // for the purpose of ordering the rows
192}
193
194impl DataviewBuilder {
195    pub fn new() -> Self {
196        Self::default()
197    }
198
199    pub fn set_row_header(mut self, row_header: &str) -> Self {
200        self.row_header = Some(row_header.to_string());
201        self
202    }
203
204    pub fn add_headline<T: ToString>(mut self, key: &str, value: T) -> Self {
205        let mut headlines: HashMap<String, String> = self.headlines.unwrap_or_default();
206
207        let key_string = key.to_string();
208        if !self.headline_order.contains(&key_string) {
209            self.headline_order.push(key_string.clone());
210        }
211
212        headlines.insert(key_string, value.to_string());
213        self.headlines = Some(headlines);
214        self
215    }
216
217    pub fn add_value<T: ToString>(mut self, row: &str, column: &str, value: T) -> Self {
218        let mut values: HashMap<(String, String), String> = self.values.unwrap_or_default();
219
220        // Track columns in order of insertion (if new)
221        let column_string = column.to_string();
222        if !self.column_order.contains(&column_string) {
223            self.column_order.push(column_string.clone());
224        }
225
226        // Track rows in order of insertion (if new)
227        let row_string = row.to_string();
228        if !self.row_order.contains(&row_string) {
229            self.row_order.push(row_string.clone());
230        }
231
232        values.insert((row_string, column_string), value.to_string());
233        self.values = Some(values);
234        self
235    }
236
237    /// Adds a complete row to the Dataview.
238    ///
239    /// This is a convenience method to add multiple values for the same row at once.
240    ///
241    /// # Example
242    /// ```
243    /// use geneos_toolkit::prelude::*;
244    ///
245    /// let row = Row::new("process1")
246    ///     .add_cell("Status", "Running")
247    ///     .add_cell("CPU", "2.5%");
248    ///
249    /// let dataview = Dataview::builder()
250    ///     .set_row_header("Process")
251    ///     .add_row(row)
252    ///     .build();
253    /// ```
254    pub fn add_row(mut self, row: Row) -> Self {
255        for (col, val) in row.cells {
256            self = self.add_value(&row.name, &col, &val);
257        }
258        self
259    }
260
261    /// Sorts rows in ascending order by row name. Opt-in; default is insertion order.
262    pub fn sort_rows(mut self) -> Self {
263        self.row_order.sort();
264        self
265    }
266
267    /// Sorts rows using a key selector. Opt-in; default is insertion order.
268    pub fn sort_rows_by<K, F>(mut self, mut f: F) -> Self
269    where
270        K: Ord,
271        F: FnMut(&str) -> K,
272    {
273        self.row_order.sort_by_key(|row| f(row));
274        self
275    }
276
277    /// Sorts rows using a custom comparator. Opt-in; default is insertion order.
278    pub fn sort_rows_with<F>(mut self, mut cmp: F) -> Self
279    where
280        F: FnMut(&str, &str) -> std::cmp::Ordering,
281    {
282        self.row_order.sort_by(|a, b| cmp(a, b));
283        self
284    }
285
286    /// Builds the `Dataview`, consuming the builder.
287    ///
288    /// The `row_header` must be set before the build or a panic will occur.
289    /// There must be at least one value.
290    /// Headlines are optional.
291    ///
292    /// The order of the columns and rows is determined by the order in which they are added through
293    /// values using the `add_value` method.
294    ///
295    /// The order of headlines is determined by the order in which they are added through the
296    /// `add_headline` method.
297    ///
298    /// Example:
299    /// ```rust
300    /// use geneos_toolkit::prelude::*;
301    ///
302    /// let view: Dataview = Dataview::builder()
303    ///     .set_row_header("Name")
304    ///     .add_headline("AverageAge", "30")
305    ///     .add_value("Anna", "Age", "30")
306    ///     .add_value("Bertil", "Age", "20")
307    ///     .add_value("Caesar", "Age", "40")
308    ///     .build()
309    ///     .unwrap();
310    ///
311    /// ```
312    pub fn build(self) -> Result<Dataview, DataviewError> {
313        let row_header = self.row_header.ok_or(DataviewError::MissingRowHeader)?;
314
315        let values = self.values.ok_or(DataviewError::MissingValue)?;
316
317        Ok(Dataview {
318            row_header,
319            headlines: self.headlines.unwrap_or_default(),
320            headline_order: self.headline_order,
321            values,
322            column_order: self.column_order,
323            row_order: self.row_order,
324        })
325    }
326}
327
328/// Prints the result of a Dataview operation and exits the program.
329///
330/// # Arguments
331/// - `dataview`: The `Result` of a Dataview operation, holding either a `Dataview` or a `DataviewError`.
332///
333/// # Returns
334/// - Exits the program with a status code of 0 if successful, or 1 if an error occurred.
335///
336/// # Example
337/// ```rust
338/// use geneos_toolkit::prelude::*;
339///
340/// let dataview = Dataview::builder()
341///    .set_row_header("ID")
342///    .add_headline("Total", "42")
343///    .add_value("1", "Name", "Alice")
344///    .build();
345///
346/// print_result_and_exit(dataview)
347/// ```
348pub fn print_result_and_exit(dataview: Result<Dataview, DataviewError>) -> ! {
349    match dataview {
350        Ok(v) => {
351            println!("{v}");
352            std::process::exit(0)
353        }
354        Err(e) => {
355            eprintln!("ERROR: {e}");
356            std::process::exit(1)
357        }
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use pretty_assertions::assert_eq;
365
366    /// Helper function to create a basic dataview for testing
367    fn create_basic_dataview() -> Result<Dataview, DataviewError> {
368        DataviewBuilder::new()
369            .set_row_header("ID")
370            .add_headline("AverageAge", "30")
371            .add_value("1", "Name", "Alice")
372            .add_value("1", "Age", "30")
373            .build()
374    }
375
376    #[test]
377    fn test_dataview_builder_single_row() -> Result<(), DataviewError> {
378        let dataview = create_basic_dataview()?;
379
380        // Test row header
381        assert_eq!(dataview.row_header(), "ID");
382
383        // Test headline
384        assert_eq!(dataview.headline("AverageAge"), Some(&"30".to_string()));
385
386        // Test values
387        assert_eq!(dataview.value("1", "Name"), Some(&"Alice".to_string()));
388        assert_eq!(dataview.value("1", "Age"), Some(&"30".to_string()));
389
390        // Test structure
391        assert_eq!(dataview.row_order().len(), 1);
392        assert_eq!(dataview.column_order().len(), 2);
393        assert!(dataview.column_order().contains(&"Name".to_string()));
394        assert!(dataview.column_order().contains(&"Age".to_string()));
395
396        Ok(())
397    }
398
399    #[test]
400    fn test_dataview_display_format() -> Result<(), DataviewError> {
401        // Test basic display
402        let dataview = create_basic_dataview()?;
403        assert_eq!(
404            dataview.to_string(),
405            "\
406ID,Name,Age
407<!>AverageAge,30
4081,Alice,30"
409        );
410
411        // Test multiple rows and columns
412        let multi_row_dataview = DataviewBuilder::new()
413            .set_row_header("id")
414            // Ensure that headlines appear in the order in which they were added.
415            .add_headline("Baz", "Foo")
416            .add_headline("AlertDetails", "this is red alert")
417            .add_value("001", "name", "agila")
418            .add_value("001", "status", "up")
419            .add_value("001", "Value", "97")
420            .add_value("002", "name", "lawin")
421            .add_value("002", "status", "down")
422            .add_value("002", "Value", "85")
423            .build()?;
424
425        let expected_output = "\
426id,name,status,Value
427<!>Baz,Foo
428<!>AlertDetails,this is red alert
429001,agila,up,97
430002,lawin,down,85";
431
432        assert_eq!(multi_row_dataview.to_string(), expected_output);
433
434        Ok(())
435    }
436
437    #[test]
438    fn test_special_characters_escaping() -> Result<(), DataviewError> {
439        // Test comma escaping in row header, columns, values
440        let dataview = DataviewBuilder::new()
441            .set_row_header("queue,id")
442            .add_value("queue3", "number,code", "7,331")
443            .add_value("queue3", "count", "45,000")
444            .add_value("queue3", "ratio", "0.16")
445            .add_value("queue3", "status", "online")
446            .build()?;
447
448        let expected_output = "\
449queue\\,id,number\\,code,count,ratio,status
450queue3,7\\,331,45\\,000,0.16,online";
451
452        assert_eq!(dataview.to_string(), expected_output);
453
454        // Test other special characters
455        let dataview_special = DataviewBuilder::new()
456            .set_row_header("special")
457            .add_headline("special,headline", "headline value with, comma")
458            .add_value("special_case", "state", "testing: \"quotes\" & <symbols>")
459            .add_value("special_case", "data", "multi-line\ntext")
460            .build()?;
461
462        let output = dataview_special.to_string();
463        assert!(output.contains("special"));
464        assert!(output.contains("<!>special\\,headline,headline value with\\, comma"));
465        assert!(output.contains("testing: \"quotes\" & <symbols>"));
466        assert!(output.contains("multi-line\ntext"));
467
468        Ok(())
469    }
470
471    #[test]
472    fn test_empty_and_missing_values() -> Result<(), DataviewError> {
473        // Test with some missing values
474        let dataview = DataviewBuilder::new()
475            .set_row_header("item")
476            .add_value("item1", "col1", "value1")
477            .add_value("item1", "col2", "value2")
478            .add_value("item2", "col1", "value3")
479            // Deliberately missing item2/col2
480            .add_value("item3", "col3", "value4") // New column not in other rows
481            .build()?;
482
483        let output = dataview.to_string();
484
485        // Verify output format has empty cells where expected
486        assert!(output.contains("item1,value1,value2,"));
487        assert!(output.contains("item2,value3,,"));
488        assert!(output.contains("item3,,,value4"));
489
490        // Test accessing missing values
491        assert_eq!(dataview.value("item2", "col2"), None);
492        assert_eq!(dataview.value("nonexistent", "col1"), None);
493
494        Ok(())
495    }
496
497    #[test]
498    fn test_dataview_complex() -> Result<(), DataviewError> {
499        // This test creates a more realistic Dataview with many rows, columns and headlines
500        let dataview = DataviewBuilder::new()
501            .set_row_header("cpu")
502            // Add multiple headlines
503            .add_headline("numOnlineCpus", "4")
504            .add_headline("loadAverage1Min", "0.32")
505            .add_headline("loadAverage5Min", "0.45")
506            .add_headline("loadAverage15Min", "0.38")
507            .add_headline("HyperThreadingStatus", "ENABLED")
508            // CPU average row
509            .add_value("Average_cpu", "percentUtilisation", "3.75 %")
510            .add_value("Average_cpu", "percentUserTime", "2.15 %")
511            .add_value("Average_cpu", "percentKernelTime", "1.25 %")
512            .add_value("Average_cpu", "percentIdle", "96.25 %")
513            // CPU 0 with values in all columns
514            .add_value("cpu_0", "type", "GenuineIntel Intel(R)")
515            .add_value("cpu_0", "state", "on-line")
516            .add_value("cpu_0", "clockSpeed", "2500.00 MHz")
517            .add_value("cpu_0", "percentUtilisation", "3.25 %")
518            .add_value("cpu_0", "percentUserTime", "1.95 %")
519            .add_value("cpu_0", "percentKernelTime", "1.30 %")
520            .add_value("cpu_0", "percentIdle", "96.75 %")
521            // CPU 1 with same structure
522            .add_value("cpu_1", "type", "GenuineIntel Intel(R)")
523            .add_value("cpu_1", "state", "on-line")
524            .add_value("cpu_1", "clockSpeed", "2500.00 MHz")
525            .add_value("cpu_1", "percentUtilisation", "4.25 %")
526            .add_value("cpu_1", "percentUserTime", "2.35 %")
527            .add_value("cpu_1", "percentKernelTime", "1.20 %")
528            .add_value("cpu_1", "percentIdle", "95.75 %")
529            // cpu_2 with a comma in one value (needs escaping) and some missing values
530            .add_value("cpu_2", "type", "GenuineIntel, Intel(R)")
531            .add_value("cpu_2", "state", "on-line")
532            .add_value("cpu_2", "clockSpeed", "2,500.00 MHz")
533            // Add another logical CPU
534            .add_value("cpu_0_logical#1", "type", "logical")
535            .add_value("cpu_0_logical#1", "state", "on-line")
536            .add_value("cpu_0_logical#1", "clockSpeed", "2500.00 MHz")
537            .add_value("cpu_0_logical#1", "percentUtilisation", "2.54 %")
538            .build()?;
539
540        // Get the output
541        let output = dataview.to_string();
542
543        // Check structure
544        assert_eq!(dataview.row_order().len(), 5); // 5 rows
545        assert_eq!(dataview.row_order()[0], "Average_cpu".to_string());
546        assert_eq!(dataview.row_order()[1], "cpu_0".to_string());
547        assert_eq!(dataview.row_order()[2], "cpu_1".to_string());
548        assert_eq!(dataview.row_order()[3], "cpu_2".to_string());
549        assert_eq!(dataview.row_order()[4], "cpu_0_logical#1".to_string());
550
551        assert_eq!(dataview.headlines.len(), 5); // 5 headlines
552
553        // Assert column ordering is preserved
554        let expected_columns = [
555            "percentUtilisation",
556            "percentUserTime",
557            "percentKernelTime",
558            "percentIdle",
559            "type",
560            "state",
561            "clockSpeed",
562        ];
563        for (idx, col) in expected_columns.iter().enumerate() {
564            if idx < dataview.column_order().len() {
565                assert!(dataview.column_order().contains(&col.to_string()));
566            }
567        }
568
569        // Basic format checks
570        assert!(output.starts_with("cpu,"));
571        assert!(output.contains("<!>numOnlineCpus,4\n"));
572        assert!(output.contains("<!>loadAverage1Min,0.32\n"));
573        assert!(output.contains("<!>HyperThreadingStatus,ENABLED\n"));
574
575        // Check comma escaping
576        assert!(output.contains("GenuineIntel\\, Intel(R)"));
577        assert!(output.contains("2\\,500.00 MHz"));
578
579        Ok(())
580    }
581
582    #[test]
583    fn test_error_conditions() -> Result<(), ()> {
584        // Test missing row header
585        let result = DataviewBuilder::new()
586            .add_value("row1", "col1", "value1")
587            .build();
588
589        assert!(matches!(result, Err(DataviewError::MissingRowHeader)));
590
591        // Test missing values
592        let result = DataviewBuilder::new().set_row_header("header").build();
593
594        assert!(matches!(result, Err(DataviewError::MissingValue)));
595
596        // Ensure headlines alone are not enough
597        let result = DataviewBuilder::new()
598            .set_row_header("header")
599            .add_headline("headline1", "value1")
600            .build();
601
602        assert!(matches!(result, Err(DataviewError::MissingValue)));
603
604        Ok(())
605    }
606
607    #[test]
608    fn test_row_builder() -> Result<(), DataviewError> {
609        let row1 = Row::new("process1")
610            .add_cell("Status", "Running")
611            .add_cell("CPU", "2.5%");
612
613        let row2 = Row::new("process2")
614            .add_cell("Status", "Stopped")
615            .add_cell("CPU", "0.0%");
616
617        let dataview = Dataview::builder()
618            .set_row_header("Process")
619            .add_row(row1)
620            .add_row(row2)
621            .build()?;
622
623        let output = dataview.to_string();
624
625        assert!(output.contains("Process,Status,CPU"));
626        assert!(output.contains("process1,Running,2.5%"));
627        assert!(output.contains("process2,Stopped,0.0%"));
628
629        Ok(())
630    }
631
632    #[test]
633    fn test_row_sorting_methods() -> Result<(), DataviewError> {
634        // Default: insertion order preserved
635        let default = Dataview::builder()
636            .set_row_header("id")
637            .add_value("b", "col", "1")
638            .add_value("a", "col", "1")
639            .add_value("c", "col", "1")
640            .build()?;
641        assert_eq!(default.row_order(), &["b", "a", "c"]);
642
643        // sort_rows: ascending by row name
644        let sorted = Dataview::builder()
645            .set_row_header("id")
646            .add_value("b", "col", "1")
647            .add_value("a", "col", "1")
648            .add_value("c", "col", "1")
649            .sort_rows()
650            .build()?;
651        assert_eq!(sorted.row_order(), &["a", "b", "c"]);
652
653        // sort_rows_by: custom key (length)
654        let by_len = Dataview::builder()
655            .set_row_header("id")
656            .add_row(Row::new("long").add_cell("v", "1"))
657            .add_row(Row::new("mid").add_cell("v", "1"))
658            .add_row(Row::new("s").add_cell("v", "1"))
659            .sort_rows_by(|name| name.len())
660            .build()?;
661        assert_eq!(by_len.row_order(), &["s", "mid", "long"]);
662
663        // sort_rows_with: custom comparator (reverse lexicographic)
664        let reversed = Dataview::builder()
665            .set_row_header("id")
666            .add_row(Row::new("alpha").add_cell("v", "1"))
667            .add_row(Row::new("beta").add_cell("v", "1"))
668            .add_row(Row::new("gamma").add_cell("v", "1"))
669            .sort_rows_with(|a, b| b.cmp(a))
670            .build()?;
671        assert_eq!(reversed.row_order(), &["gamma", "beta", "alpha"]);
672
673        Ok(())
674    }
675}