shaperail_codegen/
config_parser.rs1use shaperail_core::ProjectConfig;
2
3use crate::parser::ParseError;
4
5pub fn parse_config(yaml: &str) -> Result<ProjectConfig, ParseError> {
7 let interpolated = interpolate_env_placeholders(yaml)?;
8 let config: ProjectConfig = serde_yaml::from_str(&interpolated)?;
9 Ok(config)
10}
11
12pub fn parse_config_file(path: &std::path::Path) -> Result<ProjectConfig, ParseError> {
14 let content = std::fs::read_to_string(path)?;
15 parse_config(&content)
16}
17
18pub fn interpolate_env(yaml: &str) -> Result<String, ParseError> {
21 interpolate_env_placeholders(yaml)
22}
23
24fn interpolate_env_placeholders(yaml: &str) -> Result<String, ParseError> {
25 let mut result = String::with_capacity(yaml.len());
26 let mut index = 0usize;
27
28 while let Some(offset) = yaml[index..].find("${") {
29 let start = index + offset;
30 result.push_str(&yaml[index..start]);
31
32 let placeholder_start = start + 2;
33 let end = yaml[placeholder_start..]
34 .find('}')
35 .map(|pos| placeholder_start + pos)
36 .ok_or_else(|| {
37 ParseError::ConfigInterpolation(
38 "unterminated environment placeholder in shaperail.config.yaml".to_string(),
39 )
40 })?;
41
42 let placeholder = &yaml[placeholder_start..end];
43 if placeholder.is_empty() {
44 return Err(ParseError::ConfigInterpolation(
45 "empty environment placeholder in shaperail.config.yaml".to_string(),
46 ));
47 }
48
49 let (name, default) = match placeholder.split_once(':') {
50 Some((name, default)) => (name, Some(default)),
51 None => (placeholder, None),
52 };
53
54 if name.is_empty() {
55 return Err(ParseError::ConfigInterpolation(
56 "environment placeholder is missing a variable name".to_string(),
57 ));
58 }
59
60 let value = match std::env::var(name) {
61 Ok(value) => value,
62 Err(_) => match default {
63 Some(default) => default.to_string(),
64 None => {
65 return Err(ParseError::ConfigInterpolation(format!(
66 "environment variable '{name}' is not set"
67 )))
68 }
69 },
70 };
71
72 result.push_str(&value);
73 index = end + 1;
74 }
75
76 result.push_str(&yaml[index..]);
77 Ok(result)
78}
79
80#[cfg(test)]
81mod tests {
82 use super::*;
83 use shaperail_core::WorkerCount;
84
85 #[test]
86 fn parse_minimal_config() {
87 let yaml = r#"
88project: my-app
89"#;
90 let cfg = parse_config(yaml).unwrap();
91 assert_eq!(cfg.project, "my-app");
92 assert_eq!(cfg.port, 3000);
93 assert_eq!(cfg.workers, WorkerCount::Auto);
94 }
95
96 #[test]
97 fn parse_full_config() {
98 let yaml = r#"
99project: my-api
100port: 8080
101workers: 4
102
103databases:
104 default:
105 engine: postgres
106 url: postgresql://localhost/my_api_db
107 pool_size: 20
108
109cache:
110 type: redis
111 url: redis://localhost:6379
112
113auth:
114 provider: jwt
115 secret_env: JWT_SECRET
116 expiry: 24h
117 refresh_expiry: 30d
118
119storage:
120 provider: s3
121 bucket: my-bucket
122 region: us-east-1
123
124logging:
125 level: info
126 format: json
127 otlp_endpoint: http://localhost:4317
128"#;
129 let cfg = parse_config(yaml).unwrap();
130 assert_eq!(cfg.project, "my-api");
131 assert_eq!(cfg.port, 8080);
132 assert_eq!(cfg.workers, WorkerCount::Fixed(4));
133 let dbs = cfg.databases.as_ref().unwrap();
134 assert_eq!(
135 dbs.get("default").unwrap().url,
136 "postgresql://localhost/my_api_db"
137 );
138 let auth = cfg.auth.unwrap();
139 assert_eq!(auth.provider, "jwt");
140 }
141
142 #[test]
143 fn parse_config_error_missing_project() {
144 let yaml = "port: 3000";
145 let err = parse_config(yaml).unwrap_err();
146 assert!(err.to_string().contains("missing field"));
147 }
148
149 #[test]
150 fn parse_config_interpolates_env_vars() {
151 let yaml = r#"
152project: ${SHAPERAIL_TEST_PROJECT}
153databases:
154 default:
155 engine: postgres
156 url: postgresql://localhost/${SHAPERAIL_TEST_DB:test_db}
157"#;
158 std::env::set_var("SHAPERAIL_TEST_PROJECT", "shaperail-ai");
159 std::env::remove_var("SHAPERAIL_TEST_DB");
160
161 let cfg = parse_config(yaml).unwrap();
162 assert_eq!(cfg.project, "shaperail-ai");
163 let dbs = cfg.databases.as_ref().unwrap();
164 assert_eq!(
165 dbs.get("default").unwrap().url,
166 "postgresql://localhost/test_db"
167 );
168
169 std::env::remove_var("SHAPERAIL_TEST_PROJECT");
170 }
171
172 #[test]
173 fn parse_config_databases_multi_db() {
174 let yaml = r#"
175project: multi-db-app
176databases:
177 default:
178 engine: postgres
179 url: postgresql://localhost/main
180 pool_size: 10
181 analytics:
182 engine: postgres
183 url: postgresql://localhost/analytics
184"#;
185 let cfg = parse_config(yaml).unwrap();
186 let dbs = cfg.databases.as_ref().unwrap();
187 assert_eq!(dbs.len(), 2);
188 assert!(dbs.contains_key("default"));
189 assert!(dbs.contains_key("analytics"));
190 assert_eq!(
191 dbs.get("default").unwrap().url,
192 "postgresql://localhost/main"
193 );
194 }
195
196 #[test]
197 fn parse_config_unknown_key_fails() {
198 let yaml = r#"
199project: my-app
200port: 3000
201unknown: true
202"#;
203 let err = parse_config(yaml).unwrap_err();
204 assert!(err.to_string().contains("unknown field"));
205 assert!(err.to_string().contains("unknown"));
206 }
207
208 #[test]
209 fn parse_config_legacy_database_field_rejected() {
210 let yaml = r#"
213project: legacy-app
214database:
215 type: postgresql
216 name: legacy_db
217"#;
218 let err = parse_config(yaml)
219 .expect_err("legacy `database:` block should be rejected after v0.11");
220 let msg = err.to_string();
221 assert!(
222 msg.contains("unknown field") && msg.contains("database"),
223 "expected error to name the rejected `database` field, got: {msg}"
224 );
225 }
226
227 #[test]
228 fn parse_config_missing_env_without_default_fails() {
229 std::env::remove_var("SHAPERAIL_TEST_MISSING");
230 let yaml = "project: ${SHAPERAIL_TEST_MISSING}";
231 let err = parse_config(yaml).unwrap_err();
232 assert!(err.to_string().contains("SHAPERAIL_TEST_MISSING"));
233 }
234
235 #[test]
236 fn parse_config_protocols() {
237 let yaml = r#"
238project: gql-api
239protocols:
240 - rest
241 - graphql
242"#;
243 let cfg = parse_config(yaml).unwrap();
244 assert_eq!(cfg.protocols, vec!["rest", "graphql"]);
245 }
246}