1use crate::models::{Boilerplate, BoilerplateFile, BoilerplateSource, ConflictResolution};
7use crate::templates::discovery::BoilerplateDiscovery;
8use crate::templates::error::BoilerplateError;
9use crate::templates::resolver::{CaseTransform, PlaceholderResolver};
10use std::collections::HashMap;
11use std::fs;
12use std::path::Path;
13
14pub struct BoilerplateManager;
24
25impl BoilerplateManager {
26 pub fn new() -> Self {
28 Self
29 }
30
31 pub fn load(&self, boilerplate_path: &Path) -> Result<Boilerplate, BoilerplateError> {
45 BoilerplateDiscovery::validate_boilerplate(boilerplate_path)?;
47
48 let boilerplate = BoilerplateDiscovery::parse_metadata(boilerplate_path)?;
50
51 Ok(boilerplate)
52 }
53
54 pub fn load_by_name(
65 &self,
66 project_root: &Path,
67 boilerplate_name: &str,
68 ) -> Result<Boilerplate, BoilerplateError> {
69 let discovery_result = BoilerplateDiscovery::discover(project_root)?;
70
71 let metadata = discovery_result
73 .boilerplates
74 .iter()
75 .find(|bp| bp.name.to_lowercase() == boilerplate_name.to_lowercase())
76 .ok_or_else(|| BoilerplateError::NotFound(boilerplate_name.to_string()))?;
77
78 let path = match &metadata.source {
80 BoilerplateSource::Global(p) | BoilerplateSource::Project(p) => p,
81 };
82
83 self.load(path)
84 }
85
86 pub fn validate(&self, boilerplate: &Boilerplate) -> Result<(), BoilerplateError> {
99 if boilerplate.id.is_empty() {
101 return Err(BoilerplateError::ValidationFailed(
102 "Boilerplate ID cannot be empty".to_string(),
103 ));
104 }
105
106 if boilerplate.name.is_empty() {
107 return Err(BoilerplateError::ValidationFailed(
108 "Boilerplate name cannot be empty".to_string(),
109 ));
110 }
111
112 if boilerplate.language.is_empty() {
113 return Err(BoilerplateError::ValidationFailed(
114 "Boilerplate language cannot be empty".to_string(),
115 ));
116 }
117
118 for file in &boilerplate.files {
120 if file.path.is_empty() {
121 return Err(BoilerplateError::ValidationFailed(
122 "Boilerplate file path cannot be empty".to_string(),
123 ));
124 }
125
126 if file.template.is_empty() {
127 return Err(BoilerplateError::ValidationFailed(
128 "Boilerplate file template cannot be empty".to_string(),
129 ));
130 }
131 }
132
133 Ok(())
134 }
135
136 pub fn extract_placeholders(
146 &self,
147 boilerplate: &Boilerplate,
148 ) -> Result<HashMap<String, String>, BoilerplateError> {
149 let mut placeholders = HashMap::new();
150
151 let re = regex::Regex::new(r"\{\{([a-zA-Z_][a-zA-Z0-9_-]*)\}\}").unwrap();
153
154 for file in &boilerplate.files {
155 for cap in re.captures_iter(&file.template) {
157 if let Some(name) = cap.get(1) {
158 let placeholder_name = name.as_str().to_string();
159 placeholders.insert(placeholder_name, format!("Placeholder in {}", file.path));
160 }
161 }
162 }
163
164 Ok(placeholders)
165 }
166
167 pub fn apply(
185 &self,
186 boilerplate: &Boilerplate,
187 target_dir: &Path,
188 variables: &HashMap<String, String>,
189 conflict_resolution: ConflictResolution,
190 ) -> Result<ScaffoldingResult, BoilerplateError> {
191 self.validate(boilerplate)?;
193
194 fs::create_dir_all(target_dir).map_err(BoilerplateError::IoError)?;
196
197 let mut created_files = Vec::new();
198 let mut skipped_files = Vec::new();
199 let mut conflicts = Vec::new();
200
201 for file in &boilerplate.files {
203 if let Some(condition) = &file.condition {
205 if !self.evaluate_condition(condition, variables) {
206 continue;
207 }
208 }
209
210 let mut resolver = PlaceholderResolver::new();
212 resolver.add_values(variables.clone());
213 let rendered_content = self.render_template(&file.template, &resolver)?;
214
215 let file_path = target_dir.join(&file.path);
217
218 if let Some(parent) = file_path.parent() {
220 fs::create_dir_all(parent).map_err(BoilerplateError::IoError)?;
221 }
222
223 if file_path.exists() {
225 match conflict_resolution {
226 ConflictResolution::Skip => {
227 skipped_files.push(file.path.clone());
228 continue;
229 }
230 ConflictResolution::Overwrite => {
231 }
233 ConflictResolution::Merge => {
234 conflicts.push(FileConflict {
237 path: file.path.clone(),
238 reason: "File already exists".to_string(),
239 resolution: "Merged (overwritten)".to_string(),
240 });
241 }
242 }
243 }
244
245 fs::write(&file_path, &rendered_content).map_err(BoilerplateError::IoError)?;
247
248 created_files.push(file.path.clone());
249 }
250
251 Ok(ScaffoldingResult {
252 created_files,
253 skipped_files,
254 conflicts,
255 })
256 }
257
258 pub fn create_custom(
274 &self,
275 source_dir: &Path,
276 boilerplate_id: &str,
277 boilerplate_name: &str,
278 language: &str,
279 ) -> Result<Boilerplate, BoilerplateError> {
280 if !source_dir.exists() {
281 return Err(BoilerplateError::InvalidStructure(format!(
282 "Source directory not found: {}",
283 source_dir.display()
284 )));
285 }
286
287 let mut files = Vec::new();
288
289 self.scan_directory(source_dir, source_dir, &mut files)?;
291
292 Ok(Boilerplate {
293 id: boilerplate_id.to_string(),
294 name: boilerplate_name.to_string(),
295 description: format!("Custom boilerplate for {}", language),
296 language: language.to_string(),
297 files,
298 dependencies: Vec::new(),
299 scripts: Vec::new(),
300 })
301 }
302
303 pub fn save(
314 &self,
315 boilerplate: &Boilerplate,
316 target_dir: &Path,
317 ) -> Result<(), BoilerplateError> {
318 self.validate(boilerplate)?;
320
321 fs::create_dir_all(target_dir).map_err(BoilerplateError::IoError)?;
323
324 let metadata_path = target_dir.join("boilerplate.yaml");
326 let yaml_content = serde_yaml::to_string(boilerplate)
327 .map_err(|e| BoilerplateError::InvalidStructure(format!("YAML error: {}", e)))?;
328
329 fs::write(&metadata_path, yaml_content).map_err(BoilerplateError::IoError)?;
330
331 Ok(())
332 }
333
334 fn evaluate_condition(&self, condition: &str, variables: &HashMap<String, String>) -> bool {
346 let condition = condition.trim();
347
348 if let Some(var_name) = condition.strip_prefix('!') {
350 return !variables.contains_key(var_name.trim());
351 }
352
353 variables
355 .get(condition)
356 .map(|v| !v.is_empty() && v != "false" && v != "0")
357 .unwrap_or(false)
358 }
359
360 fn render_template(
369 &self,
370 template: &str,
371 resolver: &PlaceholderResolver,
372 ) -> Result<String, BoilerplateError> {
373 let mut result = template.to_string();
374
375 let re = regex::Regex::new(r"\{\{([a-zA-Z_][a-zA-Z0-9_-]*(?:_snake|-kebab|Camel)?)\}\}")
377 .map_err(|e| BoilerplateError::InvalidStructure(format!("Regex error: {}", e)))?;
378
379 for cap in re.captures_iter(template) {
380 if let Some(placeholder_match) = cap.get(1) {
381 let placeholder_content = placeholder_match.as_str();
382
383 let (name, case_transform) = self.parse_placeholder_syntax(placeholder_content)?;
385
386 let resolved = resolver
388 .resolve(&name, case_transform)
389 .map_err(|e| BoilerplateError::InvalidStructure(e.to_string()))?;
390
391 let placeholder_str = format!("{{{{{}}}}}", placeholder_content);
393 result = result.replace(&placeholder_str, &resolved);
394 }
395 }
396
397 Ok(result)
398 }
399
400 fn parse_placeholder_syntax(
408 &self,
409 content: &str,
410 ) -> Result<(String, CaseTransform), BoilerplateError> {
411 let content = content.trim();
412
413 if content.ends_with("_snake") {
415 let name = content.trim_end_matches("_snake").to_string();
416 Ok((name, CaseTransform::SnakeCase))
417 } else if content.ends_with("-kebab") {
418 let name = content.trim_end_matches("-kebab").to_string();
419 Ok((name, CaseTransform::KebabCase))
420 } else if content.ends_with("Camel") {
421 let name = content.trim_end_matches("Camel").to_string();
422 Ok((name, CaseTransform::CamelCase))
423 } else if content.chars().all(|c| c.is_uppercase() || c == '_') && content.len() > 1 {
424 Ok((content.to_string(), CaseTransform::UpperCase))
426 } else if content.chars().next().is_some_and(|c| c.is_uppercase()) {
427 Ok((content.to_string(), CaseTransform::PascalCase))
429 } else {
430 Ok((content.to_string(), CaseTransform::LowerCase))
432 }
433 }
434
435 #[allow(clippy::only_used_in_recursion)]
445 fn scan_directory(
446 &self,
447 current_dir: &Path,
448 base_dir: &Path,
449 files: &mut Vec<BoilerplateFile>,
450 ) -> Result<(), BoilerplateError> {
451 for entry in fs::read_dir(current_dir).map_err(BoilerplateError::IoError)? {
452 let entry = entry.map_err(BoilerplateError::IoError)?;
453 let path = entry.path();
454
455 if path.is_dir() {
456 self.scan_directory(&path, base_dir, files)?;
458 } else if path.is_file() {
459 let content = fs::read_to_string(&path).map_err(BoilerplateError::IoError)?;
461
462 let relative_path = path
464 .strip_prefix(base_dir)
465 .map_err(|_| {
466 BoilerplateError::InvalidStructure(
467 "Failed to calculate relative path".to_string(),
468 )
469 })?
470 .to_string_lossy()
471 .to_string();
472
473 files.push(BoilerplateFile {
474 path: relative_path,
475 template: content,
476 condition: None,
477 });
478 }
479 }
480
481 Ok(())
482 }
483}
484
485impl Default for BoilerplateManager {
486 fn default() -> Self {
487 Self::new()
488 }
489}
490
491#[derive(Debug, Clone)]
493pub struct ScaffoldingResult {
494 pub created_files: Vec<String>,
496 pub skipped_files: Vec<String>,
498 pub conflicts: Vec<FileConflict>,
500}
501
502#[derive(Debug, Clone)]
504pub struct FileConflict {
505 pub path: String,
507 pub reason: String,
509 pub resolution: String,
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use tempfile::TempDir;
517
518 #[test]
519 fn test_create_boilerplate_manager() {
520 let _manager = BoilerplateManager::new();
521 }
523
524 #[test]
525 fn test_validate_boilerplate_success() {
526 let manager = BoilerplateManager::new();
527 let boilerplate = Boilerplate {
528 id: "test-bp".to_string(),
529 name: "Test Boilerplate".to_string(),
530 description: "A test boilerplate".to_string(),
531 language: "rust".to_string(),
532 files: vec![BoilerplateFile {
533 path: "src/main.rs".to_string(),
534 template: "fn main() {}".to_string(),
535 condition: None,
536 }],
537 dependencies: vec![],
538 scripts: vec![],
539 };
540
541 assert!(manager.validate(&boilerplate).is_ok());
542 }
543
544 #[test]
545 fn test_validate_boilerplate_missing_id() {
546 let manager = BoilerplateManager::new();
547 let boilerplate = Boilerplate {
548 id: "".to_string(),
549 name: "Test Boilerplate".to_string(),
550 description: "A test boilerplate".to_string(),
551 language: "rust".to_string(),
552 files: vec![],
553 dependencies: vec![],
554 scripts: vec![],
555 };
556
557 assert!(manager.validate(&boilerplate).is_err());
558 }
559
560 #[test]
561 fn test_validate_boilerplate_missing_name() {
562 let manager = BoilerplateManager::new();
563 let boilerplate = Boilerplate {
564 id: "test-bp".to_string(),
565 name: "".to_string(),
566 description: "A test boilerplate".to_string(),
567 language: "rust".to_string(),
568 files: vec![],
569 dependencies: vec![],
570 scripts: vec![],
571 };
572
573 assert!(manager.validate(&boilerplate).is_err());
574 }
575
576 #[test]
577 fn test_validate_boilerplate_missing_language() {
578 let manager = BoilerplateManager::new();
579 let boilerplate = Boilerplate {
580 id: "test-bp".to_string(),
581 name: "Test Boilerplate".to_string(),
582 description: "A test boilerplate".to_string(),
583 language: "".to_string(),
584 files: vec![],
585 dependencies: vec![],
586 scripts: vec![],
587 };
588
589 assert!(manager.validate(&boilerplate).is_err());
590 }
591
592 #[test]
593 fn test_extract_placeholders() {
594 let manager = BoilerplateManager::new();
595 let boilerplate = Boilerplate {
596 id: "test-bp".to_string(),
597 name: "Test Boilerplate".to_string(),
598 description: "A test boilerplate".to_string(),
599 language: "rust".to_string(),
600 files: vec![
601 BoilerplateFile {
602 path: "src/main.rs".to_string(),
603 template: "pub struct {{Name}} {}".to_string(),
604 condition: None,
605 },
606 BoilerplateFile {
607 path: "Cargo.toml".to_string(),
608 template: "[package]\nname = \"{{name_snake}}\"".to_string(),
609 condition: None,
610 },
611 ],
612 dependencies: vec![],
613 scripts: vec![],
614 };
615
616 let placeholders = manager.extract_placeholders(&boilerplate).unwrap();
617 assert!(placeholders.contains_key("Name"));
618 assert!(placeholders.contains_key("name_snake"));
619 }
620
621 #[test]
622 fn test_apply_boilerplate_skip_conflicts() {
623 let temp_dir = TempDir::new().unwrap();
624 let manager = BoilerplateManager::new();
625
626 let existing_file = temp_dir.path().join("src").join("main.rs");
628 fs::create_dir_all(existing_file.parent().unwrap()).unwrap();
629 fs::write(&existing_file, "// existing").unwrap();
630
631 let boilerplate = Boilerplate {
632 id: "test-bp".to_string(),
633 name: "Test Boilerplate".to_string(),
634 description: "A test boilerplate".to_string(),
635 language: "rust".to_string(),
636 files: vec![BoilerplateFile {
637 path: "src/main.rs".to_string(),
638 template: "fn main() {}".to_string(),
639 condition: None,
640 }],
641 dependencies: vec![],
642 scripts: vec![],
643 };
644
645 let variables = HashMap::new();
646 let result = manager
647 .apply(
648 &boilerplate,
649 temp_dir.path(),
650 &variables,
651 ConflictResolution::Skip,
652 )
653 .unwrap();
654
655 assert_eq!(result.skipped_files.len(), 1);
656 assert_eq!(result.created_files.len(), 0);
657
658 let content = fs::read_to_string(&existing_file).unwrap();
660 assert_eq!(content, "// existing");
661 }
662
663 #[test]
664 fn test_apply_boilerplate_overwrite_conflicts() {
665 let temp_dir = TempDir::new().unwrap();
666 let manager = BoilerplateManager::new();
667
668 let existing_file = temp_dir.path().join("src").join("main.rs");
670 fs::create_dir_all(existing_file.parent().unwrap()).unwrap();
671 fs::write(&existing_file, "// existing").unwrap();
672
673 let boilerplate = Boilerplate {
674 id: "test-bp".to_string(),
675 name: "Test Boilerplate".to_string(),
676 description: "A test boilerplate".to_string(),
677 language: "rust".to_string(),
678 files: vec![BoilerplateFile {
679 path: "src/main.rs".to_string(),
680 template: "fn main() {}".to_string(),
681 condition: None,
682 }],
683 dependencies: vec![],
684 scripts: vec![],
685 };
686
687 let variables = HashMap::new();
688 let result = manager
689 .apply(
690 &boilerplate,
691 temp_dir.path(),
692 &variables,
693 ConflictResolution::Overwrite,
694 )
695 .unwrap();
696
697 assert_eq!(result.created_files.len(), 1);
698 assert_eq!(result.skipped_files.len(), 0);
699
700 let content = fs::read_to_string(&existing_file).unwrap();
702 assert_eq!(content, "fn main() {}");
703 }
704
705 #[test]
706 fn test_apply_boilerplate_with_variables() {
707 let temp_dir = TempDir::new().unwrap();
708 let manager = BoilerplateManager::new();
709
710 let boilerplate = Boilerplate {
711 id: "test-bp".to_string(),
712 name: "Test Boilerplate".to_string(),
713 description: "A test boilerplate".to_string(),
714 language: "rust".to_string(),
715 files: vec![BoilerplateFile {
716 path: "src/main.rs".to_string(),
717 template: "pub struct {{Name}} {}".to_string(),
718 condition: None,
719 }],
720 dependencies: vec![],
721 scripts: vec![],
722 };
723
724 let mut variables = HashMap::new();
725 variables.insert("Name".to_string(), "MyStruct".to_string());
726
727 let result = manager
728 .apply(
729 &boilerplate,
730 temp_dir.path(),
731 &variables,
732 ConflictResolution::Skip,
733 )
734 .unwrap();
735
736 assert_eq!(result.created_files.len(), 1);
737
738 let content = fs::read_to_string(temp_dir.path().join("src/main.rs")).unwrap();
739 assert!(content.contains("MyStruct"));
740 }
741
742 #[test]
743 fn test_apply_boilerplate_with_condition() {
744 let temp_dir = TempDir::new().unwrap();
745 let manager = BoilerplateManager::new();
746
747 let boilerplate = Boilerplate {
748 id: "test-bp".to_string(),
749 name: "Test Boilerplate".to_string(),
750 description: "A test boilerplate".to_string(),
751 language: "rust".to_string(),
752 files: vec![
753 BoilerplateFile {
754 path: "src/main.rs".to_string(),
755 template: "fn main() {}".to_string(),
756 condition: Some("include_main".to_string()),
757 },
758 BoilerplateFile {
759 path: "src/lib.rs".to_string(),
760 template: "pub fn lib() {}".to_string(),
761 condition: Some("include_lib".to_string()),
762 },
763 ],
764 dependencies: vec![],
765 scripts: vec![],
766 };
767
768 let mut variables = HashMap::new();
769 variables.insert("include_main".to_string(), "true".to_string());
770
771 let result = manager
772 .apply(
773 &boilerplate,
774 temp_dir.path(),
775 &variables,
776 ConflictResolution::Skip,
777 )
778 .unwrap();
779
780 assert_eq!(result.created_files.len(), 1);
781 assert!(temp_dir.path().join("src/main.rs").exists());
782 assert!(!temp_dir.path().join("src/lib.rs").exists());
783 }
784
785 #[test]
786 fn test_create_custom_boilerplate() {
787 let temp_dir = TempDir::new().unwrap();
788 let manager = BoilerplateManager::new();
789
790 let src_dir = temp_dir.path().join("src");
792 fs::create_dir_all(&src_dir).unwrap();
793 fs::write(src_dir.join("main.rs"), "fn main() {}").unwrap();
794 fs::write(src_dir.join("lib.rs"), "pub fn lib() {}").unwrap();
795
796 let boilerplate = manager
797 .create_custom(temp_dir.path(), "custom-bp", "Custom Boilerplate", "rust")
798 .unwrap();
799
800 assert_eq!(boilerplate.id, "custom-bp");
801 assert_eq!(boilerplate.name, "Custom Boilerplate");
802 assert_eq!(boilerplate.language, "rust");
803 assert_eq!(boilerplate.files.len(), 2);
804 }
805
806 #[test]
807 fn test_save_boilerplate() {
808 let temp_dir = TempDir::new().unwrap();
809 let manager = BoilerplateManager::new();
810
811 let boilerplate = Boilerplate {
812 id: "test-bp".to_string(),
813 name: "Test Boilerplate".to_string(),
814 description: "A test boilerplate".to_string(),
815 language: "rust".to_string(),
816 files: vec![BoilerplateFile {
817 path: "src/main.rs".to_string(),
818 template: "fn main() {}".to_string(),
819 condition: None,
820 }],
821 dependencies: vec![],
822 scripts: vec![],
823 };
824
825 let save_dir = temp_dir.path().join("saved-bp");
826 manager.save(&boilerplate, &save_dir).unwrap();
827
828 assert!(save_dir.join("boilerplate.yaml").exists());
829 }
830
831 #[test]
832 fn test_evaluate_condition_true() {
833 let manager = BoilerplateManager::new();
834 let mut variables = HashMap::new();
835 variables.insert("include_feature".to_string(), "true".to_string());
836
837 assert!(manager.evaluate_condition("include_feature", &variables));
838 }
839
840 #[test]
841 fn test_evaluate_condition_false() {
842 let manager = BoilerplateManager::new();
843 let mut variables = HashMap::new();
844 variables.insert("include_feature".to_string(), "false".to_string());
845
846 assert!(!manager.evaluate_condition("include_feature", &variables));
847 }
848
849 #[test]
850 fn test_evaluate_condition_negation() {
851 let manager = BoilerplateManager::new();
852 let variables = HashMap::new();
853
854 assert!(manager.evaluate_condition("!include_feature", &variables));
855 }
856}