cargo-port 0.1.4

A TUI for inspecting and managing Rust projects
use unicode_width::UnicodeWidthStr;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum StatContext {
    Stats,
    NonCacheableReasons,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) enum ParsedStatLine {
    Field {
        label: String,
        value: String,
    },
    Subheader {
        text:    String,
        context: StatContext,
    },
    Text(String),
}

pub(super) fn parse_stat_lines(raw_lines: &[String]) -> Vec<ParsedStatLine> {
    let mut context = StatContext::Stats;
    raw_lines
        .iter()
        .map(|line| parse_stat_line(line, &mut context))
        .collect()
}

fn parse_stat_line(text: &str, context: &mut StatContext) -> ParsedStatLine {
    let trimmed = text.trim();
    if trimmed.is_empty() {
        return ParsedStatLine::Text(String::new());
    }
    if trimmed == "Non-cacheable reasons:" {
        return ParsedStatLine::Subheader {
            text:    trimmed.to_string(),
            context: StatContext::NonCacheableReasons,
        };
    }
    if let Some((label, value)) = split_aligned_stat(trimmed) {
        if label == "Cache location" {
            *context = StatContext::Stats;
        }
        let (label, value) = normalize_stat_field(label, value, *context);
        return ParsedStatLine::Field { label, value };
    }
    if trimmed.ends_with(':') {
        return ParsedStatLine::Subheader {
            text:    trimmed.to_string(),
            context: StatContext::Stats,
        };
    }
    ParsedStatLine::Text(trimmed.to_string())
}

fn normalize_stat_field(label: &str, value: &str, context: StatContext) -> (String, String) {
    if context == StatContext::NonCacheableReasons {
        return (normalize_reason_label(label), value.to_string());
    }
    if label == "Cache location" {
        return normalize_cache_location(value);
    }
    (label.to_string(), value.to_string())
}

fn normalize_reason_label(label: &str) -> String {
    match label {
        "-" => "(no reason reported)".to_string(),
        "-o" => "compiler flag -o".to_string(),
        _ => label.to_string(),
    }
}

fn normalize_cache_location(value: &str) -> (String, String) {
    const LOCAL_DISK_PREFIX: &str = "Local disk:";
    let value = value.trim();
    if let Some(path) = value.strip_prefix(LOCAL_DISK_PREFIX) {
        return (
            "Cache location".to_string(),
            path.trim().trim_matches('"').to_string(),
        );
    }
    ("Cache location".to_string(), value.to_string())
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) struct ValueAlignment {
    numeric_left_width: usize,
}

impl ValueAlignment {
    pub(super) fn for_lines(lines: &[ParsedStatLine]) -> Self {
        let numeric_left_width = lines
            .iter()
            .filter_map(|line| match line {
                ParsedStatLine::Field { value, .. } => NumericValue::parse(value),
                ParsedStatLine::Subheader { .. } | ParsedStatLine::Text(_) => None,
            })
            .map(NumericValue::left_width)
            .max()
            .unwrap_or(0);
        Self { numeric_left_width }
    }

    pub(super) fn format(self, value: &str) -> String {
        let Some(numeric) = NumericValue::parse(value) else {
            return value.to_string();
        };
        let leading = self.numeric_left_width.saturating_sub(numeric.left_width());
        format!(
            "{}{}{}{}",
            " ".repeat(leading),
            numeric.sign,
            numeric.integer,
            numeric.rest
        )
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct NumericValue<'a> {
    sign:    &'a str,
    integer: &'a str,
    rest:    &'a str,
}

impl<'a> NumericValue<'a> {
    fn parse(value: &'a str) -> Option<Self> {
        let value = value.trim();
        let mut pos = 0;
        let sign = if value.starts_with('-') || value.starts_with('+') {
            pos = 1;
            &value[..1]
        } else {
            ""
        };
        let int_start = pos;
        while let Some(ch) = value[pos..].chars().next()
            && ch.is_ascii_digit()
        {
            pos += ch.len_utf8();
        }
        if pos == int_start {
            return None;
        }
        let int_end = pos;
        if value[pos..].starts_with('.') {
            pos += 1;
            let frac_start = pos;
            while let Some(ch) = value[pos..].chars().next()
                && ch.is_ascii_digit()
            {
                pos += ch.len_utf8();
            }
            if pos == frac_start {
                return None;
            }
        }
        if !value[pos..].is_empty() && !value[pos..].starts_with(char::is_whitespace) {
            return None;
        }
        Some(Self {
            sign,
            integer: &value[int_start..int_end],
            rest: &value[int_end..],
        })
    }

    fn left_width(self) -> usize { self.sign.width() + self.integer.width() }
}

fn split_aligned_stat(text: &str) -> Option<(&str, &str)> {
    let mut gap_start = None;
    let mut gap_len = 0;
    for (idx, ch) in text.char_indices() {
        if ch.is_whitespace() {
            gap_start.get_or_insert(idx);
            gap_len += 1;
            continue;
        }
        if gap_len >= 2 {
            let start = gap_start?;
            let label = text[..start].trim_end();
            let value = text[idx..].trim();
            if !label.is_empty() && !value.is_empty() {
                return Some((label, value));
            }
        }
        gap_start = None;
        gap_len = 0;
    }
    None
}

#[cfg(test)]
#[allow(
    clippy::expect_used,
    clippy::unwrap_used,
    clippy::panic,
    reason = "tests should panic on unexpected values"
)]
mod tests {
    use super::*;

    fn parse_stats(text: &str) -> ParsedStatLine { parse_stat_line(text, &mut StatContext::Stats) }

    fn parse_reason(text: &str) -> ParsedStatLine {
        parse_stat_line(text, &mut StatContext::NonCacheableReasons)
    }

    #[test]
    fn parse_stat_line_preserves_percent_values() {
        assert_eq!(
            parse_stats("Cache hits rate (Rust)        78.20 %"),
            ParsedStatLine::Field {
                label: "Cache hits rate (Rust)".to_string(),
                value: "78.20 %".to_string(),
            },
        );
    }

    #[test]
    fn parse_stat_line_uses_aligned_value_column() {
        assert_eq!(
            parse_stats("Compile requests              2090"),
            ParsedStatLine::Field {
                label: "Compile requests".to_string(),
                value: "2090".to_string(),
            },
        );
    }

    #[test]
    fn parse_stat_line_keeps_units_with_values() {
        assert_eq!(
            parse_stats("Average cache write           0.001 s"),
            ParsedStatLine::Field {
                label: "Average cache write".to_string(),
                value: "0.001 s".to_string(),
            },
        );
        assert_eq!(
            parse_stats("Cache size                    31 GiB"),
            ParsedStatLine::Field {
                label: "Cache size".to_string(),
                value: "31 GiB".to_string(),
            },
        );
    }

    #[test]
    fn parse_stat_line_keeps_location_kind_with_value() {
        assert_eq!(
            parse_stats(
                "Cache location                Local disk: \"/Users/natemccoy/Library/Caches/Mozilla.sccache\"",
            ),
            ParsedStatLine::Field {
                label: "Cache location".to_string(),
                value: "/Users/natemccoy/Library/Caches/Mozilla.sccache".to_string(),
            },
        );
    }

    #[test]
    fn parse_stat_line_treats_reason_header_as_subheader() {
        assert_eq!(
            parse_stats("Non-cacheable reasons:"),
            ParsedStatLine::Subheader {
                text:    "Non-cacheable reasons:".to_string(),
                context: StatContext::NonCacheableReasons,
            },
        );
    }

    #[test]
    fn parse_stat_line_explains_raw_reason_keys() {
        assert_eq!(
            parse_reason("-                                   297"),
            ParsedStatLine::Field {
                label: "(no reason reported)".to_string(),
                value: "297".to_string(),
            },
        );
        assert_eq!(
            parse_reason("-o                                    6"),
            ParsedStatLine::Field {
                label: "compiler flag -o".to_string(),
                value: "6".to_string(),
            },
        );
    }

    #[test]
    fn value_alignment_lines_up_numeric_columns() {
        let lines = vec![
            ParsedStatLine::Field {
                label: "Cache misses".to_string(),
                value: "184".to_string(),
            },
            ParsedStatLine::Field {
                label: "Cache hits rate".to_string(),
                value: "72.42 %".to_string(),
            },
            ParsedStatLine::Field {
                label: "Cache timeouts".to_string(),
                value: "0".to_string(),
            },
            ParsedStatLine::Field {
                label: "Compile requests".to_string(),
                value: "2531".to_string(),
            },
            ParsedStatLine::Field {
                label: "Average cache write".to_string(),
                value: "0.001 s".to_string(),
            },
        ];
        let alignment = ValueAlignment::for_lines(&lines);

        assert_eq!(alignment.format("184"), " 184");
        assert_eq!(alignment.format("72.42 %"), "  72.42 %");
        assert_eq!(alignment.format("0"), "   0");
        assert_eq!(alignment.format("2531"), "2531");
        assert_eq!(alignment.format("0.001 s"), "   0.001 s");
    }

    #[test]
    fn value_alignment_leaves_semver_unpadded() {
        let alignment = ValueAlignment {
            numeric_left_width: 4,
        };

        assert_eq!(alignment.format("0.14.0"), "0.14.0");
    }
}