Skip to main content

shaperail_codegen/
config_parser.rs

1use shaperail_core::ProjectConfig;
2
3use crate::parser::ParseError;
4
5/// Parse a YAML string into a `ProjectConfig`.
6pub 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
12/// Parse a shaperail.config.yaml file from disk.
13pub 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}