use allow_core::{AllowConfig, CargoAllowResult, Finding, MatchOutcome};
use std::collections::BTreeSet;
use std::path::Path;
use crate::evidence_inventory::policy_reference_diagnostics_for_source_tree;
use crate::{InventoryFacts, OutputFormat, emit_text, parse_kind_filter};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct EvidenceReportSummary {
pub(crate) policy_missing_evidence_entries: usize,
pub(crate) broken_evidence_links: usize,
pub(crate) weak_evidence_references: usize,
pub(crate) occurrence_headroom_entries: usize,
}
impl EvidenceReportSummary {
pub(crate) fn from_policy(root: &Path, cfg: &AllowConfig, outcomes: &[MatchOutcome]) -> Self {
Self::from_policy_with_source_tree_files(root, cfg, outcomes, None)
}
pub(crate) fn from_policy_with_source_tree_files(
root: &Path,
cfg: &AllowConfig,
outcomes: &[MatchOutcome],
source_tree_files: Option<&BTreeSet<String>>,
) -> Self {
let diagnostics = cfg
.allow
.iter()
.flat_map(|entry| {
policy_reference_diagnostics_for_source_tree(root, entry, source_tree_files)
})
.collect::<Vec<_>>();
Self {
policy_missing_evidence_entries: allow_report::matched_policy_missing_evidence_entries(
cfg, outcomes,
),
broken_evidence_links: diagnostics
.iter()
.filter(|reference| reference.diagnostic.status.is_broken_local_link())
.count(),
weak_evidence_references: diagnostics
.iter()
.filter(|reference| reference.diagnostic.status.is_weak_reference())
.count(),
occurrence_headroom_entries: allow_report::occurrence_headroom_entries(cfg, outcomes),
}
}
pub(crate) fn has_broken_evidence_links(self) -> bool {
self.broken_evidence_links > 0
}
pub(crate) fn apply_to(self, context: &mut allow_report::ReportContext<'_>) {
context.broken_evidence_links =
(self.broken_evidence_links > 0).then_some(self.broken_evidence_links);
context.weak_evidence_references =
(self.weak_evidence_references > 0).then_some(self.weak_evidence_references);
context.policy_missing_evidence_entries = (self.policy_missing_evidence_entries > 0)
.then_some(self.policy_missing_evidence_entries);
context.occurrence_headroom_entries =
(self.occurrence_headroom_entries > 0).then_some(self.occurrence_headroom_entries);
}
}
pub(crate) struct ReportRenderArgs<'a> {
pub(crate) command: &'a str,
pub(crate) format: OutputFormat,
pub(crate) baseline_debt_entries: usize,
pub(crate) evidence: EvidenceReportSummary,
pub(crate) findings: &'a [Finding],
pub(crate) outcomes: &'a [MatchOutcome],
pub(crate) failed: bool,
pub(crate) output: Option<&'a Path>,
pub(crate) root: &'a Path,
pub(crate) inventory_facts: InventoryFacts,
}
#[derive(Debug, Clone)]
pub(crate) struct SourceTreeReportContext {
root: String,
inventory_facts: InventoryFacts,
}
impl SourceTreeReportContext {
pub(crate) fn new(root: &Path, inventory_facts: InventoryFacts) -> Self {
Self {
root: allow_report::source_tree_path_text(root),
inventory_facts,
}
}
pub(crate) fn inventory(&self) -> allow_report::InventoryContext<'_> {
allow_report::InventoryContext::source_syntax(
self.inventory_source(),
Some(self.source_tree_root()),
self.inventory_files(),
)
.with_empty_git_tracked(self.inventory_facts.empty_git_tracked)
.with_completeness(self.inventory_facts.completeness.as_str())
}
pub(crate) fn report(
&self,
baseline_debt_entries: Option<usize>,
) -> allow_report::ReportContext<'_> {
allow_report::ReportContext::source_syntax(
self.inventory_source(),
Some(self.source_tree_root()),
self.inventory_files(),
baseline_debt_entries,
)
.with_empty_git_tracked(self.inventory_facts.empty_git_tracked)
.with_inventory_completeness(self.inventory_facts.completeness.as_str())
}
pub(crate) fn source_tree_root(&self) -> &str {
&self.root
}
pub(crate) fn inventory_source(&self) -> &str {
self.inventory_facts.source.as_str()
}
pub(crate) fn inventory_completeness(&self) -> &str {
self.inventory_facts.completeness.as_str()
}
pub(crate) fn inventory_files(&self) -> Option<usize> {
self.inventory_facts.files_scanned
}
}
pub(crate) fn print_report(args: ReportRenderArgs<'_>) -> CargoAllowResult<()> {
let source_context = SourceTreeReportContext::new(args.root, args.inventory_facts);
let mut context = source_context.report(Some(args.baseline_debt_entries));
args.evidence.apply_to(&mut context);
let text = match args.format {
OutputFormat::Human => allow_report::render_human_with_context(
args.command,
args.findings,
args.outcomes,
args.failed,
context,
),
OutputFormat::Json => allow_report::render_json_with_context(
args.command,
args.findings,
args.outcomes,
args.failed,
context,
),
OutputFormat::Html => allow_report::render_html_with_context(
args.command,
args.findings,
args.outcomes,
args.failed,
context,
),
OutputFormat::Sarif => allow_report::render_sarif_with_context(
args.command,
args.findings,
args.outcomes,
args.failed,
context,
),
OutputFormat::Markdown => allow_report::render_markdown_with_context(
args.command,
args.findings,
args.outcomes,
args.failed,
context,
),
};
emit_text(args.output, &text)
}
pub(crate) fn report_config(
cfg: &AllowConfig,
kind_filter: Option<&str>,
) -> CargoAllowResult<AllowConfig> {
let Some(kind) = kind_filter else {
return Ok(cfg.clone());
};
let parsed = parse_kind_filter(kind)?;
let mut filtered = cfg.clone();
filtered.allow.retain(|entry| parsed.matches_entry(entry));
Ok(filtered)
}
#[cfg(test)]
mod tests {
use super::*;
use allow_core::{AllowEntry, FindingKind, Lifecycle, MatchStatus, Selector};
use allow_inventory::InventorySource;
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
fn outcome(status: MatchStatus, allow_id: Option<&str>) -> MatchOutcome {
MatchOutcome {
status,
allow_id: allow_id.map(str::to_string),
candidate_ids: Vec::new(),
finding_index: None,
message: status.as_str().to_string(),
score: 0,
}
}
fn entry(id: &str, evidence: Vec<String>) -> AllowEntry {
AllowEntry {
id: id.to_string(),
kind: FindingKind::NonRustFile,
family: Some("documentation".to_string()),
path: Some(PathBuf::from("docs/source.md")),
glob: None,
owner: "docs".to_string(),
classification: "reviewed_exception".to_string(),
reason: "Reporting test entry.".to_string(),
evidence,
links: Vec::new(),
occurrence_limit: None,
lifecycle: Lifecycle::empty(),
selector: Selector {
ast_kind: Some("tracked_file".to_string()),
..Selector::default()
},
last_seen: None,
}
}
#[test]
fn evidence_report_summary_counts_policy_and_reference_diagnostics() {
let root = temp_root("evidence-summary");
let docs = root.join("docs");
fs::create_dir_all(&docs)
.unwrap_or_else(|err| std::panic::panic_any(format!("docs dir: {err}")));
fs::write(docs.join("present.md"), "present evidence")
.unwrap_or_else(|err| std::panic::panic_any(format!("present evidence: {err}")));
fs::write(docs.join("not-in-source-tree.md"), "untracked evidence")
.unwrap_or_else(|err| std::panic::panic_any(format!("untracked evidence: {err}")));
let mut cfg = AllowConfig::empty();
cfg.allow
.push(entry("matched-missing-evidence", Vec::new()));
cfg.allow.push(entry(
"matched-evidence-diagnostics",
vec![
"doc:docs/present.md".to_string(),
"doc:docs/not-in-source-tree.md".to_string(),
"doc:docs/missing.md".to_string(),
"loose manual note".to_string(),
],
));
cfg.allow
.push(entry("unmatched-missing-evidence", Vec::new()));
let outcomes = vec![
outcome(MatchStatus::Matched, Some("matched-missing-evidence")),
outcome(MatchStatus::Matched, Some("matched-evidence-diagnostics")),
outcome(MatchStatus::Stale, Some("unmatched-missing-evidence")),
];
let source_tree_files = BTreeSet::from(["docs/present.md".to_string()]);
let summary = EvidenceReportSummary::from_policy_with_source_tree_files(
&root,
&cfg,
&outcomes,
Some(&source_tree_files),
);
assert_eq!(summary.policy_missing_evidence_entries, 1);
assert_eq!(summary.broken_evidence_links, 2);
assert_eq!(summary.weak_evidence_references, 1);
assert!(summary.has_broken_evidence_links());
fs::remove_dir_all(&root)
.unwrap_or_else(|err| std::panic::panic_any(format!("remove fixture: {err}")));
}
#[test]
fn evidence_report_summary_apply_to_sets_only_positive_counts() {
let mut empty_context =
allow_report::ReportContext::source_syntax("git_tracked", None, None, None);
EvidenceReportSummary::default().apply_to(&mut empty_context);
assert_eq!(empty_context.policy_missing_evidence_entries, None);
assert_eq!(empty_context.broken_evidence_links, None);
assert_eq!(empty_context.weak_evidence_references, None);
let mut populated_context =
allow_report::ReportContext::source_syntax("git_tracked", None, None, None);
EvidenceReportSummary {
policy_missing_evidence_entries: 3,
broken_evidence_links: 2,
weak_evidence_references: 1,
occurrence_headroom_entries: 0,
}
.apply_to(&mut populated_context);
assert_eq!(populated_context.policy_missing_evidence_entries, Some(3));
assert_eq!(populated_context.broken_evidence_links, Some(2));
assert_eq!(populated_context.weak_evidence_references, Some(1));
}
#[test]
fn print_report_dispatches_all_formats_and_writes_outputs() {
let root = temp_root("print-report");
let cases = [
(OutputFormat::Human, "human.txt", "cargo-allow audit"),
(
OutputFormat::Json,
"report.json",
"\"schema_id\": \"cargo-allow.report.v1\"",
),
(OutputFormat::Html, "report.html", "<!doctype html>"),
(
OutputFormat::Sarif,
"report.sarif",
"\"version\": \"2.1.0\"",
),
(OutputFormat::Markdown, "report.md", "# cargo-allow audit"),
];
for (format, file_name, marker) in cases {
let output = root.join(file_name);
print_report(ReportRenderArgs {
command: "audit",
format,
baseline_debt_entries: 0,
evidence: EvidenceReportSummary {
policy_missing_evidence_entries: 1,
broken_evidence_links: 1,
weak_evidence_references: 1,
occurrence_headroom_entries: 0,
},
findings: &[],
outcomes: &[],
failed: false,
output: Some(&output),
root: &root,
inventory_facts: InventoryFacts::scanned(InventorySource::GitTracked, 7),
})
.unwrap_or_else(|err| std::panic::panic_any(format!("print {file_name}: {err}")));
let text = fs::read_to_string(&output)
.unwrap_or_else(|err| std::panic::panic_any(format!("read {file_name}: {err}")));
assert!(
text.contains(marker),
"{file_name} should contain marker {marker}; got {text}"
);
}
fs::remove_dir_all(&root)
.unwrap_or_else(|err| std::panic::panic_any(format!("remove fixture: {err}")));
}
static NEXT_TEMP_ROOT: AtomicUsize = AtomicUsize::new(0);
fn temp_root(label: &str) -> PathBuf {
let id = NEXT_TEMP_ROOT.fetch_add(1, Ordering::Relaxed);
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
let root = std::env::temp_dir().join(format!(
"cargo-allow-reporting-{label}-{}-{stamp}-{id}",
std::process::id()
));
fs::create_dir_all(&root)
.unwrap_or_else(|err| std::panic::panic_any(format!("temp root: {err}")));
root
}
}