summaryrefslogtreecommitdiffstats
path: root/src/modules
diff options
context:
space:
mode:
authorVegard Skui <me@vegardskui.com>2023-09-02 09:19:04 +0200
committerGitHub <noreply@github.com>2023-09-02 09:19:04 +0200
commite867cda1eb90ba452768bd2e0738afc2fd0db613 (patch)
tree83cff236c532d2f8bb2fee6a9146b1a4604418e3 /src/modules
parent91d9053aa4a544ce83f911f83cca4cb25230cb62 (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.rs297
-rw-r--r--src/modules/mod.rs3
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",