Skip to main content

ccs/
session.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4/// Source of the Claude session
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6pub enum SessionSource {
7    /// Claude Code CLI sessions stored in ~/.claude/projects/
8    ClaudeCodeCLI,
9    /// Claude Desktop app sessions stored in ~/Library/Application Support/Claude/
10    ClaudeDesktop,
11}
12
13impl SessionSource {
14    /// Detect session source from file path
15    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    /// Returns display name for the source
24    pub fn display_name(&self) -> &'static str {
25        match self {
26            SessionSource::ClaudeCodeCLI => "CLI",
27            SessionSource::ClaudeDesktop => "Desktop",
28        }
29    }
30}
31
32/// Extract session ID from a JSON record.
33/// Supports both CLI format (`sessionId`) and Desktop format (`session_id`).
34pub 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
41/// Extract timestamp from a JSON record.
42/// Supports both CLI format (`timestamp`) and Desktop format (`_audit_timestamp`).
43pub 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
51/// Extract uuid from a JSON record.
52pub 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
58/// Extract parentUuid from a JSON record.
59pub 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
65/// Extract leafUuid from a JSON record.
66pub 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
72/// Extract record type from a JSON record.
73pub fn extract_record_type(json: &serde_json::Value) -> Option<&str> {
74    json.get("type").and_then(|v| v.as_str())
75}
76
77/// Known automation tool markers found in user message content.
78/// Each entry is (marker_substring, tool_name).
79const AUTOMATION_MARKERS: &[(&str, &str)] = &[
80    ("<<<RALPHEX:", "ralphex"),
81    ("<scheduled-task", "scheduled"),
82];
83const SYNTHETIC_LINEAR_FIELD: &str = "ccsSyntheticLinear";
84
85/// Detect whether message content was produced by a known automation tool.
86/// Returns the tool name if a marker is found, `None` otherwise.
87pub 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
96/// Mark a JSON record as belonging to a synthetic linearized resume session.
97pub fn mark_synthetic_linear_record(json: &mut serde_json::Value) {
98    json[SYNTHETIC_LINEAR_FIELD] = serde_json::Value::Bool(true);
99}
100
101/// Whether a JSON record belongs to a synthetic linearized resume session.
102pub 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        // Just "RALPHEX" without the <<< prefix should not match
226        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}