1use anyhow::{Context, Result};
13use serde::Deserialize;
14use std::fs;
15use std::path::{Path, PathBuf};
16use uuid::Uuid;
17
18use crate::storage::models::{Message, MessageContent, Session};
19
20use super::common::{
21 parse_role, parse_timestamp_millis, parse_timestamp_rfc3339, vscode_global_storage,
22};
23use super::{Watcher, WatcherInfo};
24
25#[derive(Debug, Clone)]
30pub struct VsCodeExtensionConfig {
31 pub name: &'static str,
33
34 pub description: &'static str,
36
37 pub extension_id: &'static str,
39}
40
41pub struct VsCodeExtensionWatcher {
46 config: VsCodeExtensionConfig,
47}
48
49impl VsCodeExtensionWatcher {
50 pub fn new(config: VsCodeExtensionConfig) -> Self {
52 Self { config }
53 }
54
55 fn tasks_path(&self) -> PathBuf {
57 vscode_global_storage()
58 .join(self.config.extension_id)
59 .join("tasks")
60 }
61}
62
63impl Watcher for VsCodeExtensionWatcher {
64 fn info(&self) -> WatcherInfo {
65 WatcherInfo {
66 name: self.config.name,
67 description: self.config.description,
68 default_paths: vec![self.tasks_path()],
69 }
70 }
71
72 fn is_available(&self) -> bool {
73 self.tasks_path().exists()
74 }
75
76 fn find_sources(&self) -> Result<Vec<PathBuf>> {
77 find_vscode_tasks(&self.tasks_path())
78 }
79
80 fn parse_source(&self, path: &Path) -> Result<Vec<(Session, Vec<Message>)>> {
81 let parsed = parse_vscode_task(path, self.config.name)?;
82 match parsed {
83 Some((session, messages)) if !messages.is_empty() => Ok(vec![(session, messages)]),
84 _ => Ok(vec![]),
85 }
86 }
87
88 fn watch_paths(&self) -> Vec<PathBuf> {
89 vec![self.tasks_path()]
90 }
91}
92
93pub fn find_vscode_tasks(tasks_path: &Path) -> Result<Vec<PathBuf>> {
97 if !tasks_path.exists() {
98 return Ok(Vec::new());
99 }
100
101 let mut tasks = Vec::new();
102
103 for entry in fs::read_dir(tasks_path)? {
104 let entry = entry?;
105 let path = entry.path();
106
107 if path.is_dir() {
108 let history_file = path.join("api_conversation_history.json");
109 if history_file.exists() {
110 tasks.push(history_file);
111 }
112 }
113 }
114
115 Ok(tasks)
116}
117
118#[derive(Debug, Deserialize)]
120pub struct VsCodeApiMessage {
121 pub role: String,
123
124 pub content: VsCodeContent,
126
127 #[serde(default)]
129 pub ts: Option<i64>,
130}
131
132#[derive(Debug, Deserialize)]
134#[serde(untagged)]
135pub enum VsCodeContent {
136 Text(String),
138 Blocks(Vec<VsCodeContentBlock>),
140}
141
142impl VsCodeContent {
143 pub fn to_text(&self) -> String {
145 match self {
146 Self::Text(s) => s.clone(),
147 Self::Blocks(blocks) => blocks
148 .iter()
149 .filter_map(|b| match b {
150 VsCodeContentBlock::Text { text } => Some(text.clone()),
151 _ => None,
152 })
153 .collect::<Vec<_>>()
154 .join("\n"),
155 }
156 }
157}
158
159#[derive(Debug, Deserialize)]
161#[serde(tag = "type", rename_all = "snake_case")]
162pub enum VsCodeContentBlock {
163 Text { text: String },
165 Image {
167 #[allow(dead_code)]
168 source: serde_json::Value,
169 },
170 ToolUse {
172 #[allow(dead_code)]
173 id: Option<String>,
174 #[allow(dead_code)]
175 name: Option<String>,
176 #[allow(dead_code)]
177 input: Option<serde_json::Value>,
178 },
179 ToolResult {
181 #[allow(dead_code)]
182 tool_use_id: Option<String>,
183 #[allow(dead_code)]
184 content: Option<serde_json::Value>,
185 },
186}
187
188#[derive(Debug, Deserialize, Default)]
190pub struct VsCodeTaskMetadata {
191 #[serde(default)]
193 pub ts: Option<serde_json::Value>,
194
195 #[serde(default)]
197 pub dir: Option<String>,
198}
199
200pub fn parse_vscode_task(
207 history_path: &Path,
208 tool_name: &str,
209) -> Result<Option<(Session, Vec<Message>)>> {
210 let content =
211 fs::read_to_string(history_path).context("Failed to read conversation history")?;
212
213 let raw_messages: Vec<VsCodeApiMessage> =
214 serde_json::from_str(&content).context("Failed to parse conversation JSON")?;
215
216 if raw_messages.is_empty() {
217 return Ok(None);
218 }
219
220 let task_dir = history_path.parent();
222 let task_id = task_dir
223 .and_then(|p| p.file_name())
224 .and_then(|n| n.to_str())
225 .map(|s| s.to_string());
226
227 let metadata = task_dir
229 .map(|d| d.join("task_metadata.json"))
230 .filter(|p| p.exists())
231 .and_then(|p| fs::read_to_string(p).ok())
232 .and_then(|c| serde_json::from_str::<VsCodeTaskMetadata>(&c).ok())
233 .unwrap_or_default();
234
235 let session_id = task_id
237 .as_ref()
238 .and_then(|id| Uuid::parse_str(id).ok())
239 .unwrap_or_else(Uuid::new_v4);
240
241 let first_ts = raw_messages.first().and_then(|m| m.ts);
243 let last_ts = raw_messages.last().and_then(|m| m.ts);
244
245 let started_at = first_ts
246 .and_then(parse_timestamp_millis)
247 .or_else(|| {
248 metadata.ts.as_ref().and_then(|v| match v {
249 serde_json::Value::Number(n) => n.as_i64().and_then(parse_timestamp_millis),
250 serde_json::Value::String(s) => parse_timestamp_rfc3339(s),
251 _ => None,
252 })
253 })
254 .unwrap_or_else(chrono::Utc::now);
255
256 let ended_at = last_ts.and_then(parse_timestamp_millis);
257
258 let working_directory = metadata
260 .dir
261 .or_else(|| {
262 task_dir
263 .and_then(|d| d.parent())
264 .and_then(|d| d.parent())
265 .and_then(|d| d.parent())
266 .map(|d| d.to_string_lossy().to_string())
267 })
268 .unwrap_or_else(|| ".".to_string());
269
270 let mut messages = Vec::new();
272 let time_per_message = chrono::Duration::seconds(30);
273 let mut current_time = started_at;
274
275 for (idx, msg) in raw_messages.iter().enumerate() {
276 let role = match parse_role(&msg.role) {
277 Some(r) => r,
278 None => continue,
279 };
280
281 let content_text = msg.content.to_text();
282 if content_text.trim().is_empty() {
283 continue;
284 }
285
286 let timestamp = msg
287 .ts
288 .and_then(parse_timestamp_millis)
289 .unwrap_or(current_time);
290
291 messages.push(Message {
292 id: Uuid::new_v4(),
293 session_id,
294 parent_id: None,
295 index: idx as i32,
296 timestamp,
297 role,
298 content: MessageContent::Text(content_text),
299 model: None,
300 git_branch: None,
301 cwd: Some(working_directory.clone()),
302 });
303
304 current_time += time_per_message;
305 }
306
307 if messages.is_empty() {
308 return Ok(None);
309 }
310
311 let session = Session {
312 id: session_id,
313 tool: tool_name.to_string(),
314 tool_version: None,
315 started_at,
316 ended_at,
317 model: None,
318 working_directory,
319 git_branch: None,
320 source_path: Some(history_path.to_string_lossy().to_string()),
321 message_count: messages.len() as i32,
322 machine_id: crate::storage::get_machine_id(),
323 };
324
325 Ok(Some((session, messages)))
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use crate::storage::models::MessageRole;
332 use std::io::Write;
333 use tempfile::{NamedTempFile, TempDir};
334
335 fn create_temp_conversation_file(json: &str) -> NamedTempFile {
337 let mut file = NamedTempFile::with_suffix(".json").expect("Failed to create temp file");
338 file.write_all(json.as_bytes())
339 .expect("Failed to write content");
340 file.flush().expect("Failed to flush");
341 file
342 }
343
344 fn create_temp_task_dir(task_id: &str, history_json: &str) -> TempDir {
346 let temp_dir = TempDir::new().expect("Failed to create temp dir");
347 let task_dir = temp_dir.path().join(task_id);
348 fs::create_dir_all(&task_dir).expect("Failed to create task dir");
349
350 let history_file = task_dir.join("api_conversation_history.json");
351 fs::write(&history_file, history_json).expect("Failed to write history file");
352
353 temp_dir
354 }
355
356 #[test]
357 fn test_parse_simple_conversation() {
358 let json = r#"[
359 {"role": "user", "content": "Hello, can you help me?", "ts": 1704067200000},
360 {"role": "assistant", "content": "Of course! What do you need?", "ts": 1704067230000}
361 ]"#;
362
363 let file = create_temp_conversation_file(json);
364 let result = parse_vscode_task(file.path(), "test-tool").expect("Should parse");
365
366 let (session, messages) = result.expect("Should have session");
367 assert_eq!(session.tool, "test-tool");
368 assert_eq!(messages.len(), 2);
369 assert_eq!(messages[0].role, MessageRole::User);
370 assert_eq!(messages[1].role, MessageRole::Assistant);
371 }
372
373 #[test]
374 fn test_parse_with_content_blocks() {
375 let json = r#"[
376 {
377 "role": "user",
378 "content": [
379 {"type": "text", "text": "Hello"},
380 {"type": "text", "text": "World"}
381 ],
382 "ts": 1704067200000
383 }
384 ]"#;
385
386 let file = create_temp_conversation_file(json);
387 let result = parse_vscode_task(file.path(), "test-tool").expect("Should parse");
388
389 let (_, messages) = result.expect("Should have session");
390 assert_eq!(messages.len(), 1);
391 if let MessageContent::Text(text) = &messages[0].content {
392 assert!(text.contains("Hello"));
393 assert!(text.contains("World"));
394 } else {
395 panic!("Expected text content");
396 }
397 }
398
399 #[test]
400 fn test_parse_empty_conversation() {
401 let json = "[]";
402
403 let file = create_temp_conversation_file(json);
404 let result = parse_vscode_task(file.path(), "test-tool").expect("Should parse");
405
406 assert!(result.is_none());
407 }
408
409 #[test]
410 fn test_parse_with_tool_blocks() {
411 let json = r#"[
412 {
413 "role": "user",
414 "content": "Create a file",
415 "ts": 1704067200000
416 },
417 {
418 "role": "assistant",
419 "content": [
420 {"type": "text", "text": "I'll create that file."},
421 {"type": "tool_use", "id": "tool_1", "name": "write_file", "input": {"path": "test.txt"}}
422 ],
423 "ts": 1704067230000
424 }
425 ]"#;
426
427 let file = create_temp_conversation_file(json);
428 let result = parse_vscode_task(file.path(), "test-tool").expect("Should parse");
429
430 let (_, messages) = result.expect("Should have session");
431 assert_eq!(messages.len(), 2);
432 }
433
434 #[test]
435 fn test_parse_filters_empty_content() {
436 let json = r#"[
437 {"role": "user", "content": "Hello", "ts": 1704067200000},
438 {"role": "assistant", "content": "", "ts": 1704067230000}
439 ]"#;
440
441 let file = create_temp_conversation_file(json);
442 let result = parse_vscode_task(file.path(), "test-tool").expect("Should parse");
443
444 let (_, messages) = result.expect("Should have session");
445 assert_eq!(messages.len(), 1);
446 }
447
448 #[test]
449 fn test_parse_with_task_directory() {
450 let json = r#"[
451 {"role": "user", "content": "Hello", "ts": 1704067200000}
452 ]"#;
453
454 let temp_dir = create_temp_task_dir("550e8400-e29b-41d4-a716-446655440000", json);
455 let history_path = temp_dir
456 .path()
457 .join("550e8400-e29b-41d4-a716-446655440000")
458 .join("api_conversation_history.json");
459
460 let result = parse_vscode_task(&history_path, "test-tool").expect("Should parse");
461
462 let (session, _) = result.expect("Should have session");
463 assert_eq!(
464 session.id.to_string(),
465 "550e8400-e29b-41d4-a716-446655440000"
466 );
467 }
468
469 #[test]
470 fn test_timestamps_from_messages() {
471 let json = r#"[
472 {"role": "user", "content": "First", "ts": 1704067200000},
473 {"role": "assistant", "content": "Second", "ts": 1704067260000}
474 ]"#;
475
476 let file = create_temp_conversation_file(json);
477 let result = parse_vscode_task(file.path(), "test-tool").expect("Should parse");
478
479 let (session, messages) = result.expect("Should have session");
480
481 assert_eq!(session.started_at.timestamp_millis(), 1704067200000);
482 assert!(session.ended_at.is_some());
483 assert_eq!(session.ended_at.unwrap().timestamp_millis(), 1704067260000);
484 assert_eq!(messages[0].timestamp.timestamp_millis(), 1704067200000);
485 assert_eq!(messages[1].timestamp.timestamp_millis(), 1704067260000);
486 }
487
488 #[test]
489 fn test_handles_unknown_role() {
490 let json = r#"[
491 {"role": "user", "content": "Hello", "ts": 1704067200000},
492 {"role": "unknown", "content": "Should be skipped", "ts": 1704067230000}
493 ]"#;
494
495 let file = create_temp_conversation_file(json);
496 let result = parse_vscode_task(file.path(), "test-tool").expect("Should parse");
497
498 let (_, messages) = result.expect("Should have session");
499 assert_eq!(messages.len(), 1);
500 }
501
502 #[test]
503 fn test_watcher_info() {
504 let config = VsCodeExtensionConfig {
505 name: "test-ext",
506 description: "Test extension",
507 extension_id: "test.extension-id",
508 };
509 let watcher = VsCodeExtensionWatcher::new(config);
510 let info = watcher.info();
511
512 assert_eq!(info.name, "test-ext");
513 assert_eq!(info.description, "Test extension");
514 }
515
516 #[test]
517 fn test_watcher_parse_source() {
518 let config = VsCodeExtensionConfig {
519 name: "test-ext",
520 description: "Test extension",
521 extension_id: "test.extension-id",
522 };
523 let watcher = VsCodeExtensionWatcher::new(config);
524 let json = r#"[{"role": "user", "content": "Test", "ts": 1704067200000}]"#;
525
526 let file = create_temp_conversation_file(json);
527 let result = watcher
528 .parse_source(file.path())
529 .expect("Should parse successfully");
530
531 assert!(!result.is_empty());
532 let (session, _) = &result[0];
533 assert_eq!(session.tool, "test-ext");
534 }
535
536 #[test]
537 fn test_find_vscode_tasks_in_directory() {
538 let temp_dir = TempDir::new().expect("Failed to create temp dir");
539
540 let task1_dir = temp_dir.path().join("task-1");
542 fs::create_dir_all(&task1_dir).expect("Failed to create task dir");
543 fs::write(task1_dir.join("api_conversation_history.json"), "[]")
544 .expect("Failed to write file");
545
546 let task2_dir = temp_dir.path().join("task-2");
547 fs::create_dir_all(&task2_dir).expect("Failed to create task dir");
548 fs::write(task2_dir.join("api_conversation_history.json"), "[]")
549 .expect("Failed to write file");
550
551 let task3_dir = temp_dir.path().join("task-3");
553 fs::create_dir_all(&task3_dir).expect("Failed to create task dir");
554
555 let tasks = find_vscode_tasks(temp_dir.path()).expect("Should find tasks");
556 assert_eq!(tasks.len(), 2);
557 }
558
559 #[test]
560 fn test_find_vscode_tasks_nonexistent_dir() {
561 let tasks = find_vscode_tasks(Path::new("/nonexistent/path")).expect("Should return empty");
562 assert!(tasks.is_empty());
563 }
564
565 #[test]
566 fn test_vscode_content_to_text_simple() {
567 let content = VsCodeContent::Text("Hello".to_string());
568 assert_eq!(content.to_text(), "Hello");
569 }
570
571 #[test]
572 fn test_vscode_content_to_text_blocks() {
573 let content = VsCodeContent::Blocks(vec![
574 VsCodeContentBlock::Text {
575 text: "Hello".to_string(),
576 },
577 VsCodeContentBlock::ToolUse {
578 id: Some("1".to_string()),
579 name: Some("test".to_string()),
580 input: None,
581 },
582 VsCodeContentBlock::Text {
583 text: "World".to_string(),
584 },
585 ]);
586 let text = content.to_text();
587 assert!(text.contains("Hello"));
588 assert!(text.contains("World"));
589 assert!(!text.contains("test")); }
591}