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        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}