use crate::{Kind, Remedy, Report, ReuseAction};
use alloc::collections::BTreeSet;
use alloc::format;
use alloc::string::String;
use alloc::vec::Vec;
use core::fmt::Write;
#[must_use]
pub fn human(report: &Report, verbose: bool) -> String {
let mut out = String::new();
let _ = write!(
&mut out,
"{} doctests ({} unique), {} functions ({} unique), {} duplicated groups",
report.total,
report.unique,
report.functions,
report.unique_functions,
report.groups.len()
);
if report.context_blocks > 0 {
let _ = write!(
&mut out,
", {} context blocks ({} unique), {} approximate groups",
report.context_blocks,
report.unique_context_blocks,
report.context_groups.len(),
);
}
if let Some(reuse) = &report.reuse {
let _ = write!(
&mut out,
", {} dependency reuse opportunities ({} packages)",
reuse.groups.len(),
reuse.coverage.packages
);
}
organization_human(&mut out, report);
if report.groups.is_empty() && report.context_groups.is_empty() && report.reuse.is_none() {
return out;
}
for group in &report.groups {
out.push('\n');
let _ = match group.kind {
Kind::Doctest => writeln!(
&mut out,
"[{}] {} sites, {} tokens",
group.id,
group.sites.len(),
group.tokens
),
Kind::Function => writeln!(
&mut out,
"[{}] {} functions, {} tokens, {}",
group.id,
group.sites.len(),
group.tokens,
advice(group.remedy.unwrap_or(Remedy::Delete))
),
};
let deletes = group.kind == Kind::Function
&& group.remedy.unwrap_or(Remedy::Delete) == Remedy::Delete;
for (copy, site) in group.sites.iter().enumerate() {
let info = if deletes && copy > 0 && site.public {
format!(" (public API, {PUBLIC_ADVICE})")
} else if site.info.is_empty() {
String::new()
} else {
format!(" ({})", site.info.join(", "))
};
let _ = writeln!(
&mut out,
" {}:{} {}{}",
site.file.as_str(),
site.line,
site.item,
info
);
if verbose {
for line in site.code.lines() {
let _ = writeln!(&mut out, " {line}");
}
}
}
}
context_human(&mut out, report);
reuse_human(&mut out, report);
out.push('\n');
if report.groups.iter().any(|g| g.kind == Kind::Doctest) {
out.push_str(
"Remove the copies listed above, or keep one on purpose with a `rust,echoes` fence\n",
);
}
if report.groups.iter().any(|g| g.kind == Kind::Function) {
out.push_str("Act on each function group as its advice says, or keep one on purpose with a `// echoes: allow` comment above it\n");
}
if !report.context_groups.is_empty() {
out.push_str(
"Context block groups are approximate syntax matches, and they name no copy to delete\n",
);
}
out
}
fn organization_human(out: &mut String, report: &Report) {
let Some(organization) = &report.organization else {
let _ = writeln!(out);
return;
};
let owners = if organization.name.is_empty() {
let mut owners: Vec<&str> = Vec::new();
for repository in &organization.repositories {
let owner = repository.name.split('/').next().unwrap_or_default();
if !owners.iter().any(|seen| seen.eq_ignore_ascii_case(owner)) {
owners.push(owner);
}
}
format!("owners {}", owners.join(", "))
} else {
format!("organization {}", organization.name)
};
let _ = writeln!(
out,
", {owners} ({} accessible repositories, {} packages)",
organization.repositories.len(),
organization.packages.len()
);
let packages: BTreeSet<_> = organization
.file_packages
.values()
.flatten()
.copied()
.collect();
let sources = packages.into_iter().filter_map(|index| {
let package = organization.packages.get(index)?;
let repository = organization.repositories.get(package.repository)?;
Some((
repository.name.as_str(),
repository.commit.as_deref()?,
package,
))
});
for (repository, commit, package) in sources {
let _ = writeln!(
out,
"source {repository}@{commit} package {} ({})",
package.name, package.manifest
);
}
if !organization.coverage.complete {
if organization.coverage.failures.is_empty() {
let _ = writeln!(out, "echoes: incomplete organization analysis");
}
for failure in &organization.coverage.failures {
let _ = writeln!(out, "echoes: incomplete organization analysis, {failure}");
}
}
}
const PUBLIC_ADVICE: &str = "make it call the kept copy or deprecate it";
fn advice(remedy: Remedy) -> &'static str {
match remedy {
Remedy::Delete => "delete or update all but the first",
Remedy::MergeCfg => "one function under the merged cfg can replace them",
Remedy::GenericOrMacro => "a generic or a macro can share one body",
Remedy::HelperOrMacro => "a helper or a macro can share one body",
}
}
fn context_human(out: &mut String, report: &Report) {
for group in &report.context_groups {
out.push('\n');
let _ = writeln!(
out,
"[{}] {} sites, {} tokens, approximate",
group.id,
group.sites.len(),
group.tokens
);
for site in &group.sites {
let _ = writeln!(
out,
" {}:{}:{}-{}:{} {} ({})",
site.file,
site.line,
site.column,
site.end,
site.end_column,
site.item,
crate::contexts::kind_name(site.kind)
);
}
}
}
fn reuse_advice(action: ReuseAction) -> &'static str {
match action {
ReuseAction::ReuseApi => "review existing API",
ReuseAction::UpgradeApi => "review upgrade and API reuse",
ReuseAction::RequestExtraction => "request public extraction",
ReuseAction::Unresolved => "establish accessibility",
}
}
fn reuse_human(out: &mut String, report: &Report) {
let Some(reuse) = &report.reuse else {
return;
};
for group in &reuse.groups {
let local = &group.local;
let _ = writeln!(
out,
"\n{}:{}:{}-{}:{} {} (approximate dependency matches)",
local.file, local.line, local.column, local.end, local.end_column, local.item
);
for candidate in &group.candidates {
let _ = writeln!(
out,
" {} {} {} {} (depth {})",
candidate.package,
candidate.version,
candidate
.api_path
.as_deref()
.unwrap_or(&candidate.site.item),
reuse_advice(candidate.action),
candidate.depth
);
if let Some(url) = &candidate.source_url {
let _ = writeln!(out, " {url}");
}
if let Some(issue) = &candidate.existing_issue {
let _ = writeln!(out, " Existing request ({}) {}", issue.state, issue.url);
} else if let Some(url) = &candidate.issue_url {
let _ = writeln!(out, " Propose extraction {url}");
}
if !candidate.conditions.is_empty() {
let _ = writeln!(out, " Conditions {}", candidate.conditions.join(", "));
}
for note in &candidate.notes {
let _ = writeln!(out, " {note}");
}
if let Some(warning) = &candidate.lookup_warning {
let _ = writeln!(out, " {warning}");
}
}
}
for warning in &reuse.coverage.warnings {
let _ = writeln!(out, "echoes: {warning}");
}
for failure in &reuse.coverage.failures {
let _ = writeln!(out, "echoes: incomplete dependency analysis, {failure}");
}
}
#[must_use]
pub fn json(report: &Report) -> String {
serde_json::to_string(report).expect("Report serializes to JSON")
}
#[must_use]
pub fn annotations(report: &Report, no_fail: bool) -> String {
let level = if no_fail { "warning" } else { "error" };
let mut out = String::new();
for group in &report.groups {
let Some((kept, copies)) = group.sites.split_first() else {
continue;
};
for copy in copies {
let (file, line, item, kept_item, kept_file, kept_line, id) = (
escape_property(copy.file.as_str()),
copy.line,
escape(©.item),
escape(&kept.item),
escape(kept.file.as_str()),
kept.line,
&group.id,
);
let _ = match group.kind {
Kind::Doctest => writeln!(
&mut out,
"::{level} file={file},line={line},title=echoes::{item} duplicates the doctest of {kept_item} at {kept_file}:{kept_line}, group {id}"
),
Kind::Function => match group.remedy.unwrap_or(Remedy::Delete) {
Remedy::Delete if copy.public => writeln!(
&mut out,
"::{level} file={file},line={line},title=echoes::{item} repeats {kept_item} at {kept_file}:{kept_line} and is public API, {PUBLIC_ADVICE}, group {id}"
),
Remedy::Delete => writeln!(
&mut out,
"::{level} file={file},line={line},title=echoes::{item} duplicates the function {kept_item} at {kept_file}:{kept_line}, group {id}"
),
Remedy::MergeCfg => writeln!(
&mut out,
"::{level} file={file},line={line},title=echoes::{item} repeats {kept_item} at {kept_file}:{kept_line} under another cfg, one function under both cfgs can replace them, group {id}"
),
Remedy::GenericOrMacro => writeln!(
&mut out,
"::{level} file={file},line={line},title=echoes::{item} repeats {kept_item} at {kept_file}:{kept_line} on another type, a generic or a macro can share it, group {id}"
),
Remedy::HelperOrMacro => writeln!(
&mut out,
"::{level} file={file},line={line},title=echoes::{item} repeats {kept_item} at {kept_file}:{kept_line} and can't be deleted, a helper or a macro can share it, group {id}"
),
},
};
}
}
for group in &report.context_groups {
for site in &group.sites {
let (file, line, item, id) = (
escape_property(site.file.as_str()),
site.line,
escape(&site.item),
&group.id,
);
let _ = writeln!(
&mut out,
"::{level} file={file},line={line},col={},endLine={},endColumn={},title=echoes::approximate context block in {item}, group {id}",
site.column,
site.end,
site.end_column.saturating_sub(1)
);
}
}
if let Some(reuse) = &report.reuse {
let reuse_level = if reuse.fail_on_findings && !no_fail {
"error"
} else {
"notice"
};
for group in &reuse.groups {
let site = &group.local;
let file = escape_property(&site.file);
let item = escape(&site.item);
let _ = writeln!(
&mut out,
"::{reuse_level} file={file},line={},col={},endLine={},endColumn={},title=echoes reuse::{item} has {} approximate upstream reuse candidates requiring compatibility review",
site.line,
site.column,
site.end,
site.end_column.saturating_sub(1),
group.candidates.len()
);
}
for failure in &reuse.coverage.failures {
let _ = writeln!(
&mut out,
"::error title=echoes::Incomplete dependency analysis, {}",
escape(failure)
);
}
}
organization_annotations(&mut out, report);
out
}
fn organization_annotations(out: &mut String, report: &Report) {
let Some(organization) = &report.organization else {
return;
};
if organization.coverage.complete {
return;
}
if organization.coverage.failures.is_empty() {
let _ = writeln!(
out,
"::error title=echoes::Incomplete organization analysis"
);
}
for failure in &organization.coverage.failures {
let _ = writeln!(
out,
"::error title=echoes::Incomplete organization analysis, {}",
escape(failure)
);
}
}
fn escape(text: &str) -> String {
text.replace('%', "%25")
.replace('\r', "%0D")
.replace('\n', "%0A")
}
fn escape_property(text: &str) -> String {
escape(text).replace(':', "%3A").replace(',', "%2C")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{DocTest, Group, Remedy};
use alloc::string::ToString;
use alloc::vec;
use alloc::vec::Vec;
fn site(file: &str, line: u32, item: &str, code: &str, info: &[&str]) -> DocTest {
DocTest {
file: file.to_string(),
line,
end: None,
item: item.to_string(),
info: info.iter().map(ToString::to_string).collect(),
code: code.to_string(),
allow: false,
self_type: None,
public: false,
}
}
fn report() -> Report {
Report {
total: 34,
unique: 12,
functions: 0,
unique_functions: 0,
context_blocks: 0,
unique_context_blocks: 0,
context_groups: Vec::new(),
reuse: None,
organization: None,
groups: vec![Group {
kind: crate::Kind::Doctest,
remedy: None,
id: "ab12cd34".into(),
hash: "ab12cd34".into(),
unparsed: false,
tokens: 5,
sites: vec![
site("src/a.rs", 14, "m::a", "fn f() {}", &["no_run"]),
site("src/b.rs", 97, "m::b", "fn f() {}", &[]),
],
}],
}
}
#[test]
fn json_shape() {
let value: serde_json::Value = serde_json::from_str(&json(&report())).unwrap();
assert_eq!(value["total"], 34);
assert_eq!(value["unique"], 12);
let group = &value["groups"][0];
assert_eq!(
group.as_object().unwrap().keys().collect::<Vec<_>>(),
vec!["hash", "id", "kind", "sites", "tokens", "unparsed"]
);
assert_eq!(group["id"], "ab12cd34");
assert_eq!(group["kind"], "doctest");
assert_eq!(group["tokens"], 5);
assert_eq!(group["unparsed"], false);
assert_eq!(group["sites"].as_array().unwrap().len(), 2);
let first = &group["sites"][0];
assert_eq!(
first.as_object().unwrap().keys().collect::<Vec<_>>(),
vec!["code", "end", "file", "info", "item", "line"]
);
assert_eq!(first["file"], "src/a.rs");
assert_eq!(first["line"], 14);
assert_eq!(first["end"], serde_json::Value::Null);
assert_eq!(first["item"], "m::a");
assert_eq!(first["info"][0], "no_run");
}
fn function_report(remedy: Remedy) -> Report {
Report {
total: 0,
unique: 0,
functions: 7,
unique_functions: 6,
context_blocks: 0,
unique_context_blocks: 0,
context_groups: Vec::new(),
reuse: None,
organization: None,
groups: vec![Group {
kind: crate::Kind::Function,
id: "cd34ef56".into(),
hash: "cd34ef56".into(),
unparsed: false,
tokens: 40,
remedy: Some(remedy),
sites: vec![
site("src/a.rs", 3, "m::A::run", "fn run() {}", &[]),
site("src/a.rs", 9, "m::B::run", "fn run() {}", &[]),
],
}],
}
}
#[test]
fn a_public_copy_is_called_or_deprecated_never_deleted() {
let mut report = function_report(Remedy::Delete);
report.groups[0].sites[1].public = true;
assert_eq!(
annotations(&report, false),
"::error file=src/a.rs,line=9,title=echoes::m::B::run repeats m::A::run at src/a.rs:3 and is public API, make it call the kept copy or deprecate it, group cd34ef56\n"
);
assert!(human(&report, false).contains(
" src/a.rs:9 m::B::run (public API, make it call the kept copy or deprecate it)\n"
));
let mut report = function_report(Remedy::Delete);
report.groups[0].sites[0].public = true;
assert!(annotations(&report, false).contains("duplicates the function m::A::run"));
assert!(!human(&report, false).contains("public API"));
let mut report = function_report(Remedy::GenericOrMacro);
report.groups[0].sites[1].public = true;
assert!(!human(&report, false).contains("public API"));
assert!(annotations(&report, false).contains("on another type"));
}
#[test]
fn function_annotations_follow_the_remedy() {
let head = "::error file=src/a.rs,line=9,title=echoes::m::B::run ";
for (remedy, body) in [
(
Remedy::Delete,
"duplicates the function m::A::run at src/a.rs:3",
),
(
Remedy::MergeCfg,
"repeats m::A::run at src/a.rs:3 under another cfg, one function under both cfgs can replace them",
),
(
Remedy::GenericOrMacro,
"repeats m::A::run at src/a.rs:3 on another type, a generic or a macro can share it",
),
(
Remedy::HelperOrMacro,
"repeats m::A::run at src/a.rs:3 and can't be deleted, a helper or a macro can share it",
),
] {
assert_eq!(
annotations(&function_report(remedy), false),
format!("{head}{body}, group cd34ef56\n")
);
}
}
#[test]
fn annotations_mark_every_copy_but_the_first() {
let out = annotations(&report(), false);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 1);
assert_eq!(
lines[0],
"::error file=src/b.rs,line=97,title=echoes::m::b duplicates the doctest of m::a at src/a.rs:14, group ab12cd34"
);
}
#[test]
fn annotations_warn_when_the_run_cannot_fail() {
let out = annotations(&report(), true);
assert!(out.starts_with("::warning file=src/b.rs,line=97,"), "{out}");
}
#[test]
fn annotations_are_empty_without_duplicates() {
let clean = Report {
total: 3,
unique: 3,
functions: 0,
unique_functions: 0,
context_blocks: 0,
unique_context_blocks: 0,
context_groups: Vec::new(),
reuse: None,
organization: None,
groups: Vec::new(),
};
assert_eq!(annotations(&clean, false), "");
}
#[test]
fn only_incomplete_organization_coverage_is_reported() {
let failure = "org/app: archive link \"x\" cannot be resolved";
let mut report = report();
for complete in [true, false] {
report.organization = Some(crate::OrganizationReport {
name: String::from("org"),
coverage: crate::OrganizationCoverage {
complete,
failures: if complete {
Vec::new()
} else {
vec![failure.to_string()]
},
},
..Default::default()
});
let human = human(&report, false);
let annotations = annotations(&report, true);
assert_eq!(
human.contains(&format!(
"echoes: incomplete organization analysis, {failure}"
)),
!complete
);
assert_eq!(
annotations.contains(&format!(
"::error title=echoes::Incomplete organization analysis, {failure}"
)),
!complete
);
}
}
#[test]
fn organization_provenance_names_only_pinned_known_packages() {
let mut report = report();
let repository = |name: &str, commit: Option<&str>| crate::OrganizationRepository {
name: name.to_string(),
commit: commit.map(str::to_string),
};
let package = |repository: usize, name: &str| crate::OrganizationPackage {
repository,
name: name.to_string(),
manifest: String::from("Cargo.toml"),
};
report.organization = Some(crate::OrganizationReport {
name: String::from("org"),
repositories: vec![
repository("org/app", Some("abc")),
repository("org/notes", None),
],
packages: vec![package(0, "app"), package(1, "notes"), package(7, "lost")],
file_packages: [(String::from("org/app/src/lib.rs"), vec![0, 1, 2, 9])]
.into_iter()
.collect(),
coverage: crate::OrganizationCoverage {
complete: false,
failures: Vec::new(),
},
});
let human = human(&report, false);
let sources: Vec<&str> = human
.lines()
.filter(|line| line.starts_with("source "))
.collect();
assert_eq!(sources, ["source org/app@abc package app (Cargo.toml)"]);
assert!(human.contains(", organization org (2 accessible repositories, 3 packages)\n"));
assert!(
human
.lines()
.any(|line| line == "echoes: incomplete organization analysis"),
"{human}"
);
assert!(
annotations(&report, true)
.lines()
.any(|line| line == "::error title=echoes::Incomplete organization analysis")
);
}
#[test]
fn a_scan_of_several_owners_names_each_owner() {
let mut report = report();
let repository = |name: &str| crate::OrganizationRepository {
name: name.to_string(),
commit: None,
};
report.organization = Some(crate::OrganizationReport {
name: String::new(),
repositories: vec![
repository("b/two"),
repository("a/one"),
repository("B/three"),
],
..Default::default()
});
let human = human(&report, false);
assert!(
human.contains(", owners b, a (3 accessible repositories, 0 packages)\n"),
"{human}"
);
}
#[test]
fn annotations_skip_a_group_without_sites() {
let mut empty = report();
empty.groups[0].sites.clear();
assert_eq!(annotations(&empty, false), "");
}
#[test]
fn annotations_mark_every_later_site_of_a_larger_group() {
let mut three = report();
three.groups[0]
.sites
.push(site("src/c.rs", 3, "m::c", "fn f() {}", &[]));
let out = annotations(&three, false);
assert_eq!(out.lines().count(), 2);
assert!(out.contains("file=src/c.rs,line=3,"), "{out}");
assert!(
out.lines().all(|l| l.contains("of m::a at src/a.rs:14")),
"{out}"
);
}
#[test]
fn annotations_escape_the_message() {
let mut odd = report();
odd.groups[0].sites[0].item = "m::a%weird".into();
let out = annotations(&odd, false);
assert!(out.contains("of m::a%25weird at"), "{out}");
}
#[test]
fn annotations_escape_the_file_property() {
let mut odd = report();
odd.groups[0].sites[1].file = "src/a,b:c%.rs".into();
let out = annotations(&odd, false);
assert!(
out.starts_with("::error file=src/a%2Cb%3Ac%25.rs,line=97,"),
"{out}"
);
}
fn context_report() -> Report {
Report {
total: 0,
unique: 0,
functions: 0,
unique_functions: 0,
groups: Vec::new(),
reuse: None,
organization: None,
context_blocks: 5,
unique_context_blocks: 2,
context_groups: vec![crate::ContextGroup {
id: "ab12cd34".into(),
hash: "ab12cd34".into(),
tokens: 13,
sites: vec![
context_site(
"src/a.rs",
9,
5,
12,
6,
"m::a",
crate::ContextKind::FunctionBody,
),
context_site(
"src/b.rs",
41,
9,
44,
10,
"m::b",
crate::ContextKind::FunctionBody,
),
],
}],
}
}
fn context_site(
file: &str,
line: u32,
column: usize,
end: u32,
end_column: usize,
item: &str,
kind: crate::ContextKind,
) -> crate::ContextSite {
crate::ContextSite {
file: file.to_string(),
line,
column,
end,
end_column,
item: item.to_string(),
kind,
}
}
#[test]
fn json_serializes_context_groups() {
let value: serde_json::Value = serde_json::from_str(&json(&context_report())).unwrap();
assert_eq!(value["context_blocks"], 5);
assert_eq!(value["unique_context_blocks"], 2);
let group = &value["context_groups"][0];
assert_eq!(
group.as_object().unwrap().keys().collect::<Vec<_>>(),
vec!["hash", "id", "sites", "tokens"]
);
let site = &group["sites"][0];
assert_eq!(
site.as_object().unwrap().keys().collect::<Vec<_>>(),
vec![
"column",
"end",
"end_column",
"file",
"item",
"kind",
"line"
]
);
assert_eq!(site["kind"], "function-body");
assert_eq!(site["column"], 5);
assert_eq!(site["end_column"], 6);
}
#[test]
fn context_annotations_mark_every_site_as_approximate() {
let report = context_report();
let errors = annotations(&report, false);
let warnings = annotations(&report, true);
assert_eq!(errors.lines().count(), report.context_groups[0].sites.len());
assert!(errors.contains("file=src/a.rs,line=9,col=5,endLine=12,endColumn=5,"));
assert!(errors.contains("file=src/b.rs,line=41,col=9,endLine=44,endColumn=9,"));
assert!(errors.lines().all(|line| line.starts_with("::error ")));
assert!(warnings.lines().all(|line| line.starts_with("::warning ")));
}
#[test]
fn human_function_header_carries_the_remedy_action() {
let cases = [
(Remedy::Delete, "delete"),
(Remedy::MergeCfg, "merged cfg"),
(Remedy::GenericOrMacro, "generic"),
(Remedy::HelperOrMacro, "helper"),
];
for (remedy, action) in cases {
let out = human(&function_report(remedy), false);
let header = out
.lines()
.find(|line| line.starts_with("[cd34ef56]"))
.unwrap();
assert!(header.contains("2 functions, 40 tokens,"), "{header}");
assert!(header.contains(action), "{header}");
for other in ["delete", "merged cfg", "generic", "helper"] {
if other != action {
assert!(!header.contains(other), "{header}");
}
}
}
}
#[test]
fn human_verbose_prints_code_indented() {
let out = human(&report(), true);
assert!(out.contains("\n fn f() {}\n"), "got: {out:?}");
assert_eq!(out.matches("fn f() {}").count(), 2);
}
fn mixed_report() -> Report {
Report {
total: 2,
unique: 1,
functions: 2,
unique_functions: 1,
context_blocks: 5,
unique_context_blocks: 2,
context_groups: vec![crate::ContextGroup {
id: "ctx9".into(),
hash: "ctx9".into(),
tokens: 13,
sites: vec![
context_site(
"src/a.rs",
9,
5,
12,
6,
"m::a",
crate::ContextKind::FunctionBody,
),
context_site("src/b.rs", 41, 9, 44, 10, "m::b", crate::ContextKind::Block),
],
}],
groups: vec![
Group {
kind: crate::Kind::Doctest,
remedy: None,
id: "dt01".into(),
hash: "dt01".into(),
unparsed: false,
tokens: 5,
sites: vec![
site("src/a.rs", 14, "m::a", "fn f() {}", &["no_run"]),
site("src/b.rs", 97, "m::b", "fn f() {}", &[]),
],
},
Group {
kind: crate::Kind::Function,
remedy: Some(Remedy::Delete),
id: "fn02".into(),
hash: "fn02".into(),
unparsed: false,
tokens: 40,
sites: vec![
site("src/a.rs", 3, "m::A::run", "fn run() {}", &[]),
site("src/a.rs", 9, "m::B::run", "fn run() {}", &[]),
],
},
],
reuse: None,
organization: None,
}
}
#[test]
fn human_mixed_report_keeps_the_kinds_separate() {
let out = human(&mixed_report(), false);
let lines: Vec<&str> = out.lines().collect();
let summary = lines[0];
assert!(summary.contains("2 doctests (1 unique)"), "{summary}");
assert!(summary.contains("2 functions (1 unique)"), "{summary}");
assert!(summary.contains("2 duplicated groups"), "{summary}");
assert!(
summary.contains("5 context blocks (2 unique), 1 approximate groups"),
"{summary}"
);
let headers: Vec<&str> = lines
.iter()
.copied()
.filter(|line| line.starts_with('['))
.collect();
assert_eq!(headers.len(), 3, "{out}");
assert!(
headers
.iter()
.any(|h| h.starts_with("[dt01] 2 sites, 5 tokens") && !h.contains("delete")),
"{out}"
);
assert!(
headers
.iter()
.any(|h| h.starts_with("[fn02] 2 functions, 40 tokens,") && h.contains("delete")),
"{out}"
);
assert!(
headers
.iter()
.any(|h| h.starts_with("[ctx9] 2 sites, 13 tokens, approximate")
&& !h.contains("delete"),),
"{out}"
);
assert!(
lines
.iter()
.any(|l| l.contains("src/a.rs:14") && l.contains("m::a")),
"{out}"
);
assert!(
lines
.iter()
.any(|l| l.contains("src/a.rs:3") && l.contains("m::A::run")),
"{out}"
);
assert!(
lines
.iter()
.any(|l| l.contains("src/a.rs:9:5-12:6") && l.contains("(function-body)")),
"{out}"
);
assert!(
lines
.iter()
.any(|l| l.contains("src/b.rs:41:9-44:10") && l.contains("(block)")),
"{out}"
);
assert!(out.contains("rust,echoes` fence"), "{out}");
assert!(out.contains("`// echoes: allow`"), "{out}");
assert!(
lines
.iter()
.filter(|l| l.contains("src/a.rs:9:5-12:6") || l.contains("src/b.rs:41:9-44:10"))
.all(|l| !l.contains("public API") && !l.contains("delete")),
"{out}"
);
let last = lines.last().unwrap();
assert!(
last.contains("approximate") && !last.contains("echoes"),
"{out}"
);
assert_eq!(
lines
.iter()
.filter(|line| line.starts_with('[') && line.contains("delete"))
.count(),
1,
"{out}"
);
}
#[test]
fn human_summary_and_footers_follow_the_reported_kinds() {
for (out, doctest, function, context) in [
(human(&report(), false), true, false, false),
(
human(&function_report(Remedy::Delete), false),
false,
true,
false,
),
(human(&context_report(), false), false, false, true),
] {
let summary = out.lines().next().unwrap();
assert_eq!(summary.contains("context blocks"), context, "{summary}");
assert_eq!(out.contains("rust,echoes` fence"), doctest, "{out}");
assert_eq!(out.contains("`// echoes: allow`"), function, "{out}");
let last = out.lines().last().unwrap();
assert_eq!(
last.contains("approximate") && !last.contains("echoes"),
context,
"{out}"
);
}
}
fn candidate(
package: &str,
version: &str,
depth: usize,
item: &str,
api_path: Option<&str>,
action: ReuseAction,
) -> crate::DependencyCandidate {
crate::DependencyCandidate {
package: package.to_string(),
version: version.to_string(),
source: "registry+https://github.com/rust-lang/crates.io-index".to_string(),
depth,
site: context_site(
"src/dep.rs",
30,
4,
33,
8,
item,
crate::ContextKind::FunctionBody,
),
access: crate::ContextAccess::PublicFunction,
api_path: api_path.map(ToString::to_string),
conditions: Vec::new(),
notes: Vec::new(),
action,
repository: None,
source_url: None,
issue_url: None,
existing_issue: None,
lookup_warning: None,
}
}
fn reuse_report() -> Report {
let mut full = candidate(
"upstream",
"1.2.3",
1,
"upstream::f",
Some("upstream::f"),
ReuseAction::ReuseApi,
);
full.source_url = Some("https://github.com/owner/upstream/blob/abc123/src/lib.rs".into());
full.existing_issue = Some(crate::ExistingIssue {
url: "https://github.com/owner/upstream/issues/7".into(),
state: "open".into(),
});
full.conditions = vec!["cfg(unix)".into()];
full.notes = vec!["body calls a private helper".into()];
full.lookup_warning = Some("tag missing from the index".into());
let mut upgrade = candidate(
"other",
"0.9.0",
2,
"other::g",
None,
ReuseAction::UpgradeApi,
);
upgrade.issue_url = Some("https://github.com/owner/other/issues/new".into());
let extract = candidate(
"vendored",
"0.1.0",
3,
"vendored::h",
None,
ReuseAction::RequestExtraction,
);
let unresolved = candidate(
"legacy",
"2.0.0",
4,
"legacy::i",
None,
ReuseAction::Unresolved,
);
let mut report = Report {
total: 0,
unique: 0,
functions: 0,
unique_functions: 0,
context_blocks: 0,
unique_context_blocks: 0,
context_groups: Vec::new(),
reuse: None,
organization: None,
groups: Vec::new(),
};
report.reuse = Some(crate::ReuseReport {
groups: vec![
crate::ReuseGroup {
hash: "aa11".into(),
tokens: 20,
local: context_site(
"src/a.rs",
9,
5,
12,
6,
"m::a",
crate::ContextKind::FunctionBody,
),
candidates: vec![full, upgrade],
},
crate::ReuseGroup {
hash: "bb22".into(),
tokens: 25,
local: context_site(
"src/b.rs",
41,
9,
44,
10,
"m::b",
crate::ContextKind::Block,
),
candidates: vec![extract, unresolved],
},
],
coverage: crate::DependencyCoverage {
complete: false,
packages: 2,
indexed_files: 40,
cached_files: 10,
failures: vec!["no index for the vendored dependency".into()],
warnings: vec!["index cache entry is stale".into()],
},
fail_on_findings: false,
});
report.reuse.as_mut().unwrap().groups[1].candidates[1].source_url =
Some("https://git.example.com/legacy/commit/def456".into());
report
}
#[test]
fn human_reuse_section_pins_candidates_and_coverage() {
let out = human(&reuse_report(), false);
assert_eq!(
out,
r"0 doctests (0 unique), 0 functions (0 unique), 0 duplicated groups, 2 dependency reuse opportunities (2 packages)
src/a.rs:9:5-12:6 m::a (approximate dependency matches)
upstream 1.2.3 upstream::f review existing API (depth 1)
https://github.com/owner/upstream/blob/abc123/src/lib.rs
Existing request (open) https://github.com/owner/upstream/issues/7
Conditions cfg(unix)
body calls a private helper
tag missing from the index
other 0.9.0 other::g review upgrade and API reuse (depth 2)
Propose extraction https://github.com/owner/other/issues/new
src/b.rs:41:9-44:10 m::b (approximate dependency matches)
vendored 0.1.0 vendored::h request public extraction (depth 3)
legacy 2.0.0 legacy::i establish accessibility (depth 4)
https://git.example.com/legacy/commit/def456
echoes: index cache entry is stale
echoes: incomplete dependency analysis, no index for the vendored dependency
"
);
}
#[test]
fn human_reuse_summary_counts_groups_and_packages() {
let mut report = reuse_report();
report.reuse.as_mut().unwrap().groups.pop();
report.reuse.as_mut().unwrap().coverage.packages = 5;
let out = human(&report, false);
let summary = out.lines().next().unwrap();
assert_eq!(
summary,
"0 doctests (0 unique), 0 functions (0 unique), 0 duplicated groups, 1 dependency \
reuse opportunities (5 packages)"
);
}
#[test]
fn annotations_mark_reuse_groups_and_failures() {
let out = annotations(&reuse_report(), false);
assert_eq!(
out,
"::notice file=src/a.rs,line=9,col=5,endLine=12,endColumn=5,title=echoes \
reuse::m::a has 2 approximate upstream reuse candidates requiring compatibility review\n\
::notice file=src/b.rs,line=41,col=9,endLine=44,endColumn=9,title=echoes reuse::m::b has 2 \
approximate upstream reuse candidates requiring compatibility review\n\
::error title=echoes::Incomplete dependency analysis, no index for the vendored dependency\n"
);
}
#[test]
fn annotations_fail_reuse_findings_when_the_gate_requires_it() {
let mut report = reuse_report();
report.reuse.as_mut().unwrap().fail_on_findings = true;
let hard = annotations(&report, false);
assert_eq!(
hard.lines()
.filter(|line| line.starts_with("::error file="))
.count(),
2,
"{hard}"
);
assert!(hard.starts_with("::error file=src/a.rs,line=9,"), "{hard}");
let soft = annotations(&report, true);
assert!(soft.starts_with("::notice file=src/a.rs,line=9,"), "{soft}");
}
#[test]
fn json_serializes_reuse_findings() {
let value: serde_json::Value = serde_json::from_str(&json(&reuse_report())).unwrap();
let reuse = &value["reuse"];
assert_eq!(reuse["coverage"]["complete"], false);
assert_eq!(reuse["coverage"]["packages"], 2);
assert_eq!(reuse["coverage"]["indexed_files"], 40);
assert_eq!(reuse["coverage"]["cached_files"], 10);
assert_eq!(
reuse["coverage"]["failures"][0],
"no index for the vendored dependency"
);
assert_eq!(
reuse["coverage"]["warnings"][0],
"index cache entry is stale"
);
let group = &reuse["groups"][0];
assert_eq!(group["hash"], "aa11");
assert_eq!(group["tokens"], 20);
assert_eq!(group["local"]["file"], "src/a.rs");
let first = &group["candidates"][0];
assert_eq!(first["package"], "upstream");
assert_eq!(first["api_path"], "upstream::f");
assert_eq!(first["action"], "reuse-api");
assert_eq!(first["access"], "public-function");
assert_eq!(first["existing_issue"]["state"], "open");
assert_eq!(first["lookup_warning"], "tag missing from the index");
let second = &group["candidates"][1];
assert_eq!(second["api_path"], serde_json::Value::Null);
assert_eq!(
second["issue_url"],
"https://github.com/owner/other/issues/new"
);
assert_eq!(second["action"], "upgrade-api");
assert_eq!(
reuse["groups"][1]["candidates"][0]["action"],
"request-extraction"
);
assert_eq!(reuse["groups"][1]["candidates"][1]["action"], "unresolved");
}
}