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    /// The executable must be a trusted Rust libtest binary supporting `--list`.
124    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;