use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use console::style;
use crate::ask_handler::AskHandler;
use crate::config::WorkflowConfig;
use crate::error::Result;
use crate::executor::{Executor, PromptRun};
use crate::step::prompt::{PromptResult, StreamCallbacks};
use crate::variable::VariableStore;
pub const PLAN_PROMPT_TEMPLATE: &str = include_str!("../prompts/plan.md");
pub const FIX_PLAN_PROMPT_TEMPLATE: &str = include_str!("../prompts/fix-plan.md");
pub const ASK_PLAN_PROMPT_TEMPLATE: &str = include_str!("../prompts/ask-plan.md");
pub const PLAN_PROMPT_TEMPLATE_SDK: &str = include_str!("../prompts/plan-sdk.md");
pub const FIX_PLAN_PROMPT_TEMPLATE_SDK: &str = include_str!("../prompts/fix-plan-sdk.md");
pub const ASK_PLAN_PROMPT_TEMPLATE_SDK: &str = include_str!("../prompts/ask-plan-sdk.md");
pub const PLAN_GRILL_PROMPT_TEMPLATE_SDK: &str = include_str!("../prompts/plan-grill-sdk.md");
#[must_use]
pub fn sdk_plan_tools_enabled(config: &WorkflowConfig) -> bool {
config.sdk.is_some() && config.interactive_planning
}
#[must_use]
pub fn plan_template(config: &WorkflowConfig) -> &'static str {
if sdk_plan_tools_enabled(config) {
PLAN_PROMPT_TEMPLATE_SDK
} else {
PLAN_PROMPT_TEMPLATE
}
}
#[must_use]
pub fn initial_plan_template(config: &WorkflowConfig, grill: bool) -> &'static str {
if grill && sdk_plan_tools_enabled(config) {
PLAN_GRILL_PROMPT_TEMPLATE_SDK
} else {
plan_template(config)
}
}
#[must_use]
pub fn fix_plan_template(config: &WorkflowConfig) -> &'static str {
if sdk_plan_tools_enabled(config) {
FIX_PLAN_PROMPT_TEMPLATE_SDK
} else {
FIX_PLAN_PROMPT_TEMPLATE
}
}
#[must_use]
pub fn ask_plan_template(config: &WorkflowConfig) -> &'static str {
if sdk_plan_tools_enabled(config) {
ASK_PLAN_PROMPT_TEMPLATE_SDK
} else {
ASK_PLAN_PROMPT_TEMPLATE
}
}
pub struct PlanPromptCtx<'a> {
pub config: &'a WorkflowConfig,
pub ask: Arc<dyn AskHandler>,
pub plan_path: &'a Path,
pub interactive: bool,
pub rate_limit_retries: usize,
pub working_dir: Option<&'a Path>,
pub grill: bool,
}
impl PlanPromptCtx<'_> {
#[must_use]
fn executor(&self) -> Executor {
Executor::new(self.config.sdk.as_deref(), &self.config.command)
}
}
pub async fn run_plan_prompt_template(
ctx: &PlanPromptCtx<'_>,
vars: &mut VariableStore,
template: &str,
label: &str,
stream_callbacks: Option<&StreamCallbacks<'_>>,
resume: &mut Option<String>,
register_plan_tools: bool,
) -> Result<PromptResult> {
let prompt = vars.resolve(template)?;
let executor = ctx.executor();
let model_or_mode = executor.plan_model_or_mode(
ctx.config.plan_model.as_deref(),
ctx.config.model.as_deref(),
);
let plan_tools_enabled = sdk_plan_tools_enabled(ctx.config);
let tools = if plan_tools_enabled && register_plan_tools {
crate::sdk_tools::planning_tools(
ctx.plan_path.to_path_buf(),
Arc::clone(&ctx.ask),
ctx.interactive,
)
} else {
Vec::new()
};
let env = HashMap::new();
eprintln!("\n{} {}", style("▶").cyan().bold(), style(label).bold());
let spinner = (!executor.is_sdk()).then(|| crate::spinner::Spinner::start("Cruising..."));
let on_retry = move |msg: &str| eprintln!("{msg}");
let outcome = executor
.run(PromptRun {
prompt: &prompt,
model_or_mode: model_or_mode.as_deref(),
max_retries: ctx.rate_limit_retries,
env: &env,
on_retry: Some(&on_retry),
cancel_token: None,
working_dir: ctx.working_dir,
stream: stream_callbacks,
tools,
resume: resume.clone(),
})
.await;
drop(spinner);
let outcome = outcome?;
if plan_tools_enabled && outcome.session_id.is_some() {
*resume = outcome.session_id;
}
Ok(outcome.result)
}
pub fn write_input_as_plan(plan_path: &Path, input: &str) -> Result<String> {
let content = input.trim().to_string();
if content.is_empty() {
return Err(crate::error::CruiseError::Other(
"cannot use empty input as plan".to_string(),
));
}
std::fs::write(plan_path, &content)
.map_err(|e| crate::error::CruiseError::Other(format!("failed to write plan: {e}")))?;
Ok(content)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_temp_dir() -> TempDir {
TempDir::new().unwrap_or_else(|e| panic!("{e:?}"))
}
#[test]
fn write_input_as_plan_writes_trimmed_content_to_file() {
let dir = make_temp_dir();
let plan_path = dir.path().join("plan.md");
let content = write_input_as_plan(&plan_path, " implement feature X ")
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(content, "implement feature X");
assert_eq!(
std::fs::read_to_string(&plan_path).unwrap_or_else(|e| panic!("{e:?}")),
"implement feature X"
);
}
#[test]
fn write_input_as_plan_returns_err_for_empty_input() {
let dir = make_temp_dir();
let plan_path = dir.path().join("plan.md");
assert!(write_input_as_plan(&plan_path, "").is_err());
assert!(!plan_path.exists());
}
#[test]
fn write_input_as_plan_returns_err_for_whitespace_only_input() {
let dir = make_temp_dir();
let plan_path = dir.path().join("plan.md");
assert!(write_input_as_plan(&plan_path, " \n\t ").is_err());
}
#[test]
fn write_input_as_plan_preserves_multiline_markdown() {
let dir = make_temp_dir();
let plan_path = dir.path().join("plan.md");
let input = "# Plan\n\n- step 1\n- step 2";
let content = write_input_as_plan(&plan_path, input).unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(content, "# Plan\n\n- step 1\n- step 2");
assert_eq!(
std::fs::read_to_string(&plan_path).unwrap_or_else(|e| panic!("{e:?}")),
content
);
}
#[test]
fn write_input_as_plan_returns_err_on_invalid_path() {
let plan_path = std::path::Path::new("/nonexistent/dir/plan.md");
assert!(write_input_as_plan(plan_path, "some content").is_err());
}
fn config_with(sdk: Option<&str>, command: Option<&str>) -> WorkflowConfig {
let mut yaml = String::new();
if let Some(s) = sdk {
yaml.push_str("sdk: ");
yaml.push_str(s);
yaml.push('\n');
}
if let Some(c) = command {
yaml.push_str("command: [");
yaml.push_str(c);
yaml.push_str("]\n");
}
yaml.push_str("steps:\n s1:\n prompt: hi\n");
WorkflowConfig::from_yaml(&yaml).unwrap_or_else(|e| panic!("{e:?}"))
}
#[test]
fn templates_select_command_variants_without_sdk() {
let config = config_with(None, Some("echo"));
assert_eq!(plan_template(&config), PLAN_PROMPT_TEMPLATE);
assert_eq!(fix_plan_template(&config), FIX_PLAN_PROMPT_TEMPLATE);
assert_eq!(ask_plan_template(&config), ASK_PLAN_PROMPT_TEMPLATE);
}
#[test]
fn templates_select_sdk_variants_with_sdk() {
let config = config_with(Some("seher"), None);
assert_eq!(plan_template(&config), PLAN_PROMPT_TEMPLATE_SDK);
assert_eq!(fix_plan_template(&config), FIX_PLAN_PROMPT_TEMPLATE_SDK);
assert_eq!(ask_plan_template(&config), ASK_PLAN_PROMPT_TEMPLATE_SDK);
}
fn sdk_config_no_interactive() -> WorkflowConfig {
WorkflowConfig::from_yaml(
"sdk: seher\ninteractive_planning: false\nsteps:\n s1:\n prompt: hi\n",
)
.unwrap_or_else(|e| panic!("{e:?}"))
}
#[test]
fn interactive_planning_defaults_to_true_for_sdk() {
let config = config_with(Some("seher"), None);
assert!(config.interactive_planning);
assert!(sdk_plan_tools_enabled(&config));
}
#[test]
fn sdk_plan_tools_disabled_when_interactive_planning_off() {
let config = sdk_config_no_interactive();
assert!(!sdk_plan_tools_enabled(&config));
}
#[test]
fn templates_fall_back_to_command_variants_when_interactive_planning_off() {
let config = sdk_config_no_interactive();
assert_eq!(plan_template(&config), PLAN_PROMPT_TEMPLATE);
assert_eq!(fix_plan_template(&config), FIX_PLAN_PROMPT_TEMPLATE);
assert_eq!(ask_plan_template(&config), ASK_PLAN_PROMPT_TEMPLATE);
}
#[test]
fn grill_ignored_when_interactive_planning_off() {
let config = sdk_config_no_interactive();
assert_eq!(initial_plan_template(&config, true), PLAN_PROMPT_TEMPLATE);
}
#[test]
fn sdk_and_command_templates_differ() {
assert_ne!(PLAN_PROMPT_TEMPLATE, PLAN_PROMPT_TEMPLATE_SDK);
assert_ne!(FIX_PLAN_PROMPT_TEMPLATE, FIX_PLAN_PROMPT_TEMPLATE_SDK);
assert_ne!(ASK_PLAN_PROMPT_TEMPLATE, ASK_PLAN_PROMPT_TEMPLATE_SDK);
}
#[test]
fn initial_plan_template_uses_grill_variant_for_sdk_when_enabled() {
let config = config_with(Some("seher"), None);
assert_eq!(
initial_plan_template(&config, true),
PLAN_GRILL_PROMPT_TEMPLATE_SDK
);
}
#[test]
fn initial_plan_template_uses_standard_sdk_variant_when_grill_off() {
let config = config_with(Some("seher"), None);
assert_eq!(
initial_plan_template(&config, false),
PLAN_PROMPT_TEMPLATE_SDK
);
}
#[test]
fn initial_plan_template_ignores_grill_without_sdk() {
let config = config_with(None, Some("echo"));
assert_eq!(initial_plan_template(&config, true), PLAN_PROMPT_TEMPLATE);
}
#[test]
fn grill_template_differs_from_standard_sdk_plan() {
assert_ne!(PLAN_GRILL_PROMPT_TEMPLATE_SDK, PLAN_PROMPT_TEMPLATE_SDK);
}
}