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::{check, eq};
163
164 #[test]
165 fn text_renders_verbatim() -> TestResult {
166 check!(Description::text("equal to 4").to_string())
167 .satisfies(eq("equal to 4".to_string()))?;
168 Ok(())
169 }
170
171 #[test]
172 fn not_negates_and_double_negation_cancels() -> TestResult {
173 let base = Description::text("equal to 4");
174 check!((!base.clone()).to_string()).satisfies(eq("not equal to 4".to_string()))?;
175 check!((!!base).to_string()).satisfies(eq("equal to 4".to_string()))?;
176 Ok(())
177 }
178
179 #[test]
180 fn and_flattens_and_joins() -> TestResult {
181 let combined = Description::text("greater than 0")
182 .and(Description::text("less than 100"))
183 .and(Description::text("even"));
184 check!(combined.to_string())
185 .satisfies(eq("greater than 0 and less than 100 and even".to_string()))?;
186 Ok(())
187 }
188
189 #[test]
190 fn or_flattens_and_joins() -> TestResult {
191 let combined = Description::text("zero")
192 .or(Description::text("one"))
193 .or(Description::text("two"));
194 check!(combined.to_string()).satisfies(eq("zero or one or two".to_string()))?;
195 Ok(())
196 }
197
198 #[test]
199 fn or_inside_and_is_parenthesized() -> TestResult {
200 let combined = Description::text("positive")
201 .and(Description::text("small").or(Description::text("huge")));
202 check!(combined.to_string()).satisfies(eq("positive and (small or huge)".to_string()))?;
203 Ok(())
204 }
205
206 #[test]
207 fn not_of_compound_is_parenthesized() -> TestResult {
208 let combined = !Description::text("a").and(Description::text("b"));
209 check!(combined.to_string()).satisfies(eq("not (a and b)".to_string()))?;
210 Ok(())
211 }
212
213 #[test]
214 fn labeled_indents_the_child() -> TestResult {
215 let described = Description::labeled("some", Description::text("equal to 42"));
216 check!(described.to_string()).satisfies(eq("some:\n equal to 42".to_string()))?;
217 Ok(())
218 }
219
220 #[test]
221 fn nested_labels_indent_two_spaces_per_level() -> TestResult {
222 let described = Description::labeled(
223 "some",
224 Description::labeled("ok", Description::text("equal to 42")),
225 );
226 check!(described.to_string()).satisfies(eq("some:\n ok:\n equal to 42".to_string()))?;
227 Ok(())
228 }
229}