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