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 for_snapshot_with_runner(
127 snapshot_module: &str,
128 runner: Command,
129 ) -> Result<Self, BehaviorError> {
130 Self::from_test_runner(runner)?.beside_snapshot(snapshot_module)
131 }
132
133 pub fn from_test_binary(executable: impl AsRef<Path>) -> Result<Self, BehaviorError> {
135 Self::from_test_runner(Command::new(executable.as_ref()))
136 }
137
138 fn from_test_runner(mut runner: Command) -> Result<Self, BehaviorError> {
139 let executable = PathBuf::from(runner.get_program());
140 runner.args(["--list", "--format", "terse"]);
141 let mut list = |ignored| {
142 if ignored {
143 runner.arg("--ignored");
144 }
145 let output = runner
146 .output()
147 .map_err(|source| BehaviorError::DiscoveryIo {
148 executable: executable.clone(),
149 source,
150 })?;
151 test_listing(&executable, output)
152 };
153 Self::from_listings(&list(false)?, &list(true)?)
154 }
155
156 fn from_listings(all: &str, ignored: &str) -> Result<Self, BehaviorError> {
157 let all = test_names(all)?;
158 let ignored = test_names(ignored)?;
159 if let Some(name) = ignored.difference(&all).next() {
160 return Err(BehaviorError::UnknownIgnoredCheck { name: name.clone() });
161 }
162 Ok(Self {
163 checks: all
164 .into_iter()
165 .map(|qualified_name| {
166 let disposition = if ignored.contains(&qualified_name) {
167 CheckDisposition::Ignored
168 } else {
169 CheckDisposition::Runnable
170 };
171 BehaviorCheck {
172 qualified_name,
173 disposition,
174 }
175 })
176 .collect(),
177 })
178 }
179
180 fn beside_snapshot(mut self, snapshot_module: &str) -> Result<Self, BehaviorError> {
181 let invalid = || BehaviorError::InvalidSnapshotModule {
182 module: snapshot_module.to_owned(),
183 };
184 let (crate_name, relative_module) = snapshot_module.split_once("::").ok_or_else(invalid)?;
185 if crate_name.is_empty() || relative_module.split("::").any(str::is_empty) {
186 return Err(invalid());
187 }
188 let parent = relative_module
189 .rsplit_once("::")
190 .map_or("", |(parent, _)| parent);
191 let owner_prefix = format!("{parent}::");
192 let snapshot_prefix = format!("{relative_module}::");
193 self.checks.retain(|check| {
194 (parent.is_empty() || check.qualified_name.starts_with(&owner_prefix))
195 && !check.qualified_name.starts_with(&snapshot_prefix)
196 });
197 if self.checks.is_empty() {
198 return Err(BehaviorError::EmptyScope {
199 module: snapshot_module.to_owned(),
200 });
201 }
202 Ok(self)
203 }
204
205 pub fn checks(&self) -> &[BehaviorCheck] {
206 &self.checks
207 }
208
209 pub fn render(&self) -> String {
210 let mut sentences = BTreeMap::<String, Vec<&BehaviorCheck>>::new();
211 for check in &self.checks {
212 let name = check
213 .qualified_name
214 .rsplit("::")
215 .next()
216 .unwrap_or(&check.qualified_name);
217 let words = name.replace('_', " ");
218 let sentence = words
219 .chars()
220 .take(1)
221 .flat_map(char::to_uppercase)
222 .chain(words.chars().skip(1))
223 .collect();
224 sentences.entry(sentence).or_default().push(check);
225 }
226 let mut rendering = String::new();
227 for (sentence, checks) in sentences {
228 for check in &checks {
229 rendering.push_str("- ");
230 rendering.push_str(&sentence);
231 if checks.len() > 1 {
232 rendering.push_str(" (");
233 rendering.push_str(&check.qualified_name);
234 rendering.push(')');
235 }
236 match check.disposition {
237 CheckDisposition::Runnable => {}
238 CheckDisposition::Ignored => rendering.push_str(" [ignored]"),
239 }
240 rendering.push('\n');
241 }
242 }
243 rendering
244 }
245}
246
247fn test_listing(executable: &Path, output: Output) -> Result<String, BehaviorError> {
248 if !output.status.success() {
249 return Err(BehaviorError::DiscoveryFailed {
250 executable: executable.to_owned(),
251 output,
252 });
253 }
254 String::from_utf8(output.stdout).map_err(BehaviorError::ListingUtf8)
255}
256
257fn test_names(listing: &str) -> Result<BTreeSet<String>, BehaviorError> {
258 let mut names = BTreeSet::new();
259 for line in listing.lines().filter(|line| !line.is_empty()) {
260 if let Some(name) = line.strip_suffix(": test") {
261 if name.is_empty() || name.split("::").any(str::is_empty) {
262 return Err(BehaviorError::InvalidListing {
263 line: line.to_owned(),
264 });
265 }
266 if !names.insert(name.to_owned()) {
267 return Err(BehaviorError::DuplicateCheck {
268 name: name.to_owned(),
269 });
270 }
271 } else if !line.ends_with(": benchmark") {
272 return Err(BehaviorError::InvalidListing {
273 line: line.to_owned(),
274 });
275 }
276 }
277 Ok(names)
278}
279
280#[cfg(test)]
281mod tests;
282
283#[cfg(test)]
284mod snapshot;