1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6pub enum SessionSource {
7 ClaudeCodeCLI,
9 ClaudeDesktop,
11}
12
13impl SessionSource {
14 pub fn from_path(path: &str) -> Self {
16 if path.contains("local-agent-mode-sessions") {
17 SessionSource::ClaudeDesktop
18 } else {
19 SessionSource::ClaudeCodeCLI
20 }
21 }
22
23 pub fn display_name(&self) -> &'static str {
25 match self {
26 SessionSource::ClaudeCodeCLI => "CLI",
27 SessionSource::ClaudeDesktop => "Desktop",
28 }
29 }
30}
31
32pub fn extract_session_id(json: &serde_json::Value) -> Option<String> {
35 json.get("sessionId")
36 .or_else(|| json.get("session_id"))
37 .and_then(|v| v.as_str())
38 .map(|s| s.to_string())
39}
40
41pub fn extract_timestamp(json: &serde_json::Value) -> Option<DateTime<Utc>> {
44 json.get("timestamp")
45 .or_else(|| json.get("_audit_timestamp"))
46 .and_then(|v| v.as_str())
47 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
48 .map(|dt| dt.with_timezone(&Utc))
49}
50
51pub fn extract_uuid(json: &serde_json::Value) -> Option<String> {
53 json.get("uuid")
54 .and_then(|v| v.as_str())
55 .map(|s| s.to_string())
56}
57
58pub fn extract_parent_uuid(json: &serde_json::Value) -> Option<String> {
60 json.get("parentUuid")
61 .and_then(|v| v.as_str())
62 .map(|s| s.to_string())
63}
64
65pub fn extract_leaf_uuid(json: &serde_json::Value) -> Option<String> {
67 json.get("leafUuid")
68 .and_then(|v| v.as_str())
69 .map(|s| s.to_string())
70}
71
72pub fn extract_record_type(json: &serde_json::Value) -> Option<&str> {
74 json.get("type").and_then(|v| v.as_str())
75}
76
77const AUTOMATION_MARKERS: &[(&str, &str)] = &[
80 ("<<<RALPHEX:", "ralphex"),
81 ("<scheduled-task", "scheduled"),
82];
83const SYNTHETIC_LINEAR_FIELD: &str = "ccsSyntheticLinear";
84
85pub fn detect_automation(content: &str) -> Option<&'static str> {
88 for &(marker, tool_name) in AUTOMATION_MARKERS {
89 if content.contains(marker) {
90 return Some(tool_name);
91 }
92 }
93 None
94}
95
96pub fn mark_synthetic_linear_record(json: &mut serde_json::Value) {
98 json[SYNTHETIC_LINEAR_FIELD] = serde_json::Value::Bool(true);
99}
100
101pub fn is_synthetic_linear_record(json: &serde_json::Value) -> bool {
103 json.get(SYNTHETIC_LINEAR_FIELD)
104 .and_then(|v| v.as_bool())
105 .unwrap_or(false)
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 #[test]
113 fn test_session_source_from_cli_path() {
114 let path = "/Users/user/.claude/projects/-Users-user-myproject/abc123.jsonl";
115 assert_eq!(SessionSource::from_path(path), SessionSource::ClaudeCodeCLI);
116 }
117
118 #[test]
119 fn test_session_source_from_desktop_path() {
120 let path = "/Users/user/Library/Application Support/Claude/local-agent-mode-sessions/uuid1/uuid2/local_session/audit.jsonl";
121 assert_eq!(SessionSource::from_path(path), SessionSource::ClaudeDesktop);
122 }
123
124 #[test]
125 fn test_session_source_display_name() {
126 assert_eq!(SessionSource::ClaudeCodeCLI.display_name(), "CLI");
127 assert_eq!(SessionSource::ClaudeDesktop.display_name(), "Desktop");
128 }
129
130 #[test]
131 fn test_extract_session_id_cli_format() {
132 let json: serde_json::Value = serde_json::json!({"sessionId": "abc123", "type": "user"});
133 assert_eq!(extract_session_id(&json), Some("abc123".to_string()));
134 }
135
136 #[test]
137 fn test_extract_session_id_desktop_format() {
138 let json: serde_json::Value =
139 serde_json::json!({"session_id": "desktop-456", "type": "user"});
140 assert_eq!(extract_session_id(&json), Some("desktop-456".to_string()));
141 }
142
143 #[test]
144 fn test_extract_session_id_cli_takes_precedence() {
145 let json: serde_json::Value =
146 serde_json::json!({"sessionId": "cli", "session_id": "desktop"});
147 assert_eq!(extract_session_id(&json), Some("cli".to_string()));
148 }
149
150 #[test]
151 fn test_extract_timestamp_cli_format() {
152 let json: serde_json::Value = serde_json::json!({"timestamp": "2025-01-09T10:00:00Z"});
153 let ts = extract_timestamp(&json).unwrap();
154 assert_eq!(ts.to_rfc3339(), "2025-01-09T10:00:00+00:00");
155 }
156
157 #[test]
158 fn test_extract_timestamp_desktop_format() {
159 let json: serde_json::Value =
160 serde_json::json!({"_audit_timestamp": "2025-01-09T10:00:00.000Z"});
161 assert!(extract_timestamp(&json).is_some());
162 }
163
164 #[test]
165 fn test_extract_uuid() {
166 let json: serde_json::Value = serde_json::json!({"uuid": "u1"});
167 assert_eq!(extract_uuid(&json), Some("u1".to_string()));
168 }
169
170 #[test]
171 fn test_extract_uuid_missing() {
172 let json: serde_json::Value = serde_json::json!({"type": "user"});
173 assert_eq!(extract_uuid(&json), None);
174 }
175
176 #[test]
177 fn test_extract_parent_uuid() {
178 let json: serde_json::Value = serde_json::json!({"parentUuid": "p1"});
179 assert_eq!(extract_parent_uuid(&json), Some("p1".to_string()));
180 }
181
182 #[test]
183 fn test_extract_leaf_uuid() {
184 let json: serde_json::Value = serde_json::json!({"leafUuid": "l1"});
185 assert_eq!(extract_leaf_uuid(&json), Some("l1".to_string()));
186 }
187
188 #[test]
189 fn test_extract_record_type() {
190 let json: serde_json::Value = serde_json::json!({"type": "user"});
191 assert_eq!(extract_record_type(&json), Some("user"));
192 }
193
194 #[test]
195 fn test_detect_automation_ralphex_marker() {
196 let content = "Review complete. <<<RALPHEX:REVIEW_DONE>>>";
197 assert_eq!(detect_automation(content), Some("ralphex"));
198 }
199
200 #[test]
201 fn test_detect_automation_ralphex_all_tasks_done() {
202 let content = "Task done. <<<RALPHEX:ALL_TASKS_DONE>>>";
203 assert_eq!(detect_automation(content), Some("ralphex"));
204 }
205
206 #[test]
207 fn test_detect_automation_scheduled_task() {
208 let content = r#"<scheduled-task name="chezmoi-sync" file="/Users/user/.claude/scheduled-tasks/chezmoi-sync/SCHEDULE.md">"#;
209 assert_eq!(detect_automation(content), Some("scheduled"));
210 }
211
212 #[test]
213 fn test_detect_automation_no_marker() {
214 let content = "How do I sort a list in Python?";
215 assert_eq!(detect_automation(content), None);
216 }
217
218 #[test]
219 fn test_detect_automation_empty_content() {
220 assert_eq!(detect_automation(""), None);
221 }
222
223 #[test]
224 fn test_detect_automation_partial_marker_no_match() {
225 let content = "discussing RALPHEX in a conversation";
227 assert_eq!(detect_automation(content), None);
228 }
229
230 #[test]
231 fn test_mark_synthetic_linear_record() {
232 let mut json = serde_json::json!({"type": "user"});
233 mark_synthetic_linear_record(&mut json);
234 assert!(is_synthetic_linear_record(&json));
235 }
236
237 #[test]
238 fn test_is_synthetic_linear_record_false_by_default() {
239 let json = serde_json::json!({"type": "user"});
240 assert!(!is_synthetic_linear_record(&json));
241 }
242}