diff --git a/.cargo/config.toml b/.cargo/config.toml
new file mode 100644
index 00000000..47684146
--- /dev/null
+++ b/.cargo/config.toml
@@ -0,0 +1,2 @@
+[env]
+CARGO_WORKSPACE_DIR = { value = "", relative = true }
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000..5513cd81
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,71 @@
+# EditorConfig helps developers define and maintain consistent
+# coding styles between different editors and IDEs
+# editorconfig.org
+
+root = true
+
+[*]
+# Change these settings to your own preference
+indent_style = space
+indent_size = 4
+
+# We recommend you to keep these unchanged
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.csv]
+trim_trailing_whitespace = false
+
+[*.js]
+indent_style = space
+indent_size = 4
+
+[*.json]
+indent_style = space
+indent_size = 4
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.py]
+indent_style = space
+indent_size = 4
+
+[*.rs]
+indent_style = space
+indent_size = 4
+
+[*.sh]
+indent_style = space
+indent_size = 4
+
+[*.toml]
+indent_style = space
+indent_size = 4
+
+[*.{yaml,yml}]
+indent_style = space
+indent_size = 2
+trim_trailing_whitespace = false
+
+[.github/{actions,workflows}/**/*.yml]
+indent_style = space
+indent_size = 2
+
+[Cargo.toml]
+indent_style = space
+indent_size = 4
+
+[Makefile]
+indent_style = tab
+indent_size = 4
+
+[neofetch]
+indent_style = space
+indent_size = 4
+
+[package.json]
+indent_style = space
+indent_size = 2
diff --git a/.gitattributes b/.gitattributes
index dfe07704..4076392d 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,2 +1,17 @@
-# Auto detect text files and perform LF normalization
-* text=auto
+* text=auto eol=lf
+
+*.csv text eol=lf
+*.js text eol=lf
+*.json text eol=lf
+*.md text eol=lf
+*.py text eol=lf
+*.rs text eol=lf
+*.sh text eol=lf
+*.toml text eol=lf
+*.txt text eol=lf
+*.yaml text eol=lf
+*.yml text eol=lf
+Cargo.lock text eol=lf merge=binary
+Makefile text eol=lf
+MANIFEST.in text eol=lf
+neofetch text eol=lf
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 00000000..2d4d00ad
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,1161 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "ansi_colours"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a1558bd2075d341b9ca698ec8eb6fcc55a746b1fc4255585aad5b141d918a80"
+
+[[package]]
+name = "anstream"
+version = "0.6.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.86"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
+
+[[package]]
+name = "approx"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "assert_float_eq"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cea652ffbedecf29e9cd41bb4c066881057a42c0c119040f022802b26853e77"
+
+[[package]]
+name = "autocfg"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0"
+
+[[package]]
+name = "bitflags"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
+
+[[package]]
+name = "bpaf"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3280efcf6d66bc77c2cf9b67dc8acee47a217d9be67dd590b3230dffe663724d"
+dependencies = [
+ "owo-colors",
+ "supports-color",
+]
+
+[[package]]
+name = "by_address"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422"
+
+[[package]]
+name = "crossterm"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df"
+dependencies = [
+ "bitflags",
+ "libc",
+ "parking_lot",
+]
+
+[[package]]
+name = "deranged"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
+dependencies = [
+ "powerfmt",
+ "serde",
+]
+
+[[package]]
+name = "directories"
+version = "5.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "either"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
+
+[[package]]
+name = "enable-ansi-support"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa4ff3ae2a9aa54bf7ee0983e59303224de742818c1822d89f07da9856d9bc60"
+dependencies = [
+ "windows-sys 0.42.0",
+]
+
+[[package]]
+name = "enterpolation"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fadf5c8cbf7c6765ff05ccbd8811cd7bc3a763e4671755204552bf8740d042a"
+dependencies = [
+ "assert_float_eq",
+ "num-traits",
+ "serde",
+ "topology-traits",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
+
+[[package]]
+name = "errno"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "fast-srgb8"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1"
+
+[[package]]
+name = "fastrand"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a"
+
+[[package]]
+name = "getrandom"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hyfetch"
+version = "1.4.11"
+dependencies = [
+ "aho-corasick",
+ "ansi_colours",
+ "anstream",
+ "anyhow",
+ "bpaf",
+ "crossterm",
+ "deranged",
+ "directories",
+ "enable-ansi-support",
+ "enterpolation",
+ "fastrand",
+ "indexmap",
+ "itertools",
+ "normpath",
+ "palette",
+ "regex",
+ "same-file",
+ "serde",
+ "serde_json",
+ "serde_path_to_error",
+ "shell-words",
+ "strum",
+ "supports-color",
+ "tempfile",
+ "terminal-colorsaurus",
+ "terminal_size",
+ "thiserror",
+ "time",
+ "toml_edit",
+ "tracing",
+ "tracing-subscriber",
+ "unicode-normalization",
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+ "serde",
+]
+
+[[package]]
+name = "is_ci"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45"
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800"
+
+[[package]]
+name = "itertools"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.155"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
+
+[[package]]
+name = "libredox"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
+dependencies = [
+ "bitflags",
+ "libc",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
+
+[[package]]
+name = "lock_api"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17"
+dependencies = [
+ "autocfg",
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
+
+[[package]]
+name = "memchr"
+version = "2.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
+
+[[package]]
+name = "mio"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "normpath"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5831952a9476f2fed74b77d74182fa5ddc4d21c72ec45a333b250e3ed0272804"
+dependencies = [
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
+dependencies = [
+ "overload",
+ "winapi",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "num_threads"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
+
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
+name = "overload"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
+
+[[package]]
+name = "owo-colors"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caff54706df99d2a78a5a4e3455ff45448d81ef1bb63c22cd14052ca0e993a3f"
+
+[[package]]
+name = "palette"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6"
+dependencies = [
+ "approx",
+ "fast-srgb8",
+ "palette_derive",
+]
+
+[[package]]
+name = "palette_derive"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30"
+dependencies = [
+ "by_address",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "parking_lot"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-targets 0.52.5",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02"
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.86"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891"
+dependencies = [
+ "getrandom",
+ "libredox",
+ "thiserror",
+]
+
+[[package]]
+name = "regex"
+version = "1.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b"
+
+[[package]]
+name = "rustix"
+version = "0.38.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6"
+
+[[package]]
+name = "ryu"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "serde"
+version = "1.0.203"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.203"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.120"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e0d21c9a8cae1235ad58a00c11cb40d4b1e5c784f1ef2c537876ed6ffd8b7c5"
+dependencies = [
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6"
+dependencies = [
+ "itoa",
+ "serde",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shell-words"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"
+
+[[package]]
+name = "smallvec"
+version = "1.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
+
+[[package]]
+name = "strum"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn",
+]
+
+[[package]]
+name = "supports-color"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9829b314621dfc575df4e409e79f9d6a66a3bd707ab73f23cb4aa3a854ac854f"
+dependencies = [
+ "is_ci",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.68"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "901fa70d88b9d6c98022e23b4136f9f3e54e4662c3bc1bd1d84a42a9a0f0c1e9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1"
+dependencies = [
+ "cfg-if",
+ "fastrand",
+ "rustix",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "terminal-colorsaurus"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f25695f34d6f2acfa6c9dc41348e9a38d66cda2a78a010d5eafee6df69d8cc69"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "memchr",
+ "mio",
+ "terminal-trx",
+]
+
+[[package]]
+name = "terminal-trx"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d4c86910e10c782a02d3b7606de43cf7ebd80e1fafdca8e49a0db2b0d4611f0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "terminal_size"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21bebf2b7c9e0a515f6e0f8c51dc0f8e4696391e6f1ff30379559f8365fb0df7"
+dependencies = [
+ "rustix",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.61"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.61"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+]
+
+[[package]]
+name = "time"
+version = "0.3.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885"
+dependencies = [
+ "deranged",
+ "libc",
+ "num-conv",
+ "num_threads",
+ "powerfmt",
+ "serde",
+ "time-core",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
+
+[[package]]
+name = "tinyvec"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c55115c6fbe2d2bef26eb09ad74bde02d8255476fc0c7b515ef09fbb35742d82"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf"
+
+[[package]]
+name = "toml_edit"
+version = "0.22.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "278f3d518e152219c994ce877758516bca5e118eaed6996192a774fb9fbf0788"
+dependencies = [
+ "indexmap",
+ "toml_datetime",
+]
+
+[[package]]
+name = "topology-traits"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0c8dab428531e30115d3bfd6e3092b55256a4a7b4f87cb3abe37a000b1f4032"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b"
+dependencies = [
+ "nu-ansi-term",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "valuable"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
+
+[[package]]
+name = "wasi"
+version = "0.11.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b"
+dependencies = [
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-sys"
+version = "0.42.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7"
+dependencies = [
+ "windows_aarch64_gnullvm 0.42.2",
+ "windows_aarch64_msvc 0.42.2",
+ "windows_i686_gnu 0.42.2",
+ "windows_i686_msvc 0.42.2",
+ "windows_x86_64_gnu 0.42.2",
+ "windows_x86_64_gnullvm 0.42.2",
+ "windows_x86_64_msvc 0.42.2",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.5",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.5",
+ "windows_aarch64_msvc 0.52.5",
+ "windows_i686_gnu 0.52.5",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc 0.52.5",
+ "windows_x86_64_gnu 0.52.5",
+ "windows_x86_64_gnullvm 0.52.5",
+ "windows_x86_64_msvc 0.52.5",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 00000000..5f64b75a
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,50 @@
+[workspace]
+resolver = "2"
+members = ["crates/*"]
+
+[workspace.package]
+version = "1.4.11"
+authors = ["Azalea Gui <azalea@hydev.org>"]
+edition = "2021"
+rust-version = "1.75.0"
+description = "Neofetch with LGBTQ+ pride flags!"
+repository = "https://github.com/hykilpikonna/hyfetch"
+license = "MIT"
+
+[workspace.dependencies]
+aho-corasick = { version = "1.1.3", default-features = false }
+ansi_colours = { version = "1.2.2", default-features = false }
+anstream = { version = "0.6.14", default-features = false }
+anyhow = { version = "1.0.86", default-features = false }
+bpaf = { version = "0.9.12", default-features = false }
+crossterm = { version = "0.27.0", default-features = false }
+deranged = { version = "0.3.11", default-features = false }
+directories = { version = "5.0.1", default-features = false }
+enable-ansi-support = { version = "0.2.1", default-features = false }
+enterpolation = { version = "0.2.1", default-features = false }
+fastrand = { version = "2.1.0", default-features = false }
+indexmap = { version = "2.2.6", default-features = false }
+itertools = { version = "0.13.0", default-features = false }
+normpath = { version = "1.2.0", default-features = false }
+palette = { version = "0.7.6", default-features = false }
+regex = { version = "1.10.5", default-features = false }
+same-file = { version = "1.0.6", default-features = false }
+serde = { version = "1.0.203", default-features = false }
+serde_json = { version = "1.0.118", default-features = false }
+serde_path_to_error = { version = "0.1.16", default-features = false }
+shell-words = { version = "1.1.0", default-features = false }
+strum = { version = "0.26.3", default-features = false }
+supports-color = { version = "3.0.0", default-features = false }
+tempfile = { version = "3.10.1", default-features = false }
+terminal-colorsaurus = { version = "0.4.3", default-features = false }
+terminal_size = { version = "0.3.0", default-features = false }
+thiserror = { version = "1.0.61", default-features = false }
+time = { version = "0.3.36", default-features = false }
+toml_edit = { version = "0.22.16", default-features = false }
+tracing = { version = "0.1.40", default-features = false }
+tracing-subscriber = { version = "0.3.18", default-features = false }
+unicode-normalization = { version = "0.1.23", default-features = false }
+unicode-segmentation = { version = "1.11.0", default-features = false }
+
+[workspace.lints.clippy]
+arithmetic_side_effects = "warn"
diff --git a/crates/hyfetch/Cargo.toml b/crates/hyfetch/Cargo.toml
new file mode 100644
index 00000000..c892aba8
--- /dev/null
+++ b/crates/hyfetch/Cargo.toml
@@ -0,0 +1,59 @@
+[package]
+name = "hyfetch"
+version = { workspace = true }
+authors = { workspace = true }
+edition = { workspace = true }
+rust-version = { workspace = true }
+description = { workspace = true }
+repository = { workspace = true }
+license = { workspace = true }
+default-run = "hyfetch"
+
+[dependencies]
+aho-corasick = { workspace = true, features = ["perf-literal", "std"] }
+ansi_colours = { workspace = true, features = [] }
+anstream = { workspace = true, features = [], optional = true }
+anyhow = { workspace = true, features = ["std"] }
+bpaf = { workspace = true, features = [] }
+crossterm = { workspace = true, features = [] }
+deranged = { workspace = true, features = ["serde", "std"] }
+directories = { workspace = true, features = [] }
+enterpolation = { workspace = true, features = ["bspline", "std"] }
+fastrand = { workspace = true, features = ["std"] }
+indexmap = { workspace = true, features = ["serde", "std"] }
+itertools = { workspace = true, features = ["use_std"] }
+palette = { workspace = true, features = ["std"] }
+serde = { workspace = true, features = ["derive", "std"] }
+serde_json = { workspace = true, features = ["std"] }
+serde_path_to_error = { workspace = true, features = [] }
+shell-words = { workspace = true, features = ["std"] }
+strum = { workspace = true, features = ["derive", "std"] }
+supports-color = { workspace = true, features = [] }
+tempfile = { workspace = true, features = [] }
+terminal-colorsaurus = { workspace = true, features = [] }
+terminal_size = { workspace = true, features = [] }
+thiserror = { workspace = true, features = [] }
+time = { workspace = true, features = ["local-offset", "std"] }
+toml_edit = { workspace = true, features = [], optional = true }
+tracing = { workspace = true, features = ["attributes", "std"] }
+tracing-subscriber = { workspace = true, features = ["ansi", "fmt", "smallvec", "std", "tracing-log"] }
+unicode-segmentation = { workspace = true, features = [] }
+
+[build-dependencies]
+indexmap = { workspace = true, features = ["std"] }
+regex = { workspace = true, features = ["perf", "std", "unicode"] }
+unicode-normalization = { workspace = true, features = ["std"] }
+
+[target.'cfg(windows)'.dependencies]
+enable-ansi-support = { workspace = true, features = [] }
+normpath = { workspace = true, features = [] }
+same-file = { workspace = true, features = [] }
+
+[features]
+default = ["autocomplete", "color", "macchina"]
+autocomplete = ["bpaf/autocomplete"]
+color = ["bpaf/dull-color"]
+macchina = ["dep:anstream", "dep:toml_edit", "toml_edit/display"]
+
+[lints]
+workspace = true
diff --git a/crates/hyfetch/build.rs b/crates/hyfetch/build.rs
new file mode 100644
index 00000000..439d8f68
--- /dev/null
+++ b/crates/hyfetch/build.rs
@@ -0,0 +1,257 @@
+use std::fmt::Write as _;
+use std::path::Path;
+use std::{env, fs};
+
+use indexmap::IndexMap;
+use regex::Regex;
+use unicode_normalization::UnicodeNormalization as _;
+
+#[derive(Debug)]
+struct AsciiDistro {
+    pattern: String,
+    art: String,
+}
+
+impl AsciiDistro {
+    fn friendly_name(&self) -> String {
+        self.pattern
+            .split('|')
+            .next()
+            .expect("invalid distro pattern")
+            .trim_matches(|c: char| c.is_ascii_punctuation() || c == ' ')
+            .replace(['"', '*'], "")
+    }
+}
+
+fn main() {
+    let neofetch_path = Path::new(env!("CARGO_WORKSPACE_DIR")).join("neofetch");
+
+    println!(
+        "cargo:rerun-if-changed={neofetch_path}",
+        neofetch_path = neofetch_path.display()
+    );
+
+    let out_dir = env::var_os("OUT_DIR").unwrap();
+    let out_path = Path::new(&out_dir);
+
+    export_distros(neofetch_path, out_path);
+}
+
+fn export_distros<P>(neofetch_path: P, out_path: &Path)
+where
+    P: AsRef<Path>,
+{
+    let distros = parse_ascii_distros(neofetch_path);
+    let mut variants = IndexMap::with_capacity(distros.len());
+
+    for distro in &distros {
+        let variant = distro
+            .friendly_name()
+            .replace(|c: char| c.is_ascii_punctuation() || c == ' ', "_")
+            .nfc()
+            .collect::<String>();
+        if variants.contains_key(&variant) {
+            let variant_fallback = format!("{variant}_fallback");
+            if variants.contains_key(&variant_fallback) {
+                todo!("too many name clashes in ascii distro patterns: {variant}");
+            }
+            variants.insert(variant_fallback, distro);
+            continue;
+        }
+        variants.insert(variant, distro);
+    }
+
+    let mut buf = r###"
+#[derive(Clone, Eq, PartialEq, Hash, Debug)]
+pub enum Distro {
+"###
+    .to_owned();
+
+    for (variant, AsciiDistro { pattern, .. }) in &variants {
+        write!(
+            buf,
+            r###"
+    // {pattern})
+    {variant},
+"###,
+        )
+        .unwrap();
+    }
+
+    buf.push_str(
+        r###"
+}
+
+impl Distro {
+    pub fn detect<S>(name: S) -> Option<Self>
+    where
+        S: AsRef<str>,
+    {
+        let name = name.as_ref().to_lowercase();
+"###,
+    );
+
+    for (variant, AsciiDistro { pattern, .. }) in &variants {
+        let patterns = pattern.split('|').map(|s| s.trim());
+        let mut conds = Vec::new();
+
+        for m in patterns {
+            let stripped = m.trim_matches(['*', '\'', '"']).to_lowercase();
+
+            if stripped.contains(['*', '"']) {
+                if let Some((prefix, suffix)) = stripped.split_once(r#""*""#) {
+                    conds.push(format!(
+                        r#"name.starts_with("{prefix}") && name.ends_with("{suffix}")"#
+                    ));
+                    continue;
+                }
+                todo!("cannot properly parse: {m}");
+            }
+
+            // Exact matches
+            if m.trim_matches('*') == m {
+                conds.push(format!(r#"name == "{stripped}""#));
+                continue;
+            }
+
+            // Both sides are *
+            if m.starts_with('*') && m.ends_with('*') {
+                conds.push(format!(
+                    r#"name.starts_with("{stripped}") || name.ends_with("{stripped}")"#
+                ));
+                continue;
+            }
+
+            // Ends with *
+            if m.ends_with('*') {
+                conds.push(format!(r#"name.starts_with("{stripped}")"#));
+                continue;
+            }
+
+            // Starts with *
+            if m.starts_with('*') {
+                conds.push(format!(r#"name.ends_with("{stripped}")"#));
+                continue;
+            }
+        }
+
+        let condition = conds.join(" || ");
+
+        write!(
+            buf,
+            r###"
+        if {condition} {{
+            return Some(Self::{variant});
+        }}
+"###
+        )
+        .unwrap();
+    }
+
+    buf.push_str(
+        r###"
+        None
+    }
+
+    pub fn ascii_art(&self) -> &str {
+        let art = match self {
+"###,
+    );
+
+    let quotes = "#".repeat(80);
+    for (variant, AsciiDistro { art, .. }) in &variants {
+        write!(
+            buf,
+            r###"
+            Self::{variant} => r{quotes}"
+{art}
+"{quotes},
+"###,
+        )
+        .unwrap();
+    }
+
+    buf.push_str(
+        r###"
+        };
+        &art[1..art.len().checked_sub(1).unwrap()]
+    }
+}
+"###,
+    );
+
+    fs::write(out_path.join("distros.rs"), buf).expect("couldn't write distros.rs");
+}
+
+/// Parses ascii distros from neofetch script.
+fn parse_ascii_distros<P>(neofetch_path: P) -> Vec<AsciiDistro>
+where
+    P: AsRef<Path>,
+{
+    let neofetch_path = neofetch_path.as_ref();
+
+    let nf = {
+        let nf = fs::read_to_string(neofetch_path).expect("couldn't read neofetch script");
+
+        // Get the content of "get_distro_ascii" function
+        let (_, nf) = nf
+            .split_once("get_distro_ascii() {\n")
+            .expect("couldn't find get_distro_ascii function");
+        let (nf, _) = nf
+            .split_once("\n}\n")
+            .expect("couldn't find end of get_distro_ascii function");
+
+        let mut nf = nf.replace('\t', &" ".repeat(4));
+
+        // Remove trailing spaces
+        while nf.contains(" \n") {
+            nf = nf.replace(" \n", "\n");
+        }
+        nf
+    };
+
+    let case_re = Regex::new(r"case .*? in\n").expect("couldn't compile case regex");
+    let eof_re = Regex::new(r"EOF[ \n]*?;;").expect("couldn't compile eof regex");
+
+    // Split by blocks
+    let mut blocks = Vec::new();
+    for b in case_re.split(&nf) {
+        blocks.extend(eof_re.split(b).map(|sub| sub.trim()));
+    }
+
+    // Parse blocks
+    fn parse_block(block: &str) -> Option<AsciiDistro> {
+        let (block, art) = block.split_once("'EOF'\n")?;
+
+        // Join \
+        //
+        // > A <backslash> that is not quoted shall preserve the literal value of the
+        // > following character, with the exception of a <newline>. If a <newline>
+        // > follows the <backslash>, the shell shall interpret this as line
+        // > continuation. The <backslash> and <newline> shall be removed before
+        // > splitting the input into tokens. Since the escaped <newline> is removed
+        // > entirely from the input and is not replaced by any white space, it cannot
+        // > serve as a token separator.
+        // See https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_02_01
+        let block = block.replace("\\\n", "");
+
+        // Get case pattern
+        let pattern = block
+            .split('\n')
+            .next()
+            .and_then(|pattern| pattern.trim().strip_suffix(')'))?;
+
+        // Unescape backslashes here because backslashes are escaped in neofetch
+        // for printf
+        let art = art.replace(r"\\", r"\");
+
+        Some(AsciiDistro {
+            pattern: pattern.to_owned(),
+            art,
+        })
+    }
+    blocks
+        .iter()
+        .filter_map(|block| parse_block(block))
+        .collect()
+}
diff --git a/crates/hyfetch/src/ascii.rs b/crates/hyfetch/src/ascii.rs
new file mode 100644
index 00000000..53553813
--- /dev/null
+++ b/crates/hyfetch/src/ascii.rs
@@ -0,0 +1,402 @@
+use std::borrow::Cow;
+use std::fmt::Write as _;
+use std::ops::Range;
+
+use aho_corasick::AhoCorasick;
+use anyhow::{Context as _, Result};
+use indexmap::IndexMap;
+use tracing::debug;
+use unicode_segmentation::UnicodeSegmentation;
+
+use crate::color_util::{
+    color, ForegroundBackground, NeofetchAsciiIndexedColor, ToAnsiString as _,
+};
+use crate::neofetch_util::{
+    ascii_size, ColorAlignment, NEOFETCH_COLORS_AC, NEOFETCH_COLOR_PATTERNS,
+};
+use crate::presets::ColorProfile;
+use crate::types::{AnsiMode, TerminalTheme};
+
+/// Raw ascii art before any processing.
+#[derive(Clone, Debug)]
+pub struct RawAsciiArt {
+    pub asc: String,
+    pub fg: Vec<NeofetchAsciiIndexedColor>,
+}
+
+/// Normalized ascii art where every line has the same width.
+#[derive(Clone, Debug)]
+pub struct NormalizedAsciiArt {
+    pub lines: Vec<String>,
+    pub w: u8,
+    pub h: u8,
+    pub fg: Vec<NeofetchAsciiIndexedColor>,
+}
+
+/// Recolored ascii art with all color codes replaced.
+#[derive(Clone, Debug)]
+pub struct RecoloredAsciiArt {
+    pub lines: Vec<String>,
+    pub w: u8,
+    pub h: u8,
+}
+
+impl RawAsciiArt {
+    /// Makes sure every line is the same width.
+    #[tracing::instrument(level = "debug", skip(self))]
+    pub fn to_normalized(&self) -> Result<NormalizedAsciiArt> {
+        debug!("normalize ascii");
+
+        let (w, h) = ascii_size(&self.asc).context("failed to get ascii size")?;
+
+        let lines = self
+            .asc
+            .lines()
+            .map(|line| {
+                let (line_w, _) = ascii_size(line).unwrap();
+                let pad = " ".repeat(usize::from(w.checked_sub(line_w).unwrap()));
+                format!("{line}{pad}")
+            })
+            .collect();
+
+        Ok(NormalizedAsciiArt {
+            lines,
+            w,
+            h,
+            fg: self.fg.clone(),
+        })
+    }
+}
+
+impl NormalizedAsciiArt {
+    /// Uses a color alignment to recolor the ascii art.
+    #[tracing::instrument(level = "debug", skip(self), fields(self.w = self.w, self.h = self.h))]
+    pub fn to_recolored(
+        &self,
+        color_align: &ColorAlignment,
+        color_profile: &ColorProfile,
+        color_mode: AnsiMode,
+        theme: TerminalTheme,
+    ) -> Result<RecoloredAsciiArt> {
+        debug!("recolor ascii");
+
+        if self.lines.is_empty() {
+            return Ok(RecoloredAsciiArt {
+                lines: self.lines.clone(),
+                w: 0,
+                h: 0,
+            });
+        }
+
+        let reset = color("&~&*", color_mode).expect("color reset should not be invalid");
+
+        let lines = match (color_align, self) {
+            (ColorAlignment::Horizontal, Self { fg, .. }) => {
+                let Self { lines, .. } = self
+                    .fill_starting()
+                    .context("failed to fill in starting neofetch color codes")?;
+
+                let ac = NEOFETCH_COLORS_AC
+                    .get_or_init(|| AhoCorasick::new(NEOFETCH_COLOR_PATTERNS).unwrap());
+
+                // Replace foreground colors
+                let asc = {
+                    let asc = lines.join("\n");
+                    let mut replacements = NEOFETCH_COLOR_PATTERNS;
+                    let fg_color = color(
+                        match theme {
+                            TerminalTheme::Light => "&0",
+                            TerminalTheme::Dark => "&f",
+                        },
+                        color_mode,
+                    )
+                    .expect("foreground color should not be invalid");
+                    for &fore in fg {
+                        replacements[usize::from(u8::from(fore)).checked_sub(1).unwrap()] =
+                            &fg_color;
+                    }
+                    ac.replace_all(&asc, &replacements)
+                };
+                let lines = asc.lines();
+
+                // Add new colors
+                let lines = {
+                    let ColorProfile { colors } = color_profile
+                        .with_length(self.h.try_into().expect("`h` should not be 0"))
+                        .with_context(|| {
+                            format!("failed to spread color profile to length {h}", h = self.h)
+                        })?;
+                    lines.enumerate().map(move |(i, line)| {
+                        let bg_color =
+                            colors[i].to_ansi_string(color_mode, ForegroundBackground::Foreground);
+                        const N: usize = NEOFETCH_COLOR_PATTERNS.len();
+                        let replacements = [&bg_color; N];
+                        ac.replace_all(line, &replacements)
+                    })
+                };
+
+                // Reset colors at end of each line to prevent color bleeding
+                lines.map(|line| format!("{line}{reset}")).collect()
+            },
+            (ColorAlignment::Vertical, Self { fg, .. }) if !fg.is_empty() => {
+                if self.w == 0 {
+                    return Ok(RecoloredAsciiArt {
+                        lines: self.lines.clone(),
+                        w: 0,
+                        h: self.h,
+                    });
+                }
+
+                let Self { lines, .. } = self
+                    .fill_starting()
+                    .context("failed to fill in starting neofetch color codes")?;
+
+                let color_profile = color_profile
+                    .with_length(self.w.try_into().expect("`w` should not be 0"))
+                    .with_context(|| {
+                        format!("failed to spread color profile to length {w}", w = self.w)
+                    })?;
+
+                // Apply colors
+                let ac = NEOFETCH_COLORS_AC
+                    .get_or_init(|| AhoCorasick::new(NEOFETCH_COLOR_PATTERNS).unwrap());
+                lines
+                    .into_iter()
+                    .map(|line| {
+                        let line: &str = line.as_ref();
+
+                        // `AhoCorasick` operates on bytes; we need to map that back to grapheme
+                        // clusters (i.e. a character as seen on the terminal)
+                        // See https://github.com/BurntSushi/aho-corasick/issues/72#issuecomment-821128859
+                        let byte_idx_to_grapheme_idx: IndexMap<usize, usize> = {
+                            let mut m: IndexMap<_, _> = line
+                                .grapheme_indices(true)
+                                .enumerate()
+                                .map(|(gr_idx, (byte_idx, _))| (byte_idx, gr_idx))
+                                .collect();
+                            // Add an extra entry at the end, to support lookup using exclusive
+                            // range end
+                            m.insert(line.len(), m.len());
+                            m
+                        };
+
+                        let mut matches = ac.find_iter(line).peekable();
+                        let mut dst = String::new();
+                        let mut offset: u8 = 0;
+                        loop {
+                            let current = matches.next();
+                            let next = matches.peek();
+                            let (neofetch_color_idx, span, done) = match (current, next) {
+                                (Some(m), Some(m_next)) => {
+                                    let ai_start = m.start().checked_add(3).unwrap();
+                                    let ai_end = m.end().checked_sub(1).unwrap();
+                                    let neofetch_color_idx: NeofetchAsciiIndexedColor = line
+                                        [ai_start..ai_end]
+                                        .parse()
+                                        .expect("neofetch color index should be valid");
+                                    if offset == 0 && m.start() > 0 {
+                                        dst.push_str(&line[..m.start()]);
+                                    }
+                                    offset =
+                                        offset.checked_add(u8::try_from(m.len()).unwrap()).unwrap();
+                                    let mut span = m.span();
+                                    span.start = m.end();
+                                    span.end = m_next.start();
+                                    (neofetch_color_idx, span, false)
+                                },
+                                (Some(m), None) => {
+                                    // Last color code
+                                    let ai_start = m.start().checked_add(3).unwrap();
+                                    let ai_end = m.end().checked_sub(1).unwrap();
+                                    let neofetch_color_idx: NeofetchAsciiIndexedColor = line
+                                        [ai_start..ai_end]
+                                        .parse()
+                                        .expect("neofetch color index should be valid");
+                                    if offset == 0 && m.start() > 0 {
+                                        dst.push_str(&line[..m.start()]);
+                                    }
+                                    offset =
+                                        offset.checked_add(u8::try_from(m.len()).unwrap()).unwrap();
+                                    let mut span = m.span();
+                                    span.start = m.end();
+                                    span.end = line.len();
+                                    (neofetch_color_idx, span, true)
+                                },
+                                (None, _) => {
+                                    // No color code in the entire line
+                                    unreachable!(
+                                        "`fill_starting` ensured each line of ascii art starts \
+                                         with neofetch color code"
+                                    );
+                                },
+                            };
+
+                            if span.is_empty() {
+                                continue;
+                            }
+
+                            let txt = &line[span];
+
+                            if fg.contains(&neofetch_color_idx) {
+                                let fore = color(
+                                    match theme {
+                                        TerminalTheme::Light => "&0",
+                                        TerminalTheme::Dark => "&f",
+                                    },
+                                    color_mode,
+                                )
+                                .expect("foreground color should not be invalid");
+                                write!(dst, "{fore}{txt}{reset}").unwrap();
+                            } else {
+                                let mut c_range: Range<usize> = span.into();
+                                c_range.start = byte_idx_to_grapheme_idx
+                                    .get(&c_range.start)
+                                    .unwrap()
+                                    .checked_sub(usize::from(offset))
+                                    .unwrap();
+                                c_range.end = byte_idx_to_grapheme_idx
+                                    .get(&c_range.end)
+                                    .unwrap()
+                                    .checked_sub(usize::from(offset))
+                                    .unwrap();
+                                dst.push_str(
+                                    &ColorProfile::new(Vec::from(&color_profile.colors[c_range]))
+                                        .color_text(
+                                            txt,
+                                            color_mode,
+                                            ForegroundBackground::Foreground,
+                                            false,
+                                        )
+                                        .context("failed to color text using color profile")?,
+                                );
+                            }
+
+                            if done {
+                                break;
+                            }
+                        }
+                        Ok(dst)
+                    })
+                    .collect::<Result<_>>()?
+            },
+            (ColorAlignment::Vertical, Self { fg, .. }) if fg.is_empty() => {
+                // Remove existing colors
+                let asc = {
+                    let asc = self.lines.join("\n");
+                    let ac = NEOFETCH_COLORS_AC
+                        .get_or_init(|| AhoCorasick::new(NEOFETCH_COLOR_PATTERNS).unwrap());
+                    const N: usize = NEOFETCH_COLOR_PATTERNS.len();
+                    const REPLACEMENTS: [&str; N] = [""; N];
+                    ac.replace_all(&asc, &REPLACEMENTS)
+                };
+                let lines = asc.lines();
+
+                // Add new colors
+                lines
+                    .map(|line| {
+                        let line = color_profile
+                            .color_text(line, color_mode, ForegroundBackground::Foreground, false)
+                            .context("failed to color text using color profile")?;
+                        Ok(line)
+                    })
+                    .collect::<Result<_>>()?
+            },
+            (
+                ColorAlignment::Custom {
+                    colors: custom_colors,
+                },
+                _,
+            ) => {
+                let Self { lines, .. } = self
+                    .fill_starting()
+                    .context("failed to fill in starting neofetch color codes")?;
+
+                let ColorProfile { colors } = color_profile.unique_colors();
+
+                // Apply colors
+                let asc = {
+                    let asc = lines.join("\n");
+                    let ac = NEOFETCH_COLORS_AC
+                        .get_or_init(|| AhoCorasick::new(NEOFETCH_COLOR_PATTERNS).unwrap());
+                    const N: usize = NEOFETCH_COLOR_PATTERNS.len();
+                    let mut replacements = vec![Cow::from(""); N];
+                    for (&ai, &pi) in custom_colors {
+                        let ai: u8 = ai.into();
+                        let pi: u8 = pi.into();
+                        replacements[usize::from(ai.checked_sub(1).unwrap())] = colors
+                            [usize::from(pi)]
+                        .to_ansi_string(color_mode, ForegroundBackground::Foreground)
+                        .into();
+                    }
+                    ac.replace_all(&asc, &replacements)
+                };
+                let lines = asc.lines();
+
+                // Reset colors at end of each line to prevent color bleeding
+                lines.map(|line| format!("{line}{reset}")).collect()
+            },
+            _ => {
+                unreachable!()
+            },
+        };
+
+        Ok(RecoloredAsciiArt {
+            lines,
+            w: self.w,
+            h: self.h,
+        })
+    }
+
+    /// Fills the missing starting placeholders.
+    ///
+    /// e.g. `"${c1}...\n..."` -> `"${c1}...\n${c1}..."`
+    fn fill_starting(&self) -> Result<Self> {
+        let ac =
+            NEOFETCH_COLORS_AC.get_or_init(|| AhoCorasick::new(NEOFETCH_COLOR_PATTERNS).unwrap());
+
+        let mut last = None;
+        let lines =
+            self.lines
+                .iter()
+                .map(|line| {
+                    let line: &str = line.as_ref();
+
+                    let mut new = String::new();
+                    let mut matches = ac.find_iter(line).peekable();
+
+                    match matches.peek() {
+                        Some(m)
+                            if m.start() == 0
+                                || line[0..m.start()].trim_end_matches(' ').is_empty() =>
+                        {
+                            // Line starts with neofetch color code
+                            last = Some(&line[m.span()]);
+                        },
+                        Some(_) => {
+                            new.push_str(last.context(
+                                "failed to find neofetch color code from a previous line",
+                            )?);
+                        },
+                        None => {
+                            new.push_str(last.unwrap_or(NEOFETCH_COLOR_PATTERNS[0]));
+                        },
+                    }
+                    new.push_str(line);
+
+                    // Get the last placeholder for the next line
+                    if let Some(m) = matches.last() {
+                        last.context("non-space character seen before first color code")?;
+                        last = Some(&line[m.span()]);
+                    }
+
+                    Ok(new)
+                })
+                .collect::<Result<_>>()?;
+
+        Ok(Self {
+            lines,
+            fg: self.fg.clone(),
+            ..*self
+        })
+    }
+}
diff --git a/crates/hyfetch/src/bin/hyfetch.rs b/crates/hyfetch/src/bin/hyfetch.rs
new file mode 100644
index 00000000..7991f0e7
--- /dev/null
+++ b/crates/hyfetch/src/bin/hyfetch.rs
@@ -0,0 +1,1097 @@
+use std::borrow::Cow;
+use std::cmp;
+use std::fmt::Write as _;
+use std::fs::{self, File};
+use std::io::{self, IsTerminal as _, Read as _, Write as _};
+use std::iter::zip;
+use std::num::NonZeroU8;
+use std::path::{Path, PathBuf};
+
+use aho_corasick::AhoCorasick;
+use anyhow::{Context as _, Result};
+use deranged::RangedU8;
+use enterpolation::bspline::BSpline;
+use enterpolation::{Curve as _, Generator as _};
+use hyfetch::ascii::RawAsciiArt;
+use hyfetch::cli_options::options;
+use hyfetch::color_util::{
+    clear_screen, color, printc, ContrastGrayscale as _, ForegroundBackground, Lightness,
+    NeofetchAsciiIndexedColor, PresetIndexedColor, Theme as _, ToAnsiString as _,
+};
+use hyfetch::models::Config;
+#[cfg(feature = "macchina")]
+use hyfetch::neofetch_util::macchina_path;
+use hyfetch::neofetch_util::{
+    self, fastfetch_path, get_distro_ascii, literal_input, ColorAlignment, NEOFETCH_COLORS_AC,
+    NEOFETCH_COLOR_PATTERNS, TEST_ASCII,
+};
+use hyfetch::presets::{AssignLightness, Preset};
+use hyfetch::pride_month;
+use hyfetch::types::{AnsiMode, Backend, TerminalTheme};
+use hyfetch::utils::{get_cache_path, input};
+use indexmap::{IndexMap, IndexSet};
+use itertools::Itertools as _;
+use palette::{LinSrgb, Srgb};
+use serde::Serialize as _;
+use serde_json::ser::PrettyFormatter;
+use strum::{EnumCount as _, VariantArray, VariantNames};
+use terminal_colorsaurus::{background_color, QueryOptions};
+use terminal_size::{terminal_size, Height, Width};
+use time::{Month, OffsetDateTime};
+use tracing::debug;
+
+fn main() -> Result<()> {
+    #[cfg(windows)]
+    if let Err(err) = enable_ansi_support::enable_ansi_support() {
+        debug!(%err, "could not enable ANSI escape code support");
+    }
+
+    let options = options().run();
+
+    let debug_mode = options.debug;
+
+    init_tracing_subsriber(debug_mode).context("failed to init tracing subscriber")?;
+
+    debug!(?options, "CLI options");
+
+    // Use a custom distro
+    let distro = options.distro.as_ref();
+
+    let backend = options.backend.map_or_else(
+        || {
+            fastfetch_path()
+                .context("failed to get fastfetch path")
+                .map(|fastfetch_path| {
+                    if fastfetch_path.is_some() {
+                        Backend::Fastfetch
+                    } else {
+                        Backend::Neofetch
+                    }
+                })
+        },
+        Ok,
+    )?;
+
+    if options.test_print {
+        let asc = get_distro_ascii(distro, backend).context("failed to get distro ascii")?;
+        writeln!(io::stdout(), "{asc}", asc = asc.asc)
+            .context("failed to write ascii to stdout")?;
+        return Ok(());
+    }
+
+    let config = if options.config {
+        create_config(&options.config_file, distro, backend, debug_mode)
+            .context("failed to create config")?
+    } else if let Some(config) =
+        load_config(&options.config_file).context("failed to load config")?
+    {
+        config
+    } else {
+        create_config(&options.config_file, distro, backend, debug_mode)
+            .context("failed to create config")?
+    };
+
+    let color_mode = options.mode.unwrap_or(config.mode);
+    let theme = config.light_dark;
+
+    // Check if it's June (pride month)
+    let now =
+        OffsetDateTime::now_local().context("failed to get current datetime in local timezone")?;
+    let cache_path = get_cache_path().context("failed to get cache path")?;
+    let june_path = cache_path.join(format!("animation-displayed-{year}", year = now.year()));
+    let show_pride_month = options.june
+        || now.month() == Month::June && !june_path.is_file() && io::stdout().is_terminal();
+
+    if show_pride_month && !config.pride_month_disable {
+        pride_month::start_animation(color_mode).context("failed to draw pride month animation")?;
+        writeln!(
+            io::stdout(),
+            "\nHappy pride month!\n(You can always view the animation again with `hyfetch \
+             --june`)\n"
+        )
+        .context("failed to write message to stdout")?;
+
+        if !june_path.is_file() {
+            fs::create_dir_all(&cache_path)
+                .with_context(|| format!("failed to create cache dir {cache_path:?}"))?;
+            File::create(&june_path)
+                .with_context(|| format!("failed to create file {june_path:?}"))?;
+        }
+    }
+
+    // Use a custom distro
+    let distro = options.distro.as_ref().or(config.distro.as_ref());
+
+    let backend = options.backend.unwrap_or(config.backend);
+    let args = options.args.as_ref().or(config.args.as_ref());
+
+    // Get preset
+    let preset = options.preset.unwrap_or(config.preset);
+    let color_profile = preset.color_profile();
+    debug!(?color_profile, "color profile");
+
+    // Lighten
+    let color_profile = if let Some(scale) = options.scale {
+        color_profile.lighten(scale)
+    } else if let Some(lightness) = options.lightness {
+        color_profile.with_lightness(AssignLightness::Replace(lightness))
+    } else {
+        color_profile.with_lightness_adaptive(config.lightness(), theme)
+    };
+    debug!(?color_profile, "lightened color profile");
+
+    let asc = if let Some(path) = options.ascii_file {
+        RawAsciiArt {
+            asc: fs::read_to_string(&path)
+                .with_context(|| format!("failed to read ascii from {path:?}"))?,
+            fg: Vec::new(),
+        }
+    } else {
+        get_distro_ascii(distro, backend).context("failed to get distro ascii")?
+    };
+    let asc = asc.to_normalized().context("failed to normalize ascii")?;
+    let color_align = config.color_align;
+    let asc = asc
+        .to_recolored(&color_align, &color_profile, color_mode, theme)
+        .context("failed to recolor ascii")?;
+    neofetch_util::run(asc, backend, args)?;
+
+    if options.ask_exit {
+        input(Some("Press enter to exit...")).context("failed to read input")?;
+    }
+
+    Ok(())
+}
+
+/// Loads config from file.
+///
+/// Returns `None` if the config file does not exist.
+#[tracing::instrument(level = "debug")]
+fn load_config(path: &PathBuf) -> Result<Option<Config>> {
+    let mut file = match File::open(path) {
+        Ok(file) => file,
+        Err(err) if err.kind() == io::ErrorKind::NotFound => {
+            return Ok(None);
+        },
+        Err(err) => {
+            return Err(err).with_context(|| format!("failed to open file {path:?} for reading"));
+        },
+    };
+
+    let mut buf = String::new();
+
+    file.read_to_string(&mut buf)
+        .with_context(|| format!("failed to read from file {path:?}"))?;
+
+    let deserializer = &mut serde_json::Deserializer::from_str(&buf);
+    let config: Config = serde_path_to_error::deserialize(deserializer)
+        .with_context(|| format!("failed to parse config from file {path:?}"))?;
+
+    debug!(?config, "loaded config");
+
+    Ok(Some(config))
+}
+
+/// Creates config interactively.
+///
+/// The config is automatically stored to file.
+#[tracing::instrument(level = "debug")]
+fn create_config(
+    path: &PathBuf,
+    distro: Option<&String>,
+    backend: Backend,
+    debug_mode: bool,
+) -> Result<Config> {
+    // Detect terminal environment (doesn't work for all terminal emulators,
+    // especially on Windows)
+    let det_bg = if io::stdout().is_terminal() {
+        match background_color(QueryOptions::default()) {
+            Ok(bg) => Some(Srgb::<u16>::new(bg.r, bg.g, bg.b).into_format::<u8>()),
+            Err(terminal_colorsaurus::Error::UnsupportedTerminal) => None,
+            Err(err) => {
+                return Err(err).context("failed to get terminal background color");
+            },
+        }
+    } else {
+        None
+    };
+    debug!(?det_bg, "detected background color");
+    let det_ansi = supports_color::on(supports_color::Stream::Stdout).map(|color_level| {
+        if color_level.has_16m {
+            AnsiMode::Rgb
+        } else if color_level.has_256 {
+            AnsiMode::Ansi256
+        } else if color_level.has_basic {
+            unimplemented!(
+                "{mode} color mode not supported",
+                mode = AnsiMode::Ansi16.as_ref()
+            );
+        } else {
+            unreachable!();
+        }
+    });
+    debug!(?det_ansi, "detected color mode");
+
+    let asc = get_distro_ascii(distro, backend).context("failed to get distro ascii")?;
+    let asc = asc.to_normalized().context("failed to normalize ascii")?;
+    let theme = det_bg.map(|bg| bg.theme()).unwrap_or(TerminalTheme::Light);
+    let color_mode = det_ansi.unwrap_or(AnsiMode::Ansi256);
+    let mut title = format!(
+        "Welcome to {logo} Let's set up some colors first.",
+        logo = color(
+            match theme {
+                TerminalTheme::Light => "&l&bhyfetch&~&L",
+                TerminalTheme::Dark => "&l&bhy&ffetch&~&L",
+            },
+            color_mode,
+        )
+        .expect("logo should not contain invalid color codes")
+    );
+    clear_screen(Some(&title), color_mode, debug_mode).context("failed to clear screen")?;
+
+    let mut option_counter = NonZeroU8::new(1).unwrap();
+
+    fn update_title(title: &mut String, option_counter: &mut NonZeroU8, k: &str, v: &str) {
+        let k: Cow<str> = if k.ends_with(':') {
+            k.into()
+        } else {
+            format!("{k}:").into()
+        };
+        write!(title, "\n&e{option_counter}. {k:<30} &~{v}").unwrap();
+        *option_counter = option_counter
+            .checked_add(1)
+            .expect("`option_counter` should not overflow `u8`");
+    }
+
+    fn print_title_prompt(
+        option_counter: NonZeroU8,
+        prompt: &str,
+        color_mode: AnsiMode,
+    ) -> Result<()> {
+        printc(format!("&a{option_counter}. {prompt}"), color_mode)
+            .context("failed to print prompt")
+    }
+
+    //////////////////////////////
+    // 0. Check term size
+
+    {
+        let (Width(term_w), Height(term_h)) =
+            terminal_size().context("failed to get terminal size")?;
+        let (term_w_min, term_h_min) = (
+            u16::from(asc.w)
+                .checked_mul(2)
+                .unwrap()
+                .checked_add(4)
+                .unwrap(),
+            30,
+        );
+        if term_w < term_w_min || term_h < term_h_min {
+            printc(
+                format!(
+                    "&cWarning: Your terminal is too small ({term_w} * {term_h}).\nPlease resize \
+                     it to at least ({term_w_min} * {term_h_min}) for better experience."
+                ),
+                color_mode,
+            )
+            .context("failed to print message")?;
+            input(Some("Press enter to continue...")).context("failed to read input")?;
+        }
+    }
+
+    //////////////////////////////
+    // 1. Select color mode
+
+    let default_color_profile = Preset::Rainbow.color_profile();
+
+    let select_color_mode = || -> Result<(AnsiMode, &str)> {
+        if det_ansi == Some(AnsiMode::Rgb) {
+            return Ok((AnsiMode::Rgb, "Detected color mode"));
+        }
+
+        clear_screen(Some(&title), color_mode, debug_mode).context("failed to clear screen")?;
+
+        let (Width(term_w), _) = terminal_size().context("failed to get terminal size")?;
+
+        let spline = BSpline::builder()
+            .clamped()
+            .elements(
+                default_color_profile
+                    .unique_colors()
+                    .colors
+                    .iter()
+                    .map(|rgb_u8_color| rgb_u8_color.into_linear())
+                    .collect::<Vec<_>>(),
+            )
+            .equidistant::<f32>()
+            .degree(1)
+            .normalized()
+            .constant::<2>()
+            .build()
+            .expect("building spline should not fail");
+        let [dmin, dmax] = spline.domain();
+        let gradient: Vec<LinSrgb> = (0..term_w)
+            .map(|i| spline.gen(remap(i as f32, 0.0, term_w as f32, dmin, dmax)))
+            .collect();
+
+        /// Maps `t` in range `[a, b)` to range `[c, d)`.
+        fn remap(t: f32, a: f32, b: f32, c: f32, d: f32) -> f32 {
+            (t - a) * ((d - c) / (b - a)) + c
+        }
+
+        {
+            let label = format!(
+                "{label:^term_w$}",
+                label = "8bit Color Testing",
+                term_w = usize::from(term_w)
+            );
+            let line = zip(gradient.iter(), label.chars()).fold(
+                String::new(),
+                |mut s, (&rgb_f32_color, t)| {
+                    let rgb_u8_color = Srgb::<u8>::from_linear(rgb_f32_color);
+                    let back = rgb_u8_color
+                        .to_ansi_string(AnsiMode::Ansi256, ForegroundBackground::Background);
+                    let fore = rgb_u8_color
+                        .contrast_grayscale()
+                        .to_ansi_string(AnsiMode::Ansi256, ForegroundBackground::Foreground);
+                    write!(s, "{back}{fore}{t}").unwrap();
+                    s
+                },
+            );
+            printc(line, AnsiMode::Ansi256).context("failed to print 8-bit color test line")?;
+        }
+        {
+            let label = format!(
+                "{label:^term_w$}",
+                label = "RGB Color Testing",
+                term_w = usize::from(term_w)
+            );
+            let line = zip(gradient.iter(), label.chars()).fold(
+                String::new(),
+                |mut s, (&rgb_f32_color, t)| {
+                    let rgb_u8_color = Srgb::<u8>::from_linear(rgb_f32_color);
+                    let back = rgb_u8_color
+                        .to_ansi_string(AnsiMode::Rgb, ForegroundBackground::Background);
+                    let fore = rgb_u8_color
+                        .contrast_grayscale()
+                        .to_ansi_string(AnsiMode::Ansi256, ForegroundBackground::Foreground);
+                    write!(s, "{back}{fore}{t}").unwrap();
+                    s
+                },
+            );
+            printc(line, AnsiMode::Rgb).context("failed to print RGB color test line")?;
+        }
+
+        writeln!(io::stdout()).context("failed to write to stdout")?;
+        print_title_prompt(
+            option_counter,
+            "Which &bcolor system &ado you want to use?",
+            color_mode,
+        )
+        .context("failed to print title prompt")?;
+        writeln!(
+            io::stdout(),
+            "(If you can't see colors under \"RGB Color Testing\", please choose 8bit)\n"
+        )
+        .context("failed to write message to stdout")?;
+
+        let choice = literal_input(
+            "Your choice?",
+            AnsiMode::VARIANTS,
+            AnsiMode::Rgb.as_ref(),
+            true,
+            color_mode,
+        )
+        .context("failed to ask for choice input")?;
+        Ok((
+            choice.parse().expect("selected color mode should be valid"),
+            "Selected color mode",
+        ))
+    };
+
+    let color_mode = {
+        let (color_mode, ttl) = select_color_mode().context("failed to select color mode")?;
+        debug!(?color_mode, "selected color mode");
+        update_title(&mut title, &mut option_counter, ttl, color_mode.as_ref());
+        color_mode
+    };
+
+    //////////////////////////////
+    // 2. Select theme (light/dark mode)
+
+    let select_theme = || -> Result<(TerminalTheme, &str)> {
+        if let Some(det_bg) = det_bg {
+            return Ok((det_bg.theme(), "Detected background color"));
+        }
+
+        clear_screen(Some(&title), color_mode, debug_mode).context("failed to clear screen")?;
+
+        print_title_prompt(
+            option_counter,
+            "Is your terminal in &blight mode&~ or &4dark mode&~?",
+            color_mode,
+        )
+        .context("failed to print title prompt")?;
+        let choice = literal_input(
+            "",
+            TerminalTheme::VARIANTS,
+            TerminalTheme::Dark.as_ref(),
+            true,
+            color_mode,
+        )
+        .context("failed to ask for choice input")?;
+        Ok((
+            choice.parse().expect("selected theme should be valid"),
+            "Selected background color",
+        ))
+    };
+
+    let theme = {
+        let (theme, ttl) = select_theme().context("failed to select theme")?;
+        debug!(?theme, "selected theme");
+        update_title(&mut title, &mut option_counter, ttl, theme.as_ref());
+        theme
+    };
+
+    //////////////////////////////
+    // 3. Choose preset
+
+    // Create flag lines
+    let mut flags = Vec::with_capacity(Preset::COUNT);
+    let spacing = {
+        let spacing = <Preset as VariantNames>::VARIANTS
+            .iter()
+            .map(|name| name.chars().count())
+            .max()
+            .expect("preset name iterator should not be empty");
+        let spacing: u8 = spacing.try_into().expect("`spacing` should fit in `u8`");
+        cmp::max(spacing, 20)
+    };
+    for preset in <Preset as VariantArray>::VARIANTS {
+        let color_profile = preset.color_profile();
+        let flag = color_profile
+            .color_text(
+                " ".repeat(usize::from(spacing)),
+                color_mode,
+                ForegroundBackground::Background,
+                false,
+            )
+            .with_context(|| format!("failed to color flag using preset: {preset:?}"))?;
+        let name = format!(
+            "{name:^spacing$}",
+            name = preset.as_ref(),
+            spacing = usize::from(spacing)
+        );
+        flags.push([name, flag.clone(), flag.clone(), flag]);
+    }
+
+    // Calculate flags per row
+    let (flags_per_row, rows_per_page) = {
+        let (Width(term_w), Height(term_h)) =
+            terminal_size().context("failed to get terminal size")?;
+        let flags_per_row = term_w.div_euclid(u16::from(spacing).checked_add(2).unwrap());
+        let flags_per_row: u8 = flags_per_row
+            .try_into()
+            .expect("`flags_per_row` should fit in `u8`");
+        let rows_per_page = cmp::max(1, term_h.saturating_sub(13).div_euclid(5));
+        let rows_per_page: u8 = rows_per_page
+            .try_into()
+            .expect("`rows_per_page` should fit in `u8`");
+        (flags_per_row, rows_per_page)
+    };
+    let num_pages =
+        u16::from(u8::try_from(Preset::COUNT).expect("`Preset::COUNT` should fit in `u8`"))
+            .div_ceil(
+                u16::from(flags_per_row)
+                    .checked_mul(u16::from(rows_per_page))
+                    .unwrap(),
+            );
+    let num_pages: u8 = num_pages
+        .try_into()
+        .expect("`num_pages` should fit in `u8`");
+
+    // Create pages
+    let mut pages = Vec::with_capacity(usize::from(num_pages));
+    for flags in flags.chunks(usize::from(
+        u16::from(flags_per_row)
+            .checked_mul(u16::from(rows_per_page))
+            .unwrap(),
+    )) {
+        let mut page = Vec::with_capacity(usize::from(rows_per_page));
+        for flags in flags.chunks(usize::from(flags_per_row)) {
+            page.push(flags);
+        }
+        pages.push(page);
+    }
+
+    let print_flag_page = |page, page_num: u8| -> Result<()> {
+        clear_screen(Some(&title), color_mode, debug_mode).context("failed to clear screen")?;
+        print_title_prompt(option_counter, "Let's choose a flag!", color_mode)
+            .context("failed to print title prompt")?;
+        writeln!(
+            io::stdout(),
+            "Available flag presets:\nPage: {page_num} of {num_pages}\n",
+            page_num = page_num.checked_add(1).unwrap()
+        )
+        .context("failed to write header to stdout")?;
+        for &row in page {
+            print_flag_row(row, color_mode).context("failed to print flag row")?;
+        }
+        writeln!(io::stdout()).context("failed to write to stdout")?;
+        Ok(())
+    };
+
+    fn print_flag_row(row: &[[String; 4]], color_mode: AnsiMode) -> Result<()> {
+        for i in 0..4 {
+            let mut line = Vec::new();
+            for flag in row {
+                line.push(&*flag[i]);
+            }
+            printc(line.join("  "), color_mode).context("failed to print line")?;
+        }
+        writeln!(io::stdout()).context("failed to write to stdout")?;
+        Ok(())
+    }
+
+    let default_lightness = Config::default_lightness(theme);
+    let preset_default_colored = default_color_profile
+        .with_lightness_adaptive(default_lightness, theme)
+        .color_text(
+            "preset",
+            color_mode,
+            ForegroundBackground::Foreground,
+            false,
+        )
+        .expect("coloring text with default preset should not fail");
+
+    let preset: Preset;
+    let color_profile;
+
+    let mut page: u8 = 0;
+    loop {
+        print_flag_page(&pages[usize::from(page)], page).context("failed to print flag page")?;
+
+        let mut opts: Vec<&str> = <Preset as VariantNames>::VARIANTS.into();
+        if page < num_pages.checked_sub(1).unwrap() {
+            opts.push("next");
+        }
+        if page > 0 {
+            opts.push("prev");
+        }
+        writeln!(
+            io::stdout(),
+            "Enter 'next' to go to the next page and 'prev' to go to the previous page."
+        )
+        .context("failed to write message to stdout")?;
+        let selection = literal_input(
+            format!(
+                "Which {preset} do you want to use? ",
+                preset = preset_default_colored
+            ),
+            &opts[..],
+            Preset::Rainbow.as_ref(),
+            false,
+            color_mode,
+        )
+        .context("failed to ask for choice input")
+        .context("failed to select preset")?;
+        if selection == "next" {
+            page = page.checked_add(1).unwrap();
+        } else if selection == "prev" {
+            page = page.checked_sub(1).unwrap();
+        } else {
+            preset = selection.parse().expect("selected preset should be valid");
+            debug!(?preset, "selected preset");
+            color_profile = preset.color_profile();
+            update_title(
+                &mut title,
+                &mut option_counter,
+                "Selected flag",
+                &color_profile
+                    .with_lightness_adaptive(default_lightness, theme)
+                    .color_text(
+                        preset.as_ref(),
+                        color_mode,
+                        ForegroundBackground::Foreground,
+                        false,
+                    )
+                    .expect("coloring text with selected preset should not fail"),
+            );
+            break;
+        }
+    }
+
+    //////////////////////////////
+    // 4. Dim/lighten colors
+
+    let test_ascii = {
+        let asc = &TEST_ASCII[1..TEST_ASCII.len().checked_sub(1).unwrap()];
+        let asc = RawAsciiArt {
+            asc: asc.to_owned(),
+            fg: Vec::new(),
+        };
+        asc.to_normalized()
+            .expect("normalizing test ascii should not fail")
+    };
+
+    let select_lightness = || -> Result<Lightness> {
+        clear_screen(Some(&title), color_mode, debug_mode).context("failed to clear screen")?;
+        print_title_prompt(
+            option_counter,
+            "Let's adjust the color brightness!",
+            color_mode,
+        )
+        .context("failed to print title prompt")?;
+        writeln!(
+            io::stdout(),
+            "The colors might be a little bit too {bright_dark} for {light_dark} mode.\n",
+            bright_dark = match theme {
+                TerminalTheme::Light => "bright",
+                TerminalTheme::Dark => "dark",
+            },
+            light_dark = theme.as_ref()
+        )
+        .context("failed to write message to stdout")?;
+
+        let color_align = ColorAlignment::Horizontal;
+
+        // Print cats
+        {
+            let (Width(term_w), _) = terminal_size().context("failed to get terminal size")?;
+            let num_cols = cmp::max(
+                1,
+                term_w.div_euclid(u16::from(test_ascii.w).checked_add(2).unwrap()),
+            );
+            let num_cols: u8 = num_cols.try_into().expect("`num_cols` should fit in `u8`");
+            const MIN: f32 = 0.15;
+            const MAX: f32 = 0.85;
+            let ratios =
+                (0..num_cols)
+                    .map(|col| col as f32 / num_cols as f32)
+                    .map(|r| match theme {
+                        TerminalTheme::Light => r * (MAX - MIN) / 2.0 + MIN,
+                        TerminalTheme::Dark => (r * (MAX - MIN) + (MAX + MIN)) / 2.0,
+                    });
+            let row: Vec<Vec<String>> = ratios
+                .map(|r| {
+                    let mut asc = test_ascii.clone();
+                    asc.lines = asc
+                        .lines
+                        .join("\n")
+                        .replace(
+                            "{txt}",
+                            &format!(
+                                "{lightness:^5}",
+                                lightness = format!("{lightness:.0}%", lightness = r * 100.0)
+                            ),
+                        )
+                        .lines()
+                        .map(ToOwned::to_owned)
+                        .collect();
+                    let asc = asc
+                        .to_recolored(
+                            &color_align,
+                            &color_profile.with_lightness_adaptive(
+                                Lightness::new(r)
+                                    .expect("generated lightness should not be invalid"),
+                                theme,
+                            ),
+                            color_mode,
+                            theme,
+                        )
+                        .expect("recoloring test ascii should not fail");
+                    asc.lines
+                })
+                .collect();
+            for i in 0..usize::from(test_ascii.h) {
+                let mut line = Vec::new();
+                for lines in &row {
+                    line.push(&*lines[i]);
+                }
+                printc(line.join("  "), color_mode).context("failed to print test ascii line")?;
+            }
+        }
+
+        loop {
+            writeln!(
+                io::stdout(),
+                "\nWhich brightness level looks the best? (Default: {default:.0}% for \
+                 {light_dark} mode)",
+                default = f32::from(default_lightness) * 100.0,
+                light_dark = theme.as_ref()
+            )
+            .context("failed to write prompt to stdout")?;
+            let lightness = input(Some("> "))
+                .context("failed to read input")?
+                .trim()
+                .to_lowercase();
+
+            match parse_lightness(lightness, default_lightness) {
+                Ok(lightness) => {
+                    return Ok(lightness);
+                },
+                Err(err) => {
+                    debug!(%err, "could not parse lightness");
+                    printc(
+                        "&cUnable to parse lightness value, please enter a lightness value such \
+                         as 45%, .45, or 45",
+                        color_mode,
+                    )
+                    .context("failed to print message")?;
+                },
+            }
+        }
+    };
+
+    fn parse_lightness(lightness: String, default: Lightness) -> Result<Lightness> {
+        if lightness.is_empty() || ["unset", "none"].contains(&&*lightness) {
+            return Ok(default);
+        }
+
+        let lightness = if let Some(lightness) = lightness.strip_suffix('%') {
+            let lightness: RangedU8<0, 100> = lightness.parse()?;
+            lightness.get() as f32 / 100.0
+        } else {
+            match lightness.parse::<RangedU8<0, 100>>() {
+                Ok(lightness) => lightness.get() as f32 / 100.0,
+                Err(_) => lightness.parse::<f32>()?,
+            }
+        };
+
+        Ok(Lightness::new(lightness)?)
+    }
+
+    let lightness = select_lightness().context("failed to select lightness")?;
+    debug!(?lightness, "selected lightness");
+    let color_profile = color_profile.with_lightness_adaptive(lightness, theme);
+    update_title(
+        &mut title,
+        &mut option_counter,
+        "Selected brightness",
+        &format!("{lightness:.2}", lightness = f32::from(lightness)),
+    );
+
+    //////////////////////////////
+    // 5. Color arrangement
+
+    let color_align: ColorAlignment;
+
+    // Calculate amount of row/column that can be displayed on screen
+    let (ascii_per_row, ascii_rows) = {
+        let (Width(term_w), Height(term_h)) =
+            terminal_size().context("failed to get terminal size")?;
+        let ascii_per_row = cmp::max(
+            1,
+            term_w.div_euclid(u16::from(asc.w).checked_add(2).unwrap()),
+        );
+        let ascii_per_row: u8 = ascii_per_row
+            .try_into()
+            .expect("`ascii_per_row` should fit in `u8`");
+        let ascii_rows = cmp::max(
+            1,
+            term_h
+                .saturating_sub(8)
+                .div_euclid(u16::from(asc.h).checked_add(1).unwrap()),
+        );
+        let ascii_rows: u8 = ascii_rows
+            .try_into()
+            .expect("`ascii_rows` should fit in `u8`");
+        (ascii_per_row, ascii_rows)
+    };
+
+    // Displays horizontal and vertical arrangements in the first iteration, but
+    // hide them in later iterations
+    let hv_arrangements = [
+        ("Horizontal", ColorAlignment::Horizontal),
+        ("Vertical", ColorAlignment::Vertical),
+    ];
+    let mut arrangements: IndexMap<Cow<str>, ColorAlignment> =
+        hv_arrangements.map(|(k, ca)| (k.into(), ca)).into();
+
+    let slots: IndexSet<NeofetchAsciiIndexedColor> = {
+        let asc = asc.lines.join("\n");
+        let ac =
+            NEOFETCH_COLORS_AC.get_or_init(|| AhoCorasick::new(NEOFETCH_COLOR_PATTERNS).unwrap());
+        ac.find_iter(&asc)
+            .map(|m| {
+                let ai_start = m.start().checked_add(3).unwrap();
+                let ai_end = m.end().checked_sub(1).unwrap();
+                asc[ai_start..ai_end]
+                    .parse()
+                    .expect("neofetch ascii color index should not be invalid")
+            })
+            .collect()
+    };
+
+    // Loop for random rolling
+    let mut rng = fastrand::Rng::new();
+    loop {
+        clear_screen(Some(&title), color_mode, debug_mode).context("failed to clear screen")?;
+
+        // Random color schemes
+        let mut preset_indices: Vec<PresetIndexedColor> =
+            (0..color_profile.unique_colors().colors.len())
+                .map(|pi| u8::try_from(pi).expect("`pi` should fit in `u8`").into())
+                .collect();
+        while preset_indices.len() < slots.len() {
+            preset_indices.extend_from_within(0..);
+        }
+        let preset_index_permutations: IndexSet<Vec<PresetIndexedColor>> = preset_indices
+            .into_iter()
+            .permutations(slots.len())
+            .collect();
+        let random_count = u16::from(ascii_per_row)
+            .checked_mul(u16::from(ascii_rows))
+            .unwrap()
+            .saturating_sub(u8::try_from(arrangements.len()).unwrap().into());
+        let random_count: u8 = random_count
+            .try_into()
+            .expect("`random_count` should fit in `u8`");
+        let choices: IndexSet<Vec<PresetIndexedColor>> =
+            if usize::from(random_count) > preset_index_permutations.len() {
+                preset_index_permutations
+            } else {
+                rng.choose_multiple(
+                    preset_index_permutations.into_iter(),
+                    usize::from(random_count),
+                )
+                .into_iter()
+                .collect()
+            };
+        let choices: Vec<IndexMap<NeofetchAsciiIndexedColor, PresetIndexedColor>> = choices
+            .into_iter()
+            .map(|c| {
+                c.into_iter()
+                    .enumerate()
+                    .map(|(ai, pi)| (slots[ai], pi))
+                    .collect()
+            })
+            .collect();
+        arrangements.extend(choices.into_iter().enumerate().map(|(i, colors)| {
+            (format!("random{i}").into(), ColorAlignment::Custom {
+                colors,
+            })
+        }));
+        let asciis: Vec<Vec<String>> = arrangements
+            .iter()
+            .map(|(k, ca)| {
+                let mut v: Vec<String> = asc
+                    .to_recolored(ca, &color_profile, color_mode, theme)
+                    .context("failed to recolor ascii")?
+                    .lines;
+                v.push(format!("{k:^asc_width$}", asc_width = usize::from(asc.w)));
+                Ok(v)
+            })
+            .collect::<Result<_>>()?;
+
+        for row in &asciis.into_iter().chunks(usize::from(ascii_per_row)) {
+            let row: Vec<Vec<String>> = row.collect();
+
+            // Print by row
+            for i in 0..usize::from(asc.h).checked_add(1).unwrap() {
+                let mut line = Vec::new();
+                for lines in &row {
+                    line.push(&*lines[i]);
+                }
+                printc(line.join("  "), color_mode).context("failed to print ascii line")?;
+            }
+            writeln!(io::stdout()).context("failed to write to stdout")?;
+        }
+
+        print_title_prompt(
+            option_counter,
+            "Let's choose a color arrangement!",
+            color_mode,
+        )
+        .context("failed to print title prompt")?;
+        writeln!(
+            io::stdout(),
+            "You can choose standard horizontal or vertical alignment, or use one of the random \
+             color schemes.\nYou can type \"roll\" to randomize again.\n"
+        )
+        .context("failed to write message to stdout")?;
+        let mut opts: Vec<Cow<str>> = ["horizontal", "vertical", "roll"].map(Into::into).into();
+        opts.extend((0..random_count).map(|i| format!("random{i}").into()));
+        let choice = literal_input("Your choice?", &opts[..], "horizontal", true, color_mode)
+            .context("failed to ask for choice input")
+            .context("failed to select color alignment")?;
+
+        if choice == "roll" {
+            arrangements.clear();
+            continue;
+        }
+
+        // Save choice
+        color_align = arrangements
+            .into_iter()
+            .find_map(|(k, ca)| {
+                if k.to_lowercase() == choice {
+                    Some(ca)
+                } else {
+                    None
+                }
+            })
+            .expect("selected color alignment should be valid");
+        debug!(?color_align, "selected color alignment");
+        break;
+    }
+
+    update_title(
+        &mut title,
+        &mut option_counter,
+        "Selected color alignment",
+        color_align.as_ref(),
+    );
+
+    //////////////////////////////
+    // 6. Select *fetch backend
+
+    let select_backend = || -> Result<Backend> {
+        clear_screen(Some(&title), color_mode, debug_mode).context("failed to clear screen")?;
+        print_title_prompt(option_counter, "Select a *fetch backend", color_mode)
+            .context("failed to print title prompt")?;
+
+        // Check if fastfetch is installed
+        let fastfetch_path = fastfetch_path().context("failed to get fastfetch path")?;
+
+        // Check if macchina is installed
+        #[cfg(feature = "macchina")]
+        let macchina_path = macchina_path().context("failed to get macchina path")?;
+
+        printc(
+            "- &bneofetch&r: Written in bash, &nbest compatibility&r on Unix systems",
+            color_mode,
+        )
+        .context("failed to print message")?;
+        printc(
+            format!(
+                "- &bfastfetch&r: Written in C, &nbest performance&r {installed_not_installed}",
+                installed_not_installed = fastfetch_path
+                    .map(|path| format!("&a(Installed at {path})", path = path.display()))
+                    .unwrap_or_else(|| "&c(Not installed)".to_owned())
+            ),
+            color_mode,
+        )
+        .context("failed to print message")?;
+        #[cfg(feature = "macchina")]
+        printc(
+            format!(
+                "- &bmacchina&r: Written in Rust, &nbest performance&r {installed_not_installed}\n",
+                installed_not_installed = macchina_path
+                    .map(|path| format!("&a(Installed at {path})", path = path.display()))
+                    .unwrap_or_else(|| "&c(Not installed)".to_owned())
+            ),
+            color_mode,
+        )
+        .context("failed to print message")?;
+
+        let choice = literal_input(
+            "Your choice?",
+            Backend::VARIANTS,
+            backend.as_ref(),
+            true,
+            color_mode,
+        )
+        .context("failed to ask for choice input")?;
+        Ok(choice.parse().expect("selected backend should be valid"))
+    };
+
+    let backend = select_backend().context("failed to select backend")?;
+    update_title(
+        &mut title,
+        &mut option_counter,
+        "Selected backend",
+        backend.as_ref(),
+    );
+
+    // Create config
+    clear_screen(Some(&title), color_mode, debug_mode).context("failed to clear screen")?;
+    let config = Config {
+        preset,
+        mode: color_mode,
+        light_dark: theme,
+        lightness: Some(lightness),
+        color_align,
+        backend,
+        args: None,
+        distro: distro.cloned(),
+        pride_month_disable: false,
+    };
+    debug!(?config, "created config");
+
+    // Save config
+    let save = literal_input("Save config?", &["y", "n"], "y", true, color_mode)
+        .context("failed to ask for choice input")?;
+    if save == "y" {
+        match path.parent().context("invalid config file path")? {
+            parent_path if parent_path != Path::new("") => {
+                fs::create_dir_all(parent_path)
+                    .with_context(|| format!("failed to create dir {parent_path:?}"))?;
+            },
+            _ => {
+                // Nothing to do if it's a relative path with one component
+            },
+        }
+        let file = File::options()
+            .write(true)
+            .create(true)
+            .truncate(true)
+            .open(path)
+            .with_context(|| format!("failed to open file {path:?} for writing"))?;
+        let mut serializer =
+            serde_json::Serializer::with_formatter(file, PrettyFormatter::with_indent(b"    "));
+        config
+            .serialize(&mut serializer)
+            .with_context(|| format!("failed to write config to file {path:?}"))?;
+        debug!(?path, "saved config");
+    }
+
+    Ok(config)
+}
+
+fn init_tracing_subsriber(debug_mode: bool) -> Result<()> {
+    use std::env;
+    use std::str::FromStr as _;
+
+    use tracing::Level;
+    use tracing_subscriber::filter::{LevelFilter, Targets};
+    use tracing_subscriber::fmt::Subscriber;
+    use tracing_subscriber::layer::SubscriberExt as _;
+    use tracing_subscriber::util::SubscriberInitExt as _;
+
+    let builder = Subscriber::builder();
+
+    // Remove the default max level filter from the subscriber; it will be added to
+    // the `Targets` filter instead if no filter is set in `RUST_LOG`.
+    // Replacing the default `LevelFilter` with an `EnvFilter` would imply this,
+    // but we can't replace the builder's filter with a `Targets` filter yet.
+    let builder = builder.with_max_level(LevelFilter::TRACE);
+
+    let subscriber = builder.finish();
+    let subscriber = {
+        let targets = match env::var("RUST_LOG") {
+            Ok(var) => Targets::from_str(&var)
+                .map_err(|e| {
+                    eprintln!("Ignoring `RUST_LOG={var:?}`: {e}");
+                })
+                .unwrap_or_default(),
+            Err(env::VarError::NotPresent) => {
+                Targets::new().with_default(Subscriber::DEFAULT_MAX_LEVEL)
+            },
+            Err(e) => {
+                eprintln!("Ignoring `RUST_LOG`: {e}");
+                Targets::new().with_default(Subscriber::DEFAULT_MAX_LEVEL)
+            },
+        };
+        let targets = if debug_mode {
+            targets.with_target(env!("CARGO_CRATE_NAME"), Level::DEBUG)
+        } else {
+            targets
+        };
+        subscriber.with(targets)
+    };
+
+    subscriber
+        .try_init()
+        .context("failed to set the global default subscriber")
+}
diff --git a/crates/hyfetch/src/cli_options.rs b/crates/hyfetch/src/cli_options.rs
new file mode 100644
index 00000000..4bb99b21
--- /dev/null
+++ b/crates/hyfetch/src/cli_options.rs
@@ -0,0 +1,228 @@
+use std::path::PathBuf;
+use std::str::FromStr as _;
+
+use anyhow::Context as _;
+#[cfg(feature = "autocomplete")]
+use bpaf::ShellComp;
+use bpaf::{construct, long, OptionParser, Parser as _};
+use directories::BaseDirs;
+use strum::VariantNames as _;
+
+use crate::color_util::{color, Lightness};
+use crate::presets::Preset;
+use crate::types::{AnsiMode, Backend};
+
+#[derive(Clone, Debug)]
+pub struct Options {
+    pub config: bool,
+    pub config_file: PathBuf,
+    pub preset: Option<Preset>,
+    pub mode: Option<AnsiMode>,
+    pub backend: Option<Backend>,
+    pub args: Option<Vec<String>>,
+    pub scale: Option<f32>,
+    pub lightness: Option<Lightness>,
+    pub june: bool,
+    pub debug: bool,
+    pub distro: Option<String>,
+    pub ascii_file: Option<PathBuf>,
+    pub test_print: bool,
+    pub ask_exit: bool,
+}
+
+pub fn options() -> OptionParser<Options> {
+    let config = long("config").short('c').help("Configure hyfetch").switch();
+    let config_file = long("config-file")
+        .short('C')
+        .help("Use another config file")
+        .argument("CONFIG_FILE");
+    #[cfg(feature = "autocomplete")]
+    let config_file = config_file.complete_shell(ShellComp::Nothing);
+    let config_file = config_file
+        .fallback_with(|| {
+            Ok::<_, anyhow::Error>(
+                BaseDirs::new()
+                    .context("failed to get base dirs")?
+                    .config_dir()
+                    .join("hyfetch.json"),
+            )
+        })
+        .debug_fallback();
+    let preset = long("preset")
+        .short('p')
+        .help(&*format!(
+            "Use preset
+PRESET={{{presets}}}",
+            presets = Preset::VARIANTS.join(",")
+        ))
+        .argument::<String>("PRESET");
+    #[cfg(feature = "autocomplete")]
+    let preset = preset.complete(complete_preset);
+    let preset = preset
+        .parse(|s| {
+            Preset::from_str(&s).with_context(|| {
+                format!(
+                    "PRESET should be one of {{{presets}}}",
+                    presets = Preset::VARIANTS.join(",")
+                )
+            })
+        })
+        .optional();
+    let mode = long("mode")
+        .short('m')
+        .help(&*format!(
+            "Color mode
+MODE={{{modes}}}",
+            modes = AnsiMode::VARIANTS.join(",")
+        ))
+        .argument::<String>("MODE");
+    #[cfg(feature = "autocomplete")]
+    let mode = mode.complete(complete_mode);
+    let mode = mode
+        .parse(|s| {
+            AnsiMode::from_str(&s).with_context(|| {
+                format!(
+                    "MODE should be one of {{{modes}}}",
+                    modes = AnsiMode::VARIANTS.join(",")
+                )
+            })
+        })
+        .optional();
+    let backend = long("backend")
+        .short('b')
+        .help(&*format!(
+            "Choose a *fetch backend
+BACKEND={{{backends}}}",
+            backends = Backend::VARIANTS.join(",")
+        ))
+        .argument::<String>("BACKEND");
+    #[cfg(feature = "autocomplete")]
+    let backend = backend.complete(complete_backend);
+    let backend = backend
+        .parse(|s| {
+            Backend::from_str(&s).with_context(|| {
+                format!(
+                    "BACKEND should be one of {{{backends}}}",
+                    backends = Backend::VARIANTS.join(",")
+                )
+            })
+        })
+        .optional();
+    let args = long("args")
+        .help("Additional arguments pass-through to backend")
+        .argument::<String>("ARGS")
+        .parse(|s| shell_words::split(&s).context("ARGS should be valid command-line arguments"))
+        .optional();
+    let scale = long("c-scale")
+        .help("Lighten colors by a multiplier")
+        .argument("SCALE")
+        .optional();
+    let lightness = long("c-set-l")
+        .help("Set lightness value of the colors")
+        .argument("LIGHTNESS")
+        .optional();
+    let june = long("june").help("Show pride month easter egg").switch();
+    let debug = long("debug").help("Debug mode").switch();
+    let distro = long("distro")
+        .help("Test for a specific distro")
+        .argument("DISTRO")
+        .optional();
+    let test_distro = long("test-distro")
+        .help("Test for a specific distro")
+        .argument("DISTRO")
+        .optional();
+    let distro = construct!([distro, test_distro]);
+    let ascii_file = long("ascii-file")
+        .help("Use a specific file for the ascii art")
+        .argument("ASCII_FILE");
+    #[cfg(feature = "autocomplete")]
+    let ascii_file = ascii_file.complete_shell(ShellComp::Nothing);
+    let ascii_file = ascii_file.optional();
+    let test_print = long("test-print")
+        .help("Print the ascii distro and exit")
+        .switch()
+        .hide();
+    let ask_exit = long("ask-exit")
+        .help("Ask for input before exiting")
+        .switch()
+        .hide();
+
+    construct!(Options {
+        config,
+        config_file,
+        preset,
+        mode,
+        backend,
+        args,
+        scale,
+        lightness,
+        june,
+        debug,
+        distro,
+        ascii_file,
+        // hidden
+        test_print,
+        ask_exit,
+    })
+    .to_options()
+    .header(
+        &*color(
+            "&l&bhyfetch&~&L - neofetch with flags <3",
+            AnsiMode::Ansi256,
+        )
+        .expect("header should not contain invalid color codes"),
+    )
+    .version(env!("CARGO_PKG_VERSION"))
+}
+
+#[cfg(feature = "autocomplete")]
+fn complete_preset(input: &String) -> Vec<(String, Option<String>)> {
+    Preset::VARIANTS
+        .iter()
+        .filter_map(|&name| {
+            if name.starts_with(input) {
+                Some((name.to_owned(), None))
+            } else {
+                None
+            }
+        })
+        .collect::<Vec<_>>()
+}
+
+#[cfg(feature = "autocomplete")]
+fn complete_mode(input: &String) -> Vec<(String, Option<String>)> {
+    AnsiMode::VARIANTS
+        .iter()
+        .filter_map(|&name| {
+            if name.starts_with(input) {
+                Some((name.to_owned(), None))
+            } else {
+                None
+            }
+        })
+        .collect::<Vec<_>>()
+}
+
+#[cfg(feature = "autocomplete")]
+fn complete_backend(input: &String) -> Vec<(String, Option<String>)> {
+    Backend::VARIANTS
+        .iter()
+        .filter_map(|&name| {
+            if name.starts_with(input) {
+                Some((name.to_owned(), None))
+            } else {
+                None
+            }
+        })
+        .collect::<Vec<_>>()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn check_options() {
+        options().check_invariants(false)
+    }
+}
diff --git a/crates/hyfetch/src/color_util.rs b/crates/hyfetch/src/color_util.rs
new file mode 100644
index 00000000..f4584bb4
--- /dev/null
+++ b/crates/hyfetch/src/color_util.rs
@@ -0,0 +1,433 @@
+use std::io::{self, Write as _};
+use std::num::{ParseFloatError, ParseIntError};
+use std::str::FromStr;
+use std::sync::OnceLock;
+
+use aho_corasick::AhoCorasick;
+use ansi_colours::{ansi256_from_grey, rgb_from_ansi256, AsRGB as _};
+use anyhow::{anyhow, Context as _, Result};
+use deranged::RangedU8;
+use palette::color_difference::ImprovedCiede2000 as _;
+use palette::{
+    FromColor as _, IntoColor as _, IntoColorMut as _, Lab, LinSrgb, Okhsl, Srgb, SrgbLuma,
+};
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+
+use crate::types::{AnsiMode, TerminalTheme};
+
+const MINECRAFT_COLORS: [(&str, &str); 30] = [
+    // Minecraft formatting codes
+    // ==========================
+    ("&0", "\x1b[38;5;0m"),
+    ("&1", "\x1b[38;5;4m"),
+    ("&2", "\x1b[38;5;2m"),
+    ("&3", "\x1b[38;5;6m"),
+    ("&4", "\x1b[38;5;1m"),
+    ("&5", "\x1b[38;5;5m"),
+    ("&6", "\x1b[38;5;3m"),
+    ("&7", "\x1b[38;5;7m"),
+    ("&8", "\x1b[38;5;8m"),
+    ("&9", "\x1b[38;5;12m"),
+    ("&a", "\x1b[38;5;10m"),
+    ("&b", "\x1b[38;5;14m"),
+    ("&c", "\x1b[38;5;9m"),
+    ("&d", "\x1b[38;5;13m"),
+    ("&e", "\x1b[38;5;11m"),
+    ("&f", "\x1b[38;5;15m"),
+    ("&l", "\x1b[1m"), // Enable bold text
+    ("&o", "\x1b[3m"), // Enable italic text
+    ("&n", "\x1b[4m"), // Enable underlined text
+    ("&k", "\x1b[8m"), // Enable hidden text
+    ("&m", "\x1b[9m"), // Enable strikethrough text
+    ("&r", "\x1b[0m"), // Reset everything
+    // Extended codes (not officially in Minecraft)
+    // ============================================
+    ("&-", "\n"),       // Line break
+    ("&~", "\x1b[39m"), // Reset text color
+    ("&*", "\x1b[49m"), // Reset background color
+    ("&L", "\x1b[22m"), // Disable bold text
+    ("&O", "\x1b[23m"), // Disable italic text
+    ("&N", "\x1b[24m"), // Disable underlined text
+    ("&K", "\x1b[28m"), // Disable hidden text
+    ("&M", "\x1b[29m"), // Disable strikethrough text
+];
+const RGB_COLOR_PATTERNS: [&str; 2] = ["&gf(", "&gb("];
+
+/// See https://github.com/mina86/ansi_colours/blob/b9feefce10def2ac632b215ecd20830a4fca7836/src/ansi256.rs#L109
+const ANSI256_GRAYSCALE_COLORS: [u8; 30] = [
+    16, 59, 102, 145, 188, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244,
+    245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255,
+];
+
+static MINECRAFT_COLORS_AC: OnceLock<(AhoCorasick, Box<[&str; 30]>)> = OnceLock::new();
+static RGB_COLORS_AC: OnceLock<AhoCorasick> = OnceLock::new();
+
+/// Represents the lightness component in [`Okhsl`].
+///
+/// The range of valid values is
+/// [`Lightness::MIN`]`..=`[`Lightness::MAX`]
+///
+/// [`Okhsl`]: palette::Okhsl
+#[derive(Copy, Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)]
+pub struct Lightness(f32);
+
+#[derive(Debug, Error)]
+pub enum LightnessError {
+    #[error(
+        "invalid lightness {0}, expected value between {min} and {max}",
+        min = Lightness::MIN,
+        max = Lightness::MAX
+    )]
+    OutOfRange(f32),
+}
+
+#[derive(Debug, Error)]
+pub enum ParseLightnessError {
+    #[error("invalid float")]
+    InvalidFloat(#[from] ParseFloatError),
+    #[error("invalid lightness")]
+    InvalidLightness(#[from] LightnessError),
+}
+
+/// An indexed color where the color palette is the set of colors used in
+/// neofetch ascii art.
+///
+/// The range of valid values as supported in neofetch is
+/// [`NeofetchAsciiIndexedColor::MIN`]`..=`[`NeofetchAsciiIndexedColor::MAX`]
+#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)]
+pub struct NeofetchAsciiIndexedColor(
+    RangedU8<{ NeofetchAsciiIndexedColor::MIN }, { NeofetchAsciiIndexedColor::MAX }>,
+);
+
+/// An indexed color where the color palette is the set of unique colors in a
+/// preset.
+///
+/// The range of valid values depends on the number of unique colors in a
+/// certain preset.
+#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)]
+pub struct PresetIndexedColor(u8);
+
+/// Whether the color is for foreground text or background color.
+#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
+pub enum ForegroundBackground {
+    Foreground,
+    Background,
+}
+
+pub trait ToAnsiString {
+    /// Converts RGB to ANSI escape code.
+    fn to_ansi_string(&self, mode: AnsiMode, foreground_background: ForegroundBackground)
+        -> String;
+}
+
+pub trait Theme {
+    fn theme(&self) -> TerminalTheme;
+}
+
+pub trait ContrastGrayscale {
+    /// Calculates the grayscale foreground color which provides the highest
+    /// contrast against this background color.
+    ///
+    /// The returned color is one of the ANSI 256 (8-bit) grayscale colors.
+    ///
+    /// See <https://upload.wikimedia.org/wikipedia/commons/1/15/Xterm_256color_chart.svg>
+    fn contrast_grayscale(&self) -> SrgbLuma<u8>;
+}
+
+impl Lightness {
+    pub const MAX: f32 = 1.0f32;
+    pub const MIN: f32 = 0.0f32;
+
+    pub fn new(value: f32) -> Result<Self, LightnessError> {
+        if !(Self::MIN..=Self::MAX).contains(&value) {
+            return Err(LightnessError::OutOfRange(value));
+        }
+
+        Ok(Self(value))
+    }
+}
+
+impl TryFrom<f32> for Lightness {
+    type Error = LightnessError;
+
+    fn try_from(value: f32) -> Result<Self, Self::Error> {
+        Lightness::new(value)
+    }
+}
+
+impl FromStr for Lightness {
+    type Err = ParseLightnessError;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        Ok(Lightness::new(s.parse()?)?)
+    }
+}
+
+impl From<Lightness> for f32 {
+    fn from(value: Lightness) -> Self {
+        value.0
+    }
+}
+
+impl NeofetchAsciiIndexedColor {
+    pub const MAX: u8 = 6;
+    pub const MIN: u8 = 1;
+}
+
+impl TryFrom<u8> for NeofetchAsciiIndexedColor {
+    type Error = deranged::TryFromIntError;
+
+    fn try_from(value: u8) -> Result<Self, Self::Error> {
+        Ok(Self(value.try_into()?))
+    }
+}
+
+impl FromStr for NeofetchAsciiIndexedColor {
+    type Err = deranged::ParseIntError;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        Ok(Self(s.parse()?))
+    }
+}
+
+impl From<NeofetchAsciiIndexedColor> for u8 {
+    fn from(value: NeofetchAsciiIndexedColor) -> Self {
+        value.0.get()
+    }
+}
+
+impl From<u8> for PresetIndexedColor {
+    fn from(value: u8) -> Self {
+        Self(value)
+    }
+}
+
+impl FromStr for PresetIndexedColor {
+    type Err = ParseIntError;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        Ok(Self(s.parse()?))
+    }
+}
+
+impl From<PresetIndexedColor> for u8 {
+    fn from(value: PresetIndexedColor) -> Self {
+        value.0
+    }
+}
+
+impl ToAnsiString for Srgb<u8> {
+    fn to_ansi_string(
+        &self,
+        mode: AnsiMode,
+        foreground_background: ForegroundBackground,
+    ) -> String {
+        let c: u8 = match foreground_background {
+            ForegroundBackground::Foreground => 38,
+            ForegroundBackground::Background => 48,
+        };
+        match mode {
+            AnsiMode::Rgb => {
+                let [r, g, b]: [u8; 3] = (*self).into();
+                format!("\x1b[{c};2;{r};{g};{b}m")
+            },
+            AnsiMode::Ansi256 => {
+                let rgb: [u8; 3] = (*self).into();
+                let indexed = rgb.to_ansi256();
+                format!("\x1b[{c};5;{indexed}m")
+            },
+            AnsiMode::Ansi16 => {
+                unimplemented!();
+            },
+        }
+    }
+}
+
+impl ToAnsiString for SrgbLuma<u8> {
+    fn to_ansi_string(
+        &self,
+        mode: AnsiMode,
+        foreground_background: ForegroundBackground,
+    ) -> String {
+        let c: u8 = match foreground_background {
+            ForegroundBackground::Foreground => 38,
+            ForegroundBackground::Background => 48,
+        };
+        match mode {
+            AnsiMode::Rgb => {
+                let rgb_f32_color: LinSrgb = self.into_linear().into_color();
+                let [r, g, b]: [u8; 3] = Srgb::<u8>::from_linear(rgb_f32_color).into();
+                format!("\x1b[{c};2;{r};{g};{b}m")
+            },
+            AnsiMode::Ansi256 => {
+                let indexed = ansi256_from_grey(self.luma);
+                format!("\x1b[{c};5;{indexed}m")
+            },
+            AnsiMode::Ansi16 => {
+                unimplemented!();
+            },
+        }
+    }
+}
+
+impl Theme for Srgb<u8> {
+    fn theme(&self) -> TerminalTheme {
+        let mut rgb_f32_color: LinSrgb = self.into_linear();
+
+        {
+            let okhsl_f32_color: &mut Okhsl = &mut rgb_f32_color.into_color_mut();
+
+            if okhsl_f32_color.lightness > 0.5 {
+                TerminalTheme::Light
+            } else {
+                TerminalTheme::Dark
+            }
+        }
+    }
+}
+
+impl ContrastGrayscale for Srgb<u8> {
+    fn contrast_grayscale(&self) -> SrgbLuma<u8> {
+        let self_lab_f32: Lab = self.into_linear().into_color();
+
+        let mut best_contrast = None;
+        for indexed in ANSI256_GRAYSCALE_COLORS {
+            let rgb_u8_color: Srgb<u8> = rgb_from_ansi256(indexed).into();
+            let lab_f32_color: Lab = rgb_u8_color.into_linear().into_color();
+            let diff = lab_f32_color.improved_difference(self_lab_f32);
+            best_contrast = match best_contrast {
+                Some((_, best_diff)) if diff > best_diff => Some((lab_f32_color, diff)),
+                None => Some((lab_f32_color, diff)),
+                best => best,
+            };
+        }
+        let (best_lab_f32, _) = best_contrast.expect("`best_contrast` should not be `None`");
+        SrgbLuma::from_color(best_lab_f32).into_format()
+    }
+}
+
+/// Replaces extended minecraft color codes in message.
+///
+/// Returns message with escape codes.
+pub fn color<S>(msg: S, mode: AnsiMode) -> Result<String>
+where
+    S: AsRef<str>,
+{
+    let msg = msg.as_ref();
+
+    let msg = {
+        let (ac, escape_codes) = MINECRAFT_COLORS_AC.get_or_init(|| {
+            let (color_codes, escape_codes): (Vec<_>, Vec<_>) =
+                MINECRAFT_COLORS.into_iter().unzip();
+            let ac = AhoCorasick::new(color_codes).unwrap();
+            (
+                ac,
+                escape_codes.try_into().expect(
+                    "`MINECRAFT_COLORS` should have the same number of elements as \
+                     `MINECRAFT_COLORS_AC.get_or_init(...).1`",
+                ),
+            )
+        });
+        ac.replace_all(msg, &escape_codes[..])
+    };
+
+    let ac = RGB_COLORS_AC.get_or_init(|| AhoCorasick::new(RGB_COLOR_PATTERNS).unwrap());
+    let mut dst = String::new();
+    let mut ret_err = None;
+    ac.replace_all_with(&msg, &mut dst, |m, _, dst| {
+        let start = m.end();
+        let end = msg[start..]
+            .find(')')
+            .context("missing closing brace for color code");
+        let end = match end {
+            Ok(end) => end,
+            Err(err) => {
+                ret_err = Some(err);
+                return false;
+            },
+        };
+        let code = &msg[start..end];
+        let foreground_background = if m.pattern().as_usize() == 0 {
+            ForegroundBackground::Foreground
+        } else {
+            ForegroundBackground::Background
+        };
+
+        let rgb: Srgb<u8> = if code.starts_with('#') {
+            let rgb = code.parse().context("failed to parse hex color");
+            match rgb {
+                Ok(rgb) => rgb,
+                Err(err) => {
+                    ret_err = Some(err);
+                    return false;
+                },
+            }
+        } else {
+            let rgb: Result<[&str; 3], _> = code
+                .split(&[',', ';', ' '])
+                .filter(|x| x.is_empty())
+                .collect::<Vec<_>>()
+                .try_into()
+                .map_err(|_| anyhow!("wrong number of rgb components"));
+            let rgb = match rgb {
+                Ok(rgb) => rgb,
+                Err(err) => {
+                    ret_err = Some(err);
+                    return false;
+                },
+            };
+            let rgb = rgb
+                .into_iter()
+                .map(u8::from_str)
+                .collect::<Result<Vec<_>, _>>()
+                .context("failed to parse rgb components");
+            let rgb: [u8; 3] = match rgb {
+                Ok(rgb) => rgb.try_into().unwrap(),
+                Err(err) => {
+                    ret_err = Some(err);
+                    return false;
+                },
+            };
+            rgb.into()
+        };
+
+        dst.push_str(&rgb.to_ansi_string(mode, foreground_background));
+
+        true
+    });
+    if let Some(err) = ret_err {
+        return Err(err);
+    }
+
+    Ok(dst)
+}
+
+/// Prints with color.
+pub fn printc<S>(msg: S, mode: AnsiMode) -> Result<()>
+where
+    S: AsRef<str>,
+{
+    writeln!(
+        io::stdout(),
+        "{msg}",
+        msg = color(format!("{msg}&r", msg = msg.as_ref()), mode)
+            .context("failed to color message")?
+    )
+    .context("failed to write message to stdout")
+}
+
+/// Clears screen using ANSI escape codes.
+pub fn clear_screen(title: Option<&str>, mode: AnsiMode, debug_mode: bool) -> Result<()> {
+    if !debug_mode {
+        write!(io::stdout(), "\x1b[2J\x1b[H")
+            .and_then(|_| io::stdout().flush())
+            .context("failed to write clear screen sequence to stdout")?;
+    }
+
+    if let Some(title) = title {
+        printc(format!("\n{title}\n"), mode).context("failed to print title")?;
+    }
+
+    Ok(())
+}
diff --git a/crates/hyfetch/src/distros.rs b/crates/hyfetch/src/distros.rs
new file mode 100644
index 00000000..a41e9ea8
--- /dev/null
+++ b/crates/hyfetch/src/distros.rs
@@ -0,0 +1,3 @@
+#![allow(non_camel_case_types)]
+
+include!(concat!(env!("OUT_DIR"), "/distros.rs"));
diff --git a/crates/hyfetch/src/lib.rs b/crates/hyfetch/src/lib.rs
new file mode 100644
index 00000000..4cac9f42
--- /dev/null
+++ b/crates/hyfetch/src/lib.rs
@@ -0,0 +1,10 @@
+pub mod ascii;
+pub mod cli_options;
+pub mod color_util;
+pub mod distros;
+pub mod models;
+pub mod neofetch_util;
+pub mod presets;
+pub mod pride_month;
+pub mod types;
+pub mod utils;
diff --git a/crates/hyfetch/src/models.rs b/crates/hyfetch/src/models.rs
new file mode 100644
index 00000000..30571f1f
--- /dev/null
+++ b/crates/hyfetch/src/models.rs
@@ -0,0 +1,123 @@
+use serde::{Deserialize, Serialize};
+
+use crate::color_util::Lightness;
+use crate::neofetch_util::ColorAlignment;
+use crate::presets::Preset;
+use crate::types::{AnsiMode, Backend, TerminalTheme};
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct Config {
+    pub preset: Preset,
+    pub mode: AnsiMode,
+    pub light_dark: TerminalTheme,
+    pub lightness: Option<Lightness>,
+    pub color_align: ColorAlignment,
+    pub backend: Backend,
+    #[serde(default)]
+    #[serde(with = "self::args_serde")]
+    pub args: Option<Vec<String>>,
+    pub distro: Option<String>,
+    pub pride_month_disable: bool,
+}
+
+impl Config {
+    pub fn default_lightness(theme: TerminalTheme) -> Lightness {
+        match theme {
+            TerminalTheme::Dark => {
+                Lightness::new(0.65).expect("default lightness should not be invalid")
+            },
+            TerminalTheme::Light => {
+                Lightness::new(0.4).expect("default lightness should not be invalid")
+            },
+        }
+    }
+
+    pub fn lightness(&self) -> Lightness {
+        self.lightness
+            .unwrap_or_else(|| Self::default_lightness(self.light_dark))
+    }
+}
+
+mod args_serde {
+    use std::fmt;
+
+    use serde::de::{self, value, Deserialize, Deserializer, SeqAccess, Visitor};
+    use serde::ser::Serializer;
+
+    type Value = Option<Vec<String>>;
+
+    pub(super) fn serialize<S>(value: &Value, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+    {
+        match value {
+            Some(value) => serializer.serialize_some(&shell_words::join(value)),
+            None => serializer.serialize_none(),
+        }
+    }
+
+    pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<Value, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        struct StringOrVec;
+
+        struct OptionVisitor;
+
+        impl<'de> Visitor<'de> for StringOrVec {
+            type Value = Vec<String>;
+
+            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+                formatter.write_str("string or list of strings")
+            }
+
+            fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
+            where
+                E: de::Error,
+            {
+                shell_words::split(s).map_err(de::Error::custom)
+            }
+
+            fn visit_seq<S>(self, seq: S) -> Result<Self::Value, S::Error>
+            where
+                S: SeqAccess<'de>,
+            {
+                Deserialize::deserialize(value::SeqAccessDeserializer::new(seq))
+            }
+        }
+
+        impl<'de> Visitor<'de> for OptionVisitor {
+            type Value = Value;
+
+            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+                formatter.write_str("option")
+            }
+
+            #[inline]
+            fn visit_unit<E>(self) -> Result<Self::Value, E>
+            where
+                E: de::Error,
+            {
+                Ok(None)
+            }
+
+            #[inline]
+            fn visit_none<E>(self) -> Result<Self::Value, E>
+            where
+                E: de::Error,
+            {
+                Ok(None)
+            }
+
+            #[inline]
+            fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
+            where
+                D: Deserializer<'de>,
+            {
+                deserializer.deserialize_any(StringOrVec).map(Some)
+            }
+        }
+
+        deserializer.deserialize_option(OptionVisitor)
+    }
+}
diff --git a/crates/hyfetch/src/neofetch_util.rs b/crates/hyfetch/src/neofetch_util.rs
new file mode 100644
index 00000000..12e61ecb
--- /dev/null
+++ b/crates/hyfetch/src/neofetch_util.rs
@@ -0,0 +1,898 @@
+use std::borrow::Cow;
+use std::ffi::OsStr;
+#[cfg(feature = "macchina")]
+use std::fs;
+#[cfg(windows)]
+use std::io;
+use std::io::{self, Write as _};
+use std::path::{Path, PathBuf};
+use std::process::Command;
+use std::sync::OnceLock;
+use std::{env, fmt};
+
+use aho_corasick::AhoCorasick;
+use anyhow::{anyhow, Context as _, Result};
+use indexmap::IndexMap;
+use itertools::Itertools as _;
+#[cfg(windows)]
+use normpath::PathExt as _;
+#[cfg(windows)]
+use same_file::is_same_file;
+use serde::{Deserialize, Serialize};
+use strum::AsRefStr;
+#[cfg(feature = "macchina")]
+use toml_edit::{value, DocumentMut, Item, Table};
+use tracing::debug;
+use unicode_segmentation::UnicodeSegmentation as _;
+
+use crate::ascii::{RawAsciiArt, RecoloredAsciiArt};
+use crate::color_util::{printc, NeofetchAsciiIndexedColor, PresetIndexedColor};
+use crate::distros::Distro;
+use crate::types::{AnsiMode, Backend};
+use crate::utils::{find_file, find_in_path, input, process_command_status};
+
+pub const TEST_ASCII: &str = r####################"
+### |\___/| ###
+### )     ( ###
+## =\     /= ##
+#### )===( ####
+### /     \ ###
+### |     | ###
+## / {txt} \ ##
+## \       / ##
+_/\_\_   _/_/\_
+|##|  ( (  |##|
+|##|   ) ) |##|
+|##|  (_(  |##|
+"####################;
+
+pub const NEOFETCH_COLOR_PATTERNS: [&str; 6] =
+    ["${c1}", "${c2}", "${c3}", "${c4}", "${c5}", "${c6}"];
+pub static NEOFETCH_COLORS_AC: OnceLock<AhoCorasick> = OnceLock::new();
+
+#[derive(Clone, Eq, PartialEq, Debug, AsRefStr, Deserialize, Serialize)]
+#[serde(tag = "mode")]
+#[serde(rename_all = "lowercase")]
+#[strum(serialize_all = "lowercase")]
+pub enum ColorAlignment {
+    Horizontal,
+    Vertical,
+    Custom {
+        #[serde(rename = "custom_colors")]
+        #[serde(deserialize_with = "crate::utils::index_map_serde::deserialize")]
+        colors: IndexMap<NeofetchAsciiIndexedColor, PresetIndexedColor>,
+    },
+}
+
+/// Asks the user to provide an input among a list of options.
+pub fn literal_input<'a, S1, S2>(
+    prompt: S1,
+    options: &'a [S2],
+    default: &str,
+    show_options: bool,
+    color_mode: AnsiMode,
+) -> Result<&'a str>
+where
+    S1: AsRef<str>,
+    S2: AsRef<str>,
+{
+    let prompt = prompt.as_ref();
+
+    if show_options {
+        let options_text = options
+            .iter()
+            .map(|o| {
+                let o = o.as_ref();
+
+                if o == default {
+                    format!("&l&n{o}&L&N")
+                } else {
+                    o.to_owned()
+                }
+            })
+            .collect::<Vec<_>>()
+            .join("|");
+        printc(format!("{prompt} ({options_text})"), color_mode)
+            .context("failed to print input prompt")?;
+    } else {
+        printc(format!("{prompt} (default: {default})"), color_mode)
+            .context("failed to print input prompt")?;
+    }
+
+    loop {
+        let selection = input(Some("> ")).context("failed to read input")?;
+        let selection = if selection.is_empty() {
+            default.to_owned()
+        } else {
+            selection.to_lowercase()
+        };
+
+        if let Some(selected) = find_selection(&selection, options) {
+            writeln!(io::stdout()).context("failed to write to stdout")?;
+
+            return Ok(selected);
+        } else {
+            let options_text = options.iter().map(AsRef::as_ref).join("|");
+            writeln!(
+                io::stdout(),
+                "Invalid selection! {selection} is not one of {options_text}"
+            )
+            .context("failed to write message to stdout")?;
+        }
+    }
+
+    fn find_selection<'a, S>(sel: &str, options: &'a [S]) -> Option<&'a str>
+    where
+        S: AsRef<str>,
+    {
+        if sel.is_empty() {
+            return None;
+        }
+
+        // Find exact match
+        if let Some(selected) = options.iter().find(|&o| o.as_ref().to_lowercase() == sel) {
+            return Some(selected.as_ref());
+        }
+
+        // Find starting abbreviation
+        if let Some(selected) = options
+            .iter()
+            .find(|&o| o.as_ref().to_lowercase().starts_with(sel))
+        {
+            return Some(selected.as_ref());
+        }
+
+        None
+    }
+}
+
+/// Gets the absolute path of the [neofetch] command.
+///
+/// [neofetch]: https://github.com/hykilpikonna/hyfetch#running-updated-original-neofetch
+pub fn neofetch_path() -> Result<Option<PathBuf>> {
+    if let Some(workspace_dir) = env::var_os("CARGO_WORKSPACE_DIR") {
+        debug!(
+            ?workspace_dir,
+            "CARGO_WORKSPACE_DIR env var is set; using neofetch from project directory"
+        );
+        let workspace_path = Path::new(&workspace_dir);
+        let workspace_path = match workspace_path.try_exists() {
+            Ok(true) => workspace_path,
+            Ok(false) => {
+                return Err(anyhow!(
+                    "{workspace_path:?} does not exist or is not readable"
+                ));
+            },
+            Err(err) => {
+                return Err(err)
+                    .with_context(|| format!("failed to check existence of {workspace_path:?}"));
+            },
+        };
+        let neofetch_path = workspace_path.join("neofetch");
+        return find_file(&neofetch_path)
+            .with_context(|| format!("failed to check existence of file {neofetch_path:?}"));
+    }
+
+    let neowofetch_path = find_in_path("neowofetch")
+        .context("failed to check existence of `neowofetch` in `PATH`")?;
+
+    // Fall back to `neowofetch` in directory of current executable
+    #[cfg(windows)]
+    let neowofetch_path = neowofetch_path.map_or_else(
+        || {
+            let current_exe_path: PathBuf = env::current_exe()
+                .and_then(|p| p.normalize().map(|p| p.into()))
+                .context("failed to get path of current running executable")?;
+            let neowofetch_path = current_exe_path
+                .parent()
+                .expect("parent should not be `None`")
+                .join("neowofetch");
+            find_file(&neowofetch_path)
+                .with_context(|| format!("failed to check existence of file {neowofetch_path:?}"))
+        },
+        |path| Ok(Some(path)),
+    )?;
+
+    Ok(neowofetch_path)
+}
+
+/// Gets the absolute path of the [fastfetch] command.
+///
+/// [fastfetch]: https://github.com/fastfetch-cli/fastfetch
+pub fn fastfetch_path() -> Result<Option<PathBuf>> {
+    let fastfetch_path = {
+        #[cfg(not(windows))]
+        {
+            find_in_path("fastfetch")
+                .context("failed to check existence of `fastfetch` in `PATH`")?
+        }
+        #[cfg(windows)]
+        {
+            find_in_path("fastfetch.exe")
+                .context("failed to check existence of `fastfetch.exe` in `PATH`")?
+        }
+    };
+
+    // Fall back to `fastfetch\fastfetch.exe` in directory of current executable
+    #[cfg(windows)]
+    let fastfetch_path = fastfetch_path.map_or_else(
+        || {
+            let current_exe_path: PathBuf = env::current_exe()
+                .and_then(|p| p.normalize().map(|p| p.into()))
+                .context("failed to get path of current running executable")?;
+            let current_exe_dir_path = current_exe_path
+                .parent()
+                .expect("parent should not be `None`");
+            let fastfetch_path = current_exe_dir_path.join(r"fastfetch\fastfetch.exe");
+            find_file(&fastfetch_path)
+                .with_context(|| format!("failed to check existence of file {fastfetch_path:?}"))
+        },
+        |path| Ok(Some(path)),
+    )?;
+
+    Ok(fastfetch_path)
+}
+
+/// Gets the absolute path of the [macchina] command.
+///
+/// [macchina]: https://github.com/Macchina-CLI/macchina
+#[cfg(feature = "macchina")]
+pub fn macchina_path() -> Result<Option<PathBuf>> {
+    let macchina_path = {
+        #[cfg(not(windows))]
+        {
+            find_in_path("macchina").context("failed to check existence of `macchina` in `PATH`")?
+        }
+        #[cfg(windows)]
+        {
+            find_in_path("macchina.exe")
+                .context("failed to check existence of `macchina.exe` in `PATH`")?
+        }
+    };
+
+    // Fall back to `macchina.exe` in directory of current executable
+    #[cfg(windows)]
+    let macchina_path = macchina_path.map_or_else(
+        || {
+            let current_exe_path: PathBuf = env::current_exe()
+                .and_then(|p| p.normalize().map(|p| p.into()))
+                .context("failed to get path of current running executable")?;
+            let current_exe_dir_path = current_exe_path
+                .parent()
+                .expect("parent should not be `None`");
+            let macchina_path = current_exe_dir_path.join("macchina.exe");
+            find_file(&macchina_path)
+                .with_context(|| format!("failed to check existence of file {macchina_path:?}"))
+        },
+        |path| Ok(Some(path)),
+    )?;
+
+    Ok(macchina_path)
+}
+
+/// Gets the distro ascii of the current distro. Or if distro is specified, get
+/// the specific distro's ascii art instead.
+#[tracing::instrument(level = "debug")]
+pub fn get_distro_ascii<S>(distro: Option<S>, backend: Backend) -> Result<RawAsciiArt>
+where
+    S: AsRef<str> + fmt::Debug,
+{
+    let distro: Cow<_> = if let Some(distro) = distro.as_ref() {
+        distro.as_ref().into()
+    } else {
+        get_distro_name(backend)
+            .context("failed to get distro name")?
+            .into()
+    };
+    debug!(%distro, "distro name");
+
+    // Try new codegen-based detection method
+    if let Some(distro) = Distro::detect(&distro) {
+        let asc = distro.ascii_art().to_owned();
+        let fg = ascii_foreground(&distro);
+
+        return Ok(RawAsciiArt { asc, fg });
+    }
+
+    debug!(%distro, "could not find a match for distro; falling back to neofetch");
+
+    // Old detection method that calls neofetch
+    let asc = run_neofetch_command_piped(&["print_ascii", "--ascii_distro", distro.as_ref()])
+        .context("failed to get ascii art from neofetch")?;
+
+    // Unescape backslashes here because backslashes are escaped in neofetch for
+    // printf
+    let asc = asc.replace(r"\\", r"\");
+
+    Ok(RawAsciiArt {
+        asc,
+        fg: Vec::new(),
+    })
+}
+
+#[tracing::instrument(level = "debug", skip(asc), fields(asc.w = asc.w, asc.h = asc.h))]
+pub fn run(asc: RecoloredAsciiArt, backend: Backend, args: Option<&Vec<String>>) -> Result<()> {
+    let asc = asc.lines.join("\n");
+
+    match backend {
+        Backend::Neofetch => {
+            run_neofetch(asc, args).context("failed to run neofetch")?;
+        },
+        Backend::Fastfetch => {
+            run_fastfetch(asc, args).context("failed to run fastfetch")?;
+        },
+        #[cfg(feature = "macchina")]
+        Backend::Macchina => {
+            run_macchina(asc, args).context("failed to run macchina")?;
+        },
+    }
+
+    Ok(())
+}
+
+/// Gets distro ascii width and height, ignoring color code.
+pub fn ascii_size<S>(asc: S) -> Result<(u8, u8)>
+where
+    S: AsRef<str>,
+{
+    let asc = asc.as_ref();
+
+    if asc.is_empty() {
+        return Ok((0, 0));
+    }
+
+    let asc = {
+        let ac =
+            NEOFETCH_COLORS_AC.get_or_init(|| AhoCorasick::new(NEOFETCH_COLOR_PATTERNS).unwrap());
+        const N: usize = NEOFETCH_COLOR_PATTERNS.len();
+        const REPLACEMENTS: [&str; N] = [""; N];
+        ac.replace_all(asc, &REPLACEMENTS)
+    };
+
+    if asc.is_empty() {
+        return Ok((0, 0));
+    }
+
+    let width = asc
+        .lines()
+        .map(|line| line.graphemes(true).count())
+        .max()
+        .expect("line iterator should not be empty");
+    let width: u8 = width.try_into().with_context(|| {
+        format!(
+            "`asc` should not have more than {limit} characters per line",
+            limit = u8::MAX
+        )
+    })?;
+    let height = asc.lines().count();
+    let height: u8 = height.try_into().with_context(|| {
+        format!(
+            "`asc` should not have more than {limit} lines",
+            limit = u8::MAX
+        )
+    })?;
+
+    Ok((width, height))
+}
+
+/// Gets the absolute path of the bash command.
+#[cfg(windows)]
+fn bash_path() -> Result<PathBuf> {
+    // Find `bash.exe` in `PATH`, but exclude the known bad paths
+    let bash_path = find_in_path("bash.exe")
+        .context("failed to check existence of `bash.exe` in `PATH`")?
+        .map_or_else(
+            || Ok(None),
+            |bash_path| {
+                if bash_path.ends_with(r"Git\usr\bin\bash.exe") {
+                    // See https://stackoverflow.com/a/58418686/1529493
+                    Ok(None)
+                } else {
+                    // See https://github.com/hykilpikonna/hyfetch/issues/233
+                    let windir = env::var_os("windir")
+                        .context("`windir` environment variable is not set or invalid")?;
+                    match is_same_file(&bash_path, Path::new(&windir).join(r"System32\bash.exe")) {
+                        Ok(true) => Ok(None),
+                        Ok(false) => Ok(Some(bash_path)),
+                        Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(Some(bash_path)),
+                        Err(err) => {
+                            Err(err).context("failed to check if paths refer to the same file")
+                        },
+                    }
+                }
+            },
+        )?;
+
+    // Detect any Git for Windows installation in `PATH`
+    let bash_path = bash_path.map_or_else(
+        || {
+            let git_path = find_in_path("git.exe")
+                .context("failed to check existence of `git.exe` in `PATH`")?;
+            match git_path {
+                Some(git_path) if git_path.ends_with(r"Git\cmd\git.exe") => {
+                    let bash_path = git_path
+                        .parent()
+                        .expect("parent should not be `None`")
+                        .parent()
+                        .expect("parent should not be `None`")
+                        .join(r"bin\bash.exe");
+                    if bash_path.is_file() {
+                        Ok(Some(bash_path))
+                    } else {
+                        Ok(None)
+                    }
+                },
+                _ => Ok(None),
+            }
+        },
+        |path| Ok(Some(path)),
+    )?;
+
+    // Fall back to default Git for Windows installation paths
+    let bash_path = bash_path
+        .or_else(|| {
+            let program_files_dir = env::var_os("ProgramFiles")?;
+            let bash_path = Path::new(&program_files_dir).join(r"Git\bin\bash.exe");
+            if bash_path.is_file() {
+                Some(bash_path)
+            } else {
+                None
+            }
+        })
+        .or_else(|| {
+            let program_files_x86_dir = env::var_os("ProgramFiles(x86)")?;
+            let bash_path = Path::new(&program_files_x86_dir).join(r"Git\bin\bash.exe");
+            if bash_path.is_file() {
+                Some(bash_path)
+            } else {
+                None
+            }
+        });
+
+    // Bundled git bash
+    let bash_path = bash_path.map_or_else(
+        || {
+            let current_exe_path: PathBuf = env::current_exe()
+                .and_then(|p| p.normalize().map(|p| p.into()))
+                .context("failed to get path of current running executable")?;
+            let bash_path = current_exe_path
+                .parent()
+                .expect("parent should not be `None`")
+                .join(r"git\bin\bash.exe");
+            if bash_path.is_file() {
+                Ok(Some(bash_path))
+            } else {
+                Ok(None)
+            }
+        },
+        |path| Ok(Some(path)),
+    )?;
+
+    let bash_path = bash_path.context("bash command not found")?;
+
+    Ok(bash_path)
+}
+
+/// Runs neofetch command, returning the piped stdout output.
+fn run_neofetch_command_piped<S>(args: &[S]) -> Result<String>
+where
+    S: AsRef<OsStr> + fmt::Debug,
+{
+    let mut command = make_neofetch_command(args)?;
+
+    let output = command
+        .output()
+        .context("failed to execute neofetch as child process")?;
+    debug!(?output, "neofetch output");
+    process_command_status(&output.status).context("neofetch command exited with error")?;
+
+    let out = String::from_utf8(output.stdout)
+        .context("failed to process neofetch output as it contains invalid UTF-8")?
+        .trim()
+        .to_owned();
+    Ok(out)
+}
+
+fn make_neofetch_command<S>(args: &[S]) -> Result<Command>
+where
+    S: AsRef<OsStr>,
+{
+    // Find neofetch script
+    let neofetch_path = neofetch_path()
+        .context("failed to get neofetch path")?
+        .context("neofetch command not found")?;
+
+    debug!(?neofetch_path, "neofetch path");
+
+    #[cfg(not(windows))]
+    {
+        let mut command = Command::new("bash");
+        command.arg(neofetch_path);
+        command.args(args);
+        Ok(command)
+    }
+    #[cfg(windows)]
+    {
+        let bash_path = bash_path().context("failed to get bash path")?;
+        let mut command = Command::new(bash_path);
+        command.arg(neofetch_path);
+        command.args(args);
+        Ok(command)
+    }
+}
+
+/// Runs fastfetch command, returning the piped stdout output.
+fn run_fastfetch_command_piped<S>(args: &[S]) -> Result<String>
+where
+    S: AsRef<OsStr> + fmt::Debug,
+{
+    let mut command = make_fastfetch_command(args)?;
+
+    let output = command
+        .output()
+        .context("failed to execute fastfetch as child process")?;
+    debug!(?output, "fastfetch output");
+    process_command_status(&output.status).context("fastfetch command exited with error")?;
+
+    let out = String::from_utf8(output.stdout)
+        .context("failed to process fastfetch output as it contains invalid UTF-8")?
+        .trim()
+        .to_owned();
+    Ok(out)
+}
+
+fn make_fastfetch_command<S>(args: &[S]) -> Result<Command>
+where
+    S: AsRef<OsStr>,
+{
+    // Find fastfetch executable
+    let fastfetch_path = fastfetch_path()
+        .context("failed to get fastfetch path")?
+        .context("fastfetch command not found")?;
+
+    debug!(?fastfetch_path, "fastfetch path");
+
+    let mut command = Command::new(fastfetch_path);
+    command.args(args);
+    Ok(command)
+}
+
+/// Runs macchina command, returning the piped stdout output.
+#[cfg(feature = "macchina")]
+fn run_macchina_command_piped<S>(args: &[S]) -> Result<String>
+where
+    S: AsRef<OsStr> + fmt::Debug,
+{
+    let mut command = make_macchina_command(args)?;
+
+    let output = command
+        .output()
+        .context("failed to execute macchina as child process")?;
+    debug!(?output, "macchina output");
+    process_command_status(&output.status).context("macchina command exited with error")?;
+
+    let out = String::from_utf8(output.stdout)
+        .context("failed to process macchina output as it contains invalid UTF-8")?
+        .trim()
+        .to_owned();
+    Ok(out)
+}
+
+#[cfg(feature = "macchina")]
+fn make_macchina_command<S>(args: &[S]) -> Result<Command>
+where
+    S: AsRef<OsStr>,
+{
+    // Find macchina executable
+    let macchina_path = macchina_path()
+        .context("failed to get macchina path")?
+        .context("macchina command not found")?;
+
+    debug!(?macchina_path, "macchina path");
+
+    let mut command = Command::new(macchina_path);
+    command.args(args);
+    Ok(command)
+}
+
+#[tracing::instrument(level = "debug")]
+fn get_distro_name(backend: Backend) -> Result<String> {
+    match backend {
+        Backend::Neofetch => run_neofetch_command_piped(&["ascii_distro_name"])
+            .context("failed to get distro name from neofetch"),
+        Backend::Fastfetch => run_fastfetch_command_piped(&[
+            "--logo",
+            "none",
+            "-s",
+            "OS",
+            "--disable-linewrap",
+            "--os-key",
+            " ",
+        ])
+        .context("failed to get distro name from fastfetch"),
+        #[cfg(feature = "macchina")]
+        Backend::Macchina => {
+            // Write ascii art to temp file
+            let asc_file_path = {
+                let mut temp_file = tempfile::Builder::new()
+                    .suffix("ascii.txt")
+                    .tempfile()
+                    .context("failed to create temp file for ascii art")?;
+                temp_file
+                    .write_all(b"\t\n\t\n")
+                    .context("failed to write ascii art to temp file")?;
+                temp_file.into_temp_path()
+            };
+
+            // Write macchina theme to temp file
+            let theme_file_path = {
+                let project_dirs = directories::ProjectDirs::from("", "", "macchina")
+                    .context("failed to get base dirs")?;
+                let themes_path = project_dirs.config_dir().join("themes");
+                fs::create_dir_all(&themes_path).with_context(|| {
+                    format!("failed to create macchina themes dir {themes_path:?}")
+                })?;
+                let mut temp_file = tempfile::Builder::new()
+                    .suffix("theme.toml")
+                    .tempfile_in(themes_path)
+                    .context("failed to create temp file for macchina theme")?;
+                let theme_doc = {
+                    let mut doc = DocumentMut::new();
+                    doc["spacing"] = value(0);
+                    doc["padding"] = value(0);
+                    // See https://github.com/Macchina-CLI/macchina/issues/319
+                    // doc["hide_ascii"] = value(true);
+                    doc["separator"] = value("");
+                    doc["custom_ascii"] = Item::Table(Table::from_iter([(
+                        "path",
+                        &*asc_file_path.to_string_lossy(),
+                    )]));
+                    doc["keys"] = Item::Table(Table::from_iter([("os", ""), ("distro", "")]));
+                    doc
+                };
+                debug!(%theme_doc, "macchina theme");
+                temp_file
+                    .write_all(theme_doc.to_string().as_bytes())
+                    .context("failed to write macchina theme to temp file")?;
+                temp_file.into_temp_path()
+            };
+
+            let args: [&OsStr; 4] = [
+                "--show".as_ref(),
+                if cfg!(target_os = "linux") {
+                    "distribution"
+                } else {
+                    "operating-system"
+                }
+                .as_ref(),
+                "--theme".as_ref(),
+                theme_file_path
+                    .file_stem()
+                    .expect("file name should not be `None`"),
+            ];
+            run_macchina_command_piped(&args[..])
+                .map(|s| {
+                    anstream::adapter::strip_str(&s)
+                        .to_string()
+                        .trim()
+                        .to_owned()
+                })
+                .context("failed to get distro name from macchina")
+        },
+    }
+}
+
+/// Runs neofetch with custom ascii art.
+#[tracing::instrument(level = "debug", skip(asc))]
+fn run_neofetch(asc: String, args: Option<&Vec<String>>) -> Result<()> {
+    // Escape backslashes here because backslashes are escaped in neofetch for
+    // printf
+    let asc = asc.replace('\\', r"\\");
+
+    // Write ascii art to temp file
+    let asc_file_path = {
+        let mut temp_file = tempfile::Builder::new()
+            .suffix("ascii.txt")
+            .tempfile()
+            .context("failed to create temp file for ascii art")?;
+        temp_file
+            .write_all(asc.as_bytes())
+            .context("failed to write ascii art to temp file")?;
+        temp_file.into_temp_path()
+    };
+
+    // Call neofetch
+    let args = {
+        let mut v: Vec<Cow<OsStr>> = vec![
+            OsStr::new("--ascii").into(),
+            OsStr::new("--source").into(),
+            OsStr::new(&asc_file_path).into(),
+            OsStr::new("--ascii_colors").into(),
+        ];
+        if let Some(args) = args {
+            v.extend(args.iter().map(|arg| OsStr::new(arg).into()));
+        }
+        v
+    };
+    let mut command = make_neofetch_command(&args[..])?;
+
+    debug!(?command, "neofetch command");
+
+    let status = command
+        .status()
+        .context("failed to execute neofetch command as child process")?;
+    process_command_status(&status).context("neofetch command exited with error")?;
+
+    Ok(())
+}
+
+/// Runs fastfetch with custom ascii art.
+#[tracing::instrument(level = "debug", skip(asc))]
+fn run_fastfetch(asc: String, args: Option<&Vec<String>>) -> Result<()> {
+    // Write ascii art to temp file
+    let asc_file_path = {
+        let mut temp_file = tempfile::Builder::new()
+            .suffix("ascii.txt")
+            .tempfile()
+            .context("failed to create temp file for ascii art")?;
+        temp_file
+            .write_all(asc.as_bytes())
+            .context("failed to write ascii art to temp file")?;
+        temp_file.into_temp_path()
+    };
+
+    // Call fastfetch
+    let args = {
+        let mut v: Vec<Cow<OsStr>> = vec![
+            OsStr::new("--file-raw").into(),
+            OsStr::new(&asc_file_path).into(),
+        ];
+        if let Some(args) = args {
+            v.extend(args.iter().map(|arg| OsStr::new(arg).into()));
+        }
+        v
+    };
+    let mut command = make_fastfetch_command(&args[..])?;
+
+    debug!(?command, "fastfetch command");
+
+    let status = command
+        .status()
+        .context("failed to execute fastfetch command as child process")?;
+    process_command_status(&status).context("fastfetch command exited with error")?;
+
+    Ok(())
+}
+
+/// Runs macchina with custom ascii art.
+#[cfg(feature = "macchina")]
+#[tracing::instrument(level = "debug", skip(asc))]
+fn run_macchina(asc: String, args: Option<&Vec<String>>) -> Result<()> {
+    // Write ascii art to temp file
+    let asc_file_path = {
+        let mut temp_file = tempfile::Builder::new()
+            .suffix("ascii.txt")
+            .tempfile()
+            .context("failed to create temp file for ascii art")?;
+        temp_file
+            .write_all(asc.as_bytes())
+            .context("failed to write ascii art to temp file")?;
+        temp_file.into_temp_path()
+    };
+
+    // Write macchina theme to temp file
+    let theme_file_path = {
+        let project_dirs = directories::ProjectDirs::from("", "", "macchina")
+            .context("failed to get base dirs")?;
+        let themes_path = project_dirs.config_dir().join("themes");
+        fs::create_dir_all(&themes_path)
+            .with_context(|| format!("failed to create macchina themes dir {themes_path:?}"))?;
+        let mut temp_file = tempfile::Builder::new()
+            .suffix("theme.toml")
+            .tempfile_in(themes_path)
+            .context("failed to create temp file for macchina theme")?;
+        let theme_doc = {
+            let mut doc = DocumentMut::new();
+            doc["custom_ascii"] = Item::Table(Table::from_iter([(
+                "path",
+                &*asc_file_path.to_string_lossy(),
+            )]));
+            doc
+        };
+        debug!(%theme_doc, "macchina theme");
+        temp_file
+            .write_all(theme_doc.to_string().as_bytes())
+            .context("failed to write macchina theme to temp file")?;
+        temp_file.into_temp_path()
+    };
+
+    let args = {
+        let mut v: Vec<Cow<OsStr>> = vec![
+            OsStr::new("--theme").into(),
+            theme_file_path
+                .file_stem()
+                .expect("file name should not be `None`")
+                .into(),
+        ];
+        if let Some(args) = args {
+            v.extend(args.iter().map(|arg| OsStr::new(arg).into()));
+        }
+        v
+    };
+    let mut command = make_macchina_command(&args[..])?;
+
+    debug!(?command, "macchina command");
+
+    let status = command
+        .status()
+        .context("failed to execute macchina command as child process")?;
+    process_command_status(&status).context("macchina command exited with error")?;
+
+    Ok(())
+}
+
+/// Gets the color indices that should be considered as foreground, for a
+/// particular distro's ascii art.
+fn ascii_foreground(distro: &Distro) -> Vec<NeofetchAsciiIndexedColor> {
+    let fg: Vec<u8> = match distro {
+        Distro::Anarchy => vec![2],
+        Distro::Android => vec![2],
+        Distro::Antergos => vec![1],
+        Distro::ArchStrike => vec![2],
+        Distro::Arkane => vec![1],
+        Distro::Asahi => vec![5],
+        Distro::Astra_Linux => vec![2],
+        Distro::BlackArch => vec![3],
+        Distro::CelOS => vec![3],
+        Distro::Chapeau => vec![2],
+        Distro::Chrom => vec![5],
+        Distro::Clear_Linux_OS => vec![2],
+        Distro::Container_Linux_by_CoreOS => vec![3],
+        Distro::CRUX => vec![3],
+        Distro::EuroLinux => vec![2],
+        Distro::eweOS => vec![3],
+        Distro::Fedora => vec![2],
+        Distro::Fedora_Sericea => vec![2],
+        Distro::Fedora_Silverblue => vec![2],
+        Distro::GalliumOS => vec![2],
+        Distro::Gentoo => vec![1],
+        Distro::HarDClanZ => vec![2],
+        Distro::Kibojoe => vec![3],
+        Distro::KrassOS => vec![2],
+        Distro::Kubuntu => vec![2],
+        Distro::Linux => vec![1],
+        Distro::LinuxFromScratch => vec![1, 3],
+        Distro::Lubuntu => vec![2],
+        Distro::openEuler => vec![2],
+        Distro::orchid => vec![1],
+        Distro::Panwah => vec![1],
+        Distro::Peppermint => vec![2],
+        Distro::PNM_Linux => vec![2],
+        Distro::Pop__OS => vec![2],
+        Distro::Reborn_OS => vec![1],
+        Distro::SalentOS => vec![4],
+        Distro::Septor => vec![2],
+        Distro::Ubuntu_Cinnamon => vec![2],
+        Distro::Ubuntu_Kylin => vec![2],
+        Distro::Ubuntu_MATE => vec![2],
+        Distro::Ubuntu_old => vec![2],
+        Distro::Ubuntu_Studio => vec![2],
+        Distro::Ubuntu_Sway => vec![2],
+        Distro::Ultramarine_Linux => vec![2],
+        Distro::Univention => vec![2],
+        Distro::uwuntu => vec![2],
+        Distro::Vanilla => vec![2],
+        Distro::VNux => vec![3, 5],
+        Distro::Void => vec![2],
+        Distro::Xray_OS => vec![2, 3],
+        Distro::Xubuntu => vec![2],
+        _ => Vec::new(),
+    };
+
+    fg.into_iter()
+        .map(|fore| {
+            fore.try_into()
+                .expect("`fore` should be a valid neofetch color index")
+        })
+        .collect()
+}
diff --git a/crates/hyfetch/src/presets.rs b/crates/hyfetch/src/presets.rs
new file mode 100644
index 00000000..8cf567f7
--- /dev/null
+++ b/crates/hyfetch/src/presets.rs
@@ -0,0 +1,785 @@
+use std::iter;
+use std::num::{NonZeroU8, NonZeroUsize};
+
+use anyhow::{anyhow, Context as _, Result};
+use indexmap::IndexSet;
+use palette::num::ClampAssign as _;
+use palette::{IntoColorMut as _, LinSrgb, Okhsl, Srgb};
+use serde::{Deserialize, Serialize};
+use strum::{AsRefStr, EnumCount, EnumString, VariantArray, VariantNames};
+use tracing::debug;
+use unicode_segmentation::UnicodeSegmentation as _;
+
+use crate::color_util::{ForegroundBackground, Lightness, ToAnsiString as _};
+use crate::types::{AnsiMode, TerminalTheme};
+
+#[derive(
+    Copy,
+    Clone,
+    Hash,
+    Debug,
+    AsRefStr,
+    Deserialize,
+    EnumCount,
+    EnumString,
+    Serialize,
+    VariantArray,
+    VariantNames,
+)]
+#[serde(rename_all = "kebab-case")]
+#[strum(serialize_all = "kebab-case")]
+pub enum Preset {
+    Rainbow,
+
+    Transgender,
+
+    Nonbinary,
+
+    Xenogender,
+
+    Agender,
+
+    Queer,
+
+    Genderfluid,
+
+    Bisexual,
+
+    Pansexual,
+
+    Polysexual,
+
+    Omnisexual,
+
+    Omniromantic,
+
+    GayMen,
+
+    Lesbian,
+
+    Abrosexual,
+
+    Asexual,
+
+    Aromantic,
+
+    Aroace1,
+
+    Aroace2,
+
+    Aroace3,
+
+    Greysexual,
+
+    Autosexual,
+
+    Intergender,
+
+    Greygender,
+
+    Akiosexual,
+
+    Bigender,
+
+    Demigender,
+
+    Demiboy,
+
+    Demigirl,
+
+    Transmasculine,
+
+    Transfeminine,
+
+    Genderfaun,
+
+    Demifaun,
+
+    Genderfae,
+
+    Demifae,
+
+    Neutrois,
+
+    Biromantic1,
+
+    Autoromantic,
+
+    Boyflux2,
+
+    Girlflux,
+
+    Genderflux,
+
+    Finsexual,
+
+    Unlabeled1,
+
+    Unlabeled2,
+
+    Pangender,
+
+    #[serde(rename = "gendernonconforming1")]
+    #[strum(serialize = "gendernonconforming1")]
+    GenderNonconforming1,
+
+    #[serde(rename = "gendernonconforming2")]
+    #[strum(serialize = "gendernonconforming2")]
+    GenderNonconforming2,
+
+    Femboy,
+
+    Tomboy,
+
+    Gynesexual,
+
+    Androsexual,
+
+    Gendervoid,
+
+    Voidgirl,
+
+    Voidboy,
+
+    NonhumanUnity,
+
+    Plural,
+
+    Fraysexual,
+
+    Kenochoric,
+
+    Veldian,
+
+    Solian,
+
+    Lunian,
+
+    Polyam,
+
+    Sapphic,
+
+    Androgyne,
+
+    Interprogress,
+
+    Progress,
+
+    Intersex,
+
+    OldPolyam,
+
+    EqualRights,
+
+    Drag,
+
+    Pronounfluid,
+
+    Pronounflux,
+
+    Exipronoun,
+
+    Neopronoun,
+
+    Neofluid,
+
+    Genderqueer,
+
+    /// Meme flag
+    Beiyang,
+
+    /// Meme flag
+    Burger,
+
+    /// Colors from Gilbert Baker's original 1978 flag design
+    Baker,
+}
+
+#[derive(Clone, Eq, PartialEq, Debug)]
+pub struct ColorProfile {
+    pub colors: Vec<Srgb<u8>>,
+}
+
+#[derive(Clone, PartialEq, Debug)]
+pub enum AssignLightness {
+    Replace(Lightness),
+    ClampMax(Lightness),
+    ClampMin(Lightness),
+}
+
+impl Preset {
+    pub fn color_profile(&self) -> ColorProfile {
+        (match self {
+            Self::Rainbow => ColorProfile::from_hex_colors(vec![
+                "#E50000", "#FF8D00", "#FFEE00", "#028121", "#004CFF", "#770088",
+            ]),
+
+            Self::Transgender => ColorProfile::from_hex_colors(vec![
+                "#55CDFD", "#F6AAB7", "#FFFFFF", "#F6AAB7", "#55CDFD",
+            ]),
+
+            Self::Nonbinary => {
+                ColorProfile::from_hex_colors(vec!["#FCF431", "#FCFCFC", "#9D59D2", "#282828"])
+            },
+
+            // sourced from https://commons.wikimedia.org/wiki/File:Xenogender_pride_flag.svg
+            Self::Xenogender => ColorProfile::from_hex_colors(vec![
+                "#FF6692", "#FF9A98", "#FFB883", "#FBFFA8", "#85BCFF", "#9D85FF", "#A510FF",
+            ]),
+
+            Self::Agender => ColorProfile::from_hex_colors(vec![
+                "#000000", "#BABABA", "#FFFFFF", "#BAF484", "#FFFFFF", "#BABABA", "#000000",
+            ]),
+
+            Self::Queer => ColorProfile::from_hex_colors(vec!["#B57FDD", "#FFFFFF", "#49821E"]),
+
+            Self::Genderfluid => ColorProfile::from_hex_colors(vec![
+                "#FE76A2", "#FFFFFF", "#BF12D7", "#000000", "#303CBE",
+            ]),
+
+            Self::Bisexual => ColorProfile::from_hex_colors(vec!["#D60270", "#9B4F96", "#0038A8"]),
+
+            Self::Pansexual => ColorProfile::from_hex_colors(vec!["#FF1C8D", "#FFD700", "#1AB3FF"]),
+
+            Self::Polysexual => {
+                ColorProfile::from_hex_colors(vec!["#F714BA", "#01D66A", "#1594F6"])
+            },
+
+            // sourced from https://www.flagcolorcodes.com/omnisexual
+            Self::Omnisexual => ColorProfile::from_hex_colors(vec![
+                "#FE9ACE", "#FF53BF", "#200044", "#6760FE", "#8EA6FF",
+            ]),
+
+            Self::Omniromantic => ColorProfile::from_hex_colors(vec![
+                "#FEC8E4", "#FDA1DB", "#89739A", "#ABA7FE", "#BFCEFF",
+            ]),
+
+            // sourced from https://www.flagcolorcodes.com/gay-men
+            Self::GayMen => ColorProfile::from_hex_colors(vec![
+                "#078D70", "#98E8C1", "#FFFFFF", "#7BADE2", "#3D1A78",
+            ]),
+
+            Self::Lesbian => ColorProfile::from_hex_colors(vec![
+                "#D62800", "#FF9B56", "#FFFFFF", "#D462A6", "#A40062",
+            ]),
+
+            // used colorpicker to source from https://fyeahaltpride.tumblr.com/post/151704251345/could-you-guys-possibly-make-an-abrosexual-pride
+            Self::Abrosexual => ColorProfile::from_hex_colors(vec![
+                "#46D294", "#A3E9CA", "#FFFFFF", "#F78BB3", "#EE1766",
+            ]),
+
+            Self::Asexual => {
+                ColorProfile::from_hex_colors(vec!["#000000", "#A4A4A4", "#FFFFFF", "#810081"])
+            },
+
+            Self::Aromantic => ColorProfile::from_hex_colors(vec![
+                "#3BA740", "#A8D47A", "#FFFFFF", "#ABABAB", "#000000",
+            ]),
+
+            // sourced from https://flag.library.lgbt/flags/aroace/
+            Self::Aroace1 => ColorProfile::from_hex_colors(vec![
+                "#E28C00", "#ECCD00", "#FFFFFF", "#62AEDC", "#203856",
+            ]),
+
+            // sourced from https://flag.library.lgbt/flags/aroace/
+            Self::Aroace2 => ColorProfile::from_hex_colors(vec![
+                "#000000", "#810081", "#A4A4A4", "#FFFFFF", "#A8D47A", "#3BA740",
+            ]),
+
+            // sourced from https://flag.library.lgbt/flags/aroace/
+            Self::Aroace3 => ColorProfile::from_hex_colors(vec![
+                "#3BA740", "#A8D47A", "#FFFFFF", "#ABABAB", "#000000", "#A4A4A4", "#FFFFFF",
+                "#810081",
+            ]),
+
+            // sourced from https://www.flagcolorcodes.com/greysexual
+            Self::Greysexual => ColorProfile::from_hex_colors(vec![
+                "#740194", "#AEB1AA", "#FFFFFF", "#AEB1AA", "#740194",
+            ]),
+
+            // sourced from https://www.flagcolorcodes.com/autosexual
+            Self::Autosexual => ColorProfile::from_hex_colors(vec!["#99D9EA", "#7F7F7F"]),
+
+            // sourced from https://www.flagcolorcodes.com/intergender
+            Self::Intergender => {
+                ColorProfile::from_hex_colors(vec!["#900DC2", "#FFE54F", "#900DC2"])
+                    .and_then(|c| c.with_weights(vec![2, 1, 2]))
+            },
+
+            // sourced from https://www.flagcolorcodes.com/greygender
+            Self::Greygender => ColorProfile::from_hex_colors(vec![
+                "#B3B3B3", "#FFFFFF", "#062383", "#FFFFFF", "#535353",
+            ])
+            .and_then(|c| c.with_weights(vec![2, 1, 2, 1, 2])),
+
+            // sourced from https://www.flagcolorcodes.com/akiosexual
+            Self::Akiosexual => ColorProfile::from_hex_colors(vec![
+                "#F9485E", "#FEA06A", "#FEF44C", "#FFFFFF", "#000000",
+            ]),
+
+            // sourced from https://www.flagcolorcodes.com/bigender
+            Self::Bigender => ColorProfile::from_hex_colors(vec![
+                "#C479A2", "#EDA5CD", "#D6C7E8", "#FFFFFF", "#D6C7E8", "#9AC7E8", "#6D82D1",
+            ]),
+
+            // yellow sourced from https://lgbtqia.fandom.com/f/p/4400000000000041031
+            // other colors sourced from demiboy and demigirl flags
+            Self::Demigender => ColorProfile::from_hex_colors(vec![
+                "#7F7F7F", "#C4C4C4", "#FBFF75", "#FFFFFF", "#FBFF75", "#C4C4C4", "#7F7F7F",
+            ]),
+
+            // sourced from https://www.flagcolorcodes.com/demiboy
+            Self::Demiboy => ColorProfile::from_hex_colors(vec![
+                "#7F7F7F", "#C4C4C4", "#9DD7EA", "#FFFFFF", "#9DD7EA", "#C4C4C4", "#7F7F7F",
+            ]),
+
+            // sourced from https://www.flagcolorcodes.com/demigirl
+            Self::Demigirl => ColorProfile::from_hex_colors(vec![
+                "#7F7F7F", "#C4C4C4", "#FDADC8", "#FFFFFF", "#FDADC8", "#C4C4C4", "#7F7F7F",
+            ]),
+
+            // sourced from https://www.flagcolorcodes.com/transmasculine
+            Self::Transmasculine => ColorProfile::from_hex_colors(vec![
+                "#FF8ABD", "#CDF5FE", "#9AEBFF", "#74DFFF", "#9AEBFF", "#CDF5FE", "#FF8ABD",
+            ]),
+
+            // used colorpicker to source from https://www.deviantart.com/pride-flags/art/Trans-Woman-Transfeminine-1-543925985
+            // linked from https://gender.fandom.com/wiki/Transfeminine
+            Self::Transfeminine => ColorProfile::from_hex_colors(vec![
+                "#73DEFF", "#FFE2EE", "#FFB5D6", "#FF8DC0", "#FFB5D6", "#FFE2EE", "#73DEFF",
+            ]),
+
+            // sourced from https://www.flagcolorcodes.com/genderfaun
+            Self::Genderfaun => ColorProfile::from_hex_colors(vec![
+                "#FCD689", "#FFF09B", "#FAF9CD", "#FFFFFF", "#8EDED9", "#8CACDE", "#9782EC",
+            ]),
+
+            // sourced from https://www.flagcolorcodes.com/demifaun
+            Self::Demifaun => ColorProfile::from_hex_colors(vec![
+                "#7F7F7F", "#C6C6C6", "#FCC688", "#FFF19C", "#FFFFFF", "#8DE0D5", "#9682EC",
+                "#C6C6C6", "#7F7F7F",
+            ])
+            .and_then(|c| c.with_weights(vec![2, 2, 1, 1, 1, 1, 1, 2, 2])),
+
+            // sourced from https://www.flagcolorcodes.com/genderfae
+            Self::Genderfae => ColorProfile::from_hex_colors(vec![
+                "#97C3A5", "#C3DEAE", "#F9FACD", "#FFFFFF", "#FCA2C4", "#DB8AE4", "#A97EDD",
+            ]),
+
+            // used colorpicker to source form https://www.deviantart.com/pride-flags/art/Demifae-870194777
+            Self::Demifae => ColorProfile::from_hex_colors(vec![
+                "#7F7F7F", "#C5C5C5", "#97C3A4", "#C4DEAE", "#FFFFFF", "#FCA2C5", "#AB7EDF",
+                "#C5C5C5", "#7F7F7F",
+            ])
+            .and_then(|c| c.with_weights(vec![2, 2, 1, 1, 1, 1, 1, 2, 2])),
+
+            // sourced from https://www.flagcolorcodes.com/neutrois
+            Self::Neutrois => ColorProfile::from_hex_colors(vec!["#FFFFFF", "#1F9F00", "#000000"]),
+
+            // sourced from https://www.flagcolorcodes.com/biromantic-alternate-2
+            Self::Biromantic1 => ColorProfile::from_hex_colors(vec![
+                "#8869A5", "#D8A7D8", "#FFFFFF", "#FDB18D", "#151638",
+            ]),
+
+            // sourced from https://www.flagcolorcodes.com/autoromantic
+            Self::Autoromantic => ColorProfile::from_hex_colors(
+                // symbol interpreted
+                vec!["#99D9EA", "#3DA542", "#7F7F7F"],
+            )
+            .and_then(|c| c.with_weights(vec![2, 1, 2])),
+
+            // sourced from https://www.flagcolorcodes.com/boyflux-alternate-2
+            Self::Boyflux2 => ColorProfile::from_hex_colors(vec![
+                "#E48AE4", "#9A81B4", "#55BFAB", "#FFFFFF", "#A8A8A8", "#81D5EF", "#69ABE5",
+                "#5276D4",
+            ])
+            .and_then(|c| c.with_weights(vec![1, 1, 1, 1, 1, 5, 5, 5])),
+
+            // sourced from https://commons.wikimedia.org/wiki/File:Girlflux_Pride_Flag.jpg
+            Self::Girlflux => ColorProfile::from_hex_colors(vec![
+                "f9e6d7", "f2526c", "bf0311", "e9c587", "bf0311", "f2526c", "f9e6d7",
+            ]),
+
+            // sourced from https://www.deviantart.com/pride-flags/art/Genderflux-1-543925589
+            Self::Genderflux => ColorProfile::from_hex_colors(vec![
+                "f47694", "f2a2b9", "cecece", "7ce0f7", "3ecdf9", "fff48d",
+            ]),
+
+            // sourced from https://lgbtqia.wiki/wiki/Finsexual
+            Self::Finsexual => ColorProfile::from_hex_colors(vec![
+                "#B18EDF", "#D7B1E2", "#F7CDE9", "#F39FCE", "#EA7BB3",
+            ]),
+
+            // sourced from https://web.archive.org/web/20221002181913/https://unlabeledinfo.carrd.co/#flags
+            Self::Unlabeled1 => {
+                ColorProfile::from_hex_colors(vec!["#EAF8E4", "#FDFDFB", "#E1EFF7", "#F4E2C4"])
+            },
+
+            // sourced from https://web.archive.org/web/20221002181913/https://unlabeledinfo.carrd.co/#flags
+            Self::Unlabeled2 => ColorProfile::from_hex_colors(vec![
+                "#250548", "#FFFFFF", "#F7DCDA", "#EC9BEE", "#9541FA", "#7D2557",
+            ]),
+
+            Self::Pangender => ColorProfile::from_hex_colors(vec![
+                "#FFF798", "#FEDDCD", "#FFEBFB", "#FFFFFF", "#FFEBFB", "#FEDDCD", "#FFF798",
+            ]),
+
+            Self::GenderNonconforming1 => ColorProfile::from_hex_colors(vec![
+                "#50284d", "#96467b", "#5c96f7", "#ffe6f7", "#5c96f7", "#96467b", "#50284d",
+            ])
+            .and_then(|c| c.with_weights(vec![4, 1, 1, 1, 1, 1, 4])),
+
+            Self::GenderNonconforming2 => ColorProfile::from_hex_colors(vec![
+                "#50284d", "#96467b", "#5c96f7", "#ffe6f7", "#5c96f7", "#96467b", "#50284d",
+            ]),
+
+            Self::Femboy => ColorProfile::from_hex_colors(vec![
+                "#d260a5", "#e4afcd", "#fefefe", "#57cef8", "#fefefe", "#e4afcd", "#d260a5",
+            ]),
+
+            Self::Tomboy => ColorProfile::from_hex_colors(vec![
+                "#2f3fb9", "#613a03", "#fefefe", "#f1a9b7", "#fefefe", "#613a03", "#2f3fb9",
+            ]),
+
+            // sourced from https://lgbtqia.fandom.com/wiki/Gynesexual
+            Self::Gynesexual => {
+                ColorProfile::from_hex_colors(vec!["#F4A9B7", "#903F2B", "#5B953B"])
+            },
+
+            // sourced from https://lgbtqia.fandom.com/wiki/Androsexual
+            Self::Androsexual => {
+                ColorProfile::from_hex_colors(vec!["#01CCFF", "#603524", "#B799DE"])
+            },
+
+            // sourced from: https://gender.fandom.com/wiki/Gendervoid
+            Self::Gendervoid => ColorProfile::from_hex_colors(vec![
+                "#081149", "#4B484B", "#000000", "#4B484B", "#081149",
+            ]),
+
+            // sourced from: https://gender.fandom.com/wiki/Gendervoid
+            Self::Voidgirl => ColorProfile::from_hex_colors(vec![
+                "#180827", "#7A5A8B", "#E09BED", "#7A5A8B", "#180827",
+            ]),
+
+            // sourced from: https://gender.fandom.com/wiki/Gendervoid
+            Self::Voidboy => ColorProfile::from_hex_colors(vec![
+                "#0B130C", "#547655", "#66B969", "#547655", "#0B130C",
+            ]),
+
+            // used https://twitter.com/foxbrained/status/1667621855518236674/photo/1 as source and colorpicked
+            Self::NonhumanUnity => {
+                ColorProfile::from_hex_colors(vec!["#177B49", "#FFFFFF", "#593C90"])
+            },
+
+            // used https://pluralpedia.org/w/Plurality#/media/File:Plural-Flag-1.jpg as source and colorpicked
+            Self::Plural => ColorProfile::from_hex_colors(vec![
+                "#2D0625", "#543475", "#7675C3", "#89C7B0", "#F3EDBD",
+            ]),
+
+            // sampled from https://es.m.wikipedia.org/wiki/Archivo:Fraysexual_flag.jpg
+            Self::Fraysexual => {
+                ColorProfile::from_hex_colors(vec!["#226CB5", "#94E7DD", "#FFFFFF", "#636363"])
+            },
+
+            Self::Kenochoric => {
+                ColorProfile::from_hex_colors(vec!["#000000", "#2E1569", "#824DB7", "#C7A1D6"])
+            },
+
+            Self::Veldian => ColorProfile::from_hex_colors(vec![
+                "#D182A8", "#FAF6E0", "#69ACBE", "#5D448F", "#3A113E",
+            ]),
+
+            Self::Solian => ColorProfile::from_hex_colors(vec![
+                "#FFF8ED", "#FFE7A8", "#F1B870", "#A56058", "#46281E",
+            ]),
+
+            Self::Lunian => ColorProfile::from_hex_colors(vec![
+                "#2F0E62", "#6F41B1", "#889FDF", "#7DDFD5", "#D2F2E2",
+            ]),
+
+            // pulled from https://polyamproud.com/flag
+            Self::Polyam => ColorProfile::from_hex_colors(vec![
+                "#FFFFFF", "#FCBF00", "#009FE3", "#E50051", "#340C46",
+            ]),
+
+            Self::Sapphic => ColorProfile::from_hex_colors(vec![
+                "#FD8BA8", "#FBF2FF", "#C76BC5", "#FDD768", "#C76BC5", "#FBF2FF", "#FD8BA8",
+            ]),
+
+            Self::Androgyne => ColorProfile::from_hex_colors(vec!["#FE007F", "#9832FF", "#00B8E7"]),
+
+            Self::Interprogress => ColorProfile::from_hex_colors(vec![
+                "#FFD800", "#7902AA", "#FFFFFF", "#FFAFC8", "#74D7EE", "#613915", "#000000",
+                "#E50000", "#FF8D00", "#FFEE00", "#028121", "#004CFF", "#770088",
+            ]),
+
+            Self::Progress => ColorProfile::from_hex_colors(vec![
+                "#FFFFFF", "#FFAFC8", "#74D7EE", "#613915", "#000000", "#E50000", "#FF8D00",
+                "#FFEE00", "#028121", "#004CFF", "#770088",
+            ]),
+
+            Self::Intersex => ColorProfile::from_hex_colors(vec!["#FFD800", "#7902AA", "#FFD800"])
+                .and_then(|c| c.with_weights(vec![2, 1, 2])),
+
+            Self::OldPolyam => ColorProfile::from_hex_colors(vec![
+                "#0000FF", "#FF0000", "#FFFF00", "#FF0000", "#000000",
+            ]),
+
+            Self::EqualRights => ColorProfile::from_hex_colors(vec![
+                "#0000FF", "#FFFF00", "#0000FF", "#FFFF00", "#0000FF",
+            ])
+            .and_then(|c| c.with_weights(vec![2, 1, 2, 1, 2])),
+
+            Self::Drag => ColorProfile::from_hex_colors(vec![
+                "#CC67FF", "#FFFFFF", "#FFA3E3", "#FFFFFF", "#3366FF",
+            ]),
+
+            Self::Pronounfluid => ColorProfile::from_hex_colors(vec![
+                "#FFB3F9", "#FFFFFF", "#D1FDCB", "#C7B0FF", "#000000", "#B8CCFF",
+            ]),
+
+            Self::Pronounflux => ColorProfile::from_hex_colors(vec![
+                "#FDB3F8", "#B6CCFA", "#18DDD3", "#64FF89", "#FF7690", "#FFFFFF",
+            ]),
+
+            Self::Exipronoun => {
+                ColorProfile::from_hex_colors(vec!["#1C3D34", "#FFFFFF", "#321848", "#000000"])
+            },
+
+            Self::Neopronoun => {
+                ColorProfile::from_hex_colors(vec!["#BCEC64", "#FFFFFF", "#38077A"])
+            },
+
+            Self::Neofluid => ColorProfile::from_hex_colors(vec![
+                "#FFECA0", "#FFFFFF", "#FFECA0", "#38087A", "#BCEC64",
+            ]),
+
+            Self::Genderqueer => {
+                ColorProfile::from_hex_colors(vec!["#B57EDC", "#FFFFFF", "#4A8123"])
+            },
+
+            Self::Beiyang => ColorProfile::from_hex_colors(vec![
+                "#DF1B12", "#FFC600", "#01639D", "#FFFFFF", "#000000",
+            ]),
+
+            Self::Burger => ColorProfile::from_hex_colors(vec![
+                "#F3A26A", "#498701", "#FD1C13", "#7D3829", "#F3A26A",
+            ]),
+
+            // used https://gilbertbaker.com/rainbow-flag-color-meanings/ as source and colorpicked
+            Self::Baker => ColorProfile::from_hex_colors(vec![
+                "#F23D9E", "#F80A24", "#F78022", "#F9E81F", "#1E972E", "#1B86BC", "#243897",
+                "#6F0A82",
+            ]),
+        })
+        .expect("preset color profiles should be valid")
+    }
+}
+
+impl ColorProfile {
+    pub fn new(colors: Vec<Srgb<u8>>) -> Self {
+        Self { colors }
+    }
+
+    pub fn from_hex_colors<S>(hex_colors: Vec<S>) -> Result<Self>
+    where
+        S: AsRef<str>,
+    {
+        let colors = hex_colors
+            .into_iter()
+            .map(|s| s.as_ref().parse())
+            .collect::<Result<_, _>>()
+            .context("failed to parse hex colors")?;
+        Ok(Self::new(colors))
+    }
+
+    /// Maps colors based on weights.
+    ///
+    /// # Arguments
+    ///
+    /// * `weights` - Weights of each color (`weights[i]` = how many times
+    ///   `colors[i]` appears)
+    pub fn with_weights(&self, weights: Vec<u8>) -> Result<Self> {
+        if weights.len() != self.colors.len() {
+            debug!(?weights, ?self.colors, "length mismatch between `weights` and `colors`");
+            return Err(anyhow!(
+                "`weights` should have the same number of elements as `colors`"
+            ));
+        }
+
+        let mut weighted_colors = Vec::new();
+
+        for (i, w) in weights.into_iter().enumerate() {
+            weighted_colors.extend(iter::repeat(self.colors[i]).take(usize::from(w)));
+        }
+
+        Ok(Self::new(weighted_colors))
+    }
+
+    /// Creates a new color profile, with the colors spread to the specified
+    /// length.
+    pub fn with_length(&self, length: NonZeroU8) -> Result<Self> {
+        let orig_len = self.colors.len();
+        let orig_len: NonZeroUsize = orig_len.try_into().expect("`colors` should not be empty");
+        let orig_len: NonZeroU8 = orig_len
+            .try_into()
+            .expect("`colors` should not have more than 255 elements");
+        // TODO: I believe weird things can happen because of this...
+        // if length < orig_len {
+        //     unimplemented!("compressing length of color profile not implemented");
+        // }
+        let center_i = usize::from(orig_len.get() / 2);
+
+        // How many copies of each color should be displayed at least?
+        let repeats = length.get().div_euclid(orig_len.get());
+        let mut weights = vec![repeats; NonZeroUsize::from(orig_len).get()];
+
+        // How many extra spaces left?
+        let mut extras = length.get().rem_euclid(orig_len.get());
+
+        // If there is an odd space left, extend the center by one space
+        if extras % 2 == 1 {
+            weights[center_i] = weights[center_i].checked_add(1).unwrap();
+            extras = extras.checked_sub(1).unwrap();
+        }
+
+        // Add weight to border until there's no space left (extras must be even at this
+        // point)
+        let weights_len = weights.len();
+        for border_i in 0..usize::from(extras / 2) {
+            weights[border_i] = weights[border_i].checked_add(1).unwrap();
+            let border_opp = weights_len
+                .checked_sub(border_i)
+                .unwrap()
+                .checked_sub(1)
+                .unwrap();
+            weights[border_opp] = weights[border_opp].checked_add(1).unwrap();
+        }
+
+        self.with_weights(weights)
+    }
+
+    /// Colors a text.
+    ///
+    /// # Arguments
+    ///
+    /// * `foreground_background` - Whether the color is shown on the foreground
+    ///   text or the background block
+    /// * `space_only` - Whether to only color spaces
+    pub fn color_text<S>(
+        &self,
+        txt: S,
+        color_mode: AnsiMode,
+        foreground_background: ForegroundBackground,
+        space_only: bool,
+    ) -> Result<String>
+    where
+        S: AsRef<str>,
+    {
+        let txt = txt.as_ref();
+
+        let txt: Vec<&str> = txt.graphemes(true).collect();
+
+        let ColorProfile { colors } = {
+            let length = txt.len();
+            let length: NonZeroUsize = length.try_into().context("`txt` should not be empty")?;
+            let length: NonZeroU8 = length.try_into().with_context(|| {
+                format!(
+                    "`txt` should not have more than {limit} characters",
+                    limit = u8::MAX
+                )
+            })?;
+            self.with_length(length)
+                .with_context(|| format!("failed to spread color profile to length {length}"))?
+        };
+
+        let mut buf = String::new();
+        for (i, &gr) in txt.iter().enumerate() {
+            if space_only && gr != " " {
+                if i > 0 && txt[i.checked_sub(1).unwrap()] == " " {
+                    buf.push_str("\x1b[39;49m");
+                }
+                buf.push_str(gr);
+            } else {
+                buf.push_str(&colors[i].to_ansi_string(color_mode, foreground_background));
+                buf.push_str(gr);
+            }
+        }
+
+        buf.push_str("\x1b[39;49m");
+        Ok(buf)
+    }
+
+    /// Creates a new color profile, with the colors lightened by a multiplier.
+    pub fn lighten(&self, multiplier: f32) -> Self {
+        let mut rgb_f32_colors: Vec<LinSrgb> =
+            self.colors.iter().map(|c| c.into_linear()).collect();
+
+        {
+            let okhsl_f32_colors: &mut [Okhsl] = &mut rgb_f32_colors.into_color_mut();
+
+            for okhsl_f32_color in okhsl_f32_colors {
+                okhsl_f32_color.lightness *= multiplier;
+            }
+        }
+
+        let rgb_u8_colors: Vec<_> = rgb_f32_colors
+            .into_iter()
+            .map(Srgb::<u8>::from_linear)
+            .collect();
+
+        Self {
+            colors: rgb_u8_colors,
+        }
+    }
+
+    /// Creates a new color profile, with the colors set to the specified
+    /// [`Okhsl`] lightness value.
+    pub fn with_lightness(&self, assign_lightness: AssignLightness) -> Self {
+        let mut rgb_f32_colors: Vec<LinSrgb> =
+            self.colors.iter().map(|c| c.into_linear()).collect();
+
+        {
+            let okhsl_f32_colors: &mut [Okhsl] = &mut rgb_f32_colors.into_color_mut();
+
+            for okhsl_f32_color in okhsl_f32_colors {
+                match assign_lightness {
+                    AssignLightness::Replace(lightness) => {
+                        okhsl_f32_color.lightness = lightness.into();
+                    },
+                    AssignLightness::ClampMax(lightness) => {
+                        okhsl_f32_color.lightness.clamp_max_assign(lightness.into());
+                    },
+                    AssignLightness::ClampMin(lightness) => {
+                        okhsl_f32_color.lightness.clamp_min_assign(lightness.into());
+                    },
+                }
+            }
+        }
+
+        let rgb_u8_colors: Vec<Srgb<u8>> = rgb_f32_colors
+            .into_iter()
+            .map(Srgb::<u8>::from_linear)
+            .collect();
+
+        Self {
+            colors: rgb_u8_colors,
+        }
+    }
+
+    /// Creates a new color profile, with the colors set to the specified
+    /// [`Okhsl`] lightness value, adapted to the terminal theme.
+    pub fn with_lightness_adaptive(&self, lightness: Lightness, theme: TerminalTheme) -> Self {
+        match theme {
+            TerminalTheme::Dark => self.with_lightness(AssignLightness::ClampMin(lightness)),
+            TerminalTheme::Light => self.with_lightness(AssignLightness::ClampMax(lightness)),
+        }
+    }
+
+    /// Creates another color profile with only the unique colors.
+    pub fn unique_colors(&self) -> Self {
+        let unique_colors: IndexSet<[u8; 3]> = self.colors.iter().map(|&c| c.into()).collect();
+        let unique_colors: Vec<Srgb<u8>> = unique_colors.into_iter().map(|c| c.into()).collect();
+        Self::new(unique_colors)
+    }
+}
diff --git a/crates/hyfetch/src/pride_month.rs b/crates/hyfetch/src/pride_month.rs
new file mode 100644
index 00000000..20766f1f
--- /dev/null
+++ b/crates/hyfetch/src/pride_month.rs
@@ -0,0 +1,327 @@
+use std::fmt::Write as _;
+use std::io::{self, Write as _};
+use std::num::{NonZeroU16, NonZeroUsize, Wrapping};
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+use std::time::Duration;
+use std::{cmp, thread};
+
+use anyhow::{anyhow, Context as _, Result};
+use crossterm::execute;
+use crossterm::terminal::{
+    BeginSynchronizedUpdate, EndSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen,
+};
+use palette::blend::Blend as _;
+use palette::{LinSrgba, Srgb, WithAlpha as _};
+use strum::VariantArray as _;
+use terminal_size::{terminal_size, Height, Width};
+
+use crate::color_util::{color, ForegroundBackground, ToAnsiString as _};
+use crate::neofetch_util::ascii_size;
+use crate::presets::Preset;
+use crate::types::AnsiMode;
+
+const TEXT_ASCII: &str = r"
+.======================================================.
+| .  .              .__       .     .  .       , .   | |
+| |__| _.._ ._   .  [__)._.* _| _   |\/| _ ._ -+-|_  | |
+| |  |(_][_)[_)\_|  |   [  |(_](/,  |  |(_)[ ) | [ ) * |
+|        |  |  ._|                                     |
+'======================================================'
+";
+
+const TEXT_ASCII_SMALL: &str = r"
+.====================.
+| Happy Pride Month! |
+'===================='
+";
+
+const NOTICE: &str = "Press enter to continue";
+
+pub fn start_animation(color_mode: AnsiMode) -> Result<()> {
+    let (w, h) = {
+        let (Width(w), Height(h)) = terminal_size().context("failed to get terminal size")?;
+        let w: NonZeroU16 = w.try_into().context("terminal width should not be 0")?;
+        let h: NonZeroU16 = h.try_into().context("terminal height should not be 0")?;
+        (w, h)
+    };
+
+    let text = &TEXT_ASCII[1..TEXT_ASCII.len().checked_sub(1).unwrap()];
+    let (text_width, text_height) =
+        ascii_size(text).expect("text ascii should have valid width and height");
+    let (text, text_width, text_height) = {
+        const TEXT_BORDER_WIDTH: u16 = 2;
+        const NOTICE_BORDER_WIDTH: u16 = 1;
+        const VERTICAL_MARGIN: u16 = 1;
+        let notice_w = NOTICE.len();
+        let notice_w: u8 = notice_w
+            .try_into()
+            .expect("`NOTICE` width should fit in `u8`");
+        let notice_h = NOTICE.lines().count();
+        let notice_h: u8 = notice_h
+            .try_into()
+            .expect("`NOTICE` height should fit in `u8`");
+        let term_w_min = cmp::max(
+            u16::from(text_width)
+                .checked_add(TEXT_BORDER_WIDTH.checked_mul(2).unwrap())
+                .unwrap(),
+            u16::from(notice_w)
+                .checked_add(NOTICE_BORDER_WIDTH.checked_mul(2).unwrap())
+                .unwrap(),
+        );
+        let term_h_min = u16::from(text_height)
+            .checked_add(notice_h.into())
+            .unwrap()
+            .checked_add(VERTICAL_MARGIN.checked_mul(2).unwrap())
+            .unwrap();
+        if w.get() >= term_w_min && h.get() >= term_h_min {
+            (text, text_width, text_height)
+        } else {
+            let text = &TEXT_ASCII_SMALL[1..TEXT_ASCII_SMALL.len().checked_sub(1).unwrap()];
+            let (text_width, text_height) =
+                ascii_size(text).expect("text ascii should have valid width and height");
+            let term_w_min = cmp::max(
+                u16::from(text_width)
+                    .checked_add(TEXT_BORDER_WIDTH.checked_mul(2).unwrap())
+                    .unwrap(),
+                u16::from(notice_w)
+                    .checked_add(NOTICE_BORDER_WIDTH.checked_mul(2).unwrap())
+                    .unwrap(),
+            );
+            let term_h_min = u16::from(text_height)
+                .checked_add(notice_h.into())
+                .unwrap()
+                .checked_add(VERTICAL_MARGIN.checked_mul(2).unwrap())
+                .unwrap();
+            if w.get() < term_w_min || h.get() < term_h_min {
+                return Err(anyhow!(
+                    "terminal size should be at least ({term_w_min} * {term_h_min})"
+                ));
+            }
+            (text, text_width, text_height)
+        }
+    };
+    let text_lines: Vec<&str> = text.lines().collect();
+
+    const BLOCKS: u8 = 9;
+    let block_width: NonZeroU16 = w
+        .get()
+        .div_euclid(u16::from(BLOCKS))
+        .try_into()
+        .with_context(|| format!("terminal width should be at least {BLOCKS}"))?;
+
+    let text_start_y = h
+        .get()
+        .div_euclid(2)
+        .checked_sub((text_height / 2).into())
+        .unwrap();
+    let text_end_y = text_start_y.checked_add(text_height.into()).unwrap();
+    let text_start_x = w
+        .get()
+        .div_euclid(2)
+        .checked_sub((text_width / 2).into())
+        .unwrap();
+    let text_end_x = text_start_x.checked_add(text_width.into()).unwrap();
+
+    let notice_start_x = w
+        .get()
+        .checked_sub(
+            u8::try_from(NOTICE.len())
+                .expect("`NOTICE` length should fit in `u8`")
+                .into(),
+        )
+        .unwrap()
+        .checked_sub(1)
+        .unwrap();
+    let notice_end_x = w.get().checked_sub(1).unwrap();
+    let notice_y = h.get().checked_sub(1).unwrap();
+
+    // Add every preset to colors
+    let colors: Vec<Srgb<u8>> = Preset::VARIANTS
+        .iter()
+        .flat_map(|p| p.color_profile().colors)
+        .collect();
+
+    let fg: Srgb<u8> = "#FFE09B"
+        .parse()
+        .expect("foreground color hex should be valid");
+    let black = LinSrgba::new(0.0, 0.0, 0.0, 0.5);
+
+    let draw_frame = |frame: usize| -> Result<()> {
+        execute!(io::stdout(), BeginSynchronizedUpdate)
+            .context("failed to begin synchronized update")?;
+
+        let mut buf = String::new();
+
+        // Loop over the height
+        for y in 0..h.get() {
+            // Print the starting color
+            write!(
+                buf,
+                "{bg}{fg}",
+                bg = colors[frame
+                    .wrapping_add(y.into())
+                    .div_euclid(block_width.get().into())
+                    .rem_euclid(colors.len())]
+                .to_ansi_string(color_mode, ForegroundBackground::Background),
+                fg = fg.to_ansi_string(color_mode, ForegroundBackground::Foreground)
+            )
+            .unwrap();
+
+            // Loop over the width
+            for x in 0..w.get() {
+                let idx = frame
+                    .wrapping_add(x.into())
+                    .wrapping_add(y.into())
+                    .wrapping_add_signed((2.0 * (y as f64 + 0.5 * frame as f64).sin()) as isize);
+                let y_text = text_start_y <= y && y < text_end_y;
+
+                let border = 1u16
+                    .checked_add(
+                        if y == text_start_y || y == text_end_y.checked_sub(1).unwrap() {
+                            0
+                        } else {
+                            1
+                        },
+                    )
+                    .unwrap();
+                let text_bounds_x1 = text_start_x
+                    .checked_sub(border)
+                    .expect("`text_start_x - border` should not underflow `u16`");
+                let text_bounds_x2 = text_end_x
+                    .checked_add(border)
+                    .expect("`text_end_x + border` should not overflow `u16`");
+                let notice_bounds_x1 = notice_start_x
+                    .checked_sub(1)
+                    .expect("`notice_start_x - 1` should not underflow `u16`");
+                let notice_bounds_x2 = notice_end_x
+                    .checked_add(1)
+                    .expect("`notice_end_x + 1` should not overflow `u16`");
+
+                // If it's a switching point
+                if idx.rem_euclid(NonZeroUsize::from(block_width).get()) == 0
+                    || x == text_bounds_x1
+                    || x == text_bounds_x2
+                    || x == notice_bounds_x1
+                    || x == notice_bounds_x2
+                {
+                    // Print the color at the current frame
+                    let ci = idx
+                        .div_euclid(NonZeroUsize::from(block_width).get())
+                        .rem_euclid(colors.len());
+                    let c = colors[ci];
+                    if (y_text && (text_bounds_x1 <= x) && (x < text_bounds_x2))
+                        || (y == notice_y && notice_bounds_x1 <= x && x < notice_bounds_x2)
+                    {
+                        let c: LinSrgba = c.with_alpha(1.0).into_linear();
+                        let c = Srgb::<u8>::from_linear(c.overlay(black).without_alpha());
+                        write!(
+                            buf,
+                            "{bg}",
+                            bg = c.to_ansi_string(color_mode, ForegroundBackground::Background),
+                        )
+                        .unwrap();
+                    } else {
+                        write!(
+                            buf,
+                            "{bg}",
+                            bg = c.to_ansi_string(color_mode, ForegroundBackground::Background),
+                        )
+                        .unwrap();
+                    }
+                }
+
+                // If text should be printed, print text
+                if y_text && text_start_x <= x && x < text_end_x {
+                    write!(
+                        buf,
+                        "{text_char}",
+                        text_char = text_lines[usize::from(y.checked_sub(text_start_y).unwrap())]
+                            .chars()
+                            .nth(usize::from(x.checked_sub(text_start_x).unwrap()))
+                            .unwrap(),
+                    )
+                    .unwrap();
+                } else if y == notice_y && notice_start_x <= x && x < notice_end_x {
+                    write!(
+                        buf,
+                        "{notice_char}",
+                        notice_char = NOTICE
+                            .chars()
+                            .nth(usize::from(x.checked_sub(notice_start_x).unwrap()))
+                            .unwrap(),
+                    )
+                    .unwrap();
+                } else {
+                    write!(buf, " ").unwrap();
+                }
+            }
+
+            // New line if it isn't the last line
+            if y != h.get().checked_sub(1).unwrap() {
+                writeln!(
+                    buf,
+                    "{reset}",
+                    reset = color("&r", color_mode).expect("reset should be valid"),
+                )
+                .unwrap();
+            }
+        }
+
+        {
+            let mut stdout = io::stdout().lock();
+            write!(stdout, "{buf}")
+                .and_then(|_| stdout.flush())
+                .context("failed to write to stdout")?;
+        }
+
+        execute!(io::stdout(), EndSynchronizedUpdate)
+            .context("failed to end synchronized update")?;
+
+        Ok(())
+    };
+
+    let key_pressed = Arc::new(AtomicBool::new(false));
+
+    // TODO: use non-blocking I/O; no need for another thread
+    let _handle = thread::spawn({
+        let key_pressed = Arc::clone(&key_pressed);
+        move || {
+            loop {
+                match io::stdin().lines().next() {
+                    Some(Ok(_)) => {
+                        key_pressed.store(true, Ordering::Release);
+                        break;
+                    },
+                    Some(Err(err)) => {
+                        eprintln!("failed to read line from stdin: {err}");
+                    },
+                    None => {
+                        // EOF
+                    },
+                }
+            }
+        }
+    });
+
+    let mut frame: Wrapping<usize> = Wrapping(0);
+
+    const SPEED: u8 = 2;
+    let frame_delay = Duration::from_secs_f32(1.0 / 25.0);
+
+    execute!(io::stdout(), EnterAlternateScreen).context("failed to enter alternate screen")?;
+
+    loop {
+        draw_frame(frame.0)?;
+        frame += usize::from(SPEED);
+        thread::sleep(frame_delay);
+
+        if key_pressed.load(Ordering::Acquire) {
+            break;
+        }
+    }
+
+    execute!(io::stdout(), LeaveAlternateScreen).context("failed to leave alternate screen")?;
+
+    Ok(())
+}
diff --git a/crates/hyfetch/src/types.rs b/crates/hyfetch/src/types.rs
new file mode 100644
index 00000000..5094eaef
--- /dev/null
+++ b/crates/hyfetch/src/types.rs
@@ -0,0 +1,64 @@
+use serde::{Deserialize, Serialize};
+use strum::{AsRefStr, EnumString, VariantNames};
+
+#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, AsRefStr, Deserialize, EnumString, Serialize)]
+#[serde(rename_all = "lowercase")]
+#[strum(serialize_all = "lowercase")]
+pub enum AnsiMode {
+    #[serde(rename = "ansi")]
+    #[serde(skip)]
+    #[strum(serialize = "ansi")]
+    #[strum(disabled)]
+    Ansi16,
+    #[serde(rename = "8bit")]
+    #[strum(serialize = "8bit")]
+    Ansi256,
+    Rgb,
+}
+
+#[derive(
+    Copy,
+    Clone,
+    Eq,
+    PartialEq,
+    Hash,
+    Debug,
+    AsRefStr,
+    Deserialize,
+    EnumString,
+    Serialize,
+    VariantNames,
+)]
+#[serde(rename_all = "lowercase")]
+#[strum(serialize_all = "lowercase")]
+pub enum TerminalTheme {
+    Light,
+    Dark,
+}
+
+#[derive(
+    Copy,
+    Clone,
+    Eq,
+    PartialEq,
+    Hash,
+    Debug,
+    AsRefStr,
+    Deserialize,
+    EnumString,
+    Serialize,
+    VariantNames,
+)]
+#[serde(rename_all = "kebab-case")]
+#[strum(serialize_all = "kebab-case")]
+pub enum Backend {
+    Neofetch,
+    Fastfetch,
+    #[cfg(feature = "macchina")]
+    Macchina,
+}
+
+// See https://github.com/Peternator7/strum/issues/244
+impl VariantNames for AnsiMode {
+    const VARIANTS: &'static [&'static str] = &["8bit", "rgb"];
+}
diff --git a/crates/hyfetch/src/utils.rs b/crates/hyfetch/src/utils.rs
new file mode 100644
index 00000000..d7c90bff
--- /dev/null
+++ b/crates/hyfetch/src/utils.rs
@@ -0,0 +1,225 @@
+use std::io::Write as _;
+#[cfg(unix)]
+use std::os::unix::process::ExitStatusExt as _;
+use std::path::{Path, PathBuf};
+use std::process::ExitStatus;
+use std::{env, fs, io};
+
+use anyhow::{anyhow, Context as _, Result};
+use directories::ProjectDirs;
+#[cfg(windows)]
+use normpath::PathExt as _;
+use tracing::debug;
+
+pub fn get_cache_path() -> Result<PathBuf> {
+    let path = ProjectDirs::from("", "", "hyfetch")
+        .context("failed to get base dirs")?
+        .cache_dir()
+        .to_owned();
+    Ok(path)
+}
+
+/// Reads a string from standard input. The trailing newline is stripped.
+///
+/// The prompt string, if given, is printed to standard output without a
+/// trailing newline before reading input.
+pub fn input<S>(prompt: Option<S>) -> Result<String>
+where
+    S: AsRef<str>,
+{
+    if let Some(prompt) = prompt {
+        write!(io::stdout(), "{prompt}", prompt = prompt.as_ref())
+            .and_then(|_| io::stdout().flush())
+            .context("failed to write prompt to stdout")?;
+    }
+
+    io::stdin()
+        .lines()
+        .next()
+        .unwrap_or_else(|| Ok(String::new()))
+        .context("failed to read line from stdin")
+}
+
+/// Finds a command in `PATH`.
+///
+/// Returns the canonicalized / normalized absolute path of the command.
+pub fn find_in_path<P>(program: P) -> Result<Option<PathBuf>>
+where
+    P: AsRef<Path>,
+{
+    let program = program.as_ref();
+
+    // Only accept program name, i.e. a relative path with one component
+    if program.parent() != Some(Path::new("")) {
+        return Err(anyhow!("invalid command name {program:?}"));
+    };
+
+    let path_env = env::var_os("PATH").context("`PATH` env var is not set or invalid")?;
+
+    for search_path in env::split_paths(&path_env) {
+        let path = search_path.join(program);
+        let path = find_file(&path)
+            .with_context(|| format!("failed to check existence of file {path:?}"))?;
+        if path.is_some() {
+            return Ok(path);
+        }
+    }
+
+    Ok(None)
+}
+
+/// Finds a file.
+///
+/// Returns the canonicalized / normalized absolute path of the file.
+pub fn find_file<P>(path: P) -> Result<Option<PathBuf>>
+where
+    P: AsRef<Path>,
+{
+    let path = path.as_ref();
+
+    let metadata = match fs::metadata(path) {
+        Ok(metadata) => metadata,
+        Err(err) if err.kind() == io::ErrorKind::NotFound => {
+            return Ok(None);
+        },
+        Err(err) => {
+            return Err(err).with_context(|| format!("failed to get metadata for {path:?}"));
+        },
+    };
+
+    if !metadata.is_file() {
+        debug!(?path, "path exists but is not a file");
+        return Ok(None);
+    }
+
+    #[cfg(not(windows))]
+    {
+        path.canonicalize()
+            .with_context(|| format!("failed to canonicalize path {path:?}"))
+            .map(Some)
+    }
+    #[cfg(windows)]
+    {
+        path.normalize()
+            .with_context(|| format!("failed to normalize path {path:?}"))
+            .map(|p| Some(p.into()))
+    }
+}
+
+pub fn process_command_status(status: &ExitStatus) -> Result<()> {
+    if status.success() {
+        return Ok(());
+    }
+
+    let err = if let Some(code) = status.code() {
+        anyhow!("child process exited with status code: {code}")
+    } else {
+        #[cfg(unix)]
+        {
+            anyhow!(
+                "child process terminated by signal: {signal}",
+                signal = status
+                    .signal()
+                    .expect("either one of status code or signal should be set")
+            )
+        }
+        #[cfg(not(unix))]
+        {
+            unimplemented!("status code not expected to be `None` on non-Unix platforms")
+        }
+    };
+    Err(err)
+}
+
+pub(crate) mod index_map_serde {
+    use std::fmt;
+    use std::hash::Hash;
+    use std::marker::PhantomData;
+    use std::str::FromStr;
+
+    use indexmap::IndexMap;
+    use serde::de::{self, DeserializeSeed, MapAccess, Visitor};
+    use serde::{Deserialize, Deserializer};
+
+    pub(crate) fn deserialize<'de, D, K, V>(deserializer: D) -> Result<IndexMap<K, V>, D::Error>
+    where
+        D: Deserializer<'de>,
+        K: Eq + Hash + FromStr,
+        K::Err: fmt::Display,
+        V: Deserialize<'de>,
+    {
+        struct KeySeed<K> {
+            k: PhantomData<K>,
+        }
+
+        impl<'de, K> DeserializeSeed<'de> for KeySeed<K>
+        where
+            K: FromStr,
+            K::Err: fmt::Display,
+        {
+            type Value = K;
+
+            fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
+            where
+                D: Deserializer<'de>,
+            {
+                deserializer.deserialize_str(self)
+            }
+        }
+
+        impl<'de, K> Visitor<'de> for KeySeed<K>
+        where
+            K: FromStr,
+            K::Err: fmt::Display,
+        {
+            type Value = K;
+
+            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+                formatter.write_str("a string")
+            }
+
+            fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
+            where
+                E: de::Error,
+            {
+                K::from_str(s).map_err(de::Error::custom)
+            }
+        }
+
+        struct MapVisitor<K, V> {
+            k: PhantomData<K>,
+            v: PhantomData<V>,
+        }
+
+        impl<'de, K, V> Visitor<'de> for MapVisitor<K, V>
+        where
+            K: Eq + Hash + FromStr,
+            K::Err: fmt::Display,
+            V: Deserialize<'de>,
+        {
+            type Value = IndexMap<K, V>;
+
+            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+                formatter.write_str("a map")
+            }
+
+            fn visit_map<A>(self, mut input: A) -> Result<Self::Value, A::Error>
+            where
+                A: MapAccess<'de>,
+            {
+                let mut map = IndexMap::new();
+                while let Some((k, v)) =
+                    input.next_entry_seed(KeySeed { k: PhantomData }, PhantomData)?
+                {
+                    map.insert(k, v);
+                }
+                Ok(map)
+            }
+        }
+
+        deserializer.deserialize_map(MapVisitor {
+            k: PhantomData,
+            v: PhantomData,
+        })
+    }
+}
diff --git a/rustfmt.toml b/rustfmt.toml
new file mode 100644
index 00000000..abb3c84b
--- /dev/null
+++ b/rustfmt.toml
@@ -0,0 +1,18 @@
+edition = "2021"
+
+# empty_item_single_line = true
+# error_on_line_overflow = true
+# format_code_in_doc_comments = true
+# format_strings = true
+# group_imports = "StdExternalCrate"
+# imports_granularity = "Module"
+# imports_layout = "Mixed"
+match_block_trailing_comma = true
+newline_style = "Unix"
+# normalize_comments = true
+# normalize_doc_attributes = true
+# overflow_delimited_expr = true
+# reorder_impl_items = true
+use_field_init_shorthand = true
+use_try_shorthand = true
+# wrap_comments = true