1use crate::factory::AgentFactory;
28use crate::progress::{ProgressHandler, SilentProgress};
29use anyhow::{Context, Result};
30use log::debug;
31use std::path::{Path, PathBuf};
32
33const PLAN_TEMPLATE_SOURCE: &str = include_str!("../prompts/plan/1_0_0.md");
35
36pub fn plan_template() -> &'static str {
39 crate::prompts::strip_front_matter(PLAN_TEMPLATE_SOURCE)
40}
41
42pub struct PlanParams {
44 pub provider: String,
45 pub goal: String,
47 pub output: Option<String>,
51 pub instructions: Option<String>,
53 pub system_prompt: Option<String>,
54 pub model: Option<String>,
55 pub root: Option<String>,
56 pub auto_approve: bool,
57 pub add_dirs: Vec<String>,
58 pub progress: Box<dyn ProgressHandler>,
60}
61
62impl Default for PlanParams {
63 fn default() -> Self {
64 Self {
65 provider: "claude".to_string(),
66 goal: String::new(),
67 output: None,
68 instructions: None,
69 system_prompt: None,
70 model: None,
71 root: None,
72 auto_approve: false,
73 add_dirs: Vec::new(),
74 progress: Box::new(SilentProgress),
75 }
76 }
77}
78
79#[derive(Debug, Clone, Default)]
81pub struct PlanResult {
82 pub text: Option<String>,
86 pub written_to: Option<PathBuf>,
88}
89
90pub fn build_plan_prompt(goal: &str, instructions: Option<&str>) -> String {
92 let context_section = String::new();
93 let prompt_section = match instructions {
94 Some(inst) => format!("## Additional Instructions\n\n{inst}"),
95 None => String::new(),
96 };
97
98 plan_template()
99 .replace("{GOAL}", goal)
100 .replace("{CONTEXT_SECTION}", &context_section)
101 .replace("{PROMPT}", &prompt_section)
102}
103
104pub fn resolve_output_path(output: &str) -> PathBuf {
108 let path = PathBuf::from(output);
109 if path.extension().is_some() {
110 path
111 } else {
112 let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S");
113 path.join(format!("plan-{timestamp}.md"))
114 }
115}
116
117pub fn validate_output_path(path: &Path) -> Result<()> {
122 let home_dir = match std::env::var("ZAG_USER_HOME_DIR") {
123 Ok(dir) => dir,
124 Err(_) => return Ok(()),
125 };
126 let home = PathBuf::from(&home_dir);
127 let canonical_home = std::fs::canonicalize(&home).unwrap_or_else(|_| home.clone());
128 let check_path = if path.exists() {
129 path.to_path_buf()
130 } else {
131 path.parent()
132 .map(PathBuf::from)
133 .unwrap_or_else(|| PathBuf::from("."))
134 };
135 let canonical = std::fs::canonicalize(&check_path).unwrap_or_else(|_| check_path.clone());
136 if !canonical.starts_with(&canonical_home) {
137 anyhow::bail!(
138 "Output path '{}' is outside your home directory: {}",
139 path.display(),
140 canonical_home.display()
141 );
142 }
143 Ok(())
144}
145
146pub async fn run_plan(params: PlanParams) -> Result<PlanResult> {
148 let PlanParams {
149 provider,
150 goal,
151 output,
152 instructions,
153 system_prompt,
154 model,
155 root,
156 auto_approve,
157 add_dirs,
158 progress,
159 } = params;
160
161 debug!("Starting plan via {provider} for goal: {goal}");
162
163 let output_path = match output {
164 Some(ref out) => {
165 let resolved = resolve_output_path(out);
166 validate_output_path(&resolved)?;
167 Some(resolved)
168 }
169 None => None,
170 };
171
172 let plan_prompt = build_plan_prompt(&goal, instructions.as_deref());
173
174 progress.on_spinner_start(&format!("Initializing {provider} for planning"));
175 let mut agent = AgentFactory::create(
176 &provider,
177 system_prompt,
178 model,
179 root.clone(),
180 auto_approve,
181 add_dirs,
182 )?;
183 progress.on_spinner_finish();
184
185 let model_name = agent.get_model().to_string();
186
187 if output_path.is_some() {
188 agent.set_capture_output(true);
189 }
190 progress.on_success(&format!("Plan initialized with model {model_name}"));
191
192 let agent_output = agent.run(Some(&plan_prompt)).await?;
193 agent.cleanup().await?;
194
195 if let Some(path) = output_path {
196 let plan_text = agent_output.and_then(|o| o.result).unwrap_or_default();
197 if let Some(parent) = path.parent() {
198 std::fs::create_dir_all(parent)
199 .with_context(|| format!("Failed to create directory: {}", parent.display()))?;
200 }
201 std::fs::write(&path, &plan_text)
202 .with_context(|| format!("Failed to write plan to: {}", path.display()))?;
203 progress.on_success(&format!("Plan written to {}", path.display()));
204 Ok(PlanResult {
205 text: Some(plan_text),
206 written_to: Some(path),
207 })
208 } else {
209 Ok(PlanResult::default())
210 }
211}
212
213#[cfg(test)]
214#[path = "plan_tests.rs"]
215mod tests;