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
44#[must_use]
45pub fn prompt_template_selection(colors: Colors) -> TemplateSelectionResult {
46    // Interactive prompts require both stdin and stdout to be terminals.
47    if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
48        return None;
49    }
50
51    println!();
52    println!("{}PROMPT.md not found.{}", colors.yellow(), colors.reset());
53    println!();
54    println!("PROMPT.md contains your task specification for the AI agents.");
55    print!("Would you like to create one from a template? [Y/n]: ");
56    if io::stdout().flush().is_err() {
57        return None;
58    }
59
60    let mut input = String::new();
61    match io::stdin().read_line(&mut input) {
62        Ok(0) | Err(_) => return None, // EOF or error
63        Ok(_) => {}
64    }
65
66    let response = input.trim().to_lowercase();
67
68    // User declined (explicit 'n' or 'no')
69    if response == "n" || response == "no" || response == "skip" {
70        return None;
71    }
72
73    // Empty or 'y'/'yes' means yes - proceed to template selection
74    println!();
75    println!("Available templates:");
76
77    let templates = list_templates();
78
79    for (name, description) in &templates {
80        println!(
81            "  {}{}{}  {}{}{}",
82            colors.cyan(),
83            name,
84            colors.reset(),
85            colors.dim(),
86            description,
87            colors.reset()
88        );
89    }
90    println!();
91
92    // Prompt for template selection with default to feature-spec
93    print!(
94        "Select template {}[default: feature-spec]{}: ",
95        colors.dim(),
96        colors.reset()
97    );
98    if io::stdout().flush().is_err() {
99        return None;
100    }
101
102    let mut template_input = String::new();
103    match io::stdin().read_line(&mut template_input) {
104        Ok(0) | Err(_) => return None, // EOF or error
105        Ok(_) => {}
106    }
107
108    let template_name = template_input.trim();
109
110    // Empty input defaults to feature-spec
111    let selected = if template_name.is_empty() {
112        "feature-spec"
113    } else {
114        template_name
115    };
116
117    // Validate the template exists
118    if get_template(selected).is_none() {
119        println!(
120            "{}Unknown template: '{}'. Using feature-spec as default.{}",
121            colors.yellow(),
122            selected,
123            colors.reset()
124        );
125        return Some("feature-spec".to_string());
126    }
127
128    Some(selected.to_string())
129}
130
131/// Create PROMPT.md from the selected template.
132///
133/// # Arguments
134///
135/// * `template_name` - The name of the template to use
136/// * `colors` - Terminal color configuration for output
137///
138/// # Returns
139///
140/// * `Ok(())` - File created successfully
141/// * `Err(e)` - Failed to create file
142///
143/// # Errors
144///
145/// Returns error if the operation fails.
146pub fn create_prompt_from_template(template_name: &str, colors: Colors) -> anyhow::Result<()> {
147    let prompt_path = Path::new("PROMPT.md");
148
149    // Check if PROMPT.md already exists (shouldn't happen in our flow, but safety check)
150    if prompt_path.exists() {
151        println!(
152            "{}PROMPT.md already exists. Skipping creation.{}",
153            colors.yellow(),
154            colors.reset()
155        );
156        return Ok(());
157    }
158
159    // Get the template
160    let Some(template) = get_template(template_name) else {
161        return Err(anyhow::anyhow!("Template '{template_name}' not found"));
162    };
163
164    // Write the template content to PROMPT.md
165    let content = template.content();
166    fs::write(prompt_path, content)?;
167
168    println!();
169    println!(
170        "{}Created PROMPT.md from template: {}{}{}",
171        colors.green(),
172        colors.bold(),
173        template_name,
174        colors.reset()
175    );
176    println!();
177    println!(
178        "Template: {}{}{}  {}",
179        colors.cyan(),
180        template.name(),
181        colors.reset(),
182        template.description()
183    );
184    println!();
185    println!("Next steps:");
186    println!("  1. Edit PROMPT.md with your task details");
187    println!("  2. Run ralph again with your commit message");
188
189    Ok(())
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn test_get_template_by_name() {
198        // Verify all our template names are valid
199        assert!(get_template("feature-spec").is_some());
200        assert!(get_template("bug-fix").is_some());
201        assert!(get_template("refactor").is_some());
202        assert!(get_template("test").is_some());
203        assert!(get_template("docs").is_some());
204        assert!(get_template("quick").is_some());
205        assert!(get_template("nonexistent").is_none());
206    }
207
208    #[test]
209    fn test_template_has_required_content() {
210        // All templates should have Goal and Acceptance sections
211        for (name, _) in list_templates() {
212            if let Some(template) = get_template(name) {
213                let content = template.content();
214                assert!(
215                    content.contains("## Goal"),
216                    "Template {name} missing Goal section"
217                );
218                assert!(
219                    content.contains("Acceptance") || content.contains("## Acceptance Checks"),
220                    "Template {name} missing Acceptance section"
221                );
222            }
223        }
224    }
225}