Skip to main content

rustloclib/output/
table.rs

1//! Table-ready data structures for LOC output.
2//!
3//! This module provides `LOCTable`, a presentation-ready data structure
4//! that can be directly consumed by templates or serialized to JSON.
5//!
6//! The data flow is:
7//! 1. Raw Data (CountResult, DiffResult)
8//! 2. QuerySet (filtered, aggregated, sorted)
9//! 3. LOCTable (formatted strings for display)
10//!
11//! LOCTable is a pure presentation layer - it only formats data, no filtering
12//! or sorting logic. All computation happens in the QuerySet layer.
13
14use serde::{Deserialize, Serialize};
15
16use crate::data::diff::LocsDiff;
17use crate::data::stats::Locs;
18use crate::query::options::Aggregation;
19use crate::query::queryset::{CountQuerySet, DiffQuerySet};
20
21/// A single row in the table (data row or footer).
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct TableRow {
24    /// Row label (file path, crate name, "Total (N files)", etc.)
25    pub label: String,
26    /// Values for each category column (as strings, ready for display)
27    pub values: Vec<String>,
28}
29
30/// Table-ready LOC data.
31///
32/// This is the final data structure before presentation. Templates
33/// iterate over headers/rows/footer and apply formatting - no computation.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct LOCTable {
36    /// Optional title (e.g., "Diff: HEAD~5 → HEAD")
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub title: Option<String>,
39    /// Column headers: [label_header, line_type1, line_type2, ..., Total]
40    pub headers: Vec<String>,
41    /// Data rows
42    pub rows: Vec<TableRow>,
43    /// Summary/footer row
44    pub footer: TableRow,
45    /// Optional summary row (e.g., aggregate additions/removals for diffs)
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub summary: Option<TableRow>,
48    /// Optional non-Rust changes summary (e.g., "Non-Rust changes: +10/-5/5 net")
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub non_rust_summary: Option<String>,
51    /// Optional legend text below the table (e.g., "+added / -removed / net")
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub legend: Option<String>,
54}
55
56impl LOCTable {
57    /// Create a LOCTable from a CountQuerySet.
58    ///
59    /// The QuerySet already contains filtered, aggregated, and sorted data.
60    /// This method just formats it into displayable strings.
61    pub fn from_count_queryset(qs: &CountQuerySet) -> Self {
62        let headers = build_headers(&qs.aggregation, &qs.line_types);
63        let rows: Vec<TableRow> = qs
64            .items
65            .iter()
66            .map(|item| TableRow {
67                label: item.label.clone(),
68                values: format_locs(&item.stats, &qs.line_types),
69            })
70            .collect();
71        let footer = TableRow {
72            label: build_footer_label(&qs.aggregation, rows.len(), qs.file_count),
73            values: format_locs(&qs.total, &qs.line_types),
74        };
75
76        LOCTable {
77            title: None,
78            headers,
79            rows,
80            footer,
81            summary: None,
82            non_rust_summary: None,
83            legend: None,
84        }
85    }
86
87    /// Create a LOCTable from a DiffQuerySet.
88    ///
89    /// The QuerySet already contains filtered, aggregated, and sorted data.
90    /// This method just formats it into displayable strings with diff notation.
91    pub fn from_diff_queryset(qs: &DiffQuerySet) -> Self {
92        let headers = build_headers(&qs.aggregation, &qs.line_types);
93        let rows: Vec<TableRow> = qs
94            .items
95            .iter()
96            .map(|item| TableRow {
97                label: item.label.clone(),
98                values: format_locs_diff(&item.stats, &qs.line_types),
99            })
100            .collect();
101        let footer = TableRow {
102            label: build_footer_label(&qs.aggregation, rows.len(), qs.file_count),
103            values: format_locs_diff(&qs.total, &qs.line_types),
104        };
105        let title = Some(format!("Diff: {} → {}", qs.from_commit, qs.to_commit));
106
107        // Build aggregate summary row: total additions / removals / net
108        let lt = LineTypesView::from(&qs.line_types);
109        let total_added = filtered_sum(&qs.total.added, &lt);
110        let total_removed = filtered_sum(&qs.total.removed, &lt);
111        let net = total_added as i64 - total_removed as i64;
112        let summary = TableRow {
113            label: String::new(),
114            values: vec![format!(
115                "[additions]+{}[/additions] / [deletions]-{}[/deletions] / {} net",
116                total_added, total_removed, net
117            )],
118        };
119
120        let non_rust_summary = if qs.non_rust_added > 0 || qs.non_rust_removed > 0 {
121            let nr_net = qs.non_rust_added as i64 - qs.non_rust_removed as i64;
122            Some(format!(
123                    "Non-Rust changes: [additions]+{}[/additions] / [deletions]-{}[/deletions] / {} net",
124                    qs.non_rust_added, qs.non_rust_removed, nr_net
125                ))
126        } else {
127            None
128        };
129
130        LOCTable {
131            title,
132            headers,
133            rows,
134            footer,
135            summary: Some(summary),
136            non_rust_summary,
137            legend: Some("(+added / -removed / net)".to_string()),
138        }
139    }
140}
141
142/// Line types configuration for header building.
143/// This is a local copy of the fields we need to avoid circular imports.
144struct LineTypesView {
145    code: bool,
146    tests: bool,
147    examples: bool,
148    docs: bool,
149    comments: bool,
150    blanks: bool,
151    total: bool,
152}
153
154impl From<&crate::query::options::LineTypes> for LineTypesView {
155    fn from(lt: &crate::query::options::LineTypes) -> Self {
156        LineTypesView {
157            code: lt.code,
158            tests: lt.tests,
159            examples: lt.examples,
160            docs: lt.docs,
161            comments: lt.comments,
162            blanks: lt.blanks,
163            total: lt.total,
164        }
165    }
166}
167
168/// Build footer label based on aggregation level.
169///
170/// For Total aggregation (no item rows), reports the total file count.
171/// For other aggregations, reports the number of displayed items with the matching unit.
172fn build_footer_label(aggregation: &Aggregation, items_count: usize, file_count: usize) -> String {
173    match aggregation {
174        Aggregation::Total => format!("Total ({} files)", file_count),
175        Aggregation::ByCrate => format!("Total ({} crates)", items_count),
176        Aggregation::ByModule => format!("Total ({} modules)", items_count),
177        Aggregation::ByFile => format!("Total ({} files)", items_count),
178    }
179}
180
181/// Build column headers based on aggregation level and enabled line types.
182fn build_headers(
183    aggregation: &Aggregation,
184    line_types: &crate::query::options::LineTypes,
185) -> Vec<String> {
186    let label_header = match aggregation {
187        Aggregation::Total => "Name".to_string(),
188        Aggregation::ByCrate => "Crate".to_string(),
189        Aggregation::ByModule => "Module".to_string(),
190        Aggregation::ByFile => "File".to_string(),
191    };
192
193    let lt = LineTypesView::from(line_types);
194    let mut headers = vec![label_header];
195
196    if lt.code {
197        headers.push("Code".to_string());
198    }
199    if lt.tests {
200        headers.push("Tests".to_string());
201    }
202    if lt.examples {
203        headers.push("Examples".to_string());
204    }
205    if lt.docs {
206        headers.push("Docs".to_string());
207    }
208    if lt.comments {
209        headers.push("Comments".to_string());
210    }
211    if lt.blanks {
212        headers.push("Blanks".to_string());
213    }
214    if lt.total {
215        headers.push("Total".to_string());
216    }
217
218    headers
219}
220
221/// Sum the enabled line types from a Locs struct.
222fn filtered_sum(locs: &Locs, lt: &LineTypesView) -> u64 {
223    let mut sum = 0;
224    if lt.code {
225        sum += locs.code;
226    }
227    if lt.tests {
228        sum += locs.tests;
229    }
230    if lt.examples {
231        sum += locs.examples;
232    }
233    if lt.docs {
234        sum += locs.docs;
235    }
236    if lt.comments {
237        sum += locs.comments;
238    }
239    if lt.blanks {
240        sum += locs.blanks;
241    }
242    sum
243}
244
245/// Format Locs values as strings for display.
246fn format_locs(locs: &Locs, line_types: &crate::query::options::LineTypes) -> Vec<String> {
247    let lt = LineTypesView::from(line_types);
248    let mut values = Vec::new();
249
250    if lt.code {
251        values.push(locs.code.to_string());
252    }
253    if lt.tests {
254        values.push(locs.tests.to_string());
255    }
256    if lt.examples {
257        values.push(locs.examples.to_string());
258    }
259    if lt.docs {
260        values.push(locs.docs.to_string());
261    }
262    if lt.comments {
263        values.push(locs.comments.to_string());
264    }
265    if lt.blanks {
266        values.push(locs.blanks.to_string());
267    }
268    if lt.total {
269        // Use the precomputed all field
270        values.push(locs.total.to_string());
271    }
272
273    values
274}
275
276/// Format a diff value as "+added/-removed/net" with standout style tags.
277fn format_diff_value(added: u64, removed: u64) -> String {
278    let net = added as i64 - removed as i64;
279    format!(
280        "[additions]+{}[/additions]/[deletions]-{}[/deletions]/{}",
281        added, removed, net
282    )
283}
284
285/// Format LocsDiff values as strings for display.
286fn format_locs_diff(diff: &LocsDiff, line_types: &crate::query::options::LineTypes) -> Vec<String> {
287    let lt = LineTypesView::from(line_types);
288    let mut values = Vec::new();
289
290    if lt.code {
291        values.push(format_diff_value(diff.added.code, diff.removed.code));
292    }
293    if lt.tests {
294        values.push(format_diff_value(diff.added.tests, diff.removed.tests));
295    }
296    if lt.examples {
297        values.push(format_diff_value(
298            diff.added.examples,
299            diff.removed.examples,
300        ));
301    }
302    if lt.docs {
303        values.push(format_diff_value(diff.added.docs, diff.removed.docs));
304    }
305    if lt.comments {
306        values.push(format_diff_value(
307            diff.added.comments,
308            diff.removed.comments,
309        ));
310    }
311    if lt.blanks {
312        values.push(format_diff_value(diff.added.blanks, diff.removed.blanks));
313    }
314    if lt.total {
315        // Use the precomputed all fields
316        values.push(format_diff_value(diff.added.total, diff.removed.total));
317    }
318
319    values
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::data::counter::CountResult;
326    use crate::data::stats::CrateStats;
327    use crate::query::options::{LineTypes, Ordering};
328    use std::path::PathBuf;
329
330    fn sample_locs(code: u64, tests: u64) -> Locs {
331        Locs {
332            code,
333            tests,
334            examples: 0,
335            docs: 0,
336            comments: 0,
337            blanks: 0,
338            total: code + tests,
339        }
340    }
341
342    fn sample_count_result() -> CountResult {
343        CountResult {
344            root: PathBuf::from("/workspace"),
345            file_count: 4,
346            total: sample_locs(200, 100),
347            crates: vec![
348                CrateStats {
349                    name: "alpha".to_string(),
350                    path: PathBuf::from("/alpha"),
351                    stats: sample_locs(50, 25),
352                    files: vec![],
353                },
354                CrateStats {
355                    name: "beta".to_string(),
356                    path: PathBuf::from("/beta"),
357                    stats: sample_locs(150, 75),
358                    files: vec![],
359                },
360            ],
361            files: vec![],
362            modules: vec![],
363        }
364    }
365
366    #[test]
367    fn test_headers_by_crate() {
368        let headers = build_headers(&Aggregation::ByCrate, &LineTypes::everything());
369        assert_eq!(headers[0], "Crate");
370        assert_eq!(headers[1], "Code");
371        assert_eq!(headers[2], "Tests");
372        assert_eq!(headers[3], "Examples");
373        assert_eq!(headers[4], "Docs");
374        assert_eq!(headers[5], "Comments");
375        assert_eq!(headers[6], "Blanks");
376        assert_eq!(headers[7], "Total");
377    }
378
379    #[test]
380    fn test_headers_filtered_line_types() {
381        let line_types = LineTypes::new().with_code();
382        let headers = build_headers(&Aggregation::ByFile, &line_types);
383        assert_eq!(headers.len(), 3); // File, Code, All
384        assert_eq!(headers[0], "File");
385        assert_eq!(headers[1], "Code");
386        assert_eq!(headers[2], "Total");
387    }
388
389    #[test]
390    fn test_format_locs() {
391        let locs = sample_locs(100, 50);
392        let values = format_locs(&locs, &LineTypes::everything());
393        assert_eq!(values[0], "100"); // Code
394        assert_eq!(values[1], "50"); // Tests
395        assert_eq!(values[2], "0"); // Examples
396        assert_eq!(values[3], "0"); // Docs
397        assert_eq!(values[4], "0"); // Comments
398        assert_eq!(values[5], "0"); // Blanks
399        assert_eq!(values[6], "150"); // All
400    }
401
402    #[test]
403    fn test_loc_table_from_queryset() {
404        let result = sample_count_result();
405        let qs = CountQuerySet::from_result(
406            &result,
407            Aggregation::ByCrate,
408            LineTypes::everything(),
409            Ordering::default(),
410        );
411        let table = LOCTable::from_count_queryset(&qs);
412
413        assert!(table.title.is_none());
414        assert_eq!(table.headers[0], "Crate");
415        assert_eq!(table.rows.len(), 2);
416        // Default ordering is by label ascending: alpha before beta
417        assert_eq!(table.rows[0].label, "alpha");
418        assert_eq!(table.rows[1].label, "beta");
419        assert_eq!(table.footer.label, "Total (2 crates)");
420    }
421
422    #[test]
423    fn test_ordering_by_label_ascending() {
424        let result = sample_count_result();
425        let qs = CountQuerySet::from_result(
426            &result,
427            Aggregation::ByCrate,
428            LineTypes::everything(),
429            Ordering::by_label(),
430        );
431        let table = LOCTable::from_count_queryset(&qs);
432
433        assert_eq!(table.rows[0].label, "alpha");
434        assert_eq!(table.rows[1].label, "beta");
435    }
436
437    #[test]
438    fn test_ordering_by_label_descending() {
439        let result = sample_count_result();
440        let qs = CountQuerySet::from_result(
441            &result,
442            Aggregation::ByCrate,
443            LineTypes::everything(),
444            Ordering::by_label().descending(),
445        );
446        let table = LOCTable::from_count_queryset(&qs);
447
448        assert_eq!(table.rows[0].label, "beta");
449        assert_eq!(table.rows[1].label, "alpha");
450    }
451
452    #[test]
453    fn test_ordering_by_code_descending() {
454        let result = sample_count_result();
455        let qs = CountQuerySet::from_result(
456            &result,
457            Aggregation::ByCrate,
458            LineTypes::everything(),
459            Ordering::by_code(), // Descending by default
460        );
461        let table = LOCTable::from_count_queryset(&qs);
462
463        // beta has 150 code, alpha has 50
464        assert_eq!(table.rows[0].label, "beta");
465        assert_eq!(table.rows[0].values[0], "150");
466        assert_eq!(table.rows[1].label, "alpha");
467        assert_eq!(table.rows[1].values[0], "50");
468    }
469
470    #[test]
471    fn test_ordering_by_code_ascending() {
472        let result = sample_count_result();
473        let qs = CountQuerySet::from_result(
474            &result,
475            Aggregation::ByCrate,
476            LineTypes::everything(),
477            Ordering::by_code().ascending(),
478        );
479        let table = LOCTable::from_count_queryset(&qs);
480
481        // alpha has 50 code, beta has 150
482        assert_eq!(table.rows[0].label, "alpha");
483        assert_eq!(table.rows[1].label, "beta");
484    }
485
486    #[test]
487    fn test_ordering_by_total_descending() {
488        let result = sample_count_result();
489        let qs = CountQuerySet::from_result(
490            &result,
491            Aggregation::ByCrate,
492            LineTypes::everything(),
493            Ordering::by_total(),
494        );
495        let table = LOCTable::from_count_queryset(&qs);
496
497        // beta has 225 total, alpha has 75
498        assert_eq!(table.rows[0].label, "beta");
499        assert_eq!(table.rows[1].label, "alpha");
500    }
501
502    #[test]
503    fn test_format_diff_value() {
504        assert_eq!(
505            format_diff_value(10, 5),
506            "[additions]+10[/additions]/[deletions]-5[/deletions]/5"
507        );
508        assert_eq!(
509            format_diff_value(5, 10),
510            "[additions]+5[/additions]/[deletions]-10[/deletions]/-5"
511        );
512        assert_eq!(
513            format_diff_value(0, 0),
514            "[additions]+0[/additions]/[deletions]-0[/deletions]/0"
515        );
516    }
517}