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