1use crate::error::{EngineError, EngineResult};
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::collections::HashMap;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub struct TemplateConfig {
9 pub name: String,
10 pub description: String,
11 pub version: String,
12
13 #[serde(default)]
14 pub variables: Vec<TemplateVariable>,
15
16 #[serde(default)]
17 pub features: Vec<Feature>,
18
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub hooks: Option<Hooks>,
21
22 #[serde(default = "default_min_anvil_version")]
23 pub min_anvil_version: String,
24
25 #[serde(default)]
27 pub services: Vec<ServiceDefinition>,
28
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub composition: Option<CompositionConfig>,
31
32 #[serde(default)]
33 pub service_combinations: Vec<ServiceCombination>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct TemplateVariable {
38 pub name: String,
39 #[serde(rename = "type")]
40 pub var_type: VariableType,
41 pub prompt: String,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub default: Option<serde_yaml::Value>,
44 #[serde(default)]
45 pub required: bool,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(tag = "type", rename_all = "snake_case")]
50pub enum VariableType {
51 String {
52 #[serde(default)]
53 min_length: usize,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 max_length: Option<usize>,
56 },
57 Boolean,
58 Choice {
59 options: Vec<String>,
60 },
61 Number {
62 #[serde(skip_serializing_if = "Option::is_none")]
63 min: Option<i64>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 max: Option<i64>,
66 },
67}
68
69impl VariableType {
70 pub fn type_name(&self) -> String {
71 match self {
72 VariableType::String { .. } => "string".to_string(),
73 VariableType::Boolean => "boolean".to_string(),
74 VariableType::Choice { .. } => "choice".to_string(),
75 VariableType::Number { .. } => "number".to_string(),
76 }
77 }
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct Feature {
82 pub name: String,
83 pub description: String,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub enabled_when: Option<String>,
86 #[serde(default)]
87 pub dependencies: Vec<String>,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct Hooks {
92 #[serde(skip_serializing_if = "Option::is_none")]
93 pub pre_generate: Option<Vec<HookCommand>>,
94 #[serde(skip_serializing_if = "Option::is_none")]
95 pub post_generate: Option<Vec<HookCommand>>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct HookCommand {
100 pub command: String,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 pub working_dir: Option<String>,
103 #[serde(skip_serializing_if = "Option::is_none")]
104 pub condition: Option<String>,
105 #[serde(default)]
106 pub env: HashMap<String, String>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(rename_all = "snake_case")]
111pub struct ServiceDefinition {
112 pub name: String,
113 pub category: ServiceCategory,
114 pub prompt: String,
115 pub options: Vec<String>,
116 #[serde(default)]
117 pub required: bool,
118 #[serde(skip_serializing_if = "Option::is_none")]
119 pub default: Option<String>,
120 #[serde(skip_serializing_if = "Option::is_none")]
121 pub dependencies: Option<Vec<String>>,
122 #[serde(skip_serializing_if = "Option::is_none")]
123 pub conflicts: Option<Vec<String>>,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub language_requirements: Option<Vec<String>>,
126 #[serde(skip_serializing_if = "Option::is_none")]
127 pub platform_requirements: Option<Vec<String>>,
128 #[serde(skip_serializing_if = "Option::is_none")]
129 pub compatibility_rules: Option<Vec<CompatibilityRule>>,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
133#[serde(rename_all = "lowercase")]
134pub enum ServiceCategory {
135 Auth,
136 Payments,
137 Database,
138 #[serde(rename = "ai")]
139 AI,
140 Api,
141 Deployment,
142 Monitoring,
143 Email,
144 Storage,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct ServiceConfig {
149 pub name: String,
150 pub description: String,
151 pub version: String,
152 pub category: String,
153
154 #[serde(skip_serializing_if = "Option::is_none")]
155 pub dependencies: Option<ServiceDependencies>,
156
157 #[serde(default)]
158 pub environment_variables: Vec<EnvironmentVariable>,
159
160 #[serde(default)]
161 pub files: Vec<ServiceFile>,
162
163 #[serde(default)]
164 pub configuration_prompts: Vec<ServicePrompt>,
165
166 #[serde(default)]
167 #[serde(skip_serializing_if = "Option::is_none")]
168 pub language_requirements: Option<Vec<String>>,
169
170 #[serde(default)]
171 #[serde(skip_serializing_if = "Option::is_none")]
172 pub compatibility_rules: Option<Vec<CompatibilityRule>>,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct ServicePrompt {
177 pub name: String,
178 pub prompt: String,
179 pub prompt_type: ServicePromptType,
180 #[serde(default)]
181 pub required: bool,
182 #[serde(default)]
183 #[serde(skip_serializing_if = "Option::is_none")]
184 #[serde(deserialize_with = "deserialize_default_value")]
185 pub default: Option<Value>,
186 #[serde(skip_serializing_if = "Option::is_none")]
187 pub options: Option<Vec<String>>,
188 #[serde(skip_serializing_if = "Option::is_none")]
189 pub description: Option<String>,
190}
191
192fn deserialize_default_value<'de, D>(deserializer: D) -> Result<Option<Value>, D::Error>
194where
195 D: serde::Deserializer<'de>,
196{
197 use serde::Deserialize;
198 Option::<Value>::deserialize(deserializer)
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
202#[serde(rename_all = "snake_case")]
203pub enum ServicePromptType {
204 Text,
205 Boolean,
206 Select,
207 MultiSelect,
208 Password,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct ServiceDependencies {
213 #[serde(skip_serializing_if = "Option::is_none")]
214 pub npm: Option<Vec<String>>,
215
216 #[serde(skip_serializing_if = "Option::is_none")]
217 pub cargo: Option<HashMap<String, String>>,
218
219 #[serde(skip_serializing_if = "Option::is_none")]
220 pub go: Option<Vec<String>>,
221
222 #[serde(skip_serializing_if = "Option::is_none")]
223 pub python: Option<Vec<String>>,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct EnvironmentVariable {
228 pub name: String,
229 pub description: String,
230 #[serde(default)]
231 pub required: bool,
232 #[serde(skip_serializing_if = "Option::is_none")]
233 pub default: Option<String>,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct ServiceFile {
238 pub path: String,
239 pub description: String,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct CompositionConfig {
244 #[serde(default)]
245 pub file_merging_strategy: FileMergingStrategy,
246 #[serde(default)]
247 pub dependency_resolution: DependencyResolution,
248 #[serde(default)]
249 pub conditional_files: Vec<ConditionalFile>,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
253#[serde(rename_all = "snake_case")]
254pub enum FileMergingStrategy {
255 Append,
256 Merge,
257 Override,
258 Skip,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize)]
262#[serde(rename_all = "snake_case")]
263pub enum DependencyResolution {
264 Auto,
265 Manual,
266 Strict,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct ConditionalFile {
271 pub path: String,
272 pub condition: String,
273 #[serde(skip_serializing_if = "Option::is_none")]
274 pub source_service: Option<String>,
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct CompatibilityRule {
279 pub rule_type: CompatibilityRuleType,
280 pub target_service: String,
281 pub condition: String,
282 pub message: String,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
286#[serde(rename_all = "snake_case")]
287pub enum CompatibilityRuleType {
288 Requires,
289 ConflictsWith,
290 RecommendsAgainst,
291 RequiresLanguage,
292 RequiresPlatform,
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct ServiceCombination {
297 pub name: String,
298 pub description: String,
299 pub services: Vec<ServiceSpec>,
300 #[serde(default)]
301 pub recommended: bool,
302 #[serde(skip_serializing_if = "Option::is_none")]
303 pub tags: Option<Vec<String>>,
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct ServiceSpec {
308 pub category: ServiceCategory,
309 pub provider: String,
310 #[serde(default)]
311 pub config: std::collections::HashMap<String, serde_json::Value>,
312}
313
314impl TemplateConfig {
315 pub async fn from_file(path: &std::path::Path) -> EngineResult<Self> {
316 let content = tokio::fs::read_to_string(path)
317 .await
318 .map_err(|e| EngineError::file_error(path, e))?;
319
320 let config: TemplateConfig = serde_yaml::from_str(&content)?;
321 config.validate()?;
322 Ok(config)
323 }
324
325 pub fn validate(&self) -> EngineResult<()> {
326 if self.name.is_empty() {
327 return Err(EngineError::invalid_config("Template name cannot be empty"));
328 }
329
330 if self.description.is_empty() {
331 return Err(EngineError::invalid_config(
332 "Template description cannot be empty",
333 ));
334 }
335
336 semver::Version::parse(&self.version)
337 .map_err(|_| EngineError::invalid_config("Invalid version format"))?;
338
339 semver::Version::parse(&self.min_anvil_version)
340 .map_err(|_| EngineError::invalid_config("Invalid min_anvil_version format"))?;
341
342 for variable in &self.variables {
343 variable.validate()?;
344 }
345
346 for feature in &self.features {
347 feature.validate()?;
348 }
349
350 Ok(())
351 }
352
353 pub fn get_variable(&self, name: &str) -> Option<&TemplateVariable> {
354 self.variables.iter().find(|v| v.name == name)
355 }
356
357 pub fn get_feature(&self, name: &str) -> Option<&Feature> {
358 self.features.iter().find(|f| f.name == name)
359 }
360}
361
362impl TemplateVariable {
363 pub fn validate(&self) -> EngineResult<()> {
364 if self.name.is_empty() {
365 return Err(EngineError::invalid_config("Variable name cannot be empty"));
366 }
367
368 if self.prompt.is_empty() {
369 return Err(EngineError::invalid_config(format!(
370 "Variable '{}' must have a prompt",
371 self.name
372 )));
373 }
374
375 match &self.var_type {
376 VariableType::String {
377 min_length,
378 max_length,
379 } => {
380 if let Some(max) = max_length {
381 if *min_length > *max {
382 return Err(EngineError::invalid_config(format!(
383 "Variable '{}': min_length cannot be greater than max_length",
384 self.name
385 )));
386 }
387 }
388 }
389 VariableType::Choice { options } => {
390 if options.is_empty() {
391 return Err(EngineError::invalid_config(format!(
392 "Variable '{}': choice type must have at least one option",
393 self.name
394 )));
395 }
396 }
397 VariableType::Number { min, max } => {
398 if let (Some(min_val), Some(max_val)) = (min, max) {
399 if min_val > max_val {
400 return Err(EngineError::invalid_config(format!(
401 "Variable '{}': min cannot be greater than max",
402 self.name
403 )));
404 }
405 }
406 }
407 VariableType::Boolean => {}
408 }
409
410 Ok(())
411 }
412
413 pub fn validate_value(&self, value: &serde_yaml::Value) -> EngineResult<()> {
414 match (&self.var_type, value) {
415 (
416 VariableType::String {
417 min_length,
418 max_length,
419 },
420 serde_yaml::Value::String(s),
421 ) => {
422 if s.len() < *min_length {
423 return Err(EngineError::variable_error(
424 &self.name,
425 format!("String too short (minimum {} characters)", min_length),
426 ));
427 }
428 if let Some(max) = max_length {
429 if s.len() > *max {
430 return Err(EngineError::variable_error(
431 &self.name,
432 format!("String too long (maximum {} characters)", max),
433 ));
434 }
435 }
436 }
437 (VariableType::Boolean, serde_yaml::Value::Bool(_)) => {}
438 (VariableType::Number { min, max }, serde_yaml::Value::Number(n)) => {
439 if let Some(i) = n.as_i64() {
440 if let Some(min_val) = min {
441 if i < *min_val {
442 return Err(EngineError::variable_error(
443 &self.name,
444 format!("Number too small (minimum {})", min_val),
445 ));
446 }
447 }
448 if let Some(max_val) = max {
449 if i > *max_val {
450 return Err(EngineError::variable_error(
451 &self.name,
452 format!("Number too large (maximum {})", max_val),
453 ));
454 }
455 }
456 }
457 }
458 (VariableType::Choice { options }, serde_yaml::Value::String(s)) => {
459 if !options.contains(s) {
460 return Err(EngineError::variable_error(
461 &self.name,
462 format!(
463 "Invalid choice '{}'. Valid options: {}",
464 s,
465 options.join(", ")
466 ),
467 ));
468 }
469 }
470 _ => {
471 return Err(EngineError::variable_error(
472 &self.name,
473 format!("Value type mismatch for variable type {:?}", self.var_type),
474 ));
475 }
476 }
477 Ok(())
478 }
479}
480
481impl Feature {
482 pub fn validate(&self) -> EngineResult<()> {
483 if self.name.is_empty() {
484 return Err(EngineError::invalid_config("Feature name cannot be empty"));
485 }
486
487 if self.description.is_empty() {
488 return Err(EngineError::invalid_config(format!(
489 "Feature '{}' must have a description",
490 self.name
491 )));
492 }
493
494 Ok(())
495 }
496}
497
498fn default_min_anvil_version() -> String {
499 "0.1.0".to_string()
500}
501
502impl Default for FileMergingStrategy {
503 fn default() -> Self {
504 FileMergingStrategy::Merge
505 }
506}
507
508impl Default for DependencyResolution {
509 fn default() -> Self {
510 DependencyResolution::Auto
511 }
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517 use std::io::Write;
518 use tempfile::NamedTempFile;
519
520 #[tokio::test]
521 async fn test_valid_config_parsing() {
522 let yaml_content = r#"
523name: "test-template"
524description: "A test template"
525version: "1.0.0"
526variables:
527 - name: "project_name"
528 type:
529 type: "string"
530 min_length: 1
531 prompt: "Project name?"
532 required: true
533features:
534 - name: "database"
535 description: "Database integration"
536"#;
537
538 let mut temp_file = NamedTempFile::new().unwrap();
539 temp_file.write_all(yaml_content.as_bytes()).unwrap();
540
541 let config = TemplateConfig::from_file(temp_file.path()).await.unwrap();
542 assert_eq!(config.name, "test-template");
543 assert_eq!(config.variables.len(), 1);
544 assert_eq!(config.features.len(), 1);
545 }
546
547 #[test]
548 fn test_config_validation() {
549 let mut config = TemplateConfig {
550 name: "test".to_string(),
551 description: "Test template".to_string(),
552 version: "1.0.0".to_string(),
553 variables: vec![],
554 features: vec![],
555 hooks: None,
556 min_anvil_version: "0.1.0".to_string(),
557 services: vec![],
558 composition: None,
559 service_combinations: vec![],
560 };
561
562 assert!(config.validate().is_ok());
563
564 config.name = "".to_string();
565 assert!(config.validate().is_err());
566
567 config.name = "test".to_string();
568 config.version = "invalid-version".to_string();
569 assert!(config.validate().is_err());
570 }
571
572 #[test]
573 fn test_variable_validation() {
574 let variable = TemplateVariable {
575 name: "test_var".to_string(),
576 var_type: VariableType::String {
577 min_length: 1,
578 max_length: Some(10),
579 },
580 prompt: "Test variable?".to_string(),
581 default: None,
582 required: true,
583 };
584
585 assert!(variable.validate().is_ok());
586
587 assert!(
588 variable
589 .validate_value(&serde_yaml::Value::String("test".to_string()))
590 .is_ok()
591 );
592 assert!(
593 variable
594 .validate_value(&serde_yaml::Value::String("".to_string()))
595 .is_err()
596 );
597 assert!(
598 variable
599 .validate_value(&serde_yaml::Value::String("this_is_too_long".to_string()))
600 .is_err()
601 );
602 }
603}
604
605impl ServiceConfig {
606 pub async fn from_file(path: &std::path::Path) -> EngineResult<Self> {
607 let content = tokio::fs::read_to_string(path)
608 .await
609 .map_err(|e| EngineError::file_error(path, e))?;
610
611 let config: ServiceConfig = serde_yaml::from_str(&content)?;
612 Ok(config)
613 }
614}