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#[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 pub fn row_header(&self) -> &str {
75 &self.row_header
76 }
77
78 pub fn headline(&self, key: &str) -> Option<&String> {
80 self.headlines.get(key)
81 }
82
83 pub fn headline_order(&self) -> &[String] {
85 &self.headline_order
86 }
87
88 pub fn value(&self, row: &str, column: &str) -> Option<&String> {
90 self.values.get(&(row.to_string(), column.to_string()))
91 }
92
93 pub fn column_order(&self) -> &[String] {
95 &self.column_order
96 }
97
98 pub fn row_order(&self) -> &[String] {
100 &self.row_order
101 }
102}
103
104fn 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
118fn is_unicode_format_char(c: char) -> bool {
121 matches!(c as u32,
122 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 )
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 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 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 pub fn builder() -> DataviewBuilder {
254 DataviewBuilder::new()
255 }
256}
257
258#[derive(Debug, Clone, Default)]
262pub struct Row {
263 name: String,
264 cells: Vec<(String, String)>,
265}
266
267impl Row {
268 pub fn new(name: impl ToString) -> Self {
270 Self {
271 name: name.to_string(),
272 cells: Vec::new(),
273 }
274 }
275
276 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#[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>, column_order: Vec<String>, row_order: Vec<String>, 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 pub fn new() -> Self {
312 Self::default()
313 }
314
315 pub fn strip_unicode_controls(mut self, strip: bool) -> Self {
319 self.strip_unicode = strip;
320 self
321 }
322
323 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 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 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 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 if !self.column_order.contains(&column_string) {
364 self.column_order.push(column_string.clone());
365 }
366
367 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 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 pub fn sort_rows(mut self) -> Self {
404 self.row_order.sort();
405 self
406 }
407
408 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 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 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
493pub 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 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 assert_eq!(dataview.row_header(), "ID");
548
549 assert_eq!(dataview.headline("AverageAge"), Some(&"30".to_string()));
551
552 assert_eq!(dataview.value("1", "Name"), Some(&"Alice".to_string()));
554 assert_eq!(dataview.value("1", "Age"), Some(&"30".to_string()));
555
556 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 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 let multi_row_dataview = DataviewBuilder::new()
579 .set_row_header("id")
580 .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 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 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 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 .add_value("item3", "col3", "value4") .build()?;
648
649 let output = dataview.to_string();
650
651 assert!(output.contains("item1,value1,value2,"));
653 assert!(output.contains("item2,value3,,"));
654 assert!(output.contains("item3,,,value4"));
655
656 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 let dataview = DataviewBuilder::new()
667 .set_row_header("cpu")
668 .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 .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 .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 .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 .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_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 let output = dataview.to_string();
708
709 assert_eq!(dataview.row_order().len(), 5); 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); 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 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 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 let result = DataviewBuilder::new()
752 .add_value("row1", "col1", "value1")
753 .build();
754
755 assert!(matches!(result, Err(DataviewError::MissingRowHeader)));
756
757 let result = DataviewBuilder::new().set_row_header("header").build();
759
760 assert!(matches!(result, Err(DataviewError::MissingValue)));
761
762 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 assert_eq!(dataview.headline("Status"), Some(&"updated".to_string()));
810 assert_eq!(dataview.headline("Count"), Some(&"10".to_string()));
811
812 assert_eq!(dataview.headline_order(), &["Status", "Count"]);
814
815 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 assert_eq!(dataview.value("row1", "colA"), Some(&"second".to_string()));
836
837 assert_eq!(dataview.row_order(), &["row1", "row2"]);
839 assert_eq!(dataview.column_order(), &["colA", "colB"]);
840
841 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 #[test]
930 fn test_escape_headline_prefix_in_row_name() -> Result<(), DataviewError> {
931 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 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 assert!(data_row.contains("\\<!>"));
948
949 Ok(())
950 }
951
952 #[test]
953 fn test_escape_headline_prefix_in_value() -> Result<(), DataviewError> {
954 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 assert!(output.contains("\\<!>Fake"));
963
964 Ok(())
965 }
966
967 #[test]
968 fn test_escape_headline_prefix_in_row_header() -> Result<(), DataviewError> {
969 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 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 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 #[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 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 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 #[test]
1052 fn test_strip_rtl_override() -> Result<(), DataviewError> {
1053 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 if line.starts_with("<!>") {
1338 continue;
1339 }
1340
1341 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 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 let headline_line = output.lines()
1386 .find(|l| l.starts_with("<!>"))
1387 .expect("Should have headline");
1388
1389 prop_assert!(!headline_line.contains('\n'));
1391
1392 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}