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 widgets;
#[allow(unused_imports)]
pub use layout::{Layout, LayoutPanel, LayoutRow};
#[allow(unused_imports)]
pub use widgets::{
AgendaConfig, 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,
#[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 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 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"))
}
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<()> {
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 !crate::widgets::is_known_widget(&panel.widget) {
anyhow::bail!(
"unknown widget `{}`. Available widgets: {}.",
panel.widget,
crate::widgets::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 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 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 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 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"));
}
}