use serde::{Deserialize, Serialize};
use crate::data::diff::LocsDiff;
use crate::data::stats::Locs;
use crate::query::options::Aggregation;
use crate::query::queryset::{CountQuerySet, DiffQuerySet};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableRow {
pub label: String,
pub values: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LOCTable {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub headers: Vec<String>,
pub rows: Vec<TableRow>,
pub footer: TableRow,
#[serde(skip_serializing_if = "Option::is_none")]
pub non_rust_summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub legend: Option<String>,
}
impl LOCTable {
pub fn from_count_queryset(qs: &CountQuerySet) -> Self {
let headers = build_headers(&qs.aggregation, &qs.line_types);
let rows: Vec<TableRow> = qs
.items
.iter()
.map(|item| TableRow {
label: item.label.clone(),
values: format_locs(&item.stats, &qs.line_types),
})
.collect();
let footer = TableRow {
label: build_footer_label(
&qs.aggregation,
rows.len(),
qs.total_items,
qs.file_count,
qs.top_applied,
),
values: format_locs(&qs.total, &qs.line_types),
};
LOCTable {
title: None,
headers,
rows,
footer,
non_rust_summary: None,
legend: None,
}
}
pub fn from_diff_queryset(qs: &DiffQuerySet) -> Self {
let headers = build_headers(&qs.aggregation, &qs.line_types);
let rows: Vec<TableRow> = qs
.items
.iter()
.map(|item| TableRow {
label: item.label.clone(),
values: format_locs_diff(&item.stats, &qs.line_types),
})
.collect();
let footer = TableRow {
label: build_footer_label(
&qs.aggregation,
rows.len(),
qs.total_items,
qs.file_count,
qs.top_applied,
),
values: format_locs_diff(&qs.total, &qs.line_types),
};
let title = Some(format!("Diff: {} → {}", qs.from_commit, qs.to_commit));
let non_rust_summary = if qs.non_rust_added > 0 || qs.non_rust_removed > 0 {
let nr_net = qs.non_rust_added as i64 - qs.non_rust_removed as i64;
Some(format!(
"Non-Rust changes: [additions]+{}[/additions] / [deletions]-{}[/deletions] / {} net",
qs.non_rust_added, qs.non_rust_removed, nr_net
))
} else {
None
};
LOCTable {
title,
headers,
rows,
footer,
non_rust_summary,
legend: Some("(+added / -removed / net)".to_string()),
}
}
}
struct LineTypesView {
code: bool,
tests: bool,
examples: bool,
docs: bool,
comments: bool,
blanks: bool,
total: bool,
}
impl From<&crate::query::options::LineTypes> for LineTypesView {
fn from(lt: &crate::query::options::LineTypes) -> Self {
LineTypesView {
code: lt.code,
tests: lt.tests,
examples: lt.examples,
docs: lt.docs,
comments: lt.comments,
blanks: lt.blanks,
total: lt.total,
}
}
}
fn build_footer_label(
aggregation: &Aggregation,
displayed: usize,
total: usize,
file_count: usize,
top_applied: bool,
) -> String {
let unit = match aggregation {
Aggregation::Total => return format!("Total ({} files)", file_count),
Aggregation::ByCrate => "crates",
Aggregation::ByModule => "modules",
Aggregation::ByFile => "files",
};
let total = total.max(displayed);
if displayed == total {
format!("Total ({} {})", total, unit)
} else if top_applied {
format!("Total (top {} of {} {})", displayed, total, unit)
} else {
format!("Total ({} of {} {})", displayed, total, unit)
}
}
fn build_headers(
aggregation: &Aggregation,
line_types: &crate::query::options::LineTypes,
) -> Vec<String> {
let label_header = match aggregation {
Aggregation::Total => "Name".to_string(),
Aggregation::ByCrate => "Crate".to_string(),
Aggregation::ByModule => "Module".to_string(),
Aggregation::ByFile => "File".to_string(),
};
let lt = LineTypesView::from(line_types);
let mut headers = vec![label_header];
if lt.code {
headers.push("Code".to_string());
}
if lt.tests {
headers.push("Tests".to_string());
}
if lt.examples {
headers.push("Examples".to_string());
}
if lt.docs {
headers.push("Docs".to_string());
}
if lt.comments {
headers.push("Comments".to_string());
}
if lt.blanks {
headers.push("Blanks".to_string());
}
if lt.total {
headers.push("Total".to_string());
}
headers
}
fn format_locs(locs: &Locs, line_types: &crate::query::options::LineTypes) -> Vec<String> {
let lt = LineTypesView::from(line_types);
let mut values = Vec::new();
if lt.code {
values.push(locs.code.to_string());
}
if lt.tests {
values.push(locs.tests.to_string());
}
if lt.examples {
values.push(locs.examples.to_string());
}
if lt.docs {
values.push(locs.docs.to_string());
}
if lt.comments {
values.push(locs.comments.to_string());
}
if lt.blanks {
values.push(locs.blanks.to_string());
}
if lt.total {
values.push(locs.total.to_string());
}
values
}
fn format_diff_value(added: u64, removed: u64) -> String {
let net = added as i64 - removed as i64;
format!(
"[additions]+{}[/additions]/[deletions]-{}[/deletions]/{}",
added, removed, net
)
}
fn format_locs_diff(diff: &LocsDiff, line_types: &crate::query::options::LineTypes) -> Vec<String> {
let lt = LineTypesView::from(line_types);
let mut values = Vec::new();
if lt.code {
values.push(format_diff_value(diff.added.code, diff.removed.code));
}
if lt.tests {
values.push(format_diff_value(diff.added.tests, diff.removed.tests));
}
if lt.examples {
values.push(format_diff_value(
diff.added.examples,
diff.removed.examples,
));
}
if lt.docs {
values.push(format_diff_value(diff.added.docs, diff.removed.docs));
}
if lt.comments {
values.push(format_diff_value(
diff.added.comments,
diff.removed.comments,
));
}
if lt.blanks {
values.push(format_diff_value(diff.added.blanks, diff.removed.blanks));
}
if lt.total {
values.push(format_diff_value(diff.added.total, diff.removed.total));
}
values
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::counter::CountResult;
use crate::data::stats::CrateStats;
use crate::query::options::{LineTypes, Ordering};
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_headers_by_crate() {
let headers = build_headers(&Aggregation::ByCrate, &LineTypes::everything());
assert_eq!(headers[0], "Crate");
assert_eq!(headers[1], "Code");
assert_eq!(headers[2], "Tests");
assert_eq!(headers[3], "Examples");
assert_eq!(headers[4], "Docs");
assert_eq!(headers[5], "Comments");
assert_eq!(headers[6], "Blanks");
assert_eq!(headers[7], "Total");
}
#[test]
fn test_headers_filtered_line_types() {
let line_types = LineTypes::new().with_code();
let headers = build_headers(&Aggregation::ByFile, &line_types);
assert_eq!(headers.len(), 3); assert_eq!(headers[0], "File");
assert_eq!(headers[1], "Code");
assert_eq!(headers[2], "Total");
}
#[test]
fn test_format_locs() {
let locs = sample_locs(100, 50);
let values = format_locs(&locs, &LineTypes::everything());
assert_eq!(values[0], "100"); assert_eq!(values[1], "50"); assert_eq!(values[2], "0"); assert_eq!(values[3], "0"); assert_eq!(values[4], "0"); assert_eq!(values[5], "0"); assert_eq!(values[6], "150"); }
#[test]
fn test_loc_table_from_queryset() {
let result = sample_count_result();
let qs = CountQuerySet::from_result(
&result,
Aggregation::ByCrate,
LineTypes::everything(),
Ordering::default(),
);
let table = LOCTable::from_count_queryset(&qs);
assert!(table.title.is_none());
assert_eq!(table.headers[0], "Crate");
assert_eq!(table.rows.len(), 2);
assert_eq!(table.rows[0].label, "alpha");
assert_eq!(table.rows[1].label, "beta");
assert_eq!(table.footer.label, "Total (2 crates)");
}
#[test]
fn test_footer_label_marks_truncation_when_top_applied() {
let result = sample_count_result();
let qs = CountQuerySet::from_result(
&result,
Aggregation::ByCrate,
LineTypes::everything(),
Ordering::default(),
)
.top(1);
let table = LOCTable::from_count_queryset(&qs);
assert_eq!(table.rows.len(), 1);
assert_eq!(table.footer.label, "Total (top 1 of 2 crates)");
}
#[test]
fn test_footer_label_filter_only_uses_plain_x_of_y() {
use crate::query::options::{Field, Op, Predicate};
let result = sample_count_result();
let qs = CountQuerySet::from_result(
&result,
Aggregation::ByCrate,
LineTypes::everything(),
Ordering::default(),
)
.filter(&[Predicate::new(Field::Code, Op::Gte, 100)]);
let table = LOCTable::from_count_queryset(&qs);
assert_eq!(table.rows.len(), 1);
assert_eq!(table.footer.label, "Total (1 of 2 crates)");
}
#[test]
fn test_footer_label_filter_then_top_uses_top_wording() {
use crate::query::options::{Field, Op, Predicate};
let result = sample_count_result();
let qs = CountQuerySet::from_result(
&result,
Aggregation::ByCrate,
LineTypes::everything(),
Ordering::default(),
)
.filter(&[Predicate::new(Field::Code, Op::Gte, 50)])
.top(1);
let table = LOCTable::from_count_queryset(&qs);
assert_eq!(table.rows.len(), 1);
assert_eq!(table.footer.label, "Total (top 1 of 2 crates)");
}
#[test]
fn test_footer_label_clamps_when_total_items_missing() {
let result = sample_count_result();
let mut qs = CountQuerySet::from_result(
&result,
Aggregation::ByCrate,
LineTypes::everything(),
Ordering::default(),
);
qs.total_items = 0; let table = LOCTable::from_count_queryset(&qs);
assert_eq!(table.rows.len(), 2);
assert_eq!(table.footer.label, "Total (2 crates)");
}
#[test]
fn test_ordering_by_label_ascending() {
let result = sample_count_result();
let qs = CountQuerySet::from_result(
&result,
Aggregation::ByCrate,
LineTypes::everything(),
Ordering::by_label(),
);
let table = LOCTable::from_count_queryset(&qs);
assert_eq!(table.rows[0].label, "alpha");
assert_eq!(table.rows[1].label, "beta");
}
#[test]
fn test_ordering_by_label_descending() {
let result = sample_count_result();
let qs = CountQuerySet::from_result(
&result,
Aggregation::ByCrate,
LineTypes::everything(),
Ordering::by_label().descending(),
);
let table = LOCTable::from_count_queryset(&qs);
assert_eq!(table.rows[0].label, "beta");
assert_eq!(table.rows[1].label, "alpha");
}
#[test]
fn test_ordering_by_code_descending() {
let result = sample_count_result();
let qs = CountQuerySet::from_result(
&result,
Aggregation::ByCrate,
LineTypes::everything(),
Ordering::by_code(), );
let table = LOCTable::from_count_queryset(&qs);
assert_eq!(table.rows[0].label, "beta");
assert_eq!(table.rows[0].values[0], "150");
assert_eq!(table.rows[1].label, "alpha");
assert_eq!(table.rows[1].values[0], "50");
}
#[test]
fn test_ordering_by_code_ascending() {
let result = sample_count_result();
let qs = CountQuerySet::from_result(
&result,
Aggregation::ByCrate,
LineTypes::everything(),
Ordering::by_code().ascending(),
);
let table = LOCTable::from_count_queryset(&qs);
assert_eq!(table.rows[0].label, "alpha");
assert_eq!(table.rows[1].label, "beta");
}
#[test]
fn test_ordering_by_total_descending() {
let result = sample_count_result();
let qs = CountQuerySet::from_result(
&result,
Aggregation::ByCrate,
LineTypes::everything(),
Ordering::by_total(),
);
let table = LOCTable::from_count_queryset(&qs);
assert_eq!(table.rows[0].label, "beta");
assert_eq!(table.rows[1].label, "alpha");
}
#[test]
fn test_format_diff_value() {
assert_eq!(
format_diff_value(10, 5),
"[additions]+10[/additions]/[deletions]-5[/deletions]/5"
);
assert_eq!(
format_diff_value(5, 10),
"[additions]+5[/additions]/[deletions]-10[/deletions]/-5"
);
assert_eq!(
format_diff_value(0, 0),
"[additions]+0[/additions]/[deletions]-0[/deletions]/0"
);
}
}