summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorNick Gerace <nickagerace@gmail.com>2022-12-21 19:29:54 -0500
committerNick Gerace <nickagerace@gmail.com>2023-01-03 12:42:59 -0500
commite20956e08d2824d58c24389ab161aa16f0981129 (patch)
tree8c47de539e968c70a2d3850c5e31ea7ee43081f3
parent5c297ada857562b30de3801b09fdbf697182eb76 (diff)
downloadgfold-e20956e08d2824d58c24389ab161aa16f0981129.zip
Refactor floating functions into structs or unit structs
Signed-off-by: Nick Gerace <nickagerace@gmail.com>
-rw-r--r--CHANGELOG.md4
-rw-r--r--Cargo.lock24
-rw-r--r--crates/gfold/Cargo.toml5
-rw-r--r--crates/gfold/src/cli.rs97
-rw-r--r--crates/gfold/src/collector.rs58
-rw-r--r--crates/gfold/src/collector/target.rs83
-rw-r--r--crates/gfold/src/config.rs111
-rw-r--r--crates/gfold/src/display.rs250
-rw-r--r--crates/gfold/src/display/color.rs15
-rw-r--r--crates/gfold/src/error.rs36
-rw-r--r--crates/gfold/src/main.rs127
-rw-r--r--crates/gfold/src/report.rs307
-rw-r--r--crates/gfold/src/report/target.rs73
-rw-r--r--crates/gfold/src/repository_view.rs290
-rw-r--r--crates/gfold/src/repository_view/submodule_view.rs47
-rw-r--r--crates/gfold/src/run.rs24
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
diff --git a/Cargo.lock b/Cargo.lock
index 644c37f..e6f06f0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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)
+ }
}