1use serde_json::Value;
12use std::path::PathBuf;
13
14pub struct SwitchEvent {
15 pub ts: i64,
16 pub tool: String,
17 pub account: String,
18 pub action: String,
22}
23
24fn timeline_path() -> Option<PathBuf> {
27 if let Some(p) = std::env::var_os("SESSIONWIKI_SWAPDEX_TIMELINE") {
28 return Some(p.into());
29 }
30 Some(dirs::data_dir()?.join("swapdex").join("timeline.jsonl"))
31}
32
33pub fn load_events() -> Vec<SwitchEvent> {
47 let Some(path) = timeline_path() else {
48 return Vec::new();
49 };
50 let Ok(text) = crate::util::read_tail(&path, 1024 * 1024) else {
57 return Vec::new();
58 };
59 parse_events(&text)
60}
61
62fn parse_events(text: &str) -> Vec<SwitchEvent> {
65 let mut out = Vec::new();
66 for line in text.lines() {
67 let Ok(v) = serde_json::from_str::<Value>(line) else {
68 continue;
69 };
70 let action = match v.get("action") {
74 None => "use",
75 Some(Value::String(action)) => action,
76 Some(_) => continue,
77 };
78 if !matches!(action, "use" | "restore" | "serve" | "serve-off") {
79 continue;
80 }
81 if let (Some(ts), Some(tool), Some(account)) =
82 (v["ts"].as_i64(), v["tool"].as_str(), v["account"].as_str())
83 {
84 let account: String = account.chars().filter(|c| !c.is_control()).collect();
85 if action != "serve-off" && account.is_empty() {
89 continue;
90 }
91 out.push(SwitchEvent {
92 ts,
93 tool: tool.to_string(),
94 action: action.to_string(),
95 account,
98 });
99 }
100 }
101 out
102}
103
104pub fn account_for(
110 events: &[SwitchEvent],
111 tool: &str,
112 started_rfc3339: Option<&str>,
113) -> Option<String> {
114 let started = chrono::DateTime::parse_from_rfc3339(started_rfc3339?)
115 .ok()?
116 .timestamp();
117 events
118 .iter()
119 .enumerate()
120 .filter(|(_, e)| e.tool == tool && e.ts <= started)
121 .max_by_key(|(order, e)| (e.ts, *order))
122 .and_then(|(_, e)| match e.action.as_str() {
123 "use" | "restore" | "serve" => Some(e.account.clone()),
124 "serve-off" => None,
125 _ => None,
126 })
127}
128
129pub fn annotate<'a, I: IntoIterator<Item = &'a mut crate::index::SessionRow>>(rows: I) {
132 let events = load_events();
133 if events.is_empty() {
134 return;
135 }
136 for r in rows {
137 r.account = account_for(&events, &r.tool, r.started.as_deref());
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 fn ev(ts: i64, tool: &str, account: &str) -> SwitchEvent {
146 SwitchEvent {
147 ts,
148 tool: tool.into(),
149 account: account.into(),
150 action: "use".into(),
151 }
152 }
153
154 fn at(ts: i64) -> String {
155 chrono::DateTime::from_timestamp(ts, 0)
156 .unwrap()
157 .to_rfc3339()
158 }
159
160 fn serve(ts: i64, tool: &str, account: &str) -> SwitchEvent {
161 SwitchEvent {
162 action: "serve".into(),
163 ..ev(ts, tool, account)
164 }
165 }
166
167 #[test]
168 fn the_parser_keeps_serve_lines() {
169 let text = concat!(
171 r#"{"ts":1787186784,"tool":"claude-code","account":"rnd","action":"serve"}"#,
172 "\n",
173 r#"{"ts":1784504808,"tool":"codex","account":"codex","action":"use"}"#,
174 "\n",
175 r#"{"ts":1788488191,"tool":"codex","account":"work","action":"serve","by":"swapdex"}"#,
176 "\n",
177 r#"{"ts":1,"tool":"codex","account":"legacy"}"#,
178 "\n",
179 r#"{"ts":3,"tool":"codex","account":"","action":"serve-off"}"#,
180 "\n",
181 r#"not json"#,
182 "\n",
183 r#"{"ts":2,"tool":"codex","account":"nope","action":"something-new"}"#,
184 "\n",
185 r#"{"ts":4,"tool":"codex","account":"typed-null","action":null}"#,
186 );
187 let events = parse_events(text);
188 let kept: Vec<(&str, &str)> = events
189 .iter()
190 .map(|e| (e.action.as_str(), e.account.as_str()))
191 .collect();
192 assert_eq!(
193 kept,
194 vec![
195 ("serve", "rnd"),
196 ("use", "codex"),
197 ("serve", "work"),
198 ("use", "legacy"),
199 ("serve-off", ""),
200 ],
201 "serve boundaries and legacy uses are kept; unknown actions are not"
202 );
203 }
204
205 #[test]
206 fn serve_off_clears_attribution_until_a_later_activation() {
207 let off = SwitchEvent {
208 ts: 200,
209 tool: "codex".into(),
210 account: String::new(),
211 action: "serve-off".into(),
212 };
213 let events = vec![serve(100, "codex", "payer"), off];
214 assert_eq!(
215 account_for(&events, "codex", Some(&at(250))),
216 None,
217 "serve-off is a state boundary, not an empty account badge"
218 );
219
220 for action in ["use", "restore", "serve"] {
221 let mut resumed = events
222 .iter()
223 .map(|e| SwitchEvent {
224 ts: e.ts,
225 tool: e.tool.clone(),
226 account: e.account.clone(),
227 action: e.action.clone(),
228 })
229 .collect::<Vec<_>>();
230 resumed.push(SwitchEvent {
231 ts: 300,
232 tool: "codex".into(),
233 account: "home".into(),
234 action: action.into(),
235 });
236 assert_eq!(
237 account_for(&resumed, "codex", Some(&at(350))).as_deref(),
238 Some("home"),
239 "a later {action} restores attribution"
240 );
241 }
242 }
243
244 #[test]
245 fn equal_timestamps_use_append_order() {
246 let events = parse_events(concat!(
247 r#"{"ts":100,"tool":"codex","account":"payer","action":"serve"}"#,
248 "\n",
249 r#"{"ts":100,"tool":"codex","account":"","action":"serve-off"}"#,
250 "\n",
251 r#"{"ts":100,"tool":"codex","account":"home","action":"use"}"#,
252 ));
253 assert_eq!(
254 account_for(&events[..2], "codex", Some(&at(100))),
255 None,
256 "the later appended serve-off wins a timestamp tie"
257 );
258 assert_eq!(
259 account_for(&events, "codex", Some(&at(100))).as_deref(),
260 Some("home"),
261 "a still-later use at the same timestamp wins"
262 );
263 }
264
265 #[test]
266 fn a_serve_is_evidence_of_who_was_active() {
267 let only_serves = vec![serve(200, "claude-code", "kong")];
271 assert_eq!(
272 account_for(&only_serves, "claude-code", Some(&at(300))).as_deref(),
273 Some("kong"),
274 "190 serves naming three accounts is not 'no information'"
275 );
276
277 let stale_use = vec![ev(100, "codex", "codex"), serve(200, "codex", "work")];
278 assert_eq!(
279 account_for(&stale_use, "codex", Some(&at(300))).as_deref(),
280 Some("work"),
281 "the newest evidence wins, not the oldest kind of event"
282 );
283 assert_eq!(account_for(&stale_use, "codex", Some(&at(50))), None);
285 }
286
287 #[test]
288 fn attributes_to_the_last_switch_before_start() {
289 let events = vec![
290 ev(100, "codex", "work"),
291 ev(200, "codex", "personal"),
292 ev(150, "claude-code", "work"),
293 ];
294 let started = chrono::DateTime::from_timestamp(250, 0)
296 .unwrap()
297 .to_rfc3339();
298 assert_eq!(
299 account_for(&events, "codex", Some(&started)).as_deref(),
300 Some("personal")
301 );
302 assert_eq!(
304 account_for(&events, "claude-code", Some(&started)).as_deref(),
305 Some("work")
306 );
307 let early = chrono::DateTime::from_timestamp(50, 0)
309 .unwrap()
310 .to_rfc3339();
311 assert_eq!(account_for(&events, "codex", Some(&early)), None);
312 assert_eq!(account_for(&events, "codex", None), None);
314 }
315}