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}
10
11impl fmt::Display for DataviewError {
12    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
13        match self {
14            DataviewError::MissingRowHeader => write!(f, "The Dataview must have a row header"),
15            DataviewError::MissingValue => write!(f, "The Dataview must have at least one value"),
16        }
17    }
18}
19
20impl Error for DataviewError {}
21
22/// A Geneos Dataview object.
23///
24/// This struct represents a Dataview, which is a structured representation of data
25/// with a row header, headlines, and values.
26///
27/// Example Dataview format:
28/// ```text
29/// row_header,column1,column2
30/// <!>headline1,value1
31/// <!>headline2,value2
32/// row1,value1,value2
33/// row2,value1,value2
34/// ```
35///
36/// Example with data:
37/// ```text
38/// cpu,percentUtilisation,percentIdle
39/// <!>numOnlineCpus,2
40/// <!>loadAverage1Min,0.32
41/// <!>loadAverage5Min,0.45
42/// <!>loadAverage15Min,0.38
43/// <!>HyperThreadingStatus,ENABLED
44/// Average_cpu,3.75 %,96.25 %
45/// cpu_0,3.25 %,96.75 %
46/// cpu_0_logical#1,2.54 %,97.46 %
47/// cpu_0_logical#2,2.54 %,97.46 %
48/// ```
49#[derive(Debug, Default, Clone, Eq, PartialEq)]
50pub struct Dataview {
51    row_header: String,
52    headlines: HashMap<String, String>,
53    headline_order: Vec<String>,
54    values: HashMap<(String, String), String>,
55    column_order: Vec<String>,
56    row_order: Vec<String>,
57}
58
59impl Dataview {
60    /// Returns the row header label for this dataview.
61    ///
62    /// # Example
63    /// ```
64    /// use geneos_toolkit::dataview::DataviewBuilder;
65    /// let view = DataviewBuilder::new()
66    ///     .set_row_header("Process")
67    ///     .add_value("proc1", "Status", "Running")
68    ///     .build()
69    ///     .unwrap();
70    /// assert_eq!(view.row_header(), "Process");
71    /// ```
72    pub fn row_header(&self) -> &str {
73        &self.row_header
74    }
75
76    /// Returns a headline value by key, if present.
77    pub fn headline(&self, key: &str) -> Option<&String> {
78        self.headlines.get(key)
79    }
80
81    /// Returns the headline keys in display order.
82    pub fn headline_order(&self) -> &[String] {
83        &self.headline_order
84    }
85
86    /// Returns a cell value for the given row/column, if present.
87    pub fn value(&self, row: &str, column: &str) -> Option<&String> {
88        self.values.get(&(row.to_string(), column.to_string()))
89    }
90
91    /// Returns the column names in display order.
92    pub fn column_order(&self) -> &[String] {
93        &self.column_order
94    }
95
96    /// Returns the row names in display order.
97    pub fn row_order(&self) -> &[String] {
98        &self.row_order
99    }
100}
101
102trait GeneosEscaping {
103    fn escape_nasty_chars(&self) -> String;
104}
105
106impl GeneosEscaping for str {
107    fn escape_nasty_chars(&self) -> String {
108        let mut output = String::with_capacity(self.len());
109        for c in self.chars() {
110            match c {
111                '\\' => output.push_str("\\\\"),
112                ',' => output.push_str("\\,"),
113                '\n' => output.push_str("\\n"),
114                '\r' => output.push_str("\\r"),
115                c => output.push(c),
116            }
117        }
118        output
119    }
120}
121
122fn write_header_row(
123    f: &mut fmt::Formatter<'_>,
124    row_header: &str,
125    columns: &[String],
126) -> fmt::Result {
127    write!(f, "{}", row_header.escape_nasty_chars())?;
128    for col in columns {
129        write!(f, ",{}", col.escape_nasty_chars())?;
130    }
131    writeln!(f)
132}
133
134fn write_headlines(
135    f: &mut fmt::Formatter<'_>,
136    headline_order: &[String],
137    headlines: &HashMap<String, String>,
138) -> fmt::Result {
139    for name in headline_order {
140        if let Some(value) = headlines.get(name) {
141            writeln!(
142                f,
143                "<!>{},{}",
144                name.escape_nasty_chars(),
145                value.escape_nasty_chars()
146            )?;
147        }
148    }
149    Ok(())
150}
151
152fn write_data_rows(
153    f: &mut fmt::Formatter<'_>,
154    rows: &[String],
155    columns: &[String],
156    values: &HashMap<(String, String), String>,
157) -> fmt::Result {
158    let number_of_rows = rows.len();
159    for (i, row) in rows.iter().enumerate() {
160        write!(f, "{}", row.escape_nasty_chars())?;
161        for col in columns {
162            write!(f, ",")?;
163            if let Some(value) = values.get(&(row.to_string(), col.to_string())) {
164                write!(f, "{}", value.escape_nasty_chars())?;
165            }
166        }
167
168        // Only write newline if this isn't the last row
169        if i < number_of_rows - 1 {
170            writeln!(f)?;
171        }
172    }
173
174    Ok(())
175}
176
177impl fmt::Display for Dataview {
178    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179        write_header_row(f, &self.row_header, &self.column_order)?;
180        write_headlines(f, &self.headline_order, &self.headlines)?;
181        write_data_rows(f, &self.row_order, &self.column_order, &self.values)
182    }
183}
184
185impl Dataview {
186    /// Creates a new DataviewBuilder instance
187    ///
188    /// This allows users to create a Dataview without explicitly importing DataviewBuilder
189    ///
190    /// # Example
191    ///
192    /// ```
193    /// use geneos_toolkit::prelude::*;
194    /// let dataview = Dataview::builder()
195    ///     .set_row_header("ID")
196    ///     .add_headline("Total", "42")
197    ///     .add_value("1", "Name", "Alice")
198    ///     .build();
199    /// ```
200    pub fn builder() -> DataviewBuilder {
201        DataviewBuilder::new()
202    }
203}
204
205/// A helper struct to build a row of data.
206///
207/// This allows constructing a row with multiple columns before adding it to the Dataview.
208#[derive(Debug, Clone, Default)]
209pub struct Row {
210    name: String,
211    cells: Vec<(String, String)>,
212}
213
214impl Row {
215    /// Creates a new Row with the given name (row identifier).
216    pub fn new(name: impl ToString) -> Self {
217        Self {
218            name: name.to_string(),
219            cells: Vec::new(),
220        }
221    }
222
223    /// Adds a cell (column and value) to the row, preserving insertion order.
224    pub fn add_cell(mut self, column: impl ToString, value: impl ToString) -> Self {
225        self.cells.push((column.to_string(), value.to_string()));
226        self
227    }
228}
229
230/// A Builder for the `Dataview` struct.
231#[derive(Debug, Default, Clone)]
232pub struct DataviewBuilder {
233    row_header: Option<String>,
234    headlines: Option<HashMap<String, String>>,
235    values: Option<HashMap<(String, String), String>>,
236    headline_order: Vec<String>, // for the purpose of ordering the headlines
237    column_order: Vec<String>,   // for the purpose of ordering the columns
238    row_order: Vec<String>,      // for the purpose of ordering the rows
239}
240
241impl DataviewBuilder {
242    /// Creates a new, empty builder.
243    pub fn new() -> Self {
244        Self::default()
245    }
246
247    /// Sets the mandatory row header label.
248    pub fn set_row_header(mut self, row_header: &str) -> Self {
249        self.row_header = Some(row_header.to_string());
250        self
251    }
252
253    /// Adds or replaces a headline value. Order is preserved by first insert.
254    pub fn add_headline<T: ToString>(mut self, key: &str, value: T) -> Self {
255        let mut headlines: HashMap<String, String> = self.headlines.unwrap_or_default();
256
257        let key_string = key.to_string();
258        if !self.headline_order.contains(&key_string) {
259            self.headline_order.push(key_string.clone());
260        }
261
262        headlines.insert(key_string, value.to_string());
263        self.headlines = Some(headlines);
264        self
265    }
266
267    /// Adds a single cell value at `row`/`column`, recording insertion order.
268    pub fn add_value<T: ToString>(mut self, row: &str, column: &str, value: T) -> Self {
269        let mut values: HashMap<(String, String), String> = self.values.unwrap_or_default();
270
271        // Track columns in order of insertion (if new)
272        let column_string = column.to_string();
273        if !self.column_order.contains(&column_string) {
274            self.column_order.push(column_string.clone());
275        }
276
277        // Track rows in order of insertion (if new)
278        let row_string = row.to_string();
279        if !self.row_order.contains(&row_string) {
280            self.row_order.push(row_string.clone());
281        }
282
283        values.insert((row_string, column_string), value.to_string());
284        self.values = Some(values);
285        self
286    }
287
288    /// Adds a complete row to the Dataview.
289    ///
290    /// This is a convenience method to add multiple values for the same row at once.
291    ///
292    /// # Example
293    /// ```
294    /// use geneos_toolkit::prelude::*;
295    ///
296    /// let row = Row::new("process1")
297    ///     .add_cell("Status", "Running")
298    ///     .add_cell("CPU", "2.5%");
299    ///
300    /// let dataview = Dataview::builder()
301    ///     .set_row_header("Process")
302    ///     .add_row(row)
303    ///     .build();
304    /// ```
305    pub fn add_row(mut self, row: Row) -> Self {
306        for (col, val) in row.cells {
307            self = self.add_value(&row.name, &col, &val);
308        }
309        self
310    }
311
312    /// Sorts rows in ascending order by row name. Opt-in; default is insertion order.
313    /// Sorts rows in ascending order by row name. Opt-in; default is insertion order.
314    pub fn sort_rows(mut self) -> Self {
315        self.row_order.sort();
316        self
317    }
318
319    /// Sorts rows using a key selector. Opt-in; default is insertion order.
320    pub fn sort_rows_by<K, F>(mut self, mut f: F) -> Self
321    where
322        K: Ord,
323        F: FnMut(&str) -> K,
324    {
325        self.row_order.sort_by_key(|row| f(row));
326        self
327    }
328
329    /// Sorts rows using a custom comparator. Opt-in; default is insertion order.
330    pub fn sort_rows_with<F>(mut self, mut cmp: F) -> Self
331    where
332        F: FnMut(&str, &str) -> std::cmp::Ordering,
333    {
334        self.row_order.sort_by(|a, b| cmp(a, b));
335        self
336    }
337
338    /// Builds the `Dataview`, consuming the builder.
339    ///
340    /// The `row_header` must be set before the build or a panic will occur.
341    /// There must be at least one value.
342    /// Headlines are optional.
343    ///
344    /// The order of the columns and rows is determined by the order in which they are added through
345    /// values using the `add_value` method.
346    ///
347    /// The order of headlines is determined by the order in which they are added through the
348    /// `add_headline` method.
349    ///
350    /// Example:
351    /// ```rust
352    /// use geneos_toolkit::prelude::*;
353    ///
354    /// let view: Dataview = Dataview::builder()
355    ///     .set_row_header("Name")
356    ///     .add_headline("AverageAge", "30")
357    ///     .add_value("Anna", "Age", "30")
358    ///     .add_value("Bertil", "Age", "20")
359    ///     .add_value("Caesar", "Age", "40")
360    ///     .build()
361    ///     .unwrap();
362    ///
363    /// ```
364    pub fn build(self) -> Result<Dataview, DataviewError> {
365        let row_header = self.row_header.ok_or(DataviewError::MissingRowHeader)?;
366
367        let values = self.values.ok_or(DataviewError::MissingValue)?;
368
369        Ok(Dataview {
370            row_header,
371            headlines: self.headlines.unwrap_or_default(),
372            headline_order: self.headline_order,
373            values,
374            column_order: self.column_order,
375            row_order: self.row_order,
376        })
377    }
378}
379
380/// Prints the result of a Dataview operation and exits the program.
381///
382/// # Arguments
383/// - `dataview`: The `Result` of a Dataview operation, holding either a `Dataview` or a `DataviewError`.
384///
385/// # Returns
386/// - Exits the program with a status code of 0 if successful, or 1 if an error occurred.
387///
388/// # Example
389/// ```rust
390/// use geneos_toolkit::prelude::*;
391///
392/// let dataview = Dataview::builder()
393///    .set_row_header("ID")
394///    .add_headline("Total", "42")
395///    .add_value("1", "Name", "Alice")
396///    .build();
397///
398/// print_result_and_exit(dataview)
399/// ```
400/// Prints the dataview on success or an error on failure, then exits the process.
401pub fn print_result_and_exit(dataview: Result<Dataview, DataviewError>) -> ! {
402    match dataview {
403        Ok(v) => {
404            println!("{v}");
405            std::process::exit(0)
406        }
407        Err(e) => {
408            eprintln!("ERROR: {e}");
409            std::process::exit(1)
410        }
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use pretty_assertions::assert_eq;
418
419    /// Helper function to create a basic dataview for testing
420    fn create_basic_dataview() -> Result<Dataview, DataviewError> {
421        DataviewBuilder::new()
422            .set_row_header("ID")
423            .add_headline("AverageAge", "30")
424            .add_value("1", "Name", "Alice")
425            .add_value("1", "Age", "30")
426            .build()
427    }
428
429    #[test]
430    fn test_dataview_builder_single_row() -> Result<(), DataviewError> {
431        let dataview = create_basic_dataview()?;
432
433        // Test row header
434        assert_eq!(dataview.row_header(), "ID");
435
436        // Test headline
437        assert_eq!(dataview.headline("AverageAge"), Some(&"30".to_string()));
438
439        // Test values
440        assert_eq!(dataview.value("1", "Name"), Some(&"Alice".to_string()));
441        assert_eq!(dataview.value("1", "Age"), Some(&"30".to_string()));
442
443        // Test structure
444        assert_eq!(dataview.row_order().len(), 1);
445        assert_eq!(dataview.column_order().len(), 2);
446        assert!(dataview.column_order().contains(&"Name".to_string()));
447        assert!(dataview.column_order().contains(&"Age".to_string()));
448
449        Ok(())
450    }
451
452    #[test]
453    fn test_dataview_display_format() -> Result<(), DataviewError> {
454        // Test basic display
455        let dataview = create_basic_dataview()?;
456        assert_eq!(
457            dataview.to_string(),
458            "\
459ID,Name,Age
460<!>AverageAge,30
4611,Alice,30"
462        );
463
464        // Test multiple rows and columns
465        let multi_row_dataview = DataviewBuilder::new()
466            .set_row_header("id")
467            // Ensure that headlines appear in the order in which they were added.
468            .add_headline("Baz", "Foo")
469            .add_headline("AlertDetails", "this is red alert")
470            .add_value("001", "name", "agila")
471            .add_value("001", "status", "up")
472            .add_value("001", "Value", "97")
473            .add_value("002", "name", "lawin")
474            .add_value("002", "status", "down")
475            .add_value("002", "Value", "85")
476            .build()?;
477
478        let expected_output = "\
479id,name,status,Value
480<!>Baz,Foo
481<!>AlertDetails,this is red alert
482001,agila,up,97
483002,lawin,down,85";
484
485        assert_eq!(multi_row_dataview.to_string(), expected_output);
486
487        Ok(())
488    }
489
490    #[test]
491    fn test_special_characters_escaping() -> Result<(), DataviewError> {
492        // Test comma escaping in row header, columns, values
493        let dataview = DataviewBuilder::new()
494            .set_row_header("queue,id")
495            .add_value("queue3", "number,code", "7,331")
496            .add_value("queue3", "count", "45,000")
497            .add_value("queue3", "ratio", "0.16")
498            .add_value("queue3", "status", "online")
499            .build()?;
500
501        let expected_output = "\
502queue\\,id,number\\,code,count,ratio,status
503queue3,7\\,331,45\\,000,0.16,online";
504
505        assert_eq!(dataview.to_string(), expected_output);
506
507        // Test other special characters
508        let dataview_special = DataviewBuilder::new()
509            .set_row_header("special")
510            .add_headline("special,headline", "headline value with, comma")
511            .add_value("special_case", "state", "testing: \"quotes\" & <symbols>")
512            .add_value("special_case", "data", "multi-line\ntext")
513            .build()?;
514
515        let output = dataview_special.to_string();
516        assert!(output.contains("special"));
517        assert!(output.contains("<!>special\\,headline,headline value with\\, comma"));
518        assert!(output.contains("testing: \"quotes\" & <symbols>"));
519        assert!(output.contains("multi-line\\ntext"));
520
521        Ok(())
522    }
523
524    #[test]
525    fn test_empty_and_missing_values() -> Result<(), DataviewError> {
526        // Test with some missing values
527        let dataview = DataviewBuilder::new()
528            .set_row_header("item")
529            .add_value("item1", "col1", "value1")
530            .add_value("item1", "col2", "value2")
531            .add_value("item2", "col1", "value3")
532            // Deliberately missing item2/col2
533            .add_value("item3", "col3", "value4") // New column not in other rows
534            .build()?;
535
536        let output = dataview.to_string();
537
538        // Verify output format has empty cells where expected
539        assert!(output.contains("item1,value1,value2,"));
540        assert!(output.contains("item2,value3,,"));
541        assert!(output.contains("item3,,,value4"));
542
543        // Test accessing missing values
544        assert_eq!(dataview.value("item2", "col2"), None);
545        assert_eq!(dataview.value("nonexistent", "col1"), None);
546
547        Ok(())
548    }
549
550    #[test]
551    fn test_dataview_complex() -> Result<(), DataviewError> {
552        // This test creates a more realistic Dataview with many rows, columns and headlines
553        let dataview = DataviewBuilder::new()
554            .set_row_header("cpu")
555            // Add multiple headlines
556            .add_headline("numOnlineCpus", "4")
557            .add_headline("loadAverage1Min", "0.32")
558            .add_headline("loadAverage5Min", "0.45")
559            .add_headline("loadAverage15Min", "0.38")
560            .add_headline("HyperThreadingStatus", "ENABLED")
561            // CPU average row
562            .add_value("Average_cpu", "percentUtilisation", "3.75 %")
563            .add_value("Average_cpu", "percentUserTime", "2.15 %")
564            .add_value("Average_cpu", "percentKernelTime", "1.25 %")
565            .add_value("Average_cpu", "percentIdle", "96.25 %")
566            // CPU 0 with values in all columns
567            .add_value("cpu_0", "type", "GenuineIntel Intel(R)")
568            .add_value("cpu_0", "state", "on-line")
569            .add_value("cpu_0", "clockSpeed", "2500.00 MHz")
570            .add_value("cpu_0", "percentUtilisation", "3.25 %")
571            .add_value("cpu_0", "percentUserTime", "1.95 %")
572            .add_value("cpu_0", "percentKernelTime", "1.30 %")
573            .add_value("cpu_0", "percentIdle", "96.75 %")
574            // CPU 1 with same structure
575            .add_value("cpu_1", "type", "GenuineIntel Intel(R)")
576            .add_value("cpu_1", "state", "on-line")
577            .add_value("cpu_1", "clockSpeed", "2500.00 MHz")
578            .add_value("cpu_1", "percentUtilisation", "4.25 %")
579            .add_value("cpu_1", "percentUserTime", "2.35 %")
580            .add_value("cpu_1", "percentKernelTime", "1.20 %")
581            .add_value("cpu_1", "percentIdle", "95.75 %")
582            // cpu_2 with a comma in one value (needs escaping) and some missing values
583            .add_value("cpu_2", "type", "GenuineIntel, Intel(R)")
584            .add_value("cpu_2", "state", "on-line")
585            .add_value("cpu_2", "clockSpeed", "2,500.00 MHz")
586            // Add another logical CPU
587            .add_value("cpu_0_logical#1", "type", "logical")
588            .add_value("cpu_0_logical#1", "state", "on-line")
589            .add_value("cpu_0_logical#1", "clockSpeed", "2500.00 MHz")
590            .add_value("cpu_0_logical#1", "percentUtilisation", "2.54 %")
591            .build()?;
592
593        // Get the output
594        let output = dataview.to_string();
595
596        // Check structure
597        assert_eq!(dataview.row_order().len(), 5); // 5 rows
598        assert_eq!(dataview.row_order()[0], "Average_cpu".to_string());
599        assert_eq!(dataview.row_order()[1], "cpu_0".to_string());
600        assert_eq!(dataview.row_order()[2], "cpu_1".to_string());
601        assert_eq!(dataview.row_order()[3], "cpu_2".to_string());
602        assert_eq!(dataview.row_order()[4], "cpu_0_logical#1".to_string());
603
604        assert_eq!(dataview.headlines.len(), 5); // 5 headlines
605
606        // Assert column ordering is preserved
607        let expected_columns = [
608            "percentUtilisation",
609            "percentUserTime",
610            "percentKernelTime",
611            "percentIdle",
612            "type",
613            "state",
614            "clockSpeed",
615        ];
616        for (idx, col) in expected_columns.iter().enumerate() {
617            if idx < dataview.column_order().len() {
618                assert!(dataview.column_order().contains(&col.to_string()));
619            }
620        }
621
622        // Basic format checks
623        assert!(output.starts_with("cpu,"));
624        assert!(output.contains("<!>numOnlineCpus,4\n"));
625        assert!(output.contains("<!>loadAverage1Min,0.32\n"));
626        assert!(output.contains("<!>HyperThreadingStatus,ENABLED\n"));
627
628        // Check comma escaping
629        assert!(output.contains("GenuineIntel\\, Intel(R)"));
630        assert!(output.contains("2\\,500.00 MHz"));
631
632        Ok(())
633    }
634
635    #[test]
636    fn test_error_conditions() -> Result<(), ()> {
637        // Test missing row header
638        let result = DataviewBuilder::new()
639            .add_value("row1", "col1", "value1")
640            .build();
641
642        assert!(matches!(result, Err(DataviewError::MissingRowHeader)));
643
644        // Test missing values
645        let result = DataviewBuilder::new().set_row_header("header").build();
646
647        assert!(matches!(result, Err(DataviewError::MissingValue)));
648
649        // Ensure headlines alone are not enough
650        let result = DataviewBuilder::new()
651            .set_row_header("header")
652            .add_headline("headline1", "value1")
653            .build();
654
655        assert!(matches!(result, Err(DataviewError::MissingValue)));
656
657        Ok(())
658    }
659
660    #[test]
661    fn test_row_builder() -> Result<(), DataviewError> {
662        let row1 = Row::new("process1")
663            .add_cell("Status", "Running")
664            .add_cell("CPU", "2.5%");
665
666        let row2 = Row::new("process2")
667            .add_cell("Status", "Stopped")
668            .add_cell("CPU", "0.0%");
669
670        let dataview = Dataview::builder()
671            .set_row_header("Process")
672            .add_row(row1)
673            .add_row(row2)
674            .build()?;
675
676        let output = dataview.to_string();
677
678        assert!(output.contains("Process,Status,CPU"));
679        assert!(output.contains("process1,Running,2.5%"));
680        assert!(output.contains("process2,Stopped,0.0%"));
681
682        Ok(())
683    }
684
685    #[test]
686    fn test_row_sorting_methods() -> Result<(), DataviewError> {
687        // Default: insertion order preserved
688        let default = Dataview::builder()
689            .set_row_header("id")
690            .add_value("b", "col", "1")
691            .add_value("a", "col", "1")
692            .add_value("c", "col", "1")
693            .build()?;
694        assert_eq!(default.row_order(), &["b", "a", "c"]);
695
696        // sort_rows: ascending by row name
697        let sorted = Dataview::builder()
698            .set_row_header("id")
699            .add_value("b", "col", "1")
700            .add_value("a", "col", "1")
701            .add_value("c", "col", "1")
702            .sort_rows()
703            .build()?;
704        assert_eq!(sorted.row_order(), &["a", "b", "c"]);
705
706        // sort_rows_by: custom key (length)
707        let by_len = Dataview::builder()
708            .set_row_header("id")
709            .add_row(Row::new("long").add_cell("v", "1"))
710            .add_row(Row::new("mid").add_cell("v", "1"))
711            .add_row(Row::new("s").add_cell("v", "1"))
712            .sort_rows_by(|name| name.len())
713            .build()?;
714        assert_eq!(by_len.row_order(), &["s", "mid", "long"]);
715
716        // sort_rows_with: custom comparator (reverse lexicographic)
717        let reversed = Dataview::builder()
718            .set_row_header("id")
719            .add_row(Row::new("alpha").add_cell("v", "1"))
720            .add_row(Row::new("beta").add_cell("v", "1"))
721            .add_row(Row::new("gamma").add_cell("v", "1"))
722            .sort_rows_with(|a, b| b.cmp(a))
723            .build()?;
724        assert_eq!(reversed.row_order(), &["gamma", "beta", "alpha"]);
725
726        Ok(())
727    }
728}
729
730#[cfg(test)]
731mod property_tests {
732    use super::*;
733    use proptest::prelude::*;
734
735    proptest! {
736        #[test]
737        fn test_escape_nasty_chars_no_newlines(s in "\\PC*") {
738            let escaped = s.escape_nasty_chars();
739            // The escaped string should not contain raw newlines as they break the protocol
740            prop_assert!(!escaped.contains('\n'));
741            prop_assert!(!escaped.contains('\r'));
742        }
743
744        #[test]
745        fn test_dataview_structure_integrity_with_newlines(
746            row_name in "[a-z]+",
747            col_name in "[a-z]+",
748            // Explicitly generate strings with newlines and commas
749            value in "([a-z]|\n|,|\r)*"
750        ) {
751            let res = Dataview::builder()
752                .set_row_header("row_id")
753                .add_value(&row_name, &col_name, &value)
754                .build();
755
756            prop_assert!(res.is_ok());
757            let view = res.unwrap();
758            let output = view.to_string();
759
760            let lines: Vec<&str> = output.lines().collect();
761
762            // Should have exactly 2 lines (header + 1 data row)
763            // If value contained \n and wasn't escaped, this will fail
764            prop_assert_eq!(lines.len(), 2,
765                "Output should have exactly 2 lines, found {}. Value was: {:?}",
766                lines.len(), value);
767
768            prop_assert!(lines[1].starts_with(&row_name));
769        }
770
771        #[test]
772        fn test_dataview_column_count_consistency(
773            row_header in "[a-z]+",
774            rows in proptest::collection::vec("[a-z]+", 1..10),
775            cols in proptest::collection::vec("[a-z]+", 1..10),
776            val in "\\PC*"
777        ) {
778            let mut builder = Dataview::builder().set_row_header(&row_header);
779
780            // Add values for every row/col combination
781            for r in &rows {
782                for c in &cols {
783                    builder = builder.add_value(r, c, &val);
784                }
785            }
786
787            let view = builder.build().unwrap();
788            let output = view.to_string();
789
790            for line in output.lines() {
791                // Skip headline lines
792                if line.starts_with("<!>") {
793                    continue;
794                }
795
796                // Let's count occurrences of "," that are NOT preceded by an ODD number of backslashes
797                // This is getting complicated to parse with regex/simple checks.
798                // A comma is a separator if it is NOT escaped.
799                // It is escaped if it is preceded by a backslash that is NOT itself escaped.
800
801                let mut raw_commas = 0;
802                let mut chars = line.chars().peekable();
803                let mut escaped = false;
804
805                while let Some(c) = chars.next() {
806                    if escaped {
807                        escaped = false;
808                    } else if c == '\\' {
809                        escaped = true;
810                    } else if c == ',' {
811                        raw_commas += 1;
812                    }
813                }
814
815                // The number of commas should be equal to the number of columns
816                // Example: row_header, col1, col2 -> 2 commas for 2 columns
817                // Example: row1, val1, val2 -> 2 commas for 2 columns
818
819                let actual_cols = view.column_order().len();
820
821                prop_assert_eq!(raw_commas, actual_cols,
822                    "Line has wrong number of columns: {}", line);
823            }
824        }
825
826        #[test]
827        fn test_headline_escaping(
828            key in "[a-z]+",
829            value in "([a-z]|\n|,|\r)*"
830        ) {
831            let view = Dataview::builder()
832                .set_row_header("id")
833                .add_headline(&key, &value)
834                .add_value("r", "c", "v")
835                .build()
836                .unwrap();
837
838            let output = view.to_string();
839            // Find the headline line
840            let headline_line = output.lines()
841                .find(|l| l.starts_with("<!>"))
842                .expect("Should have headline");
843
844            // Should be on one line
845            prop_assert!(!headline_line.contains('\n'));
846
847            // Should have exactly one unescaped comma separating key and value
848            let raw_commas = headline_line.match_indices(',')
849                .filter(|(idx, _)| *idx == 0 || headline_line.as_bytes()[idx-1] != b'\\')
850                .count();
851
852            prop_assert_eq!(raw_commas, 1, "Headline should have exactly 1 separator comma");
853        }
854    }
855}