diff options
author | Vegard Skui <me@vegardskui.com> | 2023-09-02 09:19:04 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-09-02 09:19:04 +0200 |
commit | e867cda1eb90ba452768bd2e0738afc2fd0db613 (patch) | |
tree | 83cff236c532d2f8bb2fee6a9146b1a4604418e3 /src/modules | |
parent | 91d9053aa4a544ce83f911f83cca4cb25230cb62 (diff) |
feat(fossil_metrics): add fossil_metrics module (#4874)
* feat(fossil_metrics): add fossil_metrics module
* Return early if not in a Fossil check-out
* Add more tests for fossil_metrics
* Move is in Fossil checkout check after module enabled check
* Update type for new toml version
* Update the config file schema
* Rework parsing of fossil diff output
* Fix Fossil check-out detection in subdirectories
* Use regex to only match expected fossil diff output
* Use shared ancestor scanning and fix detection on Windows
* Add note on minimum Fossil version
Diffstat (limited to 'src/modules')
-rw-r--r-- | src/modules/fossil_metrics.rs | 297 | ||||
-rw-r--r-- | src/modules/mod.rs | 3 |
2 files changed, 300 insertions, 0 deletions
diff --git a/src/modules/fossil_metrics.rs b/src/modules/fossil_metrics.rs new file mode 100644 index 000000000..5ec97b6c1 --- /dev/null +++ b/src/modules/fossil_metrics.rs @@ -0,0 +1,297 @@ +use regex::Regex; + +use super::{Context, Module, ModuleConfig}; + +use crate::configs::fossil_metrics::FossilMetricsConfig; +use crate::formatter::StringFormatter; + +/// Creates a module with currently added/deleted lines in the Fossil check-out in the current +/// directory. +pub fn module<'a>(context: &'a Context) -> Option<Module<'a>> { + let mut module = context.new_module("fossil_metrics"); + let config = FossilMetricsConfig::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 checkout_db = if cfg!(windows) { + "_FOSSIL_" + } else { + ".fslckout" + }; + // See if we're in a check-out by scanning upwards for a directory containing the checkout_db file + context + .begin_ancestor_scan() + .set_files(&[checkout_db]) + .scan()?; + + // Read the total number of added and deleted lines from "fossil diff --numstat" + let output = context.exec_cmd("fossil", &["diff", "--numstat"])?.stdout; + let stats = FossilDiff::parse(&output, config.only_nonzero_diffs); + + 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" => Some(Ok(stats.added)), + "deleted" => Some(Ok(stats.deleted)), + _ => None, + }) + .parse(None, Some(context)) + }); + + module.set_segments(match parsed { + Ok(segments) => segments, + Err(error) => { + log::warn!("Error in module `fossil_metrics`:\n{}", error); + return None; + } + }); + + Some(module) +} + +/// Represents the parsed output from a Fossil diff with the --numstat option enabled. +#[derive(Debug, PartialEq)] +struct FossilDiff<'a> { + added: &'a str, + deleted: &'a str, +} + +impl<'a> FossilDiff<'a> { + /// Parses the output of `fossil diff --numstat` as a `FossilDiff` struct. + pub fn parse(diff_numstat: &'a str, only_nonzero_diffs: bool) -> Self { + // Fossil formats the last line of the output as "%10d %10d TOTAL over %d changed files\n" + // where the 1st and 2nd placeholders are the number of added and deleted lines respectively + let re = Regex::new(r"^\s*(\d+)\s+(\d+) TOTAL over \d+ changed files$").unwrap(); + + let (added, deleted) = diff_numstat + .lines() + .last() + .and_then(|s| re.captures(s)) + .and_then(|caps| { + let added = match caps.get(1)?.as_str() { + "0" if only_nonzero_diffs => "", + s => s, + }; + + let deleted = match caps.get(2)?.as_str() { + "0" if only_nonzero_diffs => "", + s => s, + }; + + Some((added, deleted)) + }) + .unwrap_or_default(); + + Self { added, deleted } + } +} + +#[cfg(test)] +mod tests { + use std::io; + use std::path::Path; + + use nu_ansi_term::{Color, Style}; + + use crate::test::{fixture_repo, FixtureProvider, ModuleRenderer}; + + use super::FossilDiff; + + enum Expect<'a> { + Empty, + Added(Option<&'a str>), + AddedStyle(Style), + Deleted(Option<&'a str>), + DeletedStyle(Style), + } + + #[test] + fn show_nothing_on_empty_dir() -> io::Result<()> { + let checkout_dir = tempfile::tempdir()?; + + let actual = ModuleRenderer::new("fossil_metrics") + .path(checkout_dir.path()) + .collect(); + let expected = None; + assert_eq!(expected, actual); + + checkout_dir.close() + } + + #[test] + fn test_fossil_metrics_disabled_per_default() -> io::Result<()> { + let tempdir = fixture_repo(FixtureProvider::Fossil)?; + let checkout_dir = tempdir.path(); + expect_fossil_metrics_with_config( + checkout_dir, + Some(toml::toml! { + // no "disabled=false" in config! + [fossil_metrics] + only_nonzero_diffs = false + }), + &[Expect::Empty], + ); + tempdir.close() + } + + #[test] + fn test_fossil_metrics_autodisabled() -> io::Result<()> { + let tempdir = tempfile::tempdir()?; + expect_fossil_metrics_with_config(tempdir.path(), None, &[Expect::Empty]); + tempdir.close() + } + + #[test] + fn test_fossil_metrics() -> io::Result<()> { + let tempdir = fixture_repo(FixtureProvider::Fossil)?; + let checkout_dir = tempdir.path(); + expect_fossil_metrics_with_config( + checkout_dir, + None, + &[Expect::Added(Some("3")), Expect::Deleted(Some("2"))], + ); + tempdir.close() + } + + #[test] + fn test_fossil_metrics_subdir() -> io::Result<()> { + let tempdir = fixture_repo(FixtureProvider::Fossil)?; + let checkout_dir = tempdir.path(); + expect_fossil_metrics_with_config( + &checkout_dir.join("subdir"), + None, + &[Expect::Added(Some("3")), Expect::Deleted(Some("2"))], + ); + tempdir.close() + } + + #[test] + fn test_fossil_metrics_configured() -> io::Result<()> { + let tempdir = fixture_repo(FixtureProvider::Fossil)?; + let checkout_dir = tempdir.path(); + expect_fossil_metrics_with_config( + checkout_dir, + Some(toml::toml! { + [fossil_metrics] + added_style = "underline blue" + deleted_style = "underline purple" + disabled = false + }), + &[ + Expect::Added(Some("3")), + Expect::AddedStyle(Color::Blue.underline()), + Expect::Deleted(Some("2")), + Expect::DeletedStyle(Color::Purple.underline()), + ], + ); + tempdir.close() + } + + #[test] + fn parse_no_changes_discard_zeros() { + let actual = FossilDiff::parse(" 0 0 TOTAL over 0 changed files\n", true); + let expected = FossilDiff { + added: "", + deleted: "", + }; + assert_eq!(expected, actual); + } + + #[test] + fn parse_no_changes_keep_zeros() { + let actual = FossilDiff::parse(" 0 0 TOTAL over 0 changed files\n", false); + let expected = FossilDiff { + added: "0", + deleted: "0", + }; + assert_eq!(expected, actual); + } + + #[test] + fn parse_with_changes() { + let actual = FossilDiff::parse( + " 3 2 README.md\n 3 2 TOTAL over 1 changed files\n", + true, + ); + let expected = FossilDiff { + added: "3", + deleted: "2", + }; + assert_eq!(expected, actual); + } + + #[test] + fn parse_ignore_empty() { + let actual = FossilDiff::parse("", true); + let expected = FossilDiff { + added: "", + deleted: "", + }; + assert_eq!(expected, actual); + } + + /// Tests output as produced by Fossil v2.3 to v2.14, i.e. without the summary line. + #[test] + fn parse_ignore_when_missing_total_line() { + let actual = FossilDiff::parse(" 3 2 README.md\n", true); + let expected = FossilDiff { + added: "", + deleted: "", + }; + assert_eq!(expected, actual); + } + + fn expect_fossil_metrics_with_config( + checkout_dir: &Path, + config: Option<toml::Table>, + expectations: &[Expect], + ) { + let actual = ModuleRenderer::new("fossil_metrics") + .path(checkout_dir.to_str().unwrap()) + .config(config.unwrap_or_else(|| { + toml::toml! { + [fossil_metrics] + disabled = false + } + })) + .collect(); + + let mut expect_added = Some("3"); + let mut expect_added_style = Color::Green.bold(); + let mut expect_deleted = Some("2"); + let mut expect_deleted_style = Color::Red.bold(); + + for expect in expectations { + match expect { + Expect::Empty => { + assert_eq!(None, actual); + return; + } + Expect::Added(added) => expect_added = *added, + Expect::AddedStyle(style) => expect_added_style = *style, + Expect::Deleted(deleted) => expect_deleted = *deleted, + Expect::DeletedStyle(style) => expect_deleted_style = *style, + } + } + + let expected = Some(format!( + "{}{}", + expect_added + .map(|added| format!("{} ", expect_added_style.paint(format!("+{added}")))) + .unwrap_or(String::from("")), + expect_deleted + .map(|deleted| format!("{} ", expect_deleted_style.paint(format!("-{deleted}")))) + .unwrap_or(String::from("")), + )); + assert_eq!(expected, actual); + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index d60cdc50f..fe4f42305 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -25,6 +25,7 @@ mod erlang; mod fennel; mod fill; mod fossil_branch; +mod fossil_metrics; mod gcloud; mod git_branch; mod git_commit; @@ -129,6 +130,7 @@ pub fn handle<'a>(module: &str, context: &'a Context) -> Option<Module<'a>> { "fennel" => fennel::module(context), "fill" => fill::module(context), "fossil_branch" => fossil_branch::module(context), + "fossil_metrics" => fossil_metrics::module(context), "gcloud" => gcloud::module(context), "git_branch" => git_branch::module(context), "git_commit" => git_commit::module(context), @@ -244,6 +246,7 @@ pub fn description(module: &str) -> &'static str { "fennel" => "The currently installed version of Fennel", "fill" => "Fills the remaining space on the line with a pad string", "fossil_branch" => "The active branch of the check-out in your current directory", + "fossil_metrics" => "The currently added/deleted lines in your check-out", "gcloud" => "The current GCP client configuration", "git_branch" => "The active branch of the repo in your current directory", "git_commit" => "The active commit (and tag if any) of the repo in your current directory", |