diff options
author | Nick Gerace <nickagerace@gmail.com> | 2022-12-21 19:29:54 -0500 |
---|---|---|
committer | Nick Gerace <nickagerace@gmail.com> | 2023-01-03 12:42:59 -0500 |
commit | e20956e08d2824d58c24389ab161aa16f0981129 (patch) | |
tree | 8c47de539e968c70a2d3850c5e31ea7ee43081f3 | |
parent | 5c297ada857562b30de3801b09fdbf697182eb76 (diff) | |
download | gfold-e20956e08d2824d58c24389ab161aa16f0981129.zip |
Refactor floating functions into structs or unit structs
Signed-off-by: Nick Gerace <nickagerace@gmail.com>
-rw-r--r-- | CHANGELOG.md | 4 | ||||
-rw-r--r-- | Cargo.lock | 24 | ||||
-rw-r--r-- | crates/gfold/Cargo.toml | 5 | ||||
-rw-r--r-- | crates/gfold/src/cli.rs | 97 | ||||
-rw-r--r-- | crates/gfold/src/collector.rs | 58 | ||||
-rw-r--r-- | crates/gfold/src/collector/target.rs | 83 | ||||
-rw-r--r-- | crates/gfold/src/config.rs | 111 | ||||
-rw-r--r-- | crates/gfold/src/display.rs | 250 | ||||
-rw-r--r-- | crates/gfold/src/display/color.rs | 15 | ||||
-rw-r--r-- | crates/gfold/src/error.rs | 36 | ||||
-rw-r--r-- | crates/gfold/src/main.rs | 127 | ||||
-rw-r--r-- | crates/gfold/src/report.rs | 307 | ||||
-rw-r--r-- | crates/gfold/src/report/target.rs | 73 | ||||
-rw-r--r-- | crates/gfold/src/repository_view.rs | 290 | ||||
-rw-r--r-- | crates/gfold/src/repository_view/submodule_view.rs | 47 | ||||
-rw-r--r-- | crates/gfold/src/run.rs | 24 |
16 files changed, 839 insertions, 712 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 792bbc3..8e30200 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ For new changes prior to version 4.0.0, please see [CHANGELOG_PRE_V4](./docs/CHA - Submodule information in the `json` display mode +### Changed + +- Bump dependencies + ## 4.2.0 - 2022-12-21 ### Changed @@ -77,9 +77,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" -version = "4.0.30" +version = "4.0.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "656ad1e55e23d287773f7d8192c300dc715c3eeded93b3da651d11c42cfd74d2" +checksum = "a7db700bc935f9e43e88d00b0850dae18a63773cfbec6d8e070fccf7fef89a39" dependencies = [ "bitflags", "clap_derive", @@ -375,9 +375,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.138" +version = "0.2.139" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d7e329c562c5dfab7a46a2afabc8b987ab9a4834c9d1ca04dc54c1546cef8" +checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79" [[package]] name = "libgit2-sys" @@ -463,9 +463,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f0b0d4bf799edbc74508c1e8bf170ff5f41238e5f8225603ca7caaae2b7860" +checksum = "6f61fba1741ea2b3d6a1e3178721804bb716a68a6aeba1149b5d52e3d464ea66" [[package]] name = "os_str_bytes" @@ -607,9 +607,9 @@ checksum = "7ef03e0a2b150c7a90d01faf6254c9c48a41e95fb2a8c2ac1c6f0d2b9aefc342" [[package]] name = "rustix" -version = "0.36.5" +version = "0.36.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3807b5d10909833d3e9acd1eb5fb988f79376ff10fce42937de71a449c4c588" +checksum = "4feacf7db682c6c329c4ede12649cd36ecab0f3be5b7d74e6a20304725db4549" dependencies = [ "bitflags", "errno", @@ -633,18 +633,18 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" [[package]] name = "serde" -version = "1.0.151" +version = "1.0.152" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fed41fc1a24994d044e6db6935e69511a1153b52c15eb42493b26fa87feba0" +checksum = "bb7d1f0d3021d347a83e556fc4683dea2ea09d87bccdf88ff5c12545d89d5efb" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.151" +version = "1.0.152" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "255abe9a125a985c05190d687b320c12f9b1f0b99445e608c21ba0782c719ad8" +checksum = "af487d118eecd09402d70a5d72551860e788df87b464af30e5ea6a38c75c541e" dependencies = [ "proc-macro2", "quote", diff --git a/crates/gfold/Cargo.toml b/crates/gfold/Cargo.toml index cfd21b6..31a387d 100644 --- a/crates/gfold/Cargo.toml +++ b/crates/gfold/Cargo.toml @@ -16,6 +16,7 @@ version = "4.2.0" anyhow = { version = "1.0", features = ["backtrace"] } clap = { version = "4.0", features = ["derive"] } dirs = "4.0" +env_logger = { version = "0.10", features = ["humantime"], default_features = false } git2 = { version = "0.15", default_features = false } log = "0.4" rayon = "1.6" @@ -25,10 +26,6 @@ termcolor = "1.1" thiserror = "1.0" toml = "0.5" -# Source: https://github.com/env-logger-rs/env_logger/blob/v0.9.0/Cargo.toml#L47 -# Removed features: ["regex", "termcolor"] -env_logger = { version = "0.10", features = ["humantime"], default_features = false } - [dev-dependencies] pretty_assertions = "1.3" tempfile = "3.3" diff --git a/crates/gfold/src/cli.rs b/crates/gfold/src/cli.rs index d9bb593..6c32848 100644 --- a/crates/gfold/src/cli.rs +++ b/crates/gfold/src/cli.rs @@ -4,10 +4,10 @@ use clap::Parser; use log::debug; use std::env; +use thiserror::Error; use crate::config::{ColorMode, Config, DisplayMode}; -use crate::error::{AnyhowResult, Error}; -use crate::run; +use crate::run::RunHarness; const HELP: &str = "\ More information: https://github.com/nickgerace/gfold @@ -30,6 +30,14 @@ Troubleshooting: \"RUST_BACKTRACE=1\"and \"RUST_LOG=debug\". You can adjust those variable's values to aid investigation."; +#[derive(Error, Debug)] +pub enum CliError { + #[error("invalid color mode provided (exec \"--help\" for options): {0}")] + InvalidColorMode(String), + #[error("invalid display mode provided (exec \"--help\" for options): {0}")] + InvalidDisplayMode(String), +} + #[derive(Parser)] #[command(version, about = HELP, long_about = None)] struct Cli { @@ -57,46 +65,59 @@ struct Cli { ignore_config_file: bool, } -/// Parse CLI arguments, initialize the logger, merge configurations as needed, and call -/// [`run::run()`] with the resulting [`Config`]. -pub fn parse_and_run() -> AnyhowResult<()> { - // First and foremost, get logging up and running. We want logs as quickly as possible for - // debugging by setting "RUST_LOG". - let cli = Cli::parse(); - debug!("collected args"); - - let mut config = match cli.ignore_config_file { - true => Config::try_config_default()?, - false => Config::try_config()?, - }; - debug!("loaded initial config"); - - if let Some(found_display_mode) = &cli.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()).into()), - } +pub struct CliHarness { + cli: Cli, +} + +impl CliHarness { + /// Parse CLI arguments and store the result on the [`self`](Self). + pub fn new() -> Self { + let cli = Cli::parse(); + debug!("collected args"); + Self { cli } } - if let Some(found_color_mode) = &cli.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()).into()), + /// Merge configurations as needed, and call + /// [`RunHarness::run()`](crate::run::RunHarness::run()) with the resulting [`Config`]. + pub fn run(&self) -> anyhow::Result<()> { + let mut config = match self.cli.ignore_config_file { + true => Config::try_config_default()?, + false => Config::try_config()?, + }; + debug!("loaded initial config"); + + if let Some(found_display_mode) = &self.cli.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(CliError::InvalidDisplayMode(found_display_mode.to_string()).into()) + } + } } - } - if let Some(found_path) = &cli.path { - config.path = env::current_dir()?.join(found_path).canonicalize()?; - } + if let Some(found_color_mode) = &self.cli.color_mode { + config.color_mode = match found_color_mode.to_lowercase().as_str() { + "always" => ColorMode::Always, + "compatibility" => ColorMode::Compatibility, + "never" => ColorMode::Never, + _ => return Err(CliError::InvalidColorMode(found_color_mode.to_string()).into()), + } + } - debug!("finalized config options"); - match &cli.dry_run { - true => config.print()?, - false => run::run(&config)?, + if let Some(found_path) = &self.cli.path { + config.path = env::current_dir()?.join(found_path).canonicalize()?; + } + + debug!("finalized config options"); + match &self.cli.dry_run { + true => config.print()?, + false => { + let run_harness = RunHarness::new(&config); + run_harness.run()?; + } + } + Ok(()) } - Ok(()) } diff --git a/crates/gfold/src/collector.rs b/crates/gfold/src/collector.rs new file mode 100644 index 0000000..c596dac --- /dev/null +++ b/crates/gfold/src/collector.rs @@ -0,0 +1,58 @@ +//! This module contains the functionality for generating reports. + +use rayon::prelude::*; +use std::collections::BTreeMap; +use std::path::Path; +use target::TargetCollector; + +use crate::config::DisplayMode; +use crate::repository_view::RepositoryView; + +mod target; + +/// This type represents a [`BTreeMap`] using an optional [`String`] for keys, which represents the +/// parent directory for a group of reports ([`Vec<RepositoryView>`]). The values corresponding to those keys +/// are the actual groups of reports. +/// +/// We use a [`BTreeMap`] instead of a [`HashMap`](std::collections::HashMap) in order to have +/// sorted keys. +pub type RepositoryCollection = BTreeMap<Option<String>, Vec<RepositoryView>>; + +type UnprocessedRepositoryView = anyhow::Result<RepositoryView>; + +/// A unit struct that provides [`Self::run()`], which is used to generated [`RepositoryCollection`]. +pub struct RepositoryCollector; + +impl RepositoryCollector { + /// Generate [`RepositoryCollection`] for a given path and its children. The [`DisplayMode`] is + /// required because any two display modes can require differing amounts of data to be + /// collected. + pub fn run(path: &Path, display_mode: DisplayMode) -> anyhow::Result<RepositoryCollection> { + let (include_email, include_submodules) = match display_mode { + DisplayMode::Classic => (false, false), + DisplayMode::Json => (true, true), + DisplayMode::Standard => (true, false), + }; + + let unprocessed = TargetCollector::run(path.to_path_buf())? + .par_iter() + .map(|path| RepositoryView::new(path, include_email, include_submodules)) + .collect::<Vec<UnprocessedRepositoryView>>(); + + let mut processed = RepositoryCollection::new(); + for maybe_view in unprocessed { + match maybe_view { + Ok(view) => { + if let Some(mut views) = + processed.insert(view.parent.clone(), vec![view.clone()]) + { + views.push(view.clone()); + processed.insert(view.parent, views); + } + } + Err(e) => return Err(e), + } + } + Ok(processed) + } +} diff --git a/crates/gfold/src/collector/target.rs b/crates/gfold/src/collector/target.rs new file mode 100644 index 0000000..f13219d --- /dev/null +++ b/crates/gfold/src/collector/target.rs @@ -0,0 +1,83 @@ +//! This module contains target generation logic required for generating +//! [`RepositoryViews`](crate::repository_view::RepositoryView). + +use log::{debug, error, warn}; +use rayon::prelude::*; +use std::fs::DirEntry; +use std::path::PathBuf; +use std::{fs, io}; + +/// An unprocessed target that needs to be disassembled before consumption. +type UnprocessedTarget = io::Result<MaybeTarget>; + +/// A unit struct used to centralizing target collection method(s). +pub struct TargetCollector; + +impl TargetCollector { + /// Generate targets for a given [`PathBuf`] based on its children (recursively). We use + /// recursion paired with [`rayon`] since we prioritize speed over memory use. + pub fn run(path: PathBuf) -> io::Result<Vec<PathBuf>> { + let entries: Vec<DirEntry> = match fs::read_dir(&path) { + Ok(read_dir) => read_dir.filter_map(|r| r.ok()).collect(), + Err(e) => { + match e.kind() { + io::ErrorKind::PermissionDenied => warn!("{}: {}", e, &path.display()), + _ => error!("{}: {}", e, &path.display()), + } + return Ok(Vec::with_capacity(0)); + } + }; + + let unprocessed = entries + .par_iter() + .map(Self::determine_target) + .collect::<Vec<UnprocessedTarget>>(); + + let mut results = Vec::new(); + for entry in unprocessed { + let entry = entry?; + if let MaybeTarget::Multiple(targets) = entry { + results.extend(targets); + } else if let MaybeTarget::Single(target) = entry { + results.push(target); + } + } + 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. If the directory is not a Git + /// repository, then we will recursively call [`Self::run()`]. + fn determine_target(entry: &DirEntry) -> io::Result<MaybeTarget> { + 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 => { + debug!("found target: {:?}", &path); + Ok(MaybeTarget::Single(path)) + } + false => Ok(MaybeTarget::Multiple(Self::run(path)?)), + } + } + false => Ok(MaybeTarget::None), + } + } +} + +/// An enum that contains 0 to N targets based on the variant. +enum MaybeTarget { + /// Contains multiple targets from recursive call(s) of [`TargetCollector::run()`]. + Multiple(Vec<PathBuf>), + /// Contains a single target. + Single(PathBuf), + /// Does not contain a target. + None, +} diff --git a/crates/gfold/src/config.rs b/crates/gfold/src/config.rs index 4eba4e4..b865388 100644 --- a/crates/gfold/src/config.rs +++ b/crates/gfold/src/config.rs @@ -3,8 +3,13 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::{env, fs, io}; +use thiserror::Error; -use crate::error::{AnyhowResult, Error, IoResult, TomlResult}; +#[derive(Error, Debug)] +pub enum ConfigError { + #[error("could not find home directory")] + HomeDirNotFound, +} /// 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 @@ -19,59 +24,15 @@ pub struct Config { pub color_mode: ColorMode, } -/// 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>, -} - -/// 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. - pub fn try_config() -> AnyhowResult<Self> { + pub fn try_config() -> anyhow::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 or not found, then we fall // back to the "EntryConfig" default before conversion. - let home = dirs::home_dir().ok_or(Error::HomeDirNotFound)?; + let home = dirs::home_dir().ok_or(ConfigError::HomeDirNotFound)?; let path = home.join(".config").join("gfold.toml"); let entry_config = match fs::read_to_string(path) { Ok(contents) => match contents.is_empty() { @@ -88,30 +49,74 @@ impl Config { /// This method does not look for the config file and uses [`EntryConfig`]'s defaults instead. /// Use this method when the user wishes to skip config file lookup. - pub fn try_config_default() -> IoResult<Self> { + pub fn try_config_default() -> io::Result<Self> { Self::from_entry_config(&EntryConfig::default()) } /// This method prints the full config (merged with config file, as needed) as valid, pretty TOML. - pub fn print(self) -> TomlResult<()> { + pub fn print(self) -> std::result::Result<(), toml::ser::Error> { print!("{}", toml::to_string_pretty(&self)?); Ok(()) } - fn from_entry_config(entry_config: &EntryConfig) -> IoResult<Self> { + fn from_entry_config(entry_config: &EntryConfig) -> io::Result<Self> { Ok(Config { path: match &entry_config.path { - Some(s) => s.clone(), + Some(path) => path.clone(), None => env::current_dir()?.canonicalize()?, }, display_mode: match &entry_config.display_mode { - Some(s) => s.clone(), + Some(display_mode) => *display_mode, None => DisplayMode::Standard, }, color_mode: match &entry_config.color_mode { - Some(s) => s.clone(), + Some(color_mode) => *color_mode, None => ColorMode::Always, }, }) } } + +/// 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>, +} + +/// 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 displays 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 might 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, Copy)] +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, Copy)] +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, +} diff --git a/crates/gfold/src/display.rs b/crates/gfold/src/display.rs index a613c3a..c2d4862 100644 --- a/crates/gfold/src/display.rs +++ b/crates/gfold/src/display.rs @@ -1,148 +1,166 @@ //! This module contains the functionality for displaying reports to `stdout`. +use color::ColorHarness; use log::debug; use log::warn; -use std::path::Path; +use std::io; +use std::path::{Path, PathBuf}; +use thiserror::Error; +use crate::collector::RepositoryCollection; use crate::config::{ColorMode, DisplayMode}; -use crate::display::color::ColorHarness; -use crate::error::{AnyhowResult, Error, IoResult, SerdeJsonResult}; -use crate::report::LabeledReports; 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: &LabeledReports, - color_mode: &ColorMode, -) -> AnyhowResult<()> { - match display_mode { - DisplayMode::Standard => standard(reports, color_mode)?, - DisplayMode::Json => json(reports)?, - DisplayMode::Classic => classic(reports, color_mode)?, - } - Ok(()) +#[derive(Error, Debug)] +pub enum DisplayError { + #[error("could not convert path (Path) to &str: {0}")] + PathToStrConversionFailure(PathBuf), +} + +/// This struct is used for displaying the contents of a [`RepositoryCollection`] to `stdout`. +pub struct DisplayHarness { + display_mode: DisplayMode, + color_mode: ColorMode, } -/// Display [`LabeledReports`] to `stdout` in the standard (default) format. -fn standard(reports: &LabeledReports, color_mode: &ColorMode) -> AnyhowResult<()> { - debug!("detected standard display mode"); - let mut all_reports = Vec::new(); - for grouped_report in reports { - all_reports.append(&mut grouped_report.1.clone()); +impl DisplayHarness { + pub fn new(display_mode: DisplayMode, color_mode: ColorMode) -> Self { + Self { + display_mode, + color_mode, + } } - 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); + /// This function chooses the display execution function based on the [`DisplayMode`] provided. + pub fn run(&self, reports: &RepositoryCollection) -> anyhow::Result<()> { + match self.display_mode { + DisplayMode::Standard => Self::standard(reports, self.color_mode)?, + DisplayMode::Json => Self::json(reports)?, + DisplayMode::Classic => Self::classic(reports, self.color_mode)?, + } + Ok(()) + } - for report in all_reports { - color_harness.write_bold(&report.name, false)?; + /// Display [`RepositoryCollection`] to `stdout` in the standard (default) format. + fn standard(reports: &RepositoryCollection, color_mode: ColorMode) -> anyhow::Result<()> { + debug!("detected standard display mode"); + 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 parent = match report.parent { - Some(s) => s, - None => { - warn!("parent is empty for report: {}", report.name); - continue; + 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 collector: {}", report.name); + continue; + } + }; + let full_path = Path::new(&parent).join(&report.name); + let full_path_formatted = format!( + " ~ {}", + full_path + .to_str() + .ok_or_else(|| DisplayError::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); } - }; - 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(()) } - Ok(()) -} -/// Display [`LabeledReports`] to `stdout` in JSON format. -fn json(reports: &LabeledReports) -> SerdeJsonResult<()> { - debug!("detected json display mode"); - let mut all_reports = Vec::new(); - for grouped_report in reports { - all_reports.append(&mut grouped_report.1.clone()); + /// Display [`RepositoryCollection`] to `stdout` in JSON format. + fn json(reports: &RepositoryCollection) -> serde_json::Result<()> { + debug!("detected json display mode"); + 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(()) } - 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 [`LabeledReports`] to `stdout` in the classic format. -fn classic(reports: &LabeledReports, color_mode: &ColorMode) -> IoResult<()> { - debug!("detected classic display mode"); - let color_harness = ColorHarness::new(color_mode); - - let length = reports.keys().len(); - let mut first = true; - for (title, group) in reports { - // FIXME(nick): make group title matching less cumbersome. - if length > 1 { - match first { - true => { - first = false; + /// Display [`RepositoryCollection`] to `stdout` in the classic format. + fn classic(reports: &RepositoryCollection, color_mode: ColorMode) -> io::Result<()> { + debug!("detected classic display mode"); + let color_harness = ColorHarness::new(&color_mode); + + let length = reports.keys().len(); + let mut first = true; + for (title, group) in reports { + // FIXME(nick): make group title matching less cumbersome. + if length > 1 { + match first { + true => { + first = false; + } + false => println!(), } - false => println!(), + color_harness.write_bold( + match &title { + Some(s) => s, + None => NONE, + }, + true, + )?; } - 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 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 - ); + 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(()) } - Ok(()) } diff --git a/crates/gfold/src/display/color.rs b/crates/gfold/src/display/color.rs index 153b8ab..9a69e84 100644 --- a/crates/gfold/src/display/color.rs +++ b/crates/gfold/src/display/color.rs @@ -1,10 +1,10 @@ //! This module provides a harness for non-trivial displays of information to `stdout`. +use std::io; use std::io::Write; use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor}; use crate::config::ColorMode; -use crate::error::IoResult; use crate::status::Status; /// This harness provides methods to write to `stdout`. It maps the internal [`ColorMode`] type to @@ -25,7 +25,7 @@ impl ColorHarness { } /// Writes the [`Status`] of the Git repository to `stdout`. - pub fn write_status(&self, status: &Status, status_width: usize) -> IoResult<()> { + 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 | Status::Unknown => Color::Red, @@ -43,12 +43,12 @@ impl ColorHarness { } /// Writes the input [`&str`] to `stdout` in bold. - pub fn write_bold(&self, input: &str, newline: bool) -> IoResult<()> { + 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) -> IoResult<()> { + pub fn write_gray(&self, input: &str, newline: bool) -> io::Result<()> { // FIXME(nick): check why Color::Rg(128, 128, 128) breaks in tmux on macOS Terminal.app. self.write_color( input, @@ -60,7 +60,12 @@ impl ColorHarness { ) } - fn write_color(&self, input: &str, newline: bool, color_spec: &mut ColorSpec) -> IoResult<()> { + 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 { diff --git a/crates/gfold/src/error.rs b/crates/gfold/src/error.rs deleted file mode 100644 index dd2ec3a..0000000 --- a/crates/gfold/src/error.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! This module contains the [`crate::error::Error`] type. - -use std::path::PathBuf; -use thiserror::Error; - -// Type aliases for external results. -pub type AnyhowResult<T> = anyhow::Result<T>; -pub type IoResult<T> = std::io::Result<T>; -pub type LibGitResult<T> = std::result::Result<T, git2::Error>; -pub type SerdeJsonResult<T> = serde_json::error::Result<T>; -pub type TomlResult<T> = std::result::Result<T, toml::ser::Error>; - -// Type alias for internal errors. -pub type Result<T> = std::result::Result<T, Error>; - -#[derive(Error, Debug)] -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}")] - FileNameToStrConversionFailure(PathBuf), - #[error("could not convert path (Path) to &str: {0}")] - PathToStrConversionFailure(PathBuf), - - #[error("full shorthand for Git reference is invalid UTF-8")] - GitReferenceShorthandInvalid, - #[error("submodule name is invalid UTF-8")] - SubmoduleNameInvalid, - #[error("could not find home directory")] - HomeDirNotFound, -} diff --git a/crates/gfold/src/main.rs b/crates/gfold/src/main.rs index 074d357..f210083 100644 --- a/crates/gfold/src/main.rs +++ b/crates/gfold/src/main.rs @@ -7,26 +7,28 @@ use log::debug; use log::LevelFilter; use std::env; -use crate::error::AnyhowResult; +use crate::cli::CliHarness; mod cli; +mod collector; mod config; mod display; -mod error; -mod report; +mod repository_view; mod run; mod status; -/// Initializes the logger based on the debug flag and `RUST_LOG` environment variable and calls -/// [`cli::parse_and_run()`] to generate a [`config::Config`] and eventually call [`run::run()`]. -fn main() -> AnyhowResult<()> { +/// Initializes the logger based on the debug flag and `RUST_LOG` environment variable and uses +/// the [`CliHarness`] to generate a [`Config`](config::Config). Then, this calls +/// [`CliHarness::run()`]. +fn main() -> anyhow::Result<()> { match env::var("RUST_LOG").is_err() { true => Builder::new().filter_level(LevelFilter::Off).init(), false => env_logger::init(), } debug!("initialized logger"); - cli::parse_and_run()?; + let cli_harness = CliHarness::new(); + cli_harness.run()?; Ok(()) } @@ -34,27 +36,28 @@ fn main() -> AnyhowResult<()> { mod tests { use super::*; - use crate::config::{ColorMode, Config, DisplayMode}; - use crate::report::{LabeledReports, Report}; - use crate::status::Status; - use anyhow::anyhow; use git2::ErrorCode; use git2::Oid; use git2::Repository; use git2::Signature; use pretty_assertions::assert_eq; - use std::collections::BTreeMap; use std::fs::File; use std::path::{Path, PathBuf}; use std::{fs, io}; use tempfile::tempdir; + use crate::collector::{RepositoryCollection, RepositoryCollector}; + use crate::config::{ColorMode, Config, DisplayMode}; + use crate::repository_view::RepositoryView; + use crate::run::RunHarness; + use crate::status::Status; + /// This integration test for `gfold` covers an end-to-end usage scenario. It uses the /// [`tempfile`](tempfile) crate to create some repositories with varying states and levels /// of nesting. #[test] - fn integration() -> AnyhowResult<()> { + fn integration() -> anyhow::Result<()> { env_logger::builder() .is_test(true) .filter_level(LevelFilter::Info) @@ -128,107 +131,107 @@ mod tests { let mut config = Config::try_config_default()?; config.path = root.path().to_path_buf(); config.color_mode = ColorMode::Never; - run::run(&config)?; + let run_harness = RunHarness::new(&config); + run_harness.run()?; - // Now, let's ensure our reports are what we expect. - let mut expected_reports: LabeledReports = BTreeMap::new(); - let expected_reports_key = root + // Now, let's run a second time, but generate the collection directly and ensure the + // resulting views match what we expect. + let mut expected_collection = RepositoryCollection::new(); + let expected_views_key = root .path() .to_str() .ok_or_else(|| anyhow!("could not convert PathBuf to &str"))? .to_string(); - let mut expected_reports_raw = vec![ - Report::new( + let mut expected_views = vec![ + RepositoryView::finalize( &repo_one, - "HEAD", - &Status::Unclean, + Some("HEAD".to_string()), + Status::Unclean, None, None, Vec::with_capacity(0), )?, - Report::new( + RepositoryView::finalize( &repo_two, - "HEAD", - &Status::Clean, + Some("HEAD".to_string()), + Status::Clean, None, None, Vec::with_capacity(0), )?, - Report::new( + RepositoryView::finalize( &repo_three, - "HEAD", - &Status::Clean, + Some("HEAD".to_string()), + Status::Clean, None, None, Vec::with_capacity(0), )?, ]; - expected_reports_raw.sort_by(|a, b| a.name.cmp(&b.name)); - expected_reports.insert(Some(expected_reports_key), expected_reports_raw); + expected_views.sort_by(|a, b| a.name.cmp(&b.name)); + expected_collection.insert(Some(expected_views_key), expected_views); - // Add nested reports to the expected reports map. - let nested_expected_reports_key = nested + // Add nested views to the expected collection. + let nested_expected_views_key = nested .to_str() .ok_or_else(|| anyhow!("could not convert PathBuf to &str"))? .to_string(); - let mut nested_expected_reports_raw = vec![ - Report::new( + let mut nested_expected_views_raw = vec![ + RepositoryView::finalize( &repo_four, - "HEAD", - &Status::Clean, + Some("HEAD".to_string()), + Status::Clean, Some("https://github.com/nickgerace/gfold".to_string()), None, Vec::with_capacity(0), )?, - Report::new( + RepositoryView::finalize( &repo_five, - "HEAD", - &Status::Unclean, + Some("HEAD".to_string()), + Status::Unclean, None, None, Vec::with_capacity(0), )?, - Report::new( + RepositoryView::finalize( &repo_six, - "master", - &Status::Unpushed, + Some("master".to_string()), + Status::Unpushed, Some("https://github.com/nickgerace/gfold".to_string()), None, Vec::with_capacity(0), )?, - Report::new( + RepositoryView::finalize( &repo_seven, - "needtopush", - &Status::Unpushed, + Some("needtopush".to_string()), + Status::Unpushed, Some("https://github.com/nickgerace/gfold".to_string()), None, Vec::with_capacity(0), )?, ]; - nested_expected_reports_raw.sort_by(|a, b| a.name.cmp(&b.name)); - expected_reports.insert( - Some(nested_expected_reports_key), - nested_expected_reports_raw, - ); + nested_expected_views_raw.sort_by(|a, b| a.name.cmp(&b.name)); + expected_collection.insert(Some(nested_expected_views_key), nested_expected_views_raw); - // Use classic display mode to avoid collecting email results. + // Generate a collection. Use classic display mode to avoid collecting email results. config.display_mode = DisplayMode::Classic; - let found_labeled_reports = report::generate_reports(&config.path, &config.display_mode)?; - let mut found_labeled_reports_sorted = LabeledReports::new(); - for labeled_report in found_labeled_reports { - let mut value = labeled_report.1; + let found_collection = RepositoryCollector::run(&config.path, config.display_mode)?; + + // Ensure the found collection matches our expected one. Sort the collection for the + // assertion. + let mut found_collection_sorted = RepositoryCollection::new(); + for (key, mut value) in found_collection { value.sort_by(|a, b| a.name.cmp(&b.name)); - found_labeled_reports_sorted.insert(labeled_report.0.clone(), value.clone()); + found_collection_sorted.insert(key, value); } - assert_eq!( - expected_reports, // expected - found_labeled_reports_sorted // actual + expected_collection, // expected + found_collection_sorted // actual ); Ok(()) } - fn create_directory<P: AsRef<Path>>(parent: P, name: &str) -> AnyhowResult<PathBuf> { + fn create_directory<P: AsRef<Path>>(parent: P, name: &str) -> anyhow::Result<PathBuf> { let parent = parent.as_ref(); let new_directory = parent.join(name); @@ -244,13 +247,13 @@ mod tests { Ok(new_directory) } - fn create_file<P: AsRef<Path>>(parent: P) -> AnyhowResult<()> { + fn create_file<P: AsRef<Path>>(parent: P) -> anyhow::Result<()> { let parent = parent.as_ref(); File::create(parent.join("file"))?; Ok(()) } - fn commit_head_and_create_branch(repository: &Repository, name: &str) -> AnyhowResult<()> { + fn commit_head_and_create_branch(repository: &Repository, name: &str) -> anyhow::Result<()> { // We need to commit at least once before branching. let commit_oid = commit(repository, "HEAD")?; let commit = repository.find_commit(commit_oid)?; @@ -259,7 +262,7 @@ mod tests { } // Source: https://github.com/rust-lang/git2-rs/pull/885 - fn commit(repository: &Repository, update_ref: &str) -> AnyhowResult<Oid> { + fn commit(repository: &Repository, update_ref: &str) -> anyhow::Result<Oid> { // We will commit the contents of the index. let mut index = repository.index()?; let tree_oid = index.write_tree()?; diff --git a/crates/gfold/src/report.rs b/crates/gfold/src/report.rs deleted file mode 100644 index e72a995..0000000 --- a/crates/gfold/src/report.rs +++ /dev/null @@ -1,307 +0,0 @@ -//! This module contains the functionality for generating reports. - -use git2::{ErrorCode, Reference, Remote, Repository, StatusOptions}; -use log::{debug, error, trace}; -use rayon::prelude::*; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; -use std::path::Path; - -use crate::config::DisplayMode; -use crate::error::{AnyhowResult, Error, LibGitResult, Result}; -use crate::status::Status; - -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(nick): We use a BTreeMap over a HashMap for sorted keys. -pub type LabeledReports = BTreeMap<Option<String>, Vec<Report>>; - -/// A collection of results for a Git repository at a given path. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -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`] - /// and [`DisplayMode::Json`]. - pub email: Option<String>, - /// The submodules of a repository that are only collected when using [`DisplayMode::Json`]. - pub submodules: Vec<SubmoduleReport>, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -pub struct SubmoduleReport { - pub name: String, - pub status: Status, -} - -impl Report { - pub fn new( - path: &Path, - branch: &str, - status: &Status, - url: Option<String>, - email: Option<String>, - submodules: Vec<SubmoduleReport>, - ) -> 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, - submodules, - }) - } -} - -/// Generate [`LabeledReports`] for a given path and its children. The [`DisplayMode`] is required -/// because any two display modes can require differing amounts of data to be collected. -pub fn generate_reports(path: &Path, display_mode: &DisplayMode) -> AnyhowResult<LabeledReports> { - let (include_email, include_submodules) = match display_mode { - DisplayMode::Classic => (false, false), - DisplayMode::Json => (true, true), - DisplayMode::Standard => (true, false), - }; - - let unprocessed = target::generate_targets(path.to_path_buf())? - .par_iter() - .map(|path| generate_report(path, include_email, include_submodules)) - .collect::<Vec<AnyhowResult<Report>>>(); - - let mut processed = LabeledReports::new(); - for wrapped_report in unprocessed { - match wrapped_report { - Ok(report) => { - if let Some(mut reports) = - processed.insert(report.parent.clone(), vec![report.clone()]) - { - reports.push(report.clone()); - processed.insert(report.parent, reports); - } - } - Err(e) => return Err(e), - } - } - Ok(processed) -} - -/// Generates a report with a given path. -fn generate_report( - repo_path: &Path, - include_email: bool, - include_submodules: bool, -) -> AnyhowResult<Report> { - debug!( - "attempting to generate report for repository at path: {:?}", - repo_path - ); - - let repo = match Repository::open(repo_path) { - Ok(repo) => repo, - Err(e) if e.message() == "unsupported extension name extensions.worktreeconfig" => { - error!("skipping error ({e}) until upstream libgit2 issue is resolved: https://github.com/libgit2/libgit2/issues/6044"); - let unknown_report = Report::new( - repo_path, - "unknown", - &Status::Unknown, - None, - None, - Vec::with_capacity(0), - )?; - return Ok(unknown_report); - } - Err(e) => return Err(e.into()), - }; - let (status, head, remote) = find_status(&repo)?; - - let submodules = if include_submodules { - let mut submodules = Vec::new(); - for submodule in repo.submodules()? { - if let Ok(subrepo) = submodule.open() { - let (status, _, _) = find_status(&subrepo)?; - let name = submodule.name().ok_or(Error::SubmoduleNameInvalid)?; - - submodules.push(SubmoduleReport { - name: name.to_string(), - status, - }); - } - } - submodules - } else { - Vec::with_capacity(0) - }; - - let branch = match &head { - Some(head) => head - .shorthand() - .ok_or(Error::GitReferenceShorthandInvalid)?, - None => HEAD, - }; - - let url = match remote { - Some(remote) => remote.url().map(|s| s.to_string()), - None => None, - }; - - let email = match include_email { - true => get_email(&repo), - false => None, - }; - - debug!( - "finalized report collection for repository at path: {:?}", - repo_path - ); - Ok(Report::new( - repo_path, branch, &status, url, email, submodules, - )?) -} - -/// Find the [`Status`] for a given [`Repository`](git2::Repository). The -/// [`head`](Option<git2::Reference>) and [`remote`](Option<git2::Remote>) are also returned. -fn find_status(repo: &Repository) -> AnyhowResult<(Status, Option<Reference>, Option<Remote>)> { - 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(e.into()), - }; - - // Greedily chooses a remote if "origin" is not found. - let (remote, remote_name) = match repo.find_remote("origin") { - Ok(origin) => (Some(origin), Some("origin".to_string())), - Err(e) if e.code() == ErrorCode::NotFound => choose_remote_greedily(repo)?, - Err(e) => return Err(e.into()), - }; - - // 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 remote_name { - Some(remote_name) => match is_unpushed(repo, head, &remote_name)? { - true => Status::Unpushed, - false => Status::Clean, - }, - None => Status::Clean, - }, - None => Status::Clean, - }, - Ok(_) => Status::Unclean, - Err(e) if e.code() == ErrorCode::BareRepo => Status::Bare, - Err(e) => return Err(e.into()), - }; - - Ok((status, head, remote)) -} - -/// Checks if local commit(s) on the current branch have not yet been pushed to the remote. -fn is_unpushed(repo: &Repository, head: &Reference, remote_name: &str) -> LibGitResult<bool> { - let local_head = head.peel_to_commit()?; - let remote = format!( - "{}/{}", - remote_name, - match head.shorthand() { - Some(v) => v, - None => { - debug!("assuming unpushed; could not determine shorthand for head"); - return Ok(true); - } - } - ); - let remote_head = match repo.resolve_reference_from_short_name(&remote) { - Ok(reference) => reference.peel_to_commit()?, - Err(e) => { - debug!("assuming unpushed; could not resolve remote reference from short name (ignored error: {})", e); - return Ok(true); - } - }; - 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 mut 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. - while let Some(entry) = entries.next() { - 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 -} - -fn choose_remote_greedily( - repository: &Repository, -) -> LibGitResult<(Option<Remote>, Option<String>)> { - let remotes = repository.remotes()?; - Ok(match remotes.get(0) { - Some(remote_name) => ( - Some(repository.find_remote(remote_name)?), - Some(remote_name.to_string()), - ), - None => (None, None), - }) -} diff --git a/crates/gfold/src/report/target.rs b/crates/gfold/src/report/target.rs deleted file mode 100644 index f9475ce..0000000 --- a/crates/gfold/src/report/target.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! This module contains target generation logic for eventually generating reports. - -use log::{debug, error, warn}; -use rayon::prelude::*; -use std::fs::DirEntry; -use std::path::PathBuf; -use std::{fs, io}; - -use crate::error::IoResult; - -enum TargetOption { - Multiple(Vec<PathBuf>), - Single(PathBuf), - None, -} - -/// 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) -> IoResult<TargetOption> { - 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 => { - debug!("found target: {:?}", &path); - Ok(TargetOption::Single(path)) - } - false => Ok(TargetOption::Multiple(generate_targets(path)?)), - } - } - false => Ok(TargetOption::None), - } -} - -/// Generate targets from a given [`PathBuf`] based on its children (recursively). -/// We use recursion paired with [`rayon`] since we prioritize speed over memory use. -pub fn generate_targets(path: PathBuf) -> IoResult<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 processed = entries - .par_iter() - .map(process_entry) - .collect::<Vec<IoResult<TargetOption>>>(); - - let mut results = Vec::new(); - for entry in processed { - let entry = entry?; - if let TargetOption::Multiple(targets) = entry { - results.extend(targets); - } else if let TargetOption::Single(target) = entry { - results.push(target); - } - } - Ok(results) -} diff --git a/crates/gfold/src/repository_view.rs b/crates/gfold/src/repository_view.rs new file mode 100644 index 0000000..fce5b47 --- /dev/null +++ b/crates/gfold/src/repository_view.rs @@ -0,0 +1,290 @@ +use git2::{ErrorCode, Reference, Remote, Repository, StatusOptions}; +use log::{debug, error, trace}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use submodule_view::SubmoduleView; +use thiserror::Error; + +use crate::status::Status; + +mod submodule_view; + +#[derive(Error, Debug)] +pub enum RepositoryViewError { + #[error("received None (Option<&OsStr>) for file name: {0}")] + FileNameNotFound(PathBuf), + #[error("could not convert file name (&OsStr) to &str: {0}")] + FileNameToStrConversionFailure(PathBuf), + #[error("full shorthand for Git reference is invalid UTF-8")] + GitReferenceShorthandInvalid, + #[error("could not convert path (Path) to &str: {0}")] + PathToStrConversionFailure(PathBuf), +} + +/// A collection of results for a Git repository at a given path. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct RepositoryView { + /// 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`](crate::config::DisplayMode::Standard) + /// and [`DisplayMode::Json`](crate::config::DisplayMode::Json). + pub email: Option<String>, + /// The submodules of a repository_view that are only collected when using + /// [`DisplayMode::Json`](crate::config::DisplayMode::Json). + pub submodules: Vec<SubmoduleView>, +} + +impl RepositoryView { + /// Generates a collector for a given path. + pub fn new( + repo_path: &Path, + include_email: bool, + include_submodules: bool, + ) -> anyhow::Result<RepositoryView> { + debug!( + "attempting to generate collector for repository_view at path: {:?}", + repo_path + ); + + let repo = match Repository::open(repo_path) { + Ok(repo) => repo, + Err(e) if e.message() == "unsupported extension name extensions.worktreeconfig" => { + error!("skipping error ({e}) until upstream libgit2 issue is resolved: https://github.com/libgit2/libgit2/issues/6044"); + let unknown_report = RepositoryView::finalize( + repo_path, + None, + Status::Unknown, + None, + None, + Vec::with_capacity(0), + )?; + return Ok(unknown_report); + } + Err(e) => return Err(e.into()), + }; + let (status, head, remote) = RepositoryView::find_status(&repo)?; + + let submodules = if include_submodules { + SubmoduleView::list(&repo)? + } else { + Vec::with_capacity(0) + }; + + let branch = match &head { + Some(head) => head + .shorthand() + .ok_or(RepositoryViewError::GitReferenceShorthandInvalid)?, + None => "HEAD", + }; + + let url = match remote { + Some(remote) => remote.url().map(|s| s.to_string()), + None => None, + }; + + let email = match include_email { + true => Self::get_email(&repo), + false => None, + }; + + debug!( + "finalized collector collection for repository_view at path: {:?}", + repo_path + ); + Ok(RepositoryView::finalize( + repo_path, + Some(branch.to_string()), + status, + url, + email, + submodules, + )?) + } + + pub fn finalize( + path: &Path, + branch: Option<String>, + status: Status, + url: Option<String>, + email: Option<String>, + submodules: Vec<SubmoduleView>, + ) -> Result<Self, RepositoryViewError> { + let name = match path.file_name() { + Some(s) => match s.to_str() { + Some(s) => s.to_string(), + None => { + return Err(RepositoryViewError::FileNameToStrConversionFailure( + path.to_path_buf(), + )) + } + }, + None => return Err(RepositoryViewError::FileNameNotFound(path.to_path_buf())), + }; + let parent = match path.parent() { + Some(s) => match s.to_str() { + Some(s) => Some(s.to_string()), + None => { + return Err(RepositoryViewError::PathToStrConversionFailure( + s.to_path_buf(), + )) + } + }, + None => None, + }; + let branch = match branch { + Some(branch) => branch, + None => "unknown".to_string(), + }; + + Ok(Self { + name, + branch, + status, + parent, + url, + email, + submodules, + }) + } + + /// Find the [`Status`] for a given [`Repository`](git2::Repository). The + /// [`head`](Option<git2::Reference>) and [`remote`](Option<git2::Remote>) are also returned. + pub fn find_status( + repo: &Repository, + ) -> anyhow::Result<(Status, Option<Reference>, Option<Remote>)> { + 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(e.into()), + }; + + // Greedily chooses a remote if "origin" is not found. + let (remote, remote_name) = match repo.find_remote("origin") { + Ok(origin) => (Some(origin), Some("origin".to_string())), + Err(e) if e.code() == ErrorCode::NotFound => Self::choose_remote_greedily(repo)?, + Err(e) => return Err(e.into()), + }; + + // 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_view 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 remote_name { + Some(remote_name) => { + match RepositoryView::is_unpushed(repo, head, &remote_name)? { + true => Status::Unpushed, + false => Status::Clean, + } + } + None => Status::Clean, + }, + None => Status::Clean, + }, + Ok(_) => Status::Unclean, + Err(e) if e.code() == ErrorCode::BareRepo => Status::Bare, + Err(e) => return Err(e.into()), + }; + + Ok((status, head, remote)) + } + + fn choose_remote_greedily( + repository: &Repository, + ) -> Result<(Option<Remote>, Option<String>), git2::Error> { + let remotes = repository.remotes()?; + Ok(match remotes.get(0) { + Some(remote_name) => ( + Some(repository.find_remote(remote_name)?), + Some(remote_name.to_string()), + ), + None => (None, None), + }) + } + + /// Checks if local commit(s) on the current branch have not yet been pushed to the remote. + fn is_unpushed( + repo: &Repository, + head: &Reference, + remote_name: &str, + ) -> Result<bool, git2::Error> { + let local_head = head.peel_to_commit()?; + let remote = format!( + "{}/{}", + remote_name, + match head.shorthand() { + Some(v) => v, + None => { + debug!("assuming unpushed; could not determine shorthand for head"); + return Ok(true); + } + } + ); + let remote_head = match repo.resolve_reference_from_short_name(&remote) { + Ok(reference) => reference.peel_to_commit()?, + Err(e) => { + debug!("assuming unpushed; could not resolve remote reference from short name (ignored error: {})", e); + return Ok(true); + } + }; + 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 + /// [`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 mut 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. + while let Some(entry) = entries.next() { + 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/crates/gfold/src/repository_view/submodule_view.rs b/crates/gfold/src/repository_view/submodule_view.rs new file mode 100644 index 0000000..54f0502 --- /dev/null +++ b/crates/gfold/src/repository_view/submodule_view.rs @@ -0,0 +1,47 @@ +//! This module contains the ability to gather information on submodules for a given [`Repository`]. + +use git2::Repository; +use log::error; +use serde::Deserialize; +use serde::Serialize; +use thiserror::Error; + +use crate::repository_view::RepositoryView; +use crate::status::Status; + +#[derive(Error, Debug)] +pub enum SubmoduleError { + #[error("submodule name is invalid UTF-8")] + SubmoduleNameInvalid, +} + +/// The view of a submodule with a [`Repository`]. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct SubmoduleView { + pub name: String, + pub status: Status, +} + +impl SubmoduleView { + /// Generate a list of [`submodule view(s)`](Self) for a given [`Repository`]. + pub fn list(repo: &Repository) -> anyhow::Result<Vec<Self>> { + let mut submodules = Vec::new(); + for submodule in repo.submodules()? { + match submodule.open() { + Ok(subrepo) => { + let (status, _, _) = RepositoryView::find_status(&subrepo)?; + let name = submodule + .name() + .ok_or(SubmoduleError::SubmoduleNameInvalid)?; + + submodules.push(Self { + name: name.to_string(), + status, + }); + } + Err(e) => error!("could not open submodule as repository: {e}"), + } + } + Ok(submodules) + } +} diff --git a/crates/gfold/src/run.rs b/crates/gfold/src/run.rs index 442c5e4..f168761 100644 --- a/crates/gfold/src/run.rs +++ b/crates/gfold/src/run.rs @@ -1,13 +1,25 @@ //! This module contains the execution logic for generating reports and displaying them to `stdout`. +use crate::collector::RepositoryCollector; use crate::config::Config; -use crate::error::AnyhowResult; -use crate::{display, report}; +use crate::display::DisplayHarness; -/// This function is the primary entrypoint for the crate. It takes a given config and performs +/// This struct provides the primary entrypoint for this 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) -> AnyhowResult<()> { - let reports = report::generate_reports(&config.path, &config.display_mode)?; - display::display(&config.display_mode, &reports, &config.color_mode) +pub struct RunHarness<'config> { + config: &'config Config, +} + +impl<'config> RunHarness<'config> { + pub fn new(config: &'config Config) -> Self { + Self { config } + } + + pub fn run(&self) -> anyhow::Result<()> { + let repository_collection = + RepositoryCollector::run(&self.config.path, self.config.display_mode)?; + let display_harness = DisplayHarness::new(self.config.display_mode, self.config.color_mode); + display_harness.run(&repository_collection) + } } |