1#[derive(Debug, Default, Clone)]
10pub struct Model {
11 pub mounts: Vec<Mount>,
13 pub nodes: Vec<NodeDecl>,
15 pub refs: Vec<RefDecl>,
17 pub rels: Vec<RelDecl>,
19 pub edges: Vec<EdgeDecl>,
21 pub defs_text: String,
24 pub aliases: Vec<AliasDecl>,
28 pub traits: Vec<TraitDecl>,
33}
34
35#[derive(Debug, Clone)]
37pub struct TraitDecl {
38 pub name: String,
39 pub step: String,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum AliasKind {
48 Name,
50 Trait,
53}
54
55#[derive(Debug, Clone)]
57pub enum AliasRule {
58 Exact { alias: String, original: String },
60 Subst {
65 pattern: regex::Regex,
66 replacement: String,
67 ignore_case: bool,
68 global: bool,
69 },
70}
71
72#[derive(Debug, Clone)]
73pub struct AliasDecl {
74 pub kind: AliasKind,
75 pub rule: AliasRule,
76}
77
78impl AliasDecl {
79 pub fn admits(&self, written: &str, canonical: &str) -> bool {
82 match &self.rule {
83 AliasRule::Exact { alias, original } => written == alias && canonical == original,
84 AliasRule::Subst {
85 pattern,
86 replacement,
87 ignore_case,
88 global,
89 } => {
90 if !pattern.is_match(canonical) {
93 return false;
94 }
95 let produced = if *global {
96 pattern.replace_all(canonical, replacement.as_str())
97 } else {
98 pattern.replace(canonical, replacement.as_str())
99 };
100 if *ignore_case {
101 produced.eq_ignore_ascii_case(written)
102 || produced.to_lowercase() == written.to_lowercase()
103 } else {
104 produced == written
105 }
106 }
107 }
108 }
109}
110
111fn parse_trait(body: &str, stmt: &str) -> Result<TraitDecl, String> {
117 let body = body.trim();
118 let Some(rest) = body.strip_prefix('<') else {
119 return Err(format!(
120 "trait needs '<name>: STEP' (the name in angle brackets): '{stmt}'"
121 ));
122 };
123 let Some((name, step)) = rest.split_once('>') else {
124 return Err(format!("trait <name> is not closed: '{stmt}'"));
125 };
126 let name = name.trim();
127 if let Some(bare) = name.strip_prefix('.') {
128 return Err(format!(
129 "a declared trait has a plain name — write 'trait <{bare}>', not '<.{bare}>': '{stmt}'"
130 ));
131 }
132 let plain = name
133 .chars()
134 .next()
135 .is_some_and(|c| c.is_alphabetic() || c == '_')
136 && name
137 .chars()
138 .all(|c| c.is_alphanumeric() || matches!(c, '_' | '-'));
139 if !plain {
140 return Err(format!(
141 "trait name '<{name}>' is not a plain name: '{stmt}'"
142 ));
143 }
144 let Some(step) = step.trim().strip_prefix(':') else {
145 return Err(format!(
146 "trait <{name}> needs ': STEP' after the name: '{stmt}'"
147 ));
148 };
149 let step = step.trim();
150 if step.is_empty() {
151 return Err(format!("trait <{name}> needs a step body: '{stmt}'"));
152 }
153 quarb::validate_step_body(step).map_err(|e| format!("trait <{name}>: {e}"))?;
154 Ok(TraitDecl {
155 name: name.to_string(),
156 step: step.to_string(),
157 })
158}
159
160fn parse_alias(body: &str, stmt: &str) -> Result<AliasDecl, String> {
161 let body = body.trim();
162 let (kind, body) = match body.strip_prefix('<') {
163 Some(inner) => {
164 let inner = inner.trim_end();
165 let inner = inner
166 .strip_suffix('>')
167 .ok_or_else(|| format!("a trait alias closes its angle bracket: '{stmt}'"))?;
168 let inner = inner.trim();
170 let inner = match inner.split_once("> <") {
171 Some((a, b)) => format!("{} {}", a.trim(), b.trim()),
172 None => inner.to_string(),
173 };
174 (AliasKind::Trait, inner)
175 }
176 None => (AliasKind::Name, body.to_string()),
177 };
178 let body = body.trim();
179 if let Some(rest) = body.strip_prefix("s/") {
180 let mut parts: Vec<String> = Vec::new();
182 let mut cur = String::new();
183 let mut chars = rest.chars().peekable();
184 while let Some(c) = chars.next() {
185 if c == '\\' && chars.peek() == Some(&'/') {
186 cur.push('/');
187 chars.next();
188 } else if c == '/' {
189 parts.push(std::mem::take(&mut cur));
190 } else {
191 cur.push(c);
192 }
193 }
194 parts.push(cur);
195 if parts.len() != 3 {
196 return Err(format!(
197 "a substitution alias is 's/pattern/replacement/flags': '{stmt}'"
198 ));
199 }
200 let flags = parts[2].trim();
201 for f in flags.chars() {
202 if !matches!(f, 'i' | 'g') {
203 return Err(format!("unknown substitution flag '{f}' (i, g): '{stmt}'"));
204 }
205 }
206 let pattern = regex::Regex::new(&parts[0])
207 .map_err(|e| format!("alias pattern does not parse: {e}: '{stmt}'"))?;
208 return Ok(AliasDecl {
209 kind,
210 rule: AliasRule::Subst {
211 pattern,
212 replacement: parts[1].clone(),
213 ignore_case: flags.contains('i'),
214 global: flags.contains('g'),
215 },
216 });
217 }
218 let (alias, original) = body.split_once(char::is_whitespace).ok_or_else(|| {
219 format!("alias needs 'NEW ORIGINAL' or 's/pattern/replacement/': '{stmt}'")
220 })?;
221 let strip = |t: &str| {
222 t.trim()
223 .trim_start_matches('<')
224 .trim_end_matches('>')
225 .to_string()
226 };
227 Ok(AliasDecl {
228 kind,
229 rule: AliasRule::Exact {
230 alias: strip(alias),
231 original: strip(original),
232 },
233 })
234}
235
236#[derive(Debug, Clone)]
237pub struct Mount {
238 pub name: String,
239 pub target: String,
240}
241
242#[derive(Debug, Clone)]
243pub struct NodeDecl {
244 pub name: String,
246 pub role: String,
250 pub trait_name: String,
253 pub query: String,
254}
255
256#[derive(Debug, Clone)]
257pub struct RefDecl {
258 pub scope: String,
260 pub field: String,
262 pub container: String,
263 pub path: String,
267 pub role: Option<String>,
272 pub key_field: Option<String>,
276}
277
278#[derive(Debug, Clone)]
282pub struct RelDecl {
283 pub source: String,
284 pub target: String,
286 pub cond: String,
288}
289
290#[derive(Debug, Clone)]
291pub struct EdgeDecl {
292 pub scope: String,
293 pub field_a: String,
294 pub field_b: String,
295}
296
297fn statements(text: &str) -> Vec<String> {
304 let b: Vec<char> = text.chars().collect();
305 let mut out = Vec::new();
306 let mut start = 0;
307 let mut i = 0;
308 let mut depth = 0usize;
309 while i < b.len() {
310 match b[i] {
311 q @ ('\'' | '"') => {
312 i += 1;
313 while i < b.len() && b[i] != q {
314 if b[i] == '\\' {
315 i += 1;
316 }
317 i += 1;
318 }
319 i += 1;
320 }
321 '(' | '[' | '{' => {
322 depth += 1;
323 i += 1;
324 }
325 ')' | ']' | '}' => {
326 depth = depth.saturating_sub(1);
327 i += 1;
328 }
329 ';' => {
330 let mut run = 0;
331 while i < b.len() && b[i] == ';' {
332 run += 1;
333 i += 1;
334 }
335 if run == 1 && depth == 0 {
336 out.push(b[start..i - 1].iter().collect());
337 start = i;
338 }
339 }
340 _ => i += 1,
341 }
342 }
343 if start < b.len() {
344 let tail: String = b[start..].iter().collect();
345 if !tail.trim().is_empty() {
346 out.push(tail);
347 }
348 }
349 out
350}
351
352fn strip_comments(text: &str) -> String {
357 let mut out = String::new();
358 for line in text.lines() {
359 let b: Vec<char> = line.chars().collect();
360 let mut i = 0;
361 while i < b.len() {
362 match b[i] {
363 q @ ('\'' | '"') => {
364 out.push(q);
365 i += 1;
366 while i < b.len() && b[i] != q {
367 if b[i] == '\\' && i + 1 < b.len() {
368 out.push(b[i]);
369 i += 1;
370 }
371 out.push(b[i]);
372 i += 1;
373 }
374 if i < b.len() {
375 out.push(b[i]);
376 i += 1;
377 }
378 }
379 '#' if i == 0 || b[i - 1].is_whitespace() => break,
380 c => {
381 out.push(c);
382 i += 1;
383 }
384 }
385 }
386 out.push('\n');
387 }
388 out
389}
390
391fn split_field(s: &str) -> Result<(String, String), String> {
394 let s = s.trim();
395 match s.rfind("::") {
396 Some(idx) => {
397 let path = s[..idx].trim().to_string();
398 let field = s[idx + 2..].trim().to_string();
399 if path.is_empty() {
400 return Err(format!(
401 "ref needs a path scope before '::{field}' \
402 (bare fields are refused): '{s}'"
403 ));
404 }
405 if field.is_empty() || field.contains(|c: char| c.is_whitespace()) {
406 return Err(format!("ref/edge field is not a bare property: '{s}'"));
407 }
408 Ok((path, field))
409 }
410 None => Err(format!(
411 "ref/edge needs a scoped '::field' (bare fields are refused): '{s}'"
412 )),
413 }
414}
415
416pub fn resolve_mount_target(target: &str, base_dir: Option<&std::path::Path>) -> String {
421 let has_scheme = target.split_once(':').is_some_and(|(s, _)| {
422 s.len() > 1 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
423 });
424 let p = std::path::Path::new(target);
425 if has_scheme || p.is_absolute() {
426 return target.to_string();
427 }
428 match base_dir {
429 Some(dir) if !dir.as_os_str().is_empty() => dir.join(target).display().to_string(),
430 _ => target.to_string(),
431 }
432}
433
434fn split_trait(head: &str) -> (String, Option<String>) {
436 match head.split_once('<') {
437 Some((path, rest)) => (
438 path.trim().to_string(),
439 rest.strip_suffix('>').map(|t| t.trim().to_string()),
440 ),
441 None => (head.to_string(), None),
442 }
443}
444
445fn split_container_role(path: &str) -> Option<(String, String)> {
448 let p = path.trim().trim_start_matches('/');
449 let (container, role) = p.split_once('/')?;
450 let (container, role) = (container.trim(), role.trim());
451 if container.is_empty() || role.is_empty() || role.contains('/') {
452 return None;
453 }
454 Some((container.to_string(), role.to_string()))
455}
456
457fn split_trailing_group(s: &str) -> Option<(String, String)> {
461 let s = s.trim();
462 if !s.ends_with(']') {
463 return None;
464 }
465 let b: Vec<char> = s.chars().collect();
466 let mut depth = 0usize;
467 for i in (0..b.len()).rev() {
468 match b[i] {
469 ']' => depth += 1,
470 '[' => {
471 depth -= 1;
472 if depth == 0 {
473 let path = b[..i].iter().collect::<String>().trim().to_string();
474 let group = b[i..].iter().collect::<String>();
475 return (!path.is_empty()).then_some((path, group));
476 }
477 }
478 _ => {}
479 }
480 }
481 None
482}
483
484fn parse_ref_target(s: &str) -> Result<(String, String, Option<String>, Option<String>), String> {
487 let (path, key_field) = match split_trailing_group(s) {
488 Some((path, group)) => {
489 let inner = group.trim_start_matches('[').trim_end_matches(']').trim();
490 let (lhs, rhs) = inner
491 .split_once('=')
492 .ok_or_else(|| format!("ref target condition must be 'PROP = $': '{s}'"))?;
493 if rhs.trim() != "$" {
494 return Err(format!(
495 "ref resolves by value, so its condition is 'PROP = $': '{s}'"
496 ));
497 }
498 let key = lhs.trim().trim_start_matches("::").trim().to_string();
499 (path, (!key.is_empty()).then_some(key))
500 }
501 None => (s.trim().to_string(), None),
502 };
503 let mut segs = path.trim_start_matches('/').split('/').map(str::trim);
506 let container = segs.next().unwrap_or("").to_string();
507 let role = segs.next().filter(|r| !r.is_empty()).map(str::to_string);
508 if container.is_empty() {
509 return Err(format!("ref needs a target container: '{s}'"));
510 }
511 Ok((container, path, role, key_field))
512}
513
514fn split_arrow(s: &str) -> Option<(String, String)> {
516 let b: Vec<char> = s.chars().collect();
517 let mut i = 0;
518 while i + 1 < b.len() {
519 if b[i] == '-' && b[i + 1] == '>' && !(i > 0 && b[i - 1] == '-') {
520 return Some((
521 b[..i].iter().collect::<String>().trim().to_string(),
522 b[i + 2..].iter().collect::<String>().trim().to_string(),
523 ));
524 }
525 i += 1;
526 }
527 None
528}
529
530pub fn parse_model(text: &str) -> Result<Model, String> {
534 parse_model_in(text, None, &mut Vec::new(), &mut Vec::new())
535}
536
537pub fn parse_model_file(path: &std::path::Path) -> Result<Model, String> {
544 let text =
545 std::fs::read_to_string(path).map_err(|e| format!("reading {}: {e}", path.display()))?;
546 let text = text.strip_prefix('\u{feff}').unwrap_or(&text).to_owned();
547 let mut visited = vec![path.canonicalize().unwrap_or_else(|_| path.to_path_buf())];
548 parse_model_in(&text, path.parent(), &mut visited, &mut Vec::new())
549}
550
551fn parse_model_in(
552 text: &str,
553 base_dir: Option<&std::path::Path>,
554 visited: &mut Vec<std::path::PathBuf>,
555 done: &mut Vec<std::path::PathBuf>,
556) -> Result<Model, String> {
557 let mut model = Model::default();
558 let mut defs = Vec::new();
559 for raw in statements(&strip_comments(text)) {
560 let stmt = raw.trim();
561 if stmt.is_empty() {
562 continue;
563 }
564 let (head, rest) = match stmt.split_once(char::is_whitespace) {
565 Some((h, r)) => (h, r.trim()),
566 None => (stmt, ""),
567 };
568 match head {
569 "node" => {
570 let (head, query) = rest
571 .split_once(':')
572 .ok_or_else(|| format!("node needs '/container/role: query': '{stmt}'"))?;
573 let (path, trait_name) = split_trait(head.trim());
574 let (name, role) = split_container_role(&path).ok_or_else(|| {
575 format!(
576 "node needs '/container/role', so a hop can be \
577 labelled by the role it lands on: '{stmt}'"
578 )
579 })?;
580 let trait_name = trait_name.unwrap_or_else(|| role.clone());
581 model.nodes.push(NodeDecl {
582 name,
583 role,
584 trait_name,
585 query: query.trim().to_string(),
586 });
587 }
588 "mount" => {
589 let (name, target) = rest
590 .split_once(':')
591 .ok_or_else(|| format!("mount needs 'NAME: target': '{stmt}'"))?;
592 let name = name.trim().to_string();
593 if model.mounts.iter().any(|m| m.name == name) {
594 return Err(format!("mount '{name}' is already declared: '{stmt}'"));
595 }
596 model.mounts.push(Mount {
597 name,
598 target: target.trim().to_string(),
599 });
600 }
601 "use" => {
602 let file = rest.trim().trim_matches(|c| c == '"' || c == '\'');
603 if file.is_empty() {
604 return Err(format!("use needs a model file: '{stmt}'"));
605 }
606 let Some(dir) = base_dir else {
607 return Err(format!(
608 "use needs a location to resolve '{file}' against: pass the model as a file (--model FILE)"
609 ));
610 };
611 let path = {
612 let p = std::path::Path::new(file);
613 if p.is_absolute() {
614 p.to_path_buf()
615 } else {
616 dir.join(p)
617 }
618 };
619 let canon = path.canonicalize().unwrap_or_else(|_| path.clone());
620 if visited.contains(&canon) {
625 return Err(format!("use {file}: the model includes itself"));
626 }
627 if done.contains(&canon) {
628 continue;
629 }
630 visited.push(canon.clone());
631 let text = std::fs::read_to_string(&path)
632 .map_err(|e| format!("use {file}: reading {}: {e}", path.display()))?;
633 let text = text.strip_prefix('\u{feff}').unwrap_or(&text).to_owned();
634 let sub = parse_model_in(&text, path.parent(), visited, done)
635 .map_err(|e| format!("use {file}: {e}"))?;
636 visited.pop();
637 done.push(canon);
638 for m in sub.mounts {
639 if model.mounts.iter().any(|x| x.name == m.name) {
640 return Err(format!(
641 "use {file}: mount '{}' is already declared",
642 m.name
643 ));
644 }
645 model.mounts.push(Mount {
646 name: m.name,
647 target: resolve_mount_target(&m.target, path.parent()),
648 });
649 }
650 model.nodes.extend(sub.nodes);
651 model.refs.extend(sub.refs);
652 model.rels.extend(sub.rels);
653 model.edges.extend(sub.edges);
654 model.aliases.extend(sub.aliases);
655 for t in sub.traits {
656 if model.traits.iter().any(|x| x.name == t.name) {
657 return Err(format!(
658 "use {file}: trait <{}> is already declared",
659 t.name
660 ));
661 }
662 model.traits.push(t);
663 }
664 if !sub.defs_text.trim().is_empty() {
665 defs.push(sub.defs_text);
666 }
667 }
668 "ref" => {
669 let (left, container) = rest
670 .split_once("-->")
671 .ok_or_else(|| format!("ref needs 'PATH::field --> container': '{stmt}'"))?;
672 let (scope, field) = split_field(left)?;
673 let (container, path, role, key_field) = parse_ref_target(container)?;
674 model.refs.push(RefDecl {
675 scope,
676 field,
677 container,
678 path,
679 role,
680 key_field,
681 });
682 }
683 "rel" => {
684 let (source, target) = split_arrow(rest)
685 .ok_or_else(|| format!("rel needs 'SOURCE -> TARGET[cond]': '{stmt}'"))?;
686 let (target, cond) = split_trailing_group(&target).ok_or_else(|| {
687 format!(
688 "rel needs a condition — two node sets joined by \
689 nothing are a cross product: '{stmt}'"
690 )
691 })?;
692 if source.is_empty() {
693 return Err(format!("rel needs a source path: '{stmt}'"));
694 }
695 model.rels.push(RelDecl {
696 source,
697 target,
698 cond,
699 });
700 }
701 "edge" => {
702 let (scope_head, pair) = rest
703 .split_once(':')
704 .ok_or_else(|| format!("edge needs 'PATH: ::a -- ::b': '{stmt}'"))?;
705 let (a, b) = pair
706 .split_once("--")
707 .ok_or_else(|| format!("edge needs two fields 'a -- b': '{stmt}'"))?;
708 let field_a = a.trim().trim_start_matches("::").trim().to_string();
709 let field_b = b.trim().trim_start_matches("::").trim().to_string();
710 model.edges.push(EdgeDecl {
711 scope: scope_head.trim().to_string(),
712 field_a,
713 field_b,
714 });
715 }
716 "def" | "macro" => {
717 defs.push(format!("{stmt};"));
718 }
719 "alias" => {
720 model.aliases.push(parse_alias(rest, stmt)?);
721 }
722 "trait" => {
723 let decl = parse_trait(rest, stmt)?;
724 if model.traits.iter().any(|t| t.name == decl.name) {
725 return Err(format!("trait <{}> is declared twice: '{stmt}'", decl.name));
726 }
727 model.traits.push(decl);
728 }
729 other => {
730 return Err(format!(
731 "unknown model statement '{other}' \
732 (expected node/ref/rel/edge/mount/alias/trait/def/macro)"
733 ));
734 }
735 }
736 }
737 model.defs_text = defs.join("\n");
738 for edge in &model.edges {
743 for field in [&edge.field_a, &edge.field_b] {
744 if !model.refs.iter().any(|r| &r.field == field) {
745 return Err(format!(
746 "edge field '::{field}' has no ref declaration — \
747 an edge connects the containers its fields \
748 resolve into, so declare \
749 'ref {}::{field} --> ...;' first",
750 edge.scope
751 ));
752 }
753 }
754 }
755 Ok(model)
756}
757
758#[cfg(test)]
759mod tests {
760 use super::*;
761
762 #[test]
763 fn parses_the_forum_model() {
764 let m = parse_model(
765 r#"
766 # the forum's implied graph
767 mount forum: posts.csv;
768 node /ips/ip: /forum/posts/*::ip | unique;
769 node /cookies/cookie: /forum/posts/*::cookie | unique;
770 ref /forum/posts/*::ip --> ips;
771 ref /forum/posts/*::cookie --> cookies;
772 edge /forum/posts/*: ::cookie -- ::ip;
773 "#,
774 )
775 .unwrap();
776 assert_eq!(m.mounts.len(), 1);
777 assert_eq!(m.mounts[0].name, "forum");
778 assert_eq!(m.mounts[0].target, "posts.csv");
779 assert_eq!(m.nodes.len(), 2);
780 assert_eq!(m.nodes[0].name, "ips");
781 assert_eq!(m.nodes[0].role, "ip");
782 assert_eq!(m.nodes[0].trait_name, "ip");
783 assert_eq!(m.nodes[0].query, "/forum/posts/*::ip | unique");
784 assert_eq!(m.refs.len(), 2);
785 assert_eq!(m.refs[1].scope, "/forum/posts/*");
786 assert_eq!(m.refs[1].field, "cookie");
787 assert_eq!(m.refs[1].container, "cookies");
788 assert_eq!(m.edges.len(), 1);
789 assert_eq!(m.edges[0].scope, "/forum/posts/*");
790 assert_eq!(m.edges[0].field_a, "cookie");
791 assert_eq!(m.edges[0].field_b, "ip");
792 }
793
794 #[test]
795 fn trailing_comments_are_stripped_but_glued_hashes_survive() {
796 let m = parse_model(
797 "node /ips/ip: /row::ip | unique; # distinct ips\n\
798 node /tags/tag: /row::tag | s/#(\\d+)/$1/ | unique;\n\
799 ref /row::ip --> ips; # resolves there\n",
800 )
801 .unwrap();
802 assert_eq!(m.nodes.len(), 2);
803 assert_eq!(m.nodes[0].query, "/row::ip | unique");
804 assert!(m.nodes[1].query.contains("s/#("));
806 assert_eq!(m.refs.len(), 1);
807 }
808
809 #[test]
810 fn collects_defs_and_comments() {
811 let m = parse_model("def &recent: [::ts > '2026-01-01'];\nnode /who/user: /log/*::user;")
812 .unwrap();
813 assert_eq!(m.nodes.len(), 1);
814 assert!(m.defs_text.contains("def &recent"));
815 }
816
817 #[test]
818 fn a_semicolon_projection_is_not_a_terminator() {
819 let m = parse_model("node /sizes/size: /files/*::::size;").unwrap();
821 assert_eq!(m.nodes.len(), 1);
822 assert_eq!(m.nodes[0].query, "/files/*::::size");
823 }
824
825 #[test]
826 fn a_node_head_needs_a_role() {
827 assert!(parse_model("node ips: /row::ip;").is_err());
830 }
831
832 #[test]
833 fn declares_an_explicit_trait() {
834 let m = parse_model("node /ips/ip<addr>: /row::ip | unique;").unwrap();
835 assert_eq!(m.nodes[0].role, "ip");
836 assert_eq!(m.nodes[0].trait_name, "addr");
837 }
838
839 #[test]
840 fn bare_field_ref_is_refused() {
841 assert!(parse_model("ref ::ip --> ips;").is_err());
842 }
843
844 #[test]
845 fn a_ref_target_may_be_a_path_with_its_condition_said_out_loud() {
846 let m = parse_model("ref /row::cookie --> /cookies/cookie[::id = $];").unwrap();
847 assert_eq!(m.refs[0].container, "cookies");
848 assert_eq!(m.refs[0].key_field.as_deref(), Some("id"));
849 let m = parse_model("ref /row::cookie --> /cookies/cookie;").unwrap();
852 assert_eq!(m.refs[0].container, "cookies");
853 assert_eq!(m.refs[0].key_field, None);
854 }
855
856 #[test]
857 fn parses_a_relation() {
858 let m = parse_model("rel /errors/error -> /deploys/deploy [::at > _::at];").unwrap();
859 assert_eq!(m.rels.len(), 1);
860 assert_eq!(m.rels[0].source, "/errors/error");
861 assert_eq!(m.rels[0].target, "/deploys/deploy");
862 assert_eq!(m.rels[0].cond, "[::at > _::at]");
863 }
864
865 #[test]
866 fn an_edge_over_unrefd_fields_is_refused() {
867 let err = parse_model(
870 "node /ips/ip: /row::ip | unique;\n\
871 edge /row: ::cookie -- ::ip;",
872 )
873 .unwrap_err();
874 assert!(err.contains("no ref declaration"), "{err}");
875 }
876
877 #[test]
878 fn a_relation_without_a_condition_is_refused() {
879 assert!(parse_model("rel /errors/error -> /deploys/deploy;").is_err());
881 }
882}