use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use super::{Ecosystem, SettingValue, Settings, atomic_write};
use crate::diagnostics::{Diagnostic, Severity};
use crate::i18n::I18n;
use crate::icons::{GlyphMode, IconMode, default_font_dirs, detect_glyph_mode};
use crate::runtime::Command;
const DETECTED_THEME: &str = "monochrome";
const FALLBACK_LANGUAGE: &str = "en";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Shared {
Language,
Theme,
Icons,
}
impl Shared {
pub const ALL: [Self; 3] = [Self::Language, Self::Theme, Self::Icons];
#[must_use]
pub fn key(self) -> &'static str {
match self {
Self::Language => Settings::LANGUAGE,
Self::Theme => Settings::THEME,
Self::Icons => Settings::ICONS,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Scope {
Ecosystem,
App,
}
impl Scope {
#[allow(non_upper_case_globals)]
pub const Family: Scope = Scope::Ecosystem;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Source {
App,
Ecosystem,
Detected,
}
impl Source {
#[allow(non_upper_case_globals)]
pub const Family: Source = Source::Ecosystem;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolved<T> {
pub value: T,
pub source: Source,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Preferences {
language: Resolved<String>,
theme: Resolved<String>,
icons: Resolved<IconMode>,
update_notice: bool,
diagnostics: Vec<Diagnostic>,
}
impl Preferences {
#[must_use]
pub fn update_notice(&self) -> bool {
self.update_notice
}
pub(crate) fn record_update_notice(&mut self, on: bool) {
self.update_notice = on;
}
#[must_use]
pub fn language(&self) -> &Resolved<String> {
&self.language
}
#[must_use]
pub fn theme(&self) -> &Resolved<String> {
&self.theme
}
#[must_use]
pub fn icons(&self) -> &Resolved<IconMode> {
&self.icons
}
#[must_use]
pub fn source(&self, key: Shared) -> Source {
match key {
Shared::Language => self.language.source,
Shared::Theme => self.theme.source,
Shared::Icons => self.icons.source,
}
}
pub(crate) fn record(&mut self, key: Shared, value: &str, source: Source) {
match key {
Shared::Language => self.language = Resolved { value: value.to_owned(), source },
Shared::Theme => self.theme = Resolved { value: value.to_owned(), source },
Shared::Icons => {
let mode = IconMode::from_name(value).unwrap_or(self.icons.value);
self.icons = Resolved { value: mode, source };
}
}
}
pub(crate) fn text(&self, key: Shared) -> String {
match key {
Shared::Language => self.language.value.clone(),
Shared::Theme => self.theme.value.clone(),
Shared::Icons => self.icons.value.name().to_owned(),
}
}
#[must_use]
pub fn diagnostics(&self) -> &[Diagnostic] {
&self.diagnostics
}
#[must_use]
pub fn apply<Msg: Send + 'static>(&self) -> Command<Msg> {
Command::batch([
Command::set_theme(self.theme.value.clone()),
Command::set_locale(self.language.value.clone()),
Command::set_icon_mode(self.icons.value),
])
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Missing {
Create,
Leave,
}
struct Detected {
language: String,
icons: IconMode,
}
impl Detected {
fn on_this_machine(i18n: &I18n, lookup: impl Fn(&str) -> Option<String>, font_dirs: &[PathBuf]) -> Self {
let language = i18n.detect(&lookup).unwrap_or_else(|| FALLBACK_LANGUAGE.to_owned());
let icons = match detect_glyph_mode(IconMode::Auto, &lookup, font_dirs) {
GlyphMode::Nerd => IconMode::Nerd,
GlyphMode::Unicode => IconMode::Unicode,
GlyphMode::Ascii => IconMode::Ascii,
};
Self { language, icons }
}
fn text(&self, key: Shared) -> String {
match key {
Shared::Language => self.language.clone(),
Shared::Theme => DETECTED_THEME.to_owned(),
Shared::Icons => self.icons.name().to_owned(),
}
}
fn file(&self) -> String {
let mut settings = Settings::in_memory();
for key in Shared::ALL {
settings.set(key.key(), self.text(key));
}
settings.to_toml()
}
}
impl Ecosystem {
#[must_use]
pub fn preferences(&self, app: &str, i18n: &I18n) -> Preferences {
let lookup = |name: &str| std::env::var(name).ok();
let font_dirs = default_font_dirs(lookup);
let detected = Detected::on_this_machine(i18n, lookup, &font_dirs);
match self.config_dir() {
Some(dir) => self.resolve(&dir, app, &detected, Missing::Create),
None => {
let mut prefs = resolved_from(&detected, |_| None, |_| None);
prefs
.diagnostics
.push(Diagnostic::warning(None, "no config directory found; preferences are not saved"));
prefs
}
}
}
#[must_use]
pub fn preferences_in(&self, config_dir: &Path, app: &str, i18n: &I18n) -> Preferences {
let lookup = |name: &str| std::env::var(name).ok();
let detected = Detected::on_this_machine(i18n, lookup, &default_font_dirs(lookup));
self.resolve(config_dir, app, &detected, Missing::Create)
}
#[must_use]
pub fn preferences_without_saving(&self, app: &str, i18n: &I18n) -> Preferences {
let lookup = |name: &str| std::env::var(name).ok();
let font_dirs = default_font_dirs(lookup);
let detected = Detected::on_this_machine(i18n, lookup, &font_dirs);
match self.config_dir() {
Some(dir) => self.resolve(&dir, app, &detected, Missing::Leave),
None => resolved_from(&detected, |_| None, |_| None),
}
}
#[must_use]
pub fn preferences_without_saving_in(&self, config_dir: &Path, app: &str, i18n: &I18n) -> Preferences {
let lookup = |name: &str| std::env::var(name).ok();
let detected = Detected::on_this_machine(i18n, lookup, &default_font_dirs(lookup));
self.resolve(config_dir, app, &detected, Missing::Leave)
}
#[cfg(test)]
fn preferences_detecting(
&self,
config_dir: &Path,
app: &str,
i18n: &I18n,
lookup: impl Fn(&str) -> Option<String>,
) -> Preferences {
self.resolve(config_dir, app, &Detected::on_this_machine(i18n, lookup, &[]), Missing::Create)
}
pub fn set(&self, app: &str, key: Shared, value: &str, scope: Scope) -> io::Result<()> {
match self.config_dir() {
Some(dir) => self.set_in(&dir, app, key, value, scope),
None => Err(io::Error::new(io::ErrorKind::NotFound, "no config directory found")),
}
}
pub fn set_in(&self, config_dir: &Path, app: &str, key: Shared, value: &str, scope: Scope) -> io::Result<()> {
let value = self.checked(key, value)?;
fs::create_dir_all(config_dir)?;
let _held = hold_folder(config_dir)?;
let app_file = config_dir.join(super::ecosystem::file_name(app));
match scope {
Scope::Ecosystem => {
let shared_file = config_dir.join(super::ecosystem::file_name(self.id()));
rewrite(&shared_file, key.key(), SettingValue::Text(value), None)?;
rewrite(&app_file, key.key(), SettingValue::Text(self.id().to_owned()), Some(self))
}
Scope::App => rewrite(&app_file, key.key(), SettingValue::Text(value), Some(self)),
}
}
pub fn follow(&self, app: &str, key: Shared) -> io::Result<()> {
match self.config_dir() {
Some(dir) => self.follow_in(&dir, app, key),
None => Err(io::Error::new(io::ErrorKind::NotFound, "no config directory found")),
}
}
pub fn follow_in(&self, config_dir: &Path, app: &str, key: Shared) -> io::Result<()> {
fs::create_dir_all(config_dir)?;
let _held = hold_folder(config_dir)?;
let path = config_dir.join(super::ecosystem::file_name(app));
let mut settings = Settings::open(&path).member_of(self);
if let Some(problem) = settings.diagnostics().iter().find(|problem| problem.severity == Severity::Error) {
return Err(io::Error::new(io::ErrorKind::InvalidData, problem.to_string()));
}
let value = SettingValue::Text(self.id().to_owned());
if settings.value(key.key()) == Some(&value) && path.exists() {
return Ok(());
}
settings.store(key.key(), value);
settings.save()
}
pub(crate) fn set_own_in(&self, config_dir: &Path, app: &str, key: &str, value: SettingValue) -> io::Result<()> {
fs::create_dir_all(config_dir)?;
let _held = hold_folder(config_dir)?;
rewrite(&config_dir.join(super::ecosystem::file_name(app)), key, value, Some(self))
}
fn checked(&self, key: Shared, value: &str) -> io::Result<String> {
let invalid = |why: String| io::Error::new(io::ErrorKind::InvalidInput, why);
let value = value.trim();
if value.is_empty() {
return Err(invalid(format!("`{}` cannot be empty", key.key())));
}
if value == self.id() {
return Err(invalid(format!("`{}` cannot be set to the ecosystem's own id `{value}`", key.key())));
}
match key {
Shared::Icons => IconMode::from_name(value)
.map(|mode| mode.name().to_owned())
.ok_or_else(|| invalid(format!("`{value}` is not an icon mode; use auto, nerd, unicode or ascii"))),
Shared::Language | Shared::Theme => Ok(value.to_owned()),
}
}
fn resolve(&self, config_dir: &Path, app: &str, detected: &Detected, missing: Missing) -> Preferences {
let mut diagnostics = Vec::new();
let shared_path = config_dir.join(super::ecosystem::file_name(self.id()));
let shared = if shared_path.exists() {
let shared = Settings::open(&shared_path);
diagnostics.extend(shared.diagnostics().iter().cloned());
Some(shared)
} else {
if missing == Missing::Create
&& let Err(error) = create(&shared_path, &detected.file())
{
diagnostics.push(Diagnostic::error(
None,
format!("{}: shared preferences not saved: {error}", shared_path.display()),
));
}
None
};
let own = Settings::open(config_dir.join(super::ecosystem::file_name(app))).member_of(self);
let ecosystem_value = |key: Shared| -> Option<String> {
let shared = shared.as_ref()?;
let text = shared.get::<String>(key.key()).filter(|text| valid(key, text))?;
(text != self.id()).then_some(text)
};
let app_value = |key: Shared| -> Option<String> {
own.get::<String>(key.key()).filter(|text| text != self.id() && valid(key, text))
};
if let Some(shared) = &shared {
for key in Shared::ALL {
if shared.get::<String>(key.key()).is_some_and(|text| text == self.id()) {
diagnostics.push(Diagnostic::warning(
shared.origin(key.key()),
format!(
"`{}` cannot follow the ecosystem in the ecosystem's own file; the detected value is used",
key.key()
),
));
}
}
}
let mut prefs = resolved_from(detected, app_value, ecosystem_value);
prefs.update_notice = shared.as_ref().is_none_or(super::update_notice::from_shared);
prefs.diagnostics = diagnostics;
prefs
}
}
fn valid(key: Shared, text: &str) -> bool {
match key {
Shared::Icons => IconMode::from_name(text).is_some(),
Shared::Language | Shared::Theme => !text.trim().is_empty(),
}
}
fn resolved_from(
detected: &Detected,
app_value: impl Fn(Shared) -> Option<String>,
ecosystem_value: impl Fn(Shared) -> Option<String>,
) -> Preferences {
let text = |key: Shared| -> Resolved<String> {
if let Some(value) = app_value(key) {
Resolved { value, source: Source::App }
} else if let Some(value) = ecosystem_value(key) {
Resolved { value, source: Source::Ecosystem }
} else {
Resolved { value: detected.text(key), source: Source::Detected }
}
};
let icons = text(Shared::Icons);
Preferences {
language: text(Shared::Language),
theme: text(Shared::Theme),
icons: Resolved { value: IconMode::from_name(&icons.value).unwrap_or(detected.icons), source: icons.source },
update_notice: true,
diagnostics: Vec::new(),
}
}
#[cfg(unix)]
pub(super) fn hold_folder(dir: &Path) -> io::Result<Option<fs::File>> {
let folder = fs::File::open(dir)?;
folder.lock()?;
Ok(Some(folder))
}
#[cfg(not(unix))]
pub(super) fn hold_folder(_dir: &Path) -> io::Result<Option<fs::File>> {
Ok(None)
}
fn create(path: &Path, text: &str) -> io::Result<()> {
if let Some(dir) = path.parent() {
fs::create_dir_all(dir)?;
}
atomic_write(path, text.as_bytes())
}
pub(super) fn rewrite(path: &Path, key: &str, value: SettingValue, ecosystem: Option<&Ecosystem>) -> io::Result<()> {
let mut settings = Settings::open(path);
if let Some(ecosystem) = ecosystem {
settings = settings.member_of(ecosystem);
}
if settings.value(key) == Some(&value) && path.exists() {
return Ok(());
}
settings.store(key, value);
settings.save()
}
#[cfg(test)]
#[path = "preferences_tests.rs"]
mod tests;