kptui 0.20.0

TUI password manager for KeePass vaults
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;

use anyhow::Context;
use serde::{Deserialize, Serialize};

use crate::util::expand_tilde;

const SAMPLE_CONFIG: &str = include_str!("../sample-config.toml");
const AUTO_LOCK: u64 = 300;
const CONFIGFILE: &str = "~/.config/kptui/config.toml";

fn sibling_tmp_path(path: &Path) -> PathBuf {
	let mut tmp = path.as_os_str().to_os_string();
	tmp.push(".tmp");
	PathBuf::from(tmp)
}

pub struct Config {
	pub default_database: Option<PathBuf>,
	pub keyfile: Option<PathBuf>,
	pub auto_lock: Duration,
	pub theme: Option<String>,
}

impl Default for Config {
	fn default() -> Self {
		Self { default_database: None, keyfile: None, auto_lock: Duration::from_secs(AUTO_LOCK), theme: None }
	}
}

#[derive(Deserialize, Default)]
struct ConfigFile {
	default_database: Option<String>,
	keyfile: Option<String>,
	auto_lock: Option<u64>,
	theme: Option<String>,
}

pub fn load_config() -> anyhow::Result<Config> {
	let path = expand_tilde(CONFIGFILE);

	if !path.exists() {
		if let Some(parent) = path.parent() {
			fs::create_dir_all(parent).with_context(|| format!("couldn't create {}", parent.display()))?;
		}

		let mut options = fs::OpenOptions::new();
		options.write(true).create_new(true);

		let result = options.open(&path);

		match result {
			Ok(mut file) => {
				use std::io::Write;
				file.write_all(SAMPLE_CONFIG.as_bytes()).with_context(|| format!("couldn't write {}", path.display()))?;
			}
			Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
			Err(error) => {
				return Err(error).with_context(|| format!("couldn't create {}", path.display()));
			}
		}
	}

	let text = fs::read_to_string(&path).with_context(|| format!("couldn't read {}", path.display()))?;
	parse_config(&text)
}

fn parse_config(text: &str) -> anyhow::Result<Config> {
	let raw: ConfigFile = toml::from_str(text)?;

	let defaults = Config::default();

	Ok(Config {
		default_database: raw.default_database.map(|s| expand_tilde(&s)),
		keyfile: raw.keyfile.map(|s| expand_tilde(&s)),
		auto_lock: raw.auto_lock.map(Duration::from_secs).unwrap_or(defaults.auto_lock),
		theme: raw.theme,
	})
}

#[derive(Serialize)]
struct ConfigFileOut<'a> {
	default_database: Option<String>,
	keyfile: Option<String>,
	auto_lock: u64,
	theme: Option<&'a str>,
}

pub fn save_config(config: &Config) -> anyhow::Result<()> {
	let path = expand_tilde(CONFIGFILE);

	if let Some(parent) = path.parent() {
		fs::create_dir_all(parent).with_context(|| format!("couldn't create {}", parent.display()))?;
	}

	let text = serialize_config(config)?;
	let tmp_path = sibling_tmp_path(&path);

	let mut options = fs::OpenOptions::new();
	options.write(true).create_new(true);

	let mut file = options.open(&tmp_path).with_context(|| format!("couldn't create {}", tmp_path.display()))?;

	let result = (|| {
		use std::io::Write;

		file.write_all(text.as_bytes()).with_context(|| format!("couldn't write {}", tmp_path.display()))?;

		file.sync_all().with_context(|| format!("couldn't sync {}", tmp_path.display()))?;

		fs::rename(&tmp_path, &path).with_context(|| format!("couldn't replace {}", path.display()))?;

		Ok(())
	})();

	if result.is_err() {
		let _ = fs::remove_file(&tmp_path);
	}

	result
}

fn serialize_config(config: &Config) -> anyhow::Result<String> {
	let out = ConfigFileOut {
		default_database: config.default_database.as_ref().map(|p| p.display().to_string()),
		keyfile: config.keyfile.as_ref().map(|p| p.display().to_string()),
		auto_lock: config.auto_lock.as_secs(),
		theme: config.theme.as_deref(),
	};

	toml::to_string_pretty(&out).context("couldn't serialize config")
}

#[cfg(test)]
mod tests {
	use super::{Config, parse_config, serialize_config};
	use std::path::PathBuf;

	#[test]
	fn parses_optional_keyfile_path() {
		let config = parse_config(
			r#"
                default_database = "/tmp/passwords.kdbx"
                keyfile = "/tmp/passwords.keyx"
            "#,
		)
		.expect("config should parse");

		assert_eq!(config.default_database, Some(PathBuf::from("/tmp/passwords.kdbx")));
		assert_eq!(config.keyfile, Some(PathBuf::from("/tmp/passwords.keyx")));
	}

	#[test]
	fn keyfile_remains_optional() {
		let config = parse_config("default_database = \"/tmp/passwords.kdbx\"").expect("config should parse");

		assert_eq!(config.keyfile, None);

		let text = serialize_config(&config).expect("config should serialize");
		assert!(!text.contains("keyfile"));
	}

	#[test]
	fn serializes_keyfile_path() {
		let config = Config { keyfile: Some(PathBuf::from("/tmp/passwords.keyx")), ..Config::default() };

		let text = serialize_config(&config).expect("config should serialize");
		assert!(text.contains("keyfile = \"/tmp/passwords.keyx\""));
	}
}