1mod raw;
18
19use std::collections::HashSet;
20
21use crate::ir;
22
23#[derive(Debug, thiserror::Error)]
25pub enum ParseError {
26 #[error("KDL parse error: {0}")]
28 Kdl(String),
29 #[error("schema validation error: {0}")]
31 Validation(String),
32}
33
34pub fn parse(src: &str) -> Result<ir::Schema, ParseError> {
42 let raw: raw::RawSchema =
43 club_kdl::from_str(src).map_err(|e| ParseError::Kdl(e.to_string()))?;
44 lower_schema(raw)
45}
46
47fn lower_schema(raw: raw::RawSchema) -> Result<ir::Schema, ParseError> {
52 let mut types = Vec::with_capacity(raw.structs.len() + raw.enums.len());
53 for s in raw.structs {
54 types.push(lower_struct(s)?);
55 }
56 for e in raw.enums {
57 types.push(lower_enum(e));
58 }
59 let records = raw
60 .records
61 .into_iter()
62 .map(lower_record)
63 .collect::<Result<Vec<_>, _>>()?;
64 let relations = raw
65 .relations
66 .into_iter()
67 .map(lower_relation)
68 .collect::<Result<Vec<_>, _>>()?;
69 let protocol = raw.protocol.map(lower_protocol).transpose()?;
70 let schema = ir::Schema {
71 types,
72 records,
73 relations,
74 protocol,
75 };
76 validate_type_refs(&schema)?;
77 Ok(schema)
78}
79
80struct DefinedNames<'a> {
85 values: HashSet<&'a str>,
87 records: HashSet<&'a str>,
89}
90
91fn validate_type_refs(schema: &ir::Schema) -> Result<(), ParseError> {
96 let values: HashSet<&str> = schema
97 .types
98 .iter()
99 .map(|t| match t {
100 ir::TypeDef::Struct { name, .. } | ir::TypeDef::Enum { name, .. } => name.as_str(),
101 })
102 .collect();
103 let records: HashSet<&str> = schema.records.iter().map(|r| r.name.as_str()).collect();
104 let defined = DefinedNames { values, records };
105
106 for ty in &schema.types {
107 if let ir::TypeDef::Struct { fields, .. } = ty {
108 check_fields(fields, &defined)?;
109 }
110 }
111 for record in &schema.records {
112 check_fields(&record.fields, &defined)?;
113 }
114 for relation in &schema.relations {
115 check_fields(&relation.fields, &defined)?;
116 for (role, endpoint) in [("from", &relation.from), ("to", &relation.to)] {
118 if !defined.records.contains(endpoint.as_str()) {
119 return Err(ParseError::Validation(format!(
120 "relation {:?} {role}={endpoint:?} references unknown record; \
121 define it as a `record`",
122 relation.name
123 )));
124 }
125 }
126 }
127 if let Some(protocol) = &schema.protocol {
128 for channel in &protocol.channels {
129 for request in &channel.requests {
130 check_fields(&request.fields, &defined)?;
131 if let Some(returns) = &request.returns {
132 check_fields(&returns.fields, &defined)?;
133 }
134 }
135 for event in &channel.events {
136 check_fields(&event.fields, &defined)?;
137 }
138 }
139 }
140 Ok(())
141}
142
143fn check_fields(fields: &[ir::Field], defined: &DefinedNames) -> Result<(), ParseError> {
144 for field in fields {
145 check_ty(&field.ty, &field.name, defined)?;
146 }
147 Ok(())
148}
149
150fn check_ty(ty: &ir::Ty, field: &str, defined: &DefinedNames) -> Result<(), ParseError> {
151 match ty {
152 ir::Ty::Primitive(_) | ir::Ty::Literal(_) => Ok(()),
153 ir::Ty::Array(inner) => check_ty(inner, field, defined),
154 ir::Ty::Union(members) => members.iter().try_for_each(|m| check_ty(m, field, defined)),
155 ir::Ty::Named(name) if defined.values.contains(name.as_str()) => Ok(()),
156 ir::Ty::Named(name) => Err(ParseError::Validation(format!(
157 "field {field:?} references unknown type {name:?}; \
158 define it as a `struct` or `enum`, or use `link<{name}>` for a `record`"
159 ))),
160 ir::Ty::Link(name) if defined.records.contains(name.as_str()) => Ok(()),
161 ir::Ty::Link(name) => Err(ParseError::Validation(format!(
162 "field {field:?} links to unknown record {name:?}; \
163 define it as a `record`"
164 ))),
165 }
166}
167
168fn lower_struct(raw: raw::RawStruct) -> Result<ir::TypeDef, ParseError> {
169 Ok(ir::TypeDef::Struct {
170 name: raw.name,
171 description: raw.description,
172 fields: lower_fields(raw.fields)?,
173 })
174}
175
176fn lower_enum(raw: raw::RawEnum) -> ir::TypeDef {
177 ir::TypeDef::Enum {
178 name: raw.name,
179 description: raw.description,
180 variants: raw.variants.into_iter().map(|v| v.name).collect(),
181 }
182}
183
184fn lower_record(raw: raw::RawRecord) -> Result<ir::Record, ParseError> {
185 let id_strategy = lower_id_strategy(raw.id.and_then(|i| i.strategy).as_deref())?;
186 Ok(ir::Record {
187 name: raw.name,
188 description: raw.description,
189 id_strategy,
190 fields: lower_fields(raw.fields)?,
191 })
192}
193
194fn lower_relation(raw: raw::RawRelation) -> Result<ir::Relation, ParseError> {
195 Ok(ir::Relation {
196 name: raw.name,
197 description: raw.description,
198 from: raw.from,
199 to: raw.to,
200 unique: raw.unique,
201 fields: lower_fields(raw.fields)?,
202 })
203}
204
205fn lower_id_strategy(s: Option<&str>) -> Result<ir::IdStrategy, ParseError> {
206 match s {
207 None | Some("uuidv7") => Ok(ir::IdStrategy::Uuidv7),
208 Some("ulid") => Ok(ir::IdStrategy::Ulid),
209 Some("manual") => Ok(ir::IdStrategy::Manual),
210 Some(other) => Err(ParseError::Validation(format!(
211 "unknown id `strategy` value {other:?} (expected uuidv7/ulid/manual)"
212 ))),
213 }
214}
215
216fn lower_protocol(raw: raw::RawProtocol) -> Result<ir::Protocol, ParseError> {
217 let channels = raw
218 .channels
219 .into_iter()
220 .map(lower_channel)
221 .collect::<Result<Vec<_>, _>>()?;
222 Ok(ir::Protocol {
223 name: raw.name,
224 version: raw.version,
225 namespace: raw.namespace,
226 description: raw.description,
227 channels,
228 })
229}
230
231fn lower_channel(raw: raw::RawChannel) -> Result<ir::Channel, ParseError> {
232 let from = lower_channel_from(&raw.from)?;
233 let lifetime = lower_channel_lifetime(&raw.lifetime)?;
234 let backend = lower_channel_backend(raw.backend.as_deref())?;
235 let requests = raw
236 .requests
237 .into_iter()
238 .map(lower_request)
239 .collect::<Result<Vec<_>, _>>()?;
240 let events = raw
241 .events
242 .into_iter()
243 .map(lower_event)
244 .collect::<Result<Vec<_>, _>>()?;
245
246 if backend == ir::ChannelBackend::Datagram {
248 match raw.channel_id {
249 None => {
250 return Err(ParseError::Validation(format!(
251 "channel {:?} has backend=\"datagram\" but no channel_id; \
252 channel_id=N (1..) is required",
253 raw.name
254 )));
255 }
256 Some(0) => {
257 return Err(ParseError::Validation(format!(
258 "channel {:?} has channel_id=0 which is reserved; use 1..",
259 raw.name
260 )));
261 }
262 Some(_) => {}
263 }
264 if !requests.is_empty() {
265 return Err(ParseError::Validation(format!(
266 "channel {:?} has backend=\"datagram\" with request blocks; \
267 datagram channels support event only (no Request/Response)",
268 raw.name
269 )));
270 }
271 }
272
273 Ok(ir::Channel {
274 name: raw.name,
275 from,
276 lifetime,
277 backend,
278 channel_id: raw.channel_id,
279 requests,
280 events,
281 })
282}
283
284fn lower_request(raw: raw::RawRequest) -> Result<ir::Request, ParseError> {
285 Ok(ir::Request {
286 name: raw.name,
287 fields: lower_fields(raw.fields)?,
288 returns: raw.returns.map(lower_message).transpose()?,
289 })
290}
291
292fn lower_event(raw: raw::RawEvent) -> Result<ir::Event, ParseError> {
293 Ok(ir::Event {
294 name: raw.name,
295 fields: lower_fields(raw.fields)?,
296 })
297}
298
299fn lower_message(raw: raw::RawMessage) -> Result<ir::Message, ParseError> {
300 Ok(ir::Message {
301 name: raw.name,
302 fields: lower_fields(raw.fields)?,
303 })
304}
305
306fn lower_fields(raw: Vec<raw::RawField>) -> Result<Vec<ir::Field>, ParseError> {
307 raw.into_iter().map(lower_field).collect()
308}
309
310fn lower_field(raw: raw::RawField) -> Result<ir::Field, ParseError> {
311 Ok(ir::Field {
312 ty: parse_ty(&raw.type_str)?,
313 name: raw.name,
314 required: !raw.optional,
315 flexible: raw.flexible,
316 default: raw.default,
317 description: raw.description,
318 constraints: ir::Constraints {
319 min: raw.min,
320 max: raw.max,
321 min_length: raw.min_length,
322 max_length: raw.max_length,
323 pattern: raw.pattern,
324 },
325 })
326}
327
328fn lower_channel_from(s: &str) -> Result<ir::ChannelFrom, ParseError> {
333 match s {
334 "client" => Ok(ir::ChannelFrom::Client),
335 "server" => Ok(ir::ChannelFrom::Server),
336 "either" => Ok(ir::ChannelFrom::Either),
337 other => Err(ParseError::Validation(format!(
338 "unknown channel `from` value {other:?} (expected client/server/either)"
339 ))),
340 }
341}
342
343fn lower_channel_lifetime(s: &str) -> Result<ir::ChannelLifetime, ParseError> {
344 match s {
345 "transient" => Ok(ir::ChannelLifetime::Transient),
346 "persistent" => Ok(ir::ChannelLifetime::Persistent),
347 other => Err(ParseError::Validation(format!(
348 "unknown channel `lifetime` value {other:?} (expected transient/persistent)"
349 ))),
350 }
351}
352
353fn lower_channel_backend(s: Option<&str>) -> Result<ir::ChannelBackend, ParseError> {
354 match s {
355 None | Some("stream") => Ok(ir::ChannelBackend::Stream),
356 Some("datagram") => Ok(ir::ChannelBackend::Datagram),
357 Some(other) => Err(ParseError::Validation(format!(
358 "unknown channel `backend` value {other:?} (expected stream/datagram)"
359 ))),
360 }
361}
362
363fn parse_ty(s: &str) -> Result<ir::Ty, ParseError> {
378 let s = s.trim();
379 if s.is_empty() {
380 return Err(ParseError::Validation("empty field type".to_string()));
381 }
382
383 let members = split_top_level_union(s);
385 if members.len() > 1 {
386 let parsed = members
387 .iter()
388 .map(|m| parse_ty(m))
389 .collect::<Result<Vec<_>, _>>()?;
390 return Ok(ir::Ty::Union(parsed));
391 }
392
393 parse_atom(s)
394}
395
396fn parse_atom(s: &str) -> Result<ir::Ty, ParseError> {
398 let s = s.trim();
399 if let Some(inner) = s.strip_prefix("array<").and_then(|r| r.strip_suffix('>')) {
400 return Ok(ir::Ty::Array(Box::new(parse_ty(inner)?)));
401 }
402 if let Some(inner) = s.strip_prefix("link<").and_then(|r| r.strip_suffix('>')) {
403 let name = inner.trim();
404 if name.is_empty() {
405 return Err(ParseError::Validation(
406 "empty record name in `link<>`".to_string(),
407 ));
408 }
409 return Ok(ir::Ty::Link(name.to_string()));
410 }
411 if let Some(inner) = s.strip_prefix('\'').and_then(|r| r.strip_suffix('\'')) {
413 return Ok(ir::Ty::Literal(inner.to_string()));
414 }
415 let prim = match s {
416 "string" => Some(ir::Prim::String),
417 "int" => Some(ir::Prim::Int),
418 "float" | "number" => Some(ir::Prim::Float),
419 "bool" => Some(ir::Prim::Bool),
420 "datetime" | "timestamp" => Some(ir::Prim::Datetime),
421 "json" | "object" => Some(ir::Prim::Json),
422 _ => None,
423 };
424 match prim {
425 Some(p) => Ok(ir::Ty::Primitive(p)),
426 None if s.is_empty() => Err(ParseError::Validation("empty field type".to_string())),
427 None => Ok(ir::Ty::Named(s.to_string())),
428 }
429}
430
431fn split_top_level_union(s: &str) -> Vec<String> {
434 let mut parts: Vec<String> = Vec::new();
435 let mut cur = String::new();
436 let mut depth: usize = 0;
437 let mut in_literal = false;
438 for c in s.chars() {
439 match c {
440 '\'' => {
441 in_literal = !in_literal;
442 cur.push(c);
443 }
444 '<' if !in_literal => {
445 depth += 1;
446 cur.push(c);
447 }
448 '>' if !in_literal => {
449 depth = depth.saturating_sub(1);
450 cur.push(c);
451 }
452 '|' if depth == 0 && !in_literal => {
453 parts.push(cur.trim().to_string());
454 cur.clear();
455 }
456 _ => cur.push(c),
457 }
458 }
459 parts.push(cur.trim().to_string());
460 parts
461}
462
463#[cfg(test)]
468mod tests {
469 use super::*;
470
471 #[test]
472 fn parses_a_protocol_with_request_and_returns() {
473 let src = r#"
474 protocol "ping-pong" version="2.0.0" {
475 namespace "test.pp"
476 channel "pp" from="client" lifetime="persistent" {
477 request "Ping" {
478 field "message" type="string"
479 returns "Pong" {
480 field "reply" type="string"
481 }
482 }
483 }
484 }
485 "#;
486 let schema = parse(src).expect("should parse");
487 let protocol = schema.protocol.expect("has protocol");
488 assert_eq!(protocol.name, "ping-pong");
489 assert_eq!(protocol.version, "2.0.0");
490 assert_eq!(protocol.namespace.as_deref(), Some("test.pp"));
491 assert_eq!(protocol.channels.len(), 1);
492
493 let channel = &protocol.channels[0];
494 assert_eq!(channel.name, "pp");
495 assert_eq!(channel.from, ir::ChannelFrom::Client);
496 assert_eq!(channel.lifetime, ir::ChannelLifetime::Persistent);
497 assert_eq!(channel.backend, ir::ChannelBackend::Stream);
498 assert_eq!(channel.requests.len(), 1);
499
500 let request = &channel.requests[0];
501 assert_eq!(request.name, "Ping");
502 assert_eq!(
503 request.fields,
504 vec![ir::Field {
505 name: "message".to_string(),
506 ty: ir::Ty::Primitive(ir::Prim::String),
507 required: true,
508 flexible: false,
509 default: None,
510 description: None,
511 constraints: ir::Constraints::default(),
512 }]
513 );
514 let returns = request.returns.as_ref().expect("has returns");
515 assert_eq!(returns.name, "Pong");
516 assert_eq!(returns.fields[0].name, "reply");
517 }
518
519 #[test]
520 fn parses_events_and_optional_fields() {
521 let src = r#"
522 protocol "p" version="1.0.0" {
523 channel "c" from="server" lifetime="persistent" {
524 event "Tick" {
525 field "seq" type="int"
526 field "note" type="string" optional=#true
527 }
528 }
529 }
530 "#;
531 let schema = parse(src).expect("should parse");
532 let channel = &schema.protocol.unwrap().channels[0];
533 let event = &channel.events[0];
534 assert_eq!(event.name, "Tick");
535 assert!(
536 event.fields[0].required,
537 "unmarked field defaults to required"
538 );
539 assert!(
540 !event.fields[1].required,
541 "explicit optional=#true → not required"
542 );
543 }
544
545 #[test]
546 fn datagram_channel_requires_channel_id() {
547 let src = r#"
548 protocol "p" version="1.0.0" {
549 channel "metric" from="server" lifetime="persistent" backend="datagram" {
550 event "M" { field "v" type="int" }
551 }
552 }
553 "#;
554 let err = parse(src).expect_err("datagram without channel_id is invalid");
555 assert!(matches!(err, ParseError::Validation(_)));
556 }
557
558 #[test]
559 fn datagram_channel_rejects_requests() {
560 let src = r#"
561 protocol "p" version="1.0.0" {
562 channel "c" from="client" lifetime="persistent" backend="datagram" channel_id=1 {
563 request "R" { field "x" type="int" }
564 }
565 }
566 "#;
567 let err = parse(src).expect_err("datagram with request is invalid");
568 assert!(matches!(err, ParseError::Validation(_)));
569 }
570
571 #[test]
572 fn parses_datagram_channel_with_id() {
573 let src = r#"
574 protocol "p" version="1.0.0" {
575 channel "metric" from="server" lifetime="persistent" backend="datagram" channel_id=7 {
576 event "M" { field "v" type="int" }
577 }
578 }
579 "#;
580 let channel = &parse(src).unwrap().protocol.unwrap().channels[0];
581 assert_eq!(channel.backend, ir::ChannelBackend::Datagram);
582 assert_eq!(channel.channel_id, Some(7));
583 }
584
585 #[test]
586 fn parses_data_dialect_struct_and_enum() {
587 let src = r#"
588 struct "User" {
589 field "id" type="string"
590 field "tags" type="array<string>"
591 field "role" type="Role"
592 }
593 enum "Role" {
594 variant "admin"
595 variant "member"
596 }
597 "#;
598 let schema = parse(src).expect("should parse");
599 assert!(schema.protocol.is_none());
600 assert_eq!(schema.types.len(), 2);
601
602 match &schema.types[0] {
603 ir::TypeDef::Struct { name, fields, .. } => {
604 assert_eq!(name, "User");
605 assert_eq!(
606 fields[1].ty,
607 ir::Ty::Array(Box::new(ir::Ty::Primitive(ir::Prim::String)))
608 );
609 assert_eq!(fields[2].ty, ir::Ty::Named("Role".to_string()));
610 }
611 other => panic!("expected struct, got {other:?}"),
612 }
613 match &schema.types[1] {
614 ir::TypeDef::Enum { name, variants, .. } => {
615 assert_eq!(name, "Role");
616 assert_eq!(variants, &["admin", "member"]);
617 }
618 other => panic!("expected enum, got {other:?}"),
619 }
620 }
621
622 #[test]
623 fn rejects_unknown_channel_from() {
624 let src = r#"
625 protocol "p" version="1.0.0" {
626 channel "c" from="nobody" lifetime="persistent" {
627 event "E" { field "x" type="int" }
628 }
629 }
630 "#;
631 let err = parse(src).expect_err("unknown from value is invalid");
632 assert!(matches!(err, ParseError::Validation(_)));
633 }
634
635 #[test]
636 fn primitive_type_aliases() {
637 assert_eq!(
638 parse_ty("object").unwrap(),
639 ir::Ty::Primitive(ir::Prim::Json)
640 );
641 assert_eq!(
642 parse_ty("number").unwrap(),
643 ir::Ty::Primitive(ir::Prim::Float)
644 );
645 assert_eq!(
646 parse_ty("timestamp").unwrap(),
647 ir::Ty::Primitive(ir::Prim::Datetime)
648 );
649 }
650
651 #[test]
652 fn rejects_unknown_type_reference() {
653 let src = r#"
654 struct "User" {
655 field "role" type="Role"
656 }
657 "#;
658 let err = parse(src).expect_err("unknown type reference is invalid");
660 assert!(matches!(err, ParseError::Validation(_)));
661 }
662
663 #[test]
664 fn accepts_array_of_defined_type() {
665 let src = r#"
666 struct "Team" {
667 field "members" type="array<User>"
668 }
669 struct "User" {
670 field "id" type="string"
671 }
672 "#;
673 parse(src).expect("array<User> with User defined should parse");
675 }
676
677 #[test]
682 fn parses_record_with_id_strategy_and_fields() {
683 let src = r#"
684 record "Atlas" {
685 id strategy="uuidv7"
686 field "name" type="string"
687 field "parent" type="link<Atlas>"
688 }
689 "#;
690 let schema = parse(src).expect("record should parse");
691 assert_eq!(schema.records.len(), 1);
692 let atlas = &schema.records[0];
693 assert_eq!(atlas.name, "Atlas");
694 assert_eq!(atlas.id_strategy, ir::IdStrategy::Uuidv7);
695 assert_eq!(atlas.fields[0].name, "name");
696 assert_eq!(atlas.fields[1].ty, ir::Ty::Link("Atlas".to_string()));
698 }
699
700 #[test]
701 fn record_id_strategy_defaults_to_uuidv7_when_absent() {
702 let src = r#"
703 record "Note" {
704 field "body" type="string"
705 }
706 "#;
707 let schema = parse(src).expect("record without `id` node should parse");
708 assert_eq!(schema.records[0].id_strategy, ir::IdStrategy::Uuidv7);
709 }
710
711 #[test]
712 fn parses_all_id_strategies() {
713 for (kw, expected) in [
714 ("ulid", ir::IdStrategy::Ulid),
715 ("manual", ir::IdStrategy::Manual),
716 ("uuidv7", ir::IdStrategy::Uuidv7),
717 ] {
718 let src = format!(
719 r#"record "R" {{ id strategy="{kw}"
720 field "x" type="string" }}"#
721 );
722 let schema = parse(&src).expect("record parses");
723 assert_eq!(schema.records[0].id_strategy, expected);
724 }
725 }
726
727 #[test]
728 fn rejects_unknown_id_strategy() {
729 let src = r#"record "R" { id strategy="snowflake"
730 field "x" type="string" }"#;
731 let err = parse(src).expect_err("unknown id strategy is invalid");
732 assert!(matches!(err, ParseError::Validation(_)));
733 }
734
735 #[test]
736 fn parses_relation_with_endpoints_and_edge_fields() {
737 let src = r#"
738 record "Memory" {
739 field "body" type="string"
740 }
741 relation "derivedFrom" from="Memory" to="Memory" unique=#true {
742 field "confidence" type="float"
743 field "reason" type="string"
744 }
745 "#;
746 let schema = parse(src).expect("relation should parse");
747 assert_eq!(schema.relations.len(), 1);
748 let rel = &schema.relations[0];
749 assert_eq!(rel.name, "derivedFrom");
750 assert_eq!(rel.from, "Memory");
751 assert_eq!(rel.to, "Memory");
752 assert!(rel.unique);
753 assert_eq!(rel.fields.len(), 2);
754 assert_eq!(rel.fields[0].name, "confidence");
755 }
756
757 #[test]
758 fn relation_unique_defaults_to_false() {
759 let src = r#"
760 record "A" { field "x" type="string" }
761 relation "rel" from="A" to="A"
762 "#;
763 let schema = parse(src).expect("relation parses");
764 assert!(!schema.relations[0].unique);
765 }
766
767 #[test]
768 fn rejects_relation_with_unknown_endpoint() {
769 let src = r#"
770 record "A" { field "x" type="string" }
771 relation "rel" from="A" to="Ghost"
772 "#;
773 let err = parse(src).expect_err("unknown relation endpoint is invalid");
774 assert!(matches!(err, ParseError::Validation(_)));
775 }
776
777 #[test]
778 fn parse_ty_link_and_literal_and_union() {
779 assert_eq!(
780 parse_ty("link<Atlas>").unwrap(),
781 ir::Ty::Link("Atlas".to_string())
782 );
783 assert_eq!(
784 parse_ty("'public'").unwrap(),
785 ir::Ty::Literal("public".to_string())
786 );
787 assert_eq!(
788 parse_ty("'public' | 'private'").unwrap(),
789 ir::Ty::Union(vec![
790 ir::Ty::Literal("public".to_string()),
791 ir::Ty::Literal("private".to_string()),
792 ])
793 );
794 assert_eq!(
795 parse_ty("string | int | bool").unwrap(),
796 ir::Ty::Union(vec![
797 ir::Ty::Primitive(ir::Prim::String),
798 ir::Ty::Primitive(ir::Prim::Int),
799 ir::Ty::Primitive(ir::Prim::Bool),
800 ])
801 );
802 }
803
804 #[test]
805 fn union_does_not_split_inside_brackets() {
806 assert_eq!(
809 parse_ty("array<string>").unwrap(),
810 ir::Ty::Array(Box::new(ir::Ty::Primitive(ir::Prim::String)))
811 );
812 }
813
814 #[test]
815 fn link_to_unknown_record_is_rejected() {
816 let src = r#"
817 record "Atlas" {
818 field "parent" type="link<Ghost>"
819 }
820 "#;
821 let err = parse(src).expect_err("link to undefined record is invalid");
822 assert!(matches!(err, ParseError::Validation(_)));
823 }
824
825 #[test]
826 fn flexible_and_default_properties_are_lowered() {
827 let src = r#"
828 record "Atlas" {
829 field "metadata" type="object" flexible=#true
830 field "visibility" type="'public' | 'private'" default="private"
831 }
832 "#;
833 let schema = parse(src).expect("record should parse");
834 let fields = &schema.records[0].fields;
835 assert!(fields[0].flexible, "flexible=#true is lowered");
836 assert!(!fields[1].flexible, "absent flexible defaults false");
837 assert_eq!(fields[1].default.as_deref(), Some("private"));
838 }
839
840 #[test]
841 fn bare_name_is_embedded_link_is_stored() {
842 let src = r#"
845 struct "GeoPoint" {
846 field "lat" type="float"
847 }
848 record "Place" {
849 field "at" type="GeoPoint"
850 field "parent" type="link<Place>"
851 }
852 "#;
853 let schema = parse(src).expect("schema parses");
854 let fields = &schema.records[0].fields;
855 assert_eq!(fields[0].ty, ir::Ty::Named("GeoPoint".to_string()));
856 assert_eq!(fields[1].ty, ir::Ty::Link("Place".to_string()));
857 }
858
859 #[test]
864 fn record_and_field_descriptions_are_lowered() {
865 let src = r#"
866 record "Memory" description="User memory with content" {
867 field "content" type="string" description="Memory content text"
868 }
869 "#;
870 let schema = parse(src).expect("record with descriptions parses");
871 let memory = &schema.records[0];
872 assert_eq!(
873 memory.description.as_deref(),
874 Some("User memory with content")
875 );
876 assert_eq!(
877 memory.fields[0].description.as_deref(),
878 Some("Memory content text")
879 );
880 }
881
882 #[test]
883 fn struct_enum_relation_descriptions_are_lowered() {
884 let src = r#"
885 struct "Point" description="A 2D point" {
886 field "x" type="float"
887 }
888 enum "Color" description="An RGB primary" {
889 variant "red"
890 }
891 record "Node" { field "v" type="int" }
892 relation "edge" from="Node" to="Node" description="A directed edge"
893 "#;
894 let schema = parse(src).expect("schema parses");
895 match &schema.types[0] {
896 ir::TypeDef::Struct { description, .. } => {
897 assert_eq!(description.as_deref(), Some("A 2D point"));
898 }
899 other => panic!("expected struct, got {other:?}"),
900 }
901 match &schema.types[1] {
902 ir::TypeDef::Enum { description, .. } => {
903 assert_eq!(description.as_deref(), Some("An RGB primary"));
904 }
905 other => panic!("expected enum, got {other:?}"),
906 }
907 assert_eq!(
908 schema.relations[0].description.as_deref(),
909 Some("A directed edge")
910 );
911 }
912
913 #[test]
914 fn field_constraints_are_lowered() {
915 let src = r#"
916 struct "Profile" {
917 field "confidence" type="float" min=0 max=1
918 field "name" type="string" min_length=1 max_length=32 pattern="^[a-z]+$"
919 }
920 "#;
921 let schema = parse(src).expect("schema parses");
922 let fields = match &schema.types[0] {
923 ir::TypeDef::Struct { fields, .. } => fields,
924 other => panic!("expected struct, got {other:?}"),
925 };
926 assert_eq!(fields[0].constraints.min, Some(0));
927 assert_eq!(fields[0].constraints.max, Some(1));
928 assert_eq!(fields[1].constraints.min_length, Some(1));
929 assert_eq!(fields[1].constraints.max_length, Some(32));
930 assert_eq!(fields[1].constraints.pattern.as_deref(), Some("^[a-z]+$"));
931 }
932
933 #[test]
934 fn absent_constraints_and_description_default_to_none() {
935 let src = r#"
936 struct "Bare" {
937 field "x" type="int"
938 }
939 "#;
940 let schema = parse(src).expect("schema parses");
941 let fields = match &schema.types[0] {
942 ir::TypeDef::Struct { fields, .. } => fields,
943 other => panic!("expected struct, got {other:?}"),
944 };
945 assert!(fields[0].description.is_none());
946 assert!(
947 fields[0].constraints.is_empty(),
948 "a field with no constraint properties has empty Constraints"
949 );
950 }
951}