Skip to main content

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    handle_template_list_impl(colors, false);
134}
135
136/// Handle template list all command (including deprecated).
137pub fn handle_template_list_all(colors: Colors) {
138    handle_template_list_impl(colors, true);
139}
140
141/// Implementation of template list command.
142fn handle_template_list_impl(colors: Colors, include_deprecated: bool) {
143    let all_templates = get_all_templates();
144    let filtered_templates: Vec<_> = all_templates
145        .iter()
146        .filter(|(name, _)| {
147            if include_deprecated {
148                return true;
149            }
150            // Check if this is a deprecated template by looking at the catalog
151            if let Some(meta) = template_catalog::get_template_metadata(name) {
152                !meta.deprecated
153            } else {
154                true
155            }
156        })
157        .map(|(name, (content, desc))| {
158            // For deprecated templates, use their content which points to consolidated versions
159            (name, content, desc)
160        })
161        .collect();
162
163    let header = if include_deprecated {
164        "All Templates (including deprecated):"
165    } else {
166        "Active Templates:"
167    };
168
169    println!("{}{}{}", colors.bold(), header, colors.reset());
170    println!();
171
172    for (name, _, description) in {
173        let mut items: Vec<_> = filtered_templates.clone();
174        items.sort_by(|a, b| a.0.cmp(b.0));
175        items
176    } {
177        // Show deprecated marker in the list
178        let is_deprecated = if let Some(meta) = template_catalog::get_template_metadata(name) {
179            meta.deprecated
180        } else {
181            false
182        };
183
184        let deprecated_marker = if is_deprecated {
185            format!("{} [DEPRECATED]{}", colors.yellow(), colors.reset())
186        } else {
187            String::new()
188        };
189
190        println!(
191            "  {}{}{}{}  {}{}{}",
192            colors.cyan(),
193            name,
194            colors.reset(),
195            deprecated_marker,
196            colors.dim(),
197            description,
198            colors.reset()
199        );
200    }
201
202    println!();
203    if include_deprecated {
204        let deprecated_count = filtered_templates
205            .iter()
206            .filter(|(name, _, _)| {
207                if let Some(meta) = template_catalog::get_template_metadata(name) {
208                    meta.deprecated
209                } else {
210                    false
211                }
212            })
213            .count();
214
215        println!(
216            "Total: {} templates ({} active, {} deprecated)",
217            filtered_templates.len(),
218            filtered_templates.len() - deprecated_count,
219            deprecated_count
220        );
221        println!();
222        println!("{}Tip:{}", colors.yellow(), colors.reset());
223        println!("  Edit templates in ~/.config/ralph/templates/");
224        println!("  Deprecated templates are kept for backward compatibility.");
225        println!(
226            "  Use {}--list{} to show only active templates.",
227            colors.bold(),
228            colors.reset()
229        );
230    } else {
231        println!("Total: {} active templates", filtered_templates.len());
232        println!();
233        println!("{}Tip:{}", colors.yellow(), colors.reset());
234        println!("  Edit templates in ~/.config/ralph/templates/");
235        println!(
236            "  Use {}--list-all{} to include deprecated templates",
237            colors.bold(),
238            colors.reset()
239        );
240    }
241}
242
243/// Handle template show command.
244pub fn handle_template_show(name: &str, colors: Colors) -> anyhow::Result<()> {
245    let templates = get_all_templates();
246
247    let (content, description) = templates
248        .get(name)
249        .ok_or_else(|| anyhow::anyhow!("Template '{name}' not found"))?;
250
251    println!(
252        "{}Template: {}{}{}{}",
253        colors.bold(),
254        colors.cyan(),
255        name,
256        colors.reset(),
257        colors.reset()
258    );
259    println!(
260        "{}Description: {}{}{}",
261        colors.dim(),
262        description,
263        colors.reset(),
264        colors.reset()
265    );
266    println!();
267
268    // Show metadata
269    let metadata = extract_metadata(content);
270    if let Some(version) = metadata.version {
271        println!(
272            "{}Version: {}{}{}",
273            colors.dim(),
274            version,
275            colors.reset(),
276            colors.reset()
277        );
278    }
279    if let Some(purpose) = metadata.purpose {
280        println!(
281            "{}Purpose: {}{}{}",
282            colors.dim(),
283            purpose,
284            colors.reset(),
285            colors.reset()
286        );
287    }
288
289    println!();
290    println!("{}Variables:{}", colors.bold(), colors.reset());
291
292    let variables = extract_variables(content);
293    if variables.is_empty() {
294        println!("  (none)");
295    } else {
296        for var in &variables {
297            if var.has_default {
298                println!(
299                    "  {}{}{} = {}{}{}",
300                    colors.cyan(),
301                    var.name,
302                    colors.reset(),
303                    colors.green(),
304                    var.default_value.as_deref().unwrap_or(""),
305                    colors.reset()
306                );
307            } else {
308                println!("  {}{}{}", colors.cyan(), var.name, colors.reset());
309            }
310        }
311    }
312
313    println!();
314    println!("{}Partials:{}", colors.bold(), colors.reset());
315
316    let partials = extract_partials(content);
317    if partials.is_empty() {
318        println!("  (none)");
319    } else {
320        for partial in &partials {
321            println!("  {}{}{}", colors.cyan(), partial, colors.reset());
322        }
323    }
324
325    println!();
326    println!("{}Content:{}", colors.bold(), colors.reset());
327    println!("{}", colors.dim());
328    for line in content.lines().take(50) {
329        println!("{line}");
330    }
331    if content.lines().count() > 50 {
332        println!("... ({} more lines)", content.lines().count() - 50);
333    }
334    println!("{}", colors.reset());
335
336    Ok(())
337}
338
339/// Handle template variables command.
340pub fn handle_template_variables(name: &str, colors: Colors) -> anyhow::Result<()> {
341    let templates = get_all_templates();
342
343    let (content, _) = templates
344        .get(name)
345        .ok_or_else(|| anyhow::anyhow!("Template '{name}' not found"))?;
346
347    let variables = extract_variables(content);
348
349    println!(
350        "{}Variables in '{}':{}",
351        colors.bold(),
352        name,
353        colors.reset()
354    );
355    println!();
356
357    if variables.is_empty() {
358        println!("  (no variables found)");
359    } else {
360        for var in &variables {
361            let default = if var.has_default {
362                format!(
363                    " = {}{}{}",
364                    colors.green(),
365                    var.default_value.as_deref().unwrap_or(""),
366                    colors.reset()
367                )
368            } else {
369                String::new()
370            };
371            println!(
372                "  {}{}{}{}  {}line {}{}",
373                colors.cyan(),
374                var.name,
375                colors.reset(),
376                default,
377                colors.dim(),
378                var.line,
379                colors.reset()
380            );
381        }
382    }
383
384    println!();
385    println!("Total: {} variable(s)", variables.len());
386
387    Ok(())
388}
389
390/// Handle template render command.
391pub fn handle_template_render(name: &str, colors: Colors) -> anyhow::Result<()> {
392    let templates = get_all_templates();
393
394    let (content, _) = templates
395        .get(name)
396        .ok_or_else(|| anyhow::anyhow!("Template '{name}' not found"))?;
397
398    // Get variables from environment or command line
399    let mut variables = HashMap::new();
400
401    // For now, just use some example variables for testing
402    // In a full implementation, this would parse --var KEY=VALUE arguments
403    variables.insert("PROMPT".to_string(), "Example prompt content".to_string());
404    variables.insert("PLAN".to_string(), "Example plan content".to_string());
405    variables.insert("DIFF".to_string(), "+ example line".to_string());
406
407    println!(
408        "{}Rendering template '{}'...{}",
409        colors.bold(),
410        name,
411        colors.reset()
412    );
413    println!();
414
415    let partials = get_shared_partials();
416    let template = Template::new(content);
417
418    match template.render_with_partials(
419        &variables
420            .iter()
421            .map(|(k, v)| (k.as_str(), v.clone()))
422            .collect(),
423        &partials,
424    ) {
425        Ok(rendered) => {
426            println!("{}", colors.dim());
427            println!("{rendered}");
428            println!("{}", colors.reset());
429        }
430        Err(e) => {
431            println!(
432                "{}Render error: {}{}{}",
433                colors.red(),
434                e,
435                colors.reset(),
436                colors.reset()
437            );
438            println!();
439            println!("{}Tip:{}", colors.yellow(), colors.reset());
440            println!("  Use --template-variables to see which variables are required.");
441        }
442    }
443
444    Ok(())
445}
446
447/// Format a validation error for display.
448fn format_error(error: &crate::prompts::ValidationError) -> String {
449    match error {
450        crate::prompts::ValidationError::UnclosedConditional { line } => {
451            format!("unclosed conditional block on line {line}")
452        }
453        crate::prompts::ValidationError::UnclosedLoop { line } => {
454            format!("unclosed loop block on line {line}")
455        }
456        crate::prompts::ValidationError::InvalidConditional { line, syntax } => {
457            format!("invalid conditional syntax on line {line}: '{syntax}'")
458        }
459        crate::prompts::ValidationError::InvalidLoop { line, syntax } => {
460            format!("invalid loop syntax on line {line}: '{syntax}'")
461        }
462        crate::prompts::ValidationError::UnclosedComment { line } => {
463            format!("unclosed comment on line {line}")
464        }
465        crate::prompts::ValidationError::PartialNotFound { name } => {
466            format!("partial not found: '{name}'")
467        }
468    }
469}
470
471/// Format a validation warning for display.
472fn format_warning(warning: &crate::prompts::ValidationWarning) -> String {
473    match warning {
474        crate::prompts::ValidationWarning::VariableMayError { name } => {
475            format!("variable '{name}' may cause error if not provided")
476        }
477    }
478}
479
480/// Handle template initialization command.
481///
482/// Creates the user templates directory and copies all default templates.
483fn handle_template_init(force: bool, colors: Colors) -> anyhow::Result<()> {
484    let templates_dir = TemplateRegistry::default_user_templates_dir()
485        .ok_or_else(|| anyhow::anyhow!("Cannot determine home directory for templates"))?;
486
487    // Create a registry instance to validate the directory structure
488    let registry = TemplateRegistry::new(Some(templates_dir.clone()));
489
490    // Check if we're using user templates or embedded templates
491    let source = registry.template_source("commit_message_xml");
492    let has_user = registry.has_user_template("commit_message_xml");
493
494    // Use the variables to avoid dead code warnings
495    let _ = (source, has_user);
496
497    println!(
498        "{}Initializing user templates directory...{}",
499        colors.bold(),
500        colors.reset()
501    );
502    println!(
503        "  Location: {}{}{}",
504        colors.cyan(),
505        templates_dir.display(),
506        colors.reset()
507    );
508    println!();
509
510    // Check if directory already exists
511    if templates_dir.exists() {
512        if force {
513            println!(
514                "{}Warning: {}Directory already exists. Overwriting...{}",
515                colors.yellow(),
516                colors.reset(),
517                colors.reset()
518            );
519        } else {
520            println!(
521                "{}Error: {}Directory already exists. Use --force to overwrite.{}",
522                colors.red(),
523                colors.reset(),
524                colors.reset()
525            );
526            println!();
527            println!("To reinitialize with defaults, run:");
528            println!("  ralph --template-init --force");
529            return Err(anyhow::anyhow!("Templates directory already exists"));
530        }
531    }
532
533    // Create directory structure
534    fs::create_dir_all(&templates_dir)?;
535
536    let shared_dir = templates_dir.join("shared");
537    fs::create_dir_all(&shared_dir)?;
538
539    let reviewer_dir = templates_dir.join("reviewer");
540    fs::create_dir_all(&reviewer_dir)?;
541
542    // Copy all templates from the embedded templates
543    let templates = get_all_templates();
544    let mut copied = 0;
545    let mut skipped = 0;
546
547    for (name, (content, _)) in &templates {
548        let target_path = if name.starts_with("reviewer/") {
549            let parts: Vec<&str> = name.split('/').collect();
550            if parts.len() == 2 {
551                templates_dir
552                    .join("reviewer")
553                    .join(format!("{}.txt", parts[1]))
554            } else {
555                continue;
556            }
557        } else {
558            templates_dir.join(format!("{name}.txt"))
559        };
560
561        // Skip if file exists and not forcing
562        if target_path.exists() && !force {
563            skipped += 1;
564            continue;
565        }
566
567        fs::write(&target_path, content)?;
568        copied += 1;
569    }
570
571    // Copy shared partials
572    let partials = get_shared_partials();
573    for (name, content) in &partials {
574        let target_path = templates_dir.join(format!("{name}.txt"));
575        if target_path.exists() && !force {
576            skipped += 1;
577            continue;
578        }
579        fs::write(&target_path, content)?;
580        copied += 1;
581    }
582
583    println!(
584        "{}Successfully initialized user templates!{}",
585        colors.green(),
586        colors.reset()
587    );
588    println!();
589    println!("  {copied} templates copied");
590    if skipped > 0 {
591        println!("  {skipped} templates skipped (already exists)");
592    }
593    println!();
594    println!("You can now edit templates in:");
595    println!("  {}", templates_dir.display());
596    println!();
597    println!("Changes to user templates will override the built-in templates.");
598
599    Ok(())
600}
601
602/// Handle all template commands.
603pub fn handle_template_commands(commands: &TemplateCommands, colors: Colors) -> anyhow::Result<()> {
604    if commands.init_templates_enabled() {
605        handle_template_init(commands.force, colors)?;
606    } else if commands.validate {
607        handle_template_validate(colors);
608    } else if let Some(ref name) = commands.show {
609        handle_template_show(name, colors)?;
610    } else if commands.list {
611        handle_template_list(colors);
612    } else if commands.list_all {
613        handle_template_list_all(colors);
614    } else if let Some(ref name) = commands.variables {
615        handle_template_variables(name, colors)?;
616    } else if let Some(ref name) = commands.render {
617        handle_template_render(name, colors)?;
618    }
619
620    Ok(())
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626
627    #[test]
628    fn test_get_all_templates_not_empty() {
629        let templates = get_all_templates();
630        assert!(!templates.is_empty());
631        assert!(templates.contains_key("developer_iteration_xml"));
632        assert!(templates.contains_key("commit_message_xml"));
633    }
634
635    #[test]
636    fn test_template_show_valid() {
637        let colors = Colors::new();
638        let result = handle_template_show("developer_iteration_xml", colors);
639        assert!(result.is_ok());
640    }
641
642    #[test]
643    fn test_template_show_invalid() {
644        let colors = Colors::new();
645        let result = handle_template_show("nonexistent", colors);
646        assert!(result.is_err());
647    }
648
649    #[test]
650    fn test_template_variables() {
651        let colors = Colors::new();
652        let result = handle_template_variables("developer_iteration_xml", colors);
653        assert!(result.is_ok());
654    }
655}