use std::collections::{BTreeMap, HashMap};
use std::path::Path;
use std::{env, fs};
use serde::Deserialize;
use crate::config::{Appearance, ColumnWidth, ColumnWidths, Config};
use crate::domain::error::{Error, Result};
use crate::theme::{GlyphVariant, Palette, ThemeColors, parse_color};
const GIT_PROGRAM_ENV: &str = "HOP_GIT_PROGRAM";
const EDITOR_ENV: &str = "HOP_EDITOR";
const THEME_ENV: &str = "HOP_THEME";
const GLYPHS_ENV: &str = "HOP_GLYPHS";
const CONFIRM_QUIT_ENV: &str = "HOP_CONFIRM_QUIT";
#[derive(Debug, Default, Deserialize)]
struct RawConfig {
git_program: Option<String>,
github_username: Option<String>,
example_mode: Option<bool>,
fetch_on_start: Option<bool>,
confirm_quit: Option<bool>,
editor: Option<String>,
editor_extensions: Option<Vec<String>>,
appearance: Option<RawAppearance>,
icons: Option<RawIcons>,
themes: Option<BTreeMap<String, HashMap<String, String>>>,
keys: Option<BTreeMap<String, KeyBinding>>,
column_widths: Option<HashMap<String, RawColumnWidth>>,
zip_backup_folder: Option<String>,
zip_exclude_dirs: Option<Vec<String>>,
}
#[derive(Debug, Default, Deserialize)]
struct RawAppearance {
theme: Option<String>,
colors: Option<BTreeMap<String, String>>,
glyphs: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct RawIcons {
variant: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum KeyBinding {
One(String),
Many(Vec<String>),
}
impl KeyBinding {
fn into_keys(self) -> Vec<String> {
match self {
KeyBinding::One(key) => vec![key],
KeyBinding::Many(keys) => keys,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum RawColumnWidth {
Fixed(usize),
Range {
min: Option<usize>,
max: Option<usize>,
},
}
impl RawColumnWidth {
fn resolve(&self, default: ColumnWidth) -> ColumnWidth {
match self {
RawColumnWidth::Fixed(min) => ColumnWidth {
min: *min,
max: None,
},
RawColumnWidth::Range { min, max } => ColumnWidth {
min: min.unwrap_or(default.min),
max: max.or(default.max),
},
}
}
}
pub fn load_config(path: &Path) -> Result<Config> {
let raw = read_raw(path)?;
let mut config = build(raw);
apply_env(&mut config);
Ok(config)
}
fn read_raw(path: &Path) -> Result<RawConfig> {
if !path.exists() {
return Ok(RawConfig::default());
}
let text = fs::read_to_string(path).map_err(|e| {
Error::config(path.display().to_string(), e.to_string())
})?;
toml::from_str(&text)
.map_err(|e| Error::config(path.display().to_string(), e.to_string()))
}
fn build(raw: RawConfig) -> Config {
let defaults = Config::default();
report_unknown(&unknown_color_names(&raw));
Config {
git_program: raw.git_program.or(defaults.git_program),
github_username: raw.github_username.or(defaults.github_username),
example_mode: raw.example_mode.unwrap_or(defaults.example_mode),
fetch_on_start: raw.fetch_on_start.unwrap_or(defaults.fetch_on_start),
confirm_quit: raw.confirm_quit.unwrap_or(defaults.confirm_quit),
editor: raw.editor.or(defaults.editor),
editor_extensions: raw
.editor_extensions
.filter(|exts| !exts.is_empty())
.unwrap_or(defaults.editor_extensions),
appearance: resolve_appearance(
raw.appearance,
raw.icons,
defaults.appearance,
),
themes: resolve_themes(raw.themes),
keys: raw
.keys
.map(|keys| {
keys.into_iter()
.map(|(action, binding)| (action, binding.into_keys()))
.collect()
})
.unwrap_or(defaults.keys),
column_widths: resolve_column_widths(raw.column_widths.as_ref()),
zip_backup_folder: raw.zip_backup_folder.or(defaults.zip_backup_folder),
zip_exclude_dirs: raw
.zip_exclude_dirs
.filter(|dirs| !dirs.is_empty())
.unwrap_or(defaults.zip_exclude_dirs),
}
}
fn resolve_appearance(
raw: Option<RawAppearance>,
legacy_icons: Option<RawIcons>,
defaults: Appearance,
) -> Appearance {
let raw = raw.unwrap_or_default();
let legacy_glyph = legacy_icons.and_then(|icons| icons.variant);
let glyphs = raw
.glyphs
.or(legacy_glyph)
.map_or(defaults.glyphs, |value| parse_glyph_variant(&value));
Appearance {
theme: raw.theme.unwrap_or(defaults.theme),
colors: raw.colors.unwrap_or(defaults.colors),
glyphs,
}
}
fn parse_glyph_variant(value: &str) -> GlyphVariant {
match value.trim().to_lowercase().as_str() {
"ascii" => GlyphVariant::Ascii,
_ => GlyphVariant::Unicode,
}
}
fn resolve_themes(
raw: Option<BTreeMap<String, HashMap<String, String>>>,
) -> Vec<(String, ThemeColors)> {
let Some(raw) = raw else {
return Vec::new();
};
raw.into_iter()
.map(|(name, colors)| {
let theme = ThemeColors::from_lookup(|key| {
colors.get(key).and_then(|value| parse_color(value))
});
(name, theme)
})
.collect()
}
fn unknown_color_names(raw: &RawConfig) -> Vec<(String, String)> {
let mut unknown = Vec::new();
if let Some(colors) =
raw.appearance.as_ref().and_then(|it| it.colors.as_ref())
{
for name in unknown_colors(colors.keys(), Palette::KEYS) {
unknown.push(("appearance.colors".to_string(), name));
}
}
for (theme, colors) in raw.themes.iter().flatten() {
for name in unknown_colors(colors.keys(), ThemeColors::KEYS) {
unknown.push((format!("themes.{theme}"), name));
}
}
unknown
}
fn unknown_colors<'a>(
names: impl Iterator<Item = &'a String>,
known: &[&str],
) -> Vec<String> {
names
.filter(|name| !known.contains(&name.as_str()))
.cloned()
.collect()
}
fn report_unknown(unknown: &[(String, String)]) {
for (section, name) in unknown {
log::warn!("unknown color '{name}' in [{section}], ignoring");
}
}
fn resolve_column_widths(
raw: Option<&HashMap<String, RawColumnWidth>>,
) -> ColumnWidths {
let defaults = ColumnWidths::default();
let Some(raw) = raw else {
return defaults;
};
let resolve = |key: &str, default: ColumnWidth| {
raw.get(key).map_or(default, |value| value.resolve(default))
};
ColumnWidths {
name: resolve("name", defaults.name),
current_branch_name: resolve(
"current_branch_name",
defaults.current_branch_name,
),
status: resolve("status", defaults.status),
github_repo_name: resolve(
"github_repo_name",
defaults.github_repo_name,
),
zip_backup: resolve("zip_backup", defaults.zip_backup),
}
}
fn apply_env(config: &mut Config) {
if let Ok(value) = env::var(GIT_PROGRAM_ENV)
&& !value.trim().is_empty()
{
config.git_program = Some(value);
}
if let Ok(value) = env::var(EDITOR_ENV)
&& !value.trim().is_empty()
{
config.editor = Some(value);
}
if let Ok(value) = env::var(THEME_ENV)
&& !value.trim().is_empty()
{
config.appearance.theme = value;
}
if let Ok(value) = env::var(GLYPHS_ENV)
&& !value.trim().is_empty()
{
config.appearance.glyphs = parse_glyph_variant(&value);
}
if let Ok(value) = env::var(CONFIRM_QUIT_ENV)
&& let Some(flag) = parse_bool(&value)
{
config.confirm_quit = flag;
}
}
fn parse_bool(value: &str) -> Option<bool> {
match value.trim().to_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn unknown(content: &str) -> Vec<(String, String)> {
let raw: RawConfig = toml::from_str(content).expect("valid toml");
unknown_color_names(&raw)
}
#[test]
fn a_theme_table_rejects_the_palettes_derived_colors() {
let mut reported = unknown(
"[themes.mine]\n\
accent = \"#010203\"\n\
border = \"#010203\"\n\
cursor = \"#010203\"\n\
selection = \"#010203\"\n",
);
reported.sort();
assert_eq!(
reported,
vec![
("themes.mine".to_string(), "cursor".to_string()),
("themes.mine".to_string(), "selection".to_string()),
],
);
}
#[test]
fn appearance_colors_may_name_any_palette_color() {
let reported = unknown(
"[appearance]\n\
colors = { cursor = \"#010203\", selection = \"#010203\" }\n",
);
assert!(reported.is_empty(), "{reported:?}");
}
#[test]
fn a_theme_may_name_every_theme_color() {
let table =
ThemeColors::KEYS
.iter()
.fold(String::new(), |mut table, name| {
table.push_str(name);
table.push_str(" = \"#010203\"\n");
table
});
assert!(unknown(&format!("[themes.mine]\n{table}")).is_empty());
}
#[test]
fn both_sections_report_a_typo() {
assert_eq!(
unknown("[appearance]\ncolors = { bordr = \"#010203\" }\n"),
vec![("appearance.colors".to_string(), "bordr".to_string())],
);
assert_eq!(
unknown("[themes.mine]\nbordr = \"#010203\"\n"),
vec![("themes.mine".to_string(), "bordr".to_string())],
);
}
#[test]
fn a_clean_file_reports_nothing() {
assert!(unknown("[appearance]\ntheme = \"default\"\n").is_empty());
}
#[test]
fn every_theme_color_name_is_also_a_palette_color_name() {
for name in ThemeColors::KEYS {
assert!(Palette::KEYS.contains(name), "{name} is unreachable");
}
}
#[test]
fn defaults_when_empty() {
let config = build(RawConfig::default());
assert_eq!(config.git_program.as_deref(), Some("lazygit"));
assert!(!config.example_mode);
assert!(!config.fetch_on_start);
assert_eq!(config.appearance.glyphs, GlyphVariant::Unicode);
assert_eq!(config.column_widths, ColumnWidths::default());
assert!(config.editor_extensions.iter().any(|e| e == "rs"));
}
#[test]
fn editor_extensions_override_replaces_default() {
let text = "editor_extensions = [\"md\", \"txt\"]\n";
let raw: RawConfig = toml::from_str(text).unwrap();
let config = build(raw);
assert_eq!(config.editor_extensions, ["md", "txt"]);
}
#[test]
fn parses_settings_and_ignores_repos() {
let text = r#"
git_program = "gitui"
github_username = "cgroening"
example_mode = true
fetch_on_start = true
[icons]
variant = "ascii"
[column_widths]
name = 40
[column_widths.current_branch_name]
min = 8
max = 20
[[repos]]
name = "ignored here"
path = "/tmp/x"
"#;
let raw: RawConfig = toml::from_str(text).unwrap();
let config = build(raw);
assert_eq!(config.git_program.as_deref(), Some("gitui"));
assert_eq!(config.github_username.as_deref(), Some("cgroening"));
assert!(config.example_mode);
assert!(config.fetch_on_start);
assert_eq!(config.appearance.glyphs, GlyphVariant::Ascii);
assert_eq!(config.column_widths.name, ColumnWidth::min(40));
assert_eq!(
config.column_widths.current_branch_name,
ColumnWidth::range(8, 20)
);
assert_eq!(config.column_widths.status, ColumnWidth::min(6));
}
#[test]
fn parses_appearance_themes_and_keys() {
let text = r##"
[appearance]
theme = "midnight"
glyphs = "ascii"
colors = { accent = "#ff0000" }
[themes.midnight]
accent = "#8899ff"
background = "#000010"
[keys]
add = "N"
delete = ["d", "backspace"]
"##;
let raw: RawConfig = toml::from_str(text).unwrap();
let config = build(raw);
assert_eq!(config.appearance.theme, "midnight");
assert_eq!(config.appearance.glyphs, GlyphVariant::Ascii);
assert_eq!(
config.appearance.colors.get("accent").map(String::as_str),
Some("#ff0000"),
);
assert!(config.themes.iter().any(|(name, _)| name == "midnight"));
assert_eq!(config.keys.get("add"), Some(&vec!["N".to_string()]));
assert_eq!(
config.keys.get("delete"),
Some(&vec!["d".to_string(), "backspace".to_string()]),
);
assert!(config.theme_registry().contains("midnight"));
}
#[test]
fn new_appearance_glyphs_wins_over_legacy_icons() {
let both = "[appearance]\nglyphs = \"unicode\"\n\n[icons]\nvariant = \"ascii\"\n";
let config = build(toml::from_str(both).unwrap());
assert_eq!(config.appearance.glyphs, GlyphVariant::Unicode);
let legacy = "[icons]\nvariant = \"ascii\"\n";
let config = build(toml::from_str(legacy).unwrap());
assert_eq!(config.appearance.glyphs, GlyphVariant::Ascii);
}
#[test]
fn a_bool_env_value_reads_the_usual_spellings() {
for on in ["1", "true", "TRUE", " yes ", "on"] {
assert_eq!(parse_bool(on), Some(true), "{on:?}");
}
for off in ["0", "false", "no", "OFF"] {
assert_eq!(parse_bool(off), Some(false), "{off:?}");
}
}
#[test]
fn a_misspelt_bool_env_value_leaves_the_setting_alone() {
assert_eq!(parse_bool("ja"), None);
assert_eq!(parse_bool(""), None);
}
}