ggen_cli_lib/cmds/template/
list.rs1use clap::Args;
2use ggen_utils::error::Result;
3use glob::glob;
4use std::fs;
5use std::path::Path;
6
7#[derive(Args, Debug)]
8pub struct ListArgs {
9 #[arg(long)]
11 pub pattern: Option<String>,
12
13 #[arg(long)]
15 pub local: bool,
16
17 #[arg(long)]
19 pub gpack: bool,
20}
21
22#[cfg_attr(test, mockall::automock)]
23pub trait TemplateLister {
24 fn list_templates(&self, filters: &ListFilters) -> Result<Vec<TemplateInfo>>;
25}
26
27#[derive(Debug, Clone)]
28pub struct ListFilters {
29 pub pattern: Option<String>,
30 pub local_only: bool,
31 pub gpack_only: bool,
32}
33
34#[derive(Debug, Clone)]
35pub struct TemplateInfo {
36 pub name: String,
37 pub path: String,
38 pub source: TemplateSource,
39 pub description: Option<String>,
40}
41
42#[derive(Debug, Clone, PartialEq)]
43pub enum TemplateSource {
44 Local,
45 Gpack(String),
46}
47
48fn validate_pattern(pattern: &Option<String>) -> Result<()> {
50 if let Some(pattern) = pattern {
51 if pattern.trim().is_empty() {
53 return Err(ggen_utils::error::Error::new("Pattern cannot be empty"));
54 }
55
56 if pattern.len() > 200 {
58 return Err(ggen_utils::error::Error::new(
59 "Pattern too long (max 200 characters)",
60 ));
61 }
62
63 if pattern.contains("..") {
65 return Err(ggen_utils::error::Error::new(
66 "Path traversal detected: pattern cannot contain '..'",
67 ));
68 }
69
70 if !pattern.chars().all(|c| {
72 c.is_alphanumeric()
73 || c == '.'
74 || c == '*'
75 || c == '?'
76 || c == '['
77 || c == ']'
78 || c == '-'
79 || c == '_'
80 }) {
81 return Err(ggen_utils::error::Error::new(
82 "Invalid pattern format: only alphanumeric characters, dots, wildcards, brackets, dashes, and underscores allowed",
83 ));
84 }
85 }
86
87 Ok(())
88}
89
90pub async fn run(args: &ListArgs) -> Result<()> {
91 validate_pattern(&args.pattern)?;
93
94 println!("đ Listing templates...");
95
96 let filters = ListFilters {
97 pattern: args.pattern.clone(),
98 local_only: args.local,
99 gpack_only: args.gpack,
100 };
101
102 let templates = list_local_templates(&filters)?;
103
104 if templates.is_empty() {
105 println!("âšī¸ No templates found");
106 return Ok(());
107 }
108
109 println!("đ Available Templates:");
110 for template in templates {
111 match template.source {
112 TemplateSource::Local => {
113 println!(" đ {} (local)", template.name);
114 }
115 TemplateSource::Gpack(ref gpack_id) => {
116 println!(" đĻ {} ({})", template.name, gpack_id);
117 }
118 }
119 if let Some(desc) = template.description {
120 println!(" {}", desc);
121 }
122 }
123
124 Ok(())
125}
126
127fn list_local_templates(filters: &ListFilters) -> Result<Vec<TemplateInfo>> {
129 let mut templates = Vec::new();
130
131 let templates_dir = Path::new("templates");
133 if !templates_dir.exists() {
134 return Ok(templates);
135 }
136
137 let pattern = if let Some(ref filter_pattern) = filters.pattern {
139 format!("templates/{}", filter_pattern)
140 } else {
141 "templates/*.tmpl".to_string()
142 };
143
144 for entry in glob(&pattern)
146 .map_err(|e| ggen_utils::error::Error::new(&format!("Invalid glob pattern: {}", e)))?
147 {
148 let path = entry.map_err(|e| {
149 ggen_utils::error::Error::new(&format!("Error reading directory entry: {}", e))
150 })?;
151
152 if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("tmpl") {
153 let name = path
154 .file_name()
155 .and_then(|n| n.to_str())
156 .unwrap_or("unknown")
157 .to_string();
158
159 let description = extract_template_description(&path).ok();
161
162 templates.push(TemplateInfo {
163 name,
164 path: path.to_string_lossy().to_string(),
165 source: TemplateSource::Local,
166 description: description.flatten(),
167 });
168 }
169 }
170
171 Ok(templates)
172}
173
174fn extract_template_description(path: &Path) -> Result<Option<String>> {
176 let content = fs::read_to_string(path)
177 .map_err(|e| ggen_utils::error::Error::new(&format!("Failed to read template: {}", e)))?;
178
179 if content.starts_with("---\n") {
181 if let Some(end_pos) = content.find("\n---\n") {
182 let frontmatter = &content[4..end_pos];
183
184 for line in frontmatter.lines() {
186 if line.trim().starts_with("description:") {
187 if let Some(desc) = line.split_once(':').map(|x| x.1) {
188 return Ok(Some(desc.trim().trim_matches('"').to_string()));
189 }
190 }
191 }
192 }
193 }
194
195 Ok(None)
196}
197
198pub async fn run_with_deps(args: &ListArgs, lister: &dyn TemplateLister) -> Result<()> {
199 validate_pattern(&args.pattern)?;
201
202 println!("đ Listing templates...");
204
205 let filters = ListFilters {
206 pattern: args.pattern.clone(),
207 local_only: args.local,
208 gpack_only: args.gpack,
209 };
210
211 let templates = lister.list_templates(&filters)?;
212
213 if templates.is_empty() {
214 println!("âšī¸ No templates found");
215 return Ok(());
216 }
217
218 if templates.len() > 20 {
220 println!("đ Processing {} templates...", templates.len());
221 }
222
223 println!("đ Available Templates:");
224 for template in templates {
225 match template.source {
226 TemplateSource::Local => {
227 println!(" đ {} (local)", template.name);
228 }
229 TemplateSource::Gpack(ref gpack_id) => {
230 println!(" đĻ {} ({})", template.name, gpack_id);
231 }
232 }
233 if let Some(desc) = template.description {
234 println!(" {}", desc);
235 }
236 }
237
238 Ok(())
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[tokio::test]
246 async fn test_list_displays_templates() {
247 let mut mock_lister = MockTemplateLister::new();
248 mock_lister.expect_list_templates().times(1).returning(|_| {
249 Ok(vec![
250 TemplateInfo {
251 name: "hello.tmpl".to_string(),
252 path: "templates/hello.tmpl".to_string(),
253 source: TemplateSource::Local,
254 description: Some("Hello world template".to_string()),
255 },
256 TemplateInfo {
257 name: "rust-cli".to_string(),
258 path: "gpacks/io.ggen.rust.cli/template.tmpl".to_string(),
259 source: TemplateSource::Gpack("io.ggen.rust.cli".to_string()),
260 description: None,
261 },
262 ])
263 });
264
265 let args = ListArgs {
266 pattern: None,
267 local: false,
268 gpack: false,
269 };
270
271 let result = run_with_deps(&args, &mock_lister).await;
272 assert!(result.is_ok());
273 }
274
275 #[tokio::test]
276 async fn test_list_with_pattern_filter() {
277 let mut mock_lister = MockTemplateLister::new();
278 mock_lister
279 .expect_list_templates()
280 .withf(|filters| filters.pattern == Some("rust*".to_string()))
281 .times(1)
282 .returning(|_| Ok(vec![]));
283
284 let args = ListArgs {
285 pattern: Some("rust*".to_string()),
286 local: false,
287 gpack: false,
288 };
289
290 let result = run_with_deps(&args, &mock_lister).await;
291 assert!(result.is_ok());
292 }
293
294 #[tokio::test]
295 async fn test_list_empty() {
296 let mut mock_lister = MockTemplateLister::new();
297 mock_lister
298 .expect_list_templates()
299 .times(1)
300 .returning(|_| Ok(vec![]));
301
302 let args = ListArgs {
303 pattern: None,
304 local: false,
305 gpack: false,
306 };
307
308 let result = run_with_deps(&args, &mock_lister).await;
309 assert!(result.is_ok());
310 }
311}