use std::{fmt::Write as _, fs, path::Path};
use anyhow::{Context, Result, bail, ensure};
use regex::RegexSet;
use serde_json::{Value, json};
use crate::{
Category, CategorySet, Graph, Solution,
args::{Args, Check, Format},
report::{self, workflow_location},
util::{Map, Set},
};
const BASELINE_VERSION: u32 = 2;
pub type Recorded = Map<(String, String), Vec<String>>;
#[derive(Debug, Clone)]
pub struct Finding {
pub function: String,
pub krate: String,
pub loc: Option<String>,
pub categories: Vec<String>,
}
#[derive(Debug, Clone)]
pub enum Reason {
Forbidden,
New,
Unclassified,
}
impl Reason {
const fn describe(&self) -> &'static str {
match self {
Self::Forbidden => "must not panic",
Self::New => "not in the baseline",
Self::Unclassified => "reaches an unclassified panic",
}
}
}
#[derive(Debug, Clone)]
pub struct Violation {
pub finding: Finding,
pub reason: Reason,
}
#[derive(Debug, Default)]
pub struct Outcome {
pub findings: Vec<Finding>,
pub violations: Vec<Violation>,
pub fixed: Vec<String>,
pub over_max: Option<(usize, usize)>,
}
impl Outcome {
#[must_use]
pub const fn failed(&self) -> bool {
!self.violations.is_empty() || self.over_max.is_some()
}
}
pub fn run(
graph: &Graph,
solution: &Solution,
args: &Args,
check: &Check,
) -> Result<Outcome> {
let findings = collect(graph, solution, args);
let mut outcome = Outcome {
findings,
..Outcome::default()
};
let forbid = compile(&check.forbid, "--forbid")?;
let allow = compile(&check.allow, "--allow")?;
let gate_everything = check.forbid.is_empty()
&& check.max.is_none()
&& check.baseline.is_none();
let baseline = check
.baseline
.as_deref()
.map(|path| read_baseline(path, args))
.transpose()?;
for finding in &outcome.findings {
if allow.is_match(&finding.function) {
continue;
}
let covered = gate_everything || forbid.is_match(&finding.function);
let reason = baseline.as_ref().map_or_else(
|| covered.then_some(Reason::Forbidden),
|known| is_new(known, finding).then_some(Reason::New),
);
let reason = reason.or_else(|| {
let assumed = |name: &String| {
name.parse::<Category>()
.is_ok_and(|c| CategorySet::assumed().contains(c))
};
let asked =
check.forbid.is_empty() || forbid.is_match(&finding.function);
(check.fail_on_unknown
&& asked
&& finding.categories.iter().any(assumed))
.then_some(Reason::Unclassified)
});
if let Some(reason) = reason {
outcome.violations.push(Violation {
finding: finding.clone(),
reason,
});
}
}
if let Some(known) = &baseline {
let live: Set<(&str, &str)> = outcome
.findings
.iter()
.flat_map(|f| {
let name = f.function.as_str();
[(f.krate.as_str(), name), ("", name)]
})
.collect();
outcome.fixed = known
.iter()
.filter(|((krate, name), _)| {
!live.contains(&(krate.as_str(), name.as_str()))
})
.filter(|(_, recorded)| in_view(args.only, recorded))
.map(|((_, name), _)| name.clone())
.collect();
outcome.fixed.sort();
}
if let Some(max) = check.max
&& outcome.findings.len() > max
{
outcome.over_max = Some((outcome.findings.len(), max));
}
Ok(outcome)
}
fn in_view(only: Option<CategorySet>, recorded: &[String]) -> bool {
let Some(only) = only else {
return true;
};
recorded
.iter()
.filter_map(|name| name.parse::<Category>().ok())
.any(|category| only.contains(category))
}
fn is_new(known: &Recorded, finding: &Finding) -> bool {
known
.get(&(finding.krate.clone(), finding.function.clone()))
.or_else(|| known.get(&(String::new(), finding.function.clone())))
.is_none_or(|recorded| {
finding
.categories
.iter()
.any(|category| !recorded.contains(category))
})
}
fn collect(graph: &Graph, solution: &Solution, args: &Args) -> Vec<Finding> {
report::collect(graph, solution, args)
.into_iter()
.map(|found| Finding {
function: found.name.to_owned(),
krate: found.krate.to_owned(),
loc: found
.ids
.iter()
.find_map(|id| graph.body(*id).loc.as_ref())
.map(ToString::to_string),
categories: found
.categories
.iter()
.map(|c| c.name().to_owned())
.collect(),
})
.collect()
}
fn compile(patterns: &[String], flag: &str) -> Result<RegexSet> {
RegexSet::new(patterns)
.with_context(|| format!("a pattern given to {flag} is not valid"))
}
pub fn write_baseline(
path: &Path,
args: &Args,
findings: &[Finding],
) -> Result<()> {
let doc = json!({
"version": BASELINE_VERSION,
"profile": args.profile,
"std_mode": args.std_mode.name(),
"mir_opt_level": args.mir_opt_level,
"features": args.features.describe(),
"suppressed": args.suppress.names(),
"closures": args.closures.name(),
"generics": args.generics.name(),
"all_crates": args.all_crates,
"static_only": args.static_only,
"candidates": args.candidates,
"findings": findings.iter().map(|f| json!({
"crate": f.krate,
"function": f.function,
"categories": f.categories,
})).collect::<Vec<_>>(),
});
let text = serde_json::to_string_pretty(&doc)?;
fs::write(path, format!("{text}\n"))
.with_context(|| format!("could not write {}", path.display()))
}
pub fn read_baseline(path: &Path, args: &Args) -> Result<Recorded> {
let text = fs::read_to_string(path).with_context(|| {
format!(
"could not read {}; write one with `panicgraph baseline {}`",
path.display(),
path.display()
)
})?;
let doc: Value = serde_json::from_str(&text)
.with_context(|| format!("{} is not valid json", path.display()))?;
let version = doc.get("version").and_then(Value::as_u64).unwrap_or(0);
if version != u64::from(BASELINE_VERSION) {
bail!(
"{} was written by a different version of this tool; write a \
fresh one with `panicgraph baseline {}`",
path.display(),
path.display()
);
}
settings_agree(&doc, args).with_context(|| {
format!(
"{} does not describe this run; write a fresh one with \
`panicgraph baseline {}`",
path.display(),
path.display()
)
})?;
let entries =
doc.get("findings")
.and_then(Value::as_array)
.with_context(|| {
format!("{} records no list of findings", path.display())
})?;
let mut out = Map::default();
for (at, entry) in entries.iter().enumerate() {
let name = entry.get("function").and_then(Value::as_str).with_context(
|| format!("finding {at} in {} names no function", path.display()),
)?;
let krate = entry
.get("crate")
.map_or(Some(""), Value::as_str)
.with_context(|| {
format!(
"{name} in {} names a crate that is not a name",
path.display()
)
})?
.to_owned();
let list = entry
.get("categories")
.and_then(Value::as_array)
.with_context(|| {
format!("{name} in {} lists no categories", path.display())
})?;
let mut categories = Vec::with_capacity(list.len());
for value in list {
let category = value.as_str().with_context(|| {
format!(
"{name} in {} records a category that is not a name",
path.display()
)
})?;
categories.push(category.to_owned());
}
ensure!(
out.insert((krate, name.to_owned()), categories).is_none(),
"{name} is recorded twice in {}",
path.display()
);
}
Ok(out)
}
fn describe_level(level: Option<u8>) -> String {
level.map_or_else(
|| "the profile's own mir opt level".to_owned(),
|level| format!("mir opt level {level}"),
)
}
fn settings_agree(doc: &Value, args: &Args) -> Result<()> {
let field = |name: &str| {
doc.get(name)
.and_then(Value::as_str)
.unwrap_or("unrecorded")
.to_owned()
};
let profile = field("profile");
ensure!(
profile == args.profile,
"it was written for the {profile} profile, not {}",
args.profile
);
let std_mode = field("std_mode");
ensure!(
std_mode == args.std_mode.name(),
"it was written against the {std_mode} standard library, not {}",
args.std_mode.name()
);
let level = doc
.get("mir_opt_level")
.and_then(Value::as_u64)
.and_then(|level| u8::try_from(level).ok());
ensure!(
level == args.mir_opt_level,
"it was written at {}, not {}",
describe_level(level),
describe_level(args.mir_opt_level)
);
let features = doc
.get("features")
.and_then(Value::as_str)
.unwrap_or("default");
ensure!(
features == args.features.describe(),
"it was written with the {features} features, not {}",
args.features.describe()
);
let recorded = doc.get("suppressed").and_then(Value::as_array).map_or(
CategorySet::EMPTY,
|list| {
list.iter()
.filter_map(Value::as_str)
.filter_map(|name| name.parse::<Category>().ok())
.collect()
},
);
ensure!(
recorded == args.suppress,
"it was written while suppressing a different set of categories"
);
let closures = field("closures");
ensure!(
closures == args.closures.name(),
"it was written with closures reporting as {closures}, not {}",
args.closures.name()
);
let generics = doc
.get("generics")
.and_then(Value::as_str)
.unwrap_or("written");
ensure!(
generics == args.generics.name(),
"it was written with generic functions reporting as {generics}, \
not {}",
args.generics.name()
);
let flag =
|name: &str| doc.get(name).and_then(Value::as_bool).unwrap_or_default();
ensure!(
flag("all_crates") == args.all_crates,
"it was written over a different set of crates"
);
ensure!(
flag("static_only") == args.static_only,
"it was written reading a different set of call edges"
);
ensure!(
flag("candidates") == args.candidates,
"it was written reading a different set of call targets"
);
Ok(())
}
pub fn render(
outcome: &Outcome,
args: &Args,
check: &Check,
out: &mut String,
) -> Result<()> {
match args.format {
#[cfg(feature = "svg")]
Format::Svg => human(outcome, check, out),
Format::Human => human(outcome, check, out),
Format::Github => github(outcome, out),
Format::Json => {
let doc = json!({
"passed": !outcome.failed(),
"analysed": outcome.findings.len(),
"violations": outcome.violations.iter().map(|v| json!({
"function": v.finding.function,
"reason": v.reason.describe(),
"categories": v.finding.categories,
"location": v.finding.loc,
})).collect::<Vec<_>>(),
"fixed": outcome.fixed,
});
out.push_str(&serde_json::to_string_pretty(&doc)?);
out.push('\n');
}
}
Ok(())
}
const fn plural(count: usize) -> &'static str {
if count == 1 { "" } else { "s" }
}
fn human(outcome: &Outcome, check: &Check, out: &mut String) {
if !outcome.violations.is_empty() {
let _ = writeln!(
out,
"{} function{} must not panic and can:\n",
outcome.violations.len(),
plural(outcome.violations.len())
);
for violation in &outcome.violations {
let _ = writeln!(out, "{}", violation.finding.function);
if let Some(loc) = &violation.finding.loc {
let _ = writeln!(out, " at {loc}");
}
let _ = writeln!(
out,
" {} ({})",
violation.finding.categories.join(", "),
violation.reason.describe()
);
}
out.push('\n');
}
if let Some((actual, max)) = outcome.over_max {
let _ = writeln!(
out,
"{actual} functions can panic, which is more than the {max} \
allowed.\n"
);
}
if !outcome.fixed.is_empty() {
let _ = writeln!(
out,
"{} function{} in the baseline no longer panics. Refresh it \
with `panicgraph baseline`.",
outcome.fixed.len(),
plural(outcome.fixed.len())
);
for name in outcome.fixed.iter().take(10) {
let _ = writeln!(out, " {name}");
}
out.push('\n');
}
if outcome.failed() {
let _ = writeln!(
out,
"Run `panicgraph why <function>` to see how one of them gets \
there."
);
return;
}
let total = outcome.findings.len();
if check.baseline.is_some() {
let _ = writeln!(
out,
"No panic that the baseline does not already record. {total} \
function{} can panic.",
plural(total)
);
} else if let Some(max) = check.max {
let _ = writeln!(
out,
"{total} function{} can panic, within the {max} allowed.",
plural(total)
);
} else if check.forbid.is_empty() {
let _ = writeln!(out, "No function can panic under this policy.");
} else {
let _ = writeln!(
out,
"No function matching {} can panic. {total} can in total.",
check.forbid.join(" or ")
);
}
}
fn github(outcome: &Outcome, out: &mut String) {
for violation in &outcome.violations {
let where_at = workflow_location(violation.finding.loc.as_deref());
let _ = writeln!(
out,
"::error {where_at}title=Function can panic::{} can panic with \
{} ({})",
violation.finding.function,
violation.finding.categories.join(", "),
violation.reason.describe()
);
}
if let Some((actual, max)) = outcome.over_max {
let _ = writeln!(
out,
"::error title=Too many panicking functions::{actual} functions \
can panic, more than the {max} allowed"
);
}
for name in &outcome.fixed {
let _ = writeln!(
out,
"::notice title=Baseline is stale::{name} no longer panics"
);
}
}