shaperail_codegen/
workspace_parser.rs1use shaperail_core::{SagaDefinition, WorkspaceConfig};
2
3use crate::parser::ParseError;
4
5pub 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
13pub 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
19pub 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
26pub 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
32fn 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 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 if has_circular_deps(config) {
64 return Err(ParseError::ConfigInterpolation(
65 "workspace has circular service dependencies".to_string(),
66 ));
67 }
68
69 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
122fn 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 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
175pub 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}