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
18/// Interpolate `${VAR}` and `${VAR:default}` placeholders in YAML text.
19/// Public so workspace_parser can reuse it.
20pub 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        // The singular `database:` block was removed in v0.11. Existing configs
211        // must fail loudly so users migrate to `databases.default:` or DATABASE_URL.
212        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}