use crate::abstractions::exit_status::ExitStatusExt;
use crate::subprocess::{
ClaudeRunner as SubprocessClaudeRunner, ProcessCommand, ProcessCommandBuilder,
SubprocessManager,
};
use anyhow::Result;
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
#[derive(Debug, Clone, PartialEq, Eq)]
enum CommandErrorType {
TransientError,
CommandNotFound,
PermanentError,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum OutputClassification {
Success,
TransientFailure(String),
PermanentFailure,
}
#[async_trait]
pub trait ClaudeClient: Send + Sync {
async fn execute_command(
&self,
command: &str,
args: &[&str],
env_vars: Option<HashMap<String, String>>,
max_retries: u32,
verbose: bool,
) -> Result<std::process::Output>;
async fn check_availability(&self) -> Result<()>;
async fn code_review(&self, verbose: bool) -> Result<bool>;
async fn implement_spec(&self, spec_id: &str, verbose: bool) -> Result<bool>;
async fn lint(&self, verbose: bool) -> Result<bool>;
}
pub struct RealClaudeClient {
subprocess: SubprocessManager,
}
impl RealClaudeClient {
#[must_use]
pub fn new() -> Self {
Self {
subprocess: SubprocessManager::production(),
}
}
#[cfg(test)]
pub fn with_subprocess(subprocess: SubprocessManager) -> Self {
Self { subprocess }
}
fn is_transient_error(stderr: &str) -> bool {
let transient_patterns = [
"rate limit",
"timeout",
"connection refused",
"temporary failure",
"network",
"503",
"429",
"could not connect",
"broken pipe",
];
let stderr_lower = stderr.to_lowercase();
transient_patterns
.iter()
.any(|pattern| stderr_lower.contains(pattern))
}
fn calculate_retry_delay(attempt: u32) -> std::time::Duration {
std::time::Duration::from_secs(2u64.pow(attempt.min(3)))
}
fn classify_command_error(
error: &crate::subprocess::ProcessError,
stderr: &str,
) -> CommandErrorType {
use crate::subprocess::ProcessError;
match error {
ProcessError::CommandNotFound(_) => CommandErrorType::CommandNotFound,
_ => {
if Self::is_transient_error(stderr) {
CommandErrorType::TransientError
} else {
CommandErrorType::PermanentError
}
}
}
}
fn should_retry_error(error_type: &CommandErrorType, attempt: u32, max_retries: u32) -> bool {
match error_type {
CommandErrorType::TransientError => attempt < max_retries,
CommandErrorType::CommandNotFound | CommandErrorType::PermanentError => false,
}
}
fn build_claude_command(
args: &[&str],
env_vars: Option<&HashMap<String, String>>,
) -> ProcessCommand {
let mut builder = ProcessCommandBuilder::new("claude");
for arg in args {
builder = builder.arg(arg);
}
if let Some(vars) = env_vars {
for (key, value) in vars {
builder = builder.env(key, value);
}
}
builder.build()
}
fn classify_output_result(
output: &crate::subprocess::runner::ProcessOutput,
) -> OutputClassification {
if output.status.success() {
OutputClassification::Success
} else if Self::is_transient_error(&output.stderr) {
OutputClassification::TransientFailure(output.stderr.clone())
} else {
OutputClassification::PermanentFailure
}
}
fn should_continue_retry(
classification: &OutputClassification,
attempt: u32,
max_retries: u32,
) -> bool {
matches!(classification, OutputClassification::TransientFailure(_)) && attempt < max_retries
}
fn handle_process_output(
output: crate::subprocess::runner::ProcessOutput,
attempt: u32,
max_retries: u32,
verbose: bool,
) -> (Option<std::process::Output>, Option<String>) {
let classification = Self::classify_output_result(&output);
match classification {
OutputClassification::Success => (Some(Self::convert_to_std_output(output)), None),
OutputClassification::TransientFailure(ref stderr) => {
if Self::should_continue_retry(&classification, attempt, max_retries) {
if verbose {
eprintln!(
"⚠️ Transient error detected: {}",
stderr.lines().next().unwrap_or("Unknown error")
);
}
(None, Some(stderr.clone()))
} else {
(Some(Self::convert_to_std_output(output)), None)
}
}
OutputClassification::PermanentFailure => {
(Some(Self::convert_to_std_output(output)), None)
}
}
}
fn handle_process_error(
error: crate::subprocess::ProcessError,
attempt: u32,
max_retries: u32,
verbose: bool,
) -> Result<(Option<std::process::Output>, Option<String>)> {
let error_type = Self::classify_command_error(&error, "");
if error_type == CommandErrorType::CommandNotFound {
return Err(anyhow::anyhow!("Claude CLI not found: {}", error));
}
if Self::should_retry_error(&error_type, attempt, max_retries) {
if verbose {
eprintln!("⚠️ IO error: {error}");
}
Ok((None, Some(error.to_string())))
} else {
Err(anyhow::anyhow!("Failed to execute command: {}", error))
}
}
fn convert_to_std_output(
output: crate::subprocess::runner::ProcessOutput,
) -> std::process::Output {
let exit_code = output.status.code().unwrap_or(1);
std::process::Output {
status: std::process::ExitStatus::from_raw(exit_code),
stdout: output.stdout.into_bytes(),
stderr: output.stderr.into_bytes(),
}
}
}
impl Default for RealClaudeClient {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl ClaudeClient for RealClaudeClient {
async fn execute_command(
&self,
command: &str,
args: &[&str],
env_vars: Option<HashMap<String, String>>,
max_retries: u32,
verbose: bool,
) -> Result<std::process::Output> {
use tokio::time::sleep;
let mut attempt = 0;
let mut last_error = None;
while attempt <= max_retries {
if attempt > 0 {
let delay = Self::calculate_retry_delay(attempt);
if verbose {
println!(
"⏳ Retrying {command} after {delay:?} (attempt {attempt}/{max_retries})"
);
}
sleep(delay).await;
}
let cmd = Self::build_claude_command(args, env_vars.as_ref());
let result = self.subprocess.runner().run(cmd).await;
match result {
Ok(output) => {
let (maybe_output, maybe_error) =
Self::handle_process_output(output, attempt, max_retries, verbose);
if let Some(output) = maybe_output {
return Ok(output);
}
last_error = maybe_error;
}
Err(error) => {
let (maybe_output, maybe_error) =
Self::handle_process_error(error, attempt, max_retries, verbose)?;
if let Some(output) = maybe_output {
return Ok(output);
}
last_error = maybe_error;
}
}
attempt += 1;
}
Err(anyhow::anyhow!(
"Failed {} after {} retries. Last error: {}",
command,
max_retries,
last_error.unwrap_or_else(|| "Unknown error".to_string())
))
}
async fn check_availability(&self) -> Result<()> {
let output = self
.subprocess
.runner()
.run(ProcessCommandBuilder::new("which").arg("claude").build())
.await?;
if !output.status.success() || output.stdout.is_empty() {
let version_check = self.subprocess.claude().check_availability().await?;
if !version_check {
return Err(anyhow::anyhow!(
"Claude CLI not found. Please install Claude CLI:\n\
\n\
1. Visit: https://claude.ai/download\n\
2. Download and install Claude CLI for your platform\n\
3. Run 'claude auth' to authenticate\n\
4. Ensure 'claude' is in your PATH\n\
\n\
You can verify the installation by running: claude --version"
));
}
}
Ok(())
}
async fn code_review(&self, verbose: bool) -> Result<bool> {
println!("🤖 Running /prodigy-code-review...");
let mut env_vars = HashMap::new();
if std::env::var("PRODIGY_AUTOMATION").unwrap_or_default() == "true" {
env_vars.insert("PRODIGY_AUTOMATION".to_string(), "true".to_string());
}
let args = vec![
"--dangerously-skip-permissions",
"--print",
"/prodigy-code-review",
];
let output = self
.execute_command("/prodigy-code-review", &args, Some(env_vars), 2, verbose)
.await?;
if verbose {
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.trim().is_empty() {
println!("Claude output:\n{stdout}");
}
}
Ok(output.status.success())
}
async fn implement_spec(&self, spec_id: &str, verbose: bool) -> Result<bool> {
println!("🔧 Running /prodigy-implement-spec {spec_id}...");
let mut env_vars = HashMap::new();
if std::env::var("PRODIGY_AUTOMATION").unwrap_or_default() == "true" {
env_vars.insert("PRODIGY_AUTOMATION".to_string(), "true".to_string());
}
let args = vec![
"--dangerously-skip-permissions",
"--print",
"/prodigy-implement-spec",
spec_id,
];
let output = self
.execute_command(
&format!("/prodigy-implement-spec {spec_id}"),
&args,
Some(env_vars),
2,
verbose,
)
.await?;
if verbose {
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.trim().is_empty() {
println!("Claude output:\n{stdout}");
}
}
Ok(output.status.success())
}
async fn lint(&self, verbose: bool) -> Result<bool> {
println!("🧹 Running /prodigy-lint...");
let mut env_vars = HashMap::new();
if std::env::var("PRODIGY_AUTOMATION").unwrap_or_default() == "true" {
env_vars.insert("PRODIGY_AUTOMATION".to_string(), "true".to_string());
}
let args = vec!["--dangerously-skip-permissions", "--print", "/prodigy-lint"];
let output = self
.execute_command("/prodigy-lint", &args, Some(env_vars), 2, verbose)
.await?;
if verbose {
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.trim().is_empty() {
println!("Claude output:\n{stdout}");
}
}
Ok(output.status.success())
}
}
type CalledCommands = Arc<Mutex<Vec<(String, Vec<String>)>>>;
pub struct MockClaudeClient {
pub command_responses: Arc<Mutex<Vec<Result<std::process::Output>>>>,
pub is_available: bool,
pub called_commands: CalledCommands,
}
impl MockClaudeClient {
#[must_use]
pub fn new() -> Self {
Self {
command_responses: Arc::new(Mutex::new(Vec::new())),
is_available: true,
called_commands: Arc::new(Mutex::new(Vec::new())),
}
}
pub async fn add_response(&self, response: Result<std::process::Output>) {
self.command_responses.lock().await.push(response);
}
pub async fn add_success_response(&self, stdout: &str) {
let output = std::process::Output {
status: std::process::ExitStatus::from_raw(0),
stdout: stdout.as_bytes().to_vec(),
stderr: Vec::new(),
};
self.add_response(Ok(output)).await;
}
pub async fn add_error_response(&self, stderr: &str, exit_code: i32) {
let output = std::process::Output {
status: std::process::ExitStatus::from_raw(exit_code),
stdout: Vec::new(),
stderr: stderr.as_bytes().to_vec(),
};
self.add_response(Ok(output)).await;
}
pub async fn get_called_commands(&self) -> Vec<(String, Vec<String>)> {
self.called_commands.lock().await.clone()
}
}
impl Default for MockClaudeClient {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl ClaudeClient for MockClaudeClient {
async fn execute_command(
&self,
command: &str,
args: &[&str],
_env_vars: Option<HashMap<String, String>>,
_max_retries: u32,
_verbose: bool,
) -> Result<std::process::Output> {
let args_vec: Vec<String> = args.iter().map(|&s| s.to_string()).collect();
self.called_commands
.lock()
.await
.push((command.to_string(), args_vec));
let mut responses = self.command_responses.lock().await;
if responses.is_empty() {
return Err(anyhow::anyhow!("No mock response configured"));
}
responses.remove(0)
}
async fn check_availability(&self) -> Result<()> {
if self.is_available {
Ok(())
} else {
Err(anyhow::anyhow!("Claude CLI not available (mock)"))
}
}
async fn code_review(&self, verbose: bool) -> Result<bool> {
if !self.is_available {
return Err(anyhow::anyhow!("Claude CLI not available"));
}
let args = vec![
"--dangerously-skip-permissions",
"--print",
"/prodigy-code-review",
];
let output = self
.execute_command("/prodigy-code-review", &args, None, 2, verbose)
.await?;
Ok(output.status.success())
}
async fn implement_spec(&self, spec_id: &str, verbose: bool) -> Result<bool> {
if !self.is_available {
return Err(anyhow::anyhow!("Claude CLI not available"));
}
let args = vec![
"--dangerously-skip-permissions",
"--print",
"/prodigy-implement-spec",
spec_id,
];
let output = self
.execute_command(
&format!("/prodigy-implement-spec {spec_id}"),
&args,
None,
2,
verbose,
)
.await?;
Ok(output.status.success())
}
async fn lint(&self, verbose: bool) -> Result<bool> {
if !self.is_available {
return Err(anyhow::anyhow!("Claude CLI not available"));
}
let args = vec!["--dangerously-skip-permissions", "--print", "/prodigy-lint"];
let output = self
.execute_command("/prodigy-lint", &args, None, 2, verbose)
.await?;
Ok(output.status.success())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_mock_claude_client() {
let mock = MockClaudeClient::new();
mock.add_success_response("Code review completed").await;
mock.add_success_response("Implementation completed").await;
let result = mock.code_review(false).await.unwrap();
assert!(result);
let result = mock.implement_spec("test-spec-123", false).await.unwrap();
assert!(result);
let commands = mock.get_called_commands().await;
assert_eq!(commands.len(), 2);
assert_eq!(commands[0].0, "/prodigy-code-review");
assert_eq!(commands[1].0, "/prodigy-implement-spec test-spec-123");
}
#[tokio::test]
async fn test_mock_claude_unavailable() {
let mut mock = MockClaudeClient::new();
mock.is_available = false;
let result = mock.check_availability().await;
assert!(result.is_err());
let result = mock.code_review(false).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_mock_claude_error_response() {
let mock = MockClaudeClient::new();
mock.add_error_response("rate limit exceeded", 1).await;
let result = mock.code_review(false).await.unwrap();
assert!(!result);
}
#[test]
fn test_transient_error_detection() {
assert!(RealClaudeClient::is_transient_error(
"Error: rate limit exceeded"
));
assert!(RealClaudeClient::is_transient_error("Connection timeout"));
assert!(RealClaudeClient::is_transient_error(
"HTTP 503 Service Unavailable"
));
assert!(!RealClaudeClient::is_transient_error("Syntax error"));
}
#[test]
fn test_calculate_retry_delay() {
use std::time::Duration;
assert_eq!(
RealClaudeClient::calculate_retry_delay(0),
Duration::from_secs(1)
); assert_eq!(
RealClaudeClient::calculate_retry_delay(1),
Duration::from_secs(2)
); assert_eq!(
RealClaudeClient::calculate_retry_delay(2),
Duration::from_secs(4)
); assert_eq!(
RealClaudeClient::calculate_retry_delay(3),
Duration::from_secs(8)
);
assert_eq!(
RealClaudeClient::calculate_retry_delay(4),
Duration::from_secs(8)
);
assert_eq!(
RealClaudeClient::calculate_retry_delay(10),
Duration::from_secs(8)
);
}
#[test]
fn test_classify_command_error() {
use crate::subprocess::ProcessError;
let error = ProcessError::CommandNotFound("claude".to_string());
assert_eq!(
RealClaudeClient::classify_command_error(&error, ""),
CommandErrorType::CommandNotFound
);
let io_error = std::io::Error::other("IO error");
let error = ProcessError::Io(io_error);
assert_eq!(
RealClaudeClient::classify_command_error(&error, "rate limit exceeded"),
CommandErrorType::TransientError
);
let io_error = std::io::Error::other("IO error");
let error = ProcessError::Io(io_error);
assert_eq!(
RealClaudeClient::classify_command_error(&error, "Connection timeout"),
CommandErrorType::TransientError
);
let io_error = std::io::Error::other("IO error");
let error = ProcessError::Io(io_error);
assert_eq!(
RealClaudeClient::classify_command_error(&error, "HTTP 503 Service Unavailable"),
CommandErrorType::TransientError
);
let io_error = std::io::Error::other("IO error");
let error = ProcessError::Io(io_error);
assert_eq!(
RealClaudeClient::classify_command_error(&error, "Syntax error"),
CommandErrorType::PermanentError
);
let io_error = std::io::Error::other("IO error");
let error = ProcessError::Io(io_error);
assert_eq!(
RealClaudeClient::classify_command_error(&error, "Unknown command"),
CommandErrorType::PermanentError
);
}
#[test]
fn test_should_retry_error() {
assert!(RealClaudeClient::should_retry_error(
&CommandErrorType::TransientError,
0,
3
));
assert!(RealClaudeClient::should_retry_error(
&CommandErrorType::TransientError,
2,
3
));
assert!(!RealClaudeClient::should_retry_error(
&CommandErrorType::TransientError,
3,
3
));
assert!(!RealClaudeClient::should_retry_error(
&CommandErrorType::TransientError,
5,
3
));
assert!(!RealClaudeClient::should_retry_error(
&CommandErrorType::CommandNotFound,
0,
3
));
assert!(!RealClaudeClient::should_retry_error(
&CommandErrorType::CommandNotFound,
1,
3
));
assert!(!RealClaudeClient::should_retry_error(
&CommandErrorType::PermanentError,
0,
3
));
assert!(!RealClaudeClient::should_retry_error(
&CommandErrorType::PermanentError,
2,
3
));
}
#[test]
fn test_build_claude_command() {
use std::collections::HashMap;
let args = vec!["--help"];
let cmd = RealClaudeClient::build_claude_command(&args, None);
assert_eq!(cmd.program, "claude");
assert_eq!(cmd.args, vec!["--help"]);
assert!(cmd.env.is_empty());
let args = vec!["--dangerously-skip-permissions", "--print", "/test"];
let cmd = RealClaudeClient::build_claude_command(&args, None);
assert_eq!(cmd.program, "claude");
assert_eq!(
cmd.args,
vec!["--dangerously-skip-permissions", "--print", "/test"]
);
let mut env_vars = HashMap::new();
env_vars.insert("PRODIGY_AUTOMATION".to_string(), "true".to_string());
env_vars.insert("VERBOSE".to_string(), "1".to_string());
let args = vec!["/lint"];
let cmd = RealClaudeClient::build_claude_command(&args, Some(&env_vars));
assert_eq!(cmd.program, "claude");
assert_eq!(cmd.args, vec!["/lint"]);
assert_eq!(cmd.env.len(), 2);
assert_eq!(cmd.env.get("PRODIGY_AUTOMATION"), Some(&"true".to_string()));
assert_eq!(cmd.env.get("VERBOSE"), Some(&"1".to_string()));
let args: Vec<&str> = vec![];
let cmd = RealClaudeClient::build_claude_command(&args, None);
assert_eq!(cmd.program, "claude");
assert!(cmd.args.is_empty());
assert!(cmd.env.is_empty());
}
#[test]
fn test_classify_output_result() {
use crate::subprocess::runner::{ExitStatus, ProcessOutput};
use std::time::Duration;
let output = ProcessOutput {
status: ExitStatus::Success,
stdout: "success".to_string(),
stderr: String::new(),
duration: Duration::from_secs(1),
};
assert_eq!(
RealClaudeClient::classify_output_result(&output),
OutputClassification::Success
);
let output = ProcessOutput {
status: ExitStatus::Error(1),
stdout: String::new(),
stderr: "rate limit exceeded".to_string(),
duration: Duration::from_secs(1),
};
assert_eq!(
RealClaudeClient::classify_output_result(&output),
OutputClassification::TransientFailure("rate limit exceeded".to_string())
);
let output = ProcessOutput {
status: ExitStatus::Error(1),
stdout: String::new(),
stderr: "invalid argument".to_string(),
duration: Duration::from_secs(1),
};
assert_eq!(
RealClaudeClient::classify_output_result(&output),
OutputClassification::PermanentFailure
);
}
#[test]
fn test_should_continue_retry() {
let classification = OutputClassification::TransientFailure("error".to_string());
assert!(RealClaudeClient::should_continue_retry(
&classification,
0,
3
));
assert!(RealClaudeClient::should_continue_retry(
&classification,
2,
3
));
assert!(!RealClaudeClient::should_continue_retry(
&classification,
3,
3
));
assert!(!RealClaudeClient::should_continue_retry(
&classification,
5,
3
));
let classification = OutputClassification::PermanentFailure;
assert!(!RealClaudeClient::should_continue_retry(
&classification,
0,
3
));
assert!(!RealClaudeClient::should_continue_retry(
&classification,
2,
3
));
let classification = OutputClassification::Success;
assert!(!RealClaudeClient::should_continue_retry(
&classification,
0,
3
));
assert!(!RealClaudeClient::should_continue_retry(
&classification,
2,
3
));
}
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn prop_should_retry_error_respects_max_retries(
attempt in 0u32..100,
max_retries in 0u32..100
) {
let result = RealClaudeClient::should_retry_error(
&CommandErrorType::TransientError,
attempt,
max_retries
);
if result {
prop_assert!(attempt < max_retries);
} else {
prop_assert!(attempt >= max_retries);
}
}
#[test]
fn prop_permanent_errors_never_retry(
attempt in 0u32..100,
max_retries in 0u32..100
) {
prop_assert!(!RealClaudeClient::should_retry_error(
&CommandErrorType::PermanentError,
attempt,
max_retries
));
}
#[test]
fn prop_command_not_found_never_retries(
attempt in 0u32..100,
max_retries in 0u32..100
) {
prop_assert!(!RealClaudeClient::should_retry_error(
&CommandErrorType::CommandNotFound,
attempt,
max_retries
));
}
#[test]
fn prop_retry_delay_exponential_backoff(attempt in 0u32..10) {
use std::time::Duration;
let delay = RealClaudeClient::calculate_retry_delay(attempt);
let capped_attempt = attempt.min(3);
let expected = Duration::from_secs(2u64.pow(capped_attempt));
prop_assert_eq!(delay, expected);
}
#[test]
fn prop_classify_output_consistent(
exit_code in 0i32..10,
has_transient_error in proptest::bool::ANY
) {
use crate::subprocess::runner::{ExitStatus, ProcessOutput};
use std::time::Duration;
let stderr = if has_transient_error {
"rate limit exceeded".to_string()
} else {
"other error".to_string()
};
let status = if exit_code == 0 {
ExitStatus::Success
} else {
ExitStatus::Error(exit_code)
};
let output = ProcessOutput {
status,
stdout: String::new(),
stderr,
duration: Duration::from_secs(1),
};
let classification = RealClaudeClient::classify_output_result(&output);
match classification {
OutputClassification::Success => {
prop_assert_eq!(exit_code, 0);
}
OutputClassification::TransientFailure(_) => {
prop_assert!(exit_code != 0);
prop_assert!(has_transient_error);
}
OutputClassification::PermanentFailure => {
prop_assert!(exit_code != 0);
prop_assert!(!has_transient_error);
}
}
}
#[test]
fn prop_continue_retry_consistency(
attempt in 0u32..100,
max_retries in 0u32..100
) {
let classification = OutputClassification::TransientFailure("error".to_string());
let continue_result = RealClaudeClient::should_continue_retry(
&classification,
attempt,
max_retries
);
let retry_result = RealClaudeClient::should_retry_error(
&CommandErrorType::TransientError,
attempt,
max_retries
);
prop_assert_eq!(continue_result, retry_result);
}
}
}
#[test]
fn test_convert_to_std_output() {
use crate::subprocess::runner::{ExitStatus, ProcessOutput};
use std::time::Duration;
let process_output = ProcessOutput {
status: ExitStatus::Success,
stdout: "test output".to_string(),
stderr: String::new(),
duration: Duration::from_secs(1),
};
let std_output = RealClaudeClient::convert_to_std_output(process_output);
assert!(std_output.status.success());
assert_eq!(String::from_utf8_lossy(&std_output.stdout), "test output");
assert!(std_output.stderr.is_empty());
let process_output = ProcessOutput {
status: ExitStatus::Error(42),
stdout: String::new(),
stderr: "error message".to_string(),
duration: Duration::from_secs(1),
};
let std_output = RealClaudeClient::convert_to_std_output(process_output);
assert!(!std_output.status.success());
assert!(std_output.stdout.is_empty());
assert_eq!(String::from_utf8_lossy(&std_output.stderr), "error message");
let process_output = ProcessOutput {
status: ExitStatus::Success,
stdout: "stdout content".to_string(),
stderr: "stderr content".to_string(),
duration: Duration::from_secs(1),
};
let std_output = RealClaudeClient::convert_to_std_output(process_output);
assert_eq!(
String::from_utf8_lossy(&std_output.stdout),
"stdout content"
);
assert_eq!(
String::from_utf8_lossy(&std_output.stderr),
"stderr content"
);
}
#[tokio::test]
async fn test_add_error_response() {
let mock = MockClaudeClient::new();
mock.add_error_response("test error", 1).await;
let result = mock.execute_command("/test", &[], None, 1, false).await;
assert!(result.is_ok());
let output = result.unwrap();
assert!(!output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stderr), "test error");
}
#[tokio::test]
async fn test_add_success_response() {
let mock = MockClaudeClient::new();
mock.add_success_response("success output").await;
let result = mock.execute_command("/test", &[], None, 1, false).await;
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stdout), "success output");
}
}