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 assert!(cfg.database.is_none());
95 }
96
97 #[test]
98 fn parse_full_config() {
99 let yaml = r#"
100project: my-api
101port: 8080
102workers: 4
103
104database:
105 type: postgresql
106 host: localhost
107 port: 5432
108 name: my_api_db
109 pool_size: 20
110
111cache:
112 type: redis
113 url: redis://localhost:6379
114
115auth:
116 provider: jwt
117 secret_env: JWT_SECRET
118 expiry: 24h
119 refresh_expiry: 30d
120
121storage:
122 provider: s3
123 bucket: my-bucket
124 region: us-east-1
125
126logging:
127 level: info
128 format: json
129 otlp_endpoint: http://localhost:4317
130"#;
131 let cfg = parse_config(yaml).unwrap();
132 assert_eq!(cfg.project, "my-api");
133 assert_eq!(cfg.port, 8080);
134 assert_eq!(cfg.workers, WorkerCount::Fixed(4));
135 let db = cfg.database.unwrap();
136 assert_eq!(db.db_type, "postgresql");
137 assert_eq!(db.name, "my_api_db");
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}
153database:
154 type: postgresql
155 name: ${SHAPERAIL_TEST_DB:test_db}
156"#;
157 std::env::set_var("SHAPERAIL_TEST_PROJECT", "shaperail-ai");
158 std::env::remove_var("SHAPERAIL_TEST_DB");
159
160 let cfg = parse_config(yaml).unwrap();
161 assert_eq!(cfg.project, "shaperail-ai");
162 assert_eq!(cfg.database.unwrap().name, "test_db");
163
164 std::env::remove_var("SHAPERAIL_TEST_PROJECT");
165 }
166
167 #[test]
168 fn parse_config_databases_multi_db() {
169 let yaml = r#"
170project: multi-db-app
171databases:
172 default:
173 engine: postgres
174 url: postgresql://localhost/main
175 pool_size: 10
176 analytics:
177 engine: postgres
178 url: postgresql://localhost/analytics
179"#;
180 let cfg = parse_config(yaml).unwrap();
181 let dbs = cfg.databases.as_ref().unwrap();
182 assert_eq!(dbs.len(), 2);
183 assert!(dbs.contains_key("default"));
184 assert!(dbs.contains_key("analytics"));
185 assert_eq!(
186 dbs.get("default").unwrap().url,
187 "postgresql://localhost/main"
188 );
189 }
190
191 #[test]
192 fn parse_config_unknown_key_fails() {
193 let yaml = r#"
194project: my-app
195port: 3000
196unknown: true
197"#;
198 let err = parse_config(yaml).unwrap_err();
199 assert!(err.to_string().contains("unknown field"));
200 assert!(err.to_string().contains("unknown"));
201 }
202
203 #[test]
204 fn parse_config_missing_env_without_default_fails() {
205 std::env::remove_var("SHAPERAIL_TEST_MISSING");
206 let yaml = "project: ${SHAPERAIL_TEST_MISSING}";
207 let err = parse_config(yaml).unwrap_err();
208 assert!(err.to_string().contains("SHAPERAIL_TEST_MISSING"));
209 }
210
211 #[test]
212 fn parse_config_protocols() {
213 let yaml = r#"
214project: gql-api
215protocols:
216 - rest
217 - graphql
218"#;
219 let cfg = parse_config(yaml).unwrap();
220 assert_eq!(cfg.protocols, vec!["rest", "graphql"]);
221 }
222}