1use anyhow::{anyhow, Context, Result};
2use foundry_types::{Archetype, TemplateEngineKind};
3use serde::{Deserialize, Serialize};
4use std::path::Path;
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7pub struct FoundryConfig {
8 pub project_name: String,
9 pub archetype: Archetype,
10 pub template_engine: TemplateEngineKind,
11 pub profile: String,
12 pub run_post_gen_checks: bool,
13}
14
15impl Default for FoundryConfig {
16 fn default() -> Self {
17 Self {
18 project_name: "sample-project".to_string(),
19 archetype: Archetype::Tooling,
20 template_engine: TemplateEngineKind::MiniJinja,
21 profile: "default".to_string(),
22 run_post_gen_checks: true,
23 }
24 }
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
28#[serde(deny_unknown_fields)]
29pub struct PartialConfig {
30 pub project_name: Option<String>,
31 pub archetype: Option<Archetype>,
32 pub template_engine: Option<TemplateEngineKind>,
33 pub profile: Option<String>,
34 pub run_post_gen_checks: Option<bool>,
35}
36
37pub fn load_partial_from_file(path: &Path) -> Result<PartialConfig> {
38 let content = std::fs::read_to_string(path)
39 .with_context(|| format!("failed to read config at {}", path.display()))?;
40 let value: toml::Value =
41 toml::from_str(&content).with_context(|| format!("invalid TOML at {}", path.display()))?;
42 validate_partial_config(&value)
43 .map_err(|e| anyhow!("invalid config at {}: {}", path.display(), e))?;
44 let parsed: PartialConfig = toml::from_str(&content)
45 .map_err(|e| anyhow!("invalid TOML at {}: {}", path.display(), e))?;
46 Ok(parsed)
47}
48
49fn validate_partial_config(value: &toml::Value) -> Result<()> {
50 let table = value
51 .as_table()
52 .ok_or_else(|| anyhow!("expected top-level TOML table"))?;
53
54 for (key, raw) in table {
55 match key.as_str() {
56 "project_name" | "profile" => {
57 if !raw.is_str() {
58 return Err(anyhow!(
59 "invalid type for `{}`: expected string; omit the field to use layered defaults",
60 key
61 ));
62 }
63 }
64 "archetype" => {
65 let Some(v) = raw.as_str() else {
66 return Err(anyhow!(
67 "invalid type for `archetype`: expected string; omit the field to use layered defaults"
68 ));
69 };
70 if !matches!(v, "web" | "tui" | "tooling") {
71 return Err(anyhow!(
72 "invalid value for `archetype`: `{}`; allowed: web, tui, tooling",
73 v
74 ));
75 }
76 }
77 "template_engine" => {
78 let Some(v) = raw.as_str() else {
79 return Err(anyhow!(
80 "invalid type for `template_engine`: expected string; omit the field to use layered defaults"
81 ));
82 };
83 if !matches!(v, "handlebars" | "mini_jinja") {
84 return Err(anyhow!(
85 "invalid value for `template_engine`: `{}`; allowed: handlebars, mini_jinja",
86 v
87 ));
88 }
89 }
90 "run_post_gen_checks" => {
91 if !raw.is_bool() {
92 return Err(anyhow!(
93 "invalid type for `run_post_gen_checks`: expected boolean; omit the field to use layered defaults"
94 ));
95 }
96 }
97 _ => {
98 return Err(anyhow!(
99 "unknown field `{}`; allowed keys: project_name, archetype, template_engine, profile, run_post_gen_checks",
100 key
101 ));
102 }
103 }
104 }
105
106 Ok(())
107}
108
109pub fn merge(base: &FoundryConfig, partial: &PartialConfig) -> FoundryConfig {
110 FoundryConfig {
111 project_name: partial
112 .project_name
113 .clone()
114 .unwrap_or_else(|| base.project_name.clone()),
115 archetype: partial
116 .archetype
117 .clone()
118 .unwrap_or_else(|| base.archetype.clone()),
119 template_engine: partial
120 .template_engine
121 .clone()
122 .unwrap_or_else(|| base.template_engine.clone()),
123 profile: partial
124 .profile
125 .clone()
126 .unwrap_or_else(|| base.profile.clone()),
127 run_post_gen_checks: partial
128 .run_post_gen_checks
129 .unwrap_or(base.run_post_gen_checks),
130 }
131}