Skip to main content

telltale_runtime/topology/
parser.rs

1//! Topology DSL parser.
2//!
3//! Parses topology definitions from DSL source code.
4
5use super::{Location, RoleFamilyConstraint, Topology, TopologyConstraint, TopologyMode};
6use crate::identifiers::{
7    Datacenter, Endpoint as TopologyEndpoint, IdentifierError, Namespace, Region, RoleName,
8};
9use crate::ChannelCapacity;
10use pest::Parser;
11use pest_derive::Parser;
12use thiserror::Error;
13
14#[derive(Parser)]
15#[grammar = "compiler/topology.pest"]
16struct TopologyParser;
17
18/// Errors that can occur during topology parsing.
19#[derive(Debug, Clone, Error)]
20pub enum TopologyParseError {
21    #[error("Parse error: {0}")]
22    ParseError(String),
23
24    #[error("Unknown mode: {0}")]
25    UnknownMode(String),
26
27    #[error("Invalid location: {0}")]
28    InvalidLocation(String),
29
30    #[error("Invalid constraint: {0}")]
31    InvalidConstraint(String),
32
33    #[error("Invalid capacity: {0}")]
34    InvalidCapacity(String),
35
36    #[error("Invalid identifier: {0}")]
37    InvalidIdentifier(IdentifierError),
38}
39
40impl From<pest::error::Error<Rule>> for TopologyParseError {
41    fn from(e: pest::error::Error<Rule>) -> Self {
42        TopologyParseError::ParseError(e.to_string())
43    }
44}
45
46impl From<IdentifierError> for TopologyParseError {
47    fn from(err: IdentifierError) -> Self {
48        TopologyParseError::InvalidIdentifier(err)
49    }
50}
51
52/// Parsed topology with metadata.
53#[derive(Debug, Clone)]
54pub struct ParsedTopology {
55    /// Name of this topology configuration.
56    pub name: String,
57    /// Name of the choreography this topology is for.
58    pub for_choreography: String,
59    /// The topology configuration.
60    pub topology: Topology,
61}
62
63/// Parse a topology definition from DSL source.
64pub fn parse_topology(input: &str) -> Result<ParsedTopology, TopologyParseError> {
65    let pairs = TopologyParser::parse(Rule::topology, input)?;
66    let mut name = String::new();
67    let mut for_choreography = String::new();
68    let mut topology = Topology::new();
69
70    for pair in pairs {
71        if pair.as_rule() == Rule::topology {
72            let mut inner = pair.into_inner();
73
74            // Get topology name
75            if let Some(name_pair) = inner.next() {
76                name = name_pair.as_str().to_string();
77            }
78
79            // Get choreography name (after "for")
80            if let Some(for_pair) = inner.next() {
81                for_choreography = for_pair.as_str().to_string();
82            }
83
84            // Parse topology body
85            if let Some(body_pair) = inner.next() {
86                topology = parse_topology_body(body_pair)?;
87            }
88        }
89    }
90
91    Ok(ParsedTopology {
92        name,
93        for_choreography,
94        topology,
95    })
96}
97
98fn parse_topology_body(pair: pest::iterators::Pair<Rule>) -> Result<Topology, TopologyParseError> {
99    let mut topology = Topology::new();
100
101    for inner in pair.into_inner() {
102        match inner.as_rule() {
103            Rule::topology_mode => {
104                topology.mode = Some(parse_topology_mode(inner)?);
105            }
106            Rule::topology_mappings => {
107                for mapping in inner.into_inner() {
108                    let (role, location) = parse_topology_mapping(mapping)?;
109                    topology.locations.insert(role, location);
110                }
111            }
112            Rule::topology_constraints => {
113                for constraint in inner.into_inner() {
114                    if constraint.as_rule() == Rule::constraint_decl {
115                        topology.constraints.push(parse_constraint(constraint)?);
116                    }
117                }
118            }
119            Rule::channel_capacities_block => {
120                for decl in inner.into_inner() {
121                    if decl.as_rule() == Rule::channel_capacity_decl {
122                        let (sender, receiver, capacity) = parse_channel_capacity_decl(decl)?;
123                        topology
124                            .channel_capacities
125                            .insert((sender, receiver), capacity);
126                    }
127                }
128            }
129            Rule::role_constraints_block => {
130                for decl in inner.into_inner() {
131                    if decl.as_rule() == Rule::role_constraint_decl {
132                        let (family, constraint) = parse_role_constraint_decl(decl)?;
133                        topology.role_constraints.insert(family, constraint);
134                    }
135                }
136            }
137            _ => {}
138        }
139    }
140
141    Ok(topology)
142}
143
144fn parse_topology_mode(
145    pair: pest::iterators::Pair<Rule>,
146) -> Result<TopologyMode, TopologyParseError> {
147    for inner in pair.into_inner() {
148        if inner.as_rule() == Rule::topology_mode_value {
149            return parse_mode_value(inner);
150        }
151    }
152    Err(TopologyParseError::UnknownMode("empty mode".to_string()))
153}
154
155fn parse_mode_value(pair: pest::iterators::Pair<Rule>) -> Result<TopologyMode, TopologyParseError> {
156    let inner = pair.into_inner().next();
157    match inner {
158        Some(p) => match p.as_rule() {
159            Rule::kubernetes_mode => {
160                let namespace = p.into_inner().next().map(|i| i.as_str()).ok_or_else(|| {
161                    TopologyParseError::InvalidConstraint(
162                        "kubernetes mode requires a namespace".to_string(),
163                    )
164                })?;
165                Ok(TopologyMode::Kubernetes(Namespace::new(namespace)?))
166            }
167            Rule::consul_mode => {
168                let datacenter = p.into_inner().next().map(|i| i.as_str()).ok_or_else(|| {
169                    TopologyParseError::InvalidConstraint(
170                        "consul mode requires a datacenter".to_string(),
171                    )
172                })?;
173                Ok(TopologyMode::Consul(Datacenter::new(datacenter)?))
174            }
175            _ => {
176                let s = p.as_str();
177                match s {
178                    "local" => Ok(TopologyMode::Local),
179                    "per_role" => Ok(TopologyMode::PerRole),
180                    _ => Err(TopologyParseError::UnknownMode(s.to_string())),
181                }
182            }
183        },
184        None => Ok(TopologyMode::Local),
185    }
186}
187
188fn parse_topology_mapping(
189    pair: pest::iterators::Pair<Rule>,
190) -> Result<(RoleName, Location), TopologyParseError> {
191    let mut inner = pair.into_inner();
192    let role = inner
193        .next()
194        .map(|p| RoleName::new(p.as_str()))
195        .transpose()?
196        .ok_or_else(|| TopologyParseError::InvalidConstraint("missing role".to_string()))?;
197    let location = inner
198        .next()
199        .map(|p| parse_location(p))
200        .transpose()?
201        .unwrap_or(Location::Local);
202
203    Ok((role, location))
204}
205
206fn parse_location(pair: pest::iterators::Pair<Rule>) -> Result<Location, TopologyParseError> {
207    let inner = pair.into_inner().next();
208    match inner {
209        Some(p) => match p.as_rule() {
210            Rule::local_location => Ok(Location::Local),
211            Rule::colocated_location => {
212                let peer = p
213                    .into_inner()
214                    .next()
215                    .map(|i| RoleName::new(i.as_str()))
216                    .transpose()?
217                    .ok_or_else(|| {
218                        TopologyParseError::InvalidLocation("colocated requires a role".to_string())
219                    })?;
220                Ok(Location::Colocated(peer))
221            }
222            Rule::endpoint => Ok(Location::Remote(TopologyEndpoint::new(p.as_str())?)),
223            _ => {
224                let s = p.as_str();
225                if s == "local" {
226                    Ok(Location::Local)
227                } else {
228                    Ok(Location::Remote(TopologyEndpoint::new(s)?))
229                }
230            }
231        },
232        None => Ok(Location::Local),
233    }
234}
235
236fn parse_constraint(
237    pair: pest::iterators::Pair<Rule>,
238) -> Result<TopologyConstraint, TopologyParseError> {
239    let inner = pair
240        .into_inner()
241        .next()
242        .ok_or_else(|| TopologyParseError::InvalidConstraint("empty constraint".to_string()))?;
243
244    match inner.as_rule() {
245        Rule::colocated_constraint => {
246            let mut idents = inner.into_inner();
247            let r1 = idents
248                .next()
249                .map(|p| RoleName::new(p.as_str()))
250                .transpose()?
251                .ok_or_else(|| {
252                    TopologyParseError::InvalidConstraint(
253                        "colocated requires two roles".to_string(),
254                    )
255                })?;
256            let r2 = idents
257                .next()
258                .map(|p| RoleName::new(p.as_str()))
259                .transpose()?
260                .ok_or_else(|| {
261                    TopologyParseError::InvalidConstraint(
262                        "colocated requires two roles".to_string(),
263                    )
264                })?;
265            Ok(TopologyConstraint::Colocated(r1, r2))
266        }
267        Rule::separated_constraint => {
268            let roles: Vec<RoleName> = inner
269                .into_inner()
270                .flat_map(|p| p.into_inner())
271                .map(|p| RoleName::new(p.as_str()))
272                .collect::<Result<Vec<_>, _>>()?;
273            // Separated constraints are binary; use first two roles
274            if roles.len() >= 2 {
275                Ok(TopologyConstraint::Separated(
276                    roles[0].clone(),
277                    roles[1].clone(),
278                ))
279            } else {
280                Err(TopologyParseError::InvalidConstraint(
281                    "separated requires at least 2 roles".to_string(),
282                ))
283            }
284        }
285        Rule::pinned_constraint => {
286            let mut inner_iter = inner.into_inner();
287            let role = inner_iter
288                .next()
289                .map(|p| RoleName::new(p.as_str()))
290                .transpose()?
291                .ok_or_else(|| {
292                    TopologyParseError::InvalidConstraint("pinned requires a role".to_string())
293                })?;
294            let location = inner_iter
295                .next()
296                .map(|p| parse_location(p))
297                .transpose()?
298                .unwrap_or(Location::Local);
299            Ok(TopologyConstraint::Pinned(role, location))
300        }
301        Rule::region_constraint => {
302            let mut idents = inner.into_inner();
303            let role = idents
304                .next()
305                .map(|p| RoleName::new(p.as_str()))
306                .transpose()?
307                .ok_or_else(|| {
308                    TopologyParseError::InvalidConstraint("region requires a role".to_string())
309                })?;
310            let region = idents
311                .next()
312                .map(|p| Region::new(p.as_str()))
313                .transpose()?
314                .ok_or_else(|| {
315                    TopologyParseError::InvalidConstraint("region requires a value".to_string())
316                })?;
317            Ok(TopologyConstraint::Region(role, region))
318        }
319        _ => Err(TopologyParseError::InvalidConstraint(format!(
320            "unknown constraint type: {:?}",
321            inner.as_rule()
322        ))),
323    }
324}
325
326fn parse_channel_capacity_decl(
327    pair: pest::iterators::Pair<Rule>,
328) -> Result<(RoleName, RoleName, ChannelCapacity), TopologyParseError> {
329    let mut inner = pair.into_inner();
330    let sender = inner
331        .next()
332        .map(|p| RoleName::new(p.as_str()))
333        .transpose()?
334        .ok_or_else(|| TopologyParseError::InvalidConstraint("missing sender".to_string()))?;
335    let receiver = inner
336        .next()
337        .map(|p| RoleName::new(p.as_str()))
338        .transpose()?
339        .ok_or_else(|| TopologyParseError::InvalidConstraint("missing receiver".to_string()))?;
340    let capacity = inner
341        .next()
342        .ok_or_else(|| TopologyParseError::InvalidConstraint("missing capacity".to_string()))?
343        .as_str()
344        .parse::<u32>()
345        .map_err(|e| TopologyParseError::InvalidCapacity(e.to_string()))?;
346    let capacity = ChannelCapacity::try_new(capacity)
347        .map_err(|e| TopologyParseError::InvalidCapacity(e.to_string()))?;
348
349    Ok((sender, receiver, capacity))
350}
351
352fn parse_role_constraint_decl(
353    pair: pest::iterators::Pair<Rule>,
354) -> Result<(String, RoleFamilyConstraint), TopologyParseError> {
355    let mut inner = pair.into_inner();
356
357    // Get family name
358    let family = inner
359        .next()
360        .map(|p| p.as_str().to_string())
361        .ok_or_else(|| {
362            TopologyParseError::InvalidConstraint("role constraint missing family name".to_string())
363        })?;
364
365    // Get constraint spec
366    let spec = inner.next().ok_or_else(|| {
367        TopologyParseError::InvalidConstraint("role constraint missing specification".to_string())
368    })?;
369
370    let constraint = parse_role_constraint_spec(spec)?;
371    Ok((family, constraint))
372}
373
374fn parse_role_constraint_spec(
375    pair: pest::iterators::Pair<Rule>,
376) -> Result<RoleFamilyConstraint, TopologyParseError> {
377    let mut min: Option<u32> = None;
378    let mut max: Option<u32> = None;
379
380    for inner in pair.into_inner() {
381        match inner.as_rule() {
382            Rule::min_constraint => {
383                let value = inner
384                    .into_inner()
385                    .next()
386                    .and_then(|p| p.as_str().parse::<u32>().ok())
387                    .ok_or_else(|| {
388                        TopologyParseError::InvalidConstraint(
389                            "min constraint requires integer value".to_string(),
390                        )
391                    })?;
392                min = Some(value);
393            }
394            Rule::max_constraint => {
395                let value = inner
396                    .into_inner()
397                    .next()
398                    .and_then(|p| p.as_str().parse::<u32>().ok())
399                    .ok_or_else(|| {
400                        TopologyParseError::InvalidConstraint(
401                            "max constraint requires integer value".to_string(),
402                        )
403                    })?;
404                max = Some(value);
405            }
406            _ => {}
407        }
408    }
409
410    Ok(RoleFamilyConstraint {
411        min: min.unwrap_or(0),
412        max,
413    })
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    #[test]
421    fn test_parse_local_mode_topology() {
422        let input = r#"
423            topology TestLocal for PingPong {
424                mode: local
425            }
426        "#;
427
428        let result = parse_topology(input).unwrap();
429        assert_eq!(result.name, "TestLocal");
430        assert_eq!(result.for_choreography, "PingPong");
431        assert_eq!(result.topology.mode, Some(TopologyMode::Local));
432    }
433
434    #[test]
435    fn test_parse_topology_with_mappings() {
436        let input = r#"
437            topology Dev for PingPong {
438                Alice: localhost:8080
439                Bob: localhost:8081
440            }
441        "#;
442
443        let result = parse_topology(input).unwrap();
444        assert_eq!(result.name, "Dev");
445        assert_eq!(
446            result
447                .topology
448                .get_location(&RoleName::from_static("Alice"))
449                .unwrap(),
450            Location::Remote(TopologyEndpoint::new("localhost:8080").unwrap())
451        );
452        assert_eq!(
453            result
454                .topology
455                .get_location(&RoleName::from_static("Bob"))
456                .unwrap(),
457            Location::Remote(TopologyEndpoint::new("localhost:8081").unwrap())
458        );
459    }
460
461    #[test]
462    fn test_parse_topology_with_constraints() {
463        let input = r#"
464            topology Prod for TwoPhaseCommit {
465                Coordinator: coordinator.internal:9000
466                ParticipantA: participant-a.internal:9000
467                ParticipantB: participant-b.internal:9000
468
469                constraints {
470                    separated: Coordinator, ParticipantA
471                    region: Coordinator -> us_east_1
472                }
473            }
474        "#;
475
476        let result = parse_topology(input).unwrap();
477        assert_eq!(result.name, "Prod");
478        assert_eq!(result.topology.constraints.len(), 2);
479    }
480
481    #[test]
482    fn test_parse_channel_capacities() {
483        let input = r#"
484            topology Capacity for Protocol {
485                Alice: local
486                Bob: local
487
488                channel_capacities {
489                    Alice -> Bob: 4
490                }
491            }
492        "#;
493
494        let result = parse_topology(input).unwrap();
495        let key = (RoleName::from_static("Alice"), RoleName::from_static("Bob"));
496        let capacity = result.topology.channel_capacities.get(&key).copied();
497        assert_eq!(
498            capacity,
499            Some(ChannelCapacity::try_new(4).expect("test capacity in range"))
500        );
501    }
502
503    #[test]
504    fn test_parse_kubernetes_mode() {
505        let input = r#"
506            topology K8s for MyProtocol {
507                mode: kubernetes(myapp)
508            }
509        "#;
510
511        let result = parse_topology(input).unwrap();
512        assert_eq!(
513            result.topology.mode,
514            Some(TopologyMode::Kubernetes(Namespace::new("myapp").unwrap()))
515        );
516    }
517
518    #[test]
519    fn test_parse_colocated_location() {
520        let input = r#"
521            topology Mixed for Protocol {
522                Alice: local
523                Bob: colocated(Alice)
524                Carol: remote.host:8080
525            }
526        "#;
527
528        let result = parse_topology(input).unwrap();
529        assert_eq!(
530            result
531                .topology
532                .get_location(&RoleName::from_static("Alice"))
533                .unwrap(),
534            Location::Local
535        );
536        assert_eq!(
537            result
538                .topology
539                .get_location(&RoleName::from_static("Bob"))
540                .unwrap(),
541            Location::Colocated(RoleName::from_static("Alice"))
542        );
543    }
544
545    #[test]
546    fn test_parse_role_constraints_min_only() {
547        let input = r#"
548            topology ThresholdSig for Protocol {
549                Coordinator: localhost:8000
550
551                role_constraints {
552                    Witness: min = 3
553                }
554            }
555        "#;
556
557        let result = parse_topology(input).unwrap();
558        let constraint = result.topology.role_constraints.get("Witness").unwrap();
559        assert_eq!(constraint.min, 3);
560        assert_eq!(constraint.max, None);
561    }
562
563    #[test]
564    fn test_parse_role_constraints_min_and_max() {
565        let input = r#"
566            topology ThresholdSig for Protocol {
567                role_constraints {
568                    Witness: min = 3, max = 10
569                }
570            }
571        "#;
572
573        let result = parse_topology(input).unwrap();
574        let constraint = result.topology.role_constraints.get("Witness").unwrap();
575        assert_eq!(constraint.min, 3);
576        assert_eq!(constraint.max, Some(10));
577    }
578
579    #[test]
580    fn test_parse_role_constraints_max_first() {
581        let input = r#"
582            topology ThresholdSig for Protocol {
583                role_constraints {
584                    Worker: max = 5, min = 1
585                }
586            }
587        "#;
588
589        let result = parse_topology(input).unwrap();
590        let constraint = result.topology.role_constraints.get("Worker").unwrap();
591        assert_eq!(constraint.min, 1);
592        assert_eq!(constraint.max, Some(5));
593    }
594
595    #[test]
596    fn test_parse_role_constraints_multiple_families() {
597        let input = r#"
598            topology ThresholdSig for Protocol {
599                role_constraints {
600                    Witness: min = 3
601                    Worker: min = 1, max = 10
602                    Validator: max = 5
603                }
604            }
605        "#;
606
607        let result = parse_topology(input).unwrap();
608        assert_eq!(result.topology.role_constraints.len(), 3);
609
610        let witness = result.topology.role_constraints.get("Witness").unwrap();
611        assert_eq!(witness.min, 3);
612        assert_eq!(witness.max, None);
613
614        let worker = result.topology.role_constraints.get("Worker").unwrap();
615        assert_eq!(worker.min, 1);
616        assert_eq!(worker.max, Some(10));
617
618        let validator = result.topology.role_constraints.get("Validator").unwrap();
619        assert_eq!(validator.min, 0); // default min
620        assert_eq!(validator.max, Some(5));
621    }
622
623    #[test]
624    fn test_parse_role_constraints_with_mappings_and_constraints() {
625        let input = r#"
626            topology Prod for TwoPhaseCommit {
627                Coordinator: coordinator.internal:9000
628
629                role_constraints {
630                    Participant: min = 2, max = 100
631                }
632
633                constraints {
634                    region: Coordinator -> us_east_1
635                }
636            }
637        "#;
638
639        let result = parse_topology(input).unwrap();
640        assert_eq!(result.topology.role_constraints.len(), 1);
641        assert_eq!(result.topology.constraints.len(), 1);
642
643        let participant = result.topology.role_constraints.get("Participant").unwrap();
644        assert_eq!(participant.min, 2);
645        assert_eq!(participant.max, Some(100));
646    }
647}