xwc 0.7.0

A small wc-style command line tool
Documentation
use std::time::Duration;

use bytesize::ByteSize;

use crate::{Config, Counts};

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct OutputRow<'a> {
    pub counts: Counts,
    pub duration: Option<Duration>,
    pub label: Option<&'a str>,
}

pub fn render_rows<'a>(
    config: &Config,
    rows: impl IntoIterator<Item = OutputRow<'a>>,
) -> Vec<Vec<String>> {
    let rows = rows.into_iter().collect::<Vec<_>>();
    let has_labels = rows.iter().any(|row| row.label.is_some());
    let mut rendered_rows = Vec::new();

    if config.show_headings {
        rendered_rows.push(headings(config, has_labels));
    }

    for row in rows {
        rendered_rows.push(fields(config, row));
    }

    rendered_rows
}

pub fn column_widths(rows: &[Vec<String>]) -> Vec<usize> {
    let column_count = rows.iter().map(Vec::len).max().unwrap_or(0);
    let mut widths = vec![0; column_count];

    for row in rows {
        for (width, field) in widths.iter_mut().zip(row) {
            *width = (*width).max(field.len());
        }
    }

    widths
}

pub fn format_byte_count(bytes: u64, human_readable: bool) -> String {
    if human_readable {
        ByteSize::b(bytes).display().iec_short().to_string()
    } else {
        bytes.to_string()
    }
}

pub fn format_duration(duration: Duration) -> String {
    let nanos = duration.as_nanos();

    if nanos < 1_000 {
        format!("{nanos}ns")
    } else if nanos < 1_000_000 {
        format_tenths(nanos, 1_000, "us")
    } else if nanos < 1_000_000_000 {
        format_tenths(nanos, 1_000_000, "ms")
    } else {
        let mut seconds = u128::from(duration.as_secs());
        let mut millis = u128::from(duration.subsec_nanos()) / 1_000_000;

        if millis == 1_000 {
            seconds += 1;
            millis = 0;
        }

        format!("{seconds}.{millis:03}s")
    }
}

fn format_tenths(nanos: u128, divisor: u128, unit: &str) -> String {
    let tenths = (nanos * 10 + divisor / 2) / divisor;
    let whole = tenths / 10;
    let fraction = tenths % 10;

    format!("{whole}.{fraction}{unit}")
}

fn headings(config: &Config, has_labels: bool) -> Vec<String> {
    let mut fields = Vec::new();

    if config.show_lines {
        fields.push("lines".to_owned());
    }

    if config.show_words {
        fields.push("words".to_owned());
    }

    if config.show_chars {
        fields.push("chars".to_owned());
    }

    if config.show_max_line_length {
        fields.push("max-line".to_owned());
    }

    if config.show_bytes {
        fields.push(byte_heading(config).to_owned());
    }

    if has_labels {
        fields.push("file".to_owned());
    }

    if config.self_profile {
        fields.push("duration".to_owned());
    }

    fields
}

fn fields(config: &Config, row: OutputRow<'_>) -> Vec<String> {
    let mut fields = Vec::new();

    if config.show_lines {
        fields.push(row.counts.lines.to_string());
    }

    if config.show_words {
        fields.push(row.counts.words.to_string());
    }

    if config.show_chars {
        fields.push(row.counts.chars.to_string());
    }

    if config.show_max_line_length {
        fields.push(row.counts.max_line_length.to_string());
    }

    if config.show_bytes {
        fields.push(format_byte_count(row.counts.bytes, config.human_readable));
    }

    if let Some(label) = row.label {
        fields.push(label.to_owned());
    }

    if config.self_profile {
        fields.push(row.duration.map(format_duration).unwrap_or_default());
    }

    fields
}

fn byte_heading(config: &Config) -> &'static str {
    if config.human_readable {
        "size"
    } else {
        "bytes"
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::SortOrder;

    fn default_config() -> Config {
        Config {
            show_lines: true,
            show_words: false,
            show_chars: false,
            show_bytes: true,
            show_max_line_length: false,
            show_headings: true,
            human_readable: false,
            self_profile: false,
            jobs: None,
            sort_by: None,
            sort_order: SortOrder::Asc,
            globs: Vec::new(),
            files: Vec::new(),
        }
    }

    #[test]
    fn renders_default_headings_and_fields() {
        let rendered = render_rows(
            &default_config(),
            [OutputRow {
                counts: Counts {
                    lines: 2,
                    words: 0,
                    chars: 0,
                    bytes: 14,
                    max_line_length: 0,
                },
                duration: None,
                label: None,
            }],
        );

        assert_eq!(
            rendered,
            vec![
                vec!["lines".to_owned(), "bytes".to_owned()],
                vec!["2".to_owned(), "14".to_owned()],
            ]
        );
    }

    #[test]
    fn renders_label_heading_when_any_row_has_label() {
        let rendered = render_rows(
            &default_config(),
            [OutputRow {
                counts: Counts {
                    lines: 2,
                    words: 0,
                    chars: 0,
                    bytes: 14,
                    max_line_length: 0,
                },
                duration: None,
                label: Some("a.txt"),
            }],
        );

        assert_eq!(
            rendered,
            vec![
                vec!["lines".to_owned(), "bytes".to_owned(), "file".to_owned()],
                vec!["2".to_owned(), "14".to_owned(), "a.txt".to_owned()],
            ]
        );
    }

    #[test]
    fn formats_human_readable_bytes() {
        assert_eq!(format_byte_count(1024, true), "1.0K");
        assert_eq!(format_byte_count(1024, false), "1024");
    }

    #[test]
    fn renders_duration_column_when_self_profiled() {
        let mut config = default_config();
        config.self_profile = true;
        let rendered = render_rows(
            &config,
            [OutputRow {
                counts: Counts {
                    lines: 2,
                    words: 0,
                    chars: 0,
                    bytes: 14,
                    max_line_length: 0,
                },
                duration: Some(Duration::from_micros(12)),
                label: Some("a.txt"),
            }],
        );

        assert_eq!(
            rendered,
            vec![
                vec![
                    "lines".to_owned(),
                    "bytes".to_owned(),
                    "file".to_owned(),
                    "duration".to_owned()
                ],
                vec![
                    "2".to_owned(),
                    "14".to_owned(),
                    "a.txt".to_owned(),
                    "12.0us".to_owned()
                ],
            ]
        );
    }
}