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