onefetch 2.27.1

Command-line Git information tool
Documentation
use super::utils::info_field::InfoField;
use crate::{cli::NumberSeparator, info::utils::format_number};
use anyhow::Result;
use gix::bstr::BString;
use globset::{Glob, GlobSetBuilder};
use serde::Serialize;
use std::{collections::HashMap, fmt::Write};

#[derive(Serialize, Clone, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct FileChurn {
    pub file_path: String,
    pub nbr_of_commits: usize,
    #[serde(skip_serializing)]
    number_separator: NumberSeparator,
}

impl FileChurn {
    pub fn new(
        file_path: String,
        nbr_of_commits: usize,
        number_separator: NumberSeparator,
    ) -> Self {
        Self {
            file_path,
            nbr_of_commits,
            number_separator,
        }
    }
}

impl std::fmt::Display for FileChurn {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        write!(
            f,
            "{} {}",
            shorten_file_path(&self.file_path, 2),
            format_number(&self.nbr_of_commits, self.number_separator)
        )
    }
}

#[derive(Serialize)]
pub struct ChurnInfo {
    pub file_churns: Vec<FileChurn>,
    pub churn_pool_size: usize,
}

impl ChurnInfo {
    pub fn new(
        number_of_commits_by_file_path: &HashMap<BString, usize>,
        churn_pool_size: usize,
        number_of_file_churns_to_display: usize,
        globs_to_exclude: &[String],
        number_separator: NumberSeparator,
    ) -> Result<Self> {
        let file_churns = compute_file_churns(
            number_of_commits_by_file_path,
            number_of_file_churns_to_display,
            globs_to_exclude,
            number_separator,
        )?;

        Ok(Self {
            file_churns,
            churn_pool_size,
        })
    }
}

fn compute_file_churns(
    number_of_commits_by_file_path: &HashMap<BString, usize>,
    number_of_file_churns_to_display: usize,
    globs_to_exclude: &[String],
    number_separator: NumberSeparator,
) -> Result<Vec<FileChurn>> {
    let mut builder = GlobSetBuilder::new();
    for glob in globs_to_exclude {
        builder.add(Glob::new(glob)?);
    }
    let glob_set = builder.build()?;
    let mut number_of_commits_by_file_path_sorted = Vec::from_iter(number_of_commits_by_file_path);

    number_of_commits_by_file_path_sorted
        .sort_by(|(_, a_count), (_, b_count)| b_count.cmp(a_count));

    Ok(number_of_commits_by_file_path_sorted
        .into_iter()
        .filter_map(|(file_path, nbr_of_commits)| {
            if glob_set.is_match(file_path.to_string()) {
                None
            } else {
                Some(FileChurn::new(
                    file_path.to_string(),
                    *nbr_of_commits,
                    number_separator,
                ))
            }
        })
        .take(number_of_file_churns_to_display)
        .collect())
}

impl std::fmt::Display for ChurnInfo {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        let mut churn_info = String::new();

        let pad = self.title().len() + 2;

        for (i, file_churn) in self.file_churns.iter().enumerate() {
            if i == 0 {
                write!(churn_info, "{file_churn}")?;
            } else {
                write!(churn_info, "\n{:<width$}{}", "", file_churn, width = pad)?;
            }
        }

        write!(f, "{churn_info}")
    }
}

#[typetag::serialize]
impl InfoField for ChurnInfo {
    fn value(&self) -> String {
        self.to_string()
    }

    fn title(&self) -> String {
        format!("Churn ({})", self.churn_pool_size)
    }
}

fn shorten_file_path(file_path: &str, depth: usize) -> String {
    let components: Vec<&str> = file_path.split('/').collect();

    if depth == 0 || components.len() <= depth {
        return file_path.to_string();
    }

    let truncated_components: Vec<&str> = components
        .iter()
        .skip(components.len() - depth)
        .copied()
        .collect();

    format!("\u{2026}/{}", truncated_components.join("/"))
}

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

    #[test]
    fn test_display_file_churn() {
        let file_churn = FileChurn::new("path/to/file.txt".into(), 50, NumberSeparator::Plain);

        assert_eq!(file_churn.to_string(), "\u{2026}/to/file.txt 50");
    }

    #[test]
    fn test_churn_info_value_with_two_file_churns() {
        let file_churn_1 = FileChurn::new("path/to/file.txt".into(), 50, NumberSeparator::Plain);
        let file_churn_2 = FileChurn::new("file_2.txt".into(), 30, NumberSeparator::Plain);

        let churn_info = ChurnInfo {
            file_churns: vec![file_churn_1, file_churn_2],
            churn_pool_size: 5,
        };

        assert!(
            churn_info
                .value()
                .contains(&"\u{2026}/to/file.txt 50".to_string())
        );

        assert!(churn_info.value().contains(&"file_2.txt 30".to_string()));
    }

    #[test]
    fn test_truncate_file_path() {
        assert_eq!(shorten_file_path("path/to/file.txt", 3), "path/to/file.txt");
        assert_eq!(shorten_file_path("another/file.txt", 2), "another/file.txt");
        assert_eq!(shorten_file_path("file.txt", 1), "file.txt");
        assert_eq!(
            shorten_file_path("path/to/file.txt", 2),
            "\u{2026}/to/file.txt"
        );
        assert_eq!(
            shorten_file_path("another/file.txt", 1),
            "\u{2026}/file.txt"
        );
        assert_eq!(shorten_file_path("file.txt", 0), "file.txt");
    }

    #[test]
    fn test_compute_file_churns() -> Result<()> {
        let mut number_of_commits_by_file_path = HashMap::new();
        number_of_commits_by_file_path.insert("path/to/file1.txt".into(), 2);
        number_of_commits_by_file_path.insert("path/to/file2.txt".into(), 5);
        number_of_commits_by_file_path.insert("path/to/file3.txt".into(), 3);
        number_of_commits_by_file_path.insert("path/to/file4.txt".into(), 7);
        number_of_commits_by_file_path.insert("foo/x/y/file.txt".into(), 70);
        number_of_commits_by_file_path.insert("foo/x/file.txt".into(), 10);

        let number_of_file_churns_to_display = 3;
        let number_separator = NumberSeparator::Comma;
        let globs_to_exclude = vec![
            "foo/**/file.txt".to_string(),
            "path/to/file2.txt".to_string(),
        ];
        let actual = compute_file_churns(
            &number_of_commits_by_file_path,
            number_of_file_churns_to_display,
            &globs_to_exclude,
            number_separator,
        )?;
        let expected = vec![
            FileChurn::new(String::from("path/to/file4.txt"), 7, number_separator),
            FileChurn::new(String::from("path/to/file3.txt"), 3, number_separator),
            FileChurn::new(String::from("path/to/file1.txt"), 2, number_separator),
        ];
        assert_eq!(actual, expected);
        Ok(())
    }
}