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 RALPHEX_MARKER: &str = "<<<RALPHEX:";
78const SCHEDULED_TASK_MARKER: &str = "<scheduled-task";
79const RALPHEX_INSTRUCTION_CUES: &[&str] = &[
80 "output",
81 "emit",
82 "return",
83 "respond with",
84 "reply with",
85 "print",
86];
87const SYNTHETIC_LINEAR_FIELD: &str = "ccsSyntheticLinear";
88
89fn matches_scheduled_task_marker(content: &str) -> bool {
90 content.trim_start().starts_with(SCHEDULED_TASK_MARKER)
91}
92
93fn ralphex_instruction_prefix(prefix: &str) -> String {
94 prefix
95 .lines()
96 .next_back()
97 .unwrap_or(prefix)
98 .trim_end_matches(|c: char| {
99 c.is_whitespace()
100 || matches!(c, ':' | ';' | ',' | '.' | '-' | '>' | ')' | '(' | ']' | '[')
101 })
102 .to_ascii_lowercase()
103}
104
105fn matches_ralphex_marker(content: &str) -> bool {
106 let trimmed = content.trim();
107 if trimmed.starts_with(RALPHEX_MARKER) {
108 return true;
109 }
110
111 trimmed.match_indices(RALPHEX_MARKER).any(|(idx, _)| {
112 let prefix = ralphex_instruction_prefix(&trimmed[..idx]);
113 RALPHEX_INSTRUCTION_CUES
114 .iter()
115 .any(|cue| prefix.ends_with(cue))
116 })
117}
118
119pub fn detect_automation(content: &str) -> Option<&'static str> {
122 if matches_scheduled_task_marker(content) {
123 return Some("scheduled");
124 }
125
126 if matches_ralphex_marker(content) {
127 return Some("ralphex");
128 }
129
130 None
131}
132
133pub fn mark_synthetic_linear_record(json: &mut serde_json::Value) {
135 json[SYNTHETIC_LINEAR_FIELD] = serde_json::Value::Bool(true);
136}
137
138pub fn is_synthetic_linear_record(json: &serde_json::Value) -> bool {
140 json.get(SYNTHETIC_LINEAR_FIELD)
141 .and_then(|v| v.as_bool())
142 .unwrap_or(false)
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 #[test]
150 fn test_session_source_from_cli_path() {
151 let path = "/Users/user/.claude/projects/-Users-user-myproject/abc123.jsonl";
152 assert_eq!(SessionSource::from_path(path), SessionSource::ClaudeCodeCLI);
153 }
154
155 #[test]
156 fn test_session_source_from_desktop_path() {
157 let path = "/Users/user/Library/Application Support/Claude/local-agent-mode-sessions/uuid1/uuid2/local_session/audit.jsonl";
158 assert_eq!(SessionSource::from_path(path), SessionSource::ClaudeDesktop);
159 }
160
161 #[test]
162 fn test_session_source_display_name() {
163 assert_eq!(SessionSource::ClaudeCodeCLI.display_name(), "CLI");
164 assert_eq!(SessionSource::ClaudeDesktop.display_name(), "Desktop");
165 }
166
167 #[test]
168 fn test_extract_session_id_cli_format() {
169 let json: serde_json::Value = serde_json::json!({"sessionId": "abc123", "type": "user"});
170 assert_eq!(extract_session_id(&json), Some("abc123".to_string()));
171 }
172
173 #[test]
174 fn test_extract_session_id_desktop_format() {
175 let json: serde_json::Value =
176 serde_json::json!({"session_id": "desktop-456", "type": "user"});
177 assert_eq!(extract_session_id(&json), Some("desktop-456".to_string()));
178 }
179
180 #[test]
181 fn test_extract_session_id_cli_takes_precedence() {
182 let json: serde_json::Value =
183 serde_json::json!({"sessionId": "cli", "session_id": "desktop"});
184 assert_eq!(extract_session_id(&json), Some("cli".to_string()));
185 }
186
187 #[test]
188 fn test_extract_timestamp_cli_format() {
189 let json: serde_json::Value = serde_json::json!({"timestamp": "2025-01-09T10:00:00Z"});
190 let ts = extract_timestamp(&json).unwrap();
191 assert_eq!(ts.to_rfc3339(), "2025-01-09T10:00:00+00:00");
192 }
193
194 #[test]
195 fn test_extract_timestamp_desktop_format() {
196 let json: serde_json::Value =
197 serde_json::json!({"_audit_timestamp": "2025-01-09T10:00:00.000Z"});
198 assert!(extract_timestamp(&json).is_some());
199 }
200
201 #[test]
202 fn test_extract_uuid() {
203 let json: serde_json::Value = serde_json::json!({"uuid": "u1"});
204 assert_eq!(extract_uuid(&json), Some("u1".to_string()));
205 }
206
207 #[test]
208 fn test_extract_uuid_missing() {
209 let json: serde_json::Value = serde_json::json!({"type": "user"});
210 assert_eq!(extract_uuid(&json), None);
211 }
212
213 #[test]
214 fn test_extract_parent_uuid() {
215 let json: serde_json::Value = serde_json::json!({"parentUuid": "p1"});
216 assert_eq!(extract_parent_uuid(&json), Some("p1".to_string()));
217 }
218
219 #[test]
220 fn test_extract_leaf_uuid() {
221 let json: serde_json::Value = serde_json::json!({"leafUuid": "l1"});
222 assert_eq!(extract_leaf_uuid(&json), Some("l1".to_string()));
223 }
224
225 #[test]
226 fn test_extract_record_type() {
227 let json: serde_json::Value = serde_json::json!({"type": "user"});
228 assert_eq!(extract_record_type(&json), Some("user"));
229 }
230
231 #[test]
232 fn test_detect_automation_ralphex_marker() {
233 let content = "Path A - No issues: Output <<<RALPHEX:REVIEW_DONE>>>";
234 assert_eq!(detect_automation(content), Some("ralphex"));
235 }
236
237 #[test]
238 fn test_detect_automation_ralphex_all_tasks_done() {
239 let content = "Follow the plan and emit <<<RALPHEX:ALL_TASKS_DONE>>> when complete";
240 assert_eq!(detect_automation(content), Some("ralphex"));
241 }
242
243 #[test]
244 fn test_detect_automation_scheduled_task() {
245 let content = r#"<scheduled-task name="chezmoi-sync" file="/Users/user/.claude/scheduled-tasks/chezmoi-sync/SCHEDULE.md">"#;
246 assert_eq!(detect_automation(content), Some("scheduled"));
247 }
248
249 #[test]
250 fn test_detect_automation_no_marker() {
251 let content = "How do I sort a list in Python?";
252 assert_eq!(detect_automation(content), None);
253 }
254
255 #[test]
256 fn test_detect_automation_ignores_quoted_ralphex_marker() {
257 let content = "Ralphex uses <<<RALPHEX:ALL_TASKS_DONE>>> signals.";
258 assert_eq!(detect_automation(content), None);
259 }
260
261 #[test]
262 fn test_detect_automation_ignores_quoted_scheduled_task_marker() {
263 let content = r#"такие тоже надо детектить <scheduled-task name="chezmoi-sync">"#;
264 assert_eq!(detect_automation(content), None);
265 }
266
267 #[test]
268 fn test_detect_automation_empty_content() {
269 assert_eq!(detect_automation(""), None);
270 }
271
272 #[test]
273 fn test_detect_automation_partial_marker_no_match() {
274 let content = "discussing RALPHEX in a conversation";
276 assert_eq!(detect_automation(content), None);
277 }
278
279 #[test]
280 fn test_mark_synthetic_linear_record() {
281 let mut json = serde_json::json!({"type": "user"});
282 mark_synthetic_linear_record(&mut json);
283 assert!(is_synthetic_linear_record(&json));
284 }
285
286 #[test]
287 fn test_is_synthetic_linear_record_false_by_default() {
288 let json = serde_json::json!({"type": "user"});
289 assert!(!is_synthetic_linear_record(&json));
290 }
291}