Skip to main content

pipecircuit_visualization/behavior/
mod.rs

1use 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    /// Call from a dedicated snapshot module beside the checks' owning module.
114    /// Discovers this compiled libtest target, not other targets or Kani harnesses.
115    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    /// Discovers a snapshot through a configured runner, such as Cargo's WASM
124    /// test runner. The command must accept `--list --format terse --ignored`.
125    /// Scope names belong to the runner's target, not the host snapshot binary.
126    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    /// The executable must be a trusted Rust libtest binary supporting `--list`.
134    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;