1use crate::store::Store;
4use crate::tick::{source_ref_key, Tick};
5use serde_json::{json, Value};
6use std::io::Write;
7use time::{format_description::well_known::Rfc3339, OffsetDateTime};
8
9pub fn append(
15 store: &Store,
16 op: &str,
17 tick: Option<&Tick>,
18 verdict: Option<&str>,
19 masked_stale: Option<&str>,
20) {
21 let now = OffsetDateTime::now_utc();
22 let ts = now.format(&Rfc3339).unwrap_or_default();
23 let mut e = json!({ "ts": ts, "op": op });
24 if let Some(o) = e.as_object_mut() {
25 if let Some(t) = tick {
26 o.insert("tick_id".into(), Value::String(t.id.clone()));
27 if let Some(sr) = &t.source_ref {
28 o.insert("source_ref".into(), Value::String(source_ref_key(sr)));
29 }
30 if let Some(age) = age_bucket(&t.held_since, now.unix_timestamp()) {
31 o.insert("age".into(), Value::String(age.into()));
32 }
33 }
34 if let Some(v) = verdict {
35 o.insert("verdict".into(), Value::String(v.into()));
36 }
37 if let Some(m) = masked_stale {
38 o.insert("masked_stale".into(), Value::String(m.into()));
39 }
40 }
41 let dir = store.root.join("results");
42 if std::fs::create_dir_all(&dir).is_err() {
43 return;
44 }
45 if let Ok(mut f) = std::fs::OpenOptions::new()
47 .create(true)
48 .append(true)
49 .open(dir.join("events.jsonl"))
50 {
51 let _ = writeln!(f, "{}", serde_json::to_string(&e).unwrap_or_default());
52 }
53}
54
55fn age_bucket(held_since: &str, now_unix: i64) -> Option<&'static str> {
59 let then = OffsetDateTime::parse(held_since, &Rfc3339).ok()?;
60 let days = (now_unix - then.unix_timestamp()) / 86_400;
61 Some(if days < 1 {
62 "fresh"
63 } else if days < 7 {
64 "days"
65 } else if days < 30 {
66 "weeks"
67 } else if days < 365 {
68 "months"
69 } else {
70 "year+"
71 })
72}
73
74#[cfg(test)]
75mod tests {
76 use super::age_bucket;
77 use time::{format_description::well_known::Rfc3339, OffsetDateTime};
78
79 const NOW: i64 = 1_750_000_000; fn held(secs_ago: i64) -> String {
82 OffsetDateTime::from_unix_timestamp(NOW - secs_ago)
83 .unwrap()
84 .format(&Rfc3339)
85 .unwrap()
86 }
87
88 #[test]
89 fn age_bucket_should_label_each_threshold() {
90 let h = 3_600;
91 let d = 86_400;
92 assert_eq!(age_bucket(&held(0), NOW), Some("fresh"));
94 assert_eq!(age_bucket(&held(23 * h), NOW), Some("fresh"));
95 assert_eq!(age_bucket(&held(25 * h), NOW), Some("days"));
96 assert_eq!(age_bucket(&held(6 * d), NOW), Some("days"));
97 assert_eq!(age_bucket(&held(8 * d), NOW), Some("weeks"));
98 assert_eq!(age_bucket(&held(29 * d), NOW), Some("weeks"));
99 assert_eq!(age_bucket(&held(31 * d), NOW), Some("months"));
100 assert_eq!(age_bucket(&held(364 * d), NOW), Some("months"));
101 assert_eq!(age_bucket(&held(366 * d), NOW), Some("year+"));
102 }
103
104 #[test]
105 fn age_bucket_should_be_none_when_held_since_is_unparseable() {
106 assert_eq!(age_bucket("not a timestamp", NOW), None);
108 assert_eq!(age_bucket("", NOW), None);
109 }
110}