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
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
119/// Detect whether message content was produced by a known automation tool.
120/// Returns the tool name if a marker is found, `None` otherwise.
121pub 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
133/// Mark a JSON record as belonging to a synthetic linearized resume session.
134pub fn mark_synthetic_linear_record(json: &mut serde_json::Value) {
135    json[SYNTHETIC_LINEAR_FIELD] = serde_json::Value::Bool(true);
136}
137
138/// Whether a JSON record belongs to a synthetic linearized resume session.
139pub 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        // Just "RALPHEX" without the <<< prefix should not match
275        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}