use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::Deserialize;
use crate::theme::Theme;
const A_YEAR_IN_MINUTES: u64 = 365 * 24 * 60;
pub const DEFAULT_CONFIG: &str = include_str!("../../assets/default_config.toml");
mod layout;
mod plugins;
mod widgets;
#[allow(unused_imports)]
pub use layout::{Layout, LayoutPanel, LayoutRow};
pub use plugins::PluginConfig;
#[allow(unused_imports)]
pub use widgets::{
AgendaConfig, CalculatorConfig, CalendarConfig, ClockZone, ClocksConfig, CpuConfig,
NetworkConfig, NewsConfig, NewsFeed, NotesConfig, PomodoroConfig, StocksConfig, TodoConfig,
WeatherConfig,
};
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub general: General,
pub plugins: Vec<PluginConfig>,
#[serde(deserialize_with = "crate::theme::de_theme")]
pub theme: Theme,
pub layout: Layout,
pub clocks: ClocksConfig,
pub weather: WeatherConfig,
pub todo: TodoConfig,
pub notes: NotesConfig,
pub stocks: StocksConfig,
pub agenda: AgendaConfig,
pub calendar: CalendarConfig,
pub news: NewsConfig,
pub pomodoro: PomodoroConfig,
pub calculator: CalculatorConfig,
pub cpu: CpuConfig,
pub network: NetworkConfig,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
#[allow(clippy::struct_excessive_bools)]
pub struct General {
pub tick_rate_ms: u64,
pub show_borders: bool,
pub show_status_bar: bool,
pub mouse: bool,
pub check_for_updates: bool,
}
impl Default for General {
fn default() -> Self {
Self {
tick_rate_ms: 250,
check_for_updates: false,
show_borders: true,
show_status_bar: true,
mouse: true,
}
}
}
impl Config {
pub fn widget_names(&self) -> Vec<String> {
crate::widgets::WIDGET_NAMES
.iter()
.map(|name| (*name).to_string())
.chain(self.plugins.iter().map(|plugin| plugin.id.clone()))
.collect()
}
pub fn plugin(&self, id: &str) -> Option<&PluginConfig> {
self.plugins.iter().find(|plugin| plugin.id == id)
}
pub fn knows_widget(&self, id: &str) -> bool {
crate::widgets::is_known_widget(id) || self.plugin(id).is_some()
}
pub fn load(explicit: Option<PathBuf>) -> Result<(Self, PathBuf)> {
let path = match explicit {
Some(p) => p,
None => Self::default_path()?,
};
if !path.exists() {
crate::store::write_atomic(&path, DEFAULT_CONFIG)
.with_context(|| format!("writing default config to {}", path.display()))?;
}
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("reading config {}", path.display()))?;
let mut config: Self = toml::from_str(&raw).map_err(|e| stale_config_hint(&e, &path))?;
config.resolve_theme(&path)?;
config.validate()?;
Ok((config, path))
}
fn resolve_theme(&mut self, config_path: &Path) -> Result<()> {
let Some(name) = self.theme.name.clone() else {
return Ok(());
};
let dir = crate::themes::user_dir(config_path);
self.theme = crate::themes::resolve(&name, dir.as_deref())?;
Ok(())
}
pub fn default_path() -> Result<PathBuf> {
if let Ok(from_env) = std::env::var("MIRADOR_CONFIG") {
return Ok(PathBuf::from(from_env));
}
let dir = dirs::config_dir()
.context("could not determine a config directory for this platform")?;
Ok(dir.join("mirador").join("config.toml"))
}
pub fn default_data_path() -> Result<PathBuf> {
Ok(Self::default_data_dir()?.join("todos.toml"))
}
pub fn reset(path: &Path) -> Result<Option<PathBuf>> {
let backup = match path.try_exists() {
Ok(true) => {
let to = crate::store::free_backup_path(path);
std::fs::copy(path, &to).with_context(|| {
format!("backing up {} to {}", path.display(), to.display())
})?;
Some(to)
}
Ok(false) => None,
Err(e) => anyhow::bail!("could not check whether {} exists: {e}", path.display()),
};
crate::store::write_atomic(path, DEFAULT_CONFIG)
.with_context(|| format!("writing default config to {}", path.display()))?;
Ok(backup)
}
fn default_data_dir() -> Result<PathBuf> {
let dir =
dirs::data_dir().context("could not determine a data directory for this platform")?;
Ok(dir.join("mirador"))
}
pub(crate) fn validate(&self) -> Result<()> {
let mut plugin_ids = std::collections::HashSet::new();
for plugin in &self.plugins {
plugin.validate()?;
if crate::widgets::is_known_widget(&plugin.id) {
anyhow::bail!(
"plugin id `{}` conflicts with a built-in widget.",
plugin.id
);
}
if !plugin_ids.insert(plugin.id.as_str()) {
anyhow::bail!("plugin id `{}` is declared more than once.", plugin.id);
}
}
if self.layout.rows.is_empty() {
anyhow::bail!(
"`[layout]` has no rows, so there is nothing to draw. \
Add at least one `{{ height = 100, panels = [...] }}` entry to `rows`."
);
}
for row in &self.layout.rows {
if row.panels.is_empty() {
anyhow::bail!(
"a layout row has an empty `panels` list. \
Remove the row, or give it a panel such as \
`{{ widget = \"todo\", width = 100 }}`."
);
}
for panel in &row.panels {
if !self.knows_widget(&panel.widget) {
anyhow::bail!(
"unknown widget `{}`. Available widgets: {}.",
panel.widget,
self.widget_names().join(", ")
);
}
}
}
if !matches!(self.weather.units.as_str(), "metric" | "imperial") {
anyhow::bail!(
"`[weather].units` is `{}`; expected `metric` or `imperial`.",
self.weather.units
);
}
for (key, minutes) in [
("focus_minutes", self.pomodoro.focus_minutes),
("short_break_minutes", self.pomodoro.short_break_minutes),
("long_break_minutes", self.pomodoro.long_break_minutes),
] {
if minutes == 0 {
anyhow::bail!("`[pomodoro].{key}` is 0; a phase needs at least one minute.");
}
}
if self.pomodoro.rounds_before_long_break == 0 {
anyhow::bail!(
"`[pomodoro].rounds_before_long_break` is 0; a set needs at least one focus \
interval before the long break."
);
}
if self.weather.refresh_minutes > A_YEAR_IN_MINUTES {
anyhow::bail!(
"`[weather].refresh_minutes` is {}; the maximum is {A_YEAR_IN_MINUTES} \
(one year). Leave it out to use the default of 30.",
self.weather.refresh_minutes
);
}
if self.stocks.refresh_secs > A_YEAR_IN_MINUTES * 60 {
anyhow::bail!(
"`[stocks].refresh_secs` is {}; the maximum is {} (one year). \
Leave it out to use the default of 120.",
self.stocks.refresh_secs,
A_YEAR_IN_MINUTES * 60
);
}
Ok(())
}
pub fn todo_path(&self) -> Result<PathBuf> {
match &self.todo.file {
Some(p) => Ok(expand_tilde(p)),
None => Self::default_data_path(),
}
}
pub fn notes_path(&self) -> Result<PathBuf> {
match &self.notes.file {
Some(p) => Ok(expand_tilde(p)),
None => Ok(Self::default_data_dir()?.join("notes.toml")),
}
}
pub fn agenda_path(&self) -> Result<PathBuf> {
match &self.agenda.file {
Some(p) => Ok(expand_tilde(p)),
None => Ok(Self::default_data_dir()?.join("calendar.ics")),
}
}
pub fn stocks_path(&self) -> Result<PathBuf> {
match &self.stocks.file {
Some(p) => Ok(expand_tilde(p)),
None => Ok(Self::default_data_dir()?.join("watchlist.toml")),
}
}
pub fn zones_path() -> Result<PathBuf> {
Ok(Self::default_data_dir()?.join("zones.toml"))
}
pub fn update_cache_path() -> Result<PathBuf> {
Ok(crate::update::default_path(&Self::default_data_dir()?))
}
pub fn owned_data_files() -> Result<Vec<PathBuf>> {
let dir = Self::default_data_dir()?;
Ok(vec![
crate::state::default_path(&dir),
dir.join("todos.toml"),
dir.join("notes.toml"),
dir.join("watchlist.toml"),
dir.join("zones.toml"),
crate::update::default_path(&dir),
])
}
pub fn state_path() -> Result<PathBuf> {
Ok(crate::state::default_path(&Self::default_data_dir()?))
}
pub fn apply_state(&mut self, state: &crate::state::UiState) {
if let Some(units) = &state.weather_units
&& matches!(units.as_str(), "metric" | "imperial")
{
self.weather.units.clone_from(units);
}
if let Some(sort) = &state.todo_sort
&& sort.parse::<crate::task::SortMode>().is_ok()
{
self.todo.sort.clone_from(sort);
}
if let Some(show) = state.todo_show_completed {
self.todo.show_completed = show;
}
if let Some(show) = state.clocks_show_seconds {
self.clocks.show_seconds = show;
}
if let Some(file) = &state.agenda_file {
self.agenda.file = Some(std::path::PathBuf::from(file));
}
if let Some(location) = &state.weather_location {
self.weather.location.clone_from(location);
}
for (slot, saved) in [
(
&mut self.pomodoro.focus_minutes,
state.pomodoro_focus_minutes,
),
(
&mut self.pomodoro.short_break_minutes,
state.pomodoro_short_break_minutes,
),
(
&mut self.pomodoro.long_break_minutes,
state.pomodoro_long_break_minutes,
),
] {
if let Some(minutes) = saved {
*slot = minutes.clamp(1, crate::widgets::pomodoro::MAX_MINUTES);
}
}
}
pub fn apply_state_theme(&mut self, state: &crate::state::UiState, config_path: &Path) {
let Some(name) = state.theme.as_deref() else {
return;
};
let dir = crate::themes::user_dir(config_path);
if let Ok(theme) = crate::themes::resolve(name, dir.as_deref()) {
self.theme = theme;
}
}
}
fn stale_config_hint(error: &toml::de::Error, path: &Path) -> anyhow::Error {
const RENAMED: &[(&str, &str)] = &[
(
"forecast_days",
"`forecast_hours` — the forecast is hourly now",
),
("rx", "the `[theme.rx_gradient]` table"),
("tx", "the `[theme.tx_gradient]` table"),
];
let message = error.to_string();
for (old, replacement) in RENAMED {
if message.contains(&format!("`{old}`")) {
return anyhow::anyhow!(
"{message}\n\nThe config at {} was written by an older version \
of mirador: `{old}` was replaced by {replacement}.\n\nRun \
`mirador --migrate-config` to update it in place; your original \
is kept as a .bak file.",
path.display(),
);
}
}
anyhow::anyhow!(
"{message}\n\nin {}. Run `mirador --print-config` to see the current format.",
path.display()
)
}
fn expand_tilde(path: &Path) -> PathBuf {
let Ok(stripped) = path.strip_prefix("~") else {
return path.to_path_buf();
};
dirs::home_dir().map_or_else(|| path.to_path_buf(), |home| home.join(stripped))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_owned_files_are_the_ones_mirador_writes() {
let Ok(files) = Config::owned_data_files() else {
return;
};
let names: Vec<String> = files
.iter()
.map(|p| p.file_name().unwrap_or_default().to_string_lossy().into())
.collect();
for wanted in [
"state.toml",
"todos.toml",
"notes.toml",
"watchlist.toml",
"zones.toml",
"update-check.toml",
] {
assert!(names.iter().any(|n| n == wanted), "{wanted} must be reset");
}
assert!(
!names.iter().any(|n| n == "calendar.ics"),
"a factory reset must not touch a calendar mirador only ever reads: {names:?}"
);
let dir = files[0].parent().expect("a parent");
assert!(
files.iter().all(|p| p.parent() == Some(dir)),
"every owned file must live in mirador's own data directory: {files:?}"
);
}
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("mirador-reset-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn resetting_writes_the_defaults_and_keeps_the_old_config() {
let dir = scratch("keeps");
let path = dir.join("config.toml");
std::fs::write(&path, "theme = \"nord\"\n# an evening's work\n").unwrap();
let backup = Config::reset(&path)
.unwrap()
.expect("a backup must be kept");
assert_eq!(std::fs::read_to_string(&path).unwrap(), DEFAULT_CONFIG);
assert_eq!(
std::fs::read_to_string(&backup).unwrap(),
"theme = \"nord\"\n# an evening's work\n",
"the backup must be the config that was replaced, byte for byte"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_second_reset_does_not_destroy_the_first_backup() {
let dir = scratch("twice");
let path = dir.join("config.toml");
let original = "theme = \"nord\"\n# irreplaceable\n";
std::fs::write(&path, original).unwrap();
let first = Config::reset(&path).unwrap().expect("first backup");
let second = Config::reset(&path).unwrap().expect("second backup");
assert_ne!(first, second, "the second reset must not reuse the name");
assert_eq!(
std::fs::read_to_string(&first).unwrap(),
original,
"the user's real config must still be recoverable after two resets"
);
assert_eq!(
std::fs::read_to_string(&second).unwrap(),
DEFAULT_CONFIG,
"the second backup is what the first reset wrote"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn resetting_with_no_config_yet_writes_one_and_reports_no_backup() {
let dir = scratch("absent");
let path = dir.join("config.toml");
assert!(Config::reset(&path).unwrap().is_none());
assert_eq!(std::fs::read_to_string(&path).unwrap(), DEFAULT_CONFIG);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn what_a_reset_writes_is_a_loadable_config() {
let dir = scratch("loadable");
let path = dir.join("config.toml");
std::fs::write(&path, "this is not = = valid toml\n").unwrap();
Config::reset(&path).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
toml::from_str::<Config>(&text).expect("a reset config must load");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn shipped_default_config_parses() {
let config: Config =
toml::from_str(DEFAULT_CONFIG).expect("the bundled default config must always parse");
config
.validate()
.expect("the bundled default config must always validate");
}
#[test]
fn every_commented_out_default_is_a_key_that_still_exists() {
let is_commented_default = |line: &str| -> Option<String> {
let rest = line.strip_prefix('#')?;
let rest = rest.strip_prefix(' ').unwrap_or(rest);
if rest.starts_with(' ') {
return None;
}
let key = rest.split('=').next()?.trim();
let looks_like_a_key = !key.is_empty()
&& key
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
(looks_like_a_key && rest.contains('=')).then(|| rest.to_string())
};
let lines: Vec<&str> = DEFAULT_CONFIG.lines().collect();
let mut checked = 0;
for (index, line) in lines.iter().enumerate() {
let Some(uncommented) = is_commented_default(line) else {
continue;
};
checked += 1;
let mut edited = lines.clone();
edited[index] = &uncommented;
let text = edited.join("\n");
let parsed = toml::from_str::<Config>(&text).unwrap_or_else(|e| {
panic!(
"line {}: uncommenting `{}` does not parse, so the shipped \
config documents an option that no longer exists: {e}",
index + 1,
line.trim()
)
});
parsed.validate().unwrap_or_else(|e| {
panic!(
"line {}: uncommenting `{}` parses but does not validate: {e}",
index + 1,
line.trim()
)
});
}
assert!(
checked >= 6,
"only {checked} commented-out defaults were found; the shipped \
config had six, so either they have gone or this no longer \
recognises them"
);
}
#[test]
fn the_compatibility_promise_about_configs_holds() {
toml::from_str::<Config>(DEFAULT_CONFIG).expect("the shipped config parses");
let err = toml::from_str::<Config>("[weather]\nunits = \"metric\"\nunts = \"metric\"\n")
.expect_err("an unknown key must be refused, not ignored");
let message = err.to_string();
assert!(
message.contains("unts"),
"the error must name the key the user got wrong: `{message}`"
);
let bare: Config = toml::from_str("").expect("an empty config is valid");
bare.validate()
.expect("an empty config must validate, since every key has a default");
}
#[test]
fn the_rust_default_layout_matches_the_shipped_one() {
let shipped: Config = toml::from_str(DEFAULT_CONFIG).expect("must parse");
let shape = |layout: &Layout| -> Vec<(u16, Vec<(String, u16)>)> {
layout
.rows
.iter()
.map(|r| {
let panels = r
.panels
.iter()
.map(|p| (p.widget.clone(), p.width))
.collect();
(r.height, panels)
})
.collect()
};
assert_eq!(
shape(&shipped.layout),
shape(&Layout::default()),
"the shipped config and the Rust default describe different \
dashboards. Both are first impressions — the file on a true first \
run, the Rust default for any config that omits [layout] — so a \
gap here means deleting one section silently removes panels."
);
}
#[test]
fn the_default_layout_places_every_widget() {
let layout = Layout::default();
let placed: Vec<&str> = layout
.rows
.iter()
.flat_map(|r| r.panels.iter().map(|p| p.widget.as_str()))
.collect();
for widget in crate::widgets::WIDGET_NAMES {
assert!(
placed.contains(widget),
"the default layout does not place `{widget}`"
);
}
}
#[test]
fn empty_config_falls_back_to_defaults() {
let config: Config = toml::from_str("").expect("an empty config is valid");
assert_eq!(config.layout.rows.len(), 4);
assert!(config.validate().is_ok());
}
#[test]
fn unknown_widget_is_rejected_with_a_helpful_message() {
let config: Config =
toml::from_str("[layout]\nrows = [{ height = 1, panels = [{ widget = \"nope\" }] }]")
.expect("parses");
let err = config.validate().expect_err("must be rejected");
let msg = err.to_string();
assert!(msg.contains("unknown widget `nope`"), "got: {msg}");
assert!(
msg.contains("todo"),
"should list valid widgets, got: {msg}"
);
}
#[test]
fn an_explicit_plugin_can_supply_a_layout_name_and_opaque_settings() {
let source = r#"
plugins = [{
id = "example",
command = ["mirador-example", "--compact"],
config = { label = "local" }
}]
[layout]
rows = [{ height = 1, panels = [{ widget = "example" }] }]
"#;
let config: Config = toml::from_str(source).expect("plugin declaration parses");
config
.validate()
.expect("declared plugin is a known widget");
assert_eq!(
config.widget_names().last().map(String::as_str),
Some("example")
);
assert_eq!(config.plugins[0].config["label"].as_str(), Some("local"));
}
#[test]
fn plugin_ids_cannot_shadow_core_widgets_or_each_other() {
let collision: Config =
toml::from_str("plugins = [{ id = \"notes\", command = [\"anything\"] }]").unwrap();
assert!(
collision
.validate()
.unwrap_err()
.to_string()
.contains("conflicts with a built-in")
);
let duplicate: Config = toml::from_str(
"plugins = [\n { id = \"sample\", command = [\"one\"] },\n { id = \"sample\", command = [\"two\"] }\n]",
)
.unwrap();
assert!(
duplicate
.validate()
.unwrap_err()
.to_string()
.contains("more than once")
);
}
#[test]
fn plugin_commands_are_argv_not_platform_shell_strings() {
let config: Config = toml::from_str(
"plugins = [{ id = \"sample\", command = [\"python\", \"-m\", \"sample\"] }]",
)
.unwrap();
assert_eq!(config.plugins[0].command, ["python", "-m", "sample"]);
let missing: Config =
toml::from_str("plugins = [{ id = \"sample\", command = [] }]").unwrap();
assert!(
missing
.validate()
.unwrap_err()
.to_string()
.contains("no executable")
);
}
#[test]
fn a_key_from_an_older_version_is_rejected_with_a_migration_hint() {
let err = toml::from_str::<Config>("[weather]\nforecast_days = 4")
.map_err(|e| stale_config_hint(&e, Path::new("/tmp/config.toml")))
.expect_err("a removed key must not be silently ignored");
let message = format!("{err:#}");
assert!(message.contains("forecast_days"), "got: {message}");
assert!(message.contains("forecast_hours"), "got: {message}");
}
#[test]
fn an_unrecognised_key_names_itself_rather_than_being_ignored() {
let err = toml::from_str::<Config>("[weather]\nwibble = 4")
.map_err(|e| stale_config_hint(&e, Path::new("/tmp/config.toml")))
.expect_err("typos must be reported");
assert!(format!("{err:#}").contains("wibble"));
}
#[test]
fn a_misspelled_theme_key_is_reported_rather_than_ignored() {
for source in [
"[theme]\nacent = \"#ff0000\"",
"[theme.rx_gradient]\nstrat = \"green\"",
] {
let err = toml::from_str::<Config>(source)
.map_err(|e| stale_config_hint(&e, Path::new("/tmp/config.toml")))
.unwrap_err();
let message = format!("{err:#}");
assert!(
message.contains("acent") || message.contains("strat"),
"{source} was accepted, or the error did not name the key: {message}"
);
}
}
#[test]
fn the_pre_0_1_0_theme_keys_reach_their_migration_hint() {
for key in ["rx", "tx"] {
let err = toml::from_str::<Config>(&format!("[theme]\n{key} = \"green\""))
.map_err(|e| stale_config_hint(&e, Path::new("/tmp/config.toml")))
.expect_err("an old theme key must be rejected");
let message = format!("{err:#}");
assert!(
message.contains("--migrate-config"),
"`{key}` did not reach the migration hint: {message}"
);
assert!(
message.contains(&format!("[theme.{key}_gradient]")),
"`{key}` did not name its replacement: {message}"
);
}
}
#[test]
fn an_absurd_poll_interval_is_rejected_rather_than_wrapping() {
let config: Config =
toml::from_str(&format!("[weather]\nrefresh_minutes = {}", u64::MAX)).expect("parses");
let err = config.validate().expect_err("must be rejected");
assert!(
format!("{err:#}").contains("refresh_minutes"),
"the error must name the key: {err:#}"
);
let config: Config =
toml::from_str(&format!("[stocks]\nrefresh_secs = {}", u64::MAX)).expect("parses");
assert!(config.validate().is_err());
assert!(Config::default().validate().is_ok());
let config: Config = toml::from_str("[weather]\nrefresh_minutes = 1440").expect("parses");
assert!(config.validate().is_ok(), "a day is a legitimate setting");
}
#[test]
fn bad_units_are_rejected() {
let config: Config = toml::from_str("[weather]\nunits = \"kelvin\"").expect("parses");
assert!(config.validate().is_err());
}
#[test]
fn bad_colour_names_are_rejected_at_parse_time() {
let err = toml::from_str::<Config>("[theme]\naccent = \"chartreuse\"")
.expect_err("must be rejected");
assert!(err.to_string().contains("not a colour"), "got: {err}");
}
#[test]
fn tilde_expands_to_home() {
if let Some(home) = dirs::home_dir() {
assert_eq!(expand_tilde(Path::new("~/x.toml")), home.join("x.toml"));
}
assert_eq!(expand_tilde(Path::new("/abs/x")), PathBuf::from("/abs/x"));
}
}