Skip to main content

ralph_workflow/cli/handlers/
template_selection.rs

1//! Interactive template selection module.
2//!
3//! Provides functionality for prompting users to select a PROMPT.md template
4//! when one doesn't exist and interactive mode is enabled.
5
6use std::fs;
7use std::io::{self, IsTerminal, Write};
8use std::path::Path;
9
10use crate::logger::Colors;
11use crate::templates::{get_template, list_templates};
12
13/// Result of interactive template selection.
14///
15/// * `Some(template_name)` - User selected a template
16/// * `None` - User declined or input was not a terminal
17pub type TemplateSelectionResult = Option<String>;
18
19/// Prompt the user to select a template when PROMPT.md is missing.
20///
21/// This function:
22/// 1. Displays a message that PROMPT.md is missing
23/// 2. Asks if the user wants to create one from a template
24/// 3. If yes, displays available templates
25/// 4. Prompts for template selection (with default to feature-spec)
26/// 5. Returns the selected template name or None if declined
27///
28/// # Arguments
29///
30/// * `colors` - Terminal color configuration for output
31///
32/// # Returns
33///
34/// * `Some(template_name)` - User selected a template
35/// * `None` - User declined, input was not a terminal, or input errored/ended
36pub fn prompt_template_selection(colors: Colors) -> TemplateSelectionResult {
37    // Interactive prompts require both stdin and stdout to be terminals.
38    if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
39        return None;
40    }
41
42    println!();
43    println!("{}PROMPT.md not found.{}", colors.yellow(), colors.reset());
44    println!();
45    println!("PROMPT.md contains your task specification for the AI agents.");
46    print!("Would you like to create one from a template? [Y/n]: ");
47    if io::stdout().flush().is_err() {
48        return None;
49    }
50
51    let mut input = String::new();
52    match io::stdin().read_line(&mut input) {
53        Ok(0) | Err(_) => return None, // EOF or error
54        Ok(_) => {}
55    }
56
57    let response = input.trim().to_lowercase();
58
59    // User declined (explicit 'n' or 'no')
60    if response == "n" || response == "no" || response == "skip" {
61        return None;
62    }
63
64    // Empty or 'y'/'yes' means yes - proceed to template selection
65    println!();
66    println!("Available templates:");
67
68    let templates = list_templates();
69
70    for (name, description) in &templates {
71        println!(
72            "  {}{}{}  {}{}{}",
73            colors.cyan(),
74            name,
75            colors.reset(),
76            colors.dim(),
77            description,
78            colors.reset()
79        );
80    }
81    println!();
82
83    // Prompt for template selection with default to feature-spec
84    print!(
85        "Select template {}[default: feature-spec]{}: ",
86        colors.dim(),
87        colors.reset()
88    );
89    if io::stdout().flush().is_err() {
90        return None;
91    }
92
93    let mut template_input = String::new();
94    match io::stdin().read_line(&mut template_input) {
95        Ok(0) | Err(_) => return None, // EOF or error
96        Ok(_) => {}
97    }
98
99    let template_name = template_input.trim();
100
101    // Empty input defaults to feature-spec
102    let selected = if template_name.is_empty() {
103        "feature-spec"
104    } else {
105        template_name
106    };
107
108    // Validate the template exists
109    if get_template(selected).is_none() {
110        println!(
111            "{}Unknown template: '{}'. Using feature-spec as default.{}",
112            colors.yellow(),
113            selected,
114            colors.reset()
115        );
116        return Some("feature-spec".to_string());
117    }
118
119    Some(selected.to_string())
120}
121
122/// Create PROMPT.md from the selected template.
123///
124/// # Arguments
125///
126/// * `template_name` - The name of the template to use
127/// * `colors` - Terminal color configuration for output
128///
129/// # Returns
130///
131/// * `Ok(())` - File created successfully
132/// * `Err(e)` - Failed to create file
133pub fn create_prompt_from_template(template_name: &str, colors: Colors) -> anyhow::Result<()> {
134    let prompt_path = Path::new("PROMPT.md");
135
136    // Check if PROMPT.md already exists (shouldn't happen in our flow, but safety check)
137    if prompt_path.exists() {
138        println!(
139            "{}PROMPT.md already exists. Skipping creation.{}",
140            colors.yellow(),
141            colors.reset()
142        );
143        return Ok(());
144    }
145
146    // Get the template
147    let Some(template) = get_template(template_name) else {
148        return Err(anyhow::anyhow!("Template '{template_name}' not found"));
149    };
150
151    // Write the template content to PROMPT.md
152    let content = template.content();
153    fs::write(prompt_path, content)?;
154
155    println!();
156    println!(
157        "{}Created PROMPT.md from template: {}{}{}",
158        colors.green(),
159        colors.bold(),
160        template_name,
161        colors.reset()
162    );
163    println!();
164    println!(
165        "Template: {}{}{}  {}",
166        colors.cyan(),
167        template.name(),
168        colors.reset(),
169        template.description()
170    );
171    println!();
172    println!("Next steps:");
173    println!("  1. Edit PROMPT.md with your task details");
174    println!("  2. Run ralph again with your commit message");
175
176    Ok(())
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn test_get_template_by_name() {
185        // Verify all our template names are valid
186        assert!(get_template("feature-spec").is_some());
187        assert!(get_template("bug-fix").is_some());
188        assert!(get_template("refactor").is_some());
189        assert!(get_template("test").is_some());
190        assert!(get_template("docs").is_some());
191        assert!(get_template("quick").is_some());
192        assert!(get_template("nonexistent").is_none());
193    }
194
195    #[test]
196    fn test_template_has_required_content() {
197        // All templates should have Goal and Acceptance sections
198        for (name, _) in list_templates() {
199            if let Some(template) = get_template(name) {
200                let content = template.content();
201                assert!(
202                    content.contains("## Goal"),
203                    "Template {name} missing Goal section"
204                );
205                assert!(
206                    content.contains("Acceptance") || content.contains("## Acceptance Checks"),
207                    "Template {name} missing Acceptance section"
208                );
209            }
210        }
211    }
212}