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#[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 pub fn row_header(&self) -> &str {
73 &self.row_header
74 }
75
76 pub fn headline(&self, key: &str) -> Option<&String> {
78 self.headlines.get(key)
79 }
80
81 pub fn headline_order(&self) -> &[String] {
83 &self.headline_order
84 }
85
86 pub fn value(&self, row: &str, column: &str) -> Option<&String> {
88 self.values.get(&(row.to_string(), column.to_string()))
89 }
90
91 pub fn column_order(&self) -> &[String] {
93 &self.column_order
94 }
95
96 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 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 pub fn builder() -> DataviewBuilder {
201 DataviewBuilder::new()
202 }
203}
204
205#[derive(Debug, Clone, Default)]
209pub struct Row {
210 name: String,
211 cells: Vec<(String, String)>,
212}
213
214impl Row {
215 pub fn new(name: impl ToString) -> Self {
217 Self {
218 name: name.to_string(),
219 cells: Vec::new(),
220 }
221 }
222
223 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#[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>, column_order: Vec<String>, row_order: Vec<String>, }
240
241impl DataviewBuilder {
242 pub fn new() -> Self {
244 Self::default()
245 }
246
247 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 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 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 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 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 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 pub fn sort_rows(mut self) -> Self {
315 self.row_order.sort();
316 self
317 }
318
319 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 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 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
380pub 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 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 assert_eq!(dataview.row_header(), "ID");
435
436 assert_eq!(dataview.headline("AverageAge"), Some(&"30".to_string()));
438
439 assert_eq!(dataview.value("1", "Name"), Some(&"Alice".to_string()));
441 assert_eq!(dataview.value("1", "Age"), Some(&"30".to_string()));
442
443 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 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 let multi_row_dataview = DataviewBuilder::new()
466 .set_row_header("id")
467 .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 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 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 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 .add_value("item3", "col3", "value4") .build()?;
535
536 let output = dataview.to_string();
537
538 assert!(output.contains("item1,value1,value2,"));
540 assert!(output.contains("item2,value3,,"));
541 assert!(output.contains("item3,,,value4"));
542
543 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 let dataview = DataviewBuilder::new()
554 .set_row_header("cpu")
555 .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 .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 .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 .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 .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_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 let output = dataview.to_string();
595
596 assert_eq!(dataview.row_order().len(), 5); 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); 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 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 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 let result = DataviewBuilder::new()
639 .add_value("row1", "col1", "value1")
640 .build();
641
642 assert!(matches!(result, Err(DataviewError::MissingRowHeader)));
643
644 let result = DataviewBuilder::new().set_row_header("header").build();
646
647 assert!(matches!(result, Err(DataviewError::MissingValue)));
648
649 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 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 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 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 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 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 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 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 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 if line.starts_with("<!>") {
793 continue;
794 }
795
796 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 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 let headline_line = output.lines()
841 .find(|l| l.starts_with("<!>"))
842 .expect("Should have headline");
843
844 prop_assert!(!headline_line.contains('\n'));
846
847 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}