diff options
author | bors[bot] <26634292+bors[bot]@users.noreply.github.com> | 2022-03-13 22:20:17 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-03-13 22:20:17 +0000 |
commit | 3bee1044782f15379e1a0e02701b73917cb9c39d (patch) | |
tree | 7e67397308c5f7e391f27cf2c51fcbac9e2e9085 | |
parent | 8273ec005cc40a8f47227837dd94abd2402a8ba6 (diff) | |
parent | 0880eaf2cfd9332e99d86ef10bdd1b4f39ea4ccd (diff) | |
download | gfold-3bee1044782f15379e1a0e02701b73917cb9c39d.zip |
Merge #178
178: Add JSON output and do way too much other stuff r=nickgerace a=nickgerace
General changes:
- Add JSON output flag for version and results
- Add color mode with the following options: "always", "compatibility",
and "never"
- Merge CLI functions into "parse_and_run()"
- Major module changes: "cli", "display", and "report" modules now
contain their children, which are "logging", "color", and "target"
respectively
- Add result type and remove "anyhow" dependency to reduce binary size
and simplify errors (rely more on "thiserror").
- Ensure "Status" has bi-directional serialization for JSON output.
- Re-write tests to create and use actual files, directories, and newly
initialized repositories in the "target" directory
- Ensure CHANGELOG for the upcoming release is closer to the original
"Keep a Changelog" format"
Bug fixes:
- Fix a bug where HEAD was not being detected using
"git2::Repository::head()" in a newly initialized repository
- Fix a bug where encountering a "NotFound" error with a repository's
remote "origin" would cause a panic
CI changes:
- Rename CI workflows to be more explicit
- Remove "rust-cache" from nightly workflow and add comment for its
reasoning
Makefile changes:
- Refactor README to only remove unused targets
- Add nextest target
- Add size target
Bench script changes:
- Require grealpath on macOS
- Suppress command output and only display times
Additional comment: I am probably missing some stuff here. I was on a
flight and did not have internet. My brain went nuts and... well, here
we are.
Closes #176
Closes #180
Co-authored-by: Nick Gerace <nickagerace@gmail.com>
-rw-r--r-- | .github/workflows/ci.yml | 10 | ||||
-rw-r--r-- | CHANGELOG.md | 21 | ||||
-rw-r--r-- | Cargo.lock | 31 | ||||
-rw-r--r-- | Cargo.toml | 2 | ||||
-rw-r--r-- | Makefile | 45 | ||||
-rw-r--r-- | bors.toml | 4 | ||||
-rwxr-xr-x | scripts/bench-loosely.sh | 31 | ||||
-rw-r--r-- | src/cli.rs | 79 | ||||
-rw-r--r-- | src/cli/logging.rs (renamed from src/logging.rs) | 4 | ||||
-rw-r--r-- | src/cli/mod.rs | 114 | ||||
-rw-r--r-- | src/color.rs | 41 | ||||
-rw-r--r-- | src/config.rs | 83 | ||||
-rw-r--r-- | src/display.rs | 114 | ||||
-rw-r--r-- | src/display/color.rs | 74 | ||||
-rw-r--r-- | src/display/mod.rs | 143 | ||||
-rw-r--r-- | src/error.rs | 25 | ||||
-rw-r--r-- | src/main.rs | 105 | ||||
-rw-r--r-- | src/report.rs | 240 | ||||
-rw-r--r-- | src/report/mod.rs | 213 | ||||
-rw-r--r-- | src/report/target.rs | 68 | ||||
-rw-r--r-- | src/result.rs | 6 | ||||
-rw-r--r-- | src/run.rs | 22 | ||||
-rw-r--r-- | src/status.rs | 7 |
23 files changed, 870 insertions, 612 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44f4893..5ef7f93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,23 +16,23 @@ concurrency: cancel-in-progress: true jobs: lint: - name: "Lint" + name: "Nightly Fmt Check" runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 + # Do not use "rust-cache" since nightly will wipe out the cache daily - uses: actions-rs/toolchain@v1 with: profile: minimal toolchain: nightly override: true components: rustfmt - - uses: Swatinem/rust-cache@v1 - uses: actions-rs/cargo@v1 with: command: fmt args: --all -- --check test: - name: "Clippy, Test, and Build (ubuntu-latest)" + name: "Clippy Lint, Test, and Build (ubuntu-latest)" runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 @@ -47,11 +47,15 @@ jobs: with: command: clippy args: -- -D warnings + # FIXME: use cargo nextest - uses: actions-rs/cargo@v1 with: command: test - uses: actions-rs/cargo@v1 with: + command: doc + - uses: actions-rs/cargo@v1 + with: command: build args: --locked build: diff --git a/CHANGELOG.md b/CHANGELOG.md index ab4dc53..ce4a116 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,26 +11,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added +- [Bors](https://bors.tech/) to avoid merge skew / semantic merge conflicts +- Color mode option with the following choices: "always", "compatibility" and "never" +- Documentation comments almost everywhere for `cargo doc` - [git2-rs](https://github.com/rust-lang/git2-rs), which replaces `git` subcommand usage + - Even though `git` subcommands were used over **git2-rs** to reduce binary size, significant speed increases could only be achieved by using the latter. +- JSON output flag for both version and results printing ### Changed - Config file location from `<prefix>/gfold/gfold.json` to `<prefix>/gfold.toml` - Config file type from JSON to TOML - Major performance improvements due to moving from sequential target generation to nested, parallel iterators for target generation +- Module layout + - `cli`, `display`, and `report` modules now contain their children: `logging`, `color`, and `target` respectively. + - `target` is a new submodule of `display` since they are within the same bounded context, but the former is a subdomain of the latter. + - `color` now uses a harness rather than individual functions. +- Grey color default to avoid a bug where the `stdout` color is not refreshed within `tmux` when using macOS `Terminal.app` +- Testing for the entire crate + - All tests have been replaced in favor on one integration test.The old tests relied on developer's environment, which is highly variable. The new test creates multiple files, directories, and repositories in the `target` directory to simulate an actual development environment. ### Removed +- `anyhow` dependency (reduced binary size) in favor of internal `Result` type +- Display of `none` fields for the standard (default) display of result (i.e. now, if an optional field was not found, it is not shown) - Git path option for CLI and config file - `git` subcommand usage -### Notes - -- Even though `git` subcommands were used over **git2-rs** to reduce binary size, significant speed increases could only be achieved by using the latter. -- Technically, removing the Git path option from the CLI and the config file could require a major version increase. -- Given the immaturity of `3.0.0`, the (likely) infrequent use of the Git path option, and the overall structure/behavior remaining intact, the removal of this config option only necessitates a minor version increase. -- For technical details on the field removal, please refer to the [diff between releases](https://github.com/nickgerace/gfold/compare/3.0.0...3.1.0). - ### [3.0.0] - 2022-01-06 ### Added @@ -3,12 +3,6 @@ version = 3 [[package]] -name = "anyhow" -version = "1.0.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4361135be9122e0870de935d7c439aef945b9f9ddd4199a553b5270b49c82a27" - -[[package]] name = "argh" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -181,7 +175,6 @@ dependencies = [ name = "gfold" version = "3.1.0-rc.2" dependencies = [ - "anyhow", "argh", "dirs", "env_logger", @@ -189,6 +182,7 @@ dependencies = [ "log", "rayon", "serde", + "serde_json", "termcolor", "thiserror", "toml", @@ -243,6 +237,12 @@ dependencies = [ ] [[package]] +name = "itoa" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35" + +[[package]] name = "jobserver" version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -396,6 +396,12 @@ dependencies = [ ] [[package]] +name = "ryu" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f" + +[[package]] name = "scopeguard" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -422,6 +428,17 @@ dependencies = [ ] [[package]] +name = "serde_json" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e8d9fa5c3b304765ce1fd9c4c8a3de2c8db365a5b91be52f186efc675681d95" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] name = "syn" version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -14,13 +14,13 @@ version = "3.1.0-rc.2" rust-version = "1.56.1" [dependencies] -anyhow = "1" argh = "0" dirs = "4" git2 = { version = "0", default_features = false } log = "0" rayon = "1" serde = { version = "1", features = ["derive"] } +serde_json = "1" termcolor = "1" thiserror = "1" toml = "0" @@ -9,24 +9,22 @@ prepare: cd $(MAKEPATH); cargo clippy --all-features --all-targets .PHONY: prepare -ci: lint test -.PHONY: ci - -test: - cd $(MAKEPATH); cargo test -- --nocapture -.PHONY: test - lint: cd $(MAKEPATH); cargo +nightly fmt --all -- --check cd $(MAKEPATH); cargo clippy -- -D warnings .PHONY: lint -release: - cd $(MAKEPATH); cargo build --release -.PHONY: release +test: + cd $(MAKEPATH); cargo nextest run --success-output immediate +.PHONY: test -build: release -.PHONY: build +scan: + cd $(MAKEPATH); cargo +nightly udeps + cd $(MAKEPATH); cargo bloat --release + cd $(MAKEPATH); cargo bloat --release --crates + cd $(MAKEPATH); cargo audit + cd $(MAKEPATH); cargo msrv +.PHONY: scan clean: cd $(MAKEPATH); cargo clean @@ -36,26 +34,11 @@ install: cargo install --locked --path $(MAKEPATH) .PHONY: install -scan: - cd $(MAKEPATH); cargo +nightly udeps - cd $(MAKEPATH); cargo bloat --release - cd $(MAKEPATH); cargo bloat --release --crates - cd $(MAKEPATH); cargo audit -.PHONY: scan - -msrv: - cd $(MAKEPATH); cargo msrv -.PHONY: msrv - bench-loosely: - REPOPATH=$(MAKEPATH) $(MAKEPATH)/scripts/bench-loosely.sh + $(MAKEPATH)/scripts/bench-loosely.sh .PHONY: bench-loosely -compare: release - @du -h $(shell which gfold) +size: + cd $(MAKEPATH); cargo build --release @du -h $(MAKEPATH)/target/release/gfold -.PHONY: compare - -publish-dry-run: - cargo publish --dry-run -.PHONY: publish-dry-run +.PHONY: size @@ -1,6 +1,6 @@ status = [ - "Lint", - "Clippy, Test, and Build (ubuntu-latest)", + "Nightly Fmt Check", + "Clippy Lint, Test, and Build (ubuntu-latest)", "Build (windows-latest)", "Build (macos-latest)" ] diff --git a/scripts/bench-loosely.sh b/scripts/bench-loosely.sh index 29f7e1d..b08a1b5 100755 --- a/scripts/bench-loosely.sh +++ b/scripts/bench-loosely.sh @@ -1,28 +1,27 @@ #!/usr/bin/env bash set -e -if [ "$REPOPATH" = "" ]; then - echo "must execute script via make target from repository root" - exit 1 +REALPATH=realpath +if [ "$(uname -s)" = "Darwin" ]; then + REALPATH=grealpath fi +REPOPATH=$(dirname $(dirname $($REALPATH -s $0))) ( cd $REPOPATH; cargo build --release ) -OLD=$(which gfold) +OLD=$HOME/.cargo/bin/gfold NEW=$REPOPATH/target/release/gfold function run { - for COUNT in {1..4}; do - echo " - - -$1 $COUNT -" - time $OLD $1 - echo "- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -" - time $NEW -i $1 - done + local BENCH_FILE + for COUNT in {1..4}; do + echo "- - - - - - - - - - - - -" + echo "[OLD]" + time $OLD -i $1 > /dev/null + echo "[NEW]" + time $NEW -i $1 > /dev/null + done } -run "$HOME/" -run "$HOME/src" +run "$HOME/" "home" +run "$HOME/src" "src" diff --git a/src/cli.rs b/src/cli.rs deleted file mode 100644 index a4b0e81..0000000 --- a/src/cli.rs +++ /dev/null @@ -1,79 +0,0 @@ -use crate::config::{Config, DisplayMode}; -use crate::{logging, run}; -use anyhow::Result; -use argh::FromArgs; -use std::env; - -#[derive(FromArgs)] -#[argh(description = "More information: https://github.com/nickgerace/gfold - -This application helps you keep track of multiple Git repositories via CLI. -By default, it displays relevant information for all repos in the current -working directory. - -While CLI options are prioritized, default options will fallback to the config -file if it exists. Here is the config file lookup locations for some common -operating systems: - - macOS/Linux $HOME/.config/gfold.toml - Windows {{FOLDERID_Profile}}\\.config\\gfold.toml")] -struct Args { - #[argh( - positional, - description = "specify path to target directory (defaults to current working directory)" - )] - path: Option<String>, - - #[argh( - switch, - short = 'c', - description = "display results with classic formatting" - )] - classic: bool, - #[argh( - switch, - description = "enable debug logging (sets \"RUST_LOG\" to \"debug\")" - )] - debug: bool, - #[argh(switch, short = 'i', description = "ignore config file settings")] - ignore_config_file: bool, - #[argh( - switch, - description = "display config options chosen, including those from the config file if they exist" - )] - print: bool, - #[argh(switch, short = 'V', description = "display version information")] - version: bool, -} - -pub fn parse() -> Result<()> { - // First and foremost, get logging up and running. We want logs as quickly as possible for - // debugging by setting "RUST_LOG". - let args: Args = argh::from_env(); - logging::init(args.debug); - - match args.version { - true => println!("gfold {}", env!("CARGO_PKG_VERSION")), - false => merge_config_and_run(&args)?, - } - Ok(()) -} - -fn merge_config_and_run(args: &Args) -> Result<()> { - let mut config = match args.ignore_config_file { - true => Config::new()?, - false => Config::try_config()?, - }; - - if let Some(s) = &args.path { - config.path = env::current_dir()?.join(s).canonicalize()?; - } - if args.classic { - config.display_mode = DisplayMode::Classic; - } - - match args.print { - true => config.print(), - false => run::run(&config), - } -} diff --git a/src/logging.rs b/src/cli/logging.rs index 3c23ccf..f0f1ac6 100644 --- a/src/logging.rs +++ b/src/cli/logging.rs @@ -1,7 +1,11 @@ +//! This module contains the logger initialization logic. + use env_logger::Builder; use log::LevelFilter; use std::env; +/// Initialize the logger based on the debug flag and `RUST_LOG` environment variable. The flag +/// takes precedence over the environment variable. pub fn init(debug: bool) { match debug { true => Builder::new().filter_level(LevelFilter::Debug).init(), diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..6ae5114 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,114 @@ +//! This module contains the CLI entrypoint, CLI options and config generation based on the user's +//! settings and environment. + +use crate::config::{ColorMode, Config, DisplayMode}; +use crate::error::Error; +use crate::result::Result; +use crate::run; +use argh::FromArgs; +use std::env; + +mod logging; + +#[derive(FromArgs)] +#[argh(description = "More information: https://github.com/nickgerace/gfold + +This application helps you keep track of multiple Git repositories via CLI. +By default, it displays relevant information for all repos in the current +working directory. + +While CLI options are prioritized, default options will fallback to the config +file if it exists. Here is the config file lookup locations for some common +operating systems: + + macOS/Linux $HOME/.config/gfold.toml + Windows {{FOLDERID_Profile}}\\.config\\gfold.toml")] +struct Args { + #[argh( + positional, + description = "specify path to target directory (defaults to current working directory)" + )] + path: Option<String>, + + #[argh( + option, + short = 'c', + description = "specify color mode [options: \"always\", \"compatibility\", \"off\"]" + )] + color_mode: Option<String>, + #[argh( + switch, + description = "enable debug logging (sets \"RUST_LOG\" to \"debug\")" + )] + debug: bool, + #[argh( + option, + short = 'd', + description = "specify display format [options: \"standard\"/\"default\", \"json\", \"classic\"]" + )] + display_mode: Option<String>, + #[argh( + switch, + description = "display config options chosen, including those from the config file if they exist" + )] + dry_run: bool, + #[argh(switch, short = 'i', description = "ignore config file settings")] + ignore_config_file: bool, + #[argh( + switch, + short = 'V', + description = "display version information (tip: set display mode (-d/--display-mode) to \"json\" to display version information as valid JSON" + )] + version: bool, +} + +/// Parse CLI arguments, initialize the logger, merge configurations as needed, and call +/// [`run::run()`] with the resulting [`Config`]. +pub fn parse_and_run() -> Result<()> { + // First and foremost, get logging up and running. We want logs as quickly as possible for + // debugging by setting "RUST_LOG". + let args: Args = argh::from_env(); + logging::init(args.debug); + + let mut config = match args.ignore_config_file { + true => Config::new()?, + false => Config::try_config()?, + }; + + if let Some(found_display_mode) = &args.display_mode { + config.display_mode = match found_display_mode.to_lowercase().as_str() { + "classic" => DisplayMode::Classic, + "json" => DisplayMode::Json, + "standard" | "default" => DisplayMode::Standard, + _ => return Err(Error::InvalidDisplayMode(found_display_mode.to_string())), + } + } + + // If the version flag is enabled, we need to use display mode to determine its output shape + // and then return once version information is displayed. + if args.version { + match &config.display_mode { + DisplayMode::Json => println!("{}", serde_json::to_string(env!("CARGO_PKG_VERSION"))?), + _ => println!("gfold {}", env!("CARGO_PKG_VERSION")), + } + return Ok(()); + } + + if let Some(found_color_mode) = &args.color_mode { + config.color_mode = match found_color_mode.to_lowercase().as_str() { + "always" => ColorMode::Always, + "compatibility" => ColorMode::Compatibility, + "never" => ColorMode::Never, + _ => return Err(Error::InvalidColorMode(found_color_mode.to_string())), + } + } + + if let Some(found_path) = &args.path { + config.path = env::current_dir()?.join(found_path).canonicalize()?; + } + + match &args.dry_run { + true => config.print(), + false => run::run(&config), + } +} diff --git a/src/color.rs b/src/color.rs deleted file mode 100644 index bb039a9..0000000 --- a/src/color.rs +++ /dev/null @@ -1,41 +0,0 @@ -use crate::status::Status; -use std::io::{self, Write}; -use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor}; - -pub fn write_status(status: &Status, status_width: usize) -> io::Result<()> { - let mut stdout = StandardStream::stdout(ColorChoice::Always); - stdout.set_color(ColorSpec::new().set_fg(Some(match status { - Status::Bare => Color::Red, - Status::Clean => Color::Green, - _ => Color::Yellow, - })))?; - write!( - &mut stdout, - "{:<status_width$}", - status.as_str(), - status_width = status_width, - )?; - stdout.reset() -} - -pub fn write_bold(input: &str, newline: bool) -> io::Result<()> { - write_color(input, newline, ColorSpec::new().set_bold(true)) -} - -pub fn write_gray(input: &str, newline: bool) -> io::Result<()> { - write_color( - input, - newline, - ColorSpec::new().set_fg(Some(Color::Rgb(128, 128, 128))), - ) -} - -fn write_color(input: &str, newline: bool, color_spec: &mut ColorSpec) -> io::Result<()> { - let mut stdout = StandardStream::stdout(ColorChoice::Always); - stdout.set_color(color_spec)?; - match newline { - true => writeln!(&mut stdout, "{}", input)?, - false => write!(&mut stdout, "{}", input)?, - } - stdout.reset() -} diff --git a/src/config.rs b/src/config.rs index c0a7334..f898d55 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,55 +1,76 @@ -use crate::error::Error; -use anyhow::Result; +//! This module contains the config specification and functionality for creating a config. +use crate::error::Error; +use crate::result::Result; use serde::{Deserialize, Serialize}; - use std::env; use std::path::PathBuf; -// "Config" is the actual type consumed through the codebase. It is boostrapped via its public -// methods and uses "EntryConfig", a private struct, under the hood in order to deserialize empty, -// non-existent, partial, and complete config files. +/// This struct is the actual config type consumed through the codebase. It is boostrapped via its +/// public methods and uses [`EntryConfig`], a private struct, under the hood in order to +/// deserialize empty, non-existent, partial, and complete config files. #[derive(Serialize)] pub struct Config { + /// The path that `gfold` will begin traversal in and collect results from. pub path: PathBuf, + /// The display format for results printed to `stdout`. pub display_mode: DisplayMode, + /// The color mode for results printed to `stdout`. + pub color_mode: ColorMode, } -// "EntryConfig" is a reflection of "Config" with its fields wrapped as "Option" types. This is to -// ensure that we can deserialize from partial config file contents and populate empty fields with -// defaults. Moreover, enumerations cannot set defaults values currently, so we need to set -// desired defaults for the user. In this case, the public methods for "Config" use "EntryConfig" -// privately. +/// This struct is a reflection of [`Config`] with its fields wrapped with [`Option`], which +/// ensures that we can deserialize from partial config file contents and populate empty fields +/// with defaults. Moreover, enum fields cannot set defaults values currently, so we need to +/// manually set defaults for the user. For those reasons, the public methods for [`Config`] use +/// this struct privately. #[derive(Deserialize, Default)] struct EntryConfig { + /// Reflection of the `path` field on [`Config`]. pub path: Option<PathBuf>, + /// Reflection of the `display_mode` field on [`Config`]. pub display_mode: Option<DisplayMode>, + /// Reflection of the `color_mode` field on [`Config`]. + pub color_mode: Option<ColorMode>, } -// "DisplayMode" dictates which way the results gathered should be displayed to the user via -// STDOUT. Setting this enumeration is _mostly_ cosmetic, but it is possible that collected data -// may differ in order to reduce compute load. For example: if one display mode dislays more -// information than another display mode, more subcommands and functions might get executed. -// Conversely, if another display mode requires less information to be displayed, then some -// commands and functions migth get skipped. -// -// TLDR: while this setting is primarily for cosmetics, it may also affect runtime performance -// based on what needs to be displayed. +/// Dictates how the results gathered should be displayed to the user via `stdout`. Setting this +/// enum is _mostly_ cosmetic, but it is possible that collected data may differ in order to +/// reduce compute load. For example: if one display mode dislays more information than another +/// display mode, more data may need to be collected. Conversely, if another display mode requires +/// less information to be displayed, then some commands and functions migth get skipped. +/// In summary, while this setting is primarily for cosmetics, it may also affect runtime +/// performance based on what needs to be displayed. #[derive(Serialize, Deserialize, Clone)] pub enum DisplayMode { + /// Informs the caller to display results in the standard (default) format. Standard, + /// Informs the caller to display results in the classic format. Classic, + /// Informs the caller to display results in JSON format. + Json, +} + +/// Set the color mode of results printed to `stdout`. +#[derive(Serialize, Deserialize, Clone)] +pub enum ColorMode { + /// Attempt to display colors as intended (default behavior). + Always, + /// Display colors using widely-compatible methods at the potential expense of colors being + /// displayed as intended. + Compatibility, + /// Never display colors. + Never, } impl Config { - // This method tries to deserialize the config file (empty, non-existent, partial or complete) - // and uses "EntryConfig" as an intermediary struct. This is the primary method used when - // creating a config. - // - // Within this method, we check if the config file is empty before deserializing it. Users - // should be able to proceed with empty config files. If empty, then we fall back to the - // "EntryConfig" default before conversion. + /// This method tries to deserialize the config file (empty, non-existent, partial or complete) + /// and uses "EntryConfig" as an intermediary struct. This is the primary method used when + /// creating a config. pub fn try_config() -> Result<Self> { + // Within this method, we check if the config file is empty before deserializing it. Users + // should be able to proceed with empty config files. If empty, then we fall back to the + // "EntryConfig" default before conversion. let home = dirs::home_dir().ok_or(Error::HomeDirNotFound)?; let path = home.join(".config").join("gfold.toml"); let contents = std::fs::read_to_string(path)?; @@ -60,8 +81,8 @@ impl Config { Self::from_entry_config(&entry_config) } - // This method does not look for the config file and uses "EntryConfig"'s defaults instead. - // It is best for testing use and when the user wishes to skip config file lookup. + /// This method does not look for the config file and uses "EntryConfig"'s defaults instead. + /// It is best for testing use and when the user wishes to skip config file lookup. pub fn new() -> Result<Self> { Self::from_entry_config(&EntryConfig::default()) } @@ -82,6 +103,10 @@ impl Config { Some(s) => s.clone(), None => DisplayMode::Standard, }, + color_mode: match &entry_config.color_mode { + Some(s) => s.clone(), + None => ColorMode::Always, + }, }) } } diff --git a/src/display.rs b/src/display.rs deleted file mode 100644 index 186dbd3..0000000 --- a/src/display.rs +++ /dev/null @@ -1,114 +0,0 @@ -use crate::color; -use crate::error::Error; -use crate::report::Reports; -use anyhow::Result; -use log::warn; -use std::path::Path; - -const PAD: usize = 2; -const NONE: &str = "none"; - -pub fn classic(reports: &Reports) -> Result<()> { - let length = reports.keys().len(); - let mut first = true; - for (title, group) in reports { - // FIXME: make group title matching less cumbersome. - if length > 1 { - match first { - true => { - first = false; - } - false => println!(), - } - color::write_bold( - match title { - Some(s) => s, - None => NONE, - }, - true, - )?; - } - - let mut path_max = 0; - let mut branch_max = 0; - let mut status_max = 0; - for report in group { - if report.path.len() > path_max { - path_max = report.path.len(); - } - let status_length = report.status.as_str().len(); - if status_length > status_max { - status_max = status_length; - } - if report.branch.len() > branch_max { - branch_max = report.branch.len(); - } - } - - let mut reports = group.clone(); - reports.sort_by(|a, b| a.path.cmp(&b.path)); - reports.sort_by(|a, b| a.status.as_str().cmp(b.status.as_str())); - - for report in reports { - print!("{:<path_width$}", report.path, path_width = path_max + PAD); - color::write_status(&report.status, status_max + PAD)?; - println!( - "{:<branch_width$}{}", - report.branch, - match &report.url { - Some(s) => s, - None => NONE, - }, - branch_width = branch_max + PAD - ); - } - } - Ok(()) -} - -pub fn standard(reports: &Reports) -> Result<()> { - let mut all_reports = Vec::new(); - for grouped_report in reports { - all_reports.append(&mut grouped_report.1.clone()); - } - all_reports.sort_by(|a, b| a.path.cmp(&b.path)); - all_reports.sort_by(|a, b| a.status.as_str().cmp(b.status.as_str())); - - for report in all_reports { - color::write_bold(&report.path, false)?; - - let parent = match report.parent { - Some(s) => s, - None => { - warn!("parent is empty for report: {}", report.path); - continue; - } - }; - let full_path = Path::new(&parent).join(&report.path); - let full_path_formatted = format!( - " ~ {}", - full_path - .to_str() - .ok_or_else(|| Error::PathToStrConversionFailure(full_path.clone()))? - ); - color::write_gray(&full_path_formatted, true)?; - - print!(" "); - color::write_status(&report.status, PAD)?; - println!( - " ({}) - {} - {}", - report.branch, - match &report.url { - Some(s) => s, - None => NONE, - }, - match &report.email { - Some(s) => s, - None => NONE, - }, - ); - } - Ok(()) -} diff --git a/src/display/color.rs b/src/display/color.rs new file mode 100644 index 0000000..47ae7a0 --- /dev/null +++ b/src/display/color.rs @@ -0,0 +1,74 @@ +//! This module provides a harness for non-trivial displays of information to `stdout`. + +use crate::config::ColorMode; +use crate::status::Status; +use std::io::{self, Write}; +use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor}; + +/// This harness provides methods to write to `stdout`. It maps the internal [`ColorMode`] type to +/// our dependency's [`ColorChoice`] type due to discrepancies in behavior and naming. +pub struct ColorHarness { + color_choice: ColorChoice, +} + +impl ColorHarness { + pub fn new(color_mode: &ColorMode) -> Self { + Self { + color_choice: match &color_mode { + ColorMode::Always => ColorChoice::Always, + ColorMode::Compatibility => ColorChoice::Auto, + ColorMode::Never => ColorChoice::Never, + }, + } + } + + /// Writes the [`Status`] of the Git repository to `stdout`. + pub fn write_status(&self, status: &Status, status_width: usize) -> io::Result<()> { + let mut stdout = StandardStream::stdout(self.color_choice); + stdout.set_color(ColorSpec::new().set_fg(Some(match status { + Status::Bare => Color::Red, + Status::Clean => Color::Green, + _ => Color::Yellow, + })))?; + write!( + &mut stdout, + "{:<status_width$}", + status.as_str(), + status_width = status_width, + )?; + stdout.reset() + } + + /// Writes the input [`&str`] to `stdout` in bold. + pub fn write_bold(&self, input: &str, newline: bool) -> io::Result<()> { + self.write_color(input, newline, ColorSpec::new().set_bold(true)) + } + + /// Writes the input [`&str`] to `stdout` in gray (or cyan if in compatibility mode). + pub fn write_gray(&self, input: &str, newline: bool) -> io::Result<()> { + // FIXME: check why Color::Rg(128, 128, 128) breaks in tmux on macOS Terminal.app. + self.write_color( + input, + newline, + ColorSpec::new().set_fg(Some(match &self.color_choice { + ColorChoice::Auto => Color::Cyan, + _ => Color::Ansi256(242), + })), + ) + } + + fn write_color( + &self, + input: &str, + newline: bool, + color_spec: &mut ColorSpec, + ) -> io::Result<()> { + let mut stdout = StandardStream::stdout(self.color_choice); + stdout.set_color(color_spec)?; + match newline { + true => writeln!(&mut stdout, "{}", input)?, + false => write!(&mut stdout, "{}", input)?, + } + stdout.reset() + } +} diff --git a/src/display/mod.rs b/src/display/mod.rs new file mode 100644 index 0000000..5da34fe --- /dev/null +++ b/src/display/mod.rs @@ -0,0 +1,143 @@ +//! This module contains the functionality for displaying reports to `stdout`. + +use crate::config::{ColorMode, DisplayMode}; +use crate::display::color::ColorHarness; +use crate::error::Error; +use crate::report::Reports; +use crate::result::Result; +use log::warn; +use std::path::Path; + +mod color; + +const PAD: usize = 2; +const NONE: &str = "none"; + +/// This function chooses the display execution function based on the [`DisplayMode`] provided. +pub fn display( + display_mode: &DisplayMode, + reports: &Reports, + color_mode: &ColorMode, +) -> Result<()> { + match display_mode { + DisplayMode::Standard => standard(reports, color_mode), + DisplayMode::Json => Ok(json(reports)?), + DisplayMode::Classic => classic(reports, color_mode), + } +} + +/// Display [`Reports`] to `stdout` in the standard (default) format. +fn standard(reports: &Reports, color_mode: &ColorMode) -> Result<()> { + let mut all_reports = Vec::new(); + for grouped_report in reports { + all_reports.append(&mut grouped_report.1.clone()); + } + all_reports.sort_by(|a, b| a.name.cmp(&b.name)); + all_reports.sort_by(|a, b| a.status.as_str().cmp(b.status.as_str())); + + let color_harness = ColorHarness::new(color_mode); + + for report in all_reports { + color_harness.write_bold(&report.name, false)?; + + let parent = match report.parent { + Some(s) => s, + None => { + warn!("parent is empty for report: {}", report.name); + continue; + } + }; + let full_path = Path::new(&parent).join(&report.name); + let full_path_formatted = format!( + " ~ {}", + full_path + .to_str() + .ok_or_else(|| Error::PathToStrConversionFailure(full_path.clone()))? + ); + color_harness.write_gray(&full_path_formatted, true)?; + + print!(" "); + color_harness.write_status(&report.status, PAD)?; + println!(" ({})", report.branch); + if let Some(url) = &report.url { + println!(" {}", url); + } + if let Some(email) = &report.email { + println!(" {}", email); + } + } + Ok(()) +} + +/// Display [`Reports`] to `stdout` in JSON format. +fn json(reports: &Reports) -> Result<()> { + let mut all_reports = Vec::new(); + for grouped_report in reports { + all_reports.append(&mut grouped_report.1.clone()); + } + all_reports.sort_by(|a, b| a.name.cmp(&b.name)); + all_reports.sort_by(|a, b| a.status.as_str().cmp(b.status.as_str())); + println!("{}", serde_json::to_string_pretty(&all_reports)?); + Ok(()) +} + +/// Display [`Reports`] to `stdout` in the classic format. +fn classic(reports: &Reports, color_mode: &ColorMode) -> Result<()> { + let color_harness = ColorHarness::new(color_mode); + + let length = reports.keys().len(); + let mut first = true; + for (title, group) in reports { + // FIXME: make group title matching less cumbersome. + if length > 1 { + match first { + true => { + first = false; + } + false => println!(), + } + color_harness.write_bold( + match title { + Some(s) => s, + None => NONE, + }, + true, + )?; + } + + let mut name_max = 0; + let mut branch_max = 0; + let mut status_max = 0; + for report in group { + if report.name.len() > name_max { + name_max = report.name.len(); + } + let status_length = report.status.as_str().len(); + if status_length > status_max { + status_max = status_length; + } + if report.branch.len() > branch_max { + branch_max = report.branch.len(); + } + } + + let mut reports = group.clone(); + reports.sort_by(|a, b| a.name.cmp(&b.name)); + reports.sort_by(|a, b| a.status.as_str().cmp(b.status.as_str())); + + for report in reports { + print!("{:<path_width$}", report.name, path_width = name_max + PAD); + color_harness.write_status(&report.status, status_max + PAD)?; + println!( + "{:<branch_width$}{}", + report.branch, + match &report.url { + Some(s) => s, + None => NONE, + }, + branch_width = branch_max + PAD + ); + } + } + Ok(()) +} diff --git a/src/error.rs b/src/error.rs index 1368087..09fc0d2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,3 +1,5 @@ +//! This module contains the [`crate::error::Error`] type. + use std::path::PathBuf; use thiserror::Error; @@ -5,12 +7,29 @@ use thiserror::Error; pub enum Error { #[error("received None (Option<&OsStr>) for file name: {0}")] FileNameNotFound(PathBuf), + #[error("invalid color mode provided (run \"--help\" for options): {0}")] + InvalidColorMode(String), + #[error("invalid display mode provided (run \"--help\" for options): {0}")] + InvalidDisplayMode(String), + #[error("could not convert file name (&OsStr) to &str: {0}")] - FileNameStrConversionFailure(PathBuf), + FileNameToStrConversionFailure(PathBuf), #[error("could not convert path (Path) to &str: {0}")] PathToStrConversionFailure(PathBuf), - #[error("could not find home directory")] - HomeDirNotFound, + #[error("full shorthand for Git reference is invalid UTF-8")] GitReferenceShorthandInvalid, + #[error("could not find home directory")] + HomeDirNotFound, + + #[error("git2::Error")] + Git2Rs(#[from] git2::Error), + #[error("serde_json::Error")] + SerdeJson(#[from] serde_json::Error), + #[error("std::io::Error")] + StdIo(#[from] std::io::Error), + #[error("toml::de::Error")] + TomlDe(#[from] toml::de::Error), + #[error("toml::ser::Error")] + TomlSe(#[from] toml::ser::Error), } diff --git a/src/main.rs b/src/main.rs index fb80bbb..4797f6c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,47 +1,100 @@ -use anyhow::Result; +//! [gfold](https://github.com/nickgerace/gfold) is a CLI-driven application that helps you keep +//! track of multiple Git repositories. The source code uses private modules rather than leveraging +//! a library via `lib.rs`. + +use crate::result::Result; mod cli; -mod color; mod config; mod display; mod error; -mod logging; mod report; +mod result; mod run; mod status; +/// Calls [`cli::parse_and_run()`] to generate a [`config::Config`] and eventually call [`run::run()`]; fn main() -> Result<()> { - cli::parse() + cli::parse_and_run() } #[cfg(test)] mod tests { use super::*; + use crate::config::Config; + use git2::Repository; + use std::path::Path; + use std::{env, fs, io}; #[test] - fn current_directory() { - let config = Config::new().expect("could not create new Config"); + fn integration() { + fn create_dir_or_die(path: &Path) { + if let Err(e) = fs::create_dir(path) { + if e.kind() != io::ErrorKind::AlreadyExists { + panic!( + "could not create directory ({:?}) due to error kind: {:?}", + path, + e.kind() + ); + } + } + } + + fn create_file_or_die(path: &Path) { + if let Err(e) = fs::File::create(path) { + if e.kind() != io::ErrorKind::AlreadyExists { + panic!( + "could not create file ({:?}) due to error kind: {:?}", + path, + e.kind() + ); + } + } + } + + // Test directory structure within "target": + // └── test + // ├── bar + // ├── baz + // ├── foo + // │ └── newfile + // └── nested + // ├── one + // │ ├── newfile + // ├── three + // └── two + + let cwd = env::current_dir().expect("failed to get current working directory"); + let target = cwd.join("target"); + create_dir_or_die(&target); + let test = target.join("test"); + create_dir_or_die(&test); + for name in ["foo", "bar", "baz"] { + let current = test.join(name); + create_dir_or_die(¤t); + Repository::init(¤t).expect("could not initialize repository"); + + if name == "foo" { + create_file_or_die(¤t.join("newfile")); + } + } + + let nested = test.join("nested"); + create_dir_or_die(&nested); + for name in ["one", "two", "three"] { + let current = nested.join(name); + create_dir_or_die(¤t); + Repository::init(¤t).expect("could not initialize repository"); + + if name == "one" { + create_file_or_die(¤t.join("newfile")); + } + } + + let mut config = Config::new().expect("could not create new config"); + config.path = test; + assert!(run::run(&config).is_ok()); } - - // FIXME(nick): re-enable once tests are fixed. - // #[test] - // fn parent_directory() { - // let mut config = Config::new().expect("could not create new Config"); - // let mut parent = env::current_dir().expect("failed to get current working directory"); - // parent.pop(); - // config.path = parent; - // assert!(run::run(&config).is_ok()); - // } - - // FIXME(nick): re-enable once tests are fixed. - // #[test] - // fn home_directory() { - // let mut config = Config::new().expect("could not create new Config"); - // config.path = dirs::home_dir() - // .ok_or(Error::HomeDirNotFound) - // .expect("could not find home directory"); - // assert!(run::run(&config).is_ok()); - // } } diff --git a/src/report.rs b/src/report.rs deleted file mode 100644 index 18ee8d0..0000000 --- a/src/report.rs +++ /dev/null @@ -1,240 +0,0 @@ -use crate::config::DisplayMode; -use crate::error::Error; -use crate::status::Status; -use anyhow::Result; -use git2::{ErrorCode, Reference, Repository, StatusOptions}; -use log::{debug, error, trace, warn}; -use rayon::prelude::*; -use std::collections::BTreeMap; -use std::fs::DirEntry; -use std::path::{Path, PathBuf}; -use std::{fs, io}; - -/// "Reports" uses a BTreeMap over a HashMap for sorted keys. -pub type Reports = BTreeMap<Option<String>, Vec<Report>>; - -#[derive(Clone, Debug)] -pub struct Report { - pub path: String, - pub branch: String, - pub status: Status, - - /// Parent can report "NONE" without impeding execution. - pub parent: Option<String>, - /// URL can report "NONE" without impeding execution. - pub url: Option<String>, - - /// Optional field that's only used in DisplayMode::Standard. - pub email: Option<String>, -} - -impl Report { - pub fn new( - path: &str, - branch: &str, - status: &Status, - parent: Option<String>, - url: Option<String>, - email: Option<String>, - ) -> Self { - Self { - path: (*path).into(), - branch: (*branch).into(), - status: *status, - parent, - url, - email, - } - } - - pub fn generate_reports(path: &Path, display_mode: &DisplayMode) -> Result<Reports> { - let include_email = match display_mode { - DisplayMode::Standard => true, - DisplayMode::Classic => false, - }; - - let unprocessed = recursive_target_gen(path)? - .par_iter() - .map(|path| generate_report(path, include_email)) - .collect::<Vec<Result<Report>>>(); - - let mut processed = Reports::new(); - for wrapped_report in unprocessed { - match wrapped_report { - Ok(report) => { - if let Some(mut v) = - processed.insert(report.parent.clone(), vec![report.clone()]) - { - v.push(report.clone()); - processed.insert(report.parent, v); - } - } - Err(e) => return Err(e), - } - } - Ok(processed) - } -} - -type Target = io::Result<Option<Vec<PathBuf>>>; - -fn recursive_target_gen(path: &Path) -> io::Result<Vec<PathBuf>> { - let entries: Vec<DirEntry> = match fs::read_dir(&path) { - Ok(o) => o.filter_map(|r| r.ok()).collect(), - Err(e) if e.kind() == io::ErrorKind::PermissionDenied => { - warn!("{}: {}", e, &path.display()); - return Ok(vec![]); - } - Err(e) => { - error!("{}: {}", e, &path.display()); - return Ok(vec![]); - } - }; - - let targets = entries - .par_iter() - .map(process_entry) - .collect::<Vec<Target>>(); - - let mut results = vec![]; - for target in targets { - match target { - Ok(v) => { - if let Some(mut v) = v { - if !v.is_empty() { - results.append(&mut v); - } - } - } - Err(e) => return Err(e), - } - } - Ok(results) -} - -/// Ensure the entry is a directory and is not hidden. Then, check if a Git sub directory exists, -/// which will indicate if the entry is a repository. Finally, generate targets based on that -/// repository. -fn process_entry(entry: &DirEntry) -> Target { - match entry.file_type()?.is_dir() - && !entry - .file_name() - .to_str() - .map(|file_name| file_name.starts_with('.')) - .unwrap_or(false) - { - true => { - let path = entry.path(); - let git_sub_directory = path.join(".git"); - match git_sub_directory.exists() && git_sub_directory.is_dir() { - true => Ok(Some(vec![path])), - false => Ok(Some(recursive_target_gen(&path)?)), - } - } - false => Ok(None), - } -} - -fn generate_report(repo_path: &Path, include_email: bool) -> Result<Report> { - let repo = Repository::open(repo_path)?; - let head = repo.head()?; - let branch = head - .shorthand() - .ok_or(Error::GitReferenceShorthandInvalid)?; - - let mut opts = StatusOptions::new(); - let status = match repo.statuses(Some(&mut opts)) { - Ok(v) if v.is_empty() => match is_unpushed(&repo, &head)? { - true => Status::Unpushed, - false => Status::Clean, - }, - Ok(_) => Status::Unclean, - Err(e) if e.code() == ErrorCode::BareRepo => Status::Bare, - Err(e) => return Err(e.into()), - }; - - debug!( - "generating report for repository at {:?} on branch {} with status: {:?}", - &repo_path, &branch, &status - ); - let origin = repo.find_remote("origin")?; - Ok(Report::new( - match repo_path.file_name() { - Some(s) => s - .to_str() - .ok_or_else(|| Error::FileNameStrConversionFailure(repo_path.to_path_buf()))?, - None => return Err(Error::FileNameNotFound(repo_path.to_path_buf()).into()), - }, - branch, - &status, - match repo_path.parent() { - Some(s) => match s.to_str() { - Some(s) => Some(s.to_string()), - None => return Err(Error::PathToStrConversionFailure(s.to_path_buf()).into()), - }, - None => None, - }, - origin.url().map(|s| s.to_string()), - match include_email { - true => get_email(&repo), - false => None, - }, - )) -} - -fn is_unpushed(repo: &Repository, head: &Reference) -> Result<bool> { - let local_head = head.peel_to_commit()?; - let remote = format!( - "origin/{}", - match head.shorthand() { - Some(v) => v, - None => { - trace!("assuming unpushed; could not determine shorthand for head"); - return Ok(true); - } - } - ); - let remote_head = repo - .resolve_reference_from_short_name(&remote)? - .peel_to_commit()?; - Ok( - matches!(repo.graph_ahead_behind(local_head.id(), remote_head.id()), Ok(number_unique_commits) if number_unique_commits.0 > 0), - ) -} - -/// Find the "user.email" value in the local or global Git config. The "config" method for a -/// "git2::Repository" object will look for a local config first and fallback to global, as needed. -/// Absorb and log any and all errors as the email field is non-critical to our final results. -fn get_email(repository: &Repository) -> Option<String> { - let config = match repository.config() { - Ok(v) => v, - Err(e) => { - trace!("ignored error: {}", e); - return None; - } - }; - let entries = match config.entries(None) { - Ok(v) => v, - Err(e) => { - trace!("ignored error: {}", e); - return None; - } - }; - - // Greedily find our "user.email" value. Return the first result found. - for entry in &entries { - match entry { - Ok(entry) => { - if let Some(name) = entry.name() { - if name == "user.email" { - if let Some(value) = entry.value() { - return Some(value.to_string()); - } - } - } - } - Err(e) => debug!("ignored error: {}", e), - } - } - None -} diff --git a/src/report/mod.rs b/src/report/mod.rs new file mode 100644 index 0000000..aadab52 --- /dev/null +++ b/src/report/mod.rs @@ -0,0 +1,213 @@ +//! This module contains the functionality for generating reports. + +use crate::config::DisplayMode; +use crate::error::Error; +use crate::result::Result; +use crate::status::Status; +use git2::{ErrorCode, Reference, Repository, StatusOptions}; +use log::{debug, trace}; +use rayon::prelude::*; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::Path; + +mod target; + +const HEAD: &str = "HEAD"; + +/// This type represents a [`BTreeMap`] using an optional [`String`] for keys, which represents the +/// parent directory for a group of reports ([`Vec<Report>`]). The values corresponding to those keys +/// are the actual groups of reports. +// NOTE: We use a BTreeMap over a HashMap for sorted keys. +pub type Reports = BTreeMap<Option<String>, Vec<Report>>; + +/// A collection of results for a Git repository at a given path. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Report { + /// The directory name of the Git repository. + pub name: String, + /// The name of the current, open branch. + pub branch: String, + /// The [`Status`] of the working tree. + pub status: Status, + + /// The parent directory of the `path` field. The value will be `None` if a parent is not found. + pub parent: Option<String>, + /// The remote origin URL. The value will be `None` if the URL cannot be found. + pub url: Option<String>, + + /// The "user.email" of a Git config that's only collected when using [`DisplayMode::Standard`]. + pub email: Option<String>, +} + +impl Report { + fn new( + path: &Path, + branch: &str, + status: &Status, + url: Option<String>, + email: Option<String>, + ) -> Result<Self> { + Ok(Self { + name: match path.file_name() { + Some(s) => match s.to_str() { + Some(s) => s.to_string(), + None => return Err(Error::FileNameToStrConversionFailure(path.to_path_buf())), + }, + None => return Err(Error::FileNameNotFound(path.to_path_buf())), + }, + branch: (*branch).into(), + status: *status, + parent: match path.parent() { + Some(s) => match s.to_str() { + Some(s) => Some(s.to_string()), + None => return Err(Error::PathToStrConversionFailure(s.to_path_buf())), + }, + None => None, + }, + url, + email, + }) + } +} + +/// Generate [`Reports`] for a given path and its children. The [`DisplayMode`] is required because +/// any two display modes can require differing ammounts of data to be collected. +pub fn generate_reports(path: &Path, display_mode: &DisplayMode) -> Result<Reports> { + let include_email = match display_mode { + DisplayMode::Standard | DisplayMode::Json => true, + DisplayMode::Classic => false, + }; + + let unprocessed = target::recursive_target_gen(path)? + .par_iter() + .map(|path| generate_report(path, include_email)) + .collect::<Vec<Result<Report>>>(); + + let mut processed = Reports::new(); + for wrapped_report in unprocessed { + match wrapped_report { + Ok(report) => { + if let Some(mut v) = processed.insert(report.parent.clone(), vec![report.clone()]) { + v.push(report.clone()); + processed.insert(report.parent, v); + } + } + Err(e) => return Err(e), + } + } + Ok(processed) +} + +/// Generates a report with a given path. +fn generate_report(repo_path: &Path, include_email: bool) -> Result<Report> { + debug!("attemping to generate report for path: {:?}", repo_path); + let repo = Repository::open(repo_path)?; + let head = match repo.head() { + Ok(head) => Some(head), + Err(ref e) if e.code() == ErrorCode::UnbornBranch || e.code() == ErrorCode::NotFound => { + None + } + Err(e) => return Err(Error::Git2Rs(e)), + }; + let branch = match &head { + Some(head) => head + .shorthand() + .ok_or(Error::GitReferenceShorthandInvalid)?, + None => HEAD, + }; + + // We'll include all untracked files and directories in the status options. + let mut opts = StatusOptions::new(); + opts.include_untracked(true).recurse_untracked_dirs(true); + + // If "head" is "None" and statuses are empty, then the repository must be clean because there + // are no commits to push. + let status = match repo.statuses(Some(&mut opts)) { + Ok(v) if v.is_empty() => match &head { + Some(head) => match is_unpushed(&repo, head)? { + true => Status::Unpushed, + false => Status::Clean, + }, + None => Status::Clean, + }, + Ok(_) => Status::Unclean, + Err(e) if e.code() == ErrorCode::BareRepo => Status::Bare, + Err(e) => return Err(e.into()), + }; + + let url = match repo.find_remote("origin") { + Ok(origin) => origin.url().map(|s| s.to_string()), + Err(e) if e.code() == ErrorCode::NotFound => None, + Err(e) => return Err(Error::Git2Rs(e)), + }; + let email = match include_email { + true => get_email(&repo), + false => None, + }; + debug!( + "generating report for repository at {:?} on branch {} with status {:?}, url {:?}, and email {:?}", + &repo_path, &branch, &status, &url, &email + ); + + Report::new(repo_path, branch, &status, url, email) +} + +/// Checks if local commit(s) on the current branch have not yet been pushed to the remote. +fn is_unpushed(repo: &Repository, head: &Reference) -> Result<bool> { + let local_head = head.peel_to_commit()?; + let remote = format!( + "origin/{}", + match head.shorthand() { + Some(v) => v, + None => { + trace!("assuming unpushed; could not determine shorthand for head"); + return Ok(true); + } + } + ); + let remote_head = repo + .resolve_reference_from_short_name(&remote)? + .peel_to_commit()?; + Ok( + matches!(repo.graph_ahead_behind(local_head.id(), remote_head.id()), Ok(number_unique_commits) if number_unique_commits.0 > 0), + ) +} + +/// Find the "user.email" value in the local or global Git config. The +/// [`git2::Repository::config()`] method will look for a local config first and fallback to +/// global, as needed. Absorb and log any and all errors as the email field is non-critical to +/// the final results. +fn get_email(repository: &Repository) -> Option<String> { + let config = match repository.config() { + Ok(v) => v, + Err(e) => { + trace!("ignored error: {}", e); + return None; + } + }; + let entries = match config.entries(None) { + Ok(v) => v, + Err(e) => { + trace!("ignored error: {}", e); + return None; + } + }; + + // Greedily find our "user.email" value. Return the first result found. + for entry in &entries { + match entry { + Ok(entry) => { + if let Some(name) = entry.name() { + if name == "user.email" { + if let Some(value) = entry.value() { + return Some(value.to_string()); + } + } + } + } + Err(e) => debug!("ignored error: {}", e), + } + } + None +} diff --git a/src/report/target.rs b/src/report/target.rs new file mode 100644 index 0000000..3d5383c --- /dev/null +++ b/src/report/target.rs @@ -0,0 +1,68 @@ +//! This module contains target generation logic for eventually generating reports. + +use log::{error, warn}; +use rayon::prelude::*; +use std::fs::DirEntry; +use std::path::{Path, PathBuf}; +use std::{fs, io}; + +/// This type represents bundled target Git directories that were generated from a given [`DirEntry`]. +type Targets = io::Result<Option<Vec<PathBuf>>>; + +/// Ensure the entry is a directory and is not hidden. Then, check if a Git sub directory exists, +/// which will indicate if the entry is a repository. Finally, generate targets based on that +/// repository. +fn process_entry(entry: &DirEntry) -> Targets { + match entry.file_type()?.is_dir() + && !entry + .file_name() + .to_str() + .map(|file_name| file_name.starts_with('.')) + .unwrap_or(false) + { + true => { + let path = entry.path(); + let git_sub_directory = path.join(".git"); + match git_sub_directory.exists() && git_sub_directory.is_dir() { + true => Ok(Some(vec![path])), + false => Ok(Some(recursive_target_gen(&path)?)), + } + } + false => Ok(None), + } +} + +/// Recursive function for generating targets in a child directory. +pub fn recursive_target_gen(path: &Path) -> io::Result<Vec<PathBuf>> { + let entries: Vec<DirEntry> = match fs::read_dir(&path) { + Ok(o) => o.filter_map(|r| r.ok()).collect(), + Err(e) if e.kind() == io::ErrorKind::PermissionDenied => { + warn!("{}: {}", e, &path.display()); + return Ok(vec![]); + } + Err(e) => { + error!("{}: {}", e, &path.display()); + return Ok(vec![]); + } + }; + + let targets = entries + .par_iter() + .map(process_entry) + .collect::<Vec<Targets>>(); + + let mut results = vec![]; + for target in targets { + match target { + Ok(v) => { + if let Some(mut v) = v { + if !v.is_empty() { + results.append(&mut v); + } + } + } + Err(e) => return Err(e), + } + } + Ok(results) +} diff --git a/src/result.rs b/src/result.rs new file mode 100644 index 0000000..8f1d80a --- /dev/null +++ b/src/result.rs @@ -0,0 +1,6 @@ +//! This module contains the [`crate::result::Result`] type. + +use crate::error::Error; + +/// Generic [`std::result::Result`] wrapper around [`Error`]. +pub type Result<T> = std::result::Result<T, Error>; @@ -1,15 +1,13 @@ -use crate::config::{Config, DisplayMode}; -use crate::display; -use crate::report::Report; -use anyhow::Result; +//! This module contains the execution logic for generating reports and displaying them to `stdout`. -// This function is the primary entrypoint for the crate. It takes a given config and performs -// the end-to-end workflow using it. At this point, all CLI and config file options should be -// set, merged, ignored, etc. +use crate::config::Config; +use crate::result::Result; +use crate::{display, report}; + +/// This function is the primary entrypoint for the crate. It takes a given config and performs +/// the end-to-end workflow using it. At this point, all CLI and config file options should be +/// set, merged, ignored, etc. pub fn run(config: &Config) -> Result<()> { - let reports = Report::generate_reports(&config.path, &config.display_mode)?; - match config.display_mode { - DisplayMode::Standard => display::standard(&reports), - DisplayMode::Classic => display::classic(&reports), - } + let reports = report::generate_reports(&config.path, &config.display_mode)?; + display::display(&config.display_mode, &reports, &config.color_mode) } diff --git a/src/status.rs b/src/status.rs index 162da65..a75ebde 100644 --- a/src/status.rs +++ b/src/status.rs @@ -1,4 +1,9 @@ -#[derive(Debug, Clone, Copy)] +//! This module contains the [`crate::status::Status`] type. + +use serde::{Deserialize, Serialize}; + +/// A summarized interpretation of the status of a Git working tree. +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] pub enum Status { Bare, Clean, |