Skip to main content

ward/cli/
template_cmd.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result, bail};
4use console::style;
5
6use crate::config::templates::TemplateAssets;
7
8#[derive(clap::Args)]
9pub struct TemplateCommand {
10    #[command(subcommand)]
11    pub action: TemplateAction,
12}
13
14#[derive(clap::Subcommand)]
15pub enum TemplateAction {
16    /// List all available templates
17    List,
18    /// Show template content
19    Show {
20        /// Template name (e.g., codeql/gradle.yml.tera)
21        name: String,
22    },
23    /// Export embedded templates to custom directory for editing
24    Export {
25        /// Template name to export (exports all if omitted)
26        name: Option<String>,
27    },
28    /// Create a new custom template
29    Create {
30        /// Template path relative to templates dir (e.g., custom/my-workflow.yml.tera)
31        path: String,
32    },
33    /// Show custom templates directory
34    Dir,
35}
36
37impl TemplateCommand {
38    pub fn run(self, _config_override: Option<&str>) -> Result<()> {
39        match self.action {
40            TemplateAction::List => run_list(),
41            TemplateAction::Show { name } => run_show(&name),
42            TemplateAction::Export { name } => {
43                let dir = templates_dir()?;
44                match name {
45                    Some(n) => export_single(&n, &dir),
46                    None => export_all(&dir),
47                }
48            }
49            TemplateAction::Create { path } => run_create(&path),
50            TemplateAction::Dir => run_dir(),
51        }
52    }
53}
54
55fn templates_dir() -> Result<PathBuf> {
56    let home = std::env::var("HOME").context("HOME environment variable not set")?;
57    Ok(PathBuf::from(home).join(".ward").join("templates"))
58}
59
60fn embedded_templates() -> Vec<String> {
61    let mut names: Vec<String> = TemplateAssets::iter().map(|f| f.to_string()).collect();
62    names.sort();
63    names
64}
65
66fn custom_templates(dir: &Path) -> Vec<String> {
67    if !dir.is_dir() {
68        return Vec::new();
69    }
70    let mut results = Vec::new();
71    collect_custom(dir, dir, &mut results);
72    results.sort();
73    results
74}
75
76fn collect_custom(base: &Path, current: &Path, out: &mut Vec<String>) {
77    let Ok(entries) = std::fs::read_dir(current) else {
78        return;
79    };
80    for entry in entries.flatten() {
81        let path = entry.path();
82        if path.is_dir() {
83            collect_custom(base, &path, out);
84        } else if path.is_file() {
85            if let Ok(rel) = path.strip_prefix(base) {
86                out.push(rel.to_string_lossy().to_string());
87            }
88        }
89    }
90}
91
92fn category_of(name: &str) -> &str {
93    name.split('/').next().unwrap_or("other")
94}
95
96fn run_list() -> Result<()> {
97    let embedded = embedded_templates();
98    let dir = templates_dir()?;
99    let custom = custom_templates(&dir);
100
101    let custom_set: std::collections::HashSet<&str> = custom.iter().map(|s| s.as_str()).collect();
102    let embedded_set: std::collections::HashSet<&str> =
103        embedded.iter().map(|s| s.as_str()).collect();
104
105    let mut all: Vec<(&str, &str)> = Vec::new();
106
107    for name in &embedded {
108        if custom_set.contains(name.as_str()) {
109            all.push((name, "override"));
110        } else {
111            all.push((name, "built-in"));
112        }
113    }
114
115    for name in &custom {
116        if !embedded_set.contains(name.as_str()) {
117            all.push((name, "custom"));
118        }
119    }
120
121    all.sort_by(|a, b| a.0.cmp(b.0));
122
123    let mut current_category = "";
124    for (name, source) in &all {
125        let cat = category_of(name);
126        if cat != current_category {
127            println!();
128            println!("  {}", style(cat).bold());
129            current_category = cat;
130        }
131        let tag = match *source {
132            "built-in" => style("[built-in]").dim(),
133            "custom" => style("[custom]").green(),
134            "override" => style("[override]").yellow(),
135            _ => style("[unknown]").dim(),
136        };
137        println!("    {name}  {tag}");
138    }
139    println!();
140    Ok(())
141}
142
143fn run_show(name: &str) -> Result<()> {
144    let dir = templates_dir()?;
145    let custom_path = dir.join(name);
146
147    if custom_path.is_file() {
148        let content = std::fs::read_to_string(&custom_path)
149            .with_context(|| format!("Failed to read {}", custom_path.display()))?;
150        print!("{content}");
151        return Ok(());
152    }
153
154    match TemplateAssets::get(name) {
155        Some(file) => {
156            let content = std::str::from_utf8(file.data.as_ref())
157                .context("Template content is not valid UTF-8")?;
158            print!("{content}");
159            Ok(())
160        }
161        None => bail!("Template '{name}' not found"),
162    }
163}
164
165pub fn export_single(name: &str, dest_dir: &Path) -> Result<()> {
166    let file = TemplateAssets::get(name)
167        .ok_or_else(|| anyhow::anyhow!("Embedded template '{name}' not found"))?;
168
169    let dest = dest_dir.join(name);
170    if dest.exists() {
171        println!(
172            "  {} Skipping {} (custom file already exists)",
173            style("[..]").yellow(),
174            name,
175        );
176        return Ok(());
177    }
178
179    if let Some(parent) = dest.parent() {
180        std::fs::create_dir_all(parent)
181            .with_context(|| format!("Failed to create {}", parent.display()))?;
182    }
183
184    let content =
185        std::str::from_utf8(file.data.as_ref()).context("Template content is not valid UTF-8")?;
186    std::fs::write(&dest, content)
187        .with_context(|| format!("Failed to write {}", dest.display()))?;
188
189    println!("  {} {}", style("[ok]").green(), dest.display());
190    Ok(())
191}
192
193pub fn export_all(dest_dir: &Path) -> Result<()> {
194    let names = embedded_templates();
195    let mut exported = 0usize;
196    let mut skipped = 0usize;
197
198    for name in &names {
199        let dest = dest_dir.join(name);
200        if dest.exists() {
201            println!(
202                "  {} Skipping {} (already exists)",
203                style("[..]").yellow(),
204                name,
205            );
206            skipped += 1;
207            continue;
208        }
209
210        let file = TemplateAssets::get(name).unwrap();
211        if let Some(parent) = dest.parent() {
212            std::fs::create_dir_all(parent)?;
213        }
214        let content = std::str::from_utf8(file.data.as_ref())?;
215        std::fs::write(&dest, content)?;
216        println!("  {} {}", style("[ok]").green(), dest.display());
217        exported += 1;
218    }
219
220    println!();
221    println!("  Exported {exported} template(s), skipped {skipped}.",);
222    Ok(())
223}
224
225fn run_create(template_path: &str) -> Result<()> {
226    let dir = templates_dir()?;
227    let dest = dir.join(template_path);
228
229    if dest.exists() {
230        bail!("Template already exists at {}", dest.display());
231    }
232
233    if let Some(parent) = dest.parent() {
234        std::fs::create_dir_all(parent)
235            .with_context(|| format!("Failed to create {}", parent.display()))?;
236    }
237
238    std::fs::write(&dest, "").with_context(|| format!("Failed to create {}", dest.display()))?;
239
240    let editor = std::env::var("EDITOR")
241        .or_else(|_| std::env::var("VISUAL"))
242        .unwrap_or_else(|_| "vi".to_owned());
243
244    let _ = std::process::Command::new(&editor).arg(&dest).status();
245
246    println!(
247        "  {} Created template at {}",
248        style("[ok]").green(),
249        dest.display(),
250    );
251    Ok(())
252}
253
254fn run_dir() -> Result<()> {
255    let dir = templates_dir()?;
256
257    if !dir.exists() {
258        std::fs::create_dir_all(&dir)
259            .with_context(|| format!("Failed to create {}", dir.display()))?;
260        println!("{}", dir.display());
261        println!("  {} Directory created (empty).", style("[ok]").green());
262        return Ok(());
263    }
264
265    let custom = custom_templates(&dir);
266    println!("{}", dir.display());
267    if custom.is_empty() {
268        println!("  {} No custom templates.", style("[..]").dim());
269    } else {
270        println!(
271            "  {} {} custom template(s).",
272            style("[ok]").green(),
273            custom.len(),
274        );
275    }
276    Ok(())
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use tempfile::TempDir;
283
284    #[test]
285    fn test_list_embedded_templates() {
286        let templates = embedded_templates();
287        assert!(
288            !templates.is_empty(),
289            "Should have at least one embedded template"
290        );
291        assert!(
292            templates.iter().any(|t| t.contains("codeql")),
293            "Should include codeql templates"
294        );
295        assert!(
296            templates.iter().any(|t| t.contains("dependabot")),
297            "Should include dependabot templates"
298        );
299    }
300
301    #[test]
302    fn test_export_single_template() {
303        let dir = TempDir::new().unwrap();
304        let templates = embedded_templates();
305        let name = &templates[0];
306
307        export_single(name, dir.path()).unwrap();
308
309        let exported = dir.path().join(name);
310        assert!(exported.exists(), "Exported file should exist");
311
312        let content = std::fs::read_to_string(&exported).unwrap();
313        let embedded = TemplateAssets::get(name).unwrap();
314        let expected = std::str::from_utf8(embedded.data.as_ref()).unwrap();
315        assert_eq!(content, expected, "Content should match embedded template");
316    }
317
318    #[test]
319    fn test_export_all_templates() {
320        let dir = TempDir::new().unwrap();
321        export_all(dir.path()).unwrap();
322
323        let expected_count = embedded_templates().len();
324        let exported = custom_templates(dir.path());
325        assert_eq!(
326            exported.len(),
327            expected_count,
328            "All embedded templates should be exported"
329        );
330    }
331
332    #[test]
333    fn test_export_skip_existing() {
334        let dir = TempDir::new().unwrap();
335        let templates = embedded_templates();
336        let name = &templates[0];
337
338        export_single(name, dir.path()).unwrap();
339
340        let path = dir.path().join(name);
341        std::fs::write(&path, "custom content").unwrap();
342
343        export_single(name, dir.path()).unwrap();
344
345        let content = std::fs::read_to_string(&path).unwrap();
346        assert_eq!(
347            content, "custom content",
348            "Should not overwrite existing file"
349        );
350    }
351
352    #[test]
353    fn test_create_template() {
354        let dir = TempDir::new().unwrap();
355        let dest = dir.path().join("custom").join("test.yml.tera");
356
357        assert!(!dest.exists());
358
359        if let Some(parent) = dest.parent() {
360            std::fs::create_dir_all(parent).unwrap();
361        }
362        std::fs::write(&dest, "").unwrap();
363
364        assert!(dest.exists(), "Template file should be created");
365    }
366
367    #[test]
368    fn test_embedded_template_content_is_valid_utf8() {
369        for name in embedded_templates() {
370            let file = TemplateAssets::get(&name).unwrap();
371            assert!(
372                std::str::from_utf8(file.data.as_ref()).is_ok(),
373                "Template '{name}' should be valid UTF-8"
374            );
375        }
376    }
377
378    #[test]
379    fn test_category_of_extracts_first_segment() {
380        assert_eq!(category_of("codeql/gradle.yml.tera"), "codeql");
381        assert_eq!(category_of("dependabot/npm.yml.tera"), "dependabot");
382        assert_eq!(category_of("standalone.yml"), "standalone.yml");
383    }
384}