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
This commit is contained in:
Vegard Skui 2023-09-02 09:19:04 +02:00 committed by GitHub
parent 91d9053aa4
commit e867cda1eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 415 additions and 0 deletions

View File

@ -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": {

View File

@ -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.

View File

@ -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,
}
}
}

View File

@ -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>,

View File

@ -39,6 +39,7 @@ pub const PROMPT_ORDER: &[&str] = &[
"directory",
"vcsh",
"fossil_branch",
"fossil_metrics",
"git_branch",
"git_commit",
"git_state",

View File

@ -35,6 +35,7 @@ pub const ALL_MODULES: &[&str] = &[
"fennel",
"fill",
"fossil_branch",
"fossil_metrics",
"gcloud",
"git_branch",
"git_commit",

View File

@ -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);
}
}

View File

@ -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",

View File

@ -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(),