ralph_workflow/prompts/
rebase.rs

1//! Rebase conflict resolution prompts.
2//!
3//! This module provides prompts for AI agents to resolve merge conflicts
4//! that occur during rebase operations.
5//!
6//! # Design Note
7//!
8//! Per project requirements, AI agents should NOT know that we are in the
9//! middle of a rebase. The prompt frames conflicts as "merge conflicts between
10//! two versions" without mentioning rebase or rebasing.
11
12#![deny(unsafe_code)]
13
14use crate::prompts::template_context::TemplateContext;
15use crate::prompts::template_engine::Template;
16use std::collections::HashMap;
17use std::fmt::Write;
18use std::fs;
19use std::path::Path;
20
21/// Structure representing a single file conflict.
22#[derive(Debug, Clone)]
23pub struct FileConflict {
24    /// The conflict marker content from the file
25    pub conflict_content: String,
26    /// The current file content with conflict markers
27    pub current_content: String,
28}
29
30/// Build a conflict resolution prompt for the AI agent.
31///
32/// This function generates a prompt that instructs the AI agent to resolve
33/// merge conflicts. The prompt does NOT mention "rebase" - it frames the
34/// task as resolving merge conflicts between two versions.
35///
36/// # Arguments
37///
38/// * `conflicts` - Map of file paths to their conflict information
39/// * `prompt_md_content` - Optional content from PROMPT.md for task context
40/// * `plan_content` - Optional content from PLAN.md for additional context
41///
42/// # Returns
43///
44/// Returns a formatted prompt string for the AI agent.
45#[cfg(test)]
46pub fn build_conflict_resolution_prompt(
47    conflicts: &HashMap<String, FileConflict>,
48    prompt_md_content: Option<&str>,
49    plan_content: Option<&str>,
50) -> String {
51    let template_content = include_str!("templates/conflict_resolution.txt");
52    let template = Template::new(template_content);
53
54    let context = format_context_section(prompt_md_content, plan_content);
55    let conflicts_section = format_conflicts_section(conflicts);
56
57    let variables = HashMap::from([
58        ("CONTEXT", context),
59        ("CONFLICTS", conflicts_section.clone()),
60    ]);
61
62    template.render(&variables).unwrap_or_else(|e| {
63        eprintln!("Warning: Failed to render conflict resolution template: {e}");
64        // Use fallback template
65        let fallback_template_content = include_str!("templates/conflict_resolution_fallback.txt");
66        let fallback_template = Template::new(fallback_template_content);
67        fallback_template.render(&variables).unwrap_or_else(|e| {
68            eprintln!("Critical: Failed to render fallback template: {e}");
69            // Last resort: minimal emergency prompt - conflicts_section is captured from closure
70            format!(
71                "# MERGE CONFLICT RESOLUTION\n\nResolve these conflicts:\n\n{}",
72                &conflicts_section
73            )
74        })
75    })
76}
77
78/// Build a conflict resolution prompt using template registry.
79///
80/// This version uses the template registry which supports user template overrides.
81/// It's the recommended way to generate prompts going forward.
82///
83/// # Arguments
84///
85/// * `context` - Template context containing the template registry
86/// * `conflicts` - Map of file paths to their conflict information
87/// * `prompt_md_content` - Optional content from PROMPT.md for task context
88/// * `plan_content` - Optional content from PLAN.md for additional context
89pub fn build_conflict_resolution_prompt_with_context(
90    context: &TemplateContext,
91    conflicts: &HashMap<String, FileConflict>,
92    prompt_md_content: Option<&str>,
93    plan_content: Option<&str>,
94) -> String {
95    let template_content = context
96        .registry()
97        .get_template("conflict_resolution")
98        .unwrap_or_else(|_| include_str!("templates/conflict_resolution.txt").to_string());
99    let template = Template::new(&template_content);
100
101    let ctx_section = format_context_section(prompt_md_content, plan_content);
102    let conflicts_section = format_conflicts_section(conflicts);
103
104    let variables = HashMap::from([
105        ("CONTEXT", ctx_section),
106        ("CONFLICTS", conflicts_section.clone()),
107    ]);
108
109    template.render(&variables).unwrap_or_else(|e| {
110        eprintln!("Warning: Failed to render conflict resolution template: {e}");
111        // Use fallback template
112        let fallback_template_content = context
113            .registry()
114            .get_template("conflict_resolution_fallback")
115            .unwrap_or_else(|_| {
116                include_str!("templates/conflict_resolution_fallback.txt").to_string()
117            });
118        let fallback_template = Template::new(&fallback_template_content);
119        fallback_template.render(&variables).unwrap_or_else(|e| {
120            eprintln!("Critical: Failed to render fallback template: {e}");
121            // Last resort: minimal emergency prompt - conflicts_section is captured from closure
122            format!(
123                "# MERGE CONFLICT RESOLUTION\n\nResolve these conflicts:\n\n{}",
124                &conflicts_section
125            )
126        })
127    })
128}
129
130/// Format the context section with PROMPT.md and PLAN.md content.
131///
132/// This helper builds the context section that gets injected into the
133/// {{CONTEXT}} template variable.
134fn format_context_section(prompt_md_content: Option<&str>, plan_content: Option<&str>) -> String {
135    let mut context = String::new();
136
137    // Add task context from PROMPT.md if available
138    if let Some(prompt_md) = prompt_md_content {
139        context.push_str("## Task Context\n\n");
140        context.push_str("The user was working on the following task:\n\n");
141        context.push_str("```\n");
142        context.push_str(prompt_md);
143        context.push_str("\n```\n\n");
144    }
145
146    // Add plan context from PLAN.md if available
147    if let Some(plan) = plan_content {
148        context.push_str("## Implementation Plan\n\n");
149        context.push_str("The following plan was being implemented:\n\n");
150        context.push_str("```\n");
151        context.push_str(plan);
152        context.push_str("\n```\n\n");
153    }
154
155    context
156}
157
158/// Format the conflicts section for all conflicted files.
159///
160/// This helper builds the conflicts section that gets injected into the
161/// {{CONFLICTS}} template variable.
162fn format_conflicts_section(conflicts: &HashMap<String, FileConflict>) -> String {
163    let mut section = String::new();
164
165    for (path, conflict) in conflicts {
166        writeln!(section, "### {path}\n\n").unwrap();
167        section.push_str("Current state (with conflict markers):\n\n");
168        section.push_str("```");
169        section.push_str(&get_language_marker(path));
170        section.push('\n');
171        section.push_str(&conflict.current_content);
172        section.push_str("\n```\n\n");
173
174        if !conflict.conflict_content.is_empty() {
175            section.push_str("Conflict sections:\n\n");
176            section.push_str("```\n");
177            section.push_str(&conflict.conflict_content);
178            section.push_str("\n```\n\n");
179        }
180    }
181
182    section
183}
184
185/// Get a language marker for syntax highlighting based on file extension.
186fn get_language_marker(path: &str) -> String {
187    let ext = Path::new(path)
188        .extension()
189        .and_then(|e| e.to_str())
190        .unwrap_or("");
191
192    match ext {
193        "rs" => "rust",
194        "py" => "python",
195        "js" | "jsx" => "javascript",
196        "ts" | "tsx" => "typescript",
197        "go" => "go",
198        "java" => "java",
199        "c" | "h" => "c",
200        "cpp" | "hpp" | "cc" | "cxx" => "cpp",
201        "cs" => "csharp",
202        "php" => "php",
203        "rb" => "ruby",
204        "swift" => "swift",
205        "kt" => "kotlin",
206        "scala" => "scala",
207        "sh" | "bash" | "zsh" => "bash",
208        "fish" => "fish",
209        "yaml" | "yml" => "yaml",
210        "json" => "json",
211        "toml" => "toml",
212        "md" | "markdown" => "markdown",
213        "txt" => "text",
214        "html" => "html",
215        "css" | "scss" | "less" => "css",
216        "xml" => "xml",
217        "sql" => "sql",
218        _ => "",
219    }
220    .to_string()
221}
222
223/// Collect conflict information from all conflicted files.
224///
225/// This function reads all conflicted files and builds a map of
226/// file paths to their conflict information.
227///
228/// # Arguments
229///
230/// * `conflicted_paths` - List of paths to conflicted files
231///
232/// # Returns
233///
234/// Returns `Ok(HashMap)` mapping file paths to conflict information,
235/// or an error if a file cannot be read.
236pub fn collect_conflict_info(
237    conflicted_paths: &[String],
238) -> std::io::Result<HashMap<String, FileConflict>> {
239    let mut conflicts = HashMap::new();
240
241    for path in conflicted_paths {
242        // Read the current file content with conflict markers
243        let current_content = fs::read_to_string(path)?;
244
245        // Extract conflict markers
246        let conflict_content = crate::git_helpers::get_conflict_markers_for_file(Path::new(path))?;
247
248        conflicts.insert(
249            path.clone(),
250            FileConflict {
251                conflict_content,
252                current_content,
253            },
254        );
255    }
256
257    Ok(conflicts)
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn test_build_conflict_resolution_prompt_no_mentions_rebase() {
266        let conflicts = HashMap::new();
267        let prompt = build_conflict_resolution_prompt(&conflicts, None, None);
268
269        // The prompt should NOT mention "rebase" or "rebasing"
270        assert!(!prompt.to_lowercase().contains("rebase"));
271        assert!(!prompt.to_lowercase().contains("rebasing"));
272
273        // But it SHOULD mention "merge conflict"
274        assert!(prompt.to_lowercase().contains("merge conflict"));
275    }
276
277    #[test]
278    fn test_build_conflict_resolution_prompt_with_context() {
279        let mut conflicts = HashMap::new();
280        conflicts.insert(
281            "test.rs".to_string(),
282            FileConflict {
283                conflict_content: "<<<<<<< ours\nfn foo() {}\n=======\nfn bar() {}\n>>>>>>> theirs"
284                    .to_string(),
285                current_content: "<<<<<<< ours\nfn foo() {}\n=======\nfn bar() {}\n>>>>>>> theirs"
286                    .to_string(),
287            },
288        );
289
290        let prompt_md = "Add a new feature";
291        let plan = "1. Create foo function\n2. Create bar function";
292
293        let prompt = build_conflict_resolution_prompt(&conflicts, Some(prompt_md), Some(plan));
294
295        // Should include context from PROMPT.md
296        assert!(prompt.contains("Add a new feature"));
297
298        // Should include context from PLAN.md
299        assert!(prompt.contains("Create foo function"));
300        assert!(prompt.contains("Create bar function"));
301
302        // Should include the conflicted file
303        assert!(prompt.contains("test.rs"));
304
305        // Should NOT mention rebase
306        assert!(!prompt.to_lowercase().contains("rebase"));
307    }
308
309    #[test]
310    fn test_get_language_marker() {
311        assert_eq!(get_language_marker("file.rs"), "rust");
312        assert_eq!(get_language_marker("file.py"), "python");
313        assert_eq!(get_language_marker("file.js"), "javascript");
314        assert_eq!(get_language_marker("file.ts"), "typescript");
315        assert_eq!(get_language_marker("file.go"), "go");
316        assert_eq!(get_language_marker("file.java"), "java");
317        assert_eq!(get_language_marker("file.cpp"), "cpp");
318        assert_eq!(get_language_marker("file.md"), "markdown");
319        assert_eq!(get_language_marker("file.yaml"), "yaml");
320        assert_eq!(get_language_marker("file.unknown"), "");
321    }
322
323    #[test]
324    fn test_format_context_section_with_both() {
325        let prompt_md = "Test prompt";
326        let plan = "Test plan";
327        let context = format_context_section(Some(prompt_md), Some(plan));
328
329        assert!(context.contains("## Task Context"));
330        assert!(context.contains("Test prompt"));
331        assert!(context.contains("## Implementation Plan"));
332        assert!(context.contains("Test plan"));
333    }
334
335    #[test]
336    fn test_format_context_section_with_prompt_only() {
337        let prompt_md = "Test prompt";
338        let context = format_context_section(Some(prompt_md), None);
339
340        assert!(context.contains("## Task Context"));
341        assert!(context.contains("Test prompt"));
342        assert!(!context.contains("## Implementation Plan"));
343    }
344
345    #[test]
346    fn test_format_context_section_with_plan_only() {
347        let plan = "Test plan";
348        let context = format_context_section(None, Some(plan));
349
350        assert!(!context.contains("## Task Context"));
351        assert!(context.contains("## Implementation Plan"));
352        assert!(context.contains("Test plan"));
353    }
354
355    #[test]
356    fn test_format_context_section_empty() {
357        let context = format_context_section(None, None);
358        assert!(context.is_empty());
359    }
360
361    #[test]
362    fn test_format_conflicts_section() {
363        let mut conflicts = HashMap::new();
364        conflicts.insert(
365            "src/test.rs".to_string(),
366            FileConflict {
367                conflict_content: "<<<<<<< ours\nx\n=======\ny\n>>>>>>> theirs".to_string(),
368                current_content: "<<<<<<< ours\nx\n=======\ny\n>>>>>>> theirs".to_string(),
369            },
370        );
371
372        let section = format_conflicts_section(&conflicts);
373
374        assert!(section.contains("### src/test.rs"));
375        assert!(section.contains("Current state (with conflict markers)"));
376        assert!(section.contains("```rust"));
377        assert!(section.contains("<<<<<<< ours"));
378        assert!(section.contains("Conflict sections"));
379    }
380
381    #[test]
382    fn test_template_is_used() {
383        // Verify that the template-based approach produces valid output
384        let conflicts = HashMap::new();
385        let prompt = build_conflict_resolution_prompt(&conflicts, None, None);
386
387        // Should contain key sections from the template
388        assert!(prompt.contains("# MERGE CONFLICT RESOLUTION"));
389        assert!(prompt.contains("## Conflict Resolution Instructions"));
390        assert!(prompt.contains("## Optional JSON Output Format"));
391        assert!(prompt.contains("resolved_files"));
392    }
393
394    #[test]
395    fn test_build_conflict_resolution_prompt_with_registry_context() {
396        let context = TemplateContext::default();
397        let conflicts = HashMap::new();
398        let prompt =
399            build_conflict_resolution_prompt_with_context(&context, &conflicts, None, None);
400
401        // The prompt should NOT mention "rebase" or "rebasing"
402        assert!(!prompt.to_lowercase().contains("rebase"));
403        assert!(!prompt.to_lowercase().contains("rebasing"));
404
405        // But it SHOULD mention "merge conflict"
406        assert!(prompt.to_lowercase().contains("merge conflict"));
407    }
408
409    #[test]
410    fn test_build_conflict_resolution_prompt_with_registry_context_and_content() {
411        let context = TemplateContext::default();
412        let mut conflicts = HashMap::new();
413        conflicts.insert(
414            "test.rs".to_string(),
415            FileConflict {
416                conflict_content: "<<<<<<< ours\nfn foo() {}\n=======\nfn bar() {}\n>>>>>>> theirs"
417                    .to_string(),
418                current_content: "<<<<<<< ours\nfn foo() {}\n=======\nfn bar() {}\n>>>>>>> theirs"
419                    .to_string(),
420            },
421        );
422
423        let prompt_md = "Add a new feature";
424        let plan = "1. Create foo function\n2. Create bar function";
425
426        let prompt = build_conflict_resolution_prompt_with_context(
427            &context,
428            &conflicts,
429            Some(prompt_md),
430            Some(plan),
431        );
432
433        // Should include context from PROMPT.md
434        assert!(prompt.contains("Add a new feature"));
435
436        // Should include context from PLAN.md
437        assert!(prompt.contains("Create foo function"));
438        assert!(prompt.contains("Create bar function"));
439
440        // Should include the conflicted file
441        assert!(prompt.contains("test.rs"));
442
443        // Should NOT mention rebase
444        assert!(!prompt.to_lowercase().contains("rebase"));
445    }
446
447    #[test]
448    fn test_registry_context_based_matches_regular() {
449        let context = TemplateContext::default();
450        let mut conflicts = HashMap::new();
451        conflicts.insert(
452            "test.rs".to_string(),
453            FileConflict {
454                conflict_content: "conflict".to_string(),
455                current_content: "current".to_string(),
456            },
457        );
458
459        let regular = build_conflict_resolution_prompt(&conflicts, Some("prompt"), Some("plan"));
460        let with_context = build_conflict_resolution_prompt_with_context(
461            &context,
462            &conflicts,
463            Some("prompt"),
464            Some("plan"),
465        );
466        // Both should produce equivalent output
467        assert_eq!(regular, with_context);
468    }
469}