ralph_workflow/cli/handlers/
template_mgmt.rs

1//! Template management CLI handler.
2//!
3//! Provides commands for:
4//! - Initializing user templates directory
5//! - Listing all templates with metadata
6//! - Showing template content and variables
7//! - Validating templates for syntax errors
8//! - Extracting variables from templates
9//! - Rendering templates for testing
10
11use std::collections::HashMap;
12use std::fs;
13
14use crate::cli::args::TemplateCommands;
15use crate::logger::Colors;
16use crate::prompts::partials::get_shared_partials;
17use crate::prompts::template_catalog;
18use crate::prompts::template_registry::TemplateRegistry;
19use crate::prompts::{
20    extract_metadata, extract_partials, extract_variables, validate_template, Template,
21};
22
23/// Get all available templates as a map of name -> (content, description).
24fn get_all_templates() -> HashMap<String, (String, String)> {
25    template_catalog::get_templates_map()
26}
27
28/// Handle template validation command.
29pub fn handle_template_validate(colors: Colors) {
30    println!("{}Validating templates...{}", colors.bold(), colors.reset());
31    println!();
32
33    let templates = get_all_templates();
34    let partials_set: std::collections::HashSet<String> =
35        get_shared_partials().keys().cloned().collect();
36
37    let mut total_errors = 0;
38    let mut total_warnings = 0;
39
40    for (name, (content, _)) in {
41        let mut items: Vec<_> = templates.iter().collect();
42        items.sort_by(|a, b| a.0.cmp(b.0));
43        items
44    } {
45        let result = validate_template(content, &partials_set);
46
47        if result.is_valid {
48            println!(
49                "{}✓{} {}{}{}",
50                colors.green(),
51                colors.reset(),
52                colors.cyan(),
53                name,
54                colors.reset()
55            );
56        } else {
57            println!(
58                "{}✗{} {}{}{}",
59                colors.red(),
60                colors.reset(),
61                colors.cyan(),
62                name,
63                colors.reset()
64            );
65        }
66
67        for error in &result.errors {
68            println!(
69                "  {}error:{} {}",
70                colors.red(),
71                colors.reset(),
72                format_error(error)
73            );
74            total_errors += 1;
75        }
76
77        for warning in &result.warnings {
78            println!(
79                "  {}warning:{} {}",
80                colors.yellow(),
81                colors.reset(),
82                format_warning(warning)
83            );
84            total_warnings += 1;
85        }
86
87        if !result.variables.is_empty() {
88            let var_names: Vec<&str> = result.variables.iter().map(|v| v.name.as_str()).collect();
89            println!(
90                "  {}variables:{} {}",
91                colors.dim(),
92                colors.reset(),
93                var_names.join(", ")
94            );
95        }
96
97        if !result.partials.is_empty() {
98            println!(
99                "  {}partials:{} {}",
100                colors.dim(),
101                colors.reset(),
102                result.partials.join(", ")
103            );
104        }
105    }
106
107    println!();
108    if total_errors == 0 {
109        println!(
110            "{}All templates validated successfully!{}",
111            colors.green(),
112            colors.reset()
113        );
114        if total_warnings > 0 {
115            println!("{total_warnings} warnings");
116        }
117    } else {
118        println!(
119            "{}Validation failed with {} error(s){}",
120            colors.red(),
121            total_errors,
122            colors.reset()
123        );
124        if total_warnings > 0 {
125            println!("{total_warnings} warnings");
126        }
127        std::process::exit(1);
128    }
129}
130
131/// Handle template list command.
132pub fn handle_template_list(colors: Colors) {
133    let templates = get_all_templates();
134
135    println!("{}Available Templates:{}", colors.bold(), colors.reset());
136    println!();
137
138    for (name, (_, description)) in {
139        let mut items: Vec<_> = templates.iter().collect();
140        items.sort_by(|a, b| a.0.cmp(b.0));
141        items
142    } {
143        println!(
144            "  {}{}{}  {}{}{}",
145            colors.cyan(),
146            name,
147            colors.reset(),
148            colors.dim(),
149            description,
150            colors.reset()
151        );
152    }
153
154    println!();
155    println!("Total: {} templates", templates.len());
156}
157
158/// Handle template show command.
159pub fn handle_template_show(name: &str, colors: Colors) -> anyhow::Result<()> {
160    let templates = get_all_templates();
161
162    let (content, description) = templates
163        .get(name)
164        .ok_or_else(|| anyhow::anyhow!("Template '{name}' not found"))?;
165
166    println!(
167        "{}Template: {}{}{}{}",
168        colors.bold(),
169        colors.cyan(),
170        name,
171        colors.reset(),
172        colors.reset()
173    );
174    println!(
175        "{}Description: {}{}{}",
176        colors.dim(),
177        description,
178        colors.reset(),
179        colors.reset()
180    );
181    println!();
182
183    // Show metadata
184    let metadata = extract_metadata(content);
185    if let Some(version) = metadata.version {
186        println!(
187            "{}Version: {}{}{}",
188            colors.dim(),
189            version,
190            colors.reset(),
191            colors.reset()
192        );
193    }
194    if let Some(purpose) = metadata.purpose {
195        println!(
196            "{}Purpose: {}{}{}",
197            colors.dim(),
198            purpose,
199            colors.reset(),
200            colors.reset()
201        );
202    }
203
204    println!();
205    println!("{}Variables:{}", colors.bold(), colors.reset());
206
207    let variables = extract_variables(content);
208    if variables.is_empty() {
209        println!("  (none)");
210    } else {
211        for var in &variables {
212            if var.has_default {
213                println!(
214                    "  {}{}{} = {}{}{}",
215                    colors.cyan(),
216                    var.name,
217                    colors.reset(),
218                    colors.green(),
219                    var.default_value.as_deref().unwrap_or(""),
220                    colors.reset()
221                );
222            } else {
223                println!("  {}{}{}", colors.cyan(), var.name, colors.reset());
224            }
225        }
226    }
227
228    println!();
229    println!("{}Partials:{}", colors.bold(), colors.reset());
230
231    let partials = extract_partials(content);
232    if partials.is_empty() {
233        println!("  (none)");
234    } else {
235        for partial in &partials {
236            println!("  {}{}{}", colors.cyan(), partial, colors.reset());
237        }
238    }
239
240    println!();
241    println!("{}Content:{}", colors.bold(), colors.reset());
242    println!("{}", colors.dim());
243    for line in content.lines().take(50) {
244        println!("{line}");
245    }
246    if content.lines().count() > 50 {
247        println!("... ({} more lines)", content.lines().count() - 50);
248    }
249    println!("{}", colors.reset());
250
251    Ok(())
252}
253
254/// Handle template variables command.
255pub fn handle_template_variables(name: &str, colors: Colors) -> anyhow::Result<()> {
256    let templates = get_all_templates();
257
258    let (content, _) = templates
259        .get(name)
260        .ok_or_else(|| anyhow::anyhow!("Template '{name}' not found"))?;
261
262    let variables = extract_variables(content);
263
264    println!(
265        "{}Variables in '{}':{}",
266        colors.bold(),
267        name,
268        colors.reset()
269    );
270    println!();
271
272    if variables.is_empty() {
273        println!("  (no variables found)");
274    } else {
275        for var in &variables {
276            let default = if var.has_default {
277                format!(
278                    " = {}{}{}",
279                    colors.green(),
280                    var.default_value.as_deref().unwrap_or(""),
281                    colors.reset()
282                )
283            } else {
284                String::new()
285            };
286            println!(
287                "  {}{}{}{}  {}line {}{}",
288                colors.cyan(),
289                var.name,
290                colors.reset(),
291                default,
292                colors.dim(),
293                var.line,
294                colors.reset()
295            );
296        }
297    }
298
299    println!();
300    println!("Total: {} variable(s)", variables.len());
301
302    Ok(())
303}
304
305/// Handle template render command.
306pub fn handle_template_render(name: &str, colors: Colors) -> anyhow::Result<()> {
307    let templates = get_all_templates();
308
309    let (content, _) = templates
310        .get(name)
311        .ok_or_else(|| anyhow::anyhow!("Template '{name}' not found"))?;
312
313    // Get variables from environment or command line
314    let mut variables = HashMap::new();
315
316    // For now, just use some example variables for testing
317    // In a full implementation, this would parse --var KEY=VALUE arguments
318    variables.insert("PROMPT".to_string(), "Example prompt content".to_string());
319    variables.insert("PLAN".to_string(), "Example plan content".to_string());
320    variables.insert("DIFF".to_string(), "+ example line".to_string());
321
322    println!(
323        "{}Rendering template '{}'...{}",
324        colors.bold(),
325        name,
326        colors.reset()
327    );
328    println!();
329
330    let partials = get_shared_partials();
331    let template = Template::new(content);
332
333    match template.render_with_partials(
334        &variables
335            .iter()
336            .map(|(k, v)| (k.as_str(), v.clone()))
337            .collect(),
338        &partials,
339    ) {
340        Ok(rendered) => {
341            println!("{}", colors.dim());
342            println!("{rendered}");
343            println!("{}", colors.reset());
344        }
345        Err(e) => {
346            println!(
347                "{}Render error: {}{}{}",
348                colors.red(),
349                e,
350                colors.reset(),
351                colors.reset()
352            );
353            println!();
354            println!("{}Tip:{}", colors.yellow(), colors.reset());
355            println!("  Use --template-variables to see which variables are required.");
356        }
357    }
358
359    Ok(())
360}
361
362/// Format a validation error for display.
363fn format_error(error: &crate::prompts::ValidationError) -> String {
364    match error {
365        crate::prompts::ValidationError::UnclosedConditional { line } => {
366            format!("unclosed conditional block on line {line}")
367        }
368        crate::prompts::ValidationError::UnclosedLoop { line } => {
369            format!("unclosed loop block on line {line}")
370        }
371        crate::prompts::ValidationError::InvalidConditional { line, syntax } => {
372            format!("invalid conditional syntax on line {line}: '{syntax}'")
373        }
374        crate::prompts::ValidationError::InvalidLoop { line, syntax } => {
375            format!("invalid loop syntax on line {line}: '{syntax}'")
376        }
377        crate::prompts::ValidationError::UnclosedComment { line } => {
378            format!("unclosed comment on line {line}")
379        }
380        crate::prompts::ValidationError::PartialNotFound { name } => {
381            format!("partial not found: '{name}'")
382        }
383    }
384}
385
386/// Format a validation warning for display.
387fn format_warning(warning: &crate::prompts::ValidationWarning) -> String {
388    match warning {
389        crate::prompts::ValidationWarning::VariableMayError { name } => {
390            format!("variable '{name}' may cause error if not provided")
391        }
392    }
393}
394
395/// Handle template initialization command.
396///
397/// Creates the user templates directory and copies all default templates.
398fn handle_template_init(force: bool, colors: Colors) -> anyhow::Result<()> {
399    let templates_dir = TemplateRegistry::default_user_templates_dir()
400        .ok_or_else(|| anyhow::anyhow!("Cannot determine home directory for templates"))?;
401
402    // Create a registry instance to validate the directory structure
403    let registry = TemplateRegistry::new(Some(templates_dir.clone()));
404
405    // Check if we're using user templates or embedded templates
406    let source = registry.template_source("commit_message_xml");
407    let has_user = registry.has_user_template("commit_message_xml");
408
409    // Use the variables to avoid dead code warnings
410    let _ = (source, has_user);
411
412    println!(
413        "{}Initializing user templates directory...{}",
414        colors.bold(),
415        colors.reset()
416    );
417    println!(
418        "  Location: {}{}{}",
419        colors.cyan(),
420        templates_dir.display(),
421        colors.reset()
422    );
423    println!();
424
425    // Check if directory already exists
426    if templates_dir.exists() {
427        if force {
428            println!(
429                "{}Warning: {}Directory already exists. Overwriting...{}",
430                colors.yellow(),
431                colors.reset(),
432                colors.reset()
433            );
434        } else {
435            println!(
436                "{}Error: {}Directory already exists. Use --force to overwrite.{}",
437                colors.red(),
438                colors.reset(),
439                colors.reset()
440            );
441            println!();
442            println!("To reinitialize with defaults, run:");
443            println!("  ralph --template-init --force");
444            return Err(anyhow::anyhow!("Templates directory already exists"));
445        }
446    }
447
448    // Create directory structure
449    fs::create_dir_all(&templates_dir)?;
450
451    let shared_dir = templates_dir.join("shared");
452    fs::create_dir_all(&shared_dir)?;
453
454    let reviewer_dir = templates_dir.join("reviewer");
455    fs::create_dir_all(&reviewer_dir)?;
456
457    // Copy all templates from the embedded templates
458    let templates = get_all_templates();
459    let mut copied = 0;
460    let mut skipped = 0;
461
462    for (name, (content, _)) in &templates {
463        let target_path = if name.starts_with("reviewer/") {
464            let parts: Vec<&str> = name.split('/').collect();
465            if parts.len() == 2 {
466                templates_dir
467                    .join("reviewer")
468                    .join(format!("{}.txt", parts[1]))
469            } else {
470                continue;
471            }
472        } else {
473            templates_dir.join(format!("{name}.txt"))
474        };
475
476        // Skip if file exists and not forcing
477        if target_path.exists() && !force {
478            skipped += 1;
479            continue;
480        }
481
482        fs::write(&target_path, content)?;
483        copied += 1;
484    }
485
486    // Copy shared partials
487    let partials = get_shared_partials();
488    for (name, content) in &partials {
489        let target_path = templates_dir.join(format!("{name}.txt"));
490        if target_path.exists() && !force {
491            skipped += 1;
492            continue;
493        }
494        fs::write(&target_path, content)?;
495        copied += 1;
496    }
497
498    println!(
499        "{}Successfully initialized user templates!{}",
500        colors.green(),
501        colors.reset()
502    );
503    println!();
504    println!("  {copied} templates copied");
505    if skipped > 0 {
506        println!("  {skipped} templates skipped (already exists)");
507    }
508    println!();
509    println!("You can now edit templates in:");
510    println!("  {}", templates_dir.display());
511    println!();
512    println!("Changes to user templates will override the built-in templates.");
513
514    Ok(())
515}
516
517/// Handle all template commands.
518pub fn handle_template_commands(commands: &TemplateCommands, colors: Colors) -> anyhow::Result<()> {
519    if commands.init_templates_enabled() {
520        handle_template_init(commands.force, colors)?;
521    } else if commands.validate {
522        handle_template_validate(colors);
523    } else if let Some(ref name) = commands.show {
524        handle_template_show(name, colors)?;
525    } else if commands.list {
526        handle_template_list(colors);
527    } else if let Some(ref name) = commands.variables {
528        handle_template_variables(name, colors)?;
529    } else if let Some(ref name) = commands.render {
530        handle_template_render(name, colors)?;
531    }
532
533    Ok(())
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    #[test]
541    fn test_get_all_templates_not_empty() {
542        let templates = get_all_templates();
543        assert!(!templates.is_empty());
544        assert!(templates.contains_key("developer_iteration"));
545        assert!(templates.contains_key("commit_message_xml"));
546    }
547
548    #[test]
549    fn test_template_show_valid() {
550        let colors = Colors::new();
551        let result = handle_template_show("developer_iteration", colors);
552        assert!(result.is_ok());
553    }
554
555    #[test]
556    fn test_template_show_invalid() {
557        let colors = Colors::new();
558        let result = handle_template_show("nonexistent", colors);
559        assert!(result.is_err());
560    }
561
562    #[test]
563    fn test_template_variables() {
564        let colors = Colors::new();
565        let result = handle_template_variables("developer_iteration", colors);
566        assert!(result.is_ok());
567    }
568}