use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::Deserialize;
use crate::theme::Theme;
pub const DEFAULT_CONFIG: &str = include_str!("../assets/default_config.toml");
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub general: General,
pub theme: Theme,
pub layout: Layout,
pub clocks: ClocksConfig,
pub weather: WeatherConfig,
pub todo: TodoConfig,
pub notes: NotesConfig,
pub stocks: StocksConfig,
pub calendar: CalendarConfig,
pub cpu: CpuConfig,
pub network: NetworkConfig,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct General {
pub tick_rate_ms: u64,
pub show_borders: bool,
pub show_status_bar: bool,
pub mouse: bool,
}
impl Default for General {
fn default() -> Self {
Self {
tick_rate_ms: 250,
show_borders: true,
show_status_bar: true,
mouse: true,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Layout {
pub rows: Vec<LayoutRow>,
}
impl Default for Layout {
fn default() -> Self {
Self {
rows: vec![
LayoutRow {
height: 30,
panels: vec![
LayoutPanel {
widget: "clocks".into(),
width: 40,
},
LayoutPanel {
widget: "weather".into(),
width: 60,
},
],
},
LayoutRow {
height: 45,
panels: vec![LayoutPanel {
widget: "todo".into(),
width: 100,
}],
},
LayoutRow {
height: 25,
panels: vec![
LayoutPanel {
widget: "cpu".into(),
width: 50,
},
LayoutPanel {
widget: "network".into(),
width: 50,
},
],
},
],
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct LayoutRow {
pub height: u16,
pub panels: Vec<LayoutPanel>,
}
impl Default for LayoutRow {
fn default() -> Self {
Self {
height: 1,
panels: Vec::new(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct LayoutPanel {
pub widget: String,
pub width: u16,
}
impl Default for LayoutPanel {
fn default() -> Self {
Self {
widget: String::new(),
width: 1,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ClocksConfig {
pub zones: Vec<ClockZone>,
pub time_format: String,
pub date_format: String,
pub show_offset: bool,
pub show_seconds: bool,
}
impl Default for ClocksConfig {
fn default() -> Self {
Self {
zones: vec![
ClockZone {
label: "Local".into(),
timezone: "local".into(),
},
ClockZone {
label: "UTC".into(),
timezone: "UTC".into(),
},
ClockZone {
label: "London".into(),
timezone: "Europe/London".into(),
},
ClockZone {
label: "Tokyo".into(),
timezone: "Asia/Tokyo".into(),
},
],
time_format: "%H:%M:%S".into(),
date_format: "%A %d %B".into(),
show_offset: true,
show_seconds: true,
}
}
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub struct ClockZone {
pub label: String,
pub timezone: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct WeatherConfig {
pub location: String,
pub latitude: Option<f64>,
pub longitude: Option<f64>,
pub units: String,
pub forecast_hours: u8,
pub refresh_minutes: u64,
}
impl Default for WeatherConfig {
fn default() -> Self {
Self {
location: "Boston, Massachusetts".into(),
latitude: None,
longitude: None,
units: "imperial".into(),
forecast_hours: 8,
refresh_minutes: 30,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TodoConfig {
pub file: Option<PathBuf>,
pub show_completed: bool,
pub sort: String,
pub date_format: String,
pub horizon_days: u32,
}
impl Default for TodoConfig {
fn default() -> Self {
Self {
file: None,
show_completed: false,
sort: "smart".into(),
date_format: "%a %d %b".into(),
horizon_days: 0,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct StocksConfig {
pub symbols: Vec<String>,
pub file: Option<PathBuf>,
pub source: String,
pub refresh_secs: u64,
pub stagger_ms: u64,
pub show_sparkline: bool,
}
impl Default for StocksConfig {
fn default() -> Self {
Self {
symbols: vec!["AAPL".into(), "MSFT".into(), "^GSPC".into()],
file: None,
source: "yahoo".to_string(),
refresh_secs: 120,
stagger_ms: 400,
show_sparkline: true,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct NotesConfig {
pub file: Option<PathBuf>,
pub date_format: String,
pub preview: String,
}
impl Default for NotesConfig {
fn default() -> Self {
Self {
file: None,
date_format: "%d %b".to_string(),
preview: "below".to_string(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CalendarConfig {
pub months: u8,
pub week_starts: String,
}
impl Default for CalendarConfig {
fn default() -> Self {
Self {
months: 2,
week_starts: "sunday".to_string(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CpuConfig {
pub history: usize,
pub sample_secs: u64,
pub show_per_core: bool,
pub warn_pct: f32,
pub critical_pct: f32,
}
impl Default for CpuConfig {
fn default() -> Self {
Self {
history: 120,
sample_secs: 1,
show_per_core: true,
warn_pct: 70.0,
critical_pct: 90.0,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct NetworkConfig {
pub interfaces: Vec<String>,
pub history: usize,
pub sample_secs: u64,
}
impl Default for NetworkConfig {
fn default() -> Self {
Self {
interfaces: Vec::new(),
history: 120,
sample_secs: 1,
}
}
}
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() {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating config directory {}", parent.display()))?;
}
std::fs::write(&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 config: Self = toml::from_str(&raw).map_err(|e| stale_config_hint(&e, &path))?;
config.validate()?;
Ok((config, path))
}
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"))
}
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
);
}
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 stocks_path(&self) -> Result<PathBuf> {
match &self.stocks.file {
Some(p) => Ok(expand_tilde(p)),
None => Ok(Self::default_data_dir()?.join("watchlist.toml")),
}
}
}
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 empty_config_falls_back_to_defaults() {
let config: Config = toml::from_str("").expect("an empty config is valid");
assert_eq!(config.layout.rows.len(), 3);
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 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"));
}
}