ggen_cli_lib/cmds/template/
list.rs

1use 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    /// Filter by pattern (glob)
10    #[arg(long)]
11    pub pattern: Option<String>,
12
13    /// Show only local templates
14    #[arg(long)]
15    pub local: bool,
16
17    /// Show only gpack templates
18    #[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
48/// Validate and sanitize pattern input (if provided)
49fn validate_pattern(pattern: &Option<String>) -> Result<()> {
50    if let Some(pattern) = pattern {
51        // Validate pattern is not empty
52        if pattern.trim().is_empty() {
53            return Err(ggen_utils::error::Error::new("Pattern cannot be empty"));
54        }
55
56        // Validate pattern length
57        if pattern.len() > 200 {
58            return Err(ggen_utils::error::Error::new(
59                "Pattern too long (max 200 characters)",
60            ));
61        }
62
63        // Basic path traversal protection
64        if pattern.contains("..") {
65            return Err(ggen_utils::error::Error::new(
66                "Path traversal detected: pattern cannot contain '..'",
67            ));
68        }
69
70        // Validate pattern format (basic pattern check)
71        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 input
92    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
127/// List local templates from the templates directory
128fn list_local_templates(filters: &ListFilters) -> Result<Vec<TemplateInfo>> {
129    let mut templates = Vec::new();
130
131    // Check if templates directory exists
132    let templates_dir = Path::new("templates");
133    if !templates_dir.exists() {
134        return Ok(templates);
135    }
136
137    // Build glob pattern
138    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    // Find template files
145    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            // Extract description from template content
160            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
174/// Extract description from template frontmatter
175fn 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    // Look for YAML frontmatter
180    if content.starts_with("---\n") {
181        if let Some(end_pos) = content.find("\n---\n") {
182            let frontmatter = &content[4..end_pos];
183
184            // Simple extraction of description field
185            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 input
200    validate_pattern(&args.pattern)?;
201
202    // Show progress for listing operation
203    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    // Show progress for large result sets
219    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}