1use crate::contracts::Config;
4use crate::fsutil;
5use crate::prompts;
6use crate::prompts_internal;
7use anyhow::{Result, bail};
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum RunPhase {
12 Phase1, Phase2, Phase3, }
16
17#[derive(Debug, Clone)]
18pub struct PromptPolicy {
19 pub repoprompt_plan_required: bool,
20 pub repoprompt_tool_injection: bool,
21}
22
23pub const PHASE1_TASK_REFRESH_REQUIRED_INSTRUCTION: &str = r#"## TASK REFRESH STEP (REQUIRED BEFORE PLANNING)
24Before producing the final plan, update only the current task in `.ralph/queue.jsonc`:
25- Refresh only: `scope`, `evidence`, `plan`, `notes`, `tags`, `depends_on`
26- Set `updated_at` to current UTC RFC3339 time
27- Preserve task identity/status fields (`id`, `title`, `status`, `priority`, `created_at`, `request`, `agent`)
28- Do not add or remove tasks
29
30After updating the task, re-read the updated task data and then produce the final plan."#;
31
32pub const PHASE1_TASK_REFRESH_DISABLED_INSTRUCTION: &str = r#"## TASK REFRESH STEP
33Parallel worker mode is active for this run. Do NOT edit `.ralph/queue.jsonc`.
34Use current task metadata as-is and continue with planning only."#;
35
36pub fn plan_cache_path(repo_root: &Path, task_id: &str) -> PathBuf {
38 repo_root
39 .join(".ralph/cache/plans")
40 .join(format!("{}.md", task_id))
41}
42
43pub fn write_plan_cache(repo_root: &Path, task_id: &str, plan_text: &str) -> Result<()> {
45 let path = plan_cache_path(repo_root, task_id);
46 if let Some(parent) = path.parent() {
47 std::fs::create_dir_all(parent)?;
48 }
49 fsutil::write_atomic(&path, plan_text.as_bytes())?;
50 Ok(())
51}
52
53pub fn phase2_final_response_cache_path(repo_root: &Path, task_id: &str) -> PathBuf {
55 repo_root
56 .join(".ralph/cache/phase2_final")
57 .join(format!("{}.md", task_id))
58}
59
60pub fn write_phase2_final_response_cache(
62 repo_root: &Path,
63 task_id: &str,
64 response_text: &str,
65) -> Result<()> {
66 let path = phase2_final_response_cache_path(repo_root, task_id);
67 if let Some(parent) = path.parent() {
68 std::fs::create_dir_all(parent)?;
69 }
70 fsutil::write_atomic(&path, response_text.as_bytes())?;
71 Ok(())
72}
73
74pub fn read_phase2_final_response_cache(repo_root: &Path, task_id: &str) -> Result<String> {
76 let path = phase2_final_response_cache_path(repo_root, task_id);
77 if !path.exists() {
78 bail!(
79 "Phase 2 final response cache not found at {}",
80 path.display()
81 );
82 }
83 let content = std::fs::read_to_string(&path)?;
84 if content.trim().is_empty() {
85 bail!(
86 "Phase 2 final response cache is empty at {}",
87 path.display()
88 );
89 }
90 Ok(content)
91}
92
93pub fn read_plan_cache(repo_root: &Path, task_id: &str) -> Result<String> {
95 let path = plan_cache_path(repo_root, task_id);
96 if !path.exists() {
97 bail!("Plan cache not found at {}", path.display());
98 }
99 let content = std::fs::read_to_string(&path)?;
100 if content.trim().is_empty() {
101 bail!("Plan cache is empty at {}", path.display());
102 }
103 Ok(content)
104}
105
106#[allow(clippy::too_many_arguments)]
108pub fn build_phase1_prompt(
109 template: &str,
110 base_worker_prompt: &str,
111 iteration_context: &str,
112 task_refresh_instruction: &str,
113 task_id: &str,
114 total_phases: u8,
115 policy: &PromptPolicy,
116 config: &Config,
117) -> Result<String> {
118 let plan_path = format!(".ralph/cache/plans/{}.md", task_id.trim());
119 prompts::render_worker_phase1_prompt(
120 template,
121 base_worker_prompt,
122 iteration_context,
123 task_refresh_instruction,
124 task_id,
125 total_phases,
126 &plan_path,
127 policy.repoprompt_plan_required,
128 policy.repoprompt_tool_injection,
129 config,
130 )
131}
132
133#[allow(clippy::too_many_arguments)]
135pub fn build_phase2_prompt(
136 template: &str,
137 base_worker_prompt: &str,
138 plan_text: &str,
139 completion_checklist: &str,
140 iteration_context: &str,
141 iteration_completion_block: &str,
142 task_id: &str,
143 total_phases: u8,
144 policy: &PromptPolicy,
145 config: &Config,
146) -> Result<String> {
147 prompts::render_worker_phase2_prompt(
148 template,
149 base_worker_prompt,
150 plan_text,
151 completion_checklist,
152 iteration_context,
153 iteration_completion_block,
154 task_id,
155 total_phases,
156 policy.repoprompt_tool_injection,
157 config,
158 )
159}
160
161#[allow(clippy::too_many_arguments)]
163pub fn build_phase2_handoff_prompt(
164 template: &str,
165 base_worker_prompt: &str,
166 plan_text: &str,
167 handoff_checklist: &str,
168 iteration_context: &str,
169 iteration_completion_block: &str,
170 task_id: &str,
171 total_phases: u8,
172 policy: &PromptPolicy,
173 config: &Config,
174) -> Result<String> {
175 prompts::render_worker_phase2_handoff_prompt(
176 template,
177 base_worker_prompt,
178 plan_text,
179 handoff_checklist,
180 iteration_context,
181 iteration_completion_block,
182 task_id,
183 total_phases,
184 policy.repoprompt_tool_injection,
185 config,
186 )
187}
188
189#[allow(clippy::too_many_arguments)]
191pub fn build_phase3_prompt(
192 template: &str,
193 base_worker_prompt: &str,
194 code_review_body: &str,
195 phase2_final_response: &str,
196 task_id: &str,
197 completion_checklist: &str,
198 iteration_context: &str,
199 iteration_completion_block: &str,
200 phase3_completion_guidance: &str,
201 total_phases: u8,
202 policy: &PromptPolicy,
203 config: &Config,
204) -> Result<String> {
205 prompts::render_worker_phase3_prompt(
206 template,
207 base_worker_prompt,
208 code_review_body,
209 phase2_final_response,
210 task_id,
211 completion_checklist,
212 iteration_context,
213 iteration_completion_block,
214 phase3_completion_guidance,
215 total_phases,
216 policy.repoprompt_tool_injection,
217 config,
218 )
219}
220
221#[allow(clippy::too_many_arguments)]
223pub fn build_single_phase_prompt(
224 template: &str,
225 base_worker_prompt: &str,
226 completion_checklist: &str,
227 iteration_context: &str,
228 iteration_completion_block: &str,
229 task_id: &str,
230 policy: &PromptPolicy,
231 config: &Config,
232) -> Result<String> {
233 prompts::render_worker_single_phase_prompt(
234 template,
235 base_worker_prompt,
236 completion_checklist,
237 iteration_context,
238 iteration_completion_block,
239 task_id,
240 policy.repoprompt_tool_injection,
241 config,
242 )
243}
244
245pub fn build_merge_conflict_prompt(
247 template: &str,
248 conflict_files: &[String],
249 config: &Config,
250) -> Result<String> {
251 prompts_internal::merge_conflicts::render_merge_conflict_prompt(
252 template,
253 conflict_files,
254 config,
255 )
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use tempfile::TempDir;
262
263 #[test]
264 fn phase2_final_response_cache_round_trip() -> Result<()> {
265 let dir = TempDir::new()?;
266 write_phase2_final_response_cache(dir.path(), "RQ-0001", "done")?;
267 let read = read_phase2_final_response_cache(dir.path(), "RQ-0001")?;
268 assert_eq!(read, "done");
269 Ok(())
270 }
271
272 #[test]
273 fn phase2_final_response_cache_missing_is_error() -> Result<()> {
274 let dir = TempDir::new()?;
275 let err = read_phase2_final_response_cache(dir.path(), "RQ-0001").unwrap_err();
276 assert!(
277 err.to_string()
278 .contains("Phase 2 final response cache not found")
279 );
280 Ok(())
281 }
282
283 #[test]
284 fn phase2_final_response_cache_empty_is_error() -> Result<()> {
285 let dir = TempDir::new()?;
286 let path = phase2_final_response_cache_path(dir.path(), "RQ-0001");
287 if let Some(parent) = path.parent() {
288 std::fs::create_dir_all(parent)?;
289 }
290 std::fs::write(&path, "")?;
291 let err = read_phase2_final_response_cache(dir.path(), "RQ-0001").unwrap_err();
292 assert!(
293 err.to_string()
294 .contains("Phase 2 final response cache is empty")
295 );
296 Ok(())
297 }
298
299 #[test]
300 fn build_merge_conflict_prompt_replaces_conflicts() -> Result<()> {
301 let template = "Conflicts:\n{{CONFLICT_FILES}}\n";
302 let config = Config::default();
303 let files = vec!["src/lib.rs".to_string()];
304 let prompt = build_merge_conflict_prompt(template, &files, &config)?;
305 assert!(prompt.contains("- src/lib.rs"));
306 assert!(!prompt.contains("{{CONFLICT_FILES}}"));
307 Ok(())
308 }
309}