use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use regex::Regex;
use serde_json::{Value, json};
use crate::modgraph::module_name;
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum GroupKind {
CargoWorkspace,
CargoCrate,
}
impl GroupKind {
pub fn label(self) -> &'static str {
match self {
GroupKind::CargoWorkspace => "cargo-workspace",
GroupKind::CargoCrate => "cargo-crate",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum Depth {
Crate,
Module,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum SortKey {
Name,
Files,
Lines,
Tests,
}
pub fn infer_group(manifest_text: &str) -> GroupKind {
for line in manifest_text.lines() {
let t = line.trim();
if t.starts_with("[workspace]") || t.starts_with("[workspace.") {
return GroupKind::CargoWorkspace;
}
}
GroupKind::CargoCrate
}
#[derive(Debug, Clone)]
pub struct Target {
pub kinds: Vec<String>,
pub src_path: String,
}
#[derive(Debug, Clone)]
pub struct PkgMeta {
pub id: String,
pub name: String,
pub version: String,
pub manifest_path: String,
pub targets: Vec<Target>,
}
impl PkgMeta {
pub fn dir(&self) -> PathBuf {
Path::new(&self.manifest_path)
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."))
}
pub fn src_root(&self) -> Option<PathBuf> {
let pick = self
.targets
.iter()
.find(|t| t.kinds.iter().any(|k| k == "lib"))
.or_else(|| {
self.targets
.iter()
.find(|t| t.kinds.iter().any(|k| k == "bin"))
})
.or_else(|| self.targets.first())?;
Path::new(&pick.src_path).parent().map(Path::to_path_buf)
}
pub fn test_targets(&self) -> u64 {
self.targets
.iter()
.filter(|t| t.kinds.iter().any(|k| k == "test"))
.count() as u64
}
pub fn bench_targets(&self) -> u64 {
self.targets
.iter()
.filter(|t| t.kinds.iter().any(|k| k == "bench"))
.count() as u64
}
}
#[derive(Debug, Clone)]
pub struct Metadata {
pub packages: BTreeMap<String, PkgMeta>,
pub members: Vec<String>,
pub workspace_root: String,
}
pub fn parse_metadata(text: &str) -> Result<Metadata, String> {
let v: Value = serde_json::from_str(text).map_err(|e| format!("cargo metadata JSON: {e}"))?;
let mut packages = BTreeMap::new();
for p in v["packages"]
.as_array()
.ok_or("metadata missing packages")?
{
let id = p["id"].as_str().ok_or("package missing id")?.to_string();
let targets = p["targets"]
.as_array()
.map(|ts| {
ts.iter()
.map(|t| Target {
kinds: t["kind"]
.as_array()
.map(|ks| {
ks.iter()
.filter_map(|k| k.as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
src_path: t["src_path"].as_str().unwrap_or("").to_string(),
})
.collect()
})
.unwrap_or_default();
packages.insert(
id.clone(),
PkgMeta {
id,
name: p["name"].as_str().unwrap_or("").to_string(),
version: p["version"].as_str().unwrap_or("").to_string(),
manifest_path: p["manifest_path"].as_str().unwrap_or("").to_string(),
targets,
},
);
}
let members = v["workspace_members"]
.as_array()
.ok_or("metadata missing workspace_members")?
.iter()
.filter_map(|m| m.as_str().map(String::from))
.collect();
let workspace_root = v["workspace_root"].as_str().unwrap_or("").to_string();
Ok(Metadata {
packages,
members,
workspace_root,
})
}
pub fn count_tests(src: &str) -> u64 {
static RE: OnceLock<Regex> = OnceLock::new();
let re = RE.get_or_init(|| {
Regex::new(r"#\[\s*(?:[A-Za-z_]\w*\s*::\s*)*test\s*[\](]").expect("a valid regex")
});
re.find_iter(src).count() as u64
}
#[derive(Debug, Clone)]
pub struct FileStat {
pub rel_to_src: Option<String>,
pub lines: u64,
pub words: u64,
pub chars: u64,
pub tests: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Counts {
pub files: u64,
pub lines: u64,
pub words: u64,
pub chars: u64,
pub tests: u64,
}
#[derive(Debug, Clone)]
pub struct ModuleNode {
pub name: String,
pub counts: Counts,
}
pub fn roll_up(files: &[FileStat]) -> (Counts, Vec<ModuleNode>) {
let mut crate_counts = Counts::default();
let mut by_mod: BTreeMap<String, Counts> = BTreeMap::new();
for f in files {
crate_counts.files += 1;
crate_counts.lines += f.lines;
crate_counts.words += f.words;
crate_counts.chars += f.chars;
crate_counts.tests += f.tests;
if let Some(rel) = &f.rel_to_src {
let m = by_mod.entry(module_name(Path::new(rel))).or_default();
m.files += 1;
m.lines += f.lines;
m.words += f.words;
m.chars += f.chars;
m.tests += f.tests;
}
}
let modules = by_mod
.into_iter()
.map(|(name, counts)| ModuleNode { name, counts })
.collect();
(crate_counts, modules)
}
#[derive(Debug, Clone)]
pub struct CrateNode {
pub name: String,
pub version: String,
pub counts: Counts,
pub test_targets: u64,
pub bench_targets: u64,
pub modules: Vec<ModuleNode>,
}
#[derive(Debug, Clone)]
pub struct Survey {
pub group: GroupKind,
pub name: String,
pub root: String,
pub crates: Vec<CrateNode>,
}
fn order(a_name: &str, b_name: &str, a: u64, b: u64, key: SortKey) -> std::cmp::Ordering {
match key {
SortKey::Name => a_name.cmp(b_name),
_ => b.cmp(&a).then_with(|| a_name.cmp(b_name)),
}
}
fn count_for(c: &Counts, key: SortKey) -> u64 {
match key {
SortKey::Name | SortKey::Files => c.files,
SortKey::Lines => c.lines,
SortKey::Tests => c.tests,
}
}
impl Survey {
pub fn sort(&mut self, key: SortKey) {
self.crates.sort_by(|a, b| {
order(
&a.name,
&b.name,
count_for(&a.counts, key),
count_for(&b.counts, key),
key,
)
});
for c in &mut self.crates {
c.modules.sort_by(|a, b| {
order(
&a.name,
&b.name,
count_for(&a.counts, key),
count_for(&b.counts, key),
key,
)
});
}
}
}
pub fn totals(survey: &Survey) -> (Counts, u64, u64) {
let mut c = Counts::default();
let mut test_targets = 0;
let mut bench_targets = 0;
for cr in &survey.crates {
c.files += cr.counts.files;
c.lines += cr.counts.lines;
c.words += cr.counts.words;
c.chars += cr.counts.chars;
c.tests += cr.counts.tests;
test_targets += cr.test_targets;
bench_targets += cr.bench_targets;
}
(c, test_targets, bench_targets)
}
pub fn render_text(survey: &Survey, depth: Depth) -> String {
let mut out = String::new();
match survey.group {
GroupKind::CargoWorkspace => out.push_str(&format!(
"workspace {} — {} crate(s) [grouping: authoritative via cargo metadata]\n",
survey.name,
survey.crates.len()
)),
GroupKind::CargoCrate => out.push_str(&format!(
"crate {} [grouping: authoritative via cargo metadata]\n",
survey.name
)),
}
for c in &survey.crates {
out.push_str(&format!(
" {} v{} files {} lines {} tests {}~ test-targets {} benches {}\n",
c.name,
c.version,
c.counts.files,
c.counts.lines,
c.counts.tests,
c.test_targets,
c.bench_targets
));
if depth == Depth::Module {
for m in &c.modules {
out.push_str(&format!(
" {} files {} lines {} tests {}~\n",
m.name, m.counts.files, m.counts.lines, m.counts.tests
));
}
}
}
let (tot, test_targets, bench_targets) = totals(survey);
out.push_str(&format!(
"totals files {} lines {} tests {}~ test-targets {} benches {}\n",
tot.files, tot.lines, tot.tests, test_targets, bench_targets
));
out.push_str(
"(~ = heuristic; file/line counts exact; grouping and target counts authoritative)\n",
);
out
}
pub fn to_json(survey: &Survey) -> Value {
let (tot, test_targets, bench_targets) = totals(survey);
let crates: Vec<Value> = survey
.crates
.iter()
.map(|c| {
let modules: Vec<Value> = c
.modules
.iter()
.map(|m| {
json!({
"name": m.name,
"files": m.counts.files,
"lines": m.counts.lines,
"words": m.counts.words,
"chars": m.counts.chars,
"tests": m.counts.tests,
})
})
.collect();
json!({
"name": c.name,
"version": c.version,
"files": c.counts.files,
"lines": c.counts.lines,
"words": c.counts.words,
"chars": c.counts.chars,
"tests": c.counts.tests,
"test_targets": c.test_targets,
"bench_targets": c.bench_targets,
"modules": modules,
})
})
.collect();
json!({
"tool": "ct-survey",
"group": survey.group.label(),
"name": survey.name,
"root": survey.root,
"honesty": {
"grouping": "authoritative",
"counts": "exact",
"tests": "heuristic",
"test_targets": "authoritative",
"modules": "heuristic",
},
"crates": crates,
"totals": {
"crates": survey.crates.len(),
"files": tot.files,
"lines": tot.lines,
"words": tot.words,
"chars": tot.chars,
"tests": tot.tests,
"test_targets": test_targets,
"bench_targets": bench_targets,
},
})
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> &'static str {
r#"{
"packages": [
{"id": "app 0.1.0 (path+file:///w/app)", "name": "app", "version": "0.1.0",
"manifest_path": "/w/app/Cargo.toml",
"targets": [
{"kind": ["lib"], "src_path": "/w/app/src/lib.rs"},
{"kind": ["bin"], "src_path": "/w/app/src/bin/tool.rs"},
{"kind": ["test"], "src_path": "/w/app/tests/it.rs"},
{"kind": ["bench"], "src_path": "/w/app/benches/b.rs"}
]}
],
"workspace_members": ["app 0.1.0 (path+file:///w/app)"],
"workspace_root": "/w"
}"#
}
#[test]
fn parses_packages_members_and_targets() {
let m = parse_metadata(sample()).unwrap();
assert_eq!(m.members.len(), 1);
assert_eq!(m.workspace_root, "/w");
let p = m.packages.values().next().unwrap();
assert_eq!(p.name, "app");
assert_eq!(p.version, "0.1.0");
assert_eq!(p.test_targets(), 1);
assert_eq!(p.bench_targets(), 1);
assert_eq!(p.dir(), Path::new("/w/app"));
assert_eq!(p.src_root().unwrap(), Path::new("/w/app/src"));
}
#[test]
fn malformed_or_incomplete_metadata_errors() {
assert!(parse_metadata("{ not json").is_err());
assert!(parse_metadata("{}").is_err());
}
#[test]
fn test_scan_counts_attributes_not_cfg_gates() {
let src =
"#[cfg(test)]\nmod t {\n #[test]\n fn a() {}\n #[tokio::test]\n async fn b() {}\n}";
assert_eq!(count_tests(src), 2);
}
#[test]
fn sort_orders_crates_and_breaks_ties_by_name() {
let mk = |name: &str, files: u64| CrateNode {
name: name.into(),
version: "0".into(),
counts: Counts {
files,
..Counts::default()
},
test_targets: 0,
bench_targets: 0,
modules: vec![],
};
let mut s = Survey {
group: GroupKind::CargoWorkspace,
name: "w".into(),
root: "/w".into(),
crates: vec![mk("b", 1), mk("a", 3), mk("c", 3)],
};
s.sort(SortKey::Files);
let order: Vec<&str> = s.crates.iter().map(|c| c.name.as_str()).collect();
assert_eq!(order, ["a", "c", "b"]);
s.sort(SortKey::Name);
let order: Vec<&str> = s.crates.iter().map(|c| c.name.as_str()).collect();
assert_eq!(order, ["a", "b", "c"]);
}
}