use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::config;
use crate::icons::IconMode;
use crate::images::ImageMode;
use crate::theme;
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings {
pub theme: String,
pub max_height: u32,
pub history: bool,
pub descriptions: bool,
pub refresh_minutes: u32,
pub images: ImageMode,
pub icons: IconMode,
pub card_width: u16,
pub sidebar_width: u16,
pub yt_dlp: Option<PathBuf>,
pub mpv: Option<PathBuf>,
pub deno: Option<PathBuf>,
}
impl Default for Settings {
fn default() -> Self {
Self {
theme: theme::DEFAULT.into(),
max_height: 1080,
history: true,
descriptions: true,
refresh_minutes: 30,
images: ImageMode::Auto,
icons: IconMode::Auto,
card_width: 34,
sidebar_width: 26,
yt_dlp: None,
mpv: None,
deno: None,
}
}
}
impl Settings {
#[cfg(test)]
pub fn load(path: &Path) -> Result<Self> {
Ok(Self::load_checked(path)?.0)
}
pub fn load_checked(path: &Path) -> Result<(Self, Vec<String>)> {
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(), Vec::new()));
}
Err(e) => {
return Err(e).with_context(|| format!("cannot read {}", config::shown(path)));
}
};
let shown = config::shown(path);
let table: toml::Table =
toml::from_str(&text).with_context(|| format!("{shown} is invalid"))?;
let unknown = table
.keys()
.filter(|key| !KNOWN.contains(&key.as_str()) && !RETIRED.contains(&key.as_str()))
.map(|key| crate::text::clean(key))
.collect();
let settings = toml::from_str(&text).with_context(|| format!("{shown} is invalid"))?;
Ok((settings, unknown))
}
pub fn card_width(&self) -> u16 {
self.card_width.clamp(20, 80)
}
pub fn refresh_every(&self) -> i64 {
i64::from(self.refresh_minutes.clamp(15, 7 * 24 * 60)) * 60
}
pub fn image_mode(&self) -> ImageMode {
config::var("TT_IMAGES")
.and_then(|v| ImageMode::parse(&v))
.unwrap_or(self.images)
}
pub fn icon_mode(&self) -> IconMode {
config::var("TT_ICONS")
.and_then(|v| IconMode::parse(&v))
.unwrap_or(self.icons)
}
pub fn sidebar_width(&self) -> u16 {
self.sidebar_width.clamp(16, 50)
}
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)))
}
}
const KNOWN: [&str; 12] = [
"theme",
"max_height",
"history",
"descriptions",
"refresh_minutes",
"images",
"icons",
"card_width",
"sidebar_width",
"yt_dlp",
"mpv",
"deno",
];
const RETIRED: [&str; 1] = ["refresh_hours"];
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!("tuitube-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(), Settings::default());
let settings = Settings {
theme: "latte".into(),
max_height: 720,
..Settings::default()
};
settings.save(&file).unwrap();
assert_eq!(Settings::load(&file).unwrap(), settings);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&file).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "only yours");
}
std::fs::write(&file, "card_width = 2").unwrap();
assert_eq!(Settings::load(&file).unwrap().card_width(), 20);
std::fs::write(&file, "histroy = false\nrefresh_hours = 72\nhistory = true").unwrap();
let (settings, unknown) = Settings::load_checked(&file).unwrap();
assert_eq!(
unknown,
["histroy"],
"a typo is reported, an old setting isn't"
);
assert!(settings.history);
std::fs::remove_dir_all(&dir).unwrap();
}
}