use regex::Regex; use crate::{ config::ModuleConfig, configs::git_metrics::GitMetricsConfig, formatter::string_formatter::StringFormatterError, formatter::StringFormatter, module::Module, }; use super::Context; /// Creates a module with the current added/deleted lines in the git repository at the /// current directory pub fn module<'a>(context: &'a Context) -> Option> { let mut module = context.new_module("git_metrics"); let config: GitMetricsConfig = GitMetricsConfig::try_load(module.config); // As we default to disabled=true, we have to check here after loading our config module, // before it was only checking against whatever is in the config starship.toml if config.disabled { return None; }; let repo = context.get_repo().ok()?; let mut git_args = vec!["diff", "--shortstat"]; if config.ignore_submodules { git_args.push("--ignore-submodules"); } let diff = repo.exec_git(context, &git_args)?.stdout; let stats = GitDiff::parse(&diff); let parsed = StringFormatter::new(config.format).and_then(|formatter| { formatter .map_style(|variable| match variable { "added_style" => Some(Ok(config.added_style)), "deleted_style" => Some(Ok(config.deleted_style)), _ => None, }) .map(|variable| match variable { "added" => GitDiff::get_variable(config.only_nonzero_diffs, stats.added), "deleted" => GitDiff::get_variable(config.only_nonzero_diffs, stats.deleted), _ => None, }) .parse(None, Some(context)) }); module.set_segments(match parsed { Ok(segments) => segments, Err(error) => { log::warn!("Error in module `git_metrics`:\n{}", error); return None; } }); Some(module) } /// Represents the parsed output from a git diff. struct GitDiff<'a> { added: &'a str, deleted: &'a str, } impl<'a> GitDiff<'a> { /// Returns the first capture group given a regular expression and a string. /// If it fails to get the capture group it will return "0". fn get_matched_str(diff: &'a str, re: &Regex) -> &'a str { match re.captures(diff) { Some(caps) => caps.get(1).unwrap().as_str(), _ => "0", } } /// Parses the result of 'git diff --shortstat' as a `GitDiff` struct. pub fn parse(diff: &'a str) -> Self { let added_re = Regex::new(r"(\d+) \w+\(\+\)").unwrap(); let deleted_re = Regex::new(r"(\d+) \w+\(\-\)").unwrap(); Self { added: GitDiff::get_matched_str(diff, &added_re), deleted: GitDiff::get_matched_str(diff, &deleted_re), } } pub fn get_variable( only_nonzero_diffs: bool, changed: &str, ) -> Option> { match only_nonzero_diffs { true => match changed { "0" => None, _ => Some(Ok(changed)), }, false => Some(Ok(changed)), } } } #[cfg(test)] mod tests { use crate::utils::{create_command, write_file}; use std::ffi::OsStr; use std::fs::OpenOptions; use std::io::{self, Error, ErrorKind, Write}; use std::path::Path; use std::process::Stdio; use nu_ansi_term::Color; use crate::test::ModuleRenderer; #[test] fn shows_nothing_on_empty_dir() -> io::Result<()> { let repo_dir = tempfile::tempdir()?; let path = repo_dir.path(); let actual = render_metrics(path); let expected = None; assert_eq!(expected, actual); repo_dir.close() } #[test] fn shows_added_lines() -> io::Result<()> { let repo_dir = create_repo_with_commit()?; let path = repo_dir.path(); let the_file = path.join("the_file"); let mut the_file = OpenOptions::new().append(true).open(the_file)?; writeln!(the_file, "Added line")?; the_file.sync_all()?; let actual = render_metrics(path); let expected = Some(format!("{} ", Color::Green.bold().paint("+1"),)); assert_eq!(expected, actual); repo_dir.close() } #[test] fn shows_deleted_lines() -> io::Result<()> { let repo_dir = create_repo_with_commit()?; let path = repo_dir.path(); let file_path = path.join("the_file"); write_file(file_path, "First Line\nSecond Line\n")?; let actual = render_metrics(path); let expected = Some(format!("{} ", Color::Red.bold().paint("-1"))); assert_eq!(expected, actual); repo_dir.close() } #[test] fn shows_all_changes() -> io::Result<()> { let repo_dir = create_repo_with_commit()?; let path = repo_dir.path(); let file_path = path.join("the_file"); write_file(file_path, "\nSecond Line\n\nModified\nAdded\n")?; let actual = render_metrics(path); let expected = Some(format!( "{} {} ", Color::Green.bold().paint("+4"), Color::Red.bold().paint("-2") )); assert_eq!(expected, actual); repo_dir.close() } #[test] fn shows_nothing_if_no_changes() -> io::Result<()> { let repo_dir = create_repo_with_commit()?; let path = repo_dir.path(); let actual = render_metrics(path); let expected = None; assert_eq!(expected, actual); repo_dir.close() } #[test] fn shows_all_if_only_nonzero_diffs_is_false() -> io::Result<()> { let repo_dir = create_repo_with_commit()?; let path = repo_dir.path(); let the_file = path.join("the_file"); let mut the_file = OpenOptions::new().append(true).open(the_file)?; writeln!(the_file, "Added line")?; the_file.sync_all()?; let actual = ModuleRenderer::new("git_metrics") .config(toml::toml! { [git_metrics] disabled = false only_nonzero_diffs = false }) .path(path) .collect(); let expected = Some(format!( "{} {} ", Color::Green.bold().paint("+1"), Color::Red.bold().paint("-0") )); assert_eq!(expected, actual); repo_dir.close() } #[test] fn shows_all_changes_with_ignored_submodules() -> io::Result<()> { let repo_dir = create_repo_with_commit()?; let path = repo_dir.path(); let file_path = path.join("the_file"); write_file(file_path, "\nSecond Line\n\nModified\nAdded\n")?; let actual = ModuleRenderer::new("git_metrics") .config(toml::toml! { [git_metrics] disabled = false ignore_submodules = true }) .path(path) .collect(); let expected = Some(format!( "{} {} ", Color::Green.bold().paint("+4"), Color::Red.bold().paint("-2") )); assert_eq!(expected, actual); repo_dir.close() } fn render_metrics(path: &Path) -> Option { ModuleRenderer::new("git_metrics") .config(toml::toml! { [git_metrics] disabled = false }) .path(path) .collect() } fn run_git_cmd(args: A, dir: Option<&Path>, should_succeed: bool) -> io::Result<()> where A: IntoIterator, S: AsRef, { let mut command = create_command("git")?; command .args(args) .stdout(Stdio::null()) .stderr(Stdio::null()) .stdin(Stdio::null()); if let Some(dir) = dir { command.current_dir(dir); } let status = command.status()?; if should_succeed && !status.success() { Err(Error::from(ErrorKind::Other)) } else { Ok(()) } } fn create_repo_with_commit() -> io::Result { let repo_dir = tempfile::tempdir()?; let path = repo_dir.path(); let file = repo_dir.path().join("the_file"); // Initialize a new git repo run_git_cmd( [ "init", "--quiet", path.to_str().expect("Path was not UTF-8"), ], None, true, )?; // Set local author info run_git_cmd( ["config", "--local", "user.email", "starship@example.com"], Some(path), true, )?; run_git_cmd( ["config", "--local", "user.name", "starship"], Some(path), true, )?; // Ensure on the expected branch. // If build environment has `init.defaultBranch` global set // it will default to an unknown branch, so need to make & change branch run_git_cmd( ["checkout", "-b", "master"], Some(path), // command expected to fail if already on the expected branch false, )?; // Write a file on master and commit it write_file(file, "First Line\nSecond Line\nThird Line\n")?; run_git_cmd(["add", "the_file"], Some(path), true)?; run_git_cmd( ["commit", "--message", "Commit A", "--no-gpg-sign"], Some(path), true, )?; Ok(repo_dir) } }