1use crate::correlate::DeviceHistory;
11use crate::model::{Attribute, DeviceKey, SourceKind, Value};
12use serde::Serialize;
13
14#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
19pub struct TimelineEvent {
20 pub when: i64,
22 pub device: DeviceKey,
24 pub attribute: Attribute,
26 pub source: SourceKind,
28 pub locator: String,
30}
31
32#[must_use]
37pub fn super_timeline(histories: &[DeviceHistory]) -> Vec<TimelineEvent> {
38 let mut events: Vec<TimelineEvent> = Vec::new();
39 for history in histories {
40 for attr in &history.attributes {
41 for pv in &attr.values {
42 if let Value::Timestamp(when) = pv.value {
43 events.push(TimelineEvent {
44 when,
45 device: history.device.clone(),
46 attribute: attr.attribute,
47 source: pv.provenance.source,
48 locator: pv.provenance.locator.clone(),
49 });
50 }
51 }
52 }
53 }
54 events.sort();
55 events
56}
57
58pub fn timeline_to_jsonl(events: &[TimelineEvent]) -> Result<String, serde_json::Error> {
61 let mut out = String::new();
62 for event in events {
63 out.push_str(&serde_json::to_string(event)?);
64 out.push('\n');
65 }
66 Ok(out)
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72 use crate::correlate;
73 use crate::model::{Attribute, Claim, DeviceKey, Provenance, SourceKind, Value};
74
75 fn ts_claim(device: &str, attr: Attribute, when: i64, src: SourceKind, loc: &str) -> Claim {
76 Claim {
77 device: DeviceKey(device.into()),
78 attribute: attr,
79 value: Value::Timestamp(when),
80 provenance: Provenance {
81 source: src,
82 locator: loc.into(),
83 },
84 }
85 }
86
87 #[test]
88 fn events_are_ordered_chronologically_across_devices() {
89 let claims = vec![
90 ts_claim(
91 "B",
92 Attribute::LastRemoved,
93 3_000,
94 SourceKind::SetupApi,
95 "b-rm",
96 ),
97 ts_claim(
98 "A",
99 Attribute::FirstConnected,
100 1_000,
101 SourceKind::Usbstor,
102 "a-fc",
103 ),
104 ts_claim(
105 "A",
106 Attribute::LastConnected,
107 2_000,
108 SourceKind::Usbstor,
109 "a-lc",
110 ),
111 ];
112 let histories = correlate(&claims);
113 let tl = super_timeline(&histories);
114 let times: Vec<i64> = tl.iter().map(|e| e.when).collect();
115 assert_eq!(times, vec![1_000, 2_000, 3_000]);
116 assert_eq!(tl[0].device, DeviceKey("A".into()));
117 assert_eq!(tl[0].attribute, Attribute::FirstConnected);
118 assert_eq!(tl[2].device, DeviceKey("B".into()));
119 }
120
121 #[test]
122 fn non_timestamp_values_are_omitted() {
123 let claims = vec![
124 Claim {
125 device: DeviceKey("A".into()),
126 attribute: Attribute::VolumeName,
127 value: Value::Text("KINGSTON".into()),
128 provenance: Provenance {
129 source: SourceKind::Usbstor,
130 locator: "vn".into(),
131 },
132 },
133 ts_claim(
134 "A",
135 Attribute::FirstConnected,
136 1_000,
137 SourceKind::Usbstor,
138 "fc",
139 ),
140 ];
141 let tl = super_timeline(&correlate(&claims));
142 assert_eq!(tl.len(), 1);
143 assert_eq!(tl[0].attribute, Attribute::FirstConnected);
144 }
145
146 #[test]
147 fn equal_times_break_ties_deterministically_by_device() {
148 let claims = vec![
149 ts_claim(
150 "Z",
151 Attribute::FirstConnected,
152 500,
153 SourceKind::Usbstor,
154 "z",
155 ),
156 ts_claim(
157 "A",
158 Attribute::FirstConnected,
159 500,
160 SourceKind::Usbstor,
161 "a",
162 ),
163 ];
164 let tl = super_timeline(&correlate(&claims));
165 assert_eq!(tl.len(), 2);
166 assert_eq!(tl[0].device, DeviceKey("A".into()));
167 assert_eq!(tl[1].device, DeviceKey("Z".into()));
168 }
169
170 #[test]
171 fn jsonl_is_one_line_per_event_and_round_trips_the_fields() {
172 let claims = vec![
173 ts_claim(
174 "A",
175 Attribute::FirstConnected,
176 1_000,
177 SourceKind::Usbstor,
178 "a-fc",
179 ),
180 ts_claim(
181 "A",
182 Attribute::LastRemoved,
183 2_000,
184 SourceKind::SetupApi,
185 "a-rm",
186 ),
187 ];
188 let tl = super_timeline(&correlate(&claims));
189 let jsonl = timeline_to_jsonl(&tl).expect("serialize");
190 let lines: Vec<&str> = jsonl.lines().collect();
191 assert_eq!(lines.len(), 2);
192 assert!(lines[0].contains("\"when\":1000"));
194 assert!(lines[0].contains("\"FirstConnected\""));
195 assert!(lines[0].contains("\"Usbstor\""));
196 assert!(lines[1].contains("\"when\":2000"));
197 assert!(lines[1].contains("\"a-rm\""));
198 }
199
200 #[test]
201 fn empty_histories_yield_an_empty_timeline() {
202 assert!(super_timeline(&[]).is_empty());
203 assert_eq!(timeline_to_jsonl(&[]).expect("serialize"), "");
204 }
205}