test_better_matchers/
description.rs1use std::borrow::Cow;
11use std::fmt;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct Description {
16 node: Node,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21enum Node {
22 Text(Cow<'static, str>),
24 Not(Box<Node>),
26 All(Vec<Node>),
28 Any(Vec<Node>),
30 Labeled {
33 header: Cow<'static, str>,
35 child: Box<Node>,
37 },
38}
39
40impl Description {
41 #[must_use]
43 pub fn text(text: impl Into<Cow<'static, str>>) -> Self {
44 Self {
45 node: Node::Text(text.into()),
46 }
47 }
48
49 #[must_use]
52 pub fn and(self, other: Description) -> Self {
53 let mut parts = match self.node {
54 Node::All(parts) => parts,
55 node => vec![node],
56 };
57 match other.node {
58 Node::All(more) => parts.extend(more),
59 node => parts.push(node),
60 }
61 Self {
62 node: Node::All(parts),
63 }
64 }
65
66 #[must_use]
69 pub fn or(self, other: Description) -> Self {
70 let mut parts = match self.node {
71 Node::Any(parts) => parts,
72 node => vec![node],
73 };
74 match other.node {
75 Node::Any(more) => parts.extend(more),
76 node => parts.push(node),
77 }
78 Self {
79 node: Node::Any(parts),
80 }
81 }
82
83 #[must_use]
86 pub fn labeled(header: impl Into<Cow<'static, str>>, child: Description) -> Self {
87 Self {
88 node: Node::Labeled {
89 header: header.into(),
90 child: Box::new(child.node),
91 },
92 }
93 }
94}
95
96impl std::ops::Not for Description {
97 type Output = Description;
98
99 fn not(self) -> Description {
102 let node = match self.node {
103 Node::Not(inner) => *inner,
104 other => Node::Not(Box::new(other)),
105 };
106 Description { node }
107 }
108}
109
110impl fmt::Display for Description {
111 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112 f.write_str(&render(&self.node))
113 }
114}
115
116fn render(node: &Node) -> String {
120 match node {
121 Node::Text(text) => text.to_string(),
122 Node::Not(inner) => format!("not {}", parenthesize_under_not(inner)),
123 Node::All(parts) => parts
124 .iter()
125 .map(parenthesize_under_all)
126 .collect::<Vec<_>>()
127 .join(" and "),
128 Node::Any(parts) => parts.iter().map(render).collect::<Vec<_>>().join(" or "),
129 Node::Labeled { header, child } => {
130 let body = render(child)
131 .lines()
132 .map(|line| format!(" {line}"))
133 .collect::<Vec<_>>()
134 .join("\n");
135 format!("{header}:\n{body}")
136 }
137 }
138}
139
140fn parenthesize_under_not(node: &Node) -> String {
142 match node {
143 Node::All(_) | Node::Any(_) => format!("({})", render(node)),
144 _ => render(node),
145 }
146}
147
148fn parenthesize_under_all(node: &Node) -> String {
151 match node {
152 Node::Any(_) => format!("({})", render(node)),
153 _ => render(node),
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use test_better_core::TestResult;
160
161 use super::*;
162 use crate::{eq, expect};
163
164 #[test]
165 fn text_renders_verbatim() -> TestResult {
166 expect!(Description::text("equal to 4").to_string()).to(eq("equal to 4".to_string()))?;
167 Ok(())
168 }
169
170 #[test]
171 fn not_negates_and_double_negation_cancels() -> TestResult {
172 let base = Description::text("equal to 4");
173 expect!((!base.clone()).to_string()).to(eq("not equal to 4".to_string()))?;
174 expect!((!!base).to_string()).to(eq("equal to 4".to_string()))?;
175 Ok(())
176 }
177
178 #[test]
179 fn and_flattens_and_joins() -> TestResult {
180 let combined = Description::text("greater than 0")
181 .and(Description::text("less than 100"))
182 .and(Description::text("even"));
183 expect!(combined.to_string())
184 .to(eq("greater than 0 and less than 100 and even".to_string()))?;
185 Ok(())
186 }
187
188 #[test]
189 fn or_flattens_and_joins() -> TestResult {
190 let combined = Description::text("zero")
191 .or(Description::text("one"))
192 .or(Description::text("two"));
193 expect!(combined.to_string()).to(eq("zero or one or two".to_string()))?;
194 Ok(())
195 }
196
197 #[test]
198 fn or_inside_and_is_parenthesized() -> TestResult {
199 let combined = Description::text("positive")
200 .and(Description::text("small").or(Description::text("huge")));
201 expect!(combined.to_string()).to(eq("positive and (small or huge)".to_string()))?;
202 Ok(())
203 }
204
205 #[test]
206 fn not_of_compound_is_parenthesized() -> TestResult {
207 let combined = !Description::text("a").and(Description::text("b"));
208 expect!(combined.to_string()).to(eq("not (a and b)".to_string()))?;
209 Ok(())
210 }
211
212 #[test]
213 fn labeled_indents_the_child() -> TestResult {
214 let described = Description::labeled("some", Description::text("equal to 42"));
215 expect!(described.to_string()).to(eq("some:\n equal to 42".to_string()))?;
216 Ok(())
217 }
218
219 #[test]
220 fn nested_labels_indent_two_spaces_per_level() -> TestResult {
221 let described = Description::labeled(
222 "some",
223 Description::labeled("ok", Description::text("equal to 42")),
224 );
225 expect!(described.to_string()).to(eq("some:\n ok:\n equal to 42".to_string()))?;
226 Ok(())
227 }
228}