use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::config::{self, ApiKeys};
use crate::notify::Notifications;
use crate::theme;
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings {
pub theme: String,
pub highlighted_chats: Vec<i64>,
pub notifications: Notifications,
pub normal_after_send: bool,
pub block_gaps: bool,
pub chat_gaps: bool,
pub chat_list_side: Side,
pub chat_list_width: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_keys: Option<ApiKeys>,
}
impl Default for Settings {
fn default() -> Self {
Self {
theme: theme::DEFAULT.into(),
highlighted_chats: Vec::new(),
notifications: Notifications::default(),
normal_after_send: false,
block_gaps: true,
chat_gaps: true,
chat_list_side: Side::Left,
chat_list_width: DEFAULT_LIST_WIDTH,
api_keys: None,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Side {
#[default]
Left,
Right,
}
pub const DEFAULT_LIST_WIDTH: u16 = 35;
const LIST_WIDTHS: std::ops::RangeInclusive<u16> = 15..=70;
const LIST_STEP: u16 = 5;
impl Settings {
pub fn load(path: &Path) -> Result<Self> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
Err(e) => {
return Err(e).with_context(|| format!("cannot read {}", config::shown(path)));
}
};
toml::from_str(&text).with_context(|| format!("{} is invalid", config::shown(path)))
}
pub fn list_width(&self) -> u16 {
self.chat_list_width
.clamp(*LIST_WIDTHS.start(), *LIST_WIDTHS.end())
}
pub fn resize_list(&mut self, steps: i16) {
let width = self.list_width() as i16 + steps * LIST_STEP as i16;
self.chat_list_width =
(width.max(0) as u16).clamp(*LIST_WIDTHS.start(), *LIST_WIDTHS.end());
}
pub fn save(&self, path: &Path) -> Result<()> {
let text = toml::to_string(self)?;
let new = path.with_extension("toml.new");
let write = || -> std::io::Result<()> {
let _ = std::fs::remove_file(&new);
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
let mut file = options.open(&new)?;
file.write_all(text.as_bytes())?;
file.sync_all()?;
std::fs::rename(&new, path)
};
write().with_context(|| format!("cannot write {}", config::shown(path)))
}
}
pub fn path(data_dir: &Path) -> PathBuf {
data_dir.join("settings.toml")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_file_means_defaults_and_saving_round_trips() {
let dir = std::env::temp_dir().join(format!("tuigram-settings-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = path(&dir);
let _ = std::fs::remove_file(&file);
assert_eq!(Settings::load(&file).unwrap().theme, "mocha");
let mut settings = Settings {
theme: "latte".into(),
highlighted_chats: vec![-1001234567890, 42],
notifications: Notifications::Off,
normal_after_send: true,
block_gaps: false,
chat_gaps: false,
chat_list_side: Side::Right,
chat_list_width: 40,
api_keys: None,
};
settings.save(&file).unwrap();
assert_eq!(
std::fs::read_to_string(&file).unwrap().trim(),
"theme = \"latte\"\nhighlighted_chats = [-1001234567890, 42]\nnotifications = \"off\"\nnormal_after_send = true\nblock_gaps = false\nchat_gaps = false\nchat_list_side = \"right\"\nchat_list_width = 40"
);
assert_eq!(Settings::load(&file).unwrap(), settings);
settings.api_keys = Some(ApiKeys {
id: 1234567,
hash: "0123456789abcdef0123456789abcdef".into(),
});
settings.save(&file).unwrap();
assert_eq!(Settings::load(&file).unwrap(), settings);
assert!(
!file.with_extension("toml.new").exists(),
"renamed into place"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&file).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "it can hold API keys");
}
std::fs::write(&file, r#"theme = "latte""#).unwrap();
let old = Settings::load(&file).unwrap();
assert!(old.block_gaps && old.chat_gaps);
assert_eq!(old.chat_list_width, DEFAULT_LIST_WIDTH);
assert_eq!(old.chat_list_side, Side::Left);
std::fs::write(&file, r#"theme = "deleted""#).unwrap();
assert_eq!(Settings::load(&file).unwrap().theme, "deleted");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_chat_list_resizes_in_steps_and_keeps_both_panes() {
let mut settings = Settings::default();
settings.resize_list(1);
assert_eq!(settings.list_width(), 40);
settings.resize_list(-3);
assert_eq!(settings.list_width(), 25);
settings.resize_list(-10);
assert_eq!(settings.list_width(), 15, "the chat list stays");
settings.resize_list(20);
assert_eq!(settings.list_width(), 70, "the chat stays");
settings.chat_list_width = 200;
assert_eq!(settings.list_width(), 70);
settings.resize_list(-1);
assert_eq!(settings.list_width(), 65);
}
}