lore_cli/capture/watchers/
roo_code.rs1use anyhow::{Context, Result};
17use chrono::{DateTime, TimeZone, Utc};
18use serde::Deserialize;
19use std::fs;
20use std::path::{Path, PathBuf};
21use uuid::Uuid;
22
23use crate::storage::models::{Message, MessageContent, MessageRole, Session};
24
25use super::{Watcher, WatcherInfo};
26
27pub struct RooCodeWatcher;
32
33impl Watcher for RooCodeWatcher {
34 fn info(&self) -> WatcherInfo {
35 WatcherInfo {
36 name: "roo-code",
37 description: "Roo Code VS Code extension sessions",
38 default_paths: vec![roo_code_tasks_path()],
39 }
40 }
41
42 fn is_available(&self) -> bool {
43 roo_code_tasks_path().exists()
44 }
45
46 fn find_sources(&self) -> Result<Vec<PathBuf>> {
47 find_roo_code_tasks()
48 }
49
50 fn parse_source(&self, path: &Path) -> Result<Vec<(Session, Vec<Message>)>> {
51 let parsed = parse_roo_code_task(path)?;
52 match parsed {
53 Some((session, messages)) if !messages.is_empty() => Ok(vec![(session, messages)]),
54 _ => Ok(vec![]),
55 }
56 }
57
58 fn watch_paths(&self) -> Vec<PathBuf> {
59 vec![roo_code_tasks_path()]
60 }
61}
62
63fn roo_code_tasks_path() -> PathBuf {
70 let base = get_vscode_global_storage();
71 base.join("rooveterinaryinc.roo-cline").join("tasks")
72}
73
74fn get_vscode_global_storage() -> PathBuf {
76 #[cfg(target_os = "macos")]
77 {
78 dirs::home_dir()
79 .unwrap_or_else(|| PathBuf::from("."))
80 .join("Library/Application Support/Code/User/globalStorage")
81 }
82 #[cfg(target_os = "linux")]
83 {
84 dirs::config_dir()
85 .unwrap_or_else(|| PathBuf::from("."))
86 .join("Code/User/globalStorage")
87 }
88 #[cfg(target_os = "windows")]
89 {
90 dirs::config_dir()
91 .unwrap_or_else(|| PathBuf::from("."))
92 .join("Code/User/globalStorage")
93 }
94 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
95 {
96 dirs::config_dir()
97 .unwrap_or_else(|| PathBuf::from("."))
98 .join("Code/User/globalStorage")
99 }
100}
101
102fn find_roo_code_tasks() -> Result<Vec<PathBuf>> {
106 let tasks_path = roo_code_tasks_path();
107
108 if !tasks_path.exists() {
109 return Ok(Vec::new());
110 }
111
112 let mut tasks = Vec::new();
113
114 for entry in fs::read_dir(&tasks_path)? {
115 let entry = entry?;
116 let path = entry.path();
117
118 if path.is_dir() {
119 let history_file = path.join("api_conversation_history.json");
121 if history_file.exists() {
122 tasks.push(history_file);
123 }
124 }
125 }
126
127 Ok(tasks)
128}
129
130#[derive(Debug, Deserialize)]
133struct RooCodeApiMessage {
134 role: String,
136
137 content: RooCodeContent,
139
140 #[serde(default)]
142 ts: Option<i64>,
143}
144
145#[derive(Debug, Deserialize)]
148#[serde(untagged)]
149enum RooCodeContent {
150 Text(String),
152 Blocks(Vec<RooCodeContentBlock>),
154}
155
156impl RooCodeContent {
157 fn to_text(&self) -> String {
159 match self {
160 Self::Text(s) => s.clone(),
161 Self::Blocks(blocks) => blocks
162 .iter()
163 .filter_map(|b| match b {
164 RooCodeContentBlock::Text { text } => Some(text.clone()),
165 _ => None,
166 })
167 .collect::<Vec<_>>()
168 .join("\n"),
169 }
170 }
171}
172
173#[derive(Debug, Deserialize)]
176#[serde(tag = "type", rename_all = "snake_case")]
177enum RooCodeContentBlock {
178 Text { text: String },
180 Image {
182 #[allow(dead_code)]
183 source: serde_json::Value,
184 },
185 ToolUse {
187 #[allow(dead_code)]
188 id: Option<String>,
189 #[allow(dead_code)]
190 name: Option<String>,
191 #[allow(dead_code)]
192 input: Option<serde_json::Value>,
193 },
194 ToolResult {
196 #[allow(dead_code)]
197 tool_use_id: Option<String>,
198 #[allow(dead_code)]
199 content: Option<serde_json::Value>,
200 },
201}
202
203#[derive(Debug, Deserialize, Default)]
206struct RooCodeTaskMetadata {
207 #[serde(default)]
209 ts: Option<serde_json::Value>,
210
211 #[serde(default)]
213 dir: Option<String>,
214}
215
216fn parse_roo_code_task(history_path: &Path) -> Result<Option<(Session, Vec<Message>)>> {
218 let content =
219 fs::read_to_string(history_path).context("Failed to read Roo Code conversation history")?;
220
221 let raw_messages: Vec<RooCodeApiMessage> =
222 serde_json::from_str(&content).context("Failed to parse Roo Code conversation JSON")?;
223
224 if raw_messages.is_empty() {
225 return Ok(None);
226 }
227
228 let task_dir = history_path.parent();
230 let task_id = task_dir
231 .and_then(|p| p.file_name())
232 .and_then(|n| n.to_str())
233 .map(|s| s.to_string());
234
235 let metadata = task_dir
237 .map(|d| d.join("task_metadata.json"))
238 .filter(|p| p.exists())
239 .and_then(|p| fs::read_to_string(p).ok())
240 .and_then(|c| serde_json::from_str::<RooCodeTaskMetadata>(&c).ok())
241 .unwrap_or_default();
242
243 let session_id = task_id
245 .as_ref()
246 .and_then(|id| Uuid::parse_str(id).ok())
247 .unwrap_or_else(Uuid::new_v4);
248
249 let first_ts = raw_messages.first().and_then(|m| m.ts);
251 let last_ts = raw_messages.last().and_then(|m| m.ts);
252
253 let started_at = first_ts
254 .and_then(|ts| Utc.timestamp_millis_opt(ts).single())
255 .or_else(|| {
256 metadata.ts.as_ref().and_then(|v| match v {
257 serde_json::Value::Number(n) => n
258 .as_i64()
259 .and_then(|ts| Utc.timestamp_millis_opt(ts).single()),
260 serde_json::Value::String(s) => DateTime::parse_from_rfc3339(s)
261 .ok()
262 .map(|dt| dt.with_timezone(&Utc)),
263 _ => None,
264 })
265 })
266 .unwrap_or_else(Utc::now);
267
268 let ended_at = last_ts.and_then(|ts| Utc.timestamp_millis_opt(ts).single());
269
270 let working_directory = metadata
272 .dir
273 .or_else(|| {
274 task_dir
275 .and_then(|d| d.parent())
276 .and_then(|d| d.parent())
277 .and_then(|d| d.parent())
278 .map(|d| d.to_string_lossy().to_string())
279 })
280 .unwrap_or_else(|| ".".to_string());
281
282 let session = Session {
283 id: session_id,
284 tool: "roo-code".to_string(),
285 tool_version: None,
286 started_at,
287 ended_at,
288 model: None,
289 working_directory,
290 git_branch: None,
291 source_path: Some(history_path.to_string_lossy().to_string()),
292 message_count: raw_messages.len() as i32,
293 };
294
295 let mut messages = Vec::new();
297 let time_per_message = chrono::Duration::seconds(30);
298 let mut current_time = started_at;
299
300 for (idx, msg) in raw_messages.iter().enumerate() {
301 let role = match msg.role.as_str() {
302 "user" => MessageRole::User,
303 "assistant" => MessageRole::Assistant,
304 "system" => MessageRole::System,
305 _ => continue,
306 };
307
308 let content_text = msg.content.to_text();
309 if content_text.trim().is_empty() {
310 continue;
311 }
312
313 let timestamp = msg
314 .ts
315 .and_then(|ts| Utc.timestamp_millis_opt(ts).single())
316 .unwrap_or(current_time);
317
318 messages.push(Message {
319 id: Uuid::new_v4(),
320 session_id,
321 parent_id: None,
322 index: idx as i32,
323 timestamp,
324 role,
325 content: MessageContent::Text(content_text),
326 model: None,
327 git_branch: None,
328 cwd: Some(session.working_directory.clone()),
329 });
330
331 current_time += time_per_message;
332 }
333
334 if messages.is_empty() {
335 return Ok(None);
336 }
337
338 Ok(Some((session, messages)))
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use std::io::Write;
345 use tempfile::{NamedTempFile, TempDir};
346
347 fn create_temp_conversation_file(json: &str) -> NamedTempFile {
349 let mut file = NamedTempFile::with_suffix(".json").expect("Failed to create temp file");
350 file.write_all(json.as_bytes())
351 .expect("Failed to write content");
352 file.flush().expect("Failed to flush");
353 file
354 }
355
356 fn create_temp_task_dir(task_id: &str, history_json: &str) -> TempDir {
358 let temp_dir = TempDir::new().expect("Failed to create temp dir");
359 let task_dir = temp_dir.path().join(task_id);
360 fs::create_dir_all(&task_dir).expect("Failed to create task dir");
361
362 let history_file = task_dir.join("api_conversation_history.json");
363 fs::write(&history_file, history_json).expect("Failed to write history file");
364
365 temp_dir
366 }
367
368 #[test]
369 fn test_watcher_info() {
370 let watcher = RooCodeWatcher;
371 let info = watcher.info();
372
373 assert_eq!(info.name, "roo-code");
374 assert_eq!(info.description, "Roo Code VS Code extension sessions");
375 }
376
377 #[test]
378 fn test_watcher_watch_paths() {
379 let watcher = RooCodeWatcher;
380 let paths = watcher.watch_paths();
381
382 assert!(!paths.is_empty());
383 assert!(paths[0]
384 .to_string_lossy()
385 .contains("rooveterinaryinc.roo-cline"));
386 assert!(paths[0].to_string_lossy().contains("tasks"));
387 }
388
389 #[test]
390 fn test_parse_simple_conversation() {
391 let json = r#"[
392 {"role": "user", "content": "Hello, can you help me?", "ts": 1704067200000},
393 {"role": "assistant", "content": "Of course! What do you need?", "ts": 1704067230000}
394 ]"#;
395
396 let file = create_temp_conversation_file(json);
397 let result = parse_roo_code_task(file.path()).expect("Should parse");
398
399 let (session, messages) = result.expect("Should have session");
400 assert_eq!(session.tool, "roo-code");
401 assert_eq!(messages.len(), 2);
402 assert_eq!(messages[0].role, MessageRole::User);
403 assert_eq!(messages[1].role, MessageRole::Assistant);
404 }
405
406 #[test]
407 fn test_parse_with_content_blocks() {
408 let json = r#"[
409 {
410 "role": "user",
411 "content": [
412 {"type": "text", "text": "Hello"},
413 {"type": "text", "text": "World"}
414 ],
415 "ts": 1704067200000
416 }
417 ]"#;
418
419 let file = create_temp_conversation_file(json);
420 let result = parse_roo_code_task(file.path()).expect("Should parse");
421
422 let (_, messages) = result.expect("Should have session");
423 assert_eq!(messages.len(), 1);
424 if let MessageContent::Text(text) = &messages[0].content {
425 assert!(text.contains("Hello"));
426 assert!(text.contains("World"));
427 } else {
428 panic!("Expected text content");
429 }
430 }
431
432 #[test]
433 fn test_parse_empty_conversation() {
434 let json = "[]";
435
436 let file = create_temp_conversation_file(json);
437 let result = parse_roo_code_task(file.path()).expect("Should parse");
438
439 assert!(result.is_none());
440 }
441
442 #[test]
443 fn test_parse_with_tool_blocks() {
444 let json = r#"[
445 {
446 "role": "user",
447 "content": "Create a file",
448 "ts": 1704067200000
449 },
450 {
451 "role": "assistant",
452 "content": [
453 {"type": "text", "text": "I'll create that file."},
454 {"type": "tool_use", "id": "tool_1", "name": "write_file", "input": {"path": "test.txt"}}
455 ],
456 "ts": 1704067230000
457 }
458 ]"#;
459
460 let file = create_temp_conversation_file(json);
461 let result = parse_roo_code_task(file.path()).expect("Should parse");
462
463 let (_, messages) = result.expect("Should have session");
464 assert_eq!(messages.len(), 2);
465 }
466
467 #[test]
468 fn test_parse_filters_empty_content() {
469 let json = r#"[
470 {"role": "user", "content": "Hello", "ts": 1704067200000},
471 {"role": "assistant", "content": "", "ts": 1704067230000}
472 ]"#;
473
474 let file = create_temp_conversation_file(json);
475 let result = parse_roo_code_task(file.path()).expect("Should parse");
476
477 let (_, messages) = result.expect("Should have session");
478 assert_eq!(messages.len(), 1);
480 }
481
482 #[test]
483 fn test_find_tasks_returns_ok_when_dir_missing() {
484 let result = find_roo_code_tasks();
485 assert!(result.is_ok());
486 }
487
488 #[test]
489 fn test_watcher_parse_source() {
490 let watcher = RooCodeWatcher;
491 let json = r#"[{"role": "user", "content": "Test", "ts": 1704067200000}]"#;
492
493 let file = create_temp_conversation_file(json);
494 let result = watcher
495 .parse_source(file.path())
496 .expect("Should parse successfully");
497
498 assert!(!result.is_empty());
499 let (session, _) = &result[0];
500 assert_eq!(session.tool, "roo-code");
501 }
502
503 #[test]
504 fn test_parse_with_task_directory() {
505 let json = r#"[
506 {"role": "user", "content": "Hello", "ts": 1704067200000}
507 ]"#;
508
509 let temp_dir = create_temp_task_dir("550e8400-e29b-41d4-a716-446655440000", json);
510 let history_path = temp_dir
511 .path()
512 .join("550e8400-e29b-41d4-a716-446655440000")
513 .join("api_conversation_history.json");
514
515 let result = parse_roo_code_task(&history_path).expect("Should parse");
516
517 let (session, _) = result.expect("Should have session");
518 assert_eq!(
519 session.id.to_string(),
520 "550e8400-e29b-41d4-a716-446655440000"
521 );
522 }
523
524 #[test]
525 fn test_timestamps_from_messages() {
526 let json = r#"[
527 {"role": "user", "content": "First", "ts": 1704067200000},
528 {"role": "assistant", "content": "Second", "ts": 1704067260000}
529 ]"#;
530
531 let file = create_temp_conversation_file(json);
532 let result = parse_roo_code_task(file.path()).expect("Should parse");
533
534 let (session, messages) = result.expect("Should have session");
535
536 assert!(session.started_at.timestamp_millis() == 1704067200000);
538
539 assert!(session.ended_at.is_some());
541 assert!(session.ended_at.unwrap().timestamp_millis() == 1704067260000);
542
543 assert!(messages[0].timestamp.timestamp_millis() == 1704067200000);
545 assert!(messages[1].timestamp.timestamp_millis() == 1704067260000);
546 }
547
548 #[test]
549 fn test_handles_unknown_role() {
550 let json = r#"[
551 {"role": "user", "content": "Hello", "ts": 1704067200000},
552 {"role": "unknown", "content": "Should be skipped", "ts": 1704067230000}
553 ]"#;
554
555 let file = create_temp_conversation_file(json);
556 let result = parse_roo_code_task(file.path()).expect("Should parse");
557
558 let (_, messages) = result.expect("Should have session");
559 assert_eq!(messages.len(), 1);
560 }
561}