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,
18 Show {
20 name: String,
22 },
23 Export {
25 name: Option<String>,
27 },
28 Create {
30 path: String,
32 },
33 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}