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