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#[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 pub fn row_header(&self) -> &str {
76 &self.row_header
77 }
78
79 pub fn headline(&self, key: &str) -> Option<&str> {
81 self.headlines.get(key).map(String::as_str)
82 }
83
84 pub fn headline_order(&self) -> &[String] {
86 &self.headline_order
87 }
88
89 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 pub fn column_order(&self) -> &[String] {
99 &self.column_order
100 }
101
102 pub fn row_order(&self) -> &[String] {
104 &self.row_order
105 }
106}
107
108fn 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
122fn is_unicode_format_char(c: char) -> bool {
125 matches!(c as u32,
126 0x00AD | 0x0600..=0x0605 | 0x061C | 0x06DD | 0x070F | 0x08E2 | 0x180E | 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0x2066..=0x206F | 0xFEFF | 0xFFF9..=0xFFFB | 0x110BD | 0x110CD | 0x13430..=0x13438 | 0x1BCA0..=0x1BCA3 | 0x1D173..=0x1D17A | 0xE0001 | 0xE0020..=0xE007F )
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 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 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 pub fn builder() -> DataviewBuilder {
258 DataviewBuilder::new()
259 }
260}
261
262#[derive(Debug, Clone)]
267pub struct Row {
268 name: String,
269 cells: Vec<(String, String)>,
270}
271
272impl Row {
273 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 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#[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>, column_order: Vec<String>, row_order: Vec<String>, 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 pub fn new() -> Self {
318 Self::default()
319 }
320
321 pub fn strip_unicode_controls(mut self, strip: bool) -> Self {
325 self.strip_unicode = strip;
326 self
327 }
328
329 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 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 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 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 if !self.column_order.contains(&column_string) {
375 self.column_order.push(column_string.clone());
376 }
377
378 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 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 pub fn sort_rows(mut self) -> Self {
418 self.row_order.sort();
419 self
420 }
421
422 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 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 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
507pub 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 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 assert_eq!(dataview.row_header(), "ID");
562
563 assert_eq!(dataview.headline("AverageAge"), Some("30"));
565
566 assert_eq!(dataview.value("1", "Name"), Some("Alice"));
568 assert_eq!(dataview.value("1", "Age"), Some("30"));
569
570 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 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 let multi_row_dataview = DataviewBuilder::new()
593 .set_row_header("id")
594 .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 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 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 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 .add_value("item3", "col3", "value4") .build()?;
662
663 let output = dataview.to_string();
664
665 assert!(output.contains("item1,value1,value2,"));
667 assert!(output.contains("item2,value3,,"));
668 assert!(output.contains("item3,,,value4"));
669
670 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 let dataview = DataviewBuilder::new()
681 .set_row_header("cpu")
682 .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 .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 .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 .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 .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_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 let output = dataview.to_string();
722
723 assert_eq!(dataview.row_order().len(), 5); 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); 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 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 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 let result = DataviewBuilder::new()
766 .add_value("row1", "col1", "value1")
767 .build();
768
769 assert!(matches!(result, Err(DataviewError::MissingRowHeader)));
770
771 let result = DataviewBuilder::new().set_row_header("header").build();
773
774 assert!(matches!(result, Err(DataviewError::MissingValue)));
775
776 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 assert_eq!(dataview.headline("Status"), Some("updated"));
824 assert_eq!(dataview.headline("Count"), Some("10"));
825
826 assert_eq!(dataview.headline_order(), &["Status", "Count"]);
828
829 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 assert_eq!(dataview.value("row1", "colA"), Some("second"));
850
851 assert_eq!(dataview.row_order(), &["row1", "row2"]);
853 assert_eq!(dataview.column_order(), &["colA", "colB"]);
854
855 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 #[test]
944 fn test_escape_headline_prefix_in_row_name() -> Result<(), DataviewError> {
945 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 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 assert!(data_row.contains("\\<!>"));
962
963 Ok(())
964 }
965
966 #[test]
967 fn test_escape_headline_prefix_in_value() -> Result<(), DataviewError> {
968 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 assert!(output.contains("\\<!>Fake"));
977
978 Ok(())
979 }
980
981 #[test]
982 fn test_escape_headline_prefix_in_row_header() -> Result<(), DataviewError> {
983 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 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 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 #[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 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 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 #[test]
1066 fn test_strip_rtl_override() -> Result<(), DataviewError> {
1067 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 if line.starts_with("<!>") {
1352 continue;
1353 }
1354
1355 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 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 let headline_line = output.lines()
1399 .find(|l| l.starts_with("<!>"))
1400 .expect("Should have headline");
1401
1402 prop_assert!(!headline_line.contains('\n'));
1404
1405 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}