Skip to main content

shaperail_codegen/
workspace_parser.rs

1use shaperail_core::{SagaDefinition, WorkspaceConfig};
2
3use crate::parser::ParseError;
4
5/// Parse a YAML string into a `WorkspaceConfig`.
6pub fn parse_workspace(yaml: &str) -> Result<WorkspaceConfig, ParseError> {
7    let interpolated = crate::config_parser::interpolate_env(yaml)?;
8    let config: WorkspaceConfig = serde_yaml::from_str(&interpolated)?;
9    validate_workspace(&config)?;
10    Ok(config)
11}
12
13/// Parse a `shaperail.workspace.yaml` file from disk.
14pub fn parse_workspace_file(path: &std::path::Path) -> Result<WorkspaceConfig, ParseError> {
15    let content = std::fs::read_to_string(path)?;
16    parse_workspace(&content)
17}
18
19/// Parse a saga YAML string into a `SagaDefinition`.
20pub fn parse_saga(yaml: &str) -> Result<SagaDefinition, ParseError> {
21    let saga: SagaDefinition = serde_yaml::from_str(yaml)?;
22    validate_saga(&saga)?;
23    Ok(saga)
24}
25
26/// Parse a saga YAML file from disk.
27pub fn parse_saga_file(path: &std::path::Path) -> Result<SagaDefinition, ParseError> {
28    let content = std::fs::read_to_string(path)?;
29    parse_saga(&content)
30}
31
32/// Validate workspace config: no duplicate ports, valid depends_on references.
33fn validate_workspace(config: &WorkspaceConfig) -> Result<(), ParseError> {
34    if config.workspace.is_empty() {
35        return Err(ParseError::ConfigInterpolation(
36            "workspace name cannot be empty".to_string(),
37        ));
38    }
39
40    if config.services.is_empty() {
41        return Err(ParseError::ConfigInterpolation(
42            "workspace must declare at least one service".to_string(),
43        ));
44    }
45
46    // Check depends_on references
47    for (name, svc) in &config.services {
48        for dep in &svc.depends_on {
49            if !config.services.contains_key(dep) {
50                return Err(ParseError::ConfigInterpolation(format!(
51                    "service '{name}': depends_on references unknown service '{dep}'"
52                )));
53            }
54            if dep == name {
55                return Err(ParseError::ConfigInterpolation(format!(
56                    "service '{name}': cannot depend on itself"
57                )));
58            }
59        }
60    }
61
62    // Check for circular dependencies via topological sort
63    if has_circular_deps(config) {
64        return Err(ParseError::ConfigInterpolation(
65            "workspace has circular service dependencies".to_string(),
66        ));
67    }
68
69    // Check for duplicate ports
70    let mut ports: std::collections::HashMap<u16, &str> = std::collections::HashMap::new();
71    for (name, svc) in &config.services {
72        if let Some(existing) = ports.get(&svc.port) {
73            return Err(ParseError::ConfigInterpolation(format!(
74                "services '{existing}' and '{name}' use the same port {port}",
75                port = svc.port
76            )));
77        }
78        ports.insert(svc.port, name);
79    }
80
81    Ok(())
82}
83
84fn has_circular_deps(config: &WorkspaceConfig) -> bool {
85    let mut visited = std::collections::HashSet::new();
86    let mut in_stack = std::collections::HashSet::new();
87
88    for name in config.services.keys() {
89        if !visited.contains(name.as_str()) && dfs_cycle(config, name, &mut visited, &mut in_stack)
90        {
91            return true;
92        }
93    }
94    false
95}
96
97fn dfs_cycle<'a>(
98    config: &'a WorkspaceConfig,
99    node: &'a str,
100    visited: &mut std::collections::HashSet<&'a str>,
101    in_stack: &mut std::collections::HashSet<&'a str>,
102) -> bool {
103    visited.insert(node);
104    in_stack.insert(node);
105
106    if let Some(svc) = config.services.get(node) {
107        for dep in &svc.depends_on {
108            if !visited.contains(dep.as_str()) {
109                if dfs_cycle(config, dep, visited, in_stack) {
110                    return true;
111                }
112            } else if in_stack.contains(dep.as_str()) {
113                return true;
114            }
115        }
116    }
117
118    in_stack.remove(node);
119    false
120}
121
122/// Validate saga definition: unique step names, valid action format.
123fn validate_saga(saga: &SagaDefinition) -> Result<(), ParseError> {
124    if saga.saga.is_empty() {
125        return Err(ParseError::ConfigInterpolation(
126            "saga name cannot be empty".to_string(),
127        ));
128    }
129
130    if saga.steps.is_empty() {
131        return Err(ParseError::ConfigInterpolation(format!(
132            "saga '{}': must have at least one step",
133            saga.saga
134        )));
135    }
136
137    let mut step_names = std::collections::HashSet::new();
138    for step in &saga.steps {
139        if !step_names.insert(&step.name) {
140            return Err(ParseError::ConfigInterpolation(format!(
141                "saga '{}': duplicate step name '{}'",
142                saga.saga, step.name
143            )));
144        }
145
146        // Validate action format: "METHOD /path"
147        if !is_valid_action(&step.action) {
148            return Err(ParseError::ConfigInterpolation(format!(
149                "saga '{}' step '{}': action must be 'METHOD /path' (e.g. 'POST /v1/items'), got '{}'",
150                saga.saga, step.name, step.action
151            )));
152        }
153
154        if !is_valid_action(&step.compensate) {
155            return Err(ParseError::ConfigInterpolation(format!(
156                "saga '{}' step '{}': compensate must be 'METHOD /path', got '{}'",
157                saga.saga, step.name, step.compensate
158            )));
159        }
160    }
161
162    Ok(())
163}
164
165fn is_valid_action(action: &str) -> bool {
166    let parts: Vec<&str> = action.splitn(2, ' ').collect();
167    if parts.len() != 2 {
168        return false;
169    }
170    let method = parts[0];
171    let path = parts[1];
172    matches!(method, "GET" | "POST" | "PUT" | "PATCH" | "DELETE") && path.starts_with('/')
173}
174
175/// Compute topological order of services (dependency-first).
176/// Returns service names in startup order.
177pub fn topological_order(config: &WorkspaceConfig) -> Vec<String> {
178    let mut visited = std::collections::HashSet::new();
179    let mut order = Vec::new();
180
181    for name in config.services.keys() {
182        if !visited.contains(name.as_str()) {
183            topo_visit(config, name, &mut visited, &mut order);
184        }
185    }
186
187    order
188}
189
190fn topo_visit<'a>(
191    config: &'a WorkspaceConfig,
192    node: &'a str,
193    visited: &mut std::collections::HashSet<&'a str>,
194    order: &mut Vec<String>,
195) {
196    visited.insert(node);
197
198    if let Some(svc) = config.services.get(node) {
199        for dep in &svc.depends_on {
200            if !visited.contains(dep.as_str()) {
201                topo_visit(config, dep, visited, order);
202            }
203        }
204    }
205
206    order.push(node.to_string());
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn parse_workspace_minimal() {
215        let yaml = r#"
216workspace: my-platform
217services:
218  api:
219    path: services/api
220    port: 3001
221"#;
222        let cfg = parse_workspace(yaml).unwrap();
223        assert_eq!(cfg.workspace, "my-platform");
224        assert_eq!(cfg.services.len(), 1);
225        assert_eq!(cfg.services.get("api").unwrap().port, 3001);
226    }
227
228    #[test]
229    fn parse_workspace_full() {
230        let yaml = r#"
231workspace: my-platform
232services:
233  users-api:
234    path: services/users-api
235    port: 3001
236  orders-api:
237    path: services/orders-api
238    port: 3002
239    depends_on: [users-api]
240shared:
241  cache:
242    type: redis
243    url: redis://localhost:6379
244  auth:
245    provider: jwt
246    secret_env: JWT_SECRET
247    expiry: 24h
248"#;
249        let cfg = parse_workspace(yaml).unwrap();
250        assert_eq!(cfg.services.len(), 2);
251        let orders = cfg.services.get("orders-api").unwrap();
252        assert_eq!(orders.depends_on, vec!["users-api"]);
253        assert!(cfg.shared.is_some());
254    }
255
256    #[test]
257    fn parse_workspace_empty_name_fails() {
258        let yaml = r#"
259workspace: ""
260services:
261  api:
262    path: services/api
263"#;
264        let err = parse_workspace(yaml).unwrap_err();
265        assert!(err.to_string().contains("cannot be empty"));
266    }
267
268    #[test]
269    fn parse_workspace_no_services_fails() {
270        let yaml = r#"
271workspace: test
272services: {}
273"#;
274        let err = parse_workspace(yaml).unwrap_err();
275        assert!(err.to_string().contains("at least one service"));
276    }
277
278    #[test]
279    fn parse_workspace_unknown_dependency_fails() {
280        let yaml = r#"
281workspace: test
282services:
283  api:
284    path: services/api
285    depends_on: [nonexistent]
286"#;
287        let err = parse_workspace(yaml).unwrap_err();
288        assert!(err.to_string().contains("unknown service 'nonexistent'"));
289    }
290
291    #[test]
292    fn parse_workspace_self_dependency_fails() {
293        let yaml = r#"
294workspace: test
295services:
296  api:
297    path: services/api
298    depends_on: [api]
299"#;
300        let err = parse_workspace(yaml).unwrap_err();
301        assert!(err.to_string().contains("cannot depend on itself"));
302    }
303
304    #[test]
305    fn parse_workspace_circular_dependency_fails() {
306        let yaml = r#"
307workspace: test
308services:
309  a:
310    path: services/a
311    port: 3001
312    depends_on: [b]
313  b:
314    path: services/b
315    port: 3002
316    depends_on: [a]
317"#;
318        let err = parse_workspace(yaml).unwrap_err();
319        assert!(err.to_string().contains("circular"));
320    }
321
322    #[test]
323    fn parse_workspace_duplicate_port_fails() {
324        let yaml = r#"
325workspace: test
326services:
327  a:
328    path: services/a
329    port: 3001
330  b:
331    path: services/b
332    port: 3001
333"#;
334        let err = parse_workspace(yaml).unwrap_err();
335        assert!(err.to_string().contains("same port 3001"));
336    }
337
338    #[test]
339    fn parse_saga_minimal() {
340        let yaml = r#"
341saga: create_order
342steps:
343  - name: reserve
344    service: inventory-api
345    action: POST /v1/reservations
346    compensate: DELETE /v1/reservations/:id
347"#;
348        let saga = parse_saga(yaml).unwrap();
349        assert_eq!(saga.saga, "create_order");
350        assert_eq!(saga.version, 1);
351        assert_eq!(saga.steps.len(), 1);
352    }
353
354    #[test]
355    fn parse_saga_full() {
356        let yaml = r#"
357saga: create_order
358version: 2
359steps:
360  - name: reserve
361    service: inventory-api
362    action: POST /v1/reservations
363    compensate: DELETE /v1/reservations/:id
364    timeout_secs: 5
365  - name: charge
366    service: payments-api
367    action: POST /v1/charges
368    compensate: POST /v1/charges/:id/refund
369    timeout_secs: 10
370  - name: create_record
371    service: orders-api
372    action: POST /v1/orders
373    compensate: DELETE /v1/orders/:id
374"#;
375        let saga = parse_saga(yaml).unwrap();
376        assert_eq!(saga.version, 2);
377        assert_eq!(saga.steps.len(), 3);
378        assert_eq!(saga.steps[0].timeout_secs, 5);
379        assert_eq!(saga.steps[2].timeout_secs, 30);
380    }
381
382    #[test]
383    fn parse_saga_empty_name_fails() {
384        let yaml = r#"
385saga: ""
386steps:
387  - name: step
388    service: svc
389    action: POST /v1/x
390    compensate: DELETE /v1/x/:id
391"#;
392        let err = parse_saga(yaml).unwrap_err();
393        assert!(err.to_string().contains("cannot be empty"));
394    }
395
396    #[test]
397    fn parse_saga_no_steps_fails() {
398        let yaml = r#"
399saga: test
400steps: []
401"#;
402        let err = parse_saga(yaml).unwrap_err();
403        assert!(err.to_string().contains("at least one step"));
404    }
405
406    #[test]
407    fn parse_saga_duplicate_step_name_fails() {
408        let yaml = r#"
409saga: test
410steps:
411  - name: step1
412    service: svc
413    action: POST /v1/x
414    compensate: DELETE /v1/x/:id
415  - name: step1
416    service: svc
417    action: POST /v1/y
418    compensate: DELETE /v1/y/:id
419"#;
420        let err = parse_saga(yaml).unwrap_err();
421        assert!(err.to_string().contains("duplicate step name"));
422    }
423
424    #[test]
425    fn parse_saga_invalid_action_fails() {
426        let yaml = r#"
427saga: test
428steps:
429  - name: step1
430    service: svc
431    action: invalid
432    compensate: DELETE /v1/x/:id
433"#;
434        let err = parse_saga(yaml).unwrap_err();
435        assert!(err.to_string().contains("must be 'METHOD /path'"));
436    }
437
438    #[test]
439    fn topological_order_respects_deps() {
440        let yaml = r#"
441workspace: test
442services:
443  c:
444    path: services/c
445    port: 3003
446    depends_on: [a, b]
447  b:
448    path: services/b
449    port: 3002
450    depends_on: [a]
451  a:
452    path: services/a
453    port: 3001
454"#;
455        let cfg = parse_workspace(yaml).unwrap();
456        let order = topological_order(&cfg);
457        let a_pos = order.iter().position(|s| s == "a").unwrap();
458        let b_pos = order.iter().position(|s| s == "b").unwrap();
459        let c_pos = order.iter().position(|s| s == "c").unwrap();
460        assert!(a_pos < b_pos);
461        assert!(a_pos < c_pos);
462        assert!(b_pos < c_pos);
463    }
464
465    #[test]
466    fn topological_order_no_deps() {
467        let yaml = r#"
468workspace: test
469services:
470  a:
471    path: services/a
472    port: 3001
473  b:
474    path: services/b
475    port: 3002
476"#;
477        let cfg = parse_workspace(yaml).unwrap();
478        let order = topological_order(&cfg);
479        assert_eq!(order.len(), 2);
480    }
481}