#![cfg(feature = "std")]
use echoes::{ContextKind, Echoes, Remedy, Report};
use std::path::Path;
#[cfg(feature = "cli")]
use std::process::Command;
const FIXTURE: &str = "tests/fixtures/dupws";
fn scan() -> Report {
echoes::Echoes::default().run(FIXTURE).unwrap()
}
const FN_FIXTURE: &str = "tests/fixtures/fnws";
type FnGroup<'a> = (Vec<&'a str>, Vec<Option<&'a str>>, Option<Remedy>);
#[test]
fn functions_group_per_module_across_targets_and_cfgs() {
let report = Echoes::default().functions().run(FN_FIXTURE).unwrap();
assert_eq!(
(report.total, report.functions, report.unique_functions),
(0, 17, 8)
);
let mut groups: Vec<FnGroup<'_>> = report
.groups
.iter()
.map(|g| {
assert_eq!(g.kind, echoes::Kind::Function);
let items = g.sites.iter().map(|s| s.item.as_str()).collect();
let types = g.sites.iter().map(|s| s.self_type.as_deref()).collect();
(items, types, g.remedy)
})
.collect();
groups.sort();
assert_eq!(
groups,
[
(
vec![
"fnws::describe::Gamma::short",
"fnws::describe::Gamma::long"
],
vec![Some("Gamma"), Some("Gamma")],
Some(Remedy::HelperOrMacro)
),
(
vec!["fnws::platform::width", "fnws::platform::height"],
vec![None, None],
Some(Remedy::Delete)
),
(
vec!["fnws::render::Alpha::render", "fnws::render::Beta::render"],
vec![Some("Alpha"), Some("Beta")],
Some(Remedy::GenericOrMacro)
),
(
vec!["fnws::scale::scaled", "fnws::scale::scaled_elsewhere"],
vec![None, None],
Some(Remedy::MergeCfg)
),
(
vec![
"fnws::tests::totals_small_values",
"fnws::tests::totals_large_values"
],
vec![None, None],
Some(Remedy::Delete)
),
(
vec!["fnws::total_over", "fnws::sum_over", "fnws::total_too"],
vec![None, None, None],
Some(Remedy::Delete)
),
(
vec!["it::renders_values", "it::renders_values_again"],
vec![None, None],
Some(Remedy::Delete)
),
]
);
let mut public: Vec<&str> = report
.groups
.iter()
.flat_map(|g| &g.sites)
.filter(|s| s.public)
.map(|s| s.item.as_str())
.collect();
public.sort_unstable();
assert_eq!(
public,
[
"fnws::platform::height",
"fnws::platform::width",
"fnws::render::Alpha::render",
"fnws::render::Beta::render",
"fnws::scale::scaled",
"fnws::scale::scaled_elsewhere",
"fnws::total_over",
"fnws::total_too"
]
);
for site in report.groups.iter().flat_map(|g| &g.sites) {
let text = std::fs::read_to_string(Path::new(FN_FIXTURE).join(&site.file)).unwrap();
let lines: Vec<&str> = text.lines().collect();
let first = lines[site.line as usize - 1].trim_start();
let last = lines[site.end.unwrap() as usize - 1].trim();
assert!(
first.starts_with("///") || first.starts_with("#[") || first.contains("fn "),
"{site:?}"
);
assert_eq!(last, "}", "{site:?}");
assert_eq!(
site.code.lines().count(),
(site.end.unwrap() - site.line + 1) as usize
);
}
let off = Echoes::default().run(FN_FIXTURE).unwrap();
assert_eq!((off.functions, off.groups.len()), (0, 0));
}
#[test]
fn copies_selected_by_module_declarations_merge_their_cfgs() {
let report = Echoes::default()
.functions()
.run("tests/fixtures/modcfgws")
.unwrap();
let mut groups: Vec<(Vec<&str>, Vec<&str>, Option<Remedy>)> = report
.groups
.iter()
.map(|g| {
let items = g.sites.iter().map(|s| s.item.as_str()).collect();
let files = g.sites.iter().map(|s| s.file.as_str()).collect();
(items, files, g.remedy)
})
.collect();
groups.sort();
assert_eq!(
groups,
[
(
vec!["modcfgws::bits::spread", "modcfgws::bits::spread"],
vec!["src/narrow/bits.rs", "src/wide/bits.rs"],
Some(Remedy::MergeCfg)
),
(
vec![
"modcfgws::host::probe::measure",
"modcfgws::host::probe::measure"
],
vec!["src/host/other_probe.rs", "src/host/probe.rs"],
Some(Remedy::MergeCfg)
),
(
vec!["modcfgws::width::width", "modcfgws::width::width"],
vec!["src/fast/width.rs", "src/slow/width.rs"],
Some(Remedy::MergeCfg)
),
]
);
}
#[test]
fn cfg_attr_branch_files_are_reported_and_never_linked_files_are_not() {
let files = |report: &Report| -> Vec<String> {
let mut files: Vec<String> = report
.groups
.iter()
.flat_map(|g| g.sites.iter().map(|s| s.file.clone()))
.chain(
report
.context_groups
.iter()
.flat_map(|g| g.sites.iter().map(|s| s.file.clone())),
)
.filter(|file| file.contains("width.rs") || file.contains("tail.rs"))
.collect();
files.sort();
files.dedup();
files
};
let default = Echoes::default()
.functions()
.context_blocks()
.run("tests/fixtures/modcfgws")
.unwrap();
assert_eq!(files(&default), ["src/fast/width.rs", "src/slow/width.rs"]);
let cache = tempfile::tempdir().unwrap();
let dependencies = Echoes::default()
.context_blocks()
.context_dependencies(echoes::DependencySearch {
depth: Some(1),
cache_dir: Some(cache.path().to_string_lossy().into_owned()),
check_latest: Some(false),
..echoes::DependencySearch::default()
})
.run("tests/fixtures/modcfgws")
.unwrap();
assert_eq!(
dependencies.context_groups, default.context_groups,
"dependency mode reports the default local groups"
);
}
#[test]
fn shared_group_spans_crates_and_variants() {
let report = scan();
assert_eq!(report.total, 9);
assert_eq!(report.unique, 4);
assert_eq!(report.groups.len(), 2);
let shared = report
.groups
.iter()
.find(|g| g.sites.iter().any(|s| s.item == "alpha::alpha_fn"))
.expect("shared group present");
assert_eq!(shared.sites.len(), 4);
let items: Vec<&str> = shared.sites.iter().map(|s| s.item.as_str()).collect();
assert_eq!(
items,
vec![
"alpha::util::helper",
"beta::util::helper",
"alpha::alpha_fn",
"alpha::alpha_variant"
]
);
let files: Vec<&str> = shared.sites.iter().map(|s| s.file.as_str()).collect();
assert_eq!(
files,
vec![
"alpha/src/util.rs",
"beta/src/util.rs",
"alpha/src/lib.rs",
"alpha/src/lib.rs"
]
);
}
#[test]
fn unparsed_pair_groups_with_flag() {
let report = scan();
let unparsed = report
.groups
.iter()
.find(|g| g.unparsed)
.expect("unparsed group present");
assert_eq!(unparsed.sites.len(), 2);
}
#[test]
fn allowed_sites_are_not_grouped() {
for group in scan().groups {
for site in group.sites {
assert!(!site.allow);
assert_ne!(site.item, "alpha::alpha_allowed");
}
}
}
#[test]
fn config_threshold_suppresses_groups() {
let strict = Path::new(FIXTURE).join(".echoes-strict.toml");
let report = Echoes::default()
.config(&strict)
.unwrap()
.run(FIXTURE)
.unwrap();
assert_eq!(report.groups, Vec::new());
let report = Echoes::default()
.threshold(2)
.config(&strict)
.unwrap()
.run(FIXTURE)
.unwrap();
assert_eq!(report.groups.len(), 2);
let report = Echoes::default()
.config(&strict)
.unwrap()
.threshold(2)
.run(FIXTURE)
.unwrap();
assert_eq!(report.groups.len(), 2);
}
#[test]
fn all_targets_builder_scans_bin_targets() {
let report = Echoes::default().all_targets().run(FIXTURE).unwrap();
assert_eq!(report.total, 10);
assert_eq!(report.unique, 4);
assert_eq!(report.groups.len(), 2);
}
#[test]
fn a_package_filter_keeps_the_package_bin_targets() {
let report = Echoes::default()
.package("alpha")
.all_targets()
.run(FIXTURE)
.unwrap();
assert!(
report
.groups
.iter()
.any(|g| g.sites.iter().any(|s| s.file.ends_with("dupbin.rs")))
);
}
#[test]
fn min_tokens_builder_filters_blocks() {
let report = Echoes::default().min_tokens(4).run(FIXTURE).unwrap();
assert_eq!(report.total, 9);
assert_eq!(report.groups.len(), 1);
}
#[test]
fn exit_code_reflects_duplicates() {
let report = scan();
assert_eq!(
echoes::exit_code(&report, false),
std::process::ExitCode::from(1)
);
assert_eq!(
echoes::exit_code(&report, true),
std::process::ExitCode::SUCCESS
);
}
#[test]
#[cfg(feature = "cli")]
fn binary_runs_end_to_end() {
let bin = env!("CARGO_BIN_EXE_cargo-echoes");
let out = Command::new(bin)
.current_dir(FIXTURE)
.arg("echoes")
.arg("--json")
.output()
.expect("run echoes binary");
assert_eq!(out.status.code(), Some(1));
let value: serde_json::Value = serde_json::from_slice(&out.stdout).expect("valid json output");
assert_eq!(value["total"], 9);
assert_eq!(value["groups"].as_array().unwrap().len(), 2);
}
#[test]
#[cfg(feature = "cli")]
fn binary_prints_annotations_beside_the_report() {
let bin = env!("CARGO_BIN_EXE_cargo-echoes");
let out = Command::new(bin)
.current_dir(FIXTURE)
.arg("echoes")
.arg("--github")
.output()
.expect("run echoes binary");
assert_eq!(out.status.code(), Some(1));
let text = String::from_utf8(out.stdout).expect("utf8 output");
assert!(text.contains("9 doctests (4 unique)"), "{text}");
let marks: Vec<&str> = text.lines().filter(|l| l.starts_with("::")).collect();
assert_eq!(marks.len(), 4, "{text}");
assert!(
marks
.iter()
.all(|l| l.starts_with("::error file=") && l.contains("title=echoes::")),
"{text}"
);
}
#[test]
#[cfg(feature = "cli")]
fn unknown_package_fails_with_its_name() {
let bin = env!("CARGO_BIN_EXE_cargo-echoes");
let out = Command::new(bin)
.current_dir(FIXTURE)
.arg("echoes")
.arg("--json")
.arg("--package")
.arg("echoes")
.output()
.expect("run echoes binary");
assert_eq!(out.status.code(), Some(2));
assert_eq!(String::from_utf8_lossy(&out.stdout), "");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("no workspace member named `echoes`"),
"{stderr}"
);
}
#[test]
#[cfg(feature = "cli")]
fn a_bare_organization_flag_scans_the_current_repository_owner() {
let dir = tempfile::tempdir().unwrap();
let git = |args: &[&str]| {
let status = Command::new("git")
.current_dir(dir.path())
.args(args)
.status()
.expect("run git");
assert!(status.success(), "git {args:?}");
};
git(&["init", "-q"]);
git(&[
"remote",
"add",
"origin",
"https://gitlab.com/owner/app.git",
]);
let bin = env!("CARGO_BIN_EXE_cargo-echoes");
let out = Command::new(bin)
.current_dir(dir.path())
.env_remove("GITHUB_REPOSITORY_OWNER")
.args(["echoes", "--organization", "--no-fail"])
.output()
.expect("run echoes binary");
assert_eq!(out.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("is not a GitHub repository"), "{stderr}");
for args in [
["echoes", "--organization", "--no-fail", "--json"],
["echoes", "--organization=../evil", "--no-fail", "--json"],
] {
let out = Command::new(bin)
.current_dir(dir.path())
.env("GITHUB_REPOSITORY_OWNER", "../evil")
.args(args)
.output()
.expect("run echoes binary");
assert_eq!(out.status.code(), Some(2), "{args:?}");
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(report["organization"]["name"], "../evil", "{args:?}");
}
}
#[test]
fn file_module_items_carry_module_prefix() {
let report = scan();
let helper = report
.groups
.iter()
.find(|g| g.sites.iter().any(|s| s.item.contains("helper")))
.expect("helper group present");
let items: Vec<&str> = helper
.sites
.iter()
.map(|s| s.item.as_str())
.filter(|item| item.contains("helper"))
.collect();
assert_eq!(items, vec!["alpha::util::helper", "beta::util::helper"]);
}
#[test]
fn threshold_builder_narrows_groups() {
let report = echoes::Echoes::default().threshold(3).run(FIXTURE).unwrap();
assert_eq!(report.total, 9);
assert_eq!(report.groups.len(), 1);
}
const CONTEXT_FIXTURE: &str = "tests/fixtures/contextws";
fn context_scan() -> Report {
Echoes::default()
.context_blocks()
.context_min_tokens(0)
.run(CONTEXT_FIXTURE)
.unwrap()
}
fn context_items(group: &echoes::ContextGroup) -> Vec<&str> {
group.sites.iter().map(|s| s.item.as_str()).collect()
}
#[test]
fn renamed_locals_and_captures_group_across_functions() {
let report = context_scan();
let group = report
.context_groups
.iter()
.find(|g| g.sites.iter().any(|s| s.item.ends_with("::count_up")))
.expect("count_up context group");
assert_eq!(
context_items(group),
vec!["contextws::count_up", "contextws::count_down"]
);
assert!(
group
.sites
.iter()
.all(|s| s.kind == ContextKind::FunctionBody)
);
assert!(group.sites.iter().all(|s| s.file.ends_with("src/lib.rs")));
}
#[test]
fn closure_captures_merge_and_distinct_references_do_not() {
let report = context_scan();
let closures = report
.context_groups
.iter()
.find(|g| {
g.sites.iter().all(|s| s.kind == ContextKind::Closure)
&& g.sites.iter().any(|s| s.item.ends_with("::mixed"))
})
.expect("closure group with the mixed extra site");
assert_eq!(
context_items(closures),
vec!["contextws::shifted", "contextws::moved", "contextws::mixed"]
);
assert!(report.context_groups.iter().all(|g| {
g.sites
.iter()
.filter(|s| s.item.ends_with("::mixed"))
.count()
<= 1
}));
let functions = report
.context_groups
.iter()
.find(|g| {
g.sites.iter().all(|s| s.kind == ContextKind::FunctionBody)
&& g.sites.iter().any(|s| s.item.ends_with("::shifted"))
})
.expect("shifted/moved function group");
assert_eq!(
context_items(functions),
vec!["contextws::shifted", "contextws::moved"]
);
}
#[test]
fn distinct_import_targets_do_not_group() {
let report = context_scan();
assert!(report.context_groups.iter().all(|g| {
!(g.sites.iter().any(|s| s.item.ends_with("::via_first"))
&& g.sites.iter().any(|s| s.item.ends_with("::via_second")))
}));
}
#[test]
fn contained_block_group_survives_with_an_extra_site() {
let report = context_scan();
let blocks = report
.context_groups
.iter()
.find(|g| {
g.sites.iter().all(|s| s.kind == ContextKind::Block)
&& g.sites.iter().any(|s| s.item.ends_with("::bare"))
})
.expect("small block group with its extra site");
assert_eq!(
context_items(blocks),
vec![
"contextws::framed_left",
"contextws::framed_right",
"contextws::bare"
]
);
let functions = report
.context_groups
.iter()
.find(|g| {
g.sites.iter().all(|s| s.kind == ContextKind::FunctionBody)
&& g.sites.iter().any(|s| s.item.ends_with("::framed_left"))
})
.expect("framed function group");
assert_eq!(
context_items(functions),
vec!["contextws::framed_left", "contextws::framed_right"]
);
}
#[test]
fn same_line_context_sites_keep_their_columns() {
let report = context_scan();
let group = report
.context_groups
.iter()
.find(|g| g.sites.iter().any(|s| s.item.ends_with("::paired")))
.expect("paired closure group");
assert_eq!(group.sites.len(), 2);
assert!(group.sites.iter().all(|s| s.kind == ContextKind::Closure));
let (a, b) = (&group.sites[0], &group.sites[1]);
assert_eq!(a.line, b.line);
assert_ne!(a.column, b.column);
assert!(a.end_column <= b.column);
}
#[test]
fn the_default_context_floor_drops_the_fixture_contexts() {
let report = Echoes::default()
.context_blocks()
.run(CONTEXT_FIXTURE)
.unwrap();
assert!(report.context_blocks > 0);
assert_eq!(report.unique_context_blocks, 0);
assert_eq!(report.context_groups.len(), 0);
}
#[test]
fn exit_code_reflects_context_groups() {
let report = context_scan();
assert_eq!(
echoes::exit_code(&report, false),
std::process::ExitCode::from(1)
);
assert_eq!(
echoes::exit_code(&report, true),
std::process::ExitCode::SUCCESS
);
}
#[test]
fn config_file_enables_context_blocks() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".echoes.toml");
std::fs::write(&path, "context-blocks = true\ncontext-min-tokens = 0\n").unwrap();
let report = Echoes::default()
.config(&path)
.unwrap()
.run(CONTEXT_FIXTURE)
.unwrap();
assert!(
report
.context_groups
.iter()
.any(|g| g.sites.iter().any(|s| s.item.ends_with("::count_up")))
);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".echoes.toml");
std::fs::write(&path, "context-blocks = true\n").unwrap();
let report = Echoes::default()
.config(&path)
.unwrap()
.run(CONTEXT_FIXTURE)
.unwrap();
assert!(report.context_blocks > 0);
assert_eq!(report.context_groups.len(), 0);
}
#[test]
#[cfg(feature = "cli")]
fn context_flags_run_the_scan_end_to_end() {
let bin = env!("CARGO_BIN_EXE_cargo-echoes");
let out = Command::new(bin)
.current_dir(CONTEXT_FIXTURE)
.args([
"echoes",
"--context-blocks",
"--context-min-tokens",
"0",
"--json",
])
.output()
.expect("run echoes binary");
assert_eq!(
out.status.code(),
Some(1),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let value: serde_json::Value = serde_json::from_slice(&out.stdout).expect("valid json output");
assert!(
value["context_blocks"]
.as_u64()
.expect("context block count")
> 0
);
let groups = value["context_groups"].as_array().expect("context groups");
for group in groups {
assert!(group["id"].is_string());
assert!(group["hash"].is_string());
assert!(group["tokens"].is_u64());
for site in group["sites"].as_array().expect("context sites") {
assert!(site["file"].is_string());
assert!(site["line"].is_u64());
assert!(site["column"].is_u64());
assert!(site["end"].is_u64());
assert!(site["end_column"].is_u64());
assert!(site["item"].is_string());
assert!(matches!(
site["kind"].as_str(),
Some("function-body" | "block" | "arm" | "closure")
));
}
}
let renamed = groups
.iter()
.find(|group| {
group["sites"]
.as_array()
.unwrap()
.iter()
.any(|site| site["item"] == "contextws::count_up")
})
.expect("renamed function group");
let items: Vec<_> = renamed["sites"]
.as_array()
.unwrap()
.iter()
.map(|site| site["item"].as_str().unwrap())
.collect();
assert_eq!(items, ["contextws::count_up", "contextws::count_down"]);
}
#[test]
#[cfg(feature = "cli")]
fn context_default_floor_and_no_fail_from_the_command_line() {
let bin = env!("CARGO_BIN_EXE_cargo-echoes");
let out = Command::new(bin)
.current_dir(CONTEXT_FIXTURE)
.args(["echoes", "--context-blocks", "--json"])
.output()
.expect("run echoes binary");
assert_eq!(
out.status.code(),
Some(0),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let value: serde_json::Value = serde_json::from_slice(&out.stdout).expect("valid json output");
assert!(
value["context_blocks"]
.as_u64()
.expect("context block count")
> 0
);
assert_eq!(value["context_groups"].as_array().unwrap().len(), 0);
let out = Command::new(bin)
.current_dir(CONTEXT_FIXTURE)
.args([
"echoes",
"--context-blocks",
"--context-min-tokens",
"0",
"--no-fail",
])
.output()
.expect("run echoes binary");
assert_eq!(
out.status.code(),
Some(0),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
#[cfg(feature = "cli")]
fn context_annotations_are_approximate_with_coordinates() {
let bin = env!("CARGO_BIN_EXE_cargo-echoes");
let out = Command::new(bin)
.current_dir(CONTEXT_FIXTURE)
.args([
"echoes",
"--context-blocks",
"--context-min-tokens",
"0",
"--github",
])
.output()
.expect("run echoes binary");
assert_eq!(
out.status.code(),
Some(1),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let text = String::from_utf8(out.stdout).expect("utf8 output");
let marks: Vec<std::collections::BTreeMap<_, _>> = text
.lines()
.filter_map(|line| line.strip_prefix("::error "))
.map(|mark| {
mark.split_once("::")
.unwrap()
.0
.split(',')
.filter_map(|field| field.split_once('='))
.collect()
})
.collect();
let paired: Vec<_> = marks
.iter()
.filter(|mark| mark.get("line") == Some(&"86"))
.map(|mark| (mark["col"], mark["endLine"], mark["endColumn"]))
.collect();
assert_eq!(paired, [("19", "86", "32"), ("35", "86", "48")]);
assert!(
marks
.iter()
.all(|mark| mark.get("file") == Some(&"src/lib.rs"))
);
}