From e867cda1eb90ba452768bd2e0738afc2fd0db613 Mon Sep 17 00:00:00 2001 From: Vegard Skui Date: Sat, 2 Sep 2023 09:19:04 +0200 Subject: [PATCH] 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 --- .github/config-schema.json | 40 +++++ docs/config/README.md | 36 +++++ src/configs/fossil_metrics.rs | 28 ++++ src/configs/mod.rs | 3 + src/configs/starship_root.rs | 1 + src/module.rs | 1 + src/modules/fossil_metrics.rs | 297 ++++++++++++++++++++++++++++++++++ src/modules/mod.rs | 3 + src/utils.rs | 6 + 9 files changed, 415 insertions(+) create mode 100644 src/configs/fossil_metrics.rs create mode 100644 src/modules/fossil_metrics.rs diff --git a/.github/config-schema.json b/.github/config-schema.json index 98d6348a..0a5da5d1 100644 --- a/.github/config-schema.json +++ b/.github/config-schema.json @@ -520,6 +520,20 @@ } ] }, + "fossil_metrics": { + "default": { + "added_style": "bold green", + "deleted_style": "bold red", + "disabled": true, + "format": "([+$added]($added_style) )([-$deleted]($deleted_style) )", + "only_nonzero_diffs": true + }, + "allOf": [ + { + "$ref": "#/definitions/FossilMetricsConfig" + } + ] + }, "gcloud": { "default": { "detect_env_vars": [], @@ -3068,6 +3082,32 @@ }, "additionalProperties": false }, + "FossilMetricsConfig": { + "type": "object", + "properties": { + "format": { + "default": "([+$added]($added_style) )([-$deleted]($deleted_style) )", + "type": "string" + }, + "added_style": { + "default": "bold green", + "type": "string" + }, + "deleted_style": { + "default": "bold red", + "type": "string" + }, + "only_nonzero_diffs": { + "default": true, + "type": "boolean" + }, + "disabled": { + "default": true, + "type": "boolean" + } + }, + "additionalProperties": false + }, "GcloudConfig": { "type": "object", "properties": { diff --git a/docs/config/README.md b/docs/config/README.md index 1007c1c7..80d6ed37 100644 --- a/docs/config/README.md +++ b/docs/config/README.md @@ -266,6 +266,7 @@ $kubernetes\ $directory\ $vcsh\ $fossil_branch\ +$fossil_metrics\ $git_branch\ $git_commit\ $git_state\ @@ -1604,6 +1605,41 @@ truncation_length = 4 truncation_symbol = '' ``` +## Fossil Metrics + +The `fossil_metrics` module will show the number of added and deleted lines in the check-out in your current directory. At least v2.14 (2021-01-20) of Fossil is required. + +### Options + +| Option | Default | Description | +| -------------------- | ------------------------------------------------------------ | ------------------------------------- | +| `format` | `'([+$added]($added_style) )([-$deleted]($deleted_style) )'` | The format for the module. | +| `added_style` | `'bold green'` | The style for the added count. | +| `deleted_style` | `'bold red'` | The style for the deleted count. | +| `only_nonzero_diffs` | `true` | Render status only for changed items. | +| `disabled` | `true` | Disables the `fossil_metrics` module. | + +### Variables + +| Variable | Example | Description | +| --------------- | ------- | ------------------------------------------- | +| added | `1` | The current number of added lines | +| deleted | `2` | The current number of deleted lines | +| added_style\* | | Mirrors the value of option `added_style` | +| deleted_style\* | | Mirrors the value of option `deleted_style` | + +*: This variable can only be used as a part of a style string + +### Example + +```toml +# ~/.config/starship.toml + +[fossil_metrics] +added_style = 'bold blue' +format = '[+$added]($added_style)/[-$deleted]($deleted_style) ' +``` + ## Google Cloud (`gcloud`) The `gcloud` module shows the current configuration for [`gcloud`](https://cloud.google.com/sdk/gcloud) CLI. diff --git a/src/configs/fossil_metrics.rs b/src/configs/fossil_metrics.rs new file mode 100644 index 00000000..ba99b3a1 --- /dev/null +++ b/src/configs/fossil_metrics.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Deserialize, Serialize)] +#[cfg_attr( + feature = "config-schema", + derive(schemars::JsonSchema), + schemars(deny_unknown_fields) +)] +#[serde(default)] +pub struct FossilMetricsConfig<'a> { + pub format: &'a str, + pub added_style: &'a str, + pub deleted_style: &'a str, + pub only_nonzero_diffs: bool, + pub disabled: bool, +} + +impl<'a> Default for FossilMetricsConfig<'a> { + fn default() -> Self { + FossilMetricsConfig { + format: "([+$added]($added_style) )([-$deleted]($deleted_style) )", + added_style: "bold green", + deleted_style: "bold red", + only_nonzero_diffs: true, + disabled: true, + } + } +} diff --git a/src/configs/mod.rs b/src/configs/mod.rs index e218dd40..aeb8193f 100644 --- a/src/configs/mod.rs +++ b/src/configs/mod.rs @@ -28,6 +28,7 @@ pub mod erlang; pub mod fennel; pub mod fill; pub mod fossil_branch; +pub mod fossil_metrics; pub mod gcloud; pub mod git_branch; pub mod git_commit; @@ -159,6 +160,8 @@ pub struct FullConfig<'a> { #[serde(borrow)] fossil_branch: fossil_branch::FossilBranchConfig<'a>, #[serde(borrow)] + fossil_metrics: fossil_metrics::FossilMetricsConfig<'a>, + #[serde(borrow)] gcloud: gcloud::GcloudConfig<'a>, #[serde(borrow)] git_branch: git_branch::GitBranchConfig<'a>, diff --git a/src/configs/starship_root.rs b/src/configs/starship_root.rs index 7d8cae12..aa3d65f9 100644 --- a/src/configs/starship_root.rs +++ b/src/configs/starship_root.rs @@ -39,6 +39,7 @@ pub const PROMPT_ORDER: &[&str] = &[ "directory", "vcsh", "fossil_branch", + "fossil_metrics", "git_branch", "git_commit", "git_state", diff --git a/src/module.rs b/src/module.rs index 401b8e59..26d46c97 100644 --- a/src/module.rs +++ b/src/module.rs @@ -35,6 +35,7 @@ pub const ALL_MODULES: &[&str] = &[ "fennel", "fill", "fossil_branch", + "fossil_metrics", "gcloud", "git_branch", "git_commit", diff --git a/src/modules/fossil_metrics.rs b/src/modules/fossil_metrics.rs new file mode 100644 index 00000000..5ec97b6c --- /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> { + 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, + 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 d60cdc50..fe4f4230 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> { "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", diff --git a/src/utils.rs b/src/utils.rs index 1409db80..d4d68a81 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -257,6 +257,12 @@ Elixir 1.10 (compiled with Erlang/OTP 22)\n", stdout: String::default(), stderr: String::default(), }), + "fossil diff --numstat" => Some(CommandOutput{ + stdout: String::from("\ + 3 2 README.md + 3 2 TOTAL over 1 changed files"), + stderr: String::default(), + }), "fossil update topic-branch" => Some(CommandOutput{ stdout: String::default(), stderr: String::default(),