1use serde::Serialize;
7use serde_json::Value;
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use tokio::fs;
11
12use crate::config::{CompositionConfig, FileMergingStrategy, ServiceCategory, TemplateConfig};
13use crate::error::{EngineError, EngineResult};
14
15#[derive(Debug, Clone)]
16pub struct CompositionEngine {
17 base_template_path: PathBuf,
18 shared_services_path: PathBuf,
19}
20
21#[derive(Debug, Clone)]
22pub struct ServiceSelection {
23 pub category: ServiceCategory,
24 pub provider: String,
25 pub config: HashMap<String, Value>,
26}
27
28#[derive(Debug, Clone)]
29pub struct ComposedTemplate {
30 pub base_config: TemplateConfig,
31 pub files: Vec<ComposedFile>,
32 pub merged_dependencies: HashMap<String, Value>,
33 pub environment_variables: HashMap<String, String>,
34 pub service_context: ServiceContext,
35}
36
37#[derive(Debug, Clone)]
38pub struct ComposedFile {
39 pub path: PathBuf,
40 pub content: String,
41 pub source: FileSource,
42 pub merge_strategy: FileMergingStrategy,
43 pub is_template: bool,
44}
45
46#[derive(Debug, Clone)]
47pub enum FileSource {
48 BaseTemplate,
49 Service {
50 category: ServiceCategory,
51 provider: String,
52 },
53 Merged,
54}
55
56#[derive(Debug, Clone, Serialize)]
57pub struct ServiceContext {
58 pub services: HashMap<String, ServiceInfo>,
59 pub shared_config: HashMap<String, Value>,
60}
61
62#[derive(Debug, Clone, Serialize)]
63pub struct ServiceInfo {
64 pub provider: String,
65 pub config: HashMap<String, Value>,
66 pub exports: HashMap<String, Value>,
67}
68
69impl CompositionEngine {
70 pub fn new(base_template_path: PathBuf, shared_services_path: PathBuf) -> Self {
71 Self {
72 base_template_path,
73 shared_services_path,
74 }
75 }
76
77 pub fn shared_services_path(&self) -> &PathBuf {
78 &self.shared_services_path
79 }
80
81 pub async fn discover_service_providers(
86 &self,
87 category: ServiceCategory,
88 ) -> EngineResult<Vec<String>> {
89 let category_dir = self
90 .shared_services_path
91 .join(format!("{:?}", category).to_lowercase());
92
93 if !category_dir.exists() {
94 return Ok(vec!["none".to_string()]);
95 }
96
97 let mut providers = vec!["none".to_string()];
98 let mut entries = fs::read_dir(&category_dir)
99 .await
100 .map_err(|e| EngineError::file_error(&category_dir, e))?;
101
102 while let Some(entry) = entries
103 .next_entry()
104 .await
105 .map_err(|e| EngineError::file_error(&category_dir, e))?
106 {
107 let path = entry.path();
108 if path.is_dir() {
109 if let Some(provider_name) = path.file_name().and_then(|n| n.to_str()) {
110 let config_path = path.join("anvil.yaml");
112 if config_path.exists() {
113 providers.push(provider_name.to_string());
114 }
115 }
116 }
117 }
118
119 providers.sort();
120 Ok(providers)
121 }
122
123 pub async fn discover_all_services(
128 &self,
129 ) -> EngineResult<HashMap<ServiceCategory, Vec<String>>> {
130 let mut all_services = HashMap::new();
131
132 for category in [
134 ServiceCategory::Auth,
135 ServiceCategory::Payments,
136 ServiceCategory::Database,
137 ServiceCategory::AI,
138 ServiceCategory::Api,
139 ServiceCategory::Deployment,
140 ServiceCategory::Monitoring,
141 ServiceCategory::Email,
142 ServiceCategory::Storage,
143 ] {
144 let providers = self.discover_service_providers(category.clone()).await?;
145 all_services.insert(category, providers);
146 }
147
148 Ok(all_services)
149 }
150
151 pub async fn compose_template(
156 &self,
157 template_name: &str,
158 services: Vec<ServiceSelection>,
159 ) -> EngineResult<ComposedTemplate> {
160 self.compose_template_with_context(template_name, services, None)
161 .await
162 }
163
164 pub async fn compose_template_with_context(
169 &self,
170 template_name: &str,
171 services: Vec<ServiceSelection>,
172 context: Option<&crate::Context>,
173 ) -> EngineResult<ComposedTemplate> {
174 let base_config_path = self
176 .base_template_path
177 .join(template_name)
178 .join("anvil.yaml");
179 let base_config = TemplateConfig::from_file(&base_config_path).await?;
180
181 self.validate_service_selections(&base_config, &services, context)
183 .await?;
184
185 let service_context = self.build_service_context(&services).await?;
187
188 let mut composed_files = self.collect_base_template_files(template_name).await?;
190
191 for service in &services {
193 let service_files = self.collect_service_files(&service).await?;
194 composed_files.extend(service_files);
195 }
196
197 let filtered_files = self
199 .apply_conditional_inclusion(composed_files, &services, &base_config.composition)
200 .await?;
201
202 let resolved_files = self
204 .resolve_file_conflicts(filtered_files, &base_config.composition)
205 .await?;
206
207 let merged_dependencies = self.merge_dependencies(&services).await?;
209
210 let environment_variables = self.collect_environment_variables(&services).await?;
212
213 Ok(ComposedTemplate {
214 base_config,
215 files: resolved_files,
216 merged_dependencies,
217 environment_variables,
218 service_context,
219 })
220 }
221
222 async fn validate_service_selections(
227 &self,
228 base_config: &TemplateConfig,
229 services: &[ServiceSelection],
230 context: Option<&crate::Context>,
231 ) -> EngineResult<()> {
232 for service_def in &base_config.services {
234 if service_def.required {
235 let has_service = services.iter().any(|s| s.category == service_def.category);
236 if !has_service {
237 return Err(EngineError::composition_error(format!(
238 "Required service '{}' not provided",
239 service_def.name
240 )));
241 }
242 }
243 }
244
245 for service in services {
247 let service_def = base_config
249 .services
250 .iter()
251 .find(|s| s.category == service.category)
252 .ok_or_else(|| {
253 EngineError::composition_error(format!(
254 "Service category '{:?}' not supported by template '{}'",
255 service.category, base_config.name
256 ))
257 })?;
258
259 if !service_def.options.contains(&service.provider) {
261 return Err(EngineError::composition_error(format!(
262 "Invalid provider '{}' for service '{:?}'. Valid options: {:?}",
263 service.provider, service.category, service_def.options
264 )));
265 }
266
267 let service_path = self
269 .shared_services_path
270 .join(format!("{:?}", service.category).to_lowercase())
271 .join(&service.provider);
272
273 if !service_path.exists() {
274 return Err(EngineError::composition_error(format!(
275 "Service files not found for '{:?}/{}'",
276 service.category, service.provider
277 )));
278 }
279 }
280
281 for service in services {
283 if let Some(service_def) = base_config
284 .services
285 .iter()
286 .find(|s| s.category == service.category)
287 {
288 if let Some(conflicts) = &service_def.conflicts {
289 for conflict in conflicts {
290 if services
291 .iter()
292 .any(|s| format!("{:?}", s.category).to_lowercase() == *conflict)
293 {
294 return Err(EngineError::composition_error(format!(
295 "Service conflict: {} conflicts with {}",
296 service_def.name, conflict
297 )));
298 }
299 }
300 }
301 }
302 }
303
304 for service in services {
306 if let Some(service_def) = base_config
307 .services
308 .iter()
309 .find(|s| s.category == service.category)
310 {
311 if let Some(dependencies) = &service_def.dependencies {
312 for dependency in dependencies {
313 let has_dependency = services
314 .iter()
315 .any(|s| format!("{:?}", s.category).to_lowercase() == *dependency);
316
317 if !has_dependency {
318 return Err(EngineError::composition_error(format!(
319 "Service '{}' requires dependency '{}' which is not selected",
320 service_def.name, dependency
321 )));
322 }
323 }
324 }
325 }
326 }
327
328 self.validate_service_compatibility(base_config, services, context)
330 .await?;
331
332 Ok(())
333 }
334
335 async fn validate_service_compatibility(
340 &self,
341 base_config: &TemplateConfig,
342 services: &[ServiceSelection],
343 context: Option<&crate::Context>,
344 ) -> EngineResult<()> {
345 let project_language = self.detect_project_language(base_config, context).await?;
347
348 for service in services {
350 let service_config_path = self
351 .shared_services_path
352 .join(format!("{:?}", service.category).to_lowercase())
353 .join(&service.provider)
354 .join("anvil.yaml");
355
356 if service_config_path.exists() {
357 let service_config =
358 crate::config::ServiceConfig::from_file(&service_config_path).await?;
359
360 if let Some(language_reqs) = self
362 .get_service_language_requirements(&service_config)
363 .await?
364 {
365 for required_lang in &language_reqs {
366 if !project_language.contains(required_lang) {
367 return Err(EngineError::composition_error(format!(
368 "Service '{}/{}' requires {} but project language is {:?}",
369 format!("{:?}", service.category).to_lowercase(),
370 service.provider,
371 required_lang,
372 project_language
373 )));
374 }
375 }
376 }
377
378 self.validate_service_rules(&service_config, services, &project_language)
380 .await?;
381 }
382 }
383
384 self.validate_cross_service_compatibility(services).await?;
386
387 Ok(())
388 }
389
390 async fn detect_project_language(
394 &self,
395 base_config: &TemplateConfig,
396 context: Option<&crate::Context>,
397 ) -> EngineResult<Vec<String>> {
398 let mut languages = Vec::new();
399
400 if let Some(ctx) = context {
402 if let Some(lang_value) = ctx.get_variable("language") {
403 if let Some(lang_str) = lang_value.as_str() {
404 languages.push(lang_str.to_string());
405 return Ok(languages);
406 }
407 }
408 }
409
410 if base_config.name.contains("rust") {
412 languages.push("rust".to_string());
413 } else if base_config.name.contains("go") {
414 languages.push("go".to_string());
415 } else if base_config.name.contains("python") {
416 languages.push("python".to_string());
417 } else {
418 languages.push("typescript".to_string());
420 languages.push("javascript".to_string());
421 }
422
423 Ok(languages)
427 }
428
429 async fn get_service_language_requirements(
433 &self,
434 service_config: &crate::config::ServiceConfig,
435 ) -> EngineResult<Option<Vec<String>>> {
436 Ok(service_config.language_requirements.clone())
437 }
438
439 async fn validate_service_rules(
443 &self,
444 service_config: &crate::config::ServiceConfig,
445 _services: &[ServiceSelection],
446 project_languages: &[String],
447 ) -> EngineResult<()> {
448 match service_config.name.as_str() {
453 "trpc-api" => {
454 if !project_languages.contains(&"typescript".to_string()) {
455 return Err(EngineError::composition_error(
456 "tRPC requires TypeScript for type safety".to_string(),
457 ));
458 }
459 }
460 _ => {}
461 }
462
463 Ok(())
464 }
465
466 async fn validate_cross_service_compatibility(
470 &self,
471 services: &[ServiceSelection],
472 ) -> EngineResult<()> {
473 let _service_providers: std::collections::HashSet<String> = services
475 .iter()
476 .map(|s| format!("{:?}/{}", s.category, s.provider))
477 .collect();
478
479 let auth_services: Vec<_> = services
481 .iter()
482 .filter(|s| s.category == ServiceCategory::Auth)
483 .collect();
484
485 if auth_services.len() > 1 {
486 let auth_providers: Vec<_> =
487 auth_services.iter().map(|s| s.provider.as_str()).collect();
488 return Err(EngineError::composition_error(format!(
489 "Multiple auth providers selected: {:?}. Only one auth provider is allowed.",
490 auth_providers
491 )));
492 }
493
494 let api_services: Vec<_> = services
496 .iter()
497 .filter(|s| s.category == ServiceCategory::Api)
498 .collect();
499
500 if api_services.len() > 1 {
501 let api_providers: Vec<_> = api_services.iter().map(|s| s.provider.as_str()).collect();
502 return Err(EngineError::composition_error(format!(
503 "Multiple API patterns selected: {:?}. Only one API pattern is recommended.",
504 api_providers
505 )));
506 }
507
508 Ok(())
511 }
512
513 async fn build_service_context(
518 &self,
519 services: &[ServiceSelection],
520 ) -> EngineResult<ServiceContext> {
521 let mut service_context = ServiceContext {
522 services: HashMap::new(),
523 shared_config: HashMap::new(),
524 };
525
526 for service in services {
528 if service.provider == "none" {
530 continue;
531 }
532
533 let service_config_path = self
534 .shared_services_path
535 .join(format!("{:?}", service.category).to_lowercase())
536 .join(&service.provider)
537 .join("anvil.yaml");
538
539 if service_config_path.exists() {
540 let service_config =
541 crate::config::ServiceConfig::from_file(&service_config_path).await?;
542
543 let mut exports = HashMap::new();
545
546 exports.insert(
548 "provider".to_string(),
549 Value::String(service.provider.clone()),
550 );
551 exports.insert(
552 "category".to_string(),
553 Value::String(format!("{:?}", service.category)),
554 );
555
556 match service.category {
558 ServiceCategory::Auth => {
559 exports.insert(
560 "auth_provider".to_string(),
561 Value::String(service.provider.clone()),
562 );
563 exports.insert("has_auth".to_string(), Value::Bool(true));
564 for env_var in &service_config.environment_variables {
566 if env_var.name.contains("PUBLISHABLE")
567 || env_var.name.contains("PUBLIC")
568 {
569 exports.insert(
570 "public_auth_key_name".to_string(),
571 Value::String(env_var.name.clone()),
572 );
573 }
574 }
575 }
576 ServiceCategory::Database => {
577 exports.insert(
578 "database_provider".to_string(),
579 Value::String(service.provider.clone()),
580 );
581 exports.insert("has_database".to_string(), Value::Bool(true));
582 }
583 ServiceCategory::Payments => {
584 exports.insert(
585 "payments_provider".to_string(),
586 Value::String(service.provider.clone()),
587 );
588 exports.insert("has_payments".to_string(), Value::Bool(true));
589 }
590 ServiceCategory::AI => {
591 exports.insert(
592 "ai_provider".to_string(),
593 Value::String(service.provider.clone()),
594 );
595 exports.insert("has_ai".to_string(), Value::Bool(true));
596 }
597 ServiceCategory::Api => {
598 exports.insert(
599 "api_pattern".to_string(),
600 Value::String(service.provider.clone()),
601 );
602 exports.insert("has_api".to_string(), Value::Bool(true));
603 exports.insert(
604 "api_type".to_string(),
605 Value::String(service.provider.clone()),
606 );
607 }
608 _ => {
609 exports.insert(
611 format!("has_{}", format!("{:?}", service.category).to_lowercase()),
612 Value::Bool(true),
613 );
614 }
615 }
616
617 let mut enriched_config = service.config.clone();
619 for prompt in &service_config.configuration_prompts {
620 if !enriched_config.contains_key(&prompt.name) {
621 if let Some(default_val) = &prompt.default {
622 enriched_config.insert(prompt.name.clone(), default_val.clone());
623 }
624 }
625 }
626
627 let mut all_exports = exports;
629 for (key, value) in &enriched_config {
630 let category_name = format!("{:?}", service.category).to_lowercase();
632 all_exports.insert(format!("{}_config_{}", category_name, key), value.clone());
633 }
634
635 let service_info = ServiceInfo {
636 provider: service.provider.clone(),
637 config: service.config.clone(),
638 exports: all_exports,
639 };
640
641 service_context
642 .services
643 .insert(format!("{:?}", service.category), service_info);
644 }
645 }
646
647 let mut has_any_auth = false;
649 let mut has_any_database = false;
650
651 for (category_str, _service_info) in &service_context.services {
652 match category_str.as_str() {
653 "Auth" => has_any_auth = true,
654 "Database" => has_any_database = true,
655 _ => {}
656 }
657 }
658
659 service_context
660 .shared_config
661 .insert("has_any_auth".to_string(), Value::Bool(has_any_auth));
662 service_context.shared_config.insert(
663 "has_any_database".to_string(),
664 Value::Bool(has_any_database),
665 );
666 service_context.shared_config.insert(
667 "service_count".to_string(),
668 Value::Number(serde_json::Number::from(services.len())),
669 );
670
671 Ok(service_context)
672 }
673
674 async fn collect_base_template_files(
678 &self,
679 template_name: &str,
680 ) -> EngineResult<Vec<ComposedFile>> {
681 let template_path = self.base_template_path.join(template_name);
682 let mut files = Vec::new();
683
684 self.collect_files_recursive(
685 &template_path,
686 &template_path,
687 FileSource::BaseTemplate,
688 &mut files,
689 )
690 .await?;
691
692 Ok(files)
693 }
694
695 async fn collect_service_files(
699 &self,
700 service: &ServiceSelection,
701 ) -> EngineResult<Vec<ComposedFile>> {
702 if service.provider == "none" {
704 return Ok(Vec::new());
705 }
706
707 let service_path = self
708 .shared_services_path
709 .join(format!("{:?}", service.category).to_lowercase())
710 .join(&service.provider);
711
712 if !service_path.exists() {
713 return Err(EngineError::composition_error(format!(
714 "Service files not found: {:?}/{}",
715 service.category, service.provider
716 )));
717 }
718
719 let mut files = Vec::new();
720 let source = FileSource::Service {
721 category: service.category.clone(),
722 provider: service.provider.clone(),
723 };
724
725 self.collect_files_recursive(&service_path, &service_path, source, &mut files)
726 .await?;
727
728 Ok(files)
729 }
730
731 fn collect_files_recursive<'a>(
735 &'a self,
736 dir: &'a Path,
737 base_path: &'a Path,
738 source: FileSource,
739 files: &'a mut Vec<ComposedFile>,
740 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = EngineResult<()>> + Send + 'a>> {
741 Box::pin(async move {
742 let mut entries = fs::read_dir(dir)
743 .await
744 .map_err(|e| EngineError::file_error(dir, e))?;
745
746 while let Some(entry) = entries
747 .next_entry()
748 .await
749 .map_err(|e| EngineError::file_error(dir, e))?
750 {
751 let path = entry.path();
752
753 if path.is_dir() {
754 self.collect_files_recursive(&path, base_path, source.clone(), files)
755 .await?;
756 } else if path.is_file() {
757 if path.file_name().and_then(|name| name.to_str()) == Some("anvil.yaml") {
759 continue;
760 }
761
762 let relative_path = path.strip_prefix(base_path).map_err(|_| {
763 EngineError::composition_error(format!(
764 "Invalid path structure: {}",
765 path.display()
766 ))
767 })?;
768
769 let content = fs::read_to_string(&path)
770 .await
771 .map_err(|e| EngineError::file_error(&path, e))?;
772
773 let is_template =
775 relative_path.extension().and_then(|e| e.to_str()) == Some("tera");
776
777 let output_path = if is_template {
779 let file_name = relative_path
781 .file_name()
782 .and_then(|n| n.to_str())
783 .unwrap_or("")
784 .trim_end_matches(".tera");
785 relative_path.with_file_name(file_name)
786 } else {
787 relative_path.to_path_buf()
788 };
789
790 files.push(ComposedFile {
791 path: output_path,
792 content,
793 source: source.clone(),
794 merge_strategy: FileMergingStrategy::default(),
795 is_template,
796 });
797 }
798 }
799
800 Ok(())
801 })
802 }
803
804 async fn resolve_file_conflicts(
809 &self,
810 files: Vec<ComposedFile>,
811 composition_config: &Option<CompositionConfig>,
812 ) -> EngineResult<Vec<ComposedFile>> {
813 let mut file_map: HashMap<PathBuf, Vec<ComposedFile>> = HashMap::new();
814
815 for file in files {
817 file_map
818 .entry(file.path.clone())
819 .or_insert_with(Vec::new)
820 .push(file);
821 }
822
823 let mut resolved_files = Vec::new();
824
825 for (path, conflicting_files) in file_map {
826 if conflicting_files.len() == 1 {
827 resolved_files.push(conflicting_files.into_iter().next().unwrap());
829 } else {
830 let default_strategy = FileMergingStrategy::default();
832 let strategy = composition_config
833 .as_ref()
834 .map(|c| &c.file_merging_strategy)
835 .unwrap_or(&default_strategy);
836
837 let resolved = self
838 .resolve_single_conflict(path, conflicting_files, strategy)
839 .await?;
840 resolved_files.push(resolved);
841 }
842 }
843
844 Ok(resolved_files)
845 }
846
847 async fn resolve_single_conflict(
851 &self,
852 path: PathBuf,
853 mut files: Vec<ComposedFile>,
854 strategy: &FileMergingStrategy,
855 ) -> EngineResult<ComposedFile> {
856 match strategy {
857 FileMergingStrategy::Override => {
858 files.sort_by(|a, b| match (&a.source, &b.source) {
860 (FileSource::BaseTemplate, FileSource::Service { .. }) => {
861 std::cmp::Ordering::Less
862 }
863 (FileSource::Service { .. }, FileSource::BaseTemplate) => {
864 std::cmp::Ordering::Greater
865 }
866 _ => std::cmp::Ordering::Equal,
867 });
868 Ok(files.into_iter().last().unwrap())
869 }
870 FileMergingStrategy::Append => {
871 let mut combined_content = String::new();
873 for file in &files {
874 combined_content.push_str(&file.content);
875 combined_content.push('\n');
876 }
877 Ok(ComposedFile {
878 path,
879 content: combined_content,
880 source: FileSource::Merged,
881 merge_strategy: FileMergingStrategy::Append,
882 is_template: false,
883 })
884 }
885 FileMergingStrategy::Merge => {
886 if path.extension().and_then(|ext| ext.to_str()) == Some("json") {
888 self.merge_json_files(path, files).await
889 } else {
890 let mut combined_content = String::new();
892 for file in &files {
893 combined_content.push_str(&file.content);
894 combined_content.push('\n');
895 }
896 Ok(ComposedFile {
897 path,
898 content: combined_content,
899 source: FileSource::Merged,
900 merge_strategy: FileMergingStrategy::Append,
901 is_template: false,
902 })
903 }
904 }
905 FileMergingStrategy::Skip => {
906 Ok(files.into_iter().next().unwrap())
908 }
909 }
910 }
911
912 async fn merge_json_files(
917 &self,
918 path: PathBuf,
919 files: Vec<ComposedFile>,
920 ) -> EngineResult<ComposedFile> {
921 let mut merged_json = serde_json::Map::new();
922
923 for file in &files {
924 let json: serde_json::Value = serde_json::from_str(&file.content).map_err(|e| {
925 EngineError::composition_error(format!("Invalid JSON in {}: {}", path.display(), e))
926 })?;
927
928 if let serde_json::Value::Object(obj) = json {
929 for (key, value) in obj {
930 match merged_json.get(&key) {
931 Some(existing) => {
932 if (key == "dependencies" || key == "devDependencies")
934 && existing.is_object()
935 && value.is_object()
936 {
937 let mut merged_deps = existing.as_object().unwrap().clone();
938 merged_deps.extend(value.as_object().unwrap().clone());
939 merged_json.insert(key, serde_json::Value::Object(merged_deps));
940 } else {
941 merged_json.insert(key, value);
943 }
944 }
945 None => {
946 merged_json.insert(key, value);
947 }
948 }
949 }
950 }
951 }
952
953 let merged_content = serde_json::to_string_pretty(&merged_json).map_err(|e| {
954 EngineError::composition_error(format!("Failed to serialize merged JSON: {}", e))
955 })?;
956
957 Ok(ComposedFile {
958 path,
959 content: merged_content,
960 source: FileSource::Merged,
961 merge_strategy: FileMergingStrategy::Merge,
962 is_template: false,
963 })
964 }
965
966 async fn merge_dependencies(
971 &self,
972 services: &[ServiceSelection],
973 ) -> EngineResult<HashMap<String, Value>> {
974 let mut merged_deps = HashMap::new();
975 let mut npm_dependencies = Vec::new();
976 let mut cargo_dependencies = HashMap::new();
977 let mut environment_variables = Vec::new();
978
979 for service in services {
981 let service_config_path = self
982 .shared_services_path
983 .join(format!("{:?}", service.category).to_lowercase())
984 .join(&service.provider)
985 .join("anvil.yaml");
986
987 if service_config_path.exists() {
988 let service_config =
989 crate::config::ServiceConfig::from_file(&service_config_path).await?;
990
991 if let Some(deps) = &service_config.dependencies {
993 if let Some(npm_deps) = &deps.npm {
994 npm_dependencies.extend(npm_deps.iter().cloned());
995 }
996
997 if let Some(cargo_deps) = &deps.cargo {
998 cargo_dependencies
999 .extend(cargo_deps.iter().map(|(k, v)| (k.clone(), v.clone())));
1000 }
1001 }
1002
1003 environment_variables.extend(service_config.environment_variables);
1005 }
1006 }
1007
1008 if !npm_dependencies.is_empty() {
1010 let npm_deps: Vec<Value> = npm_dependencies
1011 .into_iter()
1012 .map(|dep| {
1013 if let Some(_at_pos) = dep.rfind('@') {
1015 if dep.starts_with('@') && dep[1..].contains('@') {
1017 let parts: Vec<&str> = dep.rsplitn(2, '@').collect();
1019 if parts.len() == 2 {
1020 let mut dep_obj = serde_json::Map::new();
1021 dep_obj.insert(
1022 "name".to_string(),
1023 Value::String(parts[1].to_string()),
1024 );
1025 dep_obj.insert(
1026 "version".to_string(),
1027 Value::String(parts[0].to_string()),
1028 );
1029 return Value::Object(dep_obj);
1030 }
1031 } else if !dep.starts_with('@') {
1032 let parts: Vec<&str> = dep.splitn(2, '@').collect();
1034 if parts.len() == 2 {
1035 let mut dep_obj = serde_json::Map::new();
1036 dep_obj.insert(
1037 "name".to_string(),
1038 Value::String(parts[0].to_string()),
1039 );
1040 dep_obj.insert(
1041 "version".to_string(),
1042 Value::String(parts[1].to_string()),
1043 );
1044 return Value::Object(dep_obj);
1045 }
1046 }
1047 }
1048
1049 let mut dep_obj = serde_json::Map::new();
1051 dep_obj.insert("name".to_string(), Value::String(dep));
1052 dep_obj.insert("version".to_string(), Value::String("^1.0.0".to_string()));
1053 Value::Object(dep_obj)
1054 })
1055 .collect();
1056
1057 merged_deps.insert("npm".to_string(), Value::Array(npm_deps));
1058 }
1059
1060 if !cargo_dependencies.is_empty() {
1061 let cargo_map: serde_json::Map<String, Value> = cargo_dependencies
1062 .into_iter()
1063 .map(|(k, v)| (k, Value::String(v)))
1064 .collect();
1065 merged_deps.insert("cargo".to_string(), Value::Object(cargo_map));
1066 }
1067
1068 if !environment_variables.is_empty() {
1069 let env_array: Vec<Value> = environment_variables
1070 .into_iter()
1071 .map(|env_var| {
1072 let mut env_map = serde_json::Map::new();
1073 env_map.insert("name".to_string(), Value::String(env_var.name));
1074 env_map.insert(
1075 "description".to_string(),
1076 Value::String(env_var.description),
1077 );
1078 env_map.insert("required".to_string(), Value::Bool(env_var.required));
1079 if let Some(default) = env_var.default {
1080 env_map.insert("default".to_string(), Value::String(default));
1081 }
1082 Value::Object(env_map)
1083 })
1084 .collect();
1085
1086 merged_deps.insert("environment_variables".to_string(), Value::Array(env_array));
1087 }
1088
1089 Ok(merged_deps)
1090 }
1091
1092 async fn apply_conditional_inclusion(
1097 &self,
1098 files: Vec<ComposedFile>,
1099 services: &[ServiceSelection],
1100 composition_config: &Option<CompositionConfig>,
1101 ) -> EngineResult<Vec<ComposedFile>> {
1102 let mut filtered_files = Vec::new();
1103
1104 let mut context = HashMap::new();
1106
1107 for service in services {
1109 let category_key = format!("{:?}", service.category).to_lowercase();
1110 context.insert(category_key, service.provider.clone());
1111 context.insert(
1112 format!("has_{}", format!("{:?}", service.category).to_lowercase()),
1113 "true".to_string(),
1114 );
1115 }
1116
1117 for file in files {
1118 let should_include = self
1119 .evaluate_file_conditions(&file, &context, composition_config)
1120 .await?;
1121
1122 if should_include {
1123 filtered_files.push(file);
1124 }
1125 }
1126
1127 Ok(filtered_files)
1128 }
1129
1130 async fn evaluate_file_conditions(
1134 &self,
1135 file: &ComposedFile,
1136 context: &HashMap<String, String>,
1137 composition_config: &Option<CompositionConfig>,
1138 ) -> EngineResult<bool> {
1139 if let Some(config) = composition_config {
1141 for conditional_file in &config.conditional_files {
1142 if file.path == PathBuf::from(&conditional_file.path) {
1143 return self
1144 .evaluate_condition(&conditional_file.condition, context)
1145 .await;
1146 }
1147 }
1148 }
1149
1150 self.evaluate_implicit_conditions(file, context).await
1152 }
1153
1154 async fn evaluate_condition(
1162 &self,
1163 condition: &str,
1164 context: &HashMap<String, String>,
1165 ) -> EngineResult<bool> {
1166 let condition = condition.trim();
1167
1168 if condition.contains("&&") {
1170 let parts: Vec<&str> = condition.split("&&").collect();
1171 for part in parts {
1172 let result = self.evaluate_simple_condition(part.trim(), context)?;
1173 if !result {
1174 return Ok(false);
1175 }
1176 }
1177 return Ok(true);
1178 }
1179
1180 if condition.contains("||") {
1182 let parts: Vec<&str> = condition.split("||").collect();
1183 for part in parts {
1184 let result = self.evaluate_simple_condition(part.trim(), context)?;
1185 if result {
1186 return Ok(true);
1187 }
1188 }
1189 return Ok(false);
1190 }
1191
1192 self.evaluate_simple_condition(condition, context)
1194 }
1195
1196 fn evaluate_simple_condition(
1201 &self,
1202 condition: &str,
1203 context: &HashMap<String, String>,
1204 ) -> EngineResult<bool> {
1205 let condition = condition.trim();
1206
1207 if condition.contains("==") {
1209 let parts: Vec<&str> = condition.split("==").collect();
1210 if parts.len() == 2 {
1211 let left = parts[0].trim();
1212 let right = parts[1].trim().trim_matches('\'').trim_matches('"');
1213
1214 if left.starts_with("services.") {
1215 let service_type = left.strip_prefix("services.").unwrap();
1216 return Ok(context.get(service_type).map_or(false, |v| v == right));
1217 }
1218 }
1219 }
1220
1221 if condition.contains(" in ") {
1223 let parts: Vec<&str> = condition.split(" in ").collect();
1224 if parts.len() == 2 {
1225 let left = parts[0].trim();
1226 let right = parts[1].trim();
1227
1228 if left.starts_with("services.") && right.starts_with('[') && right.ends_with(']') {
1229 let service_type = left.strip_prefix("services.").unwrap();
1230 let options: Vec<&str> = right[1..right.len() - 1]
1231 .split(',')
1232 .map(|s| s.trim().trim_matches('\'').trim_matches('"'))
1233 .collect();
1234
1235 return Ok(context
1236 .get(service_type)
1237 .map_or(false, |v| options.contains(&v.as_str())));
1238 }
1239 }
1240 }
1241
1242 if condition.starts_with("has_") {
1244 return Ok(context.get(condition).map_or(false, |v| v == "true"));
1245 }
1246
1247 Ok(true)
1249 }
1250
1251 async fn evaluate_implicit_conditions(
1256 &self,
1257 file: &ComposedFile,
1258 context: &HashMap<String, String>,
1259 ) -> EngineResult<bool> {
1260 match &file.source {
1261 FileSource::BaseTemplate => {
1262 Ok(true)
1264 }
1265 FileSource::Service { category, provider } => {
1266 let category_key = format!("{:?}", category).to_lowercase();
1268 Ok(context
1269 .get(&category_key)
1270 .map_or(false, |selected| selected == provider))
1271 }
1272 FileSource::Merged => {
1273 Ok(true)
1275 }
1276 }
1277 }
1278
1279 async fn collect_environment_variables(
1283 &self,
1284 _services: &[ServiceSelection],
1285 ) -> EngineResult<HashMap<String, String>> {
1286 Ok(HashMap::new())
1288 }
1289}
1290
1291#[cfg(test)]
1292mod tests {
1293 use super::*;
1294 use tempfile::TempDir;
1295 use tokio::fs;
1296
1297 async fn create_test_structure() -> TempDir {
1298 let temp_dir = TempDir::new().unwrap();
1299 let base_path = temp_dir.path();
1300
1301 fs::create_dir_all(base_path.join("templates/test-app"))
1303 .await
1304 .unwrap();
1305 fs::write(
1306 base_path.join("templates/test-app/anvil.yaml"),
1307 r#"
1308name: "test-app"
1309description: "Test application"
1310version: "1.0.0"
1311services:
1312 - name: "auth"
1313 category: "auth"
1314 prompt: "Choose auth provider"
1315 options: ["clerk", "auth0"]
1316 required: true
1317"#,
1318 )
1319 .await
1320 .unwrap();
1321
1322 fs::create_dir_all(base_path.join("templates/shared/auth/clerk"))
1324 .await
1325 .unwrap();
1326 fs::write(
1327 base_path.join("templates/shared/auth/clerk/middleware.ts"),
1328 "// Clerk middleware",
1329 )
1330 .await
1331 .unwrap();
1332
1333 temp_dir
1334 }
1335
1336 #[tokio::test]
1337 async fn test_compose_template() {
1338 let temp_dir = create_test_structure().await;
1339 let base_path = temp_dir.path();
1340
1341 let engine = CompositionEngine::new(
1342 base_path.join("templates"),
1343 base_path.join("templates/shared"),
1344 );
1345
1346 let services = vec![ServiceSelection {
1347 category: ServiceCategory::Auth,
1348 provider: "clerk".to_string(),
1349 config: HashMap::new(),
1350 }];
1351
1352 let result = engine.compose_template("test-app", services).await;
1353 assert!(result.is_ok());
1354
1355 let composed = result.unwrap();
1356 assert_eq!(composed.base_config.name, "test-app");
1357 assert!(!composed.files.is_empty());
1358 }
1359}