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#[cfg(test)]
255mod tests {
256    use super::*;
257    use pretty_assertions::assert_eq;
258
259    /// Helper function to create a basic dataview for testing
260    fn create_basic_dataview() -> Result<Dataview, DataviewError> {
261        DataviewBuilder::new()
262            .set_row_header("ID")
263            .add_headline("AverageAge", "30")
264            .add_value("1", "Name", "Alice")
265            .add_value("1", "Age", "30")
266            .build()
267    }
268
269    #[test]
270    fn test_dataview_builder_single_row() -> Result<(), DataviewError> {
271        let dataview = create_basic_dataview()?;
272
273        // Test row header
274        assert_eq!(dataview.row_header(), "ID");
275
276        // Test headline
277        assert_eq!(dataview.headline("AverageAge"), Some(&"30".to_string()));
278
279        // Test values
280        assert_eq!(dataview.value("1", "Name"), Some(&"Alice".to_string()));
281        assert_eq!(dataview.value("1", "Age"), Some(&"30".to_string()));
282
283        // Test structure
284        assert_eq!(dataview.row_order().len(), 1);
285        assert_eq!(dataview.column_order().len(), 2);
286        assert!(dataview.column_order().contains(&"Name".to_string()));
287        assert!(dataview.column_order().contains(&"Age".to_string()));
288
289        Ok(())
290    }
291
292    #[test]
293    fn test_dataview_display_format() -> Result<(), DataviewError> {
294        // Test basic display
295        let dataview = create_basic_dataview()?;
296        assert_eq!(
297            dataview.to_string(),
298            "\
299ID,Name,Age
300<!>AverageAge,30
3011,Alice,30"
302        );
303
304        // Test multiple rows and columns
305        let multi_row_dataview = DataviewBuilder::new()
306            .set_row_header("id")
307            // Ensure that headlines appear in the order in which they were added.
308            .add_headline("Baz", "Foo")
309            .add_headline("AlertDetails", "this is red alert")
310            .add_value("001", "name", "agila")
311            .add_value("001", "status", "up")
312            .add_value("001", "Value", "97")
313            .add_value("002", "name", "lawin")
314            .add_value("002", "status", "down")
315            .add_value("002", "Value", "85")
316            .build()?;
317
318        let expected_output = "\
319id,name,status,Value
320<!>Baz,Foo
321<!>AlertDetails,this is red alert
322001,agila,up,97
323002,lawin,down,85";
324
325        assert_eq!(multi_row_dataview.to_string(), expected_output);
326
327        Ok(())
328    }
329
330    #[test]
331    fn test_special_characters_escaping() -> Result<(), DataviewError> {
332        // Test comma escaping in row header, columns, values
333        let dataview = DataviewBuilder::new()
334            .set_row_header("queue,id")
335            .add_value("queue3", "number,code", "7,331")
336            .add_value("queue3", "count", "45,000")
337            .add_value("queue3", "ratio", "0.16")
338            .add_value("queue3", "status", "online")
339            .build()?;
340
341        let expected_output = "\
342queue\\,id,number\\,code,count,ratio,status
343queue3,7\\,331,45\\,000,0.16,online";
344
345        assert_eq!(dataview.to_string(), expected_output);
346
347        // Test other special characters
348        let dataview_special = DataviewBuilder::new()
349            .set_row_header("special")
350            .add_headline("special,headline", "headline value with, comma")
351            .add_value("special_case", "state", "testing: \"quotes\" & <symbols>")
352            .add_value("special_case", "data", "multi-line\ntext")
353            .build()?;
354
355        let output = dataview_special.to_string();
356        assert!(output.contains("special"));
357        assert!(output.contains("<!>special\\,headline,headline value with\\, comma"));
358        assert!(output.contains("testing: \"quotes\" & <symbols>"));
359        assert!(output.contains("multi-line\ntext"));
360
361        Ok(())
362    }
363
364    #[test]
365    fn test_empty_and_missing_values() -> Result<(), DataviewError> {
366        // Test with some missing values
367        let dataview = DataviewBuilder::new()
368            .set_row_header("item")
369            .add_value("item1", "col1", "value1")
370            .add_value("item1", "col2", "value2")
371            .add_value("item2", "col1", "value3")
372            // Deliberately missing item2/col2
373            .add_value("item3", "col3", "value4") // New column not in other rows
374            .build()?;
375
376        let output = dataview.to_string();
377
378        // Verify output format has empty cells where expected
379        assert!(output.contains("item1,value1,value2,"));
380        assert!(output.contains("item2,value3,,"));
381        assert!(output.contains("item3,,,value4"));
382
383        // Test accessing missing values
384        assert_eq!(dataview.value("item2", "col2"), None);
385        assert_eq!(dataview.value("nonexistent", "col1"), None);
386
387        Ok(())
388    }
389
390    #[test]
391    fn test_dataview_complex() -> Result<(), DataviewError> {
392        // This test creates a more realistic Dataview with many rows, columns and headlines
393        let dataview = DataviewBuilder::new()
394            .set_row_header("cpu")
395            // Add multiple headlines
396            .add_headline("numOnlineCpus", "4")
397            .add_headline("loadAverage1Min", "0.32")
398            .add_headline("loadAverage5Min", "0.45")
399            .add_headline("loadAverage15Min", "0.38")
400            .add_headline("HyperThreadingStatus", "ENABLED")
401            // CPU average row
402            .add_value("Average_cpu", "percentUtilisation", "3.75 %")
403            .add_value("Average_cpu", "percentUserTime", "2.15 %")
404            .add_value("Average_cpu", "percentKernelTime", "1.25 %")
405            .add_value("Average_cpu", "percentIdle", "96.25 %")
406            // CPU 0 with values in all columns
407            .add_value("cpu_0", "type", "GenuineIntel Intel(R)")
408            .add_value("cpu_0", "state", "on-line")
409            .add_value("cpu_0", "clockSpeed", "2500.00 MHz")
410            .add_value("cpu_0", "percentUtilisation", "3.25 %")
411            .add_value("cpu_0", "percentUserTime", "1.95 %")
412            .add_value("cpu_0", "percentKernelTime", "1.30 %")
413            .add_value("cpu_0", "percentIdle", "96.75 %")
414            // CPU 1 with same structure
415            .add_value("cpu_1", "type", "GenuineIntel Intel(R)")
416            .add_value("cpu_1", "state", "on-line")
417            .add_value("cpu_1", "clockSpeed", "2500.00 MHz")
418            .add_value("cpu_1", "percentUtilisation", "4.25 %")
419            .add_value("cpu_1", "percentUserTime", "2.35 %")
420            .add_value("cpu_1", "percentKernelTime", "1.20 %")
421            .add_value("cpu_1", "percentIdle", "95.75 %")
422            // cpu_2 with a comma in one value (needs escaping) and some missing values
423            .add_value("cpu_2", "type", "GenuineIntel, Intel(R)")
424            .add_value("cpu_2", "state", "on-line")
425            .add_value("cpu_2", "clockSpeed", "2,500.00 MHz")
426            // Add another logical CPU
427            .add_value("cpu_0_logical#1", "type", "logical")
428            .add_value("cpu_0_logical#1", "state", "on-line")
429            .add_value("cpu_0_logical#1", "clockSpeed", "2500.00 MHz")
430            .add_value("cpu_0_logical#1", "percentUtilisation", "2.54 %")
431            .build()?;
432
433        // Get the output
434        let output = dataview.to_string();
435
436        // Check structure
437        assert_eq!(dataview.row_order().len(), 5); // 5 rows
438        assert_eq!(dataview.row_order()[0], "Average_cpu".to_string());
439        assert_eq!(dataview.row_order()[1], "cpu_0".to_string());
440        assert_eq!(dataview.row_order()[2], "cpu_1".to_string());
441        assert_eq!(dataview.row_order()[3], "cpu_2".to_string());
442        assert_eq!(dataview.row_order()[4], "cpu_0_logical#1".to_string());
443
444        assert_eq!(dataview.headlines.len(), 5); // 5 headlines
445
446        // Assert column ordering is preserved
447        let expected_columns = [
448            "percentUtilisation",
449            "percentUserTime",
450            "percentKernelTime",
451            "percentIdle",
452            "type",
453            "state",
454            "clockSpeed",
455        ];
456        for (idx, col) in expected_columns.iter().enumerate() {
457            if idx < dataview.column_order().len() {
458                assert!(dataview.column_order().contains(&col.to_string()));
459            }
460        }
461
462        // Basic format checks
463        assert!(output.starts_with("cpu,"));
464        assert!(output.contains("<!>numOnlineCpus,4\n"));
465        assert!(output.contains("<!>loadAverage1Min,0.32\n"));
466        assert!(output.contains("<!>HyperThreadingStatus,ENABLED\n"));
467
468        // Check comma escaping
469        assert!(output.contains("GenuineIntel\\, Intel(R)"));
470        assert!(output.contains("2\\,500.00 MHz"));
471
472        Ok(())
473    }
474
475    #[test]
476    fn test_error_conditions() -> Result<(), ()> {
477        // Test missing row header
478        let result = DataviewBuilder::new()
479            .add_value("row1", "col1", "value1")
480            .build();
481
482        assert!(matches!(result, Err(DataviewError::MissingRowHeader)));
483
484        // Test missing values
485        let result = DataviewBuilder::new().set_row_header("header").build();
486
487        assert!(matches!(result, Err(DataviewError::MissingValue)));
488
489        // Ensure headlines alone are not enough
490        let result = DataviewBuilder::new()
491            .set_row_header("header")
492            .add_headline("headline1", "value1")
493            .build();
494
495        assert!(matches!(result, Err(DataviewError::MissingValue)));
496
497        Ok(())
498    }
499}