Skip to main content

geneos_toolkit/
dataview.rs

1use std::collections::HashMap;
2use std::error::Error;
3use std::fmt;
4
5#[derive(Debug)]
6#[non_exhaustive]
7pub enum DataviewError {
8    MissingRowHeader,
9    MissingValue,
10    EmptyName(String),
11}
12
13impl fmt::Display for DataviewError {
14    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
15        match self {
16            DataviewError::MissingRowHeader => write!(f, "The Dataview must have a row header"),
17            DataviewError::MissingValue => write!(f, "The Dataview must have at least one value"),
18            DataviewError::EmptyName(field) => write!(f, "Empty {field} name is not allowed"),
19        }
20    }
21}
22
23impl Error for DataviewError {}
24
25/// A Geneos Dataview object.
26///
27/// This struct represents a Dataview, which is a structured representation of data
28/// with a row header, headlines, and values.
29///
30/// Example Dataview format:
31/// ```text
32/// row_header,column1,column2
33/// <!>headline1,value1
34/// <!>headline2,value2
35/// row1,value1,value2
36/// row2,value1,value2
37/// ```
38///
39/// Example with data:
40/// ```text
41/// cpu,percentUtilisation,percentIdle
42/// <!>numOnlineCpus,2
43/// <!>loadAverage1Min,0.32
44/// <!>loadAverage5Min,0.45
45/// <!>loadAverage15Min,0.38
46/// <!>HyperThreadingStatus,ENABLED
47/// Average_cpu,3.75 %,96.25 %
48/// cpu_0,3.25 %,96.75 %
49/// cpu_0_logical#1,2.54 %,97.46 %
50/// cpu_0_logical#2,2.54 %,97.46 %
51/// ```
52#[derive(Debug, Default, Clone, Eq, PartialEq)]
53pub struct Dataview {
54    row_header: String,
55    headlines: HashMap<String, String>,
56    headline_order: Vec<String>,
57    values: HashMap<String, HashMap<String, String>>,
58    column_order: Vec<String>,
59    row_order: Vec<String>,
60}
61
62impl Dataview {
63    /// Returns the row header label for this dataview.
64    ///
65    /// # Example
66    /// ```
67    /// use geneos_toolkit::dataview::DataviewBuilder;
68    /// let view = DataviewBuilder::new()
69    ///     .set_row_header("Process")
70    ///     .add_value("proc1", "Status", "Running")
71    ///     .build()
72    ///     .unwrap();
73    /// assert_eq!(view.row_header(), "Process");
74    /// ```
75    pub fn row_header(&self) -> &str {
76        &self.row_header
77    }
78
79    /// Returns a headline value by key, if present.
80    pub fn headline(&self, key: &str) -> Option<&str> {
81        self.headlines.get(key).map(String::as_str)
82    }
83
84    /// Returns the headline keys in display order.
85    pub fn headline_order(&self) -> &[String] {
86        &self.headline_order
87    }
88
89    /// Returns a cell value for the given row/column, if present.
90    pub fn value(&self, row: &str, column: &str) -> Option<&str> {
91        self.values
92            .get(row)
93            .and_then(|row_values| row_values.get(column))
94            .map(String::as_str)
95    }
96
97    /// Returns the column names in display order.
98    pub fn column_order(&self) -> &[String] {
99        &self.column_order
100    }
101
102    /// Returns the row names in display order.
103    pub fn row_order(&self) -> &[String] {
104        &self.row_order
105    }
106}
107
108/// Strips Unicode control characters (categories Cc and Cf) except ASCII
109/// whitespace (tab, newline, carriage return, space). Newlines and carriage
110/// returns are subsequently escaped by `escape_nasty_chars`.
111fn strip_unicode_controls(s: &str) -> String {
112    s.chars()
113        .filter(|&c| {
114            if c == '\t' || c == '\n' || c == '\r' || c == ' ' {
115                return true;
116            }
117            !c.is_control() && !is_unicode_format_char(c)
118        })
119        .collect()
120}
121
122/// Returns `true` for Unicode Cf (format) characters — RTL override, zero-width
123/// space, BOM, etc. Rust's `char::is_control()` covers Cc; this covers Cf.
124fn is_unicode_format_char(c: char) -> bool {
125    matches!(c as u32,
126        0x00AD              // SOFT HYPHEN
127        | 0x0600..=0x0605   // Arabic format chars
128        | 0x061C            // ARABIC LETTER MARK
129        | 0x06DD            // ARABIC END OF AYAH
130        | 0x070F            // SYRIAC ABBREVIATION MARK
131        | 0x08E2            // ARABIC DISPUTED END OF AYAH
132        | 0x180E            // MONGOLIAN VOWEL SEPARATOR
133        | 0x200B..=0x200F   // Zero-width space, joiners, LTR/RTL marks
134        | 0x202A..=0x202E   // Directional formatting
135        | 0x2060..=0x2064   // Word joiner, invisible operators
136        | 0x2066..=0x206F   // Directional isolates, deprecated chars
137        | 0xFEFF            // BOM / ZWNBSP
138        | 0xFFF9..=0xFFFB   // Interlinear annotations
139        | 0x110BD           // KAITHI NUMBER SIGN
140        | 0x110CD           // KAITHI NUMBER SIGN ABOVE
141        | 0x13430..=0x13438 // Egyptian hieroglyph format
142        | 0x1BCA0..=0x1BCA3 // Shorthand format controls
143        | 0x1D173..=0x1D17A // Musical symbol format
144        | 0xE0001           // LANGUAGE TAG
145        | 0xE0020..=0xE007F // TAG characters
146    )
147}
148
149trait GeneosEscaping {
150    fn escape_nasty_chars(&self) -> String;
151}
152
153impl GeneosEscaping for str {
154    fn escape_nasty_chars(&self) -> String {
155        let mut output = String::with_capacity(self.len());
156
157        // C1: Escape <!> at string start to prevent headline injection
158        let s = if let Some(rest) = self.strip_prefix("<!>") {
159            output.push_str("\\<!>");
160            rest
161        } else {
162            self
163        };
164
165        for c in s.chars() {
166            match c {
167                '\\' => output.push_str("\\\\"),
168                ',' => output.push_str("\\,"),
169                '\n' => output.push_str("\\n"),
170                '\r' => output.push_str("\\r"),
171                '\0' => output.push_str("\\0"),
172                c => output.push(c),
173            }
174        }
175        output
176    }
177}
178
179fn write_header_row(
180    f: &mut fmt::Formatter<'_>,
181    row_header: &str,
182    columns: &[String],
183) -> fmt::Result {
184    write!(f, "{}", row_header.escape_nasty_chars())?;
185    for col in columns {
186        write!(f, ",{}", col.escape_nasty_chars())?;
187    }
188    writeln!(f)
189}
190
191fn write_headlines(
192    f: &mut fmt::Formatter<'_>,
193    headline_order: &[String],
194    headlines: &HashMap<String, String>,
195) -> fmt::Result {
196    for name in headline_order {
197        if let Some(value) = headlines.get(name) {
198            writeln!(
199                f,
200                "<!>{},{}",
201                name.escape_nasty_chars(),
202                value.escape_nasty_chars()
203            )?;
204        }
205    }
206    Ok(())
207}
208
209fn write_data_rows(
210    f: &mut fmt::Formatter<'_>,
211    rows: &[String],
212    columns: &[String],
213    values: &HashMap<String, HashMap<String, String>>,
214) -> fmt::Result {
215    let number_of_rows = rows.len();
216    for (i, row) in rows.iter().enumerate() {
217        write!(f, "{}", row.escape_nasty_chars())?;
218        for col in columns {
219            write!(f, ",")?;
220            if let Some(value) = values.get(row).and_then(|row_values| row_values.get(col)) {
221                write!(f, "{}", value.escape_nasty_chars())?;
222            }
223        }
224
225        // Only write newline if this isn't the last row
226        if i < number_of_rows - 1 {
227            writeln!(f)?;
228        }
229    }
230
231    Ok(())
232}
233
234impl fmt::Display for Dataview {
235    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236        write_header_row(f, &self.row_header, &self.column_order)?;
237        write_headlines(f, &self.headline_order, &self.headlines)?;
238        write_data_rows(f, &self.row_order, &self.column_order, &self.values)
239    }
240}
241
242impl Dataview {
243    /// Creates a new DataviewBuilder instance
244    ///
245    /// This allows users to create a Dataview without explicitly importing DataviewBuilder
246    ///
247    /// # Example
248    ///
249    /// ```
250    /// use geneos_toolkit::prelude::*;
251    /// let dataview = Dataview::builder()
252    ///     .set_row_header("ID")
253    ///     .add_headline("Total", "42")
254    ///     .add_value("1", "Name", "Alice")
255    ///     .build();
256    /// ```
257    pub fn builder() -> DataviewBuilder {
258        DataviewBuilder::new()
259    }
260}
261
262/// A helper struct to build a row of data.
263///
264/// Rows must be created with [`Row::new`] so every row has an explicit name.
265/// This allows constructing a row with multiple columns before adding it to the Dataview.
266#[derive(Debug, Clone)]
267pub struct Row {
268    name: String,
269    cells: Vec<(String, String)>,
270}
271
272impl Row {
273    /// Creates a new Row with the given name (row identifier).
274    pub fn new(name: impl AsRef<str>) -> Self {
275        Self {
276            name: name.as_ref().to_string(),
277            cells: Vec::new(),
278        }
279    }
280
281    /// Adds a cell (column and value) to the row, preserving insertion order.
282    pub fn add_cell(mut self, column: impl AsRef<str>, value: impl AsRef<str>) -> Self {
283        self.cells
284            .push((column.as_ref().to_string(), value.as_ref().to_string()));
285        self
286    }
287}
288
289/// A Builder for the `Dataview` struct.
290#[derive(Debug, Clone)]
291pub struct DataviewBuilder {
292    row_header: Option<String>,
293    headlines: Option<HashMap<String, String>>,
294    values: Option<HashMap<String, HashMap<String, String>>>,
295    headline_order: Vec<String>, // for the purpose of ordering the headlines
296    column_order: Vec<String>,   // for the purpose of ordering the columns
297    row_order: Vec<String>,      // for the purpose of ordering the rows
298    strip_unicode: bool,
299}
300
301impl Default for DataviewBuilder {
302    fn default() -> Self {
303        Self {
304            row_header: None,
305            headlines: None,
306            values: None,
307            headline_order: Vec::new(),
308            column_order: Vec::new(),
309            row_order: Vec::new(),
310            strip_unicode: true,
311        }
312    }
313}
314
315impl DataviewBuilder {
316    /// Creates a new, empty builder.
317    pub fn new() -> Self {
318        Self::default()
319    }
320
321    /// Controls whether Unicode control characters (categories Cc and Cf,
322    /// excluding ASCII whitespace) are stripped from all input strings.
323    /// Enabled by default. Set to `false` to preserve raw Unicode control characters.
324    pub fn strip_unicode_controls(mut self, strip: bool) -> Self {
325        self.strip_unicode = strip;
326        self
327    }
328
329    /// Sanitize a string according to builder settings.
330    fn sanitize(&self, s: &str) -> String {
331        if self.strip_unicode {
332            strip_unicode_controls(s)
333        } else {
334            s.to_string()
335        }
336    }
337
338    /// Sets the mandatory row header label.
339    pub fn set_row_header(mut self, row_header: impl AsRef<str>) -> Self {
340        self.row_header = Some(self.sanitize(row_header.as_ref()));
341        self
342    }
343
344    /// Adds or replaces a headline value. Order is preserved by first insert.
345    pub fn add_headline(mut self, key: impl AsRef<str>, value: impl AsRef<str>) -> Self {
346        let key_string = self.sanitize(key.as_ref());
347        let value_string = self.sanitize(value.as_ref());
348
349        let mut headlines: HashMap<String, String> = self.headlines.unwrap_or_default();
350
351        if !self.headline_order.contains(&key_string) {
352            self.headline_order.push(key_string.clone());
353        }
354
355        headlines.insert(key_string, value_string);
356        self.headlines = Some(headlines);
357        self
358    }
359
360    /// Adds a single cell value at `row`/`column`, recording insertion order.
361    pub fn add_value(
362        mut self,
363        row: impl AsRef<str>,
364        column: impl AsRef<str>,
365        value: impl AsRef<str>,
366    ) -> Self {
367        let column_string = self.sanitize(column.as_ref());
368        let row_string = self.sanitize(row.as_ref());
369        let value_string = self.sanitize(value.as_ref());
370
371        let mut values: HashMap<String, HashMap<String, String>> = self.values.unwrap_or_default();
372
373        // Track columns in order of insertion (if new)
374        if !self.column_order.contains(&column_string) {
375            self.column_order.push(column_string.clone());
376        }
377
378        // Track rows in order of insertion (if new)
379        if !self.row_order.contains(&row_string) {
380            self.row_order.push(row_string.clone());
381        }
382
383        values
384            .entry(row_string)
385            .or_default()
386            .insert(column_string, value_string);
387        self.values = Some(values);
388        self
389    }
390
391    /// Adds a complete row to the Dataview.
392    ///
393    /// This is a convenience method to add multiple values for the same row at once.
394    ///
395    /// # Example
396    /// ```
397    /// use geneos_toolkit::prelude::*;
398    ///
399    /// let row = Row::new("process1")
400    ///     .add_cell("Status", "Running")
401    ///     .add_cell("CPU", "2.5%");
402    ///
403    /// let dataview = Dataview::builder()
404    ///     .set_row_header("Process")
405    ///     .add_row(row)
406    ///     .build();
407    /// ```
408    pub fn add_row(mut self, row: Row) -> Self {
409        let Row { name, cells } = row;
410        for (col, val) in cells {
411            self = self.add_value(&name, col, val);
412        }
413        self
414    }
415
416    /// Sorts rows in ascending order by row name. Opt-in; default is insertion order.
417    pub fn sort_rows(mut self) -> Self {
418        self.row_order.sort();
419        self
420    }
421
422    /// Sorts rows using a key selector. Opt-in; default is insertion order.
423    pub fn sort_rows_by<K, F>(mut self, mut f: F) -> Self
424    where
425        K: Ord,
426        F: FnMut(&str) -> K,
427    {
428        self.row_order.sort_by_key(|row| f(row));
429        self
430    }
431
432    /// Sorts rows using a custom comparator. Opt-in; default is insertion order.
433    pub fn sort_rows_with<F>(mut self, mut cmp: F) -> Self
434    where
435        F: FnMut(&str, &str) -> std::cmp::Ordering,
436    {
437        self.row_order.sort_by(|a, b| cmp(a, b));
438        self
439    }
440
441    /// Builds the `Dataview`, consuming the builder.
442    ///
443    /// Returns [`DataviewError::MissingRowHeader`] if the row header is not set.
444    /// There must be at least one value.
445    /// Headlines are optional.
446    ///
447    /// The order of the columns and rows is determined by the order in which they are added through
448    /// values using the `add_value` method.
449    ///
450    /// The order of headlines is determined by the order in which they are added through the
451    /// `add_headline` method.
452    ///
453    /// Example:
454    /// ```rust
455    /// use geneos_toolkit::prelude::*;
456    ///
457    /// let view: Dataview = Dataview::builder()
458    ///     .set_row_header("Name")
459    ///     .add_headline("AverageAge", "30")
460    ///     .add_value("Anna", "Age", "30")
461    ///     .add_value("Bertil", "Age", "20")
462    ///     .add_value("Caesar", "Age", "40")
463    ///     .build()
464    ///     .unwrap();
465    ///
466    /// ```
467    pub fn build(self) -> Result<Dataview, DataviewError> {
468        let row_header = self.row_header.ok_or(DataviewError::MissingRowHeader)?;
469
470        if row_header.is_empty() {
471            return Err(DataviewError::EmptyName("row header".into()));
472        }
473
474        let values = self.values.ok_or(DataviewError::MissingValue)?;
475
476        for row in &self.row_order {
477            if row.is_empty() {
478                return Err(DataviewError::EmptyName("row".into()));
479            }
480        }
481
482        for col in &self.column_order {
483            if col.is_empty() {
484                return Err(DataviewError::EmptyName("column".into()));
485            }
486        }
487
488        if let Some(ref headlines) = self.headlines {
489            for key in headlines.keys() {
490                if key.is_empty() {
491                    return Err(DataviewError::EmptyName("headline".into()));
492                }
493            }
494        }
495
496        Ok(Dataview {
497            row_header,
498            headlines: self.headlines.unwrap_or_default(),
499            headline_order: self.headline_order,
500            values,
501            column_order: self.column_order,
502            row_order: self.row_order,
503        })
504    }
505}
506
507/// Prints the result of a Dataview operation and exits the program.
508///
509/// # Arguments
510/// - `dataview`: The `Result` of a Dataview operation, holding either a `Dataview` or a `DataviewError`.
511///
512/// # Returns
513/// - Exits the program with a status code of 0 if successful, or 1 if an error occurred.
514///
515/// # Example
516/// ```rust
517/// use geneos_toolkit::prelude::*;
518///
519/// let dataview = Dataview::builder()
520///    .set_row_header("ID")
521///    .add_headline("Total", "42")
522///    .add_value("1", "Name", "Alice")
523///    .build();
524///
525/// print_result_and_exit(dataview)
526/// ```
527/// Prints the dataview on success or an error on failure, then exits the process.
528pub fn print_result_and_exit(dataview: Result<Dataview, DataviewError>) -> ! {
529    match dataview {
530        Ok(v) => {
531            println!("{v}");
532            std::process::exit(0)
533        }
534        Err(e) => {
535            eprintln!("ERROR: {e}");
536            std::process::exit(1)
537        }
538    }
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544    use pretty_assertions::assert_eq;
545
546    /// Helper function to create a basic dataview for testing
547    fn create_basic_dataview() -> Result<Dataview, DataviewError> {
548        DataviewBuilder::new()
549            .set_row_header("ID")
550            .add_headline("AverageAge", "30")
551            .add_value("1", "Name", "Alice")
552            .add_value("1", "Age", "30")
553            .build()
554    }
555
556    #[test]
557    fn test_dataview_builder_single_row() -> Result<(), DataviewError> {
558        let dataview = create_basic_dataview()?;
559
560        // Test row header
561        assert_eq!(dataview.row_header(), "ID");
562
563        // Test headline
564        assert_eq!(dataview.headline("AverageAge"), Some("30"));
565
566        // Test values
567        assert_eq!(dataview.value("1", "Name"), Some("Alice"));
568        assert_eq!(dataview.value("1", "Age"), Some("30"));
569
570        // Test structure
571        assert_eq!(dataview.row_order().len(), 1);
572        assert_eq!(dataview.column_order().len(), 2);
573        assert!(dataview.column_order().contains(&"Name".to_string()));
574        assert!(dataview.column_order().contains(&"Age".to_string()));
575
576        Ok(())
577    }
578
579    #[test]
580    fn test_dataview_display_format() -> Result<(), DataviewError> {
581        // Test basic display
582        let dataview = create_basic_dataview()?;
583        assert_eq!(
584            dataview.to_string(),
585            "\
586ID,Name,Age
587<!>AverageAge,30
5881,Alice,30"
589        );
590
591        // Test multiple rows and columns
592        let multi_row_dataview = DataviewBuilder::new()
593            .set_row_header("id")
594            // Ensure that headlines appear in the order in which they were added.
595            .add_headline("Baz", "Foo")
596            .add_headline("AlertDetails", "this is red alert")
597            .add_value("001", "name", "agila")
598            .add_value("001", "status", "up")
599            .add_value("001", "Value", "97")
600            .add_value("002", "name", "lawin")
601            .add_value("002", "status", "down")
602            .add_value("002", "Value", "85")
603            .build()?;
604
605        let expected_output = "\
606id,name,status,Value
607<!>Baz,Foo
608<!>AlertDetails,this is red alert
609001,agila,up,97
610002,lawin,down,85";
611
612        assert_eq!(multi_row_dataview.to_string(), expected_output);
613
614        Ok(())
615    }
616
617    #[test]
618    fn test_special_characters_escaping() -> Result<(), DataviewError> {
619        // Test comma escaping in row header, columns, values
620        let dataview = DataviewBuilder::new()
621            .set_row_header("queue,id")
622            .add_value("queue3", "number,code", "7,331")
623            .add_value("queue3", "count", "45,000")
624            .add_value("queue3", "ratio", "0.16")
625            .add_value("queue3", "status", "online")
626            .build()?;
627
628        let expected_output = "\
629queue\\,id,number\\,code,count,ratio,status
630queue3,7\\,331,45\\,000,0.16,online";
631
632        assert_eq!(dataview.to_string(), expected_output);
633
634        // Test other special characters
635        let dataview_special = DataviewBuilder::new()
636            .set_row_header("special")
637            .add_headline("special,headline", "headline value with, comma")
638            .add_value("special_case", "state", "testing: \"quotes\" & <symbols>")
639            .add_value("special_case", "data", "multi-line\ntext")
640            .build()?;
641
642        let output = dataview_special.to_string();
643        assert!(output.contains("special"));
644        assert!(output.contains("<!>special\\,headline,headline value with\\, comma"));
645        assert!(output.contains("testing: \"quotes\" & <symbols>"));
646        assert!(output.contains("multi-line\\ntext"));
647
648        Ok(())
649    }
650
651    #[test]
652    fn test_empty_and_missing_values() -> Result<(), DataviewError> {
653        // Test with some missing values
654        let dataview = DataviewBuilder::new()
655            .set_row_header("item")
656            .add_value("item1", "col1", "value1")
657            .add_value("item1", "col2", "value2")
658            .add_value("item2", "col1", "value3")
659            // Deliberately missing item2/col2
660            .add_value("item3", "col3", "value4") // New column not in other rows
661            .build()?;
662
663        let output = dataview.to_string();
664
665        // Verify output format has empty cells where expected
666        assert!(output.contains("item1,value1,value2,"));
667        assert!(output.contains("item2,value3,,"));
668        assert!(output.contains("item3,,,value4"));
669
670        // Test accessing missing values
671        assert_eq!(dataview.value("item2", "col2"), None);
672        assert_eq!(dataview.value("nonexistent", "col1"), None);
673
674        Ok(())
675    }
676
677    #[test]
678    fn test_dataview_complex() -> Result<(), DataviewError> {
679        // This test creates a more realistic Dataview with many rows, columns and headlines
680        let dataview = DataviewBuilder::new()
681            .set_row_header("cpu")
682            // Add multiple headlines
683            .add_headline("numOnlineCpus", "4")
684            .add_headline("loadAverage1Min", "0.32")
685            .add_headline("loadAverage5Min", "0.45")
686            .add_headline("loadAverage15Min", "0.38")
687            .add_headline("HyperThreadingStatus", "ENABLED")
688            // CPU average row
689            .add_value("Average_cpu", "percentUtilisation", "3.75 %")
690            .add_value("Average_cpu", "percentUserTime", "2.15 %")
691            .add_value("Average_cpu", "percentKernelTime", "1.25 %")
692            .add_value("Average_cpu", "percentIdle", "96.25 %")
693            // CPU 0 with values in all columns
694            .add_value("cpu_0", "type", "GenuineIntel Intel(R)")
695            .add_value("cpu_0", "state", "on-line")
696            .add_value("cpu_0", "clockSpeed", "2500.00 MHz")
697            .add_value("cpu_0", "percentUtilisation", "3.25 %")
698            .add_value("cpu_0", "percentUserTime", "1.95 %")
699            .add_value("cpu_0", "percentKernelTime", "1.30 %")
700            .add_value("cpu_0", "percentIdle", "96.75 %")
701            // CPU 1 with same structure
702            .add_value("cpu_1", "type", "GenuineIntel Intel(R)")
703            .add_value("cpu_1", "state", "on-line")
704            .add_value("cpu_1", "clockSpeed", "2500.00 MHz")
705            .add_value("cpu_1", "percentUtilisation", "4.25 %")
706            .add_value("cpu_1", "percentUserTime", "2.35 %")
707            .add_value("cpu_1", "percentKernelTime", "1.20 %")
708            .add_value("cpu_1", "percentIdle", "95.75 %")
709            // cpu_2 with a comma in one value (needs escaping) and some missing values
710            .add_value("cpu_2", "type", "GenuineIntel, Intel(R)")
711            .add_value("cpu_2", "state", "on-line")
712            .add_value("cpu_2", "clockSpeed", "2,500.00 MHz")
713            // Add another logical CPU
714            .add_value("cpu_0_logical#1", "type", "logical")
715            .add_value("cpu_0_logical#1", "state", "on-line")
716            .add_value("cpu_0_logical#1", "clockSpeed", "2500.00 MHz")
717            .add_value("cpu_0_logical#1", "percentUtilisation", "2.54 %")
718            .build()?;
719
720        // Get the output
721        let output = dataview.to_string();
722
723        // Check structure
724        assert_eq!(dataview.row_order().len(), 5); // 5 rows
725        assert_eq!(dataview.row_order()[0], "Average_cpu".to_string());
726        assert_eq!(dataview.row_order()[1], "cpu_0".to_string());
727        assert_eq!(dataview.row_order()[2], "cpu_1".to_string());
728        assert_eq!(dataview.row_order()[3], "cpu_2".to_string());
729        assert_eq!(dataview.row_order()[4], "cpu_0_logical#1".to_string());
730
731        assert_eq!(dataview.headline_order().len(), 5); // 5 headlines
732
733        // Assert column ordering is preserved
734        let expected_columns = [
735            "percentUtilisation",
736            "percentUserTime",
737            "percentKernelTime",
738            "percentIdle",
739            "type",
740            "state",
741            "clockSpeed",
742        ];
743        for (idx, col) in expected_columns.iter().enumerate() {
744            if idx < dataview.column_order().len() {
745                assert!(dataview.column_order().contains(&col.to_string()));
746            }
747        }
748
749        // Basic format checks
750        assert!(output.starts_with("cpu,"));
751        assert!(output.contains("<!>numOnlineCpus,4\n"));
752        assert!(output.contains("<!>loadAverage1Min,0.32\n"));
753        assert!(output.contains("<!>HyperThreadingStatus,ENABLED\n"));
754
755        // Check comma escaping
756        assert!(output.contains("GenuineIntel\\, Intel(R)"));
757        assert!(output.contains("2\\,500.00 MHz"));
758
759        Ok(())
760    }
761
762    #[test]
763    fn test_error_conditions() -> Result<(), ()> {
764        // Test missing row header
765        let result = DataviewBuilder::new()
766            .add_value("row1", "col1", "value1")
767            .build();
768
769        assert!(matches!(result, Err(DataviewError::MissingRowHeader)));
770
771        // Test missing values
772        let result = DataviewBuilder::new().set_row_header("header").build();
773
774        assert!(matches!(result, Err(DataviewError::MissingValue)));
775
776        // Ensure headlines alone are not enough
777        let result = DataviewBuilder::new()
778            .set_row_header("header")
779            .add_headline("headline1", "value1")
780            .build();
781
782        assert!(matches!(result, Err(DataviewError::MissingValue)));
783
784        Ok(())
785    }
786
787    #[test]
788    fn test_row_builder() -> Result<(), DataviewError> {
789        let row1 = Row::new("process1")
790            .add_cell("Status", "Running")
791            .add_cell("CPU", "2.5%");
792
793        let row2 = Row::new("process2")
794            .add_cell("Status", "Stopped")
795            .add_cell("CPU", "0.0%");
796
797        let dataview = Dataview::builder()
798            .set_row_header("Process")
799            .add_row(row1)
800            .add_row(row2)
801            .build()?;
802
803        let output = dataview.to_string();
804
805        assert!(output.contains("Process,Status,CPU"));
806        assert!(output.contains("process1,Running,2.5%"));
807        assert!(output.contains("process2,Stopped,0.0%"));
808
809        Ok(())
810    }
811
812    #[test]
813    fn test_duplicate_headline_overwrites_value_preserves_order() -> Result<(), DataviewError> {
814        let dataview = DataviewBuilder::new()
815            .set_row_header("id")
816            .add_headline("Status", "initial")
817            .add_headline("Count", "10")
818            .add_headline("Status", "updated")
819            .add_value("r1", "col", "val")
820            .build()?;
821
822        // Value should be overwritten
823        assert_eq!(dataview.headline("Status"), Some("updated"));
824        assert_eq!(dataview.headline("Count"), Some("10"));
825
826        // Order should reflect first insertion: Status before Count
827        assert_eq!(dataview.headline_order(), &["Status", "Count"]);
828
829        // Display should use updated value in original position
830        let output = dataview.to_string();
831        let lines: Vec<&str> = output.lines().collect();
832        assert_eq!(lines[1], "<!>Status,updated");
833        assert_eq!(lines[2], "<!>Count,10");
834
835        Ok(())
836    }
837
838    #[test]
839    fn test_duplicate_cell_overwrites_value_preserves_order() -> Result<(), DataviewError> {
840        let dataview = DataviewBuilder::new()
841            .set_row_header("id")
842            .add_value("row1", "colA", "first")
843            .add_value("row1", "colB", "other")
844            .add_value("row2", "colA", "x")
845            .add_value("row1", "colA", "second")
846            .build()?;
847
848        // Value should be overwritten
849        assert_eq!(dataview.value("row1", "colA"), Some("second"));
850
851        // Row and column order should reflect first insertion only (no duplicates)
852        assert_eq!(dataview.row_order(), &["row1", "row2"]);
853        assert_eq!(dataview.column_order(), &["colA", "colB"]);
854
855        // Display should use the overwritten value
856        let output = dataview.to_string();
857        assert!(output.contains("row1,second,other"));
858
859        Ok(())
860    }
861
862    #[test]
863    fn test_backslash_escaping() -> Result<(), DataviewError> {
864        let dataview = DataviewBuilder::new()
865            .set_row_header("path\\id")
866            .add_headline("dir", "C:\\Users\\test")
867            .add_value("row\\1", "col\\a", "val\\ue")
868            .build()?;
869
870        let output = dataview.to_string();
871        let lines: Vec<&str> = output.lines().collect();
872
873        assert_eq!(lines[0], "path\\\\id,col\\\\a");
874        assert_eq!(lines[1], "<!>dir,C:\\\\Users\\\\test");
875        assert_eq!(lines[2], "row\\\\1,val\\\\ue");
876
877        Ok(())
878    }
879
880    #[test]
881    fn test_accessor_methods_nonexistent_keys() -> Result<(), DataviewError> {
882        let dataview = DataviewBuilder::new()
883            .set_row_header("id")
884            .add_headline("exists", "yes")
885            .add_value("row1", "col1", "val1")
886            .build()?;
887
888        assert_eq!(dataview.headline("nonexistent"), None);
889        assert_eq!(dataview.value("row1", "nonexistent"), None);
890        assert_eq!(dataview.value("nonexistent", "col1"), None);
891        assert_eq!(dataview.value("nonexistent", "nonexistent"), None);
892
893        Ok(())
894    }
895
896    #[test]
897    fn test_dataview_no_headlines() -> Result<(), DataviewError> {
898        let dataview = DataviewBuilder::new()
899            .set_row_header("item")
900            .add_value("a", "x", "1")
901            .add_value("b", "x", "2")
902            .build()?;
903
904        let output = dataview.to_string();
905        assert!(!output.contains("<!>"));
906        assert_eq!(output, "item,x\na,1\nb,2");
907
908        Ok(())
909    }
910
911    #[test]
912    fn test_golden_snapshot_representative_dataview() -> Result<(), DataviewError> {
913        let dataview = DataviewBuilder::new()
914            .set_row_header("service")
915            .add_headline("environment", "production")
916            .add_headline("region", "eu-west-1")
917            .add_value("api-gateway", "status", "running")
918            .add_value("api-gateway", "latency_ms", "12")
919            .add_value("api-gateway", "errors", "0")
920            .add_value("db-primary", "status", "running")
921            .add_value("db-primary", "latency_ms", "3")
922            .add_value("db-primary", "errors", "0")
923            .add_value("cache", "status", "degraded")
924            .add_value("cache", "latency_ms", "45")
925            .add_value("cache", "errors", "7")
926            .build()?;
927
928        let expected = "\
929service,status,latency_ms,errors
930<!>environment,production
931<!>region,eu-west-1
932api-gateway,running,12,0
933db-primary,running,3,0
934cache,degraded,45,7";
935
936        assert_eq!(dataview.to_string(), expected);
937
938        Ok(())
939    }
940
941    // === C1: <!> headline injection ===
942
943    #[test]
944    fn test_escape_headline_prefix_in_row_name() -> Result<(), DataviewError> {
945        // A row name starting with <!> must not render as a headline
946        let dataview = Dataview::builder()
947            .set_row_header("id")
948            .add_value("<!>AlertSeverity,OK", "status", "injected")
949            .build()?;
950
951        let output = dataview.to_string();
952        // The data row must NOT start with raw <!>
953        let data_lines: Vec<&str> = output.lines().filter(|l| !l.starts_with("<!>")).collect();
954        assert!(data_lines.len() >= 2, "Should have header + data row");
955        let data_row = data_lines[1];
956        assert!(
957            !data_row.starts_with("<!>"),
958            "Row name must not produce a fake headline: {data_row}"
959        );
960        // The escaped form should appear
961        assert!(data_row.contains("\\<!>"));
962
963        Ok(())
964    }
965
966    #[test]
967    fn test_escape_headline_prefix_in_value() -> Result<(), DataviewError> {
968        // A value starting with <!> should be escaped
969        let dataview = Dataview::builder()
970            .set_row_header("id")
971            .add_value("row1", "col", "<!>Fake,headline")
972            .build()?;
973
974        let output = dataview.to_string();
975        // Value should contain escaped form
976        assert!(output.contains("\\<!>Fake"));
977
978        Ok(())
979    }
980
981    #[test]
982    fn test_escape_headline_prefix_in_row_header() -> Result<(), DataviewError> {
983        // Row header starting with <!> must be escaped
984        let dataview = Dataview::builder()
985            .set_row_header("<!>header")
986            .add_value("row1", "col", "val")
987            .build()?;
988
989        let output = dataview.to_string();
990        let first_line = output.lines().next().unwrap();
991        assert!(
992            first_line.starts_with("\\<!>header"),
993            "Row header must escape <!>: {first_line}"
994        );
995
996        Ok(())
997    }
998
999    #[test]
1000    fn test_headline_prefix_mid_string_not_escaped() {
1001        // <!> only matters at string start — mid-string is harmless
1002        let escaped = "some<!>text".escape_nasty_chars();
1003        assert_eq!(escaped, "some<!>text");
1004    }
1005
1006    #[test]
1007    fn test_real_headlines_unaffected() -> Result<(), DataviewError> {
1008        // Legitimate headlines must still render with <!> prefix
1009        let dataview = Dataview::builder()
1010            .set_row_header("id")
1011            .add_headline("Status", "OK")
1012            .add_value("r1", "c1", "v1")
1013            .build()?;
1014
1015        let output = dataview.to_string();
1016        assert!(output.contains("<!>Status,OK"));
1017
1018        Ok(())
1019    }
1020
1021    // === H1: null byte passthrough ===
1022
1023    #[test]
1024    fn test_escape_null_byte() {
1025        let escaped = "before\0after".escape_nasty_chars();
1026        assert_eq!(escaped, "before\\0after");
1027        assert!(!escaped.contains('\0'));
1028    }
1029
1030    #[test]
1031    fn test_null_byte_in_value() -> Result<(), DataviewError> {
1032        // Null bytes are stripped by unicode sanitizer (defense in depth:
1033        // escape_nasty_chars would also escape them if they got through)
1034        let dataview = Dataview::builder()
1035            .set_row_header("id")
1036            .add_value("row1", "col", "legitimate\0<!>INJECTED")
1037            .build()?;
1038
1039        let output = dataview.to_string();
1040        assert!(
1041            !output.contains('\0'),
1042            "Null bytes must not appear in output"
1043        );
1044        // \0 is stripped, so "legitimate" and "<!>INJECTED" are concatenated
1045        assert!(output.contains("legitimate<!>INJECTED"));
1046
1047        Ok(())
1048    }
1049
1050    #[test]
1051    fn test_null_byte_in_row_name() -> Result<(), DataviewError> {
1052        let dataview = Dataview::builder()
1053            .set_row_header("id")
1054            .add_value("row\u{0}1", "col", "val")
1055            .build()?;
1056
1057        let output = dataview.to_string();
1058        assert!(!output.contains('\0'));
1059
1060        Ok(())
1061    }
1062
1063    // === M3: unicode control character stripping ===
1064
1065    #[test]
1066    fn test_strip_rtl_override() -> Result<(), DataviewError> {
1067        // U+202E (RTL override) can make "KO" display as "OK" — must be stripped
1068        let dataview = Dataview::builder()
1069            .set_row_header("id")
1070            .add_value("row1", "status", "\u{202E}KO")
1071            .build()?;
1072
1073        let output = dataview.to_string();
1074        assert!(
1075            !output.contains('\u{202E}'),
1076            "RTL override must be stripped"
1077        );
1078        assert!(output.contains("KO"));
1079
1080        Ok(())
1081    }
1082
1083    #[test]
1084    fn test_strip_zero_width_space() -> Result<(), DataviewError> {
1085        // U+200B (zero-width space) breaks rule matching
1086        let dataview = Dataview::builder()
1087            .set_row_header("id")
1088            .add_value("row1", "col", "OK\u{200B}status")
1089            .build()?;
1090
1091        let output = dataview.to_string();
1092        assert!(!output.contains('\u{200B}'));
1093        assert!(output.contains("OKstatus"));
1094
1095        Ok(())
1096    }
1097
1098    #[test]
1099    fn test_strip_bom() -> Result<(), DataviewError> {
1100        // U+FEFF (BOM) at start can confuse encoding detection
1101        let dataview = Dataview::builder()
1102            .set_row_header("id")
1103            .add_value("row1", "col", "\u{FEFF}value")
1104            .build()?;
1105
1106        let output = dataview.to_string();
1107        assert!(!output.contains('\u{FEFF}'));
1108
1109        Ok(())
1110    }
1111
1112    #[test]
1113    fn test_preserve_ascii_whitespace() -> Result<(), DataviewError> {
1114        // Tab and space must survive stripping (they are useful ASCII control/whitespace)
1115        let dataview = Dataview::builder()
1116            .set_row_header("id")
1117            .add_value("row1", "col", "hello\tworld here")
1118            .build()?;
1119
1120        let output = dataview.to_string();
1121        assert!(output.contains("hello\tworld here"));
1122
1123        Ok(())
1124    }
1125
1126    #[test]
1127    fn test_strip_unicode_controls_opt_out() -> Result<(), DataviewError> {
1128        // When strip_unicode_controls(false), control chars pass through
1129        let dataview = Dataview::builder()
1130            .set_row_header("id")
1131            .strip_unicode_controls(false)
1132            .add_value("row1", "col", "\u{202E}KO")
1133            .build()?;
1134
1135        let output = dataview.to_string();
1136        assert!(
1137            output.contains('\u{202E}'),
1138            "RTL override should be preserved when stripping is disabled"
1139        );
1140
1141        Ok(())
1142    }
1143
1144    #[test]
1145    fn test_strip_unicode_in_headline_key_and_value() -> Result<(), DataviewError> {
1146        let dataview = Dataview::builder()
1147            .set_row_header("id")
1148            .add_headline("stat\u{200B}us", "O\u{202E}K")
1149            .add_value("r1", "c1", "v1")
1150            .build()?;
1151
1152        let output = dataview.to_string();
1153        assert!(output.contains("<!>status,OK"));
1154
1155        Ok(())
1156    }
1157
1158    #[test]
1159    fn test_strip_unicode_in_row_and_column_names() -> Result<(), DataviewError> {
1160        let dataview = Dataview::builder()
1161            .set_row_header("i\u{FEFF}d")
1162            .add_value("ro\u{200B}w1", "co\u{202E}l", "val")
1163            .build()?;
1164
1165        let output = dataview.to_string();
1166        let first_line = output.lines().next().unwrap();
1167        assert_eq!(first_line, "id,col");
1168
1169        Ok(())
1170    }
1171
1172    // === L5: empty row/column name rejection ===
1173
1174    #[test]
1175    fn test_reject_empty_row_header() {
1176        let result = Dataview::builder()
1177            .set_row_header("")
1178            .add_value("row1", "col", "val")
1179            .build();
1180
1181        assert!(matches!(result, Err(DataviewError::EmptyName(_))));
1182    }
1183
1184    #[test]
1185    fn test_reject_empty_row_name() {
1186        let result = Dataview::builder()
1187            .set_row_header("id")
1188            .add_value("", "col", "val")
1189            .build();
1190
1191        assert!(matches!(result, Err(DataviewError::EmptyName(_))));
1192    }
1193
1194    #[test]
1195    fn test_reject_empty_column_name() {
1196        let result = Dataview::builder()
1197            .set_row_header("id")
1198            .add_value("row1", "", "val")
1199            .build();
1200
1201        assert!(matches!(result, Err(DataviewError::EmptyName(_))));
1202    }
1203
1204    #[test]
1205    fn test_reject_empty_headline_key() {
1206        let result = Dataview::builder()
1207            .set_row_header("id")
1208            .add_headline("", "val")
1209            .add_value("row1", "col", "val")
1210            .build();
1211
1212        assert!(matches!(result, Err(DataviewError::EmptyName(_))));
1213    }
1214
1215    #[test]
1216    fn test_whitespace_only_name_after_stripping() {
1217        // A name that is only unicode control chars becomes empty after stripping
1218        let result = Dataview::builder()
1219            .set_row_header("id")
1220            .add_value("\u{200B}\u{FEFF}", "col", "val")
1221            .build();
1222
1223        assert!(matches!(result, Err(DataviewError::EmptyName(_))));
1224    }
1225
1226    #[test]
1227    fn test_row_sorting_methods() -> Result<(), DataviewError> {
1228        // Default: insertion order preserved
1229        let default = Dataview::builder()
1230            .set_row_header("id")
1231            .add_value("b", "col", "1")
1232            .add_value("a", "col", "1")
1233            .add_value("c", "col", "1")
1234            .build()?;
1235        assert_eq!(default.row_order(), &["b", "a", "c"]);
1236
1237        // sort_rows: ascending by row name
1238        let sorted = Dataview::builder()
1239            .set_row_header("id")
1240            .add_value("b", "col", "1")
1241            .add_value("a", "col", "1")
1242            .add_value("c", "col", "1")
1243            .sort_rows()
1244            .build()?;
1245        assert_eq!(sorted.row_order(), &["a", "b", "c"]);
1246
1247        // sort_rows_by: custom key (length)
1248        let by_len = Dataview::builder()
1249            .set_row_header("id")
1250            .add_row(Row::new("long").add_cell("v", "1"))
1251            .add_row(Row::new("mid").add_cell("v", "1"))
1252            .add_row(Row::new("s").add_cell("v", "1"))
1253            .sort_rows_by(|name| name.len())
1254            .build()?;
1255        assert_eq!(by_len.row_order(), &["s", "mid", "long"]);
1256
1257        // sort_rows_with: custom comparator (reverse lexicographic)
1258        let reversed = Dataview::builder()
1259            .set_row_header("id")
1260            .add_row(Row::new("alpha").add_cell("v", "1"))
1261            .add_row(Row::new("beta").add_cell("v", "1"))
1262            .add_row(Row::new("gamma").add_cell("v", "1"))
1263            .sort_rows_with(|a, b| b.cmp(a))
1264            .build()?;
1265        assert_eq!(reversed.row_order(), &["gamma", "beta", "alpha"]);
1266
1267        Ok(())
1268    }
1269}
1270
1271#[cfg(test)]
1272mod property_tests {
1273    use super::*;
1274    use proptest::prelude::*;
1275
1276    proptest! {
1277        #[test]
1278        fn test_escape_nasty_chars_no_newlines(s in "\\PC*") {
1279            let escaped = s.escape_nasty_chars();
1280            // The escaped string should not contain raw newlines as they break the protocol
1281            prop_assert!(!escaped.contains('\n'));
1282            prop_assert!(!escaped.contains('\r'));
1283        }
1284
1285        #[test]
1286        fn test_escape_nasty_chars_no_null_bytes(s in "\\PC*") {
1287            let escaped = s.escape_nasty_chars();
1288            prop_assert!(!escaped.contains('\0'), "Null bytes must be escaped");
1289        }
1290
1291        #[test]
1292        fn test_escape_nasty_chars_no_headline_injection(s in "\\PC*") {
1293            let escaped = s.escape_nasty_chars();
1294            // If the original started with <!>, the escaped form must not
1295            if s.starts_with("<!>") {
1296                prop_assert!(
1297                    !escaped.starts_with("<!>"),
1298                    "Escaped string must not start with raw <!>"
1299                );
1300            }
1301        }
1302
1303        #[test]
1304        fn test_dataview_structure_integrity_with_newlines(
1305            row_name in "[a-z]+",
1306            col_name in "[a-z]+",
1307            // Explicitly generate strings with newlines and commas
1308            value in "([a-z]|\n|,|\r)*"
1309        ) {
1310            let res = Dataview::builder()
1311                .set_row_header("row_id")
1312                .add_value(&row_name, &col_name, &value)
1313                .build();
1314
1315            prop_assert!(res.is_ok());
1316            let view = res.unwrap();
1317            let output = view.to_string();
1318
1319            let lines: Vec<&str> = output.lines().collect();
1320
1321            // Should have exactly 2 lines (header + 1 data row)
1322            // If value contained \n and wasn't escaped, this will fail
1323            prop_assert_eq!(lines.len(), 2,
1324                "Output should have exactly 2 lines, found {}. Value was: {:?}",
1325                lines.len(), value);
1326
1327            prop_assert!(lines[1].starts_with(&row_name));
1328        }
1329
1330        #[test]
1331        fn test_dataview_column_count_consistency(
1332            row_header in "[a-z]+",
1333            rows in proptest::collection::vec("[a-z]+", 1..10),
1334            cols in proptest::collection::vec("[a-z]+", 1..10),
1335            val in "\\PC*"
1336        ) {
1337            let mut builder = Dataview::builder().set_row_header(&row_header);
1338
1339            // Add values for every row/col combination
1340            for r in &rows {
1341                for c in &cols {
1342                    builder = builder.add_value(r, c, &val);
1343                }
1344            }
1345
1346            let view = builder.build().unwrap();
1347            let output = view.to_string();
1348
1349            for line in output.lines() {
1350                // Skip headline lines
1351                if line.starts_with("<!>") {
1352                    continue;
1353                }
1354
1355                // Let's count occurrences of "," that are NOT preceded by an ODD number of backslashes
1356                // This is getting complicated to parse with regex/simple checks.
1357                // A comma is a separator if it is NOT escaped.
1358                // It is escaped if it is preceded by a backslash that is NOT itself escaped.
1359
1360                let mut raw_commas = 0;
1361                let mut escaped = false;
1362
1363                for c in line.chars() {
1364                    if escaped {
1365                        escaped = false;
1366                    } else if c == '\\' {
1367                        escaped = true;
1368                    } else if c == ',' {
1369                        raw_commas += 1;
1370                    }
1371                }
1372
1373                // The number of commas should be equal to the number of columns
1374                // Example: row_header, col1, col2 -> 2 commas for 2 columns
1375                // Example: row1, val1, val2 -> 2 commas for 2 columns
1376
1377                let actual_cols = view.column_order().len();
1378
1379                prop_assert_eq!(raw_commas, actual_cols,
1380                    "Line has wrong number of columns: {}", line);
1381            }
1382        }
1383
1384        #[test]
1385        fn test_headline_escaping(
1386            key in "[a-z]+",
1387            value in "([a-z]|\n|,|\r)*"
1388        ) {
1389            let view = Dataview::builder()
1390                .set_row_header("id")
1391                .add_headline(&key, &value)
1392                .add_value("r", "c", "v")
1393                .build()
1394                .unwrap();
1395
1396            let output = view.to_string();
1397            // Find the headline line
1398            let headline_line = output.lines()
1399                .find(|l| l.starts_with("<!>"))
1400                .expect("Should have headline");
1401
1402            // Should be on one line
1403            prop_assert!(!headline_line.contains('\n'));
1404
1405            // Should have exactly one unescaped comma separating key and value
1406            let raw_commas = headline_line.match_indices(',')
1407                .filter(|(idx, _)| *idx == 0 || headline_line.as_bytes()[idx-1] != b'\\')
1408                .count();
1409
1410            prop_assert_eq!(raw_commas, 1, "Headline should have exactly 1 separator comma");
1411        }
1412    }
1413}