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