use std::io::{self, BufRead};
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::build::tokens::{format_tokens, TokenUsage};
use crate::config::Config;
use crate::error::RslphError;
use crate::planning::{
assess_vagueness, detect_stack, REQUIREMENTS_CLARIFIER_PERSONA, TESTING_STRATEGIST_PERSONA,
};
use crate::progress::ProgressFile;
use crate::prompts::{get_plan_prompt_for_mode, PromptMode};
use crate::subprocess::{ClaudeRunner, OutputLine, StreamEvent, StreamResponse};
use crate::tui::plan_tui::run_plan_tui;
#[allow(clippy::too_many_arguments)]
pub async fn run_plan_command(
input: &str,
adaptive: bool,
tui: bool,
mode: PromptMode,
config: &Config,
working_dir: &Path,
cancel_token: CancellationToken,
timeout: Duration,
) -> color_eyre::Result<(PathBuf, TokenUsage)> {
if tui {
return run_tui_planning(input, mode, config, working_dir, cancel_token, timeout).await;
}
if adaptive {
return run_adaptive_planning(input, mode, config, working_dir, cancel_token, timeout)
.await;
}
run_basic_planning(input, mode, config, working_dir, cancel_token, timeout).await
}
async fn run_basic_planning(
input: &str,
mode: PromptMode,
config: &Config,
working_dir: &Path,
cancel_token: CancellationToken,
timeout: Duration,
) -> color_eyre::Result<(PathBuf, TokenUsage)> {
let stack = detect_stack(working_dir);
let system_prompt = get_plan_prompt_for_mode(mode);
let full_input = format!(
"## Detected Stack\n{}\n\n## User Request\n{}",
stack.to_summary(),
input
);
let args = vec![
"-p".to_string(), "--verbose".to_string(), "--output-format".to_string(), "stream-json".to_string(), "--system-prompt".to_string(), system_prompt,
full_input, ];
eprintln!(
"[TRACE] Spawning: {} {:?}",
config.claude_path,
args.iter().take(4).collect::<Vec<_>>()
);
let mut runner = ClaudeRunner::spawn(&config.claude_path, &args, working_dir)
.await
.map_err(|e| RslphError::Subprocess(format!("Failed to spawn claude: {}", e)))?;
eprintln!("[TRACE] Spawned subprocess with PID: {:?}", runner.id());
let output = run_with_tracing(&mut runner, timeout, cancel_token.clone()).await?;
let mut stream_response = StreamResponse::new();
for line in &output {
if let OutputLine::Stdout(s) = line {
stream_response.process_line(s);
}
}
let response_text = stream_response.text;
eprintln!(
"[TRACE] Claude output length: {} chars",
response_text.len()
);
if let Some(model) = &stream_response.model {
eprintln!("[TRACE] Model: {}", model);
}
eprintln!(
"[TRACE] Tokens: {} in / {} out / {} cache_write / {} cache_read",
stream_response.input_tokens,
stream_response.output_tokens,
stream_response.cache_creation_input_tokens,
stream_response.cache_read_input_tokens
);
println!(
"Tokens used: In: {} | Out: {} | CacheW: {} | CacheR: {}",
format_tokens(stream_response.input_tokens),
format_tokens(stream_response.output_tokens),
format_tokens(stream_response.cache_creation_input_tokens),
format_tokens(stream_response.cache_read_input_tokens),
);
let mut progress_file = ProgressFile::parse(&response_text)?;
if progress_file.name.is_empty() {
eprintln!("[TRACE] Progress file has no name, generating one...");
let generated_name = generate_project_name(
&config.claude_path,
input,
working_dir,
cancel_token.clone(),
timeout,
)
.await?;
eprintln!("[TRACE] Generated project name: {}", generated_name);
progress_file.name = generated_name;
}
let output_path = working_dir.join("progress.md");
progress_file.write(&output_path)?;
eprintln!("[TRACE] Wrote progress file to: {}", output_path.display());
let tokens = TokenUsage {
input_tokens: stream_response.input_tokens,
output_tokens: stream_response.output_tokens,
cache_creation_input_tokens: stream_response.cache_creation_input_tokens,
cache_read_input_tokens: stream_response.cache_read_input_tokens,
};
Ok((output_path, tokens))
}
async fn run_tui_planning(
input: &str,
mode: PromptMode,
config: &Config,
working_dir: &Path,
cancel_token: CancellationToken,
timeout: Duration,
) -> color_eyre::Result<(PathBuf, TokenUsage)> {
use tokio::time::timeout as tokio_timeout;
let stack = detect_stack(working_dir);
let system_prompt = get_plan_prompt_for_mode(mode);
let full_input = format!(
"## Detected Stack\n{}\n\n## User Request\n{}",
stack.to_summary(),
input
);
let args = vec![
"-p".to_string(),
"--verbose".to_string(),
"--output-format".to_string(),
"stream-json".to_string(),
"--system-prompt".to_string(),
system_prompt,
full_input,
];
let mut runner = ClaudeRunner::spawn(&config.claude_path, &args, working_dir)
.await
.map_err(|e| RslphError::Subprocess(format!("Failed to spawn claude: {}", e)))?;
let (event_tx, event_rx) = mpsc::unbounded_channel();
let tui_cancel = cancel_token.clone();
let tui_handle = tokio::spawn(async move { run_plan_tui(event_rx, tui_cancel).await });
let mut stream_response = StreamResponse::new();
let stream_cancel = cancel_token.clone();
let stream_result = tokio_timeout(timeout, async {
loop {
tokio::select! {
biased;
_ = stream_cancel.cancelled() => {
runner.terminate_gracefully(Duration::from_secs(5)).await
.map_err(|e| RslphError::Subprocess(e.to_string()))?;
return Err::<(), RslphError>(RslphError::Cancelled);
}
line = runner.next_output() => {
match line {
Some(OutputLine::Stdout(s)) => {
if let Ok(event) = StreamEvent::parse(&s) {
stream_response.process_event(&event);
let _ = event_tx.send(event);
}
}
Some(OutputLine::Stderr(_)) => {
}
None => {
break;
}
}
}
}
}
Ok(())
})
.await;
drop(event_tx);
let tui_state = tui_handle
.await
.map_err(|e| RslphError::Subprocess(format!("TUI task failed: {}", e)))?
.map_err(|e| RslphError::Subprocess(format!("TUI error: {}", e)))?;
match stream_result {
Err(_) => return Err(RslphError::Timeout(timeout.as_secs()).into()),
Ok(Err(e)) => return Err(e.into()),
Ok(Ok(())) => {}
}
if tui_state.should_quit {
return Err(RslphError::Cancelled.into());
}
let mut progress_file = ProgressFile::parse(&stream_response.text)?;
if progress_file.name.is_empty() {
let generated_name = generate_project_name(
&config.claude_path,
input,
working_dir,
cancel_token.clone(),
timeout,
)
.await?;
progress_file.name = generated_name;
}
let output_path = working_dir.join("progress.md");
progress_file.write(&output_path)?;
let tokens = TokenUsage {
input_tokens: stream_response.input_tokens,
output_tokens: stream_response.output_tokens,
cache_creation_input_tokens: stream_response.cache_creation_input_tokens,
cache_read_input_tokens: stream_response.cache_read_input_tokens,
};
Ok((output_path, tokens))
}
async fn run_with_tracing(
runner: &mut ClaudeRunner,
max_duration: Duration,
cancel_token: CancellationToken,
) -> Result<Vec<OutputLine>, RslphError> {
use tokio::time::timeout;
let collect_with_trace = async {
let mut output = Vec::new();
loop {
tokio::select! {
biased;
_ = cancel_token.cancelled() => {
eprintln!("[TRACE] Cancellation requested");
runner.terminate_gracefully(Duration::from_secs(5)).await
.map_err(|e| RslphError::Subprocess(e.to_string()))?;
return Err(RslphError::Cancelled);
}
line = runner.next_output() => {
match line {
Some(OutputLine::Stdout(s)) => {
eprintln!("[STDOUT] {}", s);
output.push(OutputLine::Stdout(s));
}
Some(OutputLine::Stderr(s)) => {
eprintln!("[STDERR] {}", s);
output.push(OutputLine::Stderr(s));
}
None => {
eprintln!("[TRACE] Subprocess streams closed");
break;
}
}
}
}
}
Ok(output)
};
match timeout(max_duration, collect_with_trace).await {
Ok(result) => result,
Err(_elapsed) => {
eprintln!("[TRACE] Timeout after {:?}", max_duration);
runner
.terminate_gracefully(Duration::from_secs(5))
.await
.map_err(|e| RslphError::Subprocess(e.to_string()))?;
Err(RslphError::Timeout(max_duration.as_secs()))
}
}
}
pub async fn run_adaptive_planning(
input: &str,
mode: PromptMode,
config: &Config,
working_dir: &Path,
cancel_token: CancellationToken,
timeout: Duration,
) -> color_eyre::Result<(PathBuf, TokenUsage)> {
let stack = detect_stack(working_dir);
println!("Detected stack:\n{}", stack.to_summary());
let vagueness = assess_vagueness(input);
println!("\nVagueness score: {:.2} (threshold: 0.5)", vagueness.score);
if !vagueness.reasons.is_empty() {
println!("Reasons: {}", vagueness.reasons.join(", "));
}
let mut clarifications = String::new();
if vagueness.is_vague() {
println!("\nInput appears vague, gathering requirements...\n");
let clarifier_input = format!(
"## Project Stack\n{}\n\n## User Idea\n{}",
stack.to_summary(),
input
);
let questions = run_claude_headless(
&config.claude_path,
REQUIREMENTS_CLARIFIER_PERSONA,
&clarifier_input,
working_dir,
cancel_token.clone(),
timeout,
)
.await?;
if !questions.contains("REQUIREMENTS_CLEAR") {
println!("Clarifying Questions:\n");
println!("{}", questions);
println!("\nPlease answer the questions above (type your answers, then Enter twice to submit):\n");
clarifications = read_multiline_input()?;
println!("\nGathered clarifications. Continuing...\n");
} else {
println!("Requirements are clear enough, skipping clarification.\n");
}
} else {
println!("\nInput is specific enough, skipping clarification.\n");
}
println!("Generating testing strategy...\n");
let testing_input = format!(
"## Project Stack\n{}\n\n## Requirements\n{}\n\n## Clarifications\n{}",
stack.to_summary(),
input,
if clarifications.is_empty() {
"None"
} else {
&clarifications
}
);
let testing_strategy = run_claude_headless(
&config.claude_path,
TESTING_STRATEGIST_PERSONA,
&testing_input,
working_dir,
cancel_token.clone(),
timeout,
)
.await?;
println!("Testing strategy generated.\n");
println!("Generating final plan...\n");
let plan_prompt = get_plan_prompt_for_mode(mode);
let final_input = format!(
"## Detected Stack\n{}\n\n## Requirements\n{}\n\n## Clarifications\n{}\n\n## Testing Strategy\n{}",
stack.to_summary(),
input,
if clarifications.is_empty() { "None" } else { &clarifications },
testing_strategy
);
let args = vec![
"-p".to_string(),
"--verbose".to_string(), "--output-format".to_string(),
"stream-json".to_string(), "--system-prompt".to_string(),
plan_prompt,
final_input,
];
let mut runner = ClaudeRunner::spawn(&config.claude_path, &args, working_dir)
.await
.map_err(|e| RslphError::Subprocess(format!("Failed to spawn claude: {}", e)))?;
let output = runner
.run_with_timeout(timeout, cancel_token.clone())
.await?;
let mut stream_response = StreamResponse::new();
for line in &output {
if let OutputLine::Stdout(s) = line {
stream_response.process_line(s);
}
}
let response_text = stream_response.text;
println!(
"Tokens used: In: {} | Out: {} | CacheW: {} | CacheR: {}",
format_tokens(stream_response.input_tokens),
format_tokens(stream_response.output_tokens),
format_tokens(stream_response.cache_creation_input_tokens),
format_tokens(stream_response.cache_read_input_tokens),
);
let mut progress_file = ProgressFile::parse(&response_text)?;
if progress_file.name.is_empty() {
eprintln!("[TRACE] Progress file has no name, generating one...");
let generated_name = generate_project_name(
&config.claude_path,
input,
working_dir,
cancel_token.clone(),
timeout,
)
.await?;
eprintln!("[TRACE] Generated project name: {}", generated_name);
progress_file.name = generated_name;
}
let output_path = working_dir.join("progress.md");
progress_file.write(&output_path)?;
let tokens = TokenUsage {
input_tokens: stream_response.input_tokens,
output_tokens: stream_response.output_tokens,
cache_creation_input_tokens: stream_response.cache_creation_input_tokens,
cache_read_input_tokens: stream_response.cache_read_input_tokens,
};
Ok((output_path, tokens))
}
async fn run_claude_headless(
claude_path: &str,
system_prompt: &str,
user_input: &str,
working_dir: &Path,
cancel_token: CancellationToken,
timeout: Duration,
) -> color_eyre::Result<String> {
let args = vec![
"-p".to_string(),
"--verbose".to_string(), "--output-format".to_string(),
"stream-json".to_string(), "--system-prompt".to_string(),
system_prompt.to_string(),
user_input.to_string(),
];
let mut runner = ClaudeRunner::spawn(claude_path, &args, working_dir)
.await
.map_err(|e| RslphError::Subprocess(format!("Failed to spawn claude: {}", e)))?;
let output = runner.run_with_timeout(timeout, cancel_token).await?;
let mut stream_response = StreamResponse::new();
for line in &output {
if let OutputLine::Stdout(s) = line {
stream_response.process_line(s);
}
}
Ok(stream_response.text)
}
async fn generate_project_name(
claude_path: &str,
user_input: &str,
working_dir: &Path,
cancel_token: CancellationToken,
timeout: Duration,
) -> color_eyre::Result<String> {
const NAME_GENERATOR_PROMPT: &str = r#"Generate a short project name for the following idea.
Rules:
- Exactly 2 words maximum
- Use kebab-case (lowercase with hyphens, e.g., "quadratic-solver", "todo-app", "file-sync")
- Be descriptive but concise
- Output ONLY the project name, nothing else
Example outputs:
- task-manager
- code-formatter
- weather-api
- chat-bot"#;
let response = run_claude_headless(
claude_path,
NAME_GENERATOR_PROMPT,
user_input,
working_dir,
cancel_token,
timeout,
)
.await?;
let name = response
.lines()
.next()
.unwrap_or("unnamed-project")
.trim()
.to_lowercase()
.replace(' ', "-")
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-')
.collect::<String>();
if name.is_empty() {
Ok("unnamed-project".to_string())
} else {
Ok(name)
}
}
fn read_multiline_input() -> color_eyre::Result<String> {
let stdin = io::stdin();
let mut lines = Vec::new();
let mut empty_count = 0;
for line in stdin.lock().lines() {
let line = line?;
if line.is_empty() {
empty_count += 1;
if empty_count >= 2 {
break;
}
lines.push(line);
} else {
empty_count = 0;
lines.push(line);
}
}
while lines.last().is_some_and(|l| l.is_empty()) {
lines.pop();
}
Ok(lines.join("\n"))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_run_plan_command_spawns_and_writes_file() {
let dir = TempDir::new().expect("temp dir");
let config = Config {
claude_path: "/bin/echo".to_string(),
..Default::default()
};
let token = CancellationToken::new();
let result = run_plan_command(
"build something",
false, false, PromptMode::Basic,
&config,
dir.path(),
token,
Duration::from_secs(5),
)
.await;
assert!(result.is_ok(), "Command should complete: {:?}", result);
let (output_path, tokens) = result.unwrap();
assert!(output_path.exists(), "Progress file should exist");
assert!(output_path.ends_with("progress.md"));
assert_eq!(tokens.input_tokens, 0);
assert_eq!(tokens.output_tokens, 0);
}
#[tokio::test]
async fn test_run_plan_command_timeout() {
let dir = TempDir::new().expect("temp dir");
let script = "#!/bin/sh\nsleep 60\n";
let script_path = dir.path().join("slow_script.sh");
std::fs::write(&script_path, script).expect("write script");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))
.expect("set permissions");
}
let config = Config {
claude_path: script_path.to_string_lossy().to_string(),
..Default::default()
};
let token = CancellationToken::new();
let result = run_plan_command(
"anything",
false, false, PromptMode::Basic,
&config,
dir.path(),
token,
Duration::from_millis(100),
)
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("timeout"), "Should timeout: {}", err);
}
#[tokio::test]
async fn test_run_plan_command_cancellation() {
let dir = TempDir::new().expect("temp dir");
let script = "#!/bin/sh\nsleep 60\n";
let script_path = dir.path().join("slow_script.sh");
std::fs::write(&script_path, script).expect("write script");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))
.expect("set permissions");
}
let config = Config {
claude_path: script_path.to_string_lossy().to_string(),
..Default::default()
};
let token = CancellationToken::new();
let token_clone = token.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
token_clone.cancel();
});
let result = run_plan_command(
"anything",
false, false, PromptMode::Basic,
&config,
dir.path(),
token,
Duration::from_secs(10),
)
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("cancelled"), "Should be cancelled: {}", err);
}
#[tokio::test]
async fn test_run_plan_command_nonexistent_command() {
let dir = TempDir::new().expect("temp dir");
let config = Config {
claude_path: "/nonexistent/command".to_string(),
..Default::default()
};
let token = CancellationToken::new();
let result = run_plan_command(
"anything",
false, false, PromptMode::Basic,
&config,
dir.path(),
token,
Duration::from_secs(5),
)
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("spawn") || err.contains("Subprocess"),
"Should fail to spawn: {}",
err
);
}
}