use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::data::counter::{compute_module_name, CountResult};
use crate::data::diff::{DiffResult, LocsDiff};
use crate::data::stats::Locs;
use super::options::{Aggregation, Field, LineTypes, OrderBy, OrderDirection, Ordering, Predicate};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryItem<T> {
pub label: String,
pub stats: T,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CountQuerySet {
pub aggregation: Aggregation,
pub line_types: LineTypes,
pub items: Vec<QueryItem<Locs>>,
pub total: Locs,
pub file_count: usize,
#[serde(default)]
pub total_items: usize,
#[serde(default)]
pub top_applied: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffQuerySet {
pub aggregation: Aggregation,
pub line_types: LineTypes,
pub items: Vec<QueryItem<LocsDiff>>,
pub total: LocsDiff,
pub file_count: usize,
pub from_commit: String,
pub to_commit: String,
#[serde(default)]
pub non_rust_added: u64,
#[serde(default)]
pub non_rust_removed: u64,
#[serde(default)]
pub total_items: usize,
#[serde(default)]
pub top_applied: bool,
}
impl CountQuerySet {
pub fn from_result(
result: &CountResult,
aggregation: Aggregation,
line_types: LineTypes,
ordering: Ordering,
) -> Self {
let items = build_count_items(result, &aggregation, &line_types, &ordering);
let total = result.total.filter(line_types);
let total_items = items.len();
CountQuerySet {
aggregation,
line_types,
items,
total,
file_count: result.file_count,
total_items,
top_applied: false,
}
}
#[must_use]
pub fn top(mut self, n: usize) -> Self {
self.items.truncate(n);
self.top_applied = true;
self
}
#[must_use]
pub fn filter(mut self, preds: &[Predicate]) -> Self {
if preds.is_empty() {
return self;
}
let line_types = self.line_types;
self.items.retain(|item| {
preds
.iter()
.all(|p| matches_locs(p, &item.stats, &line_types))
});
self
}
}
fn u64_to_i64_sat(v: u64) -> i64 {
i64::try_from(v).unwrap_or(i64::MAX)
}
fn u64_sub_i64_sat(a: u64, b: u64) -> i64 {
let a = u64_to_i64_sat(a);
let b = u64_to_i64_sat(b);
a.saturating_sub(b)
}
fn locs_field_value(locs: &Locs, field: Field, line_types: &LineTypes) -> i64 {
let v: u64 = match field {
Field::Code => locs.code,
Field::Tests => locs.tests,
Field::Examples => locs.examples,
Field::Docs => locs.docs,
Field::Comments => locs.comments,
Field::Blanks => locs.blanks,
Field::Total => locs_filtered_total(locs, line_types),
};
u64_to_i64_sat(v)
}
fn matches_locs(pred: &Predicate, locs: &Locs, line_types: &LineTypes) -> bool {
let lhs = locs_field_value(locs, pred.field, line_types);
pred.op.evaluate(lhs, u64_to_i64_sat(pred.value))
}
fn diff_field_value(diff: &LocsDiff, field: Field, line_types: &LineTypes) -> i64 {
match field {
Field::Code => diff.net_code(),
Field::Tests => diff.net_tests(),
Field::Examples => diff.net_examples(),
Field::Docs => diff.net_docs(),
Field::Comments => diff.net_comments(),
Field::Blanks => diff.net_blanks(),
Field::Total => {
let (a, r) = locs_diff_filtered_total(diff, line_types);
u64_sub_i64_sat(a, r)
}
}
}
fn matches_diff(pred: &Predicate, diff: &LocsDiff, line_types: &LineTypes) -> bool {
let lhs = diff_field_value(diff, pred.field, line_types);
pred.op.evaluate(lhs, u64_to_i64_sat(pred.value))
}
fn relative_path_label(path: &std::path::Path, root: &std::path::Path) -> String {
path.strip_prefix(root)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| path.to_string_lossy().to_string())
}
impl DiffQuerySet {
pub fn from_result(
result: &DiffResult,
aggregation: Aggregation,
line_types: LineTypes,
ordering: Ordering,
) -> Self {
let items = build_diff_items(result, &aggregation, &line_types, &ordering);
let total = result.total.filter(line_types);
let total_items = items.len();
DiffQuerySet {
aggregation,
line_types,
items,
total,
file_count: result.files.len(),
from_commit: result.from_commit.clone(),
to_commit: result.to_commit.clone(),
non_rust_added: result.non_rust_added,
non_rust_removed: result.non_rust_removed,
total_items,
top_applied: false,
}
}
#[must_use]
pub fn top(mut self, n: usize) -> Self {
self.items.truncate(n);
self.top_applied = true;
self
}
#[must_use]
pub fn filter(mut self, preds: &[Predicate]) -> Self {
if preds.is_empty() {
return self;
}
let line_types = self.line_types;
self.items.retain(|item| {
preds
.iter()
.all(|p| matches_diff(p, &item.stats, &line_types))
});
self
}
}
fn locs_filtered_total(locs: &Locs, line_types: &LineTypes) -> u64 {
let mut total = 0;
if line_types.code {
total += locs.code;
}
if line_types.tests {
total += locs.tests;
}
if line_types.examples {
total += locs.examples;
}
if line_types.docs {
total += locs.docs;
}
if line_types.comments {
total += locs.comments;
}
if line_types.blanks {
total += locs.blanks;
}
total
}
fn count_sort_key(locs: &Locs, order_by: &OrderBy, line_types: &LineTypes) -> u64 {
match order_by {
OrderBy::Label => 0, OrderBy::Code => locs.code,
OrderBy::Tests => locs.tests,
OrderBy::Examples => locs.examples,
OrderBy::Docs => locs.docs,
OrderBy::Comments => locs.comments,
OrderBy::Blanks => locs.blanks,
OrderBy::Total => locs_filtered_total(locs, line_types),
}
}
fn build_count_items(
result: &CountResult,
aggregation: &Aggregation,
line_types: &LineTypes,
ordering: &Ordering,
) -> Vec<QueryItem<Locs>> {
let mut items: Vec<(String, Locs)> = match aggregation {
Aggregation::Total => return vec![],
Aggregation::ByCrate => result
.crates
.iter()
.map(|c| (c.name.clone(), c.stats.filter(*line_types)))
.collect(),
Aggregation::ByModule => result
.modules
.iter()
.map(|m| {
let label = if m.name.is_empty() {
"(root)".to_string()
} else {
m.name.clone()
};
(label, m.stats.filter(*line_types))
})
.collect(),
Aggregation::ByFile => result
.files
.iter()
.map(|f| {
(
relative_path_label(&f.path, &result.root),
f.stats.filter(*line_types),
)
})
.collect(),
};
match ordering.by {
OrderBy::Label => {
items.sort_by(|a, b| a.0.cmp(&b.0));
}
_ => {
items.sort_by(|a, b| {
let key_a = count_sort_key(&a.1, &ordering.by, line_types);
let key_b = count_sort_key(&b.1, &ordering.by, line_types);
key_a.cmp(&key_b)
});
}
}
if ordering.direction == OrderDirection::Descending {
items.reverse();
}
items
.into_iter()
.map(|(label, stats)| QueryItem { label, stats })
.collect()
}
fn locs_diff_filtered_total(diff: &LocsDiff, line_types: &LineTypes) -> (u64, u64) {
let added = locs_filtered_total(&diff.added, line_types);
let removed = locs_filtered_total(&diff.removed, line_types);
(added, removed)
}
fn diff_sort_key(diff: &LocsDiff, order_by: &OrderBy, line_types: &LineTypes) -> i64 {
match order_by {
OrderBy::Label => 0, OrderBy::Code => diff.net_code(),
OrderBy::Tests => diff.net_tests(),
OrderBy::Examples => diff.net_examples(),
OrderBy::Docs => diff.net_docs(),
OrderBy::Comments => diff.net_comments(),
OrderBy::Blanks => diff.net_blanks(),
OrderBy::Total => {
let (a, r) = locs_diff_filtered_total(diff, line_types);
a as i64 - r as i64
}
}
}
fn build_diff_items(
result: &DiffResult,
aggregation: &Aggregation,
line_types: &LineTypes,
ordering: &Ordering,
) -> Vec<QueryItem<LocsDiff>> {
let mut items: Vec<(String, LocsDiff)> = match aggregation {
Aggregation::Total => return vec![],
Aggregation::ByCrate => result
.crates
.iter()
.map(|c| (c.name.clone(), c.diff.filter(*line_types)))
.collect(),
Aggregation::ByModule => {
let mut module_map: HashMap<String, LocsDiff> = HashMap::new();
for crate_diff in &result.crates {
let src_root = crate_diff.path.join("src");
let effective_root = if src_root.exists() {
src_root
} else {
crate_diff.path.clone()
};
for file in &crate_diff.files {
let abs_path = if file.path.is_absolute() {
file.path.clone()
} else {
result.root.join(&file.path)
};
let local_module = compute_module_name(&abs_path, &effective_root);
let full_name = if local_module.is_empty() {
crate_diff.name.clone()
} else {
format!("{}::{}", crate_diff.name, local_module)
};
let entry = module_map.entry(full_name).or_default();
*entry += file.diff.filter(*line_types);
}
}
module_map.into_iter().collect()
}
Aggregation::ByFile => result
.files
.iter()
.map(|f| {
(
f.path.to_string_lossy().to_string(),
f.diff.filter(*line_types),
)
})
.collect(),
};
match ordering.by {
OrderBy::Label => {
items.sort_by(|a, b| a.0.cmp(&b.0));
}
_ => {
items.sort_by(|a, b| {
let key_a = diff_sort_key(&a.1, &ordering.by, line_types);
let key_b = diff_sort_key(&b.1, &ordering.by, line_types);
key_a.cmp(&key_b)
});
}
}
if ordering.direction == OrderDirection::Descending {
items.reverse();
}
items
.into_iter()
.map(|(label, stats)| QueryItem { label, stats })
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::stats::CrateStats;
use crate::query::options::{Field, Op, Predicate};
use std::path::PathBuf;
fn sample_locs(code: u64, tests: u64) -> Locs {
Locs {
code,
tests,
examples: 0,
docs: 0,
comments: 0,
blanks: 0,
total: code + tests,
}
}
fn sample_count_result() -> CountResult {
CountResult {
root: PathBuf::from("/workspace"),
file_count: 4,
total: sample_locs(200, 100),
crates: vec![
CrateStats {
name: "alpha".to_string(),
path: PathBuf::from("/alpha"),
stats: sample_locs(50, 25),
files: vec![],
},
CrateStats {
name: "beta".to_string(),
path: PathBuf::from("/beta"),
stats: sample_locs(150, 75),
files: vec![],
},
],
files: vec![],
modules: vec![],
}
}
#[test]
fn test_count_queryset_by_crate() {
let result = sample_count_result();
let qs = CountQuerySet::from_result(
&result,
Aggregation::ByCrate,
LineTypes::everything(),
Ordering::default(),
);
assert_eq!(qs.items.len(), 2);
assert_eq!(qs.file_count, 4);
assert_eq!(qs.items[0].label, "alpha");
assert_eq!(qs.items[1].label, "beta");
}
#[test]
fn test_count_queryset_ordering_by_code() {
let result = sample_count_result();
let qs = CountQuerySet::from_result(
&result,
Aggregation::ByCrate,
LineTypes::everything(),
Ordering::by_code(), );
assert_eq!(qs.items[0].label, "beta");
assert_eq!(qs.items[0].stats.code, 150);
assert_eq!(qs.items[1].label, "alpha");
assert_eq!(qs.items[1].stats.code, 50);
}
#[test]
fn test_count_queryset_total_aggregation() {
let result = sample_count_result();
let qs = CountQuerySet::from_result(
&result,
Aggregation::Total,
LineTypes::everything(),
Ordering::default(),
);
assert_eq!(qs.items.len(), 0);
assert_eq!(qs.total.code, 200);
assert_eq!(qs.total.tests, 100);
}
#[test]
fn test_count_queryset_filtered_line_types() {
let result = sample_count_result();
let line_types = LineTypes::new().with_code();
let qs = CountQuerySet::from_result(
&result,
Aggregation::ByCrate,
line_types,
Ordering::default(),
);
assert_eq!(qs.items[0].stats.code, 50);
assert_eq!(qs.items[0].stats.tests, 0); }
fn sample_count_result_three_crates() -> CountResult {
CountResult {
root: PathBuf::from("/workspace"),
file_count: 6,
total: sample_locs(600, 300),
crates: vec![
CrateStats {
name: "alpha".to_string(),
path: PathBuf::from("/alpha"),
stats: sample_locs(50, 25),
files: vec![],
},
CrateStats {
name: "beta".to_string(),
path: PathBuf::from("/beta"),
stats: sample_locs(150, 75),
files: vec![],
},
CrateStats {
name: "gamma".to_string(),
path: PathBuf::from("/gamma"),
stats: sample_locs(400, 200),
files: vec![],
},
],
files: vec![],
modules: vec![],
}
}
#[test]
fn test_count_queryset_top_truncates_after_sort() {
let result = sample_count_result_three_crates();
let qs = CountQuerySet::from_result(
&result,
Aggregation::ByCrate,
LineTypes::everything(),
Ordering::by_code(), )
.top(2);
assert_eq!(qs.items.len(), 2);
assert_eq!(qs.items[0].label, "gamma");
assert_eq!(qs.items[1].label, "beta");
assert_eq!(qs.total.code, 600);
assert_eq!(qs.file_count, 6);
}
#[test]
fn test_count_queryset_top_larger_than_len_is_noop() {
let result = sample_count_result_three_crates();
let qs = CountQuerySet::from_result(
&result,
Aggregation::ByCrate,
LineTypes::everything(),
Ordering::by_code(),
)
.top(99);
assert_eq!(qs.items.len(), 3);
}
#[test]
fn test_filter_gte_drops_items_below_threshold() {
let result = sample_count_result_three_crates();
let qs = CountQuerySet::from_result(
&result,
Aggregation::ByCrate,
LineTypes::everything(),
Ordering::default(),
)
.filter(&[Predicate::new(Field::Code, Op::Gte, 100)]);
let labels: Vec<_> = qs.items.iter().map(|i| i.label.as_str()).collect();
assert_eq!(labels, vec!["beta", "gamma"]);
}
#[test]
fn test_filter_eq_and_ne() {
let result = sample_count_result_three_crates();
let eq = CountQuerySet::from_result(
&result,
Aggregation::ByCrate,
LineTypes::everything(),
Ordering::default(),
)
.filter(&[Predicate::new(Field::Code, Op::Eq, 150)]);
assert_eq!(eq.items.len(), 1);
assert_eq!(eq.items[0].label, "beta");
let ne = CountQuerySet::from_result(
&result,
Aggregation::ByCrate,
LineTypes::everything(),
Ordering::default(),
)
.filter(&[Predicate::new(Field::Code, Op::Ne, 150)]);
assert_eq!(ne.items.len(), 2);
}
#[test]
fn test_filter_combines_predicates_with_and() {
let result = sample_count_result_three_crates();
let qs = CountQuerySet::from_result(
&result,
Aggregation::ByCrate,
LineTypes::everything(),
Ordering::default(),
)
.filter(&[
Predicate::new(Field::Code, Op::Gt, 100),
Predicate::new(Field::Tests, Op::Lt, 100),
]);
assert_eq!(qs.items.len(), 1);
assert_eq!(qs.items[0].label, "beta");
}
#[test]
fn test_filter_total_honors_line_types() {
let result = sample_count_result_three_crates();
let qs = CountQuerySet::from_result(
&result,
Aggregation::ByCrate,
LineTypes::everything(),
Ordering::default(),
)
.filter(&[Predicate::new(Field::Total, Op::Gte, 200)]);
assert_eq!(qs.items.len(), 2);
let qs = CountQuerySet::from_result(
&result,
Aggregation::ByCrate,
LineTypes::new().with_code(),
Ordering::default(),
)
.filter(&[Predicate::new(Field::Total, Op::Gte, 200)]);
assert_eq!(qs.items.len(), 1);
assert_eq!(qs.items[0].label, "gamma");
}
#[test]
fn test_filter_empty_predicates_is_noop() {
let result = sample_count_result_three_crates();
let qs = CountQuerySet::from_result(
&result,
Aggregation::ByCrate,
LineTypes::everything(),
Ordering::default(),
)
.filter(&[]);
assert_eq!(qs.items.len(), 3);
}
#[test]
fn test_filter_preserves_total_and_total_items() {
let result = sample_count_result_three_crates();
let qs = CountQuerySet::from_result(
&result,
Aggregation::ByCrate,
LineTypes::everything(),
Ordering::default(),
)
.filter(&[Predicate::new(Field::Code, Op::Gte, 200)]);
assert_eq!(qs.items.len(), 1); assert_eq!(qs.total_items, 3); assert_eq!(qs.total.code, 600); }
#[test]
fn test_filter_chains_with_top() {
let result = sample_count_result_three_crates();
let qs = CountQuerySet::from_result(
&result,
Aggregation::ByCrate,
LineTypes::everything(),
Ordering::default(),
)
.filter(&[Predicate::new(Field::Code, Op::Gte, 100)])
.top(1);
assert_eq!(qs.items.len(), 1);
assert_eq!(qs.items[0].label, "beta");
assert_eq!(qs.total_items, 3);
}
#[test]
fn test_count_queryset_top_zero_empties_items() {
let result = sample_count_result_three_crates();
let qs = CountQuerySet::from_result(
&result,
Aggregation::ByCrate,
LineTypes::everything(),
Ordering::by_code(),
)
.top(0);
assert_eq!(qs.items.len(), 0);
assert_eq!(qs.total.code, 600);
}
#[test]
fn test_count_queryset_by_file_relative_paths() {
use crate::data::stats::FileStats;
let result = CountResult {
root: PathBuf::from("/workspace"),
file_count: 2,
total: sample_locs(100, 50),
crates: vec![],
files: vec![
FileStats::new(PathBuf::from("/workspace/src/main.rs"), sample_locs(50, 25)),
FileStats::new(
PathBuf::from("/workspace/crate-a/src/lib.rs"),
sample_locs(50, 25),
),
],
modules: vec![],
};
let qs = CountQuerySet::from_result(
&result,
Aggregation::ByFile,
LineTypes::everything(),
Ordering::default(),
);
assert_eq!(qs.items.len(), 2);
assert!(qs.items.iter().any(|item| item.label == "src/main.rs"));
assert!(qs
.items
.iter()
.any(|item| item.label == "crate-a/src/lib.rs"));
}
fn sample_diff_result_two_files() -> crate::data::diff::DiffResult {
use crate::data::diff::{
CrateDiffStats, DiffResult, FileChangeType, FileDiffStats, LocsDiff,
};
use crate::data::stats::Locs;
let big = LocsDiff {
added: Locs {
code: 200,
tests: 0,
examples: 0,
docs: 0,
comments: 0,
blanks: 0,
total: 200,
},
removed: Locs {
code: 50,
tests: 0,
examples: 0,
docs: 0,
comments: 0,
blanks: 0,
total: 50,
},
};
let small = LocsDiff {
added: Locs {
code: 10,
tests: 0,
examples: 0,
docs: 0,
comments: 0,
blanks: 0,
total: 10,
},
removed: Locs {
code: 30,
tests: 0,
examples: 0,
docs: 0,
comments: 0,
blanks: 0,
total: 30,
},
};
let big_file = FileDiffStats {
path: PathBuf::from("big.rs"),
change_type: FileChangeType::Modified,
diff: big,
};
let small_file = FileDiffStats {
path: PathBuf::from("small.rs"),
change_type: FileChangeType::Modified,
diff: small,
};
DiffResult {
root: PathBuf::from("/workspace"),
from_commit: "HEAD~1".to_string(),
to_commit: "HEAD".to_string(),
total: big + small,
crates: vec![CrateDiffStats {
name: "x".to_string(),
path: PathBuf::from("/workspace"),
diff: big + small,
files: vec![big_file.clone(), small_file.clone()],
}],
files: vec![big_file, small_file],
non_rust_added: 0,
non_rust_removed: 0,
}
}
#[test]
fn test_diff_filter_uses_net_value() {
let result = sample_diff_result_two_files();
let qs = DiffQuerySet::from_result(
&result,
Aggregation::ByFile,
LineTypes::everything(),
Ordering::default(),
)
.filter(&[Predicate::new(Field::Code, Op::Gt, 0)]);
assert_eq!(qs.items.len(), 1);
assert_eq!(qs.items[0].label, "big.rs");
}
#[test]
fn test_diff_filter_negative_net_via_lt_zero() {
let result = sample_diff_result_two_files();
let qs = DiffQuerySet::from_result(
&result,
Aggregation::ByFile,
LineTypes::everything(),
Ordering::default(),
)
.filter(&[Predicate::new(Field::Code, Op::Lt, 0)]);
assert_eq!(qs.items.len(), 1);
assert_eq!(qs.items[0].label, "small.rs");
}
#[test]
fn test_diff_filter_preserves_total_items() {
let result = sample_diff_result_two_files();
let qs = DiffQuerySet::from_result(
&result,
Aggregation::ByFile,
LineTypes::everything(),
Ordering::default(),
)
.filter(&[Predicate::new(Field::Code, Op::Gte, 100)]);
assert_eq!(qs.items.len(), 1);
assert_eq!(qs.total_items, 2); }
}