Skip to main content

geneos_toolkit/
dataview.rs

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