use std::{
env,
path::{Path, PathBuf},
};
use garde::Validate;
use rustc_hash::FxHashMap;
use serde::Deserialize;
use smart_default::SmartDefault;
use umbra::optional;
use crate::{
color::{ColorTheme, OptionalColorTheme},
keybind::KeyBind,
CommitOrderType, GraphStyle, GraphWidthType, ImageProtocolType, InitialSelection, Result,
};
const XDG_CONFIG_HOME_ENV_NAME: &str = "XDG_CONFIG_HOME";
const DEFAULT_CONFIG_DIR: &str = ".config";
const APP_DIR_NAME: &str = "serie";
const CONFIG_FILE_NAME: &str = "config.toml";
const CONFIG_FILE_ENV_NAME: &str = "SERIE_CONFIG_FILE";
pub fn load() -> Result<(
CoreConfig,
UiConfig,
GraphConfig,
ColorTheme,
Option<KeyBind>,
)> {
let config = match config_file_path_from_env() {
Some(user_path) => {
if !user_path.exists() {
let msg = format!(
"Config file specified by ${CONFIG_FILE_ENV_NAME} environment variable not found: {}",
user_path.display()
);
return Err(msg.into());
}
read_config_from_path(&user_path)
}
None => {
if let Some(default_path) = config_file_path() {
if default_path.exists() {
read_config_from_path(&default_path)
} else {
Ok(Config::default())
}
} else {
Ok(Config::default())
}
}
}?;
config.validate()?;
Ok((
config.core,
config.ui,
config.graph,
config.color,
config.keybind,
))
}
fn config_file_path_from_env() -> Option<PathBuf> {
env::var(CONFIG_FILE_ENV_NAME).ok().map(PathBuf::from)
}
fn config_file_path() -> Option<PathBuf> {
env::var(XDG_CONFIG_HOME_ENV_NAME)
.ok()
.map(PathBuf::from)
.or_else(|| env::home_dir().map(|home| home.join(DEFAULT_CONFIG_DIR)))
.map(|config_dir| config_dir.join(APP_DIR_NAME).join(CONFIG_FILE_NAME))
}
fn read_config_from_path(path: &Path) -> Result<Config> {
let content = std::fs::read_to_string(path)?;
let config: OptionalConfig = toml::from_str(&content)?;
Ok(config.into())
}
#[optional(derives = [Deserialize])]
#[derive(Debug, Default, Clone, PartialEq, Eq, Validate)]
struct Config {
#[garde(dive)]
#[nested]
core: CoreConfig,
#[garde(dive)]
#[nested]
ui: UiConfig,
#[garde(dive)]
#[nested]
graph: GraphConfig,
#[garde(skip)]
#[nested]
color: ColorTheme,
#[garde(skip)]
keybind: Option<KeyBind>,
}
#[optional(derives = [Deserialize])]
#[derive(Debug, Default, Clone, PartialEq, Eq, Validate)]
pub struct CoreConfig {
#[garde(skip)]
#[nested]
pub option: CoreOptionConfig,
#[garde(skip)]
#[nested]
pub search: CoreSearchConfig,
#[garde(dive)]
#[nested]
pub user_command: CoreUserCommandConfig,
#[garde(dive)]
#[nested]
pub external: CoreExternalConfig,
}
#[optional(derives = [Deserialize])]
#[derive(Debug, Clone, PartialEq, Eq, SmartDefault)]
pub struct CoreOptionConfig {
pub protocol: Option<ImageProtocolType>,
pub order: Option<CommitOrderType>,
pub graph_width: Option<GraphWidthType>,
pub graph_style: Option<GraphStyle>,
pub initial_selection: Option<InitialSelection>,
}
#[optional(derives = [Deserialize])]
#[derive(Debug, Clone, PartialEq, Eq, SmartDefault)]
pub struct CoreSearchConfig {
#[default = false]
pub ignore_case: bool,
#[default = false]
pub fuzzy: bool,
}
#[optional]
#[derive(Debug, Clone, PartialEq, Eq, SmartDefault, Validate)]
pub struct CoreUserCommandConfig {
#[garde(dive)]
#[default(FxHashMap::from_iter([("1".into(), UserCommand {
name: "git diff".into(),
r#type: UserCommandType::Inline,
commands: vec![
"git".into(),
"--no-pager".into(),
"diff".into(),
"--color=always".into(),
"{{first_parent_hash}}".into(),
"{{target_hash}}".into(),
],
refresh: false,
})]))]
pub commands: FxHashMap<String, UserCommand>,
#[garde(range(min = 0))]
#[default = 4]
pub tab_width: u16,
}
impl<'de> Deserialize<'de> for OptionalCoreUserCommandConfig {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::{Error, MapAccess, Visitor};
use std::fmt;
struct OptionalCoreUserCommandConfigVisitor;
impl<'de> Visitor<'de> for OptionalCoreUserCommandConfigVisitor {
type Value = OptionalCoreUserCommandConfig;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a user command configuration")
}
fn visit_map<V>(self, mut map: V) -> std::result::Result<Self::Value, V::Error>
where
V: MapAccess<'de>,
{
let mut commands = FxHashMap::default();
let mut tab_width = None;
while let Some(key) = map.next_key::<String>()? {
if let Some(suffix) = key.strip_prefix("commands_") {
let command_key = suffix.to_string();
if command_key.is_empty() {
return Err(V::Error::custom(
"command key cannot be empty, like `commands_`",
));
}
let command_value: UserCommand = map.next_value()?;
commands.insert(command_key, command_value);
} else if key == "tab_width" {
tab_width = Some(map.next_value()?);
} else if key == "commands" {
return Err(V::Error::custom(
"invalid key `commands`, use `commands_n` format instead",
));
} else {
let _: serde::de::IgnoredAny = map.next_value()?;
}
}
let commands = if commands.is_empty() {
None
} else {
Some(commands)
};
Ok(OptionalCoreUserCommandConfig {
commands,
tab_width,
})
}
}
deserializer.deserialize_map(OptionalCoreUserCommandConfigVisitor)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Validate)]
pub struct UserCommand {
#[garde(length(min = 1))]
pub name: String,
#[serde(default)]
#[garde(skip)]
pub r#type: UserCommandType,
#[garde(length(min = 1), inner(length(min = 1)))]
pub commands: Vec<String>,
#[serde(default)]
#[garde(custom(validate_user_command_refresh(&self.r#type)))]
pub refresh: bool,
}
fn validate_user_command_refresh(
command_type: &UserCommandType,
) -> impl FnOnce(&bool, &()) -> garde::Result + '_ {
move |refresh, _| {
if matches!(command_type, UserCommandType::Inline) && *refresh {
return Err(garde::Error::new(
"refresh cannot be true for inline command",
));
}
Ok(())
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum UserCommandType {
#[default]
Inline,
Silent,
Suspend,
}
#[optional(derives = [Deserialize])]
#[derive(Debug, Default, Clone, PartialEq, Eq, Validate)]
pub struct UiConfig {
#[garde(skip)]
#[nested]
pub common: UiCommonConfig,
#[garde(dive)]
#[nested]
pub list: UiListConfig,
#[garde(dive)]
#[nested]
pub detail: UiDetailConfig,
#[garde(dive)]
#[nested]
pub user_command: UiUserCommandConfig,
#[garde(dive)]
#[nested]
pub refs: UiRefsConfig,
}
#[optional(derives = [Deserialize])]
#[derive(Debug, Clone, PartialEq, Eq, SmartDefault)]
pub struct UiCommonConfig {
#[default(CursorType::Native)]
pub cursor_type: CursorType,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub enum CursorType {
Native,
Virtual(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default, Validate)]
pub enum ClipboardConfig {
#[default]
Auto,
Custom {
#[garde(length(min = 1), inner(length(min = 1)))]
commands: Vec<String>,
},
}
#[optional(derives = [Deserialize])]
#[derive(Debug, Clone, PartialEq, Eq, SmartDefault, Validate)]
pub struct CoreExternalConfig {
#[garde(dive)]
#[default(ClipboardConfig::Auto)]
pub clipboard: ClipboardConfig,
}
#[optional(derives = [Deserialize])]
#[derive(Debug, Clone, PartialEq, Eq, SmartDefault, Validate)]
pub struct UiListConfig {
#[garde(length(min = 1))]
#[default(vec![
UserListColumnType::Graph,
UserListColumnType::Marker,
UserListColumnType::Subject,
UserListColumnType::Name,
UserListColumnType::Hash,
UserListColumnType::Date,
])]
pub columns: Vec<UserListColumnType>,
#[garde(range(min = 1))]
#[default = 20]
pub subject_min_width: u16,
#[garde(length(min = 1))]
#[default = "%Y-%m-%d"]
pub date_format: String,
#[garde(range(min = 0))]
#[default = 10]
pub date_width: u16,
#[garde(skip)]
#[default = true]
pub date_local: bool,
#[garde(range(min = 0))]
#[default = 20]
pub name_width: u16,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum UserListColumnType {
Graph,
Marker,
Subject,
Name,
Hash,
Date,
}
#[optional(derives = [Deserialize])]
#[derive(Debug, Clone, PartialEq, Eq, SmartDefault, Validate)]
pub struct UiDetailConfig {
#[garde(range(min = 1))]
#[default = 20]
pub height: u16,
#[garde(length(min = 1))]
#[default = "%Y-%m-%d %H:%M:%S %z"]
pub date_format: String,
#[garde(skip)]
#[default = true]
pub date_local: bool,
}
#[optional(derives = [Deserialize])]
#[derive(Debug, Clone, PartialEq, Eq, SmartDefault, Validate)]
pub struct UiUserCommandConfig {
#[garde(range(min = 1))]
#[default = 20]
pub height: u16,
}
#[optional(derives = [Deserialize])]
#[derive(Debug, Clone, PartialEq, Eq, SmartDefault, Validate)]
pub struct UiRefsConfig {
#[garde(range(min = 1))]
#[default = 26]
pub width: u16,
}
#[optional(derives = [Deserialize])]
#[derive(Debug, Default, Clone, PartialEq, Eq, Validate)]
pub struct GraphConfig {
#[garde(dive)]
#[nested]
pub color: GraphColorConfig,
}
#[optional(derives = [Deserialize])]
#[derive(Debug, Clone, PartialEq, Eq, SmartDefault, Validate)]
pub struct GraphColorConfig {
#[garde(length(min = 1), inner(pattern(r"^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$")))]
#[default(vec![
"#E06C76".into(),
"#98C379".into(),
"#E5C07B".into(),
"#61AFEF".into(),
"#C678DD".into(),
"#56B6C2".into(),
])]
pub branches: Vec<String>,
#[garde(pattern(r"^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$"))]
#[default = "#00000000"]
pub edge: String,
#[garde(pattern(r"^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$"))]
#[default = "#00000000"]
pub background: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_default() {
let actual = Config::default();
let expected = Config {
core: CoreConfig {
option: CoreOptionConfig {
protocol: None,
order: None,
graph_width: None,
graph_style: None,
initial_selection: None,
},
search: CoreSearchConfig {
ignore_case: false,
fuzzy: false,
},
user_command: CoreUserCommandConfig {
commands: FxHashMap::from_iter([(
"1".into(),
UserCommand {
name: "git diff".into(),
r#type: UserCommandType::Inline,
commands: vec![
"git".into(),
"--no-pager".into(),
"diff".into(),
"--color=always".into(),
"{{first_parent_hash}}".into(),
"{{target_hash}}".into(),
],
refresh: false,
},
)]),
tab_width: 4,
},
external: CoreExternalConfig {
clipboard: ClipboardConfig::Auto,
},
},
ui: UiConfig {
common: UiCommonConfig {
cursor_type: CursorType::Native,
},
list: UiListConfig {
columns: vec![
UserListColumnType::Graph,
UserListColumnType::Marker,
UserListColumnType::Subject,
UserListColumnType::Name,
UserListColumnType::Hash,
UserListColumnType::Date,
],
subject_min_width: 20,
date_format: "%Y-%m-%d".into(),
date_width: 10,
date_local: true,
name_width: 20,
},
detail: UiDetailConfig {
height: 20,
date_format: "%Y-%m-%d %H:%M:%S %z".into(),
date_local: true,
},
user_command: UiUserCommandConfig { height: 20 },
refs: UiRefsConfig { width: 26 },
},
graph: GraphConfig {
color: GraphColorConfig {
branches: vec![
"#E06C76".into(),
"#98C379".into(),
"#E5C07B".into(),
"#61AFEF".into(),
"#C678DD".into(),
"#56B6C2".into(),
],
edge: "#00000000".into(),
background: "#00000000".into(),
},
},
color: ColorTheme::default(),
keybind: None,
};
assert_eq!(actual, expected);
}
#[test]
fn test_config_complete_toml() {
let toml = r##"
[core.option]
protocol = "kitty"
order = "topo"
graph_width = "single"
graph_style = "angular"
initial_selection = "head"
[core.search]
ignore_case = true
fuzzy = true
[core.user_command]
commands_1 = { name = "git diff no color", commands = ["git", "diff", "{{first_parent_hash}}", "{{target_hash}}"] }
commands_2 = { name = "echo hello", type = "silent", commands = ["echo", "hello"], refresh = true }
commands_10 = { name = "echo world", type = "inline", commands = ["echo", "world"], refresh = false }
commands_3 = { name = "open vim", type = "suspend", commands = ["vim"] }
tab_width = 2
[ui.common]
cursor_type = { Virtual = "|" }
[ui.list]
columns = ["date", "subject", "hash", "graph"]
subject_min_width = 40
date_format = "%Y/%m/%d"
date_width = 20
date_local = false
name_width = 30
[ui.detail]
height = 30
date_format = "%Y/%m/%d %H:%M:%S"
date_local = false
[ui.user_command]
height = 30
[ui.refs]
width = 40
[graph.color]
branches = ["#ff0000", "#00ff00", "#0000ff"]
edge = "#000000"
background = "#ffffff"
"##;
let actual: Config = toml::from_str::<OptionalConfig>(toml).unwrap().into();
let expected = Config {
core: CoreConfig {
option: CoreOptionConfig {
protocol: Some(ImageProtocolType::Kitty),
order: Some(CommitOrderType::Topo),
graph_width: Some(GraphWidthType::Single),
graph_style: Some(GraphStyle::Angular),
initial_selection: Some(InitialSelection::Head),
},
search: CoreSearchConfig {
ignore_case: true,
fuzzy: true,
},
user_command: CoreUserCommandConfig {
commands: FxHashMap::from_iter([
(
"1".into(),
UserCommand {
name: "git diff no color".into(),
r#type: UserCommandType::Inline,
commands: vec![
"git".into(),
"diff".into(),
"{{first_parent_hash}}".into(),
"{{target_hash}}".into(),
],
refresh: false,
},
),
(
"2".into(),
UserCommand {
name: "echo hello".into(),
r#type: UserCommandType::Silent,
commands: vec!["echo".into(), "hello".into()],
refresh: true,
},
),
(
"3".into(),
UserCommand {
name: "open vim".into(),
r#type: UserCommandType::Suspend,
commands: vec!["vim".into()],
refresh: false,
},
),
(
"10".into(),
UserCommand {
name: "echo world".into(),
r#type: UserCommandType::Inline,
commands: vec!["echo".into(), "world".into()],
refresh: false,
},
),
]),
tab_width: 2,
},
external: CoreExternalConfig {
clipboard: ClipboardConfig::Auto,
},
},
ui: UiConfig {
common: UiCommonConfig {
cursor_type: CursorType::Virtual("|".into()),
},
list: UiListConfig {
columns: vec![
UserListColumnType::Date,
UserListColumnType::Subject,
UserListColumnType::Hash,
UserListColumnType::Graph,
],
subject_min_width: 40,
date_format: "%Y/%m/%d".into(),
date_width: 20,
date_local: false,
name_width: 30,
},
detail: UiDetailConfig {
height: 30,
date_format: "%Y/%m/%d %H:%M:%S".into(),
date_local: false,
},
user_command: UiUserCommandConfig { height: 30 },
refs: UiRefsConfig { width: 40 },
},
graph: GraphConfig {
color: GraphColorConfig {
branches: vec!["#ff0000".into(), "#00ff00".into(), "#0000ff".into()],
edge: "#000000".into(),
background: "#ffffff".into(),
},
},
color: ColorTheme::default(),
keybind: None,
};
assert_eq!(actual, expected);
}
#[test]
fn test_config_partial_toml() {
let toml = r#"
[ui.list]
date_format = "%Y/%m/%d"
"#;
let actual: Config = toml::from_str::<OptionalConfig>(toml).unwrap().into();
let expected = Config {
core: CoreConfig {
option: CoreOptionConfig {
protocol: None,
order: None,
graph_width: None,
graph_style: None,
initial_selection: None,
},
search: CoreSearchConfig {
ignore_case: false,
fuzzy: false,
},
user_command: CoreUserCommandConfig {
commands: FxHashMap::from_iter([(
"1".into(),
UserCommand {
name: "git diff".into(),
r#type: UserCommandType::Inline,
commands: vec![
"git".into(),
"--no-pager".into(),
"diff".into(),
"--color=always".into(),
"{{first_parent_hash}}".into(),
"{{target_hash}}".into(),
],
refresh: false,
},
)]),
tab_width: 4,
},
external: CoreExternalConfig {
clipboard: ClipboardConfig::Auto,
},
},
ui: UiConfig {
common: UiCommonConfig {
cursor_type: CursorType::Native,
},
list: UiListConfig {
columns: vec![
UserListColumnType::Graph,
UserListColumnType::Marker,
UserListColumnType::Subject,
UserListColumnType::Name,
UserListColumnType::Hash,
UserListColumnType::Date,
],
subject_min_width: 20,
date_format: "%Y/%m/%d".into(),
date_width: 10,
date_local: true,
name_width: 20,
},
detail: UiDetailConfig {
height: 20,
date_format: "%Y-%m-%d %H:%M:%S %z".into(),
date_local: true,
},
user_command: UiUserCommandConfig { height: 20 },
refs: UiRefsConfig { width: 26 },
},
graph: GraphConfig {
color: GraphColorConfig {
branches: vec![
"#E06C76".into(),
"#98C379".into(),
"#E5C07B".into(),
"#61AFEF".into(),
"#C678DD".into(),
"#56B6C2".into(),
],
edge: "#00000000".into(),
background: "#00000000".into(),
},
},
color: ColorTheme::default(),
keybind: None,
};
assert_eq!(actual, expected);
}
#[test]
fn test_config_clipboard_auto() {
let toml = r#"
[core.external]
clipboard = "Auto"
"#;
let config: Config = toml::from_str::<OptionalConfig>(toml).unwrap().into();
assert_eq!(config.core.external.clipboard, ClipboardConfig::Auto);
}
#[test]
fn test_config_clipboard_custom_single_command() {
let toml = r#"
[core.external]
clipboard = { Custom = { commands = ["wl-copy"] } }
"#;
let config: Config = toml::from_str::<OptionalConfig>(toml).unwrap().into();
assert_eq!(
config.core.external.clipboard,
ClipboardConfig::Custom {
commands: vec!["wl-copy".into()]
}
);
}
#[test]
fn test_config_clipboard_custom_command_with_args() {
let toml = r#"
[core.external]
clipboard = { Custom = { commands = ["xclip", "-selection", "clipboard"] } }
"#;
let config: Config = toml::from_str::<OptionalConfig>(toml).unwrap().into();
assert_eq!(
config.core.external.clipboard,
ClipboardConfig::Custom {
commands: vec!["xclip".into(), "-selection".into(), "clipboard".into()]
}
);
}
}