pipecircuit_visualization/behavior/
mod.rs1use std::{
2 collections::{BTreeMap, BTreeSet},
3 env,
4 error::Error,
5 fmt, io,
6 path::{Path, PathBuf},
7 process::{Command, Output},
8 string::FromUtf8Error,
9};
10
11#[derive(Debug)]
12pub enum BehaviorError {
13 CurrentExecutable(io::Error),
14 DiscoveryIo {
15 executable: PathBuf,
16 source: io::Error,
17 },
18 DiscoveryFailed {
19 executable: PathBuf,
20 output: Output,
21 },
22 ListingUtf8(FromUtf8Error),
23 InvalidListing {
24 line: String,
25 },
26 DuplicateCheck {
27 name: String,
28 },
29 UnknownIgnoredCheck {
30 name: String,
31 },
32 InvalidSnapshotModule {
33 module: String,
34 },
35 EmptyScope {
36 module: String,
37 },
38 RecursiveDiscovery,
39}
40
41impl fmt::Display for BehaviorError {
42 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
43 match self {
44 Self::CurrentExecutable(source) => {
45 write!(formatter, "could not locate the test executable: {source}")
46 }
47 Self::DiscoveryIo { executable, source } => write!(
48 formatter,
49 "could not list tests in `{}`: {source}",
50 executable.display()
51 ),
52 Self::DiscoveryFailed { executable, output } => write!(
53 formatter,
54 "test listing in `{}` failed with {}",
55 executable.display(),
56 output.status
57 ),
58 Self::ListingUtf8(source) => write!(formatter, "test listing was not UTF-8: {source}"),
59 Self::InvalidListing { line } => {
60 write!(formatter, "unrecognized test listing line: `{line}`")
61 }
62 Self::DuplicateCheck { name } => {
63 write!(formatter, "test `{name}` was listed more than once")
64 }
65 Self::UnknownIgnoredCheck { name } => write!(
66 formatter,
67 "ignored test `{name}` was absent from the complete listing"
68 ),
69 Self::InvalidSnapshotModule { module } => write!(
70 formatter,
71 "snapshot module `{module}` was not a fully qualified child module"
72 ),
73 Self::EmptyScope { module } => {
74 write!(formatter, "no compiled checks were found beside `{module}`")
75 }
76 Self::RecursiveDiscovery => formatter.write_str(
77 "behavior discovery must run inside a libtest test, not its listing process",
78 ),
79 }
80 }
81}
82
83impl Error for BehaviorError {}
84
85#[derive(Clone, Copy, Debug, Eq, PartialEq)]
86pub enum CheckDisposition {
87 Runnable,
88 Ignored,
89}
90
91#[derive(Clone, Debug, Eq, PartialEq)]
92pub struct BehaviorCheck {
93 qualified_name: String,
94 disposition: CheckDisposition,
95}
96
97impl BehaviorCheck {
98 pub fn qualified_name(&self) -> &str {
99 &self.qualified_name
100 }
101
102 pub const fn disposition(&self) -> CheckDisposition {
103 self.disposition
104 }
105}
106
107#[derive(Clone, Debug, Eq, PartialEq)]
108pub struct BehaviorInventory {
109 checks: Vec<BehaviorCheck>,
110}
111
112impl BehaviorInventory {
113 pub fn for_snapshot(snapshot_module: &str) -> Result<Self, BehaviorError> {
116 if env::args_os().any(|argument| argument == "--list") {
117 return Err(BehaviorError::RecursiveDiscovery);
118 }
119 let executable = env::current_exe().map_err(BehaviorError::CurrentExecutable)?;
120 Self::from_test_binary(executable)?.beside_snapshot(snapshot_module)
121 }
122
123 pub fn from_test_binary(executable: impl AsRef<Path>) -> Result<Self, BehaviorError> {
125 let executable = executable.as_ref();
126 let list = |ignored| {
127 let mut command = Command::new(executable);
128 command.args(["--list", "--format", "terse"]);
129 if ignored {
130 command.arg("--ignored");
131 }
132 let output = command
133 .output()
134 .map_err(|source| BehaviorError::DiscoveryIo {
135 executable: executable.to_owned(),
136 source,
137 })?;
138 test_listing(executable, output)
139 };
140 Self::from_listings(&list(false)?, &list(true)?)
141 }
142
143 fn from_listings(all: &str, ignored: &str) -> Result<Self, BehaviorError> {
144 let all = test_names(all)?;
145 let ignored = test_names(ignored)?;
146 if let Some(name) = ignored.difference(&all).next() {
147 return Err(BehaviorError::UnknownIgnoredCheck { name: name.clone() });
148 }
149 Ok(Self {
150 checks: all
151 .into_iter()
152 .map(|qualified_name| {
153 let disposition = if ignored.contains(&qualified_name) {
154 CheckDisposition::Ignored
155 } else {
156 CheckDisposition::Runnable
157 };
158 BehaviorCheck {
159 qualified_name,
160 disposition,
161 }
162 })
163 .collect(),
164 })
165 }
166
167 fn beside_snapshot(mut self, snapshot_module: &str) -> Result<Self, BehaviorError> {
168 let invalid = || BehaviorError::InvalidSnapshotModule {
169 module: snapshot_module.to_owned(),
170 };
171 let (crate_name, relative_module) = snapshot_module.split_once("::").ok_or_else(invalid)?;
172 if crate_name.is_empty() || relative_module.split("::").any(str::is_empty) {
173 return Err(invalid());
174 }
175 let parent = relative_module
176 .rsplit_once("::")
177 .map_or("", |(parent, _)| parent);
178 let owner_prefix = format!("{parent}::");
179 let snapshot_prefix = format!("{relative_module}::");
180 self.checks.retain(|check| {
181 (parent.is_empty() || check.qualified_name.starts_with(&owner_prefix))
182 && !check.qualified_name.starts_with(&snapshot_prefix)
183 });
184 if self.checks.is_empty() {
185 return Err(BehaviorError::EmptyScope {
186 module: snapshot_module.to_owned(),
187 });
188 }
189 Ok(self)
190 }
191
192 pub fn checks(&self) -> &[BehaviorCheck] {
193 &self.checks
194 }
195
196 pub fn render(&self) -> String {
197 let mut sentences = BTreeMap::<String, Vec<&BehaviorCheck>>::new();
198 for check in &self.checks {
199 let name = check
200 .qualified_name
201 .rsplit("::")
202 .next()
203 .unwrap_or(&check.qualified_name);
204 let words = name.replace('_', " ");
205 let sentence = words
206 .chars()
207 .take(1)
208 .flat_map(char::to_uppercase)
209 .chain(words.chars().skip(1))
210 .collect();
211 sentences.entry(sentence).or_default().push(check);
212 }
213 let mut rendering = String::new();
214 for (sentence, checks) in sentences {
215 for check in &checks {
216 rendering.push_str("- ");
217 rendering.push_str(&sentence);
218 if checks.len() > 1 {
219 rendering.push_str(" (");
220 rendering.push_str(&check.qualified_name);
221 rendering.push(')');
222 }
223 match check.disposition {
224 CheckDisposition::Runnable => {}
225 CheckDisposition::Ignored => rendering.push_str(" [ignored]"),
226 }
227 rendering.push('\n');
228 }
229 }
230 rendering
231 }
232}
233
234fn test_listing(executable: &Path, output: Output) -> Result<String, BehaviorError> {
235 if !output.status.success() {
236 return Err(BehaviorError::DiscoveryFailed {
237 executable: executable.to_owned(),
238 output,
239 });
240 }
241 String::from_utf8(output.stdout).map_err(BehaviorError::ListingUtf8)
242}
243
244fn test_names(listing: &str) -> Result<BTreeSet<String>, BehaviorError> {
245 let mut names = BTreeSet::new();
246 for line in listing.lines().filter(|line| !line.is_empty()) {
247 if let Some(name) = line.strip_suffix(": test") {
248 if name.is_empty() || name.split("::").any(str::is_empty) {
249 return Err(BehaviorError::InvalidListing {
250 line: line.to_owned(),
251 });
252 }
253 if !names.insert(name.to_owned()) {
254 return Err(BehaviorError::DuplicateCheck {
255 name: name.to_owned(),
256 });
257 }
258 } else if !line.ends_with(": benchmark") {
259 return Err(BehaviorError::InvalidListing {
260 line: line.to_owned(),
261 });
262 }
263 }
264 Ok(names)
265}
266
267#[cfg(test)]
268mod tests;
269
270#[cfg(test)]
271mod snapshot;