1use crate::app::command::Command;
4use crate::app::style::Style;
5use crate::args::Args;
6use crate::gpg::key::KeyDetail;
7use crate::widget::style::Color;
8use anyhow::Result;
9use clap::ValueEnum;
10use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
11use serde::{de, Deserialize, Deserializer, Serialize};
12use std::fs;
13use std::str::FromStr;
14use toml::value::Value;
15
16const DEFAULT_COLOR: &str = "gray";
18const DEFAULT_STYLE: &str = "plain";
19const DEFAULT_FILE_EXPLORER: &str = "xplr";
20const DEFAULT_TICK_RATE: u64 = 250_u64;
21const DEFAULT_SPLASH: bool = false;
22const DEFAULT_ARMOR: bool = false;
23const DEFAULT_DETAIL_LEVEL: &str = "minimum";
24const DEFAULT_HOMEDIR: &str = "~/.gnupg";
25const DEFAULT_OUTDIR: &str = "~/.gnupg";
26
27#[derive(Debug, Default, Serialize, Deserialize)]
29pub struct Config {
30 pub general: Option<GeneralConfig>,
32 pub gpg: Option<GpgConfig>,
34}
35
36#[derive(Debug, Default, Serialize, Deserialize)]
38pub struct GeneralConfig {
39 pub splash: Option<bool>,
41 pub tick_rate: Option<u64>,
43 pub color: Option<String>,
45 pub style: Option<String>,
47 pub file_explorer: Option<String>,
49 pub detail_level: Option<KeyDetail>,
51 #[serde(skip_serializing)]
53 pub key_bindings: Option<Vec<CustomKeyBinding>>,
54 pub log_file: Option<String>,
56}
57
58#[derive(Debug, Clone, PartialEq, Deserialize)]
60pub struct CustomKeyBinding {
61 #[serde(deserialize_with = "deserialize_keys")]
63 pub keys: Vec<KeyEvent>,
64 #[serde(deserialize_with = "deserialize_command")]
66 pub command: Command,
67}
68
69fn deserialize_keys<'de, D>(deserializer: D) -> Result<Vec<KeyEvent>, D::Error>
71where
72 D: Deserializer<'de>,
73{
74 let mut key_bindings = Vec::new();
75 let keys: Vec<Value> = Deserialize::deserialize(deserializer)?;
76 for key in keys {
77 if let Some(key_str) = key.as_str() {
78 let mut modifiers = KeyModifiers::NONE;
79 let key_code = if key_str.len() == 1 {
81 KeyCode::Char(key_str.chars().collect::<Vec<char>>()[0])
82 } else if key_str.len() == 2
84 && key_str.to_lowercase().starts_with('f')
85 {
86 let num = key_str
87 .chars()
88 .map(|v| v.to_string())
89 .collect::<Vec<String>>()[1]
90 .parse::<u8>()
91 .map_err(de::Error::custom)?;
92 KeyCode::F(num)
93 } else if key_str.len() == 3 && key_str.contains('-') {
95 if key_str.to_lowercase().starts_with("c-") {
96 modifiers = KeyModifiers::CONTROL
97 } else if key_str.to_lowercase().starts_with("a-") {
98 modifiers = KeyModifiers::ALT
99 }
100 KeyCode::Char(key_str.chars().collect::<Vec<char>>()[2])
101 } else {
103 let mut c = key_str.chars();
104 let key_str = match c.next() {
105 None => String::new(),
106 Some(v) => {
107 v.to_uppercase().collect::<String>() + c.as_str()
108 }
109 };
110 Deserialize::deserialize(Value::String(key_str))
111 .map_err(de::Error::custom)?
112 };
113 key_bindings.push(KeyEvent::new(key_code, modifiers))
114 } else {
115 return Err(de::Error::custom("invalid type"));
116 }
117 }
118 Ok(key_bindings)
119}
120
121fn deserialize_command<'de, D>(deserializer: D) -> Result<Command, D::Error>
123where
124 D: Deserializer<'de>,
125{
126 let s = String::deserialize(deserializer)?;
127 Command::from_str(&s)
128 .map_err(|_| de::Error::custom(format!("invalid command ({s})")))
129}
130
131#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)]
133pub struct GpgConfig {
134 pub armor: Option<bool>,
136 pub homedir: Option<String>,
138 pub outdir: Option<String>,
140 pub outfile: Option<String>,
142 pub default_key: Option<String>,
144}
145
146impl Config {
147 pub fn get_default_location() -> Option<String> {
155 if let Some(config_dir) = dirs_next::config_dir() {
156 let file_name = concat!(env!("CARGO_PKG_NAME"), ".toml");
157 for config_file in [
158 config_dir.join(file_name),
159 config_dir.join(env!("CARGO_PKG_NAME")).join(file_name),
160 config_dir.join(env!("CARGO_PKG_NAME")).join("config"),
161 ] {
162 if config_file.exists() {
163 return config_file.to_str().map(String::from);
164 }
165 }
166 }
167 None
168 }
169
170 pub fn parse_config(file: &str) -> Result<Config> {
172 let contents = fs::read_to_string(file)?;
173 let config: Config = toml::from_str(&contents)?;
174 Ok(config)
175 }
176
177 pub fn update_args(&self, mut args: Args) -> Args {
179 let default_color: Color = Color::from(DEFAULT_COLOR);
180 let default_style =
181 Style::from_str(DEFAULT_STYLE, true).unwrap_or_default();
182 let default_file_explorer: String = String::from(DEFAULT_FILE_EXPLORER);
183 match self.gpg.as_ref() {
184 Some(gpg) => {
185 args.armor = gpg.armor.unwrap_or_default();
186 args.homedir.clone_from(&gpg.homedir);
187 args.outdir.clone_from(&gpg.outdir);
188 if let Some(outfile) = &gpg.outfile {
189 args.outfile = outfile.to_string();
190 }
191 if let Some(default_key) = &gpg.default_key {
192 args.default_key = Some(default_key.clone());
193 }
194 }
195 None => {
196 args.armor = DEFAULT_ARMOR;
197 args.homedir = Some(String::from(DEFAULT_HOMEDIR));
198 args.outdir = Some(String::from(DEFAULT_OUTDIR));
199 }
200 }
201 match self.general.as_ref() {
202 Some(general) => {
203 args.splash = general.splash.unwrap_or_default();
204 args.tick_rate = general.tick_rate.unwrap_or(DEFAULT_TICK_RATE);
205 args.color = general
206 .color
207 .as_ref()
208 .map(|color| Color::from(color.as_ref()))
209 .unwrap_or(default_color);
210 args.style = general
211 .style
212 .as_ref()
213 .map(|style| {
214 Style::from_str(style.as_ref(), true)
215 .unwrap_or_default()
216 })
217 .unwrap_or_default();
218 args.file_explorer = general
219 .file_explorer
220 .as_ref()
221 .cloned()
222 .unwrap_or(default_file_explorer);
223 args.detail_level = general.detail_level.unwrap_or(
224 KeyDetail::from_str(DEFAULT_DETAIL_LEVEL, true)
225 .unwrap_or_default(),
226 );
227 if general.log_file.is_some() {
228 args.log_file.clone_from(&general.log_file);
229 }
230 }
231 None => {
232 args.splash = DEFAULT_SPLASH;
233 args.tick_rate = DEFAULT_TICK_RATE;
234 args.color = default_color;
235 args.style = default_style;
236 args.file_explorer = default_file_explorer;
237 }
238 }
239 args
240 }
241}
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use pretty_assertions::assert_eq;
246 use std::fs::{self, File};
247 use std::io::Write;
248 use std::path::PathBuf;
249
250 #[test]
251 fn test_parse_config() -> Result<()> {
252 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
253 .join("config")
254 .join(concat!(env!("CARGO_PKG_NAME"), ".toml"))
255 .to_string_lossy()
256 .into_owned();
257 let mut config = Config::parse_config(&path)?;
258 if let Some(ref mut gpg) = config.gpg {
259 gpg.default_key = Some(String::from("test_key"));
260 }
261 let args = config.update_args(Args::default());
262 assert_eq!(Some(String::from("test_key")), args.default_key);
263 Ok(())
264 }
265
266 #[test]
267 fn test_args_partial_config_general() -> Result<()> {
268 let mut temp_file = File::create("config/temp.toml")?;
269 temp_file.write_all("[general]\n splash = true\n".as_bytes())?;
270 let tmp_path = PathBuf::from("config/temp.toml");
271 if let Ok(config) = Config::parse_config(&tmp_path.to_string_lossy()) {
272 let args = config.update_args(Args::default());
273 assert_eq!(args.splash, true); assert_eq!(args.tick_rate, 250_u64);
276 assert_eq!(args.armor, false);
278 assert_eq!(args.default_key, None);
279 }
280 fs::remove_file(tmp_path)?;
281 Ok(())
282 }
283
284 #[test]
285 fn test_args_partial_config_gpg() -> Result<()> {
286 let mut temp_file = File::create("config/temp2.toml")?;
287 temp_file.write_all("[gpg]\n armor = true\n".as_bytes())?;
288 let tmp_path = PathBuf::from("config/temp2.toml");
289 if let Ok(config) = Config::parse_config(&tmp_path.to_string_lossy()) {
290 let args = config.update_args(Args::default());
291 assert_eq!(args.splash, false);
293 assert_eq!(args.tick_rate, 250_u64);
294 assert_eq!(args.armor, true); assert_eq!(args.default_key, None);
297 }
298 fs::remove_file(tmp_path)?;
299 Ok(())
300 }
301
302 #[test]
303 fn test_parse_key_bindings() -> Result<()> {
304 for (keys, cmd, config) in [
305 (
306 vec![
307 KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
308 KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE),
309 ],
310 ":visual",
311 "keys = [ 'enter', 'v' ]",
312 ),
313 (
314 vec![
315 KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
316 KeyEvent::new(KeyCode::Char('Q'), KeyModifiers::NONE),
317 KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
318 ],
319 "quit",
320 "keys = [ 'C-c', 'Q', 'esc' ]",
321 ),
322 (
323 vec![
324 KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE),
325 KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE),
326 KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE),
327 ],
328 ":help",
329 "keys = [ '?', 'h', 'f1' ]",
330 ),
331 (
332 vec![
333 KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE),
334 KeyEvent::new(KeyCode::Char('1'), KeyModifiers::ALT),
335 KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE),
336 ],
337 ":REFRESH",
338 "keys = [ 'F5', 'A-1', 'R' ]",
339 ),
340 (
341 vec![
342 KeyEvent::new(KeyCode::Char('O'), KeyModifiers::NONE),
343 KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE),
344 ],
345 ":OPTIONS",
346 "keys = [ 'O', ' ' ]",
347 ),
348 (
349 vec![
350 KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE),
351 KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL),
352 ],
353 ":paste",
354 "keys = [ 'p', 'c-p' ]",
355 ),
356 (
357 vec![
358 KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
359 KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
360 KeyEvent::new(KeyCode::Left, KeyModifiers::NONE),
361 KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE),
362 ],
363 ":delete",
364 "keys = [ 'backspace', 'Backspace', 'left', 'delete' ]",
365 ),
366 (
367 vec![
368 KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
369 KeyEvent::new(KeyCode::Char('D'), KeyModifiers::CONTROL),
370 KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
371 KeyEvent::new(KeyCode::Char('3'), KeyModifiers::ALT),
372 KeyEvent::new(KeyCode::F(0), KeyModifiers::NONE),
373 ],
374 ":export",
375 "keys = [ 'x', 'c-D', 'c-x', 'A-3', 'f0' ]",
376 ),
377 ] {
378 assert_eq!(
379 CustomKeyBinding {
380 keys,
381 command: Command::from_str(cmd).expect("invalid command"),
382 },
383 toml::from_str(&format!("{config}\ncommand = '{cmd}'"))?
384 );
385 }
386
387 for config in &[
388 "keys = [ 'x' ] \n command = ':x'",
389 "keys = [ 'test' ] \n command = ':help'",
390 "keys = [ '' ] \n command = ':help'",
391 "keys = [ 'q' ] \n command = ':qx'",
392 ] {
393 assert!(toml::from_str::<CustomKeyBinding>(config).is_err());
394 }
395 Ok(())
396 }
397}