1use super::{Agent, LogProcessor};
2use crate::context::file_system::FileSystemOperations;
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::path::Path;
7use std::process::Command;
8use std::sync::Arc;
9
10pub struct ClaudeCodeAgent;
12
13impl ClaudeCodeAgent {
14 pub fn new() -> Self {
15 Self
16 }
17}
18
19impl Default for ClaudeCodeAgent {
20 fn default() -> Self {
21 Self::new()
22 }
23}
24
25#[async_trait]
26impl Agent for ClaudeCodeAgent {
27 fn build_command(&self, instruction_path: &str) -> Vec<String> {
28 let filename = Path::new(instruction_path)
30 .file_name()
31 .and_then(|f| f.to_str())
32 .unwrap_or("instructions.md");
33
34 vec![
35 "sh".to_string(),
36 "-c".to_string(),
37 format!(
38 "cat /instructions/{} | claude -p --verbose --output-format stream-json --dangerously-skip-permissions",
39 filename
40 ),
41 ]
42 }
43
44 fn volumes(&self) -> Vec<(String, String, String)> {
45 let home_dir = std::env::var("HOME").unwrap_or_else(|_| "/home/agent".to_string());
47
48 vec![
49 (
51 format!("{home_dir}/.claude"),
52 "/home/agent/.claude".to_string(),
53 "".to_string(),
54 ),
55 (
57 format!("{home_dir}/.claude.json"),
58 "/home/agent/.claude.json".to_string(),
59 "".to_string(),
60 ),
61 ]
62 }
63
64 fn environment(&self) -> Vec<(String, String)> {
65 vec![
66 ("HOME".to_string(), "/home/agent".to_string()),
67 ("USER".to_string(), "agent".to_string()),
68 ]
69 }
70
71 fn create_log_processor(
72 &self,
73 file_system: Arc<dyn FileSystemOperations>,
74 ) -> Box<dyn LogProcessor> {
75 Box::new(ClaudeCodeLogProcessor::new(file_system))
76 }
77
78 fn name(&self) -> &str {
79 "claude-code"
80 }
81
82 async fn validate(&self) -> Result<(), String> {
83 let home_dir = std::env::var("HOME").map_err(|_| "HOME environment variable not set")?;
85 let claude_config = Path::new(&home_dir).join(".claude.json");
86
87 if !claude_config.exists() {
88 return Err(format!(
89 "Claude configuration not found at {}. Please run 'claude login' first.",
90 claude_config.display()
91 ));
92 }
93
94 Ok(())
95 }
96
97 async fn warmup(&self) -> Result<(), String> {
98 if cfg!(test) {
100 return Ok(());
101 }
102
103 println!("Running Claude Code warmup steps...");
104
105 let output = Command::new("claude")
107 .args(["-p", "--model", "sonnet", "say hi and nothing else"])
108 .output()
109 .map_err(|e| format!("Failed to run Claude CLI: {e}"))?;
110
111 if !output.status.success() {
112 return Err(format!(
113 "Claude CLI failed: {}",
114 String::from_utf8_lossy(&output.stderr)
115 ));
116 }
117
118 if std::env::consts::OS == "macos" {
120 let user = std::env::var("USER").map_err(|_| "USER environment variable not set")?;
121
122 let output = Command::new("sh")
123 .arg("-c")
124 .arg(format!(
125 "security find-generic-password -a {user} -w -s 'Claude Code-credentials' > ~/.claude/.credentials.json"
126 ))
127 .output()
128 .map_err(|e| format!("Failed to export OAuth token: {e}"))?;
129
130 if !output.status.success() {
131 eprintln!("Warning: Could not export OAuth token from keychain");
133 }
134 }
135
136 println!("Claude Code warmup completed successfully");
137 Ok(())
138 }
139}
140
141#[derive(Debug, Deserialize, Serialize)]
143struct ClaudeMessage {
144 #[serde(rename = "type")]
145 message_type: String,
146 message: Option<MessageContent>,
147 subtype: Option<String>,
148 cost_usd: Option<f64>,
149 is_error: Option<bool>,
150 duration_ms: Option<u64>,
151 duration_api_ms: Option<u64>,
152 num_turns: Option<u64>,
153 result: Option<String>,
154 total_cost: Option<f64>,
155 session_id: Option<String>,
156 timestamp: Option<String>,
157 #[serde(rename = "toolUseResult")]
158 tool_use_result: Option<ToolUseResult>,
159 summary: Option<String>,
160}
161
162#[derive(Debug, Deserialize, Serialize)]
164struct ToolUseResult {
165 stdout: Option<String>,
166 stderr: Option<String>,
167 is_error: Option<bool>,
168 error: Option<String>,
169 filenames: Option<Vec<String>>,
170}
171
172#[derive(Debug, Deserialize, Serialize)]
174struct MessageContent {
175 role: Option<String>,
176 content: Option<Value>,
177 id: Option<String>,
178 #[serde(rename = "type")]
179 content_type: Option<String>,
180 model: Option<String>,
181 stop_reason: Option<String>,
182 usage: Option<Usage>,
183}
184
185#[derive(Debug, Deserialize, Serialize)]
187struct Usage {
188 input_tokens: Option<u64>,
189 output_tokens: Option<u64>,
190 cache_creation_input_tokens: Option<u64>,
191 cache_read_input_tokens: Option<u64>,
192 service_tier: Option<String>,
193}
194
195#[derive(Debug, Deserialize, Serialize, Clone)]
197struct TodoItem {
198 id: String,
199 content: String,
200 status: String,
201 priority: String,
202}
203
204#[derive(Debug, Clone)]
206pub struct TaskResult {
207 pub success: bool,
208 pub message: String,
209 #[allow(dead_code)] pub cost_usd: Option<f64>,
211 #[allow(dead_code)] pub duration_ms: Option<u64>,
213}
214
215struct ClaudeCodeLogProcessor {
225 full_log: Vec<String>,
226 final_result: Option<TaskResult>,
227 file_system: Arc<dyn FileSystemOperations>,
228}
229
230impl ClaudeCodeLogProcessor {
231 fn new(file_system: Arc<dyn FileSystemOperations>) -> Self {
233 Self {
234 full_log: Vec::new(),
235 final_result: None,
236 file_system,
237 }
238 }
239
240 fn format_message(&mut self, msg: ClaudeMessage) -> Option<String> {
242 match msg.message_type.as_str() {
243 "assistant" => self.format_assistant_message(msg),
244 "user" => self.format_user_message(&msg),
245 "result" => self.format_result_message(msg),
246 "summary" => {
247 if let Some(summary_text) = msg.summary {
249 Some(format!("📋 Summary: {summary_text}"))
250 } else {
251 Some("📋 [summary]".to_string())
252 }
253 }
254 other_type => {
255 Some(format!("📋 [{other_type}]"))
257 }
258 }
259 }
260
261 fn format_user_message(&self, msg: &ClaudeMessage) -> Option<String> {
263 if let Some(tool_result) = &msg.tool_use_result {
264 let mut output = String::new();
265
266 if let Some(filenames) = &tool_result.filenames {
268 let count = filenames.len();
269 output.push_str(&format!(
270 "👤 Tool result: Found {count} file{}",
271 if count == 1 { "" } else { "s" }
272 ));
273 } else if let Some(stdout) = &tool_result.stdout {
274 if stdout.contains("test result: ok") {
275 output.push_str("👤 Tool result: Tests passed ✅");
276 } else if stdout.contains("test result: FAILED") {
277 output.push_str("👤 Tool result: Tests failed ❌");
278 } else if stdout.trim().is_empty() {
279 output.push_str("👤 Tool result: Command completed");
280 } else {
281 let first_line = stdout.lines().next().unwrap_or("").trim();
283 if first_line.len() > 50 {
284 output.push_str(&format!("👤 Tool result: {}...", &first_line[..50]));
285 } else {
286 output.push_str(&format!("👤 Tool result: {first_line}"));
287 }
288 }
289 } else if let Some(error) = &tool_result.error {
290 output.push_str(&format!("👤 Tool error: {error}"));
291 } else if tool_result.is_error == Some(true) {
292 output.push_str("👤 Tool result: Error occurred");
293 } else {
294 output.push_str("👤 Tool result: Completed");
295 }
296
297 Some(output)
298 } else {
299 if let Some(message) = &msg.message {
301 if let Some(Value::String(text)) = &message.content {
302 let first_line = text.lines().next().unwrap_or("").trim();
304 if first_line.starts_with("# ") {
305 Some(format!("👤 User: {}", first_line.trim_start_matches("# ")))
306 } else if first_line.len() > 60 {
307 Some(format!("👤 User: {}...", &first_line[..60]))
308 } else if !first_line.is_empty() {
309 Some(format!("👤 User: {first_line}"))
310 } else {
311 Some("👤 [user]".to_string())
312 }
313 } else {
314 Some("👤 [user]".to_string())
315 }
316 } else {
317 Some("👤 [user]".to_string())
318 }
319 }
320 }
321
322 fn format_assistant_message(&self, msg: ClaudeMessage) -> Option<String> {
324 if let Some(message) = msg.message {
325 if let Some(content) = message.content {
326 match content {
327 Value::Array(contents) => {
328 let mut output = String::new();
329
330 for item in contents {
331 if let Some(tool_name) = item.get("name").and_then(|n| n.as_str()) {
333 match tool_name {
334 "TodoWrite" => {
335 if let Some(input) = item.get("input") {
336 if let Some(todos) = input.get("todos") {
337 if let Ok(todo_items) =
338 serde_json::from_value::<Vec<TodoItem>>(
339 todos.clone(),
340 )
341 {
342 output.push_str(
343 &self.format_todo_update(&todo_items),
344 );
345 }
346 }
347 }
348 }
349 "Read" | "LS" | "NotebookRead" => {
350 }
352 "Edit" | "MultiEdit" => {
353 if let Some(input) = item.get("input") {
354 if let Some(file_path) =
355 input.get("file_path").and_then(|f| f.as_str())
356 {
357 let file_name = file_path
358 .rsplit('/')
359 .next()
360 .unwrap_or(file_path);
361 if tool_name == "MultiEdit" {
362 if let Some(edits) = input
363 .get("edits")
364 .and_then(|e| e.as_array())
365 {
366 output.push_str(&format!(
367 "🔧 Editing {file_name} ({} changes)\n",
368 edits.len()
369 ));
370 } else {
371 output.push_str(&format!(
372 "🔧 Editing {file_name}\n"
373 ));
374 }
375 } else {
376 output.push_str(&format!(
377 "🔧 Editing {file_name}\n"
378 ));
379 }
380 }
381 }
382 }
383 "Bash" => {
384 if let Some(input) = item.get("input") {
385 if let Some(cmd) =
386 input.get("command").and_then(|c| c.as_str())
387 {
388 let cmd_preview = if cmd.len() > 60 {
389 format!("{}...", &cmd[..60])
390 } else {
391 cmd.to_string()
392 };
393 output.push_str(&format!(
394 "🖥️ Running: {cmd_preview}\n"
395 ));
396 }
397 }
398 }
399 "Write" => {
400 if let Some(input) = item.get("input") {
401 if let Some(file_path) =
402 input.get("file_path").and_then(|f| f.as_str())
403 {
404 let file_name = file_path
405 .rsplit('/')
406 .next()
407 .unwrap_or(file_path);
408 output
409 .push_str(&format!("📝 Writing {file_name}\n"));
410 }
411 }
412 }
413 "Grep" => {
414 if let Some(input) = item.get("input") {
415 if let Some(pattern) =
416 input.get("pattern").and_then(|p| p.as_str())
417 {
418 output.push_str(&format!(
419 "🔍 Searching for: {pattern}\n"
420 ));
421 }
422 }
423 }
424 "WebSearch" => {
425 if let Some(input) = item.get("input") {
426 if let Some(query) =
427 input.get("query").and_then(|q| q.as_str())
428 {
429 output
430 .push_str(&format!("🌐 Web search: {query}\n"));
431 }
432 }
433 }
434 _ => {
435 output.push_str(&format!("🔧 Using {tool_name}\n"));
437 }
438 }
439 }
440
441 if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
443 if !text.trim().is_empty() {
444 if !output.is_empty() {
445 output.push('\n');
446 }
447 output.push_str(&format!("🤖 {text}"));
448 }
449 }
450 }
451
452 if !output.is_empty() {
453 Some(output.trim_end().to_string())
454 } else {
455 None
456 }
457 }
458 Value::String(text) => Some(format!("🤖 {text}")),
459 _ => None,
460 }
461 } else {
462 None
463 }
464 } else {
465 None
466 }
467 }
468
469 fn format_todo_update(&self, todos: &[TodoItem]) -> String {
471 let mut output = String::new();
472 output.push_str("📝 TODO Update:\n");
473 output.push_str(&"─".repeat(60));
474 output.push('\n');
475
476 for todo in todos {
478 let status_emoji = match todo.status.as_str() {
479 "in_progress" => "🔄",
480 "pending" => "⏳",
481 "completed" => "✅",
482 _ => "⏳", };
484
485 output.push_str(&format!(
486 "{} {} [{}] {}\n",
487 status_emoji,
488 self.get_priority_emoji(&todo.priority),
489 todo.id,
490 todo.content
491 ));
492 }
493
494 output.push_str(&"─".repeat(60));
495 output.push('\n');
496
497 let total = todos.len();
499 let completed = todos.iter().filter(|t| t.status == "completed").count();
500 let in_progress = todos.iter().filter(|t| t.status == "in_progress").count();
501 let pending = todos.iter().filter(|t| t.status == "pending").count();
502
503 output.push_str(&format!(
504 "Summary: {total} total | {completed} completed | {in_progress} in progress | {pending} pending\n"
505 ));
506 output.push_str(&"─".repeat(60));
507
508 output
509 }
510
511 fn get_priority_emoji(&self, priority: &str) -> &'static str {
513 match priority {
514 "high" => "🔴",
515 "medium" => "🟡",
516 "low" => "🟢",
517 _ => "⚪",
518 }
519 }
520
521 fn format_duration(&self, duration_ms: u64) -> String {
528 let total_seconds = duration_ms / 1000;
529 let hours = total_seconds / 3600;
530 let minutes = (total_seconds % 3600) / 60;
531 let seconds = total_seconds % 60;
532
533 let mut parts = Vec::new();
534
535 if hours > 0 {
536 parts.push(format!(
537 "{} hour{}",
538 hours,
539 if hours == 1 { "" } else { "s" }
540 ));
541 }
542
543 if minutes > 0 {
544 parts.push(format!(
545 "{} minute{}",
546 minutes,
547 if minutes == 1 { "" } else { "s" }
548 ));
549 }
550
551 if seconds > 0 || parts.is_empty() {
552 parts.push(format!(
553 "{} second{}",
554 seconds,
555 if seconds == 1 { "" } else { "s" }
556 ));
557 }
558
559 parts.join(", ")
560 }
561
562 fn format_result_message(&mut self, msg: ClaudeMessage) -> Option<String> {
564 if let Some(subtype) = &msg.subtype {
566 let success = subtype == "success";
567 let message = msg.result.clone().unwrap_or_else(|| {
568 if success {
569 "Task completed successfully".to_string()
570 } else {
571 "Task failed".to_string()
572 }
573 });
574
575 let cost_usd = msg.cost_usd.or_else(|| {
577 if let Some(message_content) = &msg.message {
578 if let Some(usage) = &message_content.usage {
579 self.calculate_cost_from_usage(usage)
580 } else {
581 None
582 }
583 } else {
584 None
585 }
586 });
587
588 self.final_result = Some(TaskResult {
589 success,
590 message: message.clone(),
591 cost_usd,
592 duration_ms: msg.duration_ms,
593 });
594
595 let mut output = String::new();
597 output.push('\n');
598 output.push_str(&"─".repeat(60));
599 output.push('\n');
600
601 let status_emoji = if success { "✅" } else { "❌" };
602 output.push_str(&format!("{status_emoji} Task Result: {subtype}\n"));
603
604 if let Some(cost) = cost_usd {
605 output.push_str(&format!("💰 Cost: ${cost:.2}\n"));
606 }
607
608 if let Some(duration) = msg.duration_ms {
609 output.push_str(&format!(
610 "⏱️ Duration: {}\n",
611 self.format_duration(duration)
612 ));
613 }
614
615 if let Some(turns) = msg.num_turns {
616 output.push_str(&format!("🔄 Turns: {turns}\n"));
617 }
618
619 output.push_str(&"─".repeat(60));
620
621 Some(output)
622 } else {
623 None
624 }
625 }
626
627 fn calculate_cost_from_usage(&self, usage: &Usage) -> Option<f64> {
629 const INPUT_COST_PER_1K: f64 = 0.015; const OUTPUT_COST_PER_1K: f64 = 0.075; const CACHE_WRITE_COST_PER_1K: f64 = 0.01875; const CACHE_READ_COST_PER_1K: f64 = 0.0015; let input_tokens = usage.input_tokens.unwrap_or(0) as f64;
637 let output_tokens = usage.output_tokens.unwrap_or(0) as f64;
638 let cache_write_tokens = usage.cache_creation_input_tokens.unwrap_or(0) as f64;
639 let cache_read_tokens = usage.cache_read_input_tokens.unwrap_or(0) as f64;
640
641 let total_cost = (input_tokens * INPUT_COST_PER_1K / 1000.0)
642 + (output_tokens * OUTPUT_COST_PER_1K / 1000.0)
643 + (cache_write_tokens * CACHE_WRITE_COST_PER_1K / 1000.0)
644 + (cache_read_tokens * CACHE_READ_COST_PER_1K / 1000.0);
645
646 if total_cost > 0.0 {
647 Some(total_cost)
648 } else {
649 None
650 }
651 }
652}
653
654#[async_trait]
655impl LogProcessor for ClaudeCodeLogProcessor {
656 fn process_line(&mut self, line: &str) -> Option<String> {
657 self.full_log.push(line.to_string());
659
660 if line.trim().is_empty() {
662 return None;
663 }
664
665 match serde_json::from_str::<ClaudeMessage>(line) {
667 Ok(msg) => self.format_message(msg),
668 Err(_) => {
669 Some("‼️ parsing error".to_string())
671 }
672 }
673 }
674
675 fn get_full_log(&self) -> String {
676 self.full_log.join("\n")
677 }
678
679 async fn save_full_log(&self, path: &Path) -> Result<(), String> {
680 let content = self.full_log.join("\n");
681 self.file_system
682 .write_file(path, &content)
683 .await
684 .map_err(|e| format!("Failed to save log file: {e}"))
685 }
686
687 fn get_final_result(&self) -> Option<&TaskResult> {
688 self.final_result.as_ref()
689 }
690}
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695 use crate::context::file_system::tests::MockFileSystem;
696
697 #[test]
698 fn test_process_assistant_message() {
699 let fs = Arc::new(MockFileSystem::new());
700 let mut processor = ClaudeCodeLogProcessor::new(fs);
701 let json = r#"{
702 "type": "assistant",
703 "message": {
704 "content": [{"type": "text", "text": "Hello, world!"}]
705 }
706 }"#;
707
708 let result = processor.process_line(json);
709 assert_eq!(result, Some("🤖 Hello, world!".to_string()));
710 }
711
712 #[test]
713 fn test_process_result_message() {
714 let fs = Arc::new(MockFileSystem::new());
715 let mut processor = ClaudeCodeLogProcessor::new(fs);
716 let json = r#"{
717 "type": "result",
718 "subtype": "success",
719 "cost_usd": 0.123,
720 "result": "Task completed successfully with all tests passing"
721 }"#;
722
723 let result = processor.process_line(json);
724 assert!(result.is_some());
725 let formatted = result.unwrap();
726 assert!(formatted.contains("✅ Task Result: success"));
727 assert!(formatted.contains("💰 Cost: $0.12"));
728
729 let final_result = processor.get_final_result();
731 assert!(final_result.is_some());
732 let task_result = final_result.unwrap();
733 assert_eq!(task_result.success, true);
734 assert_eq!(
735 task_result.message,
736 "Task completed successfully with all tests passing"
737 );
738 assert_eq!(task_result.cost_usd, Some(0.123));
739 }
740
741 #[test]
742 fn test_process_result_message_failure() {
743 let fs = Arc::new(MockFileSystem::new());
744 let mut processor = ClaudeCodeLogProcessor::new(fs);
745 let json = r#"{
746 "type": "result",
747 "subtype": "error",
748 "is_error": true,
749 "result": "Task failed due to compilation errors",
750 "duration_ms": 5000
751 }"#;
752
753 let result = processor.process_line(json);
754 assert!(result.is_some());
755 let formatted = result.unwrap();
756 assert!(formatted.contains("❌ Task Result: error"));
757 assert!(formatted.contains("⏱️ Duration: 5 seconds"));
758
759 let final_result = processor.get_final_result();
761 assert!(final_result.is_some());
762 let task_result = final_result.unwrap();
763 assert_eq!(task_result.success, false);
764 assert_eq!(task_result.message, "Task failed due to compilation errors");
765 assert_eq!(task_result.duration_ms, Some(5000));
766 }
767
768 #[test]
769 fn test_process_non_json() {
770 let fs = Arc::new(MockFileSystem::new());
771 let mut processor = ClaudeCodeLogProcessor::new(fs);
772 let line = "This is not JSON";
773
774 let result = processor.process_line(line);
775 assert_eq!(result, Some("‼️ parsing error".to_string()));
776 }
777
778 #[test]
779 fn test_process_other_message_types() {
780 let fs = Arc::new(MockFileSystem::new());
781 let mut processor = ClaudeCodeLogProcessor::new(fs);
782
783 let json = r#"{"type": "tool_use"}"#;
785 let result = processor.process_line(json);
786 assert_eq!(result, Some("📋 [tool_use]".to_string()));
787
788 let json = r#"{"type": "system"}"#;
790 let result = processor.process_line(json);
791 assert_eq!(result, Some("📋 [system]".to_string()));
792
793 let json = r#"{"type": "thinking", "message": {"content": "Processing..."}}"#;
795 let result = processor.process_line(json);
796 assert_eq!(result, Some("📋 [thinking]".to_string()));
797 }
798
799 #[test]
800 fn test_process_todo_update() {
801 let fs = Arc::new(MockFileSystem::new());
802 let mut processor = ClaudeCodeLogProcessor::new(fs);
803 let json = r#"{
804 "type": "assistant",
805 "message": {
806 "id": "msg_01715dTbzrJ49yvb5Mp68sQa",
807 "type": "message",
808 "role": "assistant",
809 "model": "claude-opus-4-20250514",
810 "content": [{
811 "type": "tool_use",
812 "id": "toolu_013pfL2AAyzkXVLeuGBrD2Z1",
813 "name": "TodoWrite",
814 "input": {
815 "todos": [
816 {"id": "1", "content": "Analyze existing MockDockerClient implementations", "status": "pending", "priority": "high"},
817 {"id": "2", "content": "Create test_utils module structure", "status": "pending", "priority": "high"},
818 {"id": "3", "content": "Implement NoOpDockerClient", "status": "completed", "priority": "high"},
819 {"id": "4", "content": "Run tests and fix any issues", "status": "in_progress", "priority": "medium"}
820 ]
821 }
822 }]
823 }
824 }"#;
825
826 let result = processor.process_line(json);
827 assert!(result.is_some());
828 let formatted = result.unwrap();
829
830 assert!(formatted.contains("📝 TODO Update:"));
832
833 let lines: Vec<&str> = formatted.lines().collect();
835 let todo_lines: Vec<&str> = lines
836 .iter()
837 .filter(|line| {
838 line.contains("[1]")
839 || line.contains("[2]")
840 || line.contains("[3]")
841 || line.contains("[4]")
842 })
843 .cloned()
844 .collect();
845
846 assert_eq!(todo_lines.len(), 4);
848 assert!(todo_lines[0].starts_with("⏳ 🔴 [1]")); assert!(todo_lines[1].starts_with("⏳ 🔴 [2]")); assert!(todo_lines[2].starts_with("✅ 🔴 [3]")); assert!(todo_lines[3].starts_with("🔄 🟡 [4]")); assert!(formatted.contains("Analyze existing MockDockerClient implementations"));
855 assert!(formatted.contains("Create test_utils module structure"));
856 assert!(formatted.contains("Implement NoOpDockerClient"));
857 assert!(formatted.contains("Run tests and fix any issues"));
858
859 assert!(formatted.contains("🔴")); assert!(formatted.contains("🟡")); assert!(formatted.contains("Summary: 4 total | 1 completed | 1 in progress | 2 pending"));
865 }
866
867 #[test]
868 fn test_process_todo_update_all_completed() {
869 let fs = Arc::new(MockFileSystem::new());
870 let mut processor = ClaudeCodeLogProcessor::new(fs);
871 let json = r#"{
872 "type": "assistant",
873 "message": {
874 "content": [{
875 "type": "tool_use",
876 "name": "TodoWrite",
877 "input": {
878 "todos": [
879 {"id": "1", "content": "Task 1", "status": "completed", "priority": "high"},
880 {"id": "2", "content": "Task 2", "status": "completed", "priority": "low"}
881 ]
882 }
883 }]
884 }
885 }"#;
886
887 let result = processor.process_line(json);
888 assert!(result.is_some());
889 let formatted = result.unwrap();
890
891 let lines: Vec<&str> = formatted.lines().collect();
893 let todo_lines: Vec<&str> = lines
894 .iter()
895 .filter(|line| line.contains("[1]") || line.contains("[2]"))
896 .cloned()
897 .collect();
898
899 assert_eq!(todo_lines.len(), 2);
900 assert!(todo_lines[0].starts_with("✅ 🔴 [1]")); assert!(todo_lines[1].starts_with("✅ 🟢 [2]")); assert!(formatted.contains("Summary: 2 total | 2 completed | 0 in progress | 0 pending"));
905 }
906
907 #[test]
908 fn test_todo_priority_emojis() {
909 let fs = Arc::new(MockFileSystem::new());
910 let processor = ClaudeCodeLogProcessor::new(fs);
911 assert_eq!(processor.get_priority_emoji("high"), "🔴");
912 assert_eq!(processor.get_priority_emoji("medium"), "🟡");
913 assert_eq!(processor.get_priority_emoji("low"), "🟢");
914 assert_eq!(processor.get_priority_emoji("unknown"), "⚪");
915 }
916
917 #[test]
918 fn test_process_result_message_with_long_duration() {
919 let fs = Arc::new(MockFileSystem::new());
920 let mut processor = ClaudeCodeLogProcessor::new(fs);
921 let json = r#"{
922 "type": "result",
923 "subtype": "success",
924 "duration_ms": 130000,
925 "result": "Task completed after processing"
926 }"#;
927
928 let result = processor.process_line(json);
929 assert!(result.is_some());
930 let formatted = result.unwrap();
931 assert!(formatted.contains("✅ Task Result: success"));
932 assert!(formatted.contains("⏱️ Duration: 2 minutes, 10 seconds"));
933 }
934
935 #[test]
936 fn test_format_duration() {
937 let fs = Arc::new(MockFileSystem::new());
938 let processor = ClaudeCodeLogProcessor::new(fs);
939
940 assert_eq!(processor.format_duration(0), "0 seconds");
942 assert_eq!(processor.format_duration(1000), "1 second");
943 assert_eq!(processor.format_duration(45000), "45 seconds");
944
945 assert_eq!(processor.format_duration(60000), "1 minute");
947 assert_eq!(processor.format_duration(61000), "1 minute, 1 second");
948 assert_eq!(processor.format_duration(130000), "2 minutes, 10 seconds");
949 assert_eq!(processor.format_duration(180000), "3 minutes");
950
951 assert_eq!(processor.format_duration(3600000), "1 hour");
953 assert_eq!(
954 processor.format_duration(3661000),
955 "1 hour, 1 minute, 1 second"
956 );
957 assert_eq!(
958 processor.format_duration(7321000),
959 "2 hours, 2 minutes, 1 second"
960 );
961 assert_eq!(processor.format_duration(10800000), "3 hours");
962
963 assert_eq!(processor.format_duration(3660000), "1 hour, 1 minute");
965 assert_eq!(processor.format_duration(7200000), "2 hours");
966 assert_eq!(
967 processor.format_duration(86399000),
968 "23 hours, 59 minutes, 59 seconds"
969 );
970 }
971
972 #[test]
973 fn test_todo_order_preservation() {
974 let fs = Arc::new(MockFileSystem::new());
975 let mut processor = ClaudeCodeLogProcessor::new(fs);
976 let json = r#"{
977 "type": "assistant",
978 "message": {
979 "content": [{
980 "type": "tool_use",
981 "name": "TodoWrite",
982 "input": {
983 "todos": [
984 {"id": "1", "content": "First task", "status": "completed", "priority": "low"},
985 {"id": "2", "content": "Second task", "status": "in_progress", "priority": "high"},
986 {"id": "3", "content": "Third task", "status": "pending", "priority": "medium"},
987 {"id": "4", "content": "Fourth task", "status": "completed", "priority": "high"},
988 {"id": "5", "content": "Fifth task", "status": "pending", "priority": "low"}
989 ]
990 }
991 }]
992 }
993 }"#;
994
995 let result = processor.process_line(json);
996 assert!(result.is_some());
997 let formatted = result.unwrap();
998
999 let lines: Vec<&str> = formatted.lines().collect();
1001 let todo_lines: Vec<&str> = lines
1002 .iter()
1003 .filter(|line| {
1004 line.contains("task")
1005 && (line.contains("[1]")
1006 || line.contains("[2]")
1007 || line.contains("[3]")
1008 || line.contains("[4]")
1009 || line.contains("[5]"))
1010 })
1011 .cloned()
1012 .collect();
1013
1014 assert_eq!(todo_lines.len(), 5);
1016 assert!(todo_lines[0].contains("First task") && todo_lines[0].starts_with("✅"));
1017 assert!(todo_lines[1].contains("Second task") && todo_lines[1].starts_with("🔄"));
1018 assert!(todo_lines[2].contains("Third task") && todo_lines[2].starts_with("⏳"));
1019 assert!(todo_lines[3].contains("Fourth task") && todo_lines[3].starts_with("✅"));
1020 assert!(todo_lines[4].contains("Fifth task") && todo_lines[4].starts_with("⏳"));
1021 }
1022
1023 #[test]
1024 fn test_user_message() {
1025 let fs = Arc::new(MockFileSystem::new());
1026 let mut processor = ClaudeCodeLogProcessor::new(fs);
1027 let json = r#"{
1028 "type": "user",
1029 "message": {
1030 "content": "User input message"
1031 }
1032 }"#;
1033
1034 let result = processor.process_line(json);
1035 assert_eq!(result, Some("👤 User: User input message".to_string()));
1036 }
1037
1038 #[test]
1039 fn test_user_message_with_tool_result() {
1040 let fs = Arc::new(MockFileSystem::new());
1041 let mut processor = ClaudeCodeLogProcessor::new(fs);
1042 let json = r#"{
1043 "type": "user",
1044 "toolUseResult": {
1045 "stdout": "test result: ok. 5 passed; 0 failed",
1046 "stderr": "",
1047 "is_error": false
1048 }
1049 }"#;
1050
1051 let result = processor.process_line(json);
1052 assert_eq!(result, Some("👤 Tool result: Tests passed ✅".to_string()));
1053 }
1054
1055 #[test]
1056 fn test_user_message_with_file_search_result() {
1057 let fs = Arc::new(MockFileSystem::new());
1058 let mut processor = ClaudeCodeLogProcessor::new(fs);
1059 let json = r#"{
1060 "type": "user",
1061 "toolUseResult": {
1062 "filenames": ["file1.rs", "file2.rs", "file3.rs"]
1063 }
1064 }"#;
1065
1066 let result = processor.process_line(json);
1067 assert_eq!(result, Some("👤 Tool result: Found 3 files".to_string()));
1068 }
1069
1070 #[test]
1071 fn test_assistant_message_with_tool_use() {
1072 let fs = Arc::new(MockFileSystem::new());
1073 let mut processor = ClaudeCodeLogProcessor::new(fs);
1074 let json = r#"{
1075 "type": "assistant",
1076 "message": {
1077 "content": [{
1078 "type": "tool_use",
1079 "name": "Bash",
1080 "input": {
1081 "command": "cargo test --all"
1082 }
1083 }]
1084 }
1085 }"#;
1086
1087 let result = processor.process_line(json);
1088 assert!(result.is_some());
1089 assert!(result.unwrap().contains("🖥️ Running: cargo test --all"));
1090 }
1091
1092 #[test]
1093 fn test_assistant_message_with_edit_tool() {
1094 let fs = Arc::new(MockFileSystem::new());
1095 let mut processor = ClaudeCodeLogProcessor::new(fs);
1096 let json = r#"{
1097 "type": "assistant",
1098 "message": {
1099 "content": [{
1100 "type": "tool_use",
1101 "name": "Edit",
1102 "input": {
1103 "file_path": "/workspace/src/main.rs"
1104 }
1105 }]
1106 }
1107 }"#;
1108
1109 let result = processor.process_line(json);
1110 assert!(result.is_some());
1111 assert!(result.unwrap().contains("🔧 Editing main.rs"));
1112 }
1113
1114 #[test]
1115 fn test_cost_calculation_from_usage() {
1116 let fs = Arc::new(MockFileSystem::new());
1117 let processor = ClaudeCodeLogProcessor::new(fs);
1118
1119 let usage = Usage {
1120 input_tokens: Some(1000),
1121 output_tokens: Some(500),
1122 cache_creation_input_tokens: Some(0),
1123 cache_read_input_tokens: Some(0),
1124 service_tier: Some("standard".to_string()),
1125 };
1126
1127 let cost = processor.calculate_cost_from_usage(&usage);
1128 assert!(cost.is_some());
1129 assert!((cost.unwrap() - 0.0525).abs() < 0.0001);
1131 }
1132
1133 #[test]
1134 fn test_summary_message() {
1135 let fs = Arc::new(MockFileSystem::new());
1136 let mut processor = ClaudeCodeLogProcessor::new(fs);
1137 let json = r#"{
1138 "type": "summary",
1139 "summary": "Test Summary: Running unit tests"
1140 }"#;
1141
1142 let result = processor.process_line(json);
1143 assert_eq!(
1144 result,
1145 Some("📋 Summary: Test Summary: Running unit tests".to_string())
1146 );
1147 }
1148
1149 #[test]
1150 fn test_empty_line_processing() {
1151 let fs = Arc::new(MockFileSystem::new());
1152 let mut processor = ClaudeCodeLogProcessor::new(fs);
1153
1154 let result = processor.process_line("");
1155 assert!(result.is_none());
1156
1157 let result = processor.process_line(" ");
1158 assert!(result.is_none());
1159 }
1160
1161 #[tokio::test]
1162 async fn test_save_full_log() {
1163 let fs = Arc::new(MockFileSystem::new());
1164 let mut processor = ClaudeCodeLogProcessor::new(fs.clone());
1165
1166 processor.process_line(r#"{"type": "user"}"#);
1168 processor.process_line("Regular text line");
1169 processor.process_line(r#"{"type": "result", "subtype": "success"}"#);
1170
1171 let path = std::path::Path::new("/test/log.txt");
1173 let result = processor.save_full_log(path).await;
1174 assert!(result.is_ok());
1175
1176 let content = fs.read_file(path).await.unwrap();
1178 assert!(content.contains(r#"{"type": "user"}"#));
1179 assert!(content.contains("Regular text line"));
1180 assert!(content.contains(r#"{"type": "result", "subtype": "success"}"#));
1181 }
1182
1183 #[test]
1184 fn test_get_full_log() {
1185 let fs = Arc::new(MockFileSystem::new());
1186 let mut processor = ClaudeCodeLogProcessor::new(fs);
1187
1188 processor.process_line("Line 1");
1190 processor.process_line("Line 2");
1191 processor.process_line("Line 3");
1192
1193 let full_log = processor.get_full_log();
1194 assert_eq!(full_log, "Line 1\nLine 2\nLine 3");
1195 }
1196
1197 #[test]
1198 fn test_claude_code_agent_properties() {
1199 let agent = ClaudeCodeAgent::new();
1200
1201 assert_eq!(agent.name(), "claude-code");
1203
1204 let volumes = agent.volumes();
1206 assert_eq!(volumes.len(), 2);
1207
1208 let volume_paths: Vec<&str> = volumes
1210 .iter()
1211 .map(|(_, container_path, _)| container_path.as_str())
1212 .collect();
1213 assert!(volume_paths.contains(&"/home/agent/.claude"));
1214 assert!(volume_paths.contains(&"/home/agent/.claude.json"));
1215
1216 let env = agent.environment();
1218 assert_eq!(env.len(), 2);
1219
1220 let env_map: std::collections::HashMap<_, _> = env.into_iter().collect();
1221 assert_eq!(env_map.get("HOME"), Some(&"/home/agent".to_string()));
1222 assert_eq!(env_map.get("USER"), Some(&"agent".to_string()));
1223 }
1224
1225 #[test]
1226 fn test_claude_code_agent_build_command() {
1227 let agent = ClaudeCodeAgent::new();
1228
1229 let command = agent.build_command("/tmp/instructions.md");
1231 assert_eq!(command.len(), 3);
1232 assert_eq!(command[0], "sh");
1233 assert_eq!(command[1], "-c");
1234 assert!(command[2].contains("cat /instructions/instructions.md"));
1235 assert!(command[2].contains("claude -p --verbose --output-format stream-json"));
1236
1237 let command = agent.build_command("/path/to/task/instructions.txt");
1239 assert!(command[2].contains("cat /instructions/instructions.txt"));
1240 }
1241
1242 #[tokio::test]
1243 async fn test_claude_code_agent_validate_without_config() {
1244 let temp_dir = tempfile::tempdir().unwrap();
1246 unsafe {
1247 std::env::set_var("HOME", temp_dir.path());
1248 }
1249
1250 let agent = ClaudeCodeAgent::new();
1251 let result = agent.validate().await;
1252
1253 assert!(result.is_err());
1254 assert!(
1255 result
1256 .unwrap_err()
1257 .contains("Claude configuration not found")
1258 );
1259 }
1260
1261 #[test]
1262 fn test_claude_code_agent_create_log_processor() {
1263 let fs = Arc::new(MockFileSystem::new());
1264 let agent = ClaudeCodeAgent::new();
1265
1266 let log_processor = agent.create_log_processor(fs);
1267
1268 let _ = log_processor.get_full_log();
1271 }
1272}