1use std::fs;
2use std::path::{Path, PathBuf};
3use anyhow::{Context, Result};
4use indicatif::{ProgressBar, ProgressStyle};
5use crate::config::{Config, Repo, Template};
6use crate::utils::copy_directory;
7use colored::*;
8use dialoguer::Confirm;
9use serde_json;
10
11pub struct TemplateManager {
12 pub config: Config,
13 cache_dir: PathBuf,
14}
15
16impl TemplateManager {
17 pub fn new() -> Result<Self> {
18 let config_path = Self::get_config_path()?;
19 let config = if config_path.exists() {
20 let content = fs::read_to_string(&config_path).context("Failed to read config file")?;
21 serde_json::from_str(&content).context("Failed to parse config file")?
22 } else {
23 Config {
24 repos: vec![],
25 templates: vec![],
26 }
27 };
28
29 let cache_dir = Self::get_cache_dir()?;
30 fs::create_dir_all(&cache_dir).context("Failed to create cache directory")?;
31
32 Ok(Self { config, cache_dir })
33 }
34
35 fn get_config_path() -> Result<PathBuf> {
36 let config_dir = dirs::config_dir()
37 .unwrap_or_else(|| PathBuf::from(".config"))
38 .join("mammoth-cli");
39 fs::create_dir_all(&config_dir).context("Failed to create config directory")?;
40 Ok(config_dir.join("templates.json"))
41 }
42
43 fn get_cache_dir() -> Result<PathBuf> {
44 let cache_dir = dirs::cache_dir()
45 .unwrap_or_else(|| PathBuf::from(".cache"))
46 .join("mammoth-cli")
47 .join("templates");
48 Ok(cache_dir)
49 }
50
51 pub fn save_config(&self) -> Result<()> {
52 let config_path = Self::get_config_path()?;
53 let content =
54 serde_json::to_string_pretty(&self.config).context("Failed to serialize config")?;
55 fs::write(config_path, content).context("Failed to write config file")?;
56 Ok(())
57 }
58
59 pub fn get_template_by_id(&self, id: &str) -> Option<&Template> {
60 self.config.templates.iter().find(|t| t.id == id)
61 }
62
63 pub fn get_repo_by_name(&self, name: &str) -> Option<&Repo> {
64 self.config.repos.iter().find(|r| r.name == name)
65 }
66
67 fn get_template_cache_path(&self, template: &Template) -> PathBuf {
68 self.cache_dir.join(&template.repo).join(&template.id)
69 }
70
71 pub async fn download_template(&self, template: &Template, force: bool) -> Result<()> {
72 let repo = self
73 .get_repo_by_name(&template.repo)
74 .ok_or_else(|| anyhow::anyhow!("Repository '{}' not found", template.repo))?;
75
76 let cache_path = self.get_template_cache_path(template);
77
78 if cache_path.exists() && !force {
79 println!("✨ Template '{}' already cached", template.id);
80 return Ok(());
81 }
82
83 println!("🚀 Downloading template '{}'...", template.id);
84
85 let pb = ProgressBar::new(100);
87 pb.set_style(
88 ProgressStyle::default_bar()
89 .template(
90 "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} {msg}",
91 )
92 .unwrap()
93 .progress_chars("#>-"),
94 );
95
96 let temp_dir = self.cache_dir.join(format!("temp_{}", repo.name));
98
99 self.cleanup_temp_dir(&temp_dir)?;
101 fs::create_dir_all(&temp_dir).context("Failed to create temp dir")?;
102
103 let result = self.download_template_internal(template, repo, &temp_dir, &cache_path, &pb).await;
105
106 if let Err(ref e) = result {
108 eprintln!("❌ Download failed: {}", e);
109 }
110
111 self.cleanup_temp_dir(&temp_dir)?;
113
114 result
115 }
116
117 async fn download_template_internal(
118 &self,
119 template: &Template,
120 repo: &Repo,
121 temp_dir: &Path,
122 cache_path: &Path,
123 pb: &ProgressBar,
124 ) -> Result<()> {
125 pb.set_message("Preparing sparse checkout...");
126 pb.inc(20);
127
128 pb.set_message("Cloning repository...");
130 pb.inc(30);
131
132 let clone_result = tokio::time::timeout(
133 std::time::Duration::from_secs(300), tokio::process::Command::new("git")
135 .args([
136 "clone",
137 "--no-checkout",
138 "--filter=blob:none",
139 "--sparse",
140 &repo.url,
141 &temp_dir.to_string_lossy(),
142 ])
143 .status(),
144 )
145 .await;
146
147 let status = match clone_result {
148 Ok(Ok(status)) => status,
149 Ok(Err(e)) => anyhow::bail!("Failed to clone repository: {}", e),
150 Err(_) => anyhow::bail!("Git clone timed out after 5 minutes"),
151 };
152
153 if !status.success() {
154 anyhow::bail!("Failed to clone repository: {}", repo.url);
155 }
156
157 pb.set_message("Configuring sparse checkout...");
159 pb.inc(40);
160
161 let sparse_result = tokio::time::timeout(
162 std::time::Duration::from_secs(60), tokio::process::Command::new("git")
164 .args(["sparse-checkout", "set", &template.path])
165 .current_dir(temp_dir)
166 .status(),
167 )
168 .await;
169
170 let status = match sparse_result {
171 Ok(Ok(status)) => status,
172 Ok(Err(e)) => anyhow::bail!("Failed to set sparse checkout: {}", e),
173 Err(_) => anyhow::bail!("Sparse checkout timed out"),
174 };
175
176 if !status.success() {
177 anyhow::bail!("Failed to set sparse checkout for path: {}", template.path);
178 }
179
180 pb.set_message("Checking out files...");
182 pb.inc(50);
183
184 let checkout_result = tokio::time::timeout(
185 std::time::Duration::from_secs(120), tokio::process::Command::new("git")
187 .args(["checkout", &repo.branch])
188 .current_dir(temp_dir)
189 .status(),
190 )
191 .await;
192
193 let status = match checkout_result {
194 Ok(Ok(status)) => status,
195 Ok(Err(e)) => anyhow::bail!("Failed to checkout branch: {}", e),
196 Err(_) => anyhow::bail!("Git checkout timed out"),
197 };
198
199 if !status.success() {
200 anyhow::bail!("Failed to checkout branch: {}", repo.branch);
201 }
202
203 fs::create_dir_all(cache_path.parent().unwrap())
205 .context("Failed to create repo cache parent dir")?;
206
207 pb.set_message("Copying template files...");
209 pb.inc(60);
210
211 let template_source = temp_dir.join(&template.path);
212 if !template_source.exists() {
213 anyhow::bail!("Template path '{}' not found in repository", template.path);
214 }
215
216 self.safe_copy_template_files(&template_source, cache_path)?;
218
219 pb.finish_with_message("Template downloaded successfully!");
220 println!(
221 "✅ Template '{}' downloaded to: {}",
222 template.id,
223 cache_path.display()
224 );
225
226 Ok(())
227 }
228
229 fn cleanup_temp_dir(&self, temp_dir: &Path) -> Result<()> {
230 if temp_dir.exists() {
231 for attempt in 1..=3 {
233 match fs::remove_dir_all(temp_dir) {
234 Ok(_) => {
235 if attempt > 1 {
236 println!("✅ Temp directory cleaned on attempt {}", attempt);
237 }
238 return Ok(());
239 }
240 Err(e) => {
241 if attempt == 3 {
242 eprintln!("⚠️ Warning: Failed to remove temp dir after 3 attempts: {}", e);
243 return Err(e.into());
244 }
245 std::thread::sleep(std::time::Duration::from_millis(500));
247 }
248 }
249 }
250 }
251 Ok(())
252 }
253
254 fn safe_copy_template_files(&self, source: &Path, dest: &Path) -> Result<()> {
255 if dest.exists() {
257 for attempt in 1..=3 {
259 match fs::remove_dir_all(dest) {
260 Ok(_) => break,
261 Err(e) => {
262 if attempt == 3 {
263 anyhow::bail!("Failed to remove old cache after 3 attempts: {}", e);
264 }
265 std::thread::sleep(std::time::Duration::from_millis(500));
266 }
267 }
268 }
269 }
270
271 copy_directory(source, dest).context("Failed to copy template files")?;
273
274 Ok(())
275 }
276
277 pub async fn download_all_templates(&self, force: bool) -> Result<()> {
278 println!("🚀 Downloading all templates...");
279
280 for template in &self.config.templates {
281 match self.download_template(template, force).await {
282 Ok(_) => {}
283 Err(e) => {
284 println!("❌ Failed to download template '{}': {}", template.id, e);
285 }
286 }
287 }
288
289 println!("🎉 All templates downloaded!");
290 Ok(())
291 }
292
293 pub fn list_templates(&self, verbose: bool) {
294 if verbose {
295 println!("{}", "📋 Available Templates".bold().blue());
296 } else {
297 println!("{}", "📋 Template List".bold().blue());
298 }
299 println!();
300
301 if self.config.templates.is_empty() {
302 println!("No templates available. Add templates first.");
303 return;
304 }
305
306 for template in &self.config.templates {
307 let cache_path = self.get_template_cache_path(template);
308 let status = if cache_path.exists() {
309 "✅".green()
310 } else {
311 "❌".red()
312 };
313
314 if verbose {
315 println!("{} {} - {}", status, template.id.bold(), template.name);
317 println!(" Description: {}", template.description);
318 println!(" Language: {}", template.language);
319 println!(" Repository: {}", template.repo);
320 println!(" Path: {}", template.path);
321 println!(" Tags: {}", template.tags.join(", "));
322 println!();
323 } else {
324 println!(
326 "{} {} - {} ({})",
327 status,
328 template.id.bold(),
329 template.name,
330 template.language
331 );
332 }
333 }
334
335 if !verbose {
336 println!();
337 println!("💡 Use --verbose to see detailed information");
338 }
339 }
340
341 pub fn add_template(
342 &mut self,
343 id: String,
344 name: String,
345 repo: String,
346 path: String,
347 description: String,
348 language: String,
349 tags: Option<String>,
350 ) -> Result<()> {
351 if !self.config.repos.iter().any(|r| r.name == repo) {
353 anyhow::bail!(
354 "Repository '{}' not found. Add it first with 'repo add'",
355 repo
356 );
357 }
358
359 if self.config.templates.iter().any(|t| t.id == id) {
361 anyhow::bail!("Template with ID '{}' already exists", id);
362 }
363
364 let tags_vec = if let Some(tags_str) = tags {
366 tags_str
367 .split(',')
368 .map(|s| s.trim().to_string())
369 .filter(|s| !s.is_empty())
370 .collect()
371 } else {
372 vec![]
373 };
374
375 let template = Template {
376 id,
377 name,
378 repo,
379 path,
380 description,
381 language,
382 tags: tags_vec,
383 };
384
385 self.config.templates.push(template);
386 self.save_config()?;
387
388 println!("🎉 Template added successfully!");
389 Ok(())
390 }
391
392 pub fn remove_template(&mut self, id: &str) -> Result<()> {
393 let index = self.config.templates.iter().position(|t| t.id == id);
394
395 if let Some(index) = index {
396 self.config.templates.remove(index);
397 self.save_config()?;
398 println!("🗑️ Template '{}' removed successfully!", id);
399 } else {
400 anyhow::bail!("Template '{}' not found", id);
401 }
402
403 Ok(())
404 }
405
406 pub fn add_repo(&mut self, name: String, url: String, branch: String) -> Result<()> {
407 if self.config.repos.iter().any(|r| r.name == name) {
409 anyhow::bail!("Repository '{}' already exists", name);
410 }
411
412 let repo = Repo { name, url, branch };
413 self.config.repos.push(repo);
414 self.save_config()?;
415
416 println!("🎉 Repository added successfully!");
417 Ok(())
418 }
419
420 pub fn remove_repo(&mut self, name: &str) -> Result<()> {
421 if self.config.templates.iter().any(|t| t.repo == name) {
423 anyhow::bail!(
424 "Cannot remove repository '{}' - it is used by templates",
425 name
426 );
427 }
428
429 let index = self.config.repos.iter().position(|r| r.name == name);
430
431 if let Some(index) = index {
432 self.config.repos.remove(index);
433 self.save_config()?;
434 println!("🗑️ Repository '{}' removed successfully!", name);
435 } else {
436 anyhow::bail!("Repository '{}' not found", name);
437 }
438
439 Ok(())
440 }
441
442 pub fn copy_template_files(&self, template: &Template, project_path: &Path) -> Result<()> {
443 let cache_path = self.get_template_cache_path(template);
444
445 if !cache_path.exists() {
446 anyhow::bail!(
447 "Template '{}' not cached. Run 'template download {}' first",
448 template.id,
449 template.id
450 );
451 }
452
453 copy_directory(&cache_path, project_path)?;
454 Ok(())
455 }
456
457 pub fn list_repos(&self) {
458 println!("{}", "📦 Configured Template Repositories".bold().blue());
459 println!();
460 if self.config.repos.is_empty() {
461 println!("No repositories configured. Add repositories first.");
462 return;
463 }
464 for repo in &self.config.repos {
465 println!("{} - {}", repo.name.bold(), repo.url);
466 println!(" 🪐Branch: {}", repo.branch);
467 println!();
468 }
469 }
470
471 pub fn export_config(&self, output: &str, include_cache: bool) -> Result<()> {
472 println!("📤 Exporting configuration to: {}", output);
473
474 let export_config = Config {
475 repos: self.config.repos.clone(),
476 templates: self.config.templates.clone(),
477 };
478
479 if include_cache {
481 println!("📦 Including cache information...");
482 }
484
485 let content = serde_json::to_string_pretty(&export_config)
486 .context("Failed to serialize configuration")?;
487
488 fs::write(output, content)
489 .with_context(|| format!("Failed to write configuration to: {}", output))?;
490
491 println!("✅ Configuration exported successfully!");
492 println!(
493 "📊 Exported {} repositories and {} templates",
494 export_config.repos.len(),
495 export_config.templates.len()
496 );
497
498 Ok(())
499 }
500
501 pub fn import_config(&mut self, file: &str, mode: &str, skip_validation: bool) -> Result<()> {
502 println!("📥 Importing configuration from: {}", file);
503
504 let config_content = fs::read_to_string(file)
505 .with_context(|| format!("Failed to read configuration file: {}", file))?;
506
507 let import_config: Config =
508 serde_json::from_str(&config_content).context("Failed to parse configuration file")?;
509
510 if !skip_validation {
511 self.validate_import_config(&import_config)?;
512 }
513
514 match mode.to_lowercase().as_str() {
515 "merge" => {
516 println!("🔄 Merging configuration...");
517 self.merge_config(import_config)?;
518 }
519 "overwrite" => {
520 println!("⚠️ Overwriting configuration...");
521 self.config = import_config;
522 }
523 _ => {
524 anyhow::bail!("Invalid import mode: {}. Use 'merge' or 'overwrite'", mode);
525 }
526 }
527
528 self.save_config()?;
529
530 println!("✅ Configuration imported successfully!");
531 println!(
532 "📊 Current configuration: {} repositories and {} templates",
533 self.config.repos.len(),
534 self.config.templates.len()
535 );
536
537 Ok(())
538 }
539
540 pub fn validate_config_file(&self, file: &str) -> Result<()> {
541 println!("🔍 Validating configuration file: {}", file);
542
543 let config_content = fs::read_to_string(file)
544 .with_context(|| format!("Failed to read configuration file: {}", file))?;
545
546 let config: Config =
547 serde_json::from_str(&config_content).context("Failed to parse configuration file")?;
548
549 self.validate_import_config(&config)?;
550
551 println!("✅ Configuration file is valid!");
552 println!(
553 "📊 Contains {} repositories and {} templates",
554 config.repos.len(),
555 config.templates.len()
556 );
557
558 Ok(())
559 }
560
561 fn validate_import_config(&self, import_config: &Config) -> Result<()> {
562 let mut validation_errors = Vec::new();
563 let mut validation_warnings = Vec::new();
564
565 for repo in &import_config.repos {
567 if repo.name.is_empty() {
568 validation_errors.push("Repository name cannot be empty".to_string());
569 }
570 if repo.url.is_empty() {
571 validation_errors.push(format!("Repository '{}' URL cannot be empty", repo.name));
572 }
573 if repo.branch.is_empty() {
574 validation_errors
575 .push(format!("Repository '{}' branch cannot be empty", repo.name));
576 }
577 }
578
579 for template in &import_config.templates {
581 if template.id.is_empty() {
582 validation_errors.push("Template ID cannot be empty".to_string());
583 }
584 if template.name.is_empty() {
585 validation_errors.push(format!("Template '{}' name cannot be empty", template.id));
586 }
587 if template.repo.is_empty() {
588 validation_errors.push(format!(
589 "Template '{}' repository cannot be empty",
590 template.id
591 ));
592 }
593 if template.path.is_empty() {
594 validation_errors.push(format!("Template '{}' path cannot be empty", template.id));
595 }
596
597 if !import_config.repos.iter().any(|r| r.name == template.repo) {
599 validation_warnings.push(format!(
600 "Template '{}' references non-existent repository '{}'",
601 template.id, template.repo
602 ));
603 }
604 }
605
606 if !validation_errors.is_empty() {
608 println!("❌ Validation errors:");
609 for error in validation_errors {
610 println!(" {}", error);
611 }
612 anyhow::bail!("Configuration validation failed");
613 }
614
615 if !validation_warnings.is_empty() {
616 println!("⚠️ Validation warnings:");
617 for warning in validation_warnings {
618 println!(" {}", warning);
619 }
620 }
621
622 Ok(())
623 }
624
625 fn merge_config(&mut self, import_config: Config) -> Result<()> {
626 let mut merged_repos = 0;
627 let mut merged_templates = 0;
628
629 for import_repo in import_config.repos {
631 if let Some(existing_repo) = self
632 .config
633 .repos
634 .iter_mut()
635 .find(|r| r.name == import_repo.name)
636 {
637 existing_repo.url = import_repo.url;
639 existing_repo.branch = import_repo.branch;
640 merged_repos += 1;
641 } else {
642 self.config.repos.push(import_repo);
644 merged_repos += 1;
645 }
646 }
647
648 for import_template in import_config.templates {
650 if let Some(existing_template) = self
651 .config
652 .templates
653 .iter_mut()
654 .find(|t| t.id == import_template.id)
655 {
656 *existing_template = import_template;
658 merged_templates += 1;
659 } else {
660 self.config.templates.push(import_template);
662 merged_templates += 1;
663 }
664 }
665
666 println!(
667 "📊 Merged {} repositories and {} templates",
668 merged_repos, merged_templates
669 );
670
671 Ok(())
672 }
673
674 pub fn clean_templates(&mut self, all: bool, force: bool) -> Result<()> {
675 if !force {
676 let message = if all {
677 "⚠️ This will remove ALL templates, cache, and configuration. Are you sure?"
678 } else {
679 "⚠️ This will remove ALL cached template files. Are you sure?"
680 };
681
682 let confirm = Confirm::new()
683 .with_prompt(message)
684 .default(false)
685 .interact()?;
686
687 if !confirm {
688 println!("❌ Clean operation cancelled");
689 return Ok(());
690 }
691 }
692
693 println!("🧹 Cleaning templates...");
694
695 if self.cache_dir.exists() {
697 match fs::remove_dir_all(&self.cache_dir) {
698 Ok(_) => println!("✅ Cache directory cleaned"),
699 Err(e) => println!("⚠️ Failed to clean cache directory: {}", e),
700 }
701 }
702
703 fs::create_dir_all(&self.cache_dir).context("Failed to recreate cache directory")?;
705
706 if all {
707 let config_path = Self::get_config_path()?;
709 if config_path.exists() {
710 match fs::remove_file(&config_path) {
711 Ok(_) => println!("✅ Configuration file removed"),
712 Err(e) => println!("⚠️ Failed to remove configuration file: {}", e),
713 }
714 }
715
716 self.config = Config {
718 repos: vec![],
719 templates: vec![],
720 };
721 }
722
723 println!("🎉 Clean operation completed!");
724 if all {
725 println!("📝 Configuration has been reset to empty state");
726 } else {
727 println!("💾 Configuration preserved, only cache was cleaned");
728 }
729
730 Ok(())
731 }
732
733 pub fn show_info(&self, json: bool) -> Result<()> {
734 if json {
735 let config_json = serde_json::to_string_pretty(&self.config)
737 .context("Failed to serialize configuration")?;
738 println!("{}", config_json);
739 } else {
740 println!("{}", "📋 Current Configuration".bold().blue());
742 println!();
743
744 println!("{}", "📦 Repositories".bold().yellow());
746 if self.config.repos.is_empty() {
747 println!(" No repositories configured");
748 } else {
749 for repo in &self.config.repos {
750 println!(" {} - {}", repo.name.bold(), repo.url);
751 println!(" Branch: {}", repo.branch);
752 }
753 }
754 println!();
755
756 println!("{}", "🎨 Templates".bold().yellow());
758 if self.config.templates.is_empty() {
759 println!(" No templates configured");
760 } else {
761 for template in &self.config.templates {
762 let cache_path = self.get_template_cache_path(template);
763 let status = if cache_path.exists() {
764 "✅".green()
765 } else {
766 "❌".red()
767 };
768
769 println!(" {} {} - {}", status, template.id.bold(), template.name);
770 println!(" Description: {}", template.description);
771 println!(" Language: {}", template.language);
772 println!(" Repository: {}", template.repo);
773 println!(" Path: {}", template.path);
774 println!(" Tags: {}", template.tags.join(", "));
775 println!();
776 }
777 }
778
779 println!("{}", "📊 Statistics".bold().yellow());
781 println!(" Repositories: {}", self.config.repos.len());
782 println!(" Templates: {}", self.config.templates.len());
783
784 let cached_count = self
786 .config
787 .templates
788 .iter()
789 .filter(|t| self.get_template_cache_path(t).exists())
790 .count();
791 println!(
792 " Cached templates: {}/{}",
793 cached_count,
794 self.config.templates.len()
795 );
796
797 println!();
799 println!("{}", "📁 Paths".bold().yellow());
800 match Self::get_config_path() {
801 Ok(path) => println!(" Config: {}", path.display()),
802 Err(_) => println!(" Config: Unable to determine path"),
803 }
804 println!(" Cache: {}", self.cache_dir.display());
805 }
806
807 Ok(())
808 }
809}