use std::collections::HashSet;
use std::fmt;
use std::io::{IsTerminal, Read};
use std::path::Path;
use std::process::Stdio;
use console::style;
use inquire::InquireError;
use crate::cli::{
DEFAULT_MAX_RETRIES, DEFAULT_RATE_LIMIT_RETRIES, PLAN_STDIN_SENTINEL, PlanArgs, PlanWorkerArgs,
};
use crate::config::{WorkflowConfig, validate_config};
use crate::error::{CruiseError, Result};
use crate::multiline_input::{InputResult, prompt_multiline};
use crate::new_session_history::{NewSessionHistory, resolved_config_key_for_session};
use crate::resolver::ConfigSource;
use crate::session::{PLAN_VAR, SessionManager, SessionState, get_cruise_home};
use crate::variable::VariableStore;
use crate::workflow::{SkippableStepNode, list_skippable_steps};
const PLAN_PROMPT_TEMPLATE: &str = include_str!("../prompts/plan.md");
const FIX_PLAN_PROMPT_TEMPLATE: &str = include_str!("../prompts/fix-plan.md");
const ASK_PLAN_PROMPT_TEMPLATE: &str = include_str!("../prompts/ask-plan.md");
pub async fn run(args: PlanArgs) -> Result<()> {
let (yaml, source) = crate::resolver::resolve_config(args.config.as_deref())?;
eprintln!("{}", style(source.display_string()).dim());
let noninteractive = !std::io::stdin().is_terminal();
let input = read_plan_input(args.input, noninteractive)?;
if args.dry_run {
eprintln!(
"{}",
style(format!("Would plan: \"{}\"", input.trim())).dim()
);
return Ok(());
}
let config = WorkflowConfig::from_yaml(&yaml)
.map_err(|e| CruiseError::ConfigParseError(e.to_string()))?;
validate_config(&config)?;
let manager = SessionManager::new(get_cruise_home()?);
let mut session = create_planning_session(&manager, &source, &yaml, input.trim().to_string())?;
let plan_path = session.plan_path(&manager.sessions_dir());
let mut vars = VariableStore::new(session.input.clone());
vars.set_named_file(PLAN_VAR, plan_path.clone());
if let Err(e) = generate_plan_markdown(
&config,
&mut vars,
&plan_path,
args.rate_limit_retries,
Some(session.base_dir.as_path()),
)
.await
{
eprintln!(
"\n{} Plan generation failed. Session {} discarded.",
style("✗").red().bold(),
session.id
);
if let Err(del_err) = manager.delete(&session.id) {
eprintln!("warning: failed to clean up session: {del_err}");
}
return Err(e);
}
run_approve_loop(
&config,
&manager,
&mut session,
&plan_path,
&mut vars,
args.rate_limit_retries,
noninteractive,
)
.await
}
pub fn launch_background_plan(plan_input: &str) -> Result<()> {
let (yaml, source) = crate::resolver::resolve_config(None)?;
eprintln!("{}", style(source.display_string()).dim());
let config = WorkflowConfig::from_yaml(&yaml)
.map_err(|e| CruiseError::ConfigParseError(e.to_string()))?;
validate_config(&config)?;
let input = read_background_plan_input(plan_input)?;
let manager = SessionManager::new(get_cruise_home()?);
let session = create_planning_session(&manager, &source, &yaml, input)?;
spawn_plan_worker(&session.id, DEFAULT_RATE_LIMIT_RETRIES)?;
eprintln!(
"\n{} Session {} created. Planning in background.",
style("✓").green().bold(),
session.id
);
eprintln!(" Check status with: {}", style("cruise list").cyan());
eprintln!(
" Run once ready: {}",
style(format!("cruise run {}", session.id)).cyan()
);
Ok(())
}
pub async fn run_plan_worker(args: PlanWorkerArgs) -> Result<()> {
let manager = SessionManager::new(get_cruise_home()?);
let mut session = match manager.load(&args.session) {
Ok(session) => session,
Err(CruiseError::SessionError(_)) => return Ok(()),
Err(err) => return Err(err),
};
session.plan_error = None;
manager.save(&session)?;
let result = generate_plan_for_session(&manager, &session, args.rate_limit_retries).await;
match result {
Ok(plan_markdown) => {
crate::metadata::refresh_session_title_from_plan(&mut session, &plan_markdown);
session.plan_error = None;
manager.save(&session)?;
Ok(())
}
Err(err) => {
let plan_error = err.to_string();
session.plan_error = Some(plan_error.clone());
manager.save(&session)?;
Err(CruiseError::Other(plan_error))
}
}
}
fn read_plan_input(input: Option<String>, noninteractive: bool) -> Result<String> {
let stdin_input = if input.is_none() && noninteractive {
let mut s = String::new();
std::io::stdin()
.read_to_string(&mut s)
.map_err(CruiseError::IoError)?;
Some(s)
} else {
None
};
resolve_input(input, stdin_input, || {
if noninteractive {
return Err(CruiseError::Other(
"no input provided: stdin is not a terminal and no --input flag was given"
.to_string(),
));
}
prompt_for_plan_input()
})
}
fn read_background_plan_input(input: &str) -> Result<String> {
if input == PLAN_STDIN_SENTINEL {
if std::io::stdin().is_terminal() {
return Err(CruiseError::Other(format!(
"--plan {PLAN_STDIN_SENTINEL} requires piped stdin"
)));
}
let mut stdin_input = String::new();
std::io::stdin()
.read_to_string(&mut stdin_input)
.map_err(CruiseError::IoError)?;
let trimmed = stdin_input.trim().to_string();
if trimmed.is_empty() {
return Err(CruiseError::Other(
"no input provided on stdin for background planning".to_string(),
));
}
return Ok(trimmed);
}
let trimmed = input.trim().to_string();
if trimmed.is_empty() {
return Err(CruiseError::Other(
"background planning input cannot be empty".to_string(),
));
}
Ok(trimmed)
}
async fn approve_with_title(
session: &mut SessionState,
manager: &SessionManager,
plan_content: &str,
llm_api: Option<&crate::llm_api::LlmApiConfig>,
) -> Result<()> {
if let Some(api_config) = llm_api {
match crate::llm_api::generate_session_title(api_config, &session.input, plan_content).await
{
Ok(title) => session.title = Some(title),
Err(e) => {
eprintln!("warning: session title generation via API failed: {e}");
crate::metadata::refresh_session_title_from_plan(session, plan_content);
}
}
} else {
crate::metadata::refresh_session_title_from_plan(session, plan_content);
}
session.approve();
manager.save(session)
}
fn create_planning_session(
manager: &SessionManager,
source: &ConfigSource,
yaml: &str,
input: String,
) -> Result<SessionState> {
let session_id = SessionManager::new_session_id();
let base_dir = std::env::current_dir()?;
let mut session =
SessionState::new(session_id.clone(), base_dir, source.display_string(), input);
session.config_path = source.path().cloned();
manager.create(&session)?;
if session.config_path.is_none() {
let session_dir = manager.sessions_dir().join(&session_id);
std::fs::write(session_dir.join("config.yaml"), yaml)?;
}
Ok(session)
}
fn spawn_plan_worker(session_id: &str, rate_limit_retries: usize) -> Result<()> {
let exe = std::env::current_exe().map_err(CruiseError::IoError)?;
std::process::Command::new(exe)
.arg("plan-worker")
.arg("--session")
.arg(session_id)
.arg("--rate-limit-retries")
.arg(rate_limit_retries.to_string())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| CruiseError::ProcessSpawnError(format!("failed to spawn plan worker: {e}")))?;
Ok(())
}
async fn generate_plan_for_session(
manager: &SessionManager,
session: &SessionState,
rate_limit_retries: usize,
) -> Result<String> {
let config = manager.load_config(session)?;
let plan_path = session.plan_path(&manager.sessions_dir());
let mut vars = VariableStore::new(session.input.clone());
vars.set_named_file(PLAN_VAR, plan_path.clone());
generate_plan_markdown(
&config,
&mut vars,
&plan_path,
rate_limit_retries,
Some(session.base_dir.as_path()),
)
.await
}
async fn generate_plan_markdown(
config: &WorkflowConfig,
vars: &mut VariableStore,
plan_path: &Path,
rate_limit_retries: usize,
working_dir: Option<&Path>,
) -> Result<String> {
let prompt_result = crate::planning::run_plan_prompt_template(
config,
vars,
PLAN_PROMPT_TEMPLATE,
"[plan] creating plan...",
rate_limit_retries,
working_dir,
)
.await?;
crate::metadata::resolve_plan_content(plan_path, &prompt_result.output, &prompt_result.stderr)
}
#[derive(Clone)]
struct FlatNode {
label: String,
expanded_step_ids: Vec<String>,
}
impl fmt::Display for FlatNode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.label)
}
}
fn flatten_nodes(nodes: &[SkippableStepNode]) -> Vec<FlatNode> {
let mut flat = Vec::new();
flatten_nodes_into(nodes, 0, &mut flat);
flat
}
fn flatten_nodes_into(nodes: &[SkippableStepNode], depth: usize, flat: &mut Vec<FlatNode>) {
for node in nodes {
let label = if depth == 0 {
node.id.clone()
} else {
node.id
.rsplit('/')
.next()
.unwrap_or(node.id.as_str())
.to_string()
};
flat.push(FlatNode {
label: format!("{}{}", " ".repeat(depth), label),
expanded_step_ids: node.expanded_step_ids.clone(),
});
flatten_nodes_into(&node.children, depth + 1, flat);
}
}
fn collect_expanded_ids(selected_nodes: Vec<FlatNode>) -> Vec<String> {
let mut seen = HashSet::new();
let mut expanded_ids = Vec::new();
for expanded_id in selected_nodes
.into_iter()
.flat_map(|node| node.expanded_step_ids)
{
if seen.insert(expanded_id.clone()) {
expanded_ids.push(expanded_id);
}
}
expanded_ids
}
fn flat_node_default_indices(flat: &[FlatNode], previously_skipped: &[String]) -> Vec<usize> {
let skipped_set: HashSet<&str> = previously_skipped.iter().map(String::as_str).collect();
flat.iter()
.enumerate()
.filter(|(_, node)| {
!node.expanded_step_ids.is_empty()
&& node
.expanded_step_ids
.iter()
.all(|id| skipped_set.contains(id.as_str()))
})
.map(|(i, _)| i)
.collect()
}
enum StepSkipSelection {
Confirmed(Vec<String>),
Cancelled,
}
fn select_steps_to_skip(
config: &WorkflowConfig,
previously_skipped: &[String],
) -> Result<StepSkipSelection> {
let nodes = list_skippable_steps(config)?;
if nodes.is_empty() {
return Ok(StepSkipSelection::Confirmed(vec![]));
}
let flat = flatten_nodes(&nodes);
let defaults = flat_node_default_indices(&flat, previously_skipped);
match inquire::MultiSelect::new("Steps to skip (Space to toggle, Enter to confirm):", flat)
.with_help_message("No selection = run all steps")
.with_default(&defaults)
.prompt()
{
Ok(selected_nodes) => Ok(StepSkipSelection::Confirmed(collect_expanded_ids(
selected_nodes,
))),
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
Ok(StepSkipSelection::Cancelled)
}
Err(e) => Err(CruiseError::Other(format!("selection error: {e}"))),
}
}
fn apply_skip_step_selection(
history: &mut NewSessionHistory,
resolved_config_key: &str,
selection: StepSkipSelection,
) -> (Vec<String>, bool) {
match selection {
StepSkipSelection::Confirmed(skipped_steps) => {
history.record_skip_selection_for_config(resolved_config_key, skipped_steps.clone());
(skipped_steps, true)
}
StepSkipSelection::Cancelled => (vec![], false),
}
}
fn select_skipped_steps_with_history(
session: &SessionState,
config: &WorkflowConfig,
) -> Result<Vec<String>> {
if config.steps.is_empty() {
return Ok(vec![]);
}
let key = resolved_config_key_for_session(session.config_path.as_ref());
let mut history = NewSessionHistory::load_best_effort();
let previously_skipped = history
.latest_entry_for_config(&key)
.map(|entry| entry.skipped_steps.clone())
.unwrap_or_default();
let selection = select_steps_to_skip(config, &previously_skipped)?;
let (skipped_steps, should_persist) = apply_skip_step_selection(&mut history, &key, selection);
if should_persist {
history.save_best_effort();
}
Ok(skipped_steps)
}
#[expect(
clippy::too_many_lines,
reason = "approve/fix/ask/execute loop with multiple action branches"
)]
async fn run_approve_loop(
config: &WorkflowConfig,
manager: &SessionManager,
session: &mut SessionState,
plan_path: &std::path::Path,
vars: &mut VariableStore,
rate_limit_retries: usize,
noninteractive: bool,
) -> Result<()> {
let llm_api = crate::llm_api::resolve_llm_api_config(config.llm.as_ref());
let working_dir = session.base_dir.clone();
let mut plan_content = match crate::metadata::read_plan_markdown(plan_path) {
Ok(content) => content,
Err(err) => {
eprintln!(
"\n{} Generated plan is missing or empty. Session {} discarded.",
style("x").red().bold(),
session.id
);
if let Err(del_err) = manager.delete(&session.id) {
eprintln!("warning: failed to clean up session: {del_err}");
}
return Err(err);
}
};
loop {
crate::display::print_bordered(&plan_content, Some("plan.md"));
if noninteractive {
approve_with_title(session, manager, &plan_content, llm_api.as_ref()).await?;
eprintln!(
"\n{} Session {} created.",
style("v").green().bold(),
session.id
);
eprintln!(
" Run with: {}",
style(format!("cruise run {}", session.id)).cyan()
);
return Ok(());
}
let options = vec!["Approve", "Fix", "Ask", "Execute now"];
let selected = match inquire::Select::new("Action:", options).prompt() {
Ok(s) => s,
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
eprintln!("\nCancelled. Session {} discarded.", session.id);
manager.delete(&session.id)?;
return Ok(());
}
Err(e) => return Err(CruiseError::Other(format!("selection error: {e}"))),
};
match selected {
"Approve" => {
session.skipped_steps = select_skipped_steps_with_history(session, config)?;
approve_with_title(session, manager, &plan_content, llm_api.as_ref()).await?;
eprintln!(
"\n{} Session {} created.",
style("v").green().bold(),
session.id
);
eprintln!(
" Run with: {}",
style(format!("cruise run {}", session.id)).cyan()
);
return Ok(());
}
"Fix" => {
let text = match prompt_multiline("Describe the changes needed:")? {
InputResult::Submitted(t) => t,
InputResult::Cancelled => continue,
};
vars.set_prev_input(Some(text));
run_fix_plan(
config,
vars,
rate_limit_retries,
Some(working_dir.as_path()),
)
.await?;
plan_content = crate::metadata::read_plan_markdown(plan_path)?;
}
"Ask" => {
let text = match prompt_multiline("Your question:")? {
InputResult::Submitted(t) => t,
InputResult::Cancelled => continue,
};
vars.set_prev_input(Some(text));
run_ask_plan(
config,
vars,
rate_limit_retries,
Some(working_dir.as_path()),
)
.await?;
}
"Execute now" => {
session.skipped_steps = select_skipped_steps_with_history(session, config)?;
approve_with_title(session, manager, &plan_content, llm_api.as_ref()).await?;
eprintln!(
"\n{} Executing session {}...",
style("->").cyan(),
session.id
);
let run_args = crate::cli::RunArgs {
session: Some(session.id.clone()),
all: false,
max_retries: DEFAULT_MAX_RETRIES,
rate_limit_retries,
dry_run: false,
};
return crate::run_cmd::run(run_args).await;
}
_ => {}
}
}
}
#[expect(dead_code, reason = "Used by Tauri GUI backend")]
pub async fn generate_plan(
config: &crate::config::WorkflowConfig,
vars: &mut crate::variable::VariableStore,
rate_limit_retries: usize,
) -> crate::error::Result<()> {
crate::planning::run_plan_prompt_template(
config,
vars,
PLAN_PROMPT_TEMPLATE,
"[plan] creating plan...",
rate_limit_retries,
None,
)
.await?;
Ok(())
}
pub async fn replan_session(
manager: &SessionManager,
session: &mut SessionState,
feedback: String,
rate_limit_retries: usize,
) -> Result<()> {
let config = manager.load_config(session)?;
let plan_path = session.plan_path(&manager.sessions_dir());
let mut vars = VariableStore::new(session.input.clone());
vars.set_named_file(PLAN_VAR, plan_path.clone());
vars.set_prev_input(Some(feedback));
let working_dir = session.base_dir.clone();
run_fix_plan(
&config,
&mut vars,
rate_limit_retries,
Some(working_dir.as_path()),
)
.await?;
let plan_markdown = crate::metadata::read_plan_markdown(&plan_path)?;
crate::metadata::refresh_session_title_from_plan(session, &plan_markdown);
session.plan_error = None;
manager.save(session)?;
Ok(())
}
async fn run_fix_plan(
config: &WorkflowConfig,
vars: &mut VariableStore,
rate_limit_retries: usize,
working_dir: Option<&Path>,
) -> Result<()> {
run_plan_prompt(
config,
vars,
rate_limit_retries,
FIX_PLAN_PROMPT_TEMPLATE,
"[fix-plan] applying fixes...",
working_dir,
)
.await
}
async fn run_ask_plan(
config: &WorkflowConfig,
vars: &mut VariableStore,
rate_limit_retries: usize,
working_dir: Option<&Path>,
) -> Result<()> {
run_plan_prompt(
config,
vars,
rate_limit_retries,
ASK_PLAN_PROMPT_TEMPLATE,
"[ask-plan] answering question...",
working_dir,
)
.await
}
async fn run_plan_prompt(
config: &WorkflowConfig,
vars: &mut VariableStore,
rate_limit_retries: usize,
template: &str,
label: &str,
working_dir: Option<&Path>,
) -> Result<()> {
let result = crate::planning::run_plan_prompt_template(
config,
vars,
template,
label,
rate_limit_retries,
working_dir,
)
.await?;
vars.set_prev_output(Some(result.output));
vars.set_prev_stderr(Some(result.stderr));
vars.set_prev_input(None);
Ok(())
}
fn resolve_input<F>(
arg: Option<String>,
stdin_input: Option<String>,
interactive: F,
) -> Result<String>
where
F: FnOnce() -> Result<String>,
{
if let Some(input) = arg {
return Ok(input);
}
if let Some(input) = stdin_input {
let trimmed = input.trim().to_string();
if !trimmed.is_empty() {
return Ok(trimmed);
}
}
interactive()
}
fn prompt_for_plan_input() -> Result<String> {
prompt_multiline("What would you like to implement?")?.into_result()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::new_session_history::{NewSessionHistory, NewSessionHistoryEntry};
#[test]
fn test_resolve_input_from_arg() {
let result = resolve_input(Some("add feature X".to_string()), None, || {
panic!("interactive prompt should not run")
});
assert_eq!(result.unwrap_or_else(|e| panic!("{e:?}")), "add feature X");
}
#[test]
fn test_resolve_input_from_stdin() {
let result = resolve_input(None, Some(" add feature from pipe\n".to_string()), || {
panic!("interactive prompt should not run")
});
assert_eq!(
result.unwrap_or_else(|e| panic!("{e:?}")),
"add feature from pipe"
);
}
#[test]
fn test_resolve_input_without_arg_or_stdin_uses_interactive_result() {
let result = resolve_input(None, None, || Ok("resume in place".to_string()));
assert_eq!(
result.unwrap_or_else(|e| panic!("{e:?}")),
"resume in place"
);
}
#[test]
fn test_resolve_input_multiline_from_stdin_preserves_internal_newlines() {
let stdin = "line1\nline2\nline3\n".to_string();
let result = resolve_input(None, Some(stdin), || {
panic!("interactive prompt should not run")
});
assert_eq!(
result.unwrap_or_else(|e| panic!("{e:?}")),
"line1\nline2\nline3"
);
}
#[test]
fn test_resolve_input_multiline_trims_only_leading_trailing_whitespace() {
let stdin = " line1\nline2 \n".to_string();
let result = resolve_input(None, Some(stdin), || {
panic!("interactive prompt should not run")
});
assert_eq!(result.unwrap_or_else(|e| panic!("{e:?}")), "line1\nline2");
}
#[test]
fn test_collect_expanded_ids_deduplicates_parent_and_child_selection() {
let selected = vec![
FlatNode {
label: "review-pass".to_string(),
expanded_step_ids: vec![
"review-pass/simplify".to_string(),
"review-pass/coderabbit".to_string(),
],
},
FlatNode {
label: " simplify".to_string(),
expanded_step_ids: vec!["review-pass/simplify".to_string()],
},
];
assert_eq!(
collect_expanded_ids(selected),
vec!["review-pass/simplify", "review-pass/coderabbit"]
);
}
#[test]
fn test_apply_skip_step_selection_records_confirmed_empty_selection() {
let mut history = NewSessionHistory::default();
let (skipped_steps, should_persist) = apply_skip_step_selection(
&mut history,
"/config/a.yaml",
StepSkipSelection::Confirmed(vec![]),
);
assert!(should_persist);
assert!(skipped_steps.is_empty());
assert_eq!(history.entries.len(), 1);
assert_eq!(
history.entries[0],
NewSessionHistoryEntry {
selected_at: history.entries[0].selected_at.clone(),
requested_config_path: None,
working_dir: String::new(),
resolved_config_key: "/config/a.yaml".to_string(),
skipped_steps: vec![],
}
);
}
#[test]
fn test_apply_skip_step_selection_does_not_record_cancelled_prompt() {
let mut history = NewSessionHistory::default();
history.record_selection(NewSessionHistoryEntry {
selected_at: String::new(),
requested_config_path: None,
working_dir: String::new(),
resolved_config_key: "/config/a.yaml".to_string(),
skipped_steps: vec!["review".to_string()],
});
let (skipped_steps, should_persist) =
apply_skip_step_selection(&mut history, "/config/a.yaml", StepSkipSelection::Cancelled);
assert!(!should_persist);
assert!(skipped_steps.is_empty());
assert_eq!(history.entries.len(), 1);
assert_eq!(history.entries[0].skipped_steps, vec!["review"]);
}
#[test]
fn test_apply_skip_step_selection_updates_existing_gui_history_entry() {
let mut history = NewSessionHistory::default();
history.record_selection(NewSessionHistoryEntry {
selected_at: "2026-04-07T00:00:00Z".to_string(),
requested_config_path: Some("/config/a.yaml".to_string()),
working_dir: "/tmp/project".to_string(),
resolved_config_key: "/config/a.yaml".to_string(),
skipped_steps: vec!["plan".to_string()],
});
let (skipped_steps, should_persist) = apply_skip_step_selection(
&mut history,
"/config/a.yaml",
StepSkipSelection::Confirmed(vec!["review".to_string()]),
);
assert!(should_persist);
assert_eq!(skipped_steps, vec!["review"]);
assert_eq!(history.entries.len(), 1);
assert_eq!(
history.entries[0].requested_config_path.as_deref(),
Some("/config/a.yaml")
);
assert_eq!(history.entries[0].working_dir, "/tmp/project");
assert_eq!(history.entries[0].skipped_steps, vec!["review"]);
}
}