tsk/assets/
layered.rs

1use anyhow::Result;
2use async_trait::async_trait;
3use std::collections::HashSet;
4use std::path::Path;
5use std::sync::Arc;
6
7use crate::assets::{
8    AssetManager, embedded::EmbeddedAssetManager, filesystem::FileSystemAssetManager,
9};
10use crate::storage::xdg::XdgDirectories;
11
12/// A layered implementation of AssetManager that checks multiple sources in priority order
13pub struct LayeredAssetManager {
14    layers: Vec<Arc<dyn AssetManager>>,
15}
16
17impl LayeredAssetManager {
18    /// Creates a new LayeredAssetManager with the given layers in priority order
19    pub fn new(layers: Vec<Arc<dyn AssetManager>>) -> Self {
20        Self { layers }
21    }
22
23    /// Creates a LayeredAssetManager with standard layers:
24    /// 1. Project-level templates (.tsk/templates)
25    /// 2. User-level templates (~/.config/tsk/templates)
26    /// 3. Built-in templates (embedded)
27    pub fn new_with_standard_layers(
28        project_root: Option<&Path>,
29        xdg_dirs: &XdgDirectories,
30    ) -> Self {
31        let mut layers: Vec<Arc<dyn AssetManager>> = Vec::new();
32
33        // Project layer (highest priority)
34        if let Some(root) = project_root {
35            let project_tsk_dir = root.join(".tsk");
36            if project_tsk_dir.exists() {
37                layers.push(Arc::new(FileSystemAssetManager::new(project_tsk_dir)));
38            }
39        }
40
41        // User layer
42        let user_templates_dir = xdg_dirs.config_dir().join("templates");
43        if user_templates_dir.exists() {
44            layers.push(Arc::new(FileSystemAssetManager::new(user_templates_dir)));
45        }
46
47        // Built-in layer (lowest priority)
48        layers.push(Arc::new(EmbeddedAssetManager));
49
50        Self::new(layers)
51    }
52}
53
54#[async_trait]
55impl AssetManager for LayeredAssetManager {
56    fn get_template(&self, template_type: &str) -> Result<String> {
57        for layer in &self.layers {
58            match layer.get_template(template_type) {
59                Ok(template) => return Ok(template),
60                Err(_) => continue,
61            }
62        }
63
64        Err(anyhow::anyhow!(
65            "Template '{}' not found in any layer",
66            template_type
67        ))
68    }
69
70    fn get_dockerfile(&self, dockerfile_name: &str) -> Result<Vec<u8>> {
71        for layer in &self.layers {
72            match layer.get_dockerfile(dockerfile_name) {
73                Ok(content) => return Ok(content),
74                Err(_) => continue,
75            }
76        }
77
78        Err(anyhow::anyhow!(
79            "Dockerfile '{}' not found",
80            dockerfile_name
81        ))
82    }
83
84    fn get_dockerfile_file(&self, dockerfile_name: &str, file_path: &str) -> Result<Vec<u8>> {
85        for layer in &self.layers {
86            match layer.get_dockerfile_file(dockerfile_name, file_path) {
87                Ok(content) => return Ok(content),
88                Err(_) => continue,
89            }
90        }
91
92        Err(anyhow::anyhow!(
93            "Dockerfile file '{}/{}' not found",
94            dockerfile_name,
95            file_path
96        ))
97    }
98
99    fn list_templates(&self) -> Vec<String> {
100        let mut templates = HashSet::new();
101
102        for layer in &self.layers {
103            for template in layer.list_templates() {
104                templates.insert(template);
105            }
106        }
107
108        let mut result: Vec<String> = templates.into_iter().collect();
109        result.sort();
110        result
111    }
112
113    fn list_dockerfiles(&self) -> Vec<String> {
114        let mut dockerfiles = HashSet::new();
115
116        for layer in &self.layers {
117            for dockerfile in layer.list_dockerfiles() {
118                dockerfiles.insert(dockerfile);
119            }
120        }
121
122        let mut result: Vec<String> = dockerfiles.into_iter().collect();
123        result.sort();
124        result
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::assets::{
132        AssetManager, filesystem::FileSystemAssetManager, layered::LayeredAssetManager,
133    };
134    use crate::context::AppContext;
135    use crate::storage::XdgDirectories;
136    use std::fs;
137    use tempfile::TempDir;
138
139    struct MockAssetManager {
140        templates: Vec<String>,
141        template_content: String,
142    }
143
144    impl MockAssetManager {
145        fn new(templates: Vec<String>, template_content: String) -> Self {
146            Self {
147                templates,
148                template_content,
149            }
150        }
151    }
152
153    #[async_trait]
154    impl AssetManager for MockAssetManager {
155        fn get_template(&self, template_type: &str) -> Result<String> {
156            if self.templates.contains(&template_type.to_string()) {
157                Ok(self.template_content.clone())
158            } else {
159                Err(anyhow::anyhow!("Template not found"))
160            }
161        }
162
163        fn get_dockerfile(&self, _dockerfile_name: &str) -> Result<Vec<u8>> {
164            Err(anyhow::anyhow!("Not implemented"))
165        }
166
167        fn get_dockerfile_file(&self, _dockerfile_name: &str, _file_path: &str) -> Result<Vec<u8>> {
168            Err(anyhow::anyhow!("Not implemented"))
169        }
170
171        fn list_templates(&self) -> Vec<String> {
172            self.templates.clone()
173        }
174
175        fn list_dockerfiles(&self) -> Vec<String> {
176            Vec::new()
177        }
178    }
179
180    #[test]
181    fn test_layered_get_template_priority() {
182        let layer1 = Arc::new(MockAssetManager::new(
183            vec!["feature".to_string()],
184            "Layer 1 content".to_string(),
185        ));
186        let layer2 = Arc::new(MockAssetManager::new(
187            vec!["feature".to_string(), "fix".to_string()],
188            "Layer 2 content".to_string(),
189        ));
190
191        let manager = LayeredAssetManager::new(vec![layer1, layer2]);
192
193        // Should get from first layer
194        assert_eq!(manager.get_template("feature").unwrap(), "Layer 1 content");
195
196        // Should get from second layer since first doesn't have it
197        assert_eq!(manager.get_template("fix").unwrap(), "Layer 2 content");
198
199        // Should fail if not in any layer
200        assert!(manager.get_template("nonexistent").is_err());
201    }
202
203    #[test]
204    fn test_layered_list_templates_aggregation() {
205        let layer1 = Arc::new(MockAssetManager::new(
206            vec!["feature".to_string(), "fix".to_string()],
207            "content".to_string(),
208        ));
209        let layer2 = Arc::new(MockAssetManager::new(
210            vec!["fix".to_string(), "doc".to_string()],
211            "content".to_string(),
212        ));
213
214        let manager = LayeredAssetManager::new(vec![layer1, layer2]);
215        let templates = manager.list_templates();
216
217        assert_eq!(templates, vec!["doc", "feature", "fix"]);
218    }
219
220    #[test]
221    fn test_standard_layers_with_project() {
222        let temp_dir = TempDir::new().unwrap();
223
224        // Create project templates directory
225        let project_templates = temp_dir.path().join(".tsk").join("templates");
226        fs::create_dir_all(&project_templates).unwrap();
227        fs::write(project_templates.join("feature.md"), "Project template").unwrap();
228
229        // Create mock XDG directories
230        let config_dir = temp_dir.path().join("config");
231        fs::create_dir_all(&config_dir).unwrap();
232
233        let xdg_dirs = XdgDirectories::new_with_paths(
234            temp_dir.path().to_path_buf(),
235            temp_dir.path().to_path_buf(),
236            config_dir,
237            temp_dir.path().to_path_buf(),
238        );
239
240        let manager =
241            LayeredAssetManager::new_with_standard_layers(Some(temp_dir.path()), &xdg_dirs);
242
243        // Should get project template
244        assert_eq!(manager.get_template("feature").unwrap(), "Project template");
245    }
246
247    #[test]
248    fn test_layered_template_resolution_priority() {
249        // Create temporary directories
250        let temp_dir = TempDir::new().unwrap();
251
252        // Create project directory with .tsk/templates
253        let project_dir = temp_dir.path().join("project");
254        let project_templates = project_dir.join(".tsk").join("templates");
255        fs::create_dir_all(&project_templates).unwrap();
256        fs::write(project_templates.join("feat.md"), "Project feat template").unwrap();
257        fs::write(project_templates.join("fix.md"), "Project fix template").unwrap();
258
259        // Create user config directory
260        let config_dir = temp_dir.path().join("config");
261        let user_templates = config_dir.join("templates");
262        fs::create_dir_all(&user_templates).unwrap();
263        fs::write(user_templates.join("feat.md"), "User feat template").unwrap();
264        fs::write(user_templates.join("doc.md"), "User doc template").unwrap();
265
266        // Create XDG directories
267        let xdg_dirs = XdgDirectories::new_with_paths(
268            temp_dir.path().join("data"),
269            temp_dir.path().join("runtime"),
270            config_dir,
271            temp_dir.path().join("cache"),
272        );
273
274        // Create layered asset manager
275        let manager = LayeredAssetManager::new_with_standard_layers(Some(&project_dir), &xdg_dirs);
276
277        // Test priority: project > user > built-in
278        assert_eq!(
279            manager.get_template("feat").unwrap(),
280            "Project feat template"
281        );
282        assert_eq!(manager.get_template("fix").unwrap(), "Project fix template");
283        assert_eq!(manager.get_template("doc").unwrap(), "User doc template");
284
285        // Test built-in fallback (refactor template should be built-in only)
286        assert!(
287            manager
288                .get_template("refactor")
289                .unwrap()
290                .contains("{{DESCRIPTION}}")
291        );
292
293        // Test listing all templates
294        let all_templates = manager.list_templates();
295        assert!(all_templates.contains(&"feat".to_string()));
296        assert!(all_templates.contains(&"fix".to_string()));
297        assert!(all_templates.contains(&"doc".to_string()));
298        assert!(all_templates.contains(&"refactor".to_string()));
299    }
300
301    #[test]
302    fn test_filesystem_asset_manager_with_missing_directory() {
303        let temp_dir = TempDir::new().unwrap();
304        let nonexistent_dir = temp_dir.path().join("nonexistent");
305
306        let manager = FileSystemAssetManager::new(nonexistent_dir);
307
308        // Should return error for missing templates
309        assert!(manager.get_template("feat").is_err());
310
311        // Should return empty list
312        assert!(manager.list_templates().is_empty());
313    }
314
315    #[test]
316    fn test_template_content_validation() {
317        let temp_dir = TempDir::new().unwrap();
318        let templates_dir = temp_dir.path().join("templates");
319        fs::create_dir_all(&templates_dir).unwrap();
320
321        // Create a valid template with placeholder
322        let valid_content =
323            "# Feature Template\n\n{{DESCRIPTION}}\n\n## Best Practices\n- Test your code";
324        fs::write(templates_dir.join("valid.md"), valid_content).unwrap();
325
326        let manager = FileSystemAssetManager::new(templates_dir);
327        let template = manager.get_template("valid").unwrap();
328
329        // Verify template contains expected placeholder
330        assert!(template.contains("{{DESCRIPTION}}"));
331        assert!(template.contains("Best Practices"));
332    }
333
334    #[tokio::test]
335    async fn test_app_context_with_layered_asset_manager() {
336        // Create a temporary git repository
337        let temp_dir = TempDir::new().unwrap();
338        let repo_dir = temp_dir.path().join("repo");
339        fs::create_dir_all(&repo_dir).unwrap();
340
341        // Initialize git repo
342        std::process::Command::new("git")
343            .args(&["init"])
344            .current_dir(&repo_dir)
345            .output()
346            .expect("Failed to init git repo");
347
348        // Create project-level template
349        let project_templates = repo_dir.join(".tsk").join("templates");
350        fs::create_dir_all(&project_templates).unwrap();
351        fs::write(
352            project_templates.join("custom.md"),
353            "Custom project template",
354        )
355        .unwrap();
356
357        // Change to repo directory
358        std::env::set_current_dir(&repo_dir).unwrap();
359
360        // Build AppContext
361        let app_context = AppContext::builder().build();
362
363        // Create asset manager on-demand
364        let asset_manager = LayeredAssetManager::new_with_standard_layers(
365            Some(&repo_dir),
366            &app_context.xdg_directories(),
367        );
368
369        // Verify it can access the custom template
370        let template = asset_manager.get_template("custom");
371        assert!(template.is_ok());
372        assert_eq!(template.unwrap(), "Custom project template");
373
374        // Verify it still has access to built-in templates
375        assert!(asset_manager.get_template("feat").is_ok());
376    }
377
378    #[test]
379    fn test_template_listing_deduplication() {
380        let temp_dir = TempDir::new().unwrap();
381
382        // Create project directory
383        let project_dir = temp_dir.path().join("project");
384        let project_templates = project_dir.join(".tsk").join("templates");
385        fs::create_dir_all(&project_templates).unwrap();
386        fs::write(project_templates.join("feat.md"), "Project feat").unwrap();
387
388        // Create user config directory
389        let config_dir = temp_dir.path().join("config");
390        let user_templates = config_dir.join("templates");
391        fs::create_dir_all(&user_templates).unwrap();
392        fs::write(user_templates.join("feat.md"), "User feat").unwrap();
393        fs::write(user_templates.join("custom.md"), "User custom").unwrap();
394
395        let xdg_dirs = XdgDirectories::new_with_paths(
396            temp_dir.path().join("data"),
397            temp_dir.path().join("runtime"),
398            config_dir,
399            temp_dir.path().join("cache"),
400        );
401
402        let manager = LayeredAssetManager::new_with_standard_layers(Some(&project_dir), &xdg_dirs);
403
404        let templates = manager.list_templates();
405
406        // Should only have one "feat" entry (deduplicated)
407        let feat_count = templates.iter().filter(|t| t == &"feat").count();
408        assert_eq!(feat_count, 1);
409
410        // Should have custom template
411        assert!(templates.contains(&"custom".to_string()));
412    }
413}