Skip to main content

gpg_tui/
config.rs

1//! Configuration file parser.
2
3use 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
16/// Default color, style, and settings.
17const 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/// Application configuration.
28#[derive(Debug, Default, Serialize, Deserialize)]
29pub struct Config {
30	/// General configuration.
31	pub general: Option<GeneralConfig>,
32	/// GnuPG configuration.
33	pub gpg: Option<GpgConfig>,
34}
35
36/// General configuration.
37#[derive(Debug, Default, Serialize, Deserialize)]
38pub struct GeneralConfig {
39	/// [`Args::splash`]
40	pub splash: Option<bool>,
41	/// [`Args::tick_rate`]
42	pub tick_rate: Option<u64>,
43	/// [`Args::color`]
44	pub color: Option<String>,
45	/// [`Args::style`]
46	pub style: Option<String>,
47	/// [`Args::file_explorer`]
48	pub file_explorer: Option<String>,
49	/// [`Args::detail_level`]
50	pub detail_level: Option<KeyDetail>,
51	/// Custom key bindings.
52	#[serde(skip_serializing)]
53	pub key_bindings: Option<Vec<CustomKeyBinding>>,
54	/// File to save the logs.
55	pub log_file: Option<String>,
56}
57
58/// Representation of custom key bindings.
59#[derive(Debug, Clone, PartialEq, Deserialize)]
60pub struct CustomKeyBinding {
61	/// Key events to check.
62	#[serde(deserialize_with = "deserialize_keys")]
63	pub keys: Vec<KeyEvent>,
64	/// Command to run.
65	#[serde(deserialize_with = "deserialize_command")]
66	pub command: Command,
67}
68
69/// Custom deserializer for parsing a vector of [`KeyEvent`]s
70fn 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			// parse a single character
80			let key_code = if key_str.len() == 1 {
81				KeyCode::Char(key_str.chars().collect::<Vec<char>>()[0])
82			// parse function keys
83			} 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			// parse control/alt combinations
94			} 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			// try parsing the keycode
102			} 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
121/// Custom deserializer for parsing [`Command`]s
122fn 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/// GnuPG configuration.
132#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)]
133pub struct GpgConfig {
134	/// [`Args::armor`]
135	pub armor: Option<bool>,
136	/// [`Args::homedir`]
137	pub homedir: Option<String>,
138	/// [`Args::outdir`]
139	pub outdir: Option<String>,
140	/// [`Args::outfile`]
141	pub outfile: Option<String>,
142	/// [`Args::default_key`]
143	pub default_key: Option<String>,
144}
145
146impl Config {
147	/// Checks the possible locations for the configuration file.
148	///
149	/// - `<config_dir>/gpg-tui.toml`
150	/// - `<config_dir>/gpg-tui/gpg-tui.toml`
151	/// - `<config_dir>/gpg-tui/config`
152	///
153	/// Returns the path if the configuration file is found.
154	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	/// Parses the configuration file.
171	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	/// Update the command-line arguments based on configuration.
178	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			// [general]
274			assert_eq!(args.splash, true); // supplied
275			assert_eq!(args.tick_rate, 250_u64);
276			// [gpg]
277			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			// [general]
292			assert_eq!(args.splash, false);
293			assert_eq!(args.tick_rate, 250_u64);
294			// [gpg]
295			assert_eq!(args.armor, true); // supplied
296			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}