Repository: nickgerace/gfold (language: rust)

Issue:
Only allow the log level to be changed via an explicit flag
Using `RUST_LOG` is confusing and counterintuitive for users. `gfold` should own its own logging bootstrapping sequence.


Discussion / hints:

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index d67845f..2ce9bbe 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -58,11 +58,12 @@ dependencies = [
 
 [[package]]
 name = "anstyle-wincon"
-version = "3.0.6"
+version = "3.0.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125"
+checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e"
 dependencies = [
  "anstyle",
+ "once_cell",
  "windows-sys",
 ]
 
@@ -92,15 +93,15 @@ dependencies = [
 
 [[package]]
 name = "bitflags"
-version = "2.6.0"
+version = "2.7.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
+checksum = "1be3f42a67d6d345ecd59f675f3f012d6974981560836e938c22b424b85ce1be"
 
 [[package]]
 name = "cc"
-version = "1.2.7"
+version = "1.2.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a012a0df96dd6d06ba9a1b29d6402d1a5d77c6befd2566afdc26e10603dc93d7"
+checksum = "c8293772165d9345bdaaa39b45b2109591e63fe5e6fbc23c6ff930a048aa310b"
 dependencies = [
  "jobserver",
  "libc",
@@ -115,19 +116,29 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
 
 [[package]]
 name = "clap"
-version = "4.5.24"
+version = "4.5.26"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9560b07a799281c7e0958b9296854d6fafd4c5f31444a7e5bb1ad6dde5ccf1bd"
+checksum = "a8eb5e908ef3a6efbe1ed62520fb7287959888c88485abe072543190ecc66783"
 dependencies = [
  "clap_builder",
  "clap_derive",
 ]
 
+[[package]]
+name = "clap-verbosity-flag"
+version = "3.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2678fade3b77aa3a8ff3aae87e9c008d3fb00473a41c71fbf74e91c8c7b37e84"
+dependencies = [
+ "clap",
+ "log",
+]
+
 [[package]]
 name = "clap_builder"
-version = "4.5.24"
+version = "4.5.26"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "874e0dd3eb68bf99058751ac9712f622e61e6f393a94f7128fa26e3f02f5c7cd"
+checksum = "96b01801b5fc6a0a232407abc821660c9c6d25a1cafc0d4f85f29fb8d9afc121"
 dependencies = [
  "anstream",
  "anstyle",
@@ -275,6 +286,7 @@ version = "4.6.0"
 dependencies = [
  "anyhow",
  "clap",
+ "clap-verbosity-flag",
  "env_logger",
  "git2",
  "log",
@@ -285,7 +297,6 @@ dependencies = [
  "serde_json",
  "tempfile",
  "termcolor",
- "thiserror",
  "toml",
  "user_dirs",
 ]
@@ -550,9 +561,9 @@ checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104"
 
 [[package]]
 name = "log"
-version = "0.4.22"
+version = "0.4.25"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
+checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f"
 
 [[package]]
 name = "memchr"
@@ -562,9 +573,9 @@ checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
 
 [[package]]
 name = "miniz_oxide"
-version = "0.8.2"
+version = "0.8.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4ffbe83022cedc1d264172192511ae958937694cd57ce297164951b8b3568394"
+checksum = "b8402cab7aefae129c6977bb0ff1b8fd9a04eb5b51efc50a70bea51cda0c7924"
 dependencies = [
  "adler2",
 ]
@@ -608,9 +619,9 @@ dependencies = [
 
 [[package]]
 name = "proc-macro2"
-version = "1.0.92"
+version = "1.0.93"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0"
+checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99"
 dependencies = [
  "unicode-ident",
 ]
@@ -747,9 +758,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
 
 [[package]]
 name = "syn"
-version = "2.0.95"
+version = "2.0.96"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "46f71c0377baf4ef1cc3e3402ded576dccc315800fbc62dfc7fe04b009773b4a"
+checksum = "d5d0adab1ae378d7f53bdebc67a39f1f151407ef230f0ce2883572f5d8985c80"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -790,26 +801,6 @@ dependencies = [
  "winapi-util",
 ]
 
-[[package]]
-name = "thiserror"
-version = "2.0.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a3ac7f54ca534db81081ef1c1e7f6ea8a3ef428d2fc069097c079443d24124d3"
-dependencies = [
- "thiserror-impl",
-]
-
-[[package]]
-name = "thiserror-impl"
-version = "2.0.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9e9465d30713b56a37ede7185763c3492a91be2f5fa68d958c44e41ab9248beb"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
 [[package]]
 name = "tinystr"
 version = "0.7.6"
@@ -994,9 +985,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
 
 [[package]]
 name = "winnow"
-version = "0.6.22"
+version = "0.6.24"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "39281189af81c07ec09db316b302a3e67bf9bd7cbf6c820b50e35fee9c2fa980"
+checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a"
 dependencies = [
  "memchr",
 ]
diff --git a/Cargo.toml b/Cargo.toml
index 38fdbdf..b53f6c2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -22,6 +22,7 @@ strip = true
 [dependencies]
 anyhow = { version = "1.0", features = ["backtrace"] }
 clap = { version = "4.5", features = ["derive"] }
+clap-verbosity-flag = "3.0.2"
 env_logger = { version = "0.11", features = [ "humantime" ], default-features = false }
 git2 = { version = "0.20", default-features = false }
 log = "0.4"
@@ -30,7 +31,6 @@ remain = "0.2"
 serde = { version = "1.0", features = ["derive"] }
 serde_json = "1.0"
 termcolor = "1.4"
-thiserror = "2.0"
 toml = "0.8"
 user_dirs = "0.2"
 
diff --git a/justfile b/justfile
index 92672ac..bd27ca8 100644
--- a/justfile
+++ b/justfile
@@ -1,6 +1,10 @@
 _default:
     @just --list
 
+# Build and run at the debug level in the parent directory
+run:
+    cargo run -- -vvv ..
+
 # Scan for potential bloat
 bloat:
     cargo bloat --release
diff --git a/src/collector.rs b/src/collector.rs
index ea62cc2..654549b 100644
--- a/src/collector.rs
+++ b/src/collector.rs
@@ -1,28 +1,16 @@
 //! This module contains the functionality for generating reports.
 
-use rayon::prelude::*;
 use std::collections::BTreeMap;
 use std::path::Path;
+
+use anyhow::Result;
+use rayon::prelude::*;
 use target::TargetCollector;
-use thiserror::Error;
 
-use crate::repository_view::{RepositoryView, RepositoryViewError, RepositoryViewResult};
+use crate::repository_view::RepositoryView;
 
 mod target;
 
-#[allow(missing_docs)]
-#[remain::sorted]
-#[derive(Error, Debug)]
-pub enum CollectorError {
-    #[error(transparent)]
-    FromRepositoryView(#[from] RepositoryViewError),
-    #[error(transparent)]
-    FromStdIo(#[from] std::io::Error),
-}
-
-/// The result type used when multiple kinds of errors can be encountered during collection.
-pub type CollectorResult<T> = Result<T, CollectorError>;
-
 /// 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.
@@ -31,7 +19,7 @@ pub type CollectorResult<T> = Result<T, CollectorError>;
 /// sorted keys.
 pub type RepositoryCollection = BTreeMap<Option<String>, Vec<RepositoryView>>;
 
-type UnprocessedRepositoryView = RepositoryViewResult<RepositoryView>;
+type UnprocessedRepositoryView = Result<RepositoryView>;
 
 /// A unit struct that provides [`Self::run()`], which is used to generated [`RepositoryCollection`].
 #[derive(Debug)]
@@ -43,7 +31,7 @@ impl RepositoryCollector {
         path: &Path,
         include_email: bool,
         include_submodules: bool,
-    ) -> CollectorResult<RepositoryCollection> {
+    ) -> Result<RepositoryCollection> {
         let unprocessed = TargetCollector::run(path.to_path_buf())?
             .par_iter()
             .map(|path| RepositoryView::new(path, include_email, include_submodules))
@@ -51,16 +39,10 @@ impl RepositoryCollector {
 
         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.into()),
+            let view = maybe_view?;
+            if let Some(mut views) = processed.insert(view.parent.clone(), vec![view.clone()]) {
+                views.push(view.clone());
+                processed.insert(view.parent, views);
             }
         }
         Ok(processed)
diff --git a/src/config.rs b/src/config.rs
index 36d638e..9c930cb 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -9,7 +9,7 @@ use std::{env, fs};
 /// This struct is the actual config type consumed through the codebase. It is boostrapped via its
 /// public methods and uses [`EntryConfig`], a private struct, under the hood in order to
 /// deserialize empty, non-existent, partial, and complete config files.
-#[derive(Serialize)]
+#[derive(Debug, Serialize)]
 pub struct Config {
     /// The paths that `gfold` will traverse and collect results from.
     pub paths: Vec<PathBuf>,
@@ -131,7 +131,7 @@ struct EntryConfig {
 /// In summary, while this setting is primarily for cosmetics, it may also affect runtime
 /// performance based on what needs to be displayed.
 #[remain::sorted]
-#[derive(Serialize, Deserialize, Clone, Copy, ValueEnum)]
+#[derive(Debug, Serialize, Deserialize, Clone, Copy, ValueEnum)]
 pub enum DisplayMode {
     /// Informs the caller to display results in the classic format.
     Classic,
@@ -147,7 +147,7 @@ pub enum DisplayMode {
 
 /// Set the color mode of results printed to `stdout`.
 #[remain::sorted]
-#[derive(Serialize, Deserialize, Clone, Copy, ValueEnum)]
+#[derive(Debug, Serialize, Deserialize, Clone, Copy, ValueEnum)]
 pub enum ColorMode {
     /// Attempt to display colors as intended (default behavior).
     Always,
diff --git a/src/display.rs b/src/display.rs
index 38f5ecf..3aeccd6 100644
--- a/src/display.rs
+++ b/src/display.rs
@@ -1,28 +1,24 @@
 //! This module contains the functionality for displaying reports to `stdout`.
 
+use std::io;
+use std::path::Path;
+
+use anyhow::{anyhow, Result};
 use color::ColorHarness;
-use gfold::RepositoryCollection;
 use log::debug;
 use log::warn;
-use std::io;
-use std::path::{Path, PathBuf};
-use thiserror::Error;
 
+use crate::collector::RepositoryCollection;
 use crate::config::{ColorMode, DisplayMode};
 
-mod color;
+// TODO(nick): make this module private.
+pub mod color;
 
 const PAD: usize = 2;
 const NONE: &str = "none";
 
-#[remain::sorted]
-#[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`.
+#[derive(Debug)]
 pub struct DisplayHarness {
     display_mode: DisplayMode,
     color_mode: ColorMode,
@@ -37,7 +33,7 @@ impl DisplayHarness {
     }
 
     /// This function chooses the display execution function based on the [`DisplayMode`] provided.
-    pub fn run(&self, reports: &RepositoryCollection) -> anyhow::Result<()> {
+    pub fn run(&self, reports: &RepositoryCollection) -> Result<()> {
         match self.display_mode {
             DisplayMode::Standard => Self::standard(reports, self.color_mode, false)?,
             DisplayMode::StandardAlphabetical => Self::standard(reports, self.color_mode, true)?,
@@ -52,7 +48,7 @@ impl DisplayHarness {
         reports: &RepositoryCollection,
         color_mode: ColorMode,
         alphabetical_sort_only: bool,
-    ) -> anyhow::Result<()> {
+    ) -> Result<()> {
         debug!("detected standard display mode");
         let mut all_reports = Vec::new();
         for grouped_report in reports {
@@ -76,9 +72,9 @@ impl DisplayHarness {
             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()))?
+                full_path.to_str().ok_or(anyhow!(
+                    "could not convert path (Path) to &str: {full_path:?}"
+                ))?
             );
             color_harness.write_gray(&full_path_formatted, true)?;
 
diff --git a/src/display/color.rs b/src/display/color.rs
index bd7ce1c..9955a51 100644
--- a/src/display/color.rs
+++ b/src/display/color.rs
@@ -1,19 +1,20 @@
 //! This module provides a harness for non-trivial displays of information to `stdout`.
 
-use gfold::Status;
 use std::io;
 use std::io::Write;
 use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};
 
-use crate::config::ColorMode;
+use crate::{config::ColorMode, status::Status};
 
 /// This harness provides methods to write to `stdout`. It maps the internal [`ColorMode`] type to
 /// our dependency's [`ColorChoice`] type due to discrepancies in behavior and naming.
+#[derive(Debug)]
 pub struct ColorHarness {
     color_choice: ColorChoice,
 }
 
 impl ColorHarness {
+    /// Creates a new color harness.
     pub fn new(color_mode: ColorMode) -> Self {
         Self {
             color_choice: match &color_mode {
diff --git a/src/lib.rs b/src/lib.rs
deleted file mode 100644
index 4d0dc45..0000000
--- a/src/lib.rs
+++ /dev/null
@@ -1,255 +0,0 @@
-pub mod collector;
-pub mod repository_view;
-pub mod status;
-
-pub use collector::RepositoryCollection;
-pub use collector::RepositoryCollector;
-pub use repository_view::RepositoryView;
-pub use status::Status;
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    use git2::ErrorCode;
-    use git2::Oid;
-    use git2::Repository;
-    use git2::Signature;
-    use log::LevelFilter;
-    use pretty_assertions::assert_eq;
-    use std::fs::File;
-    use std::path::{Path, PathBuf};
-    use std::{fs, io};
-    use tempfile::tempdir;
-
-    /// This scenario 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 scenario() -> anyhow::Result<()> {
-        env_logger::builder()
-            .is_test(true)
-            .filter_level(LevelFilter::Info)
-            .try_init()?;
-
-        // Temporary directory structure:
-        // └── root
-        //     ├── one (repo)
-        //     │   └── file
-        //     ├── two (repo)
-        //     ├── three (repo)
-        //     └── nested
-        //         ├── four (repo)
-        //         ├── five (repo)
-        //         │   └── file
-        //         ├── six (repo)
-        //         └── seven (repo)
-        let root = tempdir()?;
-        let repo_one = create_directory(&root, "one")?;
-        let repo_two = create_directory(&root, "two")?;
-        let repo_three = create_directory(&root, "three")?;
-
-        let nested = create_directory(&root, "nested")?;
-        let repo_four = create_directory(&nested, "four")?;
-        let repo_five = create_directory(&nested, "five")?;
-        let repo_six = create_directory(&nested, "six")?;
-        let repo_seven = create_directory(&nested, "seven")?;
-
-        // Repo One
-        Repository::init(&repo_one)?;
-        create_file(&repo_one)?;
-
-        // Repo Two
-        Repository::init(&repo_two)?;
-
-        // Repo Three
-        Repository::init(&repo_three)?;
-
-        // Repo Four
-        let repository = Repository::init(&repo_four)?;
-        if let Err(e) = repository.remote("origin", "https://github.com/nickgerace/gfold") {
-            if e.code() != ErrorCode::Exists {
-                return Err(e.into());
-            }
-        }
-
-        // Repo Five
-        Repository::init(&repo_five)?;
-        create_file(&repo_five)?;
-
-        // Repo Six
-        let repository = Repository::init(&repo_six)?;
-        if let Err(e) = repository.remote("fork", "https://github.com/nickgerace/gfold") {
-            if e.code() != ErrorCode::Exists {
-                return Err(e.into());
-            }
-        }
-        commit_head_and_create_branch(&repository, "feat")?;
-
-        // Repo Seven
-        let repository = Repository::init(&repo_seven)?;
-        if let Err(e) = repository.remote("origin", "https://github.com/nickgerace/gfold") {
-            if e.code() != ErrorCode::Exists {
-                return Err(e.into());
-            }
-        }
-        commit_head_and_create_branch(&repository, "needtopush")?;
-        repository.set_head("refs/heads/needtopush")?;
-
-        // Generate the collection directly with a default config and ensure the resulting views
-        // match what we expect.
-        let mut expected_collection = RepositoryCollection::new();
-        let expected_views_key = root
-            .path()
-            .to_str()
-            .expect("could not convert PathBuf to &str")
-            .to_string();
-        let mut expected_views = vec![
-            RepositoryView::finalize(
-                &repo_one,
-                Some("HEAD".to_string()),
-                Status::Unclean,
-                None,
-                None,
-                Vec::with_capacity(0),
-            )?,
-            RepositoryView::finalize(
-                &repo_two,
-                Some("HEAD".to_string()),
-                Status::Clean,
-                None,
-                None,
-                Vec::with_capacity(0),
-            )?,
-            RepositoryView::finalize(
-                &repo_three,
-                Some("HEAD".to_string()),
-                Status::Clean,
-                None,
-                None,
-                Vec::with_capacity(0),
-            )?,
-        ];
-        expected_views.sort_by(|a, b| a.name.cmp(&b.name));
-        expected_collection.insert(Some(expected_views_key), expected_views);
-
-        // Add nested views to the expected collection.
-        let nested_expected_views_key = nested
-            .to_str()
-            .expect("could not convert PathBuf to &str")
-            .to_string();
-        let mut nested_expected_views_raw = vec![
-            RepositoryView::finalize(
-                &repo_four,
-                Some("HEAD".to_string()),
-                Status::Clean,
-                Some("https://github.com/nickgerace/gfold".to_string()),
-                None,
-                Vec::with_capacity(0),
-            )?,
-            RepositoryView::finalize(
-                &repo_five,
-                Some("HEAD".to_string()),
-                Status::Unclean,
-                None,
-                None,
-                Vec::with_capacity(0),
-            )?,
-            RepositoryView::finalize(
-                &repo_six,
-                Some("master".to_string()),
-                Status::Unpushed,
-                Some("https://github.com/nickgerace/gfold".to_string()),
-                None,
-                Vec::with_capacity(0),
-            )?,
-            RepositoryView::finalize(
-                &repo_seven,
-                Some("needtopush".to_string()),
-                Status::Unpushed,
-                Some("https://github.com/nickgerace/gfold".to_string()),
-                None,
-                Vec::with_capacity(0),
-            )?,
-        ];
-        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);
-
-        // Generate a collection.
-        let found_collection = RepositoryCollector::run(root.path(), false, false)?;
-
-        // 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_collection_sorted.insert(key, value);
-        }
-        assert_eq!(
-            expected_collection,     // expected
-            found_collection_sorted  // actual
-        );
-        Ok(())
-    }
-
-    fn create_directory<P: AsRef<Path>>(parent: P, name: &str) -> io::Result<PathBuf> {
-        let parent = parent.as_ref();
-        let new_directory = parent.join(name);
-
-        if let Err(e) = fs::create_dir(&new_directory) {
-            if e.kind() != io::ErrorKind::AlreadyExists {
-                return Err(e);
-            }
-        }
-        Ok(new_directory)
-    }
-
-    fn create_file<P: AsRef<Path>>(parent: P) -> io::Result<()> {
-        let parent = parent.as_ref();
-        File::create(parent.join("file"))?;
-        Ok(())
-    }
-
-    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)?;
-        repository.branch(name, &commit, true)?;
-        Ok(())
-    }
-
-    // Source: https://github.com/rust-lang/git2-rs/pull/885
-    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()?;
-        let tree = repository.find_tree(tree_oid)?;
-
-        // If this is the first commit, there is no parent. If the object returned by
-        // "revparse_single" cannot be converted into a commit, then it isn't a commit and we know
-        // there is no parent _commit_.
-        let maybe_parent = match repository.revparse_single("HEAD") {
-            Ok(object) => match object.into_commit() {
-                Ok(commit) => Some(commit),
-                Err(_) => None,
-            },
-            Err(e) if e.code() == ErrorCode::NotFound => None,
-            Err(e) => return Err(e.into()),
-        };
-
-        let mut parents = Vec::new();
-        if let Some(parent) = maybe_parent.as_ref() {
-            parents.push(parent);
-        };
-
-        let signature = Signature::now("Bob", "bob@bob")?;
-        Ok(repository.commit(
-            Some(update_ref),
-            &signature,
-            &signature,
-            "hello",
-            &tree,
-            parents.as_ref(),
-        )?)
-    }
-}
diff --git a/src/main.rs b/src/main.rs
index d92c71a..45e1907 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,20 +1,49 @@
 //! [gfold](https://github.com/nickgerace/gfold) is a CLI tool that helps you keep track of
-//! multiple Git repositories. The source code uses private modules rather than leveraging
-//! a library via `lib.rs`.
+//! multiple Git repositories.
 
-#![warn(missing_docs, clippy::missing_errors_doc, clippy::missing_panics_doc)]
+#![warn(
+    bad_style,
+    clippy::missing_panics_doc,
+    clippy::panic,
+    clippy::panic_in_result_fn,
+    clippy::unwrap_in_result,
+    clippy::unwrap_used,
+    dead_code,
+    improper_ctypes,
+    missing_debug_implementations,
+    // TODO(nick): fix missing docs.
+    // missing_docs,
+    no_mangle_generic_items,
+    non_shorthand_field_patterns,
+    overflowing_literals,
+    path_statements,
+    patterns_in_fns_without_body,
+    unconditional_recursion,
+    unreachable_pub,
+    unused,
+    unused_allocation,
+    unused_comparisons,
+    unused_parens,
+    while_true
+)]
 
-use crate::config::{ColorMode, Config, DisplayMode};
-use crate::display::DisplayHarness;
-use gfold::RepositoryCollector;
+use std::{env, path::PathBuf};
 
+use anyhow::Result;
 use clap::Parser;
-use env_logger::Builder;
-use log::{debug, LevelFilter};
-use std::{env, path::PathBuf};
+use clap_verbosity_flag::{InfoLevel, Verbosity};
+use collector::RepositoryCollector;
+use log::debug;
+
+use crate::config::{ColorMode, Config, DisplayMode};
+use crate::display::DisplayHarness;
 
-mod config;
-mod display;
+pub mod collector;
+pub mod repository_view;
+pub mod status;
+// TODO(nick): make this module private.
+pub mod config;
+pub mod display;
 
 const HELP: &str = "\
 More information: https://github.com/nickgerace/gfold
@@ -25,9 +54,7 @@ Config File Usage: while CLI options are prioritized, default options will fallb
 
     $XDG_CONFIG_HOME/gfold.toml
     $XDG_CONFIG_HOME/gfold/config.toml
-    $HOME/.config/gfold.toml (or {{FOLDERID_Profile}}\\.config\\gfold.toml on Windows)
-
-Troubleshooting: investigate unexpected behavior by prepending execution with \"RUST_BACKTRACE=1\"and \"RUST_LOG=debug\". You can adjust those variable's values to aid investigation.";
+    $HOME/.config/gfold.toml (or {{FOLDERID_Profile}}\\.config\\gfold.toml on Windows)";
 
 #[derive(Parser)]
 #[command(version, about = HELP, long_about = None)]
@@ -46,20 +73,22 @@ struct Cli {
     /// ignore config file settings
     #[arg(short, long)]
     pub ignore_config_file: bool,
+
+    #[command(flatten)]
+    verbose: Verbosity<InfoLevel>,
 }
 
 /// Initializes the logger based on the debug flag and `RUST_LOG` environment variable, then
-/// parses CLI arguments and generates a [`Config`](config::Config) by merging configurations as needed,
+/// parses CLI arguments and generates a [`Config`] by merging configurations as needed,
 /// and finally collects results and displays them.
-fn main() -> anyhow::Result<()> {
-    if env::var("RUST_LOG").is_err() {
-        Builder::new().filter_level(LevelFilter::Off).init();
-    } else {
-        env_logger::init();
-    }
+fn main() -> Result<()> {
+    let cli = Cli::parse();
+
+    env_logger::Builder::new()
+        .filter_level(cli.verbose.log_level_filter())
+        .init();
     debug!("initialized logger");
 
-    let cli = Cli::parse();
     let mut config = if cli.ignore_config_file {
         Config::try_config_default()?
     } else {
@@ -101,3 +130,248 @@ fn main() -> anyhow::Result<()> {
     }
     Ok(())
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    use collector::RepositoryCollection;
+    use git2::ErrorCode;
+    use git2::Oid;
+    use git2::Repository;
+    use git2::Signature;
+    use pretty_assertions::assert_eq;
+    use repository_view::RepositoryView;
+    use status::Status;
+    use std::fs::File;
+    use std::path::{Path, PathBuf};
+    use std::{fs, io};
+    use tempfile::tempdir;
+
+    /// This scenario 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.
+    #[allow(clippy::panic_in_result_fn)]
+    #[test]
+    fn scenario() -> anyhow::Result<()> {
+        // Temporary directory structure:
+        // └── root
+        //     ├── one (repo)
+        //     │   └── file
+        //     ├── two (repo)
+        //     ├── three (repo)
+        //     └── nested
+        //         ├── four (repo)
+        //         ├── five (repo)
+        //         │   └── file
+        //         ├── six (repo)
+        //         └── seven (repo)
+        let root = tempdir()?;
+        let repo_one = create_directory(&root, "one")?;
+        let repo_two = create_directory(&root, "two")?;
+        let repo_three = create_directory(&root, "three")?;
+
+        let nested = create_directory(&root, "nested")?;
+        let repo_four = create_directory(&nested, "four")?;
+        let repo_five = create_directory(&nested, "five")?;
+        let repo_six = create_directory(&nested, "six")?;
+        let repo_seven = create_directory(&nested, "seven")?;
+
+        // Repo One
+        Repository::init(&repo_one)?;
+        create_file(&repo_one)?;
+
+        // Repo Two
+        Repository::init(&repo_two)?;
+
+        // Repo Three
+        Repository::init(&repo_three)?;
+
+        // Repo Four
+        let repository = Repository::init(&repo_four)?;
+        if let Err(e) = repository.remote("origin", "https://github.com/nickgerace/gfold") {
+            if e.code() != ErrorCode::Exists {
+                return Err(e.into());
+            }
+        }
+
+        // Repo Five
+        Repository::init(&repo_five)?;
+        create_file(&repo_five)?;
+
+        // Repo Six
+        let repository = Repository::init(&repo_six)?;
+        if let Err(e) = repository.remote("fork", "https://github.com/nickgerace/gfold") {
+            if e.code() != ErrorCode::Exists {
+                return Err(e.into());
+            }
+        }
+        commit_head_and_create_branch(&repository, "feat")?;
+
+        // Repo Seven
+        let repository = Repository::init(&repo_seven)?;
+        if let Err(e) = repository.remote("origin", "https://github.com/nickgerace/gfold") {
+            if e.code() != ErrorCode::Exists {
+                return Err(e.into());
+            }
+        }
+        commit_head_and_create_branch(&repository, "needtopush")?;
+        repository.set_head("refs/heads/needtopush")?;
+
+        // Generate the collection directly with a default config and ensure the resulting views
+        // match what we expect.
+        let mut expected_collection = RepositoryCollection::new();
+        let expected_views_key = root
+            .path()
+            .to_str()
+            .expect("could not convert PathBuf to &str")
+            .to_string();
+        let mut expected_views = vec![
+            RepositoryView::finalize(
+                &repo_one,
+                Some("HEAD".to_string()),
+                Status::Unclean,
+                None,
+                None,
+                Vec::with_capacity(0),
+            )?,
+            RepositoryView::finalize(
+                &repo_two,
+                Some("HEAD".to_string()),
+                Status::Clean,
+                None,
+                None,
+                Vec::with_capacity(0),
+            )?,
+            RepositoryView::finalize(
+                &repo_three,
+                Some("HEAD".to_string()),
+                Status::Clean,
+                None,
+                None,
+                Vec::with_capacity(0),
+            )?,
+        ];
+        expected_views.sort_by(|a, b| a.name.cmp(&b.name));
+        expected_collection.insert(Some(expected_views_key), expected_views);
+
+        // Add nested views to the expected collection.
+        let nested_expected_views_key = nested
+            .to_str()
+            .expect("could not convert PathBuf to &str")
+            .to_string();
+        let mut nested_expected_views_raw = vec![
+            RepositoryView::finalize(
+                &repo_four,
+                Some("HEAD".to_string()),
+                Status::Clean,
+                Some("https://github.com/nickgerace/gfold".to_string()),
+                None,
+                Vec::with_capacity(0),
+            )?,
+            RepositoryView::finalize(
+                &repo_five,
+                Some("HEAD".to_string()),
+                Status::Unclean,
+                None,
+                None,
+                Vec::with_capacity(0),
+            )?,
+            RepositoryView::finalize(
+                &repo_six,
+                Some("master".to_string()),
+                Status::Unpushed,
+                Some("https://github.com/nickgerace/gfold".to_string()),
+                None,
+                Vec::with_capacity(0),
+            )?,
+            RepositoryView::finalize(
+                &repo_seven,
+                Some("needtopush".to_string()),
+                Status::Unpushed,
+                Some("https://github.com/nickgerace/gfold".to_string()),
+                None,
+                Vec::with_capacity(0),
+            )?,
+        ];
+        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);
+
+        // Generate a collection.
+        let found_collection = RepositoryCollector::run(root.path(), false, false)?;
+
+        // 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_collection_sorted.insert(key, value);
+        }
+        assert_eq!(
+            expected_collection,     // expected
+            found_collection_sorted  // actual
+        );
+        Ok(())
+    }
+
+    fn create_directory<P: AsRef<Path>>(parent: P, name: &str) -> io::Result<PathBuf> {
+        let parent = parent.as_ref();
+        let new_directory = parent.join(name);
+
+        if let Err(e) = fs::create_dir(&new_directory) {
+            if e.kind() != io::ErrorKind::AlreadyExists {
+                return Err(e);
+            }
+        }
+        Ok(new_directory)
+    }
+
+    fn create_file<P: AsRef<Path>>(parent: P) -> io::Result<()> {
+        let parent = parent.as_ref();
+        File::create(parent.join("file"))?;
+        Ok(())
+    }
+
+    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)?;
+        repository.branch(name, &commit, true)?;
+        Ok(())
+    }
+
+    // Source: https://github.com/rust-lang/git2-rs/pull/885
+    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()?;
+        let tree = repository.find_tree(tree_oid)?;
+
+        // If this is the first commit, there is no parent. If the object returned by
+        // "revparse_single" cannot be converted into a commit, then it isn't a commit and we know
+        // there is no parent _commit_.
+        let maybe_parent = match repository.revparse_single("HEAD") {
+            Ok(object) => match object.into_commit() {
+                Ok(commit) => Some(commit),
+                Err(_) => None,
+            },
+            Err(e) if e.code() == ErrorCode::NotFound => None,
+            Err(e) => return Err(e.into()),
+        };
+
+        let mut parents = Vec::new();
+        if let Some(parent) = maybe_parent.as_ref() {
+            parents.push(parent);
+        };
+
+        let signature = Signature::now("Bob", "bob@bob")?;
+        Ok(repository.commit(
+            Some(update_ref),
+            &signature,
+            &signature,
+            "hello",
+            &tree,
+            parents.as_ref(),
+        )?)
+    }
+}
diff --git a/src/repository_view.rs b/src/repository_view.rs
index 6cd8462..b1367d5 100644
--- a/src/repository_view.rs
+++ b/src/repository_view.rs
@@ -1,44 +1,19 @@
 //! This module contains [`RepositoryView`], which provides the [`Status`]
 //! and general overview of the state of a given Git repository.
 
+use std::path::Path;
+
+use anyhow::anyhow;
+use anyhow::Result;
 use git2::Repository;
 use log::{debug, error, trace};
 use serde::{Deserialize, Serialize};
-use std::io;
-use std::path::{Path, PathBuf};
 use submodule_view::SubmoduleView;
-use thiserror::Error;
 
-use crate::repository_view::submodule_view::SubmoduleError;
-use crate::status::{Status, StatusError};
+use crate::status::Status;
 
 mod submodule_view;
 
-#[allow(missing_docs)]
-#[remain::sorted]
-#[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(transparent)]
-    FromGit2(#[from] git2::Error),
-    #[error(transparent)]
-    FromStatus(#[from] StatusError),
-    #[error(transparent)]
-    FromStdIo(#[from] io::Error),
-    #[error(transparent)]
-    FromSubmodule(#[from] SubmoduleError),
-    #[error("full shorthand for Git reference is invalid UTF-8")]
-    GitReferenceShorthandInvalid,
-    #[error("could not convert path (Path) to &str: {0}")]
-    PathToStrConversionFailure(PathBuf),
-}
-
-#[allow(missing_docs)]
-pub type RepositoryViewResult<T> = Result<T, RepositoryViewError>;
-
 /// A collection of results for a Git repository at a given path.
 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
 pub struct RepositoryView {
@@ -66,7 +41,7 @@ impl RepositoryView {
         repo_path: &Path,
         include_email: bool,
         include_submodules: bool,
-    ) -> RepositoryViewResult<RepositoryView> {
+    ) -> Result<RepositoryView> {
         debug!(
             "attempting to generate collector for repository_view at path: {:?}",
             repo_path
@@ -99,7 +74,7 @@ impl RepositoryView {
         let branch = match &head {
             Some(head) => head
                 .shorthand()
-                .ok_or(RepositoryViewError::GitReferenceShorthandInvalid)?,
+                .ok_or(anyhow!("full shorthand for Git reference is invalid UTF-8"))?,
             None => "HEAD",
         };
 
@@ -135,26 +110,26 @@ impl RepositoryView {
         url: Option<String>,
         email: Option<String>,
         submodules: Vec<SubmoduleView>,
-    ) -> Result<Self, RepositoryViewError> {
+    ) -> Result<Self> {
         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(),
+                    return Err(anyhow!(
+                        "could not convert file name (&OsStr) to &str: {path:?}"
                     ));
                 }
             },
-            None => return Err(RepositoryViewError::FileNameNotFound(path.to_path_buf())),
+            None => {
+                return Err(anyhow!(
+                    "received None (Option<&OsStr>) for file name: {path:?}"
+                ))
+            }
         };
         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 => return Err(anyhow!("could not convert path (Path) to &str: {s:?}")),
             },
             None => None,
         };
diff --git a/src/repository_view/submodule_view.rs b/src/repository_view/submodule_view.rs
index 7859b12..0cc0276 100644
--- a/src/repository_view/submodule_view.rs
+++ b/src/repository_view/submodule_view.rs
@@ -1,28 +1,12 @@
 //! This module contains the ability to gather information on submodules for a given [`Repository`].
 
+use anyhow::{anyhow, Result};
 use git2::Repository;
 use log::error;
 use serde::Deserialize;
 use serde::Serialize;
-use std::io;
-use thiserror::Error;
 
-use crate::status::{Status, StatusError};
-
-#[remain::sorted]
-#[derive(Error, Debug)]
-pub enum SubmoduleError {
-    #[error(transparent)]
-    FromGit2(#[from] git2::Error),
-    #[error(transparent)]
-    FromStatus(#[from] StatusError),
-    #[error(transparent)]
-    FromStdIo(#[from] io::Error),
-    #[error("submodule name is invalid UTF-8")]
-    SubmoduleNameInvalid,
-}
-
-type SubmoduleResult<T> = Result<T, SubmoduleError>;
+use crate::status::Status;
 
 /// The view of a submodule with a [`Repository`].
 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -33,7 +17,7 @@ pub struct SubmoduleView {
 
 impl SubmoduleView {
     /// Generate a list of [`submodule view(s)`](Self) for a given [`Repository`].
-    pub fn list(repo: &Repository) -> SubmoduleResult<Vec<Self>> {
+    pub fn list(repo: &Repository) -> Result<Vec<Self>> {
         let mut submodules = Vec::new();
         for submodule in repo.submodules()? {
             match submodule.open() {
@@ -41,7 +25,7 @@ impl SubmoduleView {
                     let (status, _, _) = Status::find(&subrepo)?;
                     let name = submodule
                         .name()
-                        .ok_or(SubmoduleError::SubmoduleNameInvalid)?;
+                        .ok_or(anyhow!("submodule name is invalid UTF-8"))?;
 
                     submodules.push(Self {
                         name: name.to_string(),
diff --git a/src/status.rs b/src/status.rs
index 1e05127..59bfa60 100644
--- a/src/status.rs
+++ b/src/status.rs
@@ -1,20 +1,9 @@
 //! This module contains the [`crate::status::Status`] type.
 
+use anyhow::Result;
 use git2::{ErrorCode, Reference, Remote, Repository, StatusOptions};
 use log::debug;
 use serde::{Deserialize, Serialize};
-use thiserror::Error;
-
-#[allow(missing_docs)]
-#[remain::sorted]
-#[derive(Error, Debug)]
-pub enum StatusError {
-    #[error(transparent)]
-    FromGit2(#[from] git2::Error),
-}
-
-#[allow(missing_docs)]
-pub type StatusResult<T> = Result<T, StatusError>;
 
 /// A summarized interpretation of the status of a Git working tree.
 #[remain::sorted]
@@ -46,9 +35,7 @@ impl Status {
 
     /// Find the [`Status`] for a given [`Repository`]. The
     /// [`head`](Option<git2::Reference>) and [`remote`](Option<git2::Remote>) are also returned.
-    pub fn find(
-        repo: &Repository,
-    ) -> StatusResult<(Status, Option<Reference<'_>>, Option<Remote<'_>>)> {
+    pub fn find(repo: &Repository) -> Result<(Status, Option<Reference<'_>>, Option<Remote<'_>>)> {
         let head = match repo.head() {
             Ok(head) => Some(head),
             Err(ref e)


Test patch: