1use serde::ser::SerializeStruct;
2use serde::{Serialize, Serializer};
3use serde_json::Value;
4
5#[derive(Debug, Clone)]
6pub struct ReportEntry {
7 pub rule_id: String,
8 pub name: String,
9 pub action: String,
10 pub before: Option<Value>,
11 pub after: Option<Value>,
12 pub applied: bool,
13 pub error: Option<String>,
14 pub value_list: Option<crate::value_lists::Evidence>,
15}
16
17impl Serialize for ReportEntry {
18 fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
19 if let Some(evidence) = &self.value_list {
20 return evidence.serialize(serializer);
21 }
22 let mut value = serializer.serialize_struct("ReportEntry", 7)?;
23 value.serialize_field("rule_id", &self.rule_id)?;
24 value.serialize_field("name", &self.name)?;
25 value.serialize_field("action", &self.action)?;
26 value.serialize_field("before", &self.before)?;
27 value.serialize_field("after", &self.after)?;
28 value.serialize_field("applied", &self.applied)?;
29 value.serialize_field("error", &self.error)?;
30 value.end()
31 }
32}
33
34impl From<crate::value_lists::Evidence> for ReportEntry {
35 fn from(evidence: crate::value_lists::Evidence) -> Self {
36 Self {
37 rule_id: evidence.rule_id.clone(),
38 name: evidence.name.clone(),
39 action: serde_json::to_value(evidence.action)
40 .expect("action serializes")
41 .as_str()
42 .unwrap()
43 .into(),
44 before: None,
45 after: None,
46 applied: evidence.outcome == crate::value_lists::Outcome::Applied,
47 error: None,
48 value_list: Some(evidence),
49 }
50 }
51}
52
53#[derive(Debug, Clone, Serialize)]
54pub struct ChangeReport {
55 pub profile: String,
56 pub host: String,
57 pub space: String,
58 pub applied: bool,
59 pub entries: Vec<ReportEntry>,
60}
61
62impl ChangeReport {
63 pub fn counts(&self) -> (usize, usize, usize, usize) {
64 let created = self
65 .entries
66 .iter()
67 .filter(|e| e.action == "create" && e.applied)
68 .count();
69 let updated = self
70 .entries
71 .iter()
72 .filter(|e| e.action == "update" && e.applied)
73 .count();
74 let skipped = self
75 .entries
76 .iter()
77 .filter(|e| e.action == "skipped_remote_only")
78 .count();
79 let failed = self.entries.iter().filter(|e| e.error.is_some()).count();
80 (created, updated, skipped, failed)
81 }
82
83 pub fn pending(&self) -> usize {
84 self.entries
85 .iter()
86 .filter(|e| {
87 matches!(e.action.as_str(), "create" | "update") && !e.applied && e.error.is_none()
88 })
89 .count()
90 }
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96
97 fn entry(action: &str, applied: bool, error: Option<&str>) -> ReportEntry {
98 ReportEntry {
99 rule_id: "x".into(),
100 name: "X".into(),
101 action: action.into(),
102 before: None,
103 after: None,
104 applied,
105 error: error.map(String::from),
106 value_list: None,
107 }
108 }
109
110 fn report(entries: Vec<ReportEntry>) -> ChangeReport {
111 ChangeReport {
112 profile: "default".into(),
113 host: "kb.example.com".into(),
114 space: "default".into(),
115 applied: false,
116 entries,
117 }
118 }
119
120 #[test]
121 fn pending_counts_unapplied_unfailed_create_and_update_entries() {
122 let r = report(vec![
123 entry("create", false, None),
124 entry("update", false, None),
125 entry("skipped_remote_only", false, None),
126 ]);
127 assert_eq!(r.pending(), 2);
128 }
129
130 #[test]
131 fn pending_is_zero_once_every_actionable_entry_has_an_outcome() {
132 let r = report(vec![
133 entry("create", true, None),
134 entry("update", false, Some("conflict")),
135 ]);
136 assert_eq!(r.pending(), 0, "applied or failed entries are not pending");
137 }
138
139 #[test]
140 fn counts_are_unaffected_by_pending_entries() {
141 let r = report(vec![
142 entry("create", false, None),
143 entry("update", true, None),
144 ]);
145 let (created, updated, skipped, failed) = r.counts();
146 assert_eq!((created, updated, skipped, failed), (0, 1, 0, 0));
147 }
148}