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 machine_id: crate::storage::get_machine_id(),
294 };
295
296 let mut messages = Vec::new();
298 let time_per_message = chrono::Duration::seconds(30);
299 let mut current_time = started_at;
300
301 for (idx, msg) in raw_messages.iter().enumerate() {
302 let role = match msg.role.as_str() {
303 "user" => MessageRole::User,
304 "assistant" => MessageRole::Assistant,
305 "system" => MessageRole::System,
306 _ => continue,
307 };
308
309 let content_text = msg.content.to_text();
310 if content_text.trim().is_empty() {
311 continue;
312 }
313
314 let timestamp = msg
315 .ts
316 .and_then(|ts| Utc.timestamp_millis_opt(ts).single())
317 .unwrap_or(current_time);
318
319 messages.push(Message {
320 id: Uuid::new_v4(),
321 session_id,
322 parent_id: None,
323 index: idx as i32,
324 timestamp,
325 role,
326 content: MessageContent::Text(content_text),
327 model: None,
328 git_branch: None,
329 cwd: Some(session.working_directory.clone()),
330 });
331
332 current_time += time_per_message;
333 }
334
335 if messages.is_empty() {
336 return Ok(None);
337 }
338
339 Ok(Some((session, messages)))
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345 use std::io::Write;
346 use tempfile::{NamedTempFile, TempDir};
347
348 fn create_temp_conversation_file(json: &str) -> NamedTempFile {
350 let mut file = NamedTempFile::with_suffix(".json").expect("Failed to create temp file");
351 file.write_all(json.as_bytes())
352 .expect("Failed to write content");
353 file.flush().expect("Failed to flush");
354 file
355 }
356
357 fn create_temp_task_dir(task_id: &str, history_json: &str) -> TempDir {
359 let temp_dir = TempDir::new().expect("Failed to create temp dir");
360 let task_dir = temp_dir.path().join(task_id);
361 fs::create_dir_all(&task_dir).expect("Failed to create task dir");
362
363 let history_file = task_dir.join("api_conversation_history.json");
364 fs::write(&history_file, history_json).expect("Failed to write history file");
365
366 temp_dir
367 }
368
369 #[test]
370 fn test_watcher_info() {
371 let watcher = RooCodeWatcher;
372 let info = watcher.info();
373
374 assert_eq!(info.name, "roo-code");
375 assert_eq!(info.description, "Roo Code VS Code extension sessions");
376 }
377
378 #[test]
379 fn test_watcher_watch_paths() {
380 let watcher = RooCodeWatcher;
381 let paths = watcher.watch_paths();
382
383 assert!(!paths.is_empty());
384 assert!(paths[0]
385 .to_string_lossy()
386 .contains("rooveterinaryinc.roo-cline"));
387 assert!(paths[0].to_string_lossy().contains("tasks"));
388 }
389
390 #[test]
391 fn test_parse_simple_conversation() {
392 let json = r#"[
393 {"role": "user", "content": "Hello, can you help me?", "ts": 1704067200000},
394 {"role": "assistant", "content": "Of course! What do you need?", "ts": 1704067230000}
395 ]"#;
396
397 let file = create_temp_conversation_file(json);
398 let result = parse_roo_code_task(file.path()).expect("Should parse");
399
400 let (session, messages) = result.expect("Should have session");
401 assert_eq!(session.tool, "roo-code");
402 assert_eq!(messages.len(), 2);
403 assert_eq!(messages[0].role, MessageRole::User);
404 assert_eq!(messages[1].role, MessageRole::Assistant);
405 }
406
407 #[test]
408 fn test_parse_with_content_blocks() {
409 let json = r#"[
410 {
411 "role": "user",
412 "content": [
413 {"type": "text", "text": "Hello"},
414 {"type": "text", "text": "World"}
415 ],
416 "ts": 1704067200000
417 }
418 ]"#;
419
420 let file = create_temp_conversation_file(json);
421 let result = parse_roo_code_task(file.path()).expect("Should parse");
422
423 let (_, messages) = result.expect("Should have session");
424 assert_eq!(messages.len(), 1);
425 if let MessageContent::Text(text) = &messages[0].content {
426 assert!(text.contains("Hello"));
427 assert!(text.contains("World"));
428 } else {
429 panic!("Expected text content");
430 }
431 }
432
433 #[test]
434 fn test_parse_empty_conversation() {
435 let json = "[]";
436
437 let file = create_temp_conversation_file(json);
438 let result = parse_roo_code_task(file.path()).expect("Should parse");
439
440 assert!(result.is_none());
441 }
442
443 #[test]
444 fn test_parse_with_tool_blocks() {
445 let json = r#"[
446 {
447 "role": "user",
448 "content": "Create a file",
449 "ts": 1704067200000
450 },
451 {
452 "role": "assistant",
453 "content": [
454 {"type": "text", "text": "I'll create that file."},
455 {"type": "tool_use", "id": "tool_1", "name": "write_file", "input": {"path": "test.txt"}}
456 ],
457 "ts": 1704067230000
458 }
459 ]"#;
460
461 let file = create_temp_conversation_file(json);
462 let result = parse_roo_code_task(file.path()).expect("Should parse");
463
464 let (_, messages) = result.expect("Should have session");
465 assert_eq!(messages.len(), 2);
466 }
467
468 #[test]
469 fn test_parse_filters_empty_content() {
470 let json = r#"[
471 {"role": "user", "content": "Hello", "ts": 1704067200000},
472 {"role": "assistant", "content": "", "ts": 1704067230000}
473 ]"#;
474
475 let file = create_temp_conversation_file(json);
476 let result = parse_roo_code_task(file.path()).expect("Should parse");
477
478 let (_, messages) = result.expect("Should have session");
479 assert_eq!(messages.len(), 1);
481 }
482
483 #[test]
484 fn test_find_tasks_returns_ok_when_dir_missing() {
485 let result = find_roo_code_tasks();
486 assert!(result.is_ok());
487 }
488
489 #[test]
490 fn test_watcher_parse_source() {
491 let watcher = RooCodeWatcher;
492 let json = r#"[{"role": "user", "content": "Test", "ts": 1704067200000}]"#;
493
494 let file = create_temp_conversation_file(json);
495 let result = watcher
496 .parse_source(file.path())
497 .expect("Should parse successfully");
498
499 assert!(!result.is_empty());
500 let (session, _) = &result[0];
501 assert_eq!(session.tool, "roo-code");
502 }
503
504 #[test]
505 fn test_parse_with_task_directory() {
506 let json = r#"[
507 {"role": "user", "content": "Hello", "ts": 1704067200000}
508 ]"#;
509
510 let temp_dir = create_temp_task_dir("550e8400-e29b-41d4-a716-446655440000", json);
511 let history_path = temp_dir
512 .path()
513 .join("550e8400-e29b-41d4-a716-446655440000")
514 .join("api_conversation_history.json");
515
516 let result = parse_roo_code_task(&history_path).expect("Should parse");
517
518 let (session, _) = result.expect("Should have session");
519 assert_eq!(
520 session.id.to_string(),
521 "550e8400-e29b-41d4-a716-446655440000"
522 );
523 }
524
525 #[test]
526 fn test_timestamps_from_messages() {
527 let json = r#"[
528 {"role": "user", "content": "First", "ts": 1704067200000},
529 {"role": "assistant", "content": "Second", "ts": 1704067260000}
530 ]"#;
531
532 let file = create_temp_conversation_file(json);
533 let result = parse_roo_code_task(file.path()).expect("Should parse");
534
535 let (session, messages) = result.expect("Should have session");
536
537 assert!(session.started_at.timestamp_millis() == 1704067200000);
539
540 assert!(session.ended_at.is_some());
542 assert!(session.ended_at.unwrap().timestamp_millis() == 1704067260000);
543
544 assert!(messages[0].timestamp.timestamp_millis() == 1704067200000);
546 assert!(messages[1].timestamp.timestamp_millis() == 1704067260000);
547 }
548
549 #[test]
550 fn test_handles_unknown_role() {
551 let json = r#"[
552 {"role": "user", "content": "Hello", "ts": 1704067200000},
553 {"role": "unknown", "content": "Should be skipped", "ts": 1704067230000}
554 ]"#;
555
556 let file = create_temp_conversation_file(json);
557 let result = parse_roo_code_task(file.path()).expect("Should parse");
558
559 let (_, messages) = result.expect("Should have session");
560 assert_eq!(messages.len(), 1);
561 }
562}