use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::store::strip_comment;
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Report {
pub changes: Vec<String>,
pub backup: Option<PathBuf>,
}
impl Report {
pub fn is_empty(&self) -> bool {
self.changes.is_empty()
}
}
enum Rule {
Replace {
section: &'static str,
from: &'static str,
to: &'static str,
value: &'static str,
why: &'static str,
},
Retire {
section: &'static str,
key: &'static str,
why: &'static str,
},
}
const RULES: &[Rule] = &[
Rule::Replace {
section: "weather",
from: "forecast_days",
to: "forecast_hours",
value: "8",
why: "the forecast is hourly now, and a day count does not convert to hours, so the default of 8 is used",
},
Rule::Replace {
section: "notes",
from: "side_by_side_min_width",
to: "preview",
value: "\"below\"",
why: "the note body is placed by name now, not by a width threshold",
},
Rule::Retire {
section: "theme",
key: "rx",
why: "replaced by the [theme.rx_gradient] table",
},
Rule::Retire {
section: "theme",
key: "tx",
why: "replaced by the [theme.tx_gradient] table",
},
];
impl Rule {
fn section(&self) -> &'static str {
match self {
Self::Replace { section, .. } | Self::Retire { section, .. } => section,
}
}
fn key(&self) -> &'static str {
match self {
Self::Replace { from, .. } => from,
Self::Retire { key, .. } => key,
}
}
fn change(&self) -> String {
match self {
Self::Replace {
section,
from,
to,
why,
..
} => format!("[{section}] {from} -> {to}: {why}"),
Self::Retire { section, key, why } => {
format!("[{section}] {key} commented out: {why}")
}
}
}
fn proposal(&self) -> String {
match self {
Self::Replace {
section,
from,
to,
value,
why,
} => format!("[{section}] {from} will become {to} = {value}: {why}"),
Self::Retire { section, key, why } => {
format!("[{section}] {key} will be commented out: {why}")
}
}
}
}
fn rule_for(section: &str, line: &str) -> Option<&'static Rule> {
let key = key_of(line)?;
RULES
.iter()
.find(|rule| rule.section() == section && rule.key() == key)
}
pub fn change_at(contents: &str, at: usize) -> Option<String> {
let (_, section, line) = lines_in_sections(contents).find(|&(end, ..)| at < end)?;
rule_for(section, line).map(Rule::proposal)
}
#[cfg(test)]
pub fn stale_keys() -> impl Iterator<Item = (&'static str, &'static str)> {
RULES.iter().map(|rule| (rule.section(), rule.key()))
}
fn section_of(line: &str) -> Option<&str> {
let trimmed = strip_comment(line).trim();
let inner = trimmed.strip_prefix('[')?.strip_suffix(']')?;
let inner = match inner.strip_prefix('[') {
Some(rest) => rest.strip_suffix(']')?,
None => inner,
};
Some(inner.trim())
}
fn key_of(line: &str) -> Option<&str> {
let (key, _) = strip_comment(line).split_once('=')?;
Some(key.trim().trim_matches('"'))
}
fn lines_in_sections(contents: &str) -> impl Iterator<Item = (usize, &str, &str)> {
let mut section = "";
let mut end = 0;
contents.split_inclusive('\n').map(move |raw| {
end += raw.len();
let line = raw
.strip_suffix('\n')
.map_or(raw, |line| line.strip_suffix('\r').unwrap_or(line));
if let Some(name) = section_of(line) {
section = name;
}
(end, section, line)
})
}
pub fn migrate_text(contents: &str) -> (String, Vec<String>) {
let newline = crate::store::line_ending(contents);
let mut out = String::with_capacity(contents.len());
let mut changes = Vec::new();
for (_, section, line) in lines_in_sections(contents) {
let Some(rule) = rule_for(section, line) else {
out.push_str(line);
out.push_str(newline);
continue;
};
match rule {
Rule::Replace {
from, to, value, ..
} => {
let indent = &line[..line.len() - line.trim_start().len()];
let eq_col = line.find('=').unwrap_or_default();
let pad = eq_col.saturating_sub(indent.len() + to.len()).max(1);
let _ = write!(
out,
"{indent}{to}{:pad$}= {value} # migrated from {from}",
""
);
}
Rule::Retire { why, .. } => {
let _ = write!(out, "# {} # removed: {why}", line.trim());
}
}
out.push_str(newline);
changes.push(rule.change());
}
(out, changes)
}
#[derive(Debug)]
pub enum Refusal {
NothingKnown,
StillBroken {
error: toml::de::Error,
line: Option<usize>,
},
}
pub fn migrate(contents: &str) -> std::result::Result<(String, Vec<String>), Refusal> {
let (migrated, changes) = migrate_text(contents);
if changes.is_empty() {
return Err(Refusal::NothingKnown);
}
if let Err(error) = toml::from_str::<crate::config::Config>(&migrated) {
let line = error.span().and_then(|span| {
let before = migrated.get(..span.start)?;
Some(before.matches('\n').count() + 1)
});
return Err(Refusal::StillBroken { error, line });
}
Ok((migrated, changes))
}
pub fn migrate_file(path: &Path) -> Result<Report> {
let contents = std::fs::read_to_string(path)
.with_context(|| format!("reading config {}", path.display()))?;
if toml::from_str::<crate::config::Config>(&contents).is_ok() {
return Ok(Report::default());
}
let (migrated, changes) = migrate(&contents).map_err(|refusal| match refusal {
Refusal::NothingKnown => anyhow::anyhow!(
"the config at {} does not parse, and none of the problems are ones \
this version knows how to migrate. Run `mirador --print-config` to \
see the current format.",
path.display()
),
Refusal::StillBroken { error, .. } => anyhow::anyhow!(
"migrating {} did not produce a usable config: {error}\n\nThe original \
file has been left untouched.",
path.display()
),
})?;
let backup = crate::store::free_backup_path(path);
std::fs::copy(path, &backup)
.with_context(|| format!("backing up {} to {}", path.display(), backup.display()))?;
crate::store::write_atomic(path, &migrated)
.with_context(|| format!("writing migrated config to {}", path.display()))?;
Ok(Report {
changes,
backup: Some(backup),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::testing::TempDir;
#[test]
fn a_crlf_config_stays_crlf() {
let lf = "[weather]\nlocation = \"Oslo\"\nforecast_days = 4\n\n[theme]\nrx = \"green\"\n";
let (lf_out, lf_changes) = migrate(lf).expect("the LF config migrates");
let (out, changes) = migrate(&lf.replace('\n', "\r\n"))
.unwrap_or_else(|refusal| panic!("the CRLF config was refused: {refusal:?}"));
assert_eq!(changes, lf_changes);
assert_eq!(changes.len(), 2, "both rules must act: {changes:?}");
assert_eq!(out, lf_out.replace('\n', "\r\n"));
}
#[test]
fn a_stale_key_deep_in_a_crlf_config_is_found_on_its_own_line() {
let shipped = crate::config::DEFAULT_CONFIG.replace("\r\n", "\n");
let lf = shipped.replacen("\nforecast_hours ", "\nforecast_days ", 1);
assert_ne!(
lf, shipped,
"the shipped config sets no forecast_hours to make stale"
);
let crlf = lf.replace('\n', "\r\n");
let error = toml::from_str::<crate::config::Config>(&crlf).expect_err("a stale key");
let at = error
.span()
.expect("the parser says where it stopped")
.start;
let line = crlf[..at].matches('\n').count() + 1;
assert!(
line > 100,
"line {line} is too shallow for a drift of a byte a line to leave it"
);
let change = change_at(&crlf, at);
assert!(
change
.as_deref()
.is_some_and(|c| c.starts_with("[weather] forecast_days will become")),
"line {line} of a CRLF config was read as {change:?}"
);
}
#[test]
fn an_lf_config_stays_lf() {
let (out, _) = migrate_text("[theme]\nrx = \"green\"\n");
assert_eq!(out.matches('\r').count(), 0);
}
#[test]
fn a_renamed_key_keeps_the_alignment_of_its_block() {
let before = "[weather]\n\
location = \"Boston\"\n\
forecast_days = 4\n\
refresh_minutes = 30\n";
let (after, _) = migrate_text(before);
let migrated = after
.lines()
.find(|l| l.starts_with("forecast_hours"))
.expect("the key was renamed");
assert_eq!(
migrated.find('='),
Some(16),
"`=` moved out of the column its neighbours use: {migrated:?}"
);
}
#[test]
fn a_renamed_key_keeps_its_indentation_and_always_spaces_the_equals() {
let (after, _) = migrate_text("[weather]\n forecast_days=4\n");
let migrated = after
.lines()
.find(|l| l.trim_start().starts_with("forecast_hours"))
.expect("the key was renamed");
assert!(
migrated.starts_with(" forecast_hours = "),
"{migrated:?}"
);
}
#[test]
fn section_headers_are_recognised() {
assert_eq!(section_of("[weather]"), Some("weather"));
assert_eq!(section_of(" [theme] "), Some("theme"));
assert_eq!(section_of("[[layout.rows]]"), Some("layout.rows"));
assert_eq!(section_of("[theme.rx_gradient]"), Some("theme.rx_gradient"));
assert_eq!(section_of("key = 1"), None);
assert_eq!(section_of("# [weather]"), None);
assert_eq!(section_of("[theme] # colours"), Some("theme"));
assert_eq!(section_of("[theme] # see [notes]"), Some("theme"));
assert_eq!(section_of("[\"a#b\"] # c"), Some("\"a#b\""));
assert_eq!(
section_of("[plugins.config.'a#b']"),
Some("plugins.config.'a#b'")
);
assert_eq!(
section_of("[plugins.config.\"a\\\"#b\"]"),
Some("plugins.config.\"a\\\"#b\"")
);
assert_eq!(
section_of("[plugins.config.'C:\\dir\\'] # c"),
Some("plugins.config.'C:\\dir\\'")
);
}
#[test]
fn assignments_are_recognised_and_comments_are_not() {
assert_eq!(key_of("forecast_days = 4"), Some("forecast_days"));
assert_eq!(key_of(" units = \"imperial\""), Some("units"));
assert_eq!(key_of("# forecast_days = 4"), None);
assert_eq!(key_of(""), None);
assert_eq!(key_of(" "), None);
assert_eq!(key_of("rx = \"green\" # was red"), Some("rx"));
assert_eq!(key_of("\"a#b\" = 1"), Some("a#b"));
}
#[test]
fn a_commented_header_still_scopes_its_keys() {
let text = "[theme] # my colours\nrx = \"green\"\n";
let (out, changes) = migrate(text).unwrap_or_else(|refusal| panic!("refused: {refusal:?}"));
assert_eq!(changes.len(), 1, "{changes:?}");
assert!(out.contains("# rx = \"green\""), "got:\n{out}");
assert!(out.starts_with("[theme] # my colours\n"), "got:\n{out}");
let at = text.find("rx").expect("the key is in the text");
let change = change_at(text, at);
assert!(
change
.as_deref()
.is_some_and(|c| c.starts_with("[theme] rx will be commented out")),
"the line under a commented header was read as {change:?}"
);
}
#[test]
fn a_hash_inside_a_quoted_table_name_is_not_a_comment() {
for name in ["'chan#1'", "\"a\\\"#b\""] {
let text = format!(
"[weather]\nforecast_days = 4\n\
[[plugins]]\nid = \"probe\"\ncommand = [\"/usr/bin/true\"]\n\
[theme]\nborder = \"red\"\n\
[plugins.config.{name}]\nrx = \"green\"\n"
);
let (out, changes) =
migrate(&text).unwrap_or_else(|refusal| panic!("{name}: refused: {refusal:?}"));
assert_eq!(changes.len(), 1, "{name}: {changes:?}");
assert!(
changes[0].starts_with("[weather] forecast_days"),
"{name}: {changes:?}"
);
assert!(out.ends_with("\nrx = \"green\"\n"), "{name}: got:\n{out}");
let at = text.rfind("rx").expect("the key is in the text");
assert_eq!(
change_at(&text, at),
None,
"{name}: rx was read as a theme key"
);
}
}
#[test]
fn a_renamed_key_is_rewritten_in_place() {
let (out, changes) = migrate_text("[weather]\nlocation = \"Boston\"\nforecast_days = 4\n");
assert!(out.contains("forecast_hours = 8"), "got:\n{out}");
assert!(
!out.contains("forecast_days = 4"),
"old key survived:\n{out}"
);
assert!(
out.contains("location = \"Boston\""),
"settings must survive"
);
assert_eq!(changes.len(), 1);
assert!(changes[0].contains("forecast_hours"));
}
#[test]
fn a_retired_key_is_commented_out_with_its_value_visible() {
let (out, changes) = migrate_text("[theme]\nrx = \"green\"\n");
assert!(out.contains("# rx = \"green\""), "got:\n{out}");
assert_eq!(changes.len(), 1);
assert!(changes[0].contains("rx_gradient"));
}
#[test]
fn rules_are_scoped_to_their_section() {
let (out, changes) = migrate_text("[network]\nrx = \"green\"\n");
assert!(out.contains("rx = \"green\""));
assert!(!out.contains("# rx"), "must not touch another section");
assert!(changes.is_empty(), "{changes:?}");
}
#[test]
fn comments_and_blank_lines_survive_untouched() {
let input =
"# my notes\n\n[weather]\n# which city\nlocation = \"Boston\"\nforecast_days = 4\n";
let (out, _) = migrate_text(input);
assert!(out.contains("# my notes"));
assert!(out.contains("# which city"));
assert!(out.contains("\n\n"), "blank lines must survive");
}
#[test]
fn a_file_with_nothing_to_migrate_is_returned_unchanged() {
let input = "[weather]\nlocation = \"Boston\"\nforecast_hours = 8\n";
let (out, changes) = migrate_text(input);
assert_eq!(out, input);
assert!(changes.is_empty(), "{changes:?}");
}
#[test]
fn a_migrated_v1_config_actually_parses() {
let v1 = r#"
[general]
tick_rate_ms = 250
[theme]
border = "dark-gray"
border_focused = "cyan"
rx = "green"
tx = "magenta"
[clocks]
time_format = "%H:%M:%S"
[weather]
location = "Boston, Massachusetts"
units = "imperial"
forecast_days = 4
refresh_minutes = 30
"#;
assert!(
toml::from_str::<crate::config::Config>(v1).is_err(),
"the v1 config must be rejected, or there is nothing to migrate"
);
let (migrated, changes) = migrate_text(v1);
assert_eq!(changes.len(), 3, "forecast_days, rx and tx");
toml::from_str::<crate::config::Config>(&migrated).expect("a migrated config must load");
assert!(migrated.contains("Boston, Massachusetts"));
assert!(migrated.contains("refresh_minutes = 30"));
assert!(migrated.contains("border_focused = \"cyan\""));
}
#[test]
fn migrating_a_healthy_file_is_a_no_op_and_leaves_no_backup() {
let dir = TempDir::new("migrate");
let path = dir.join("config.toml");
std::fs::write(&path, crate::config::DEFAULT_CONFIG).unwrap();
let report = migrate_file(&path).expect("the shipped default must be healthy");
assert!(report.is_empty());
assert!(report.backup.is_none());
assert!(!path.with_extension("toml.bak").exists());
}
#[test]
fn migrating_a_stale_file_backs_it_up_and_rewrites_it() {
let dir = TempDir::new("migrate2");
let path = dir.join("config.toml");
std::fs::write(&path, "[weather]\nlocation = \"Oslo\"\nforecast_days = 4\n").unwrap();
let report = migrate_file(&path).expect("must migrate");
assert!(!report.is_empty());
let backup = report.backup.expect("a backup must be written");
assert!(backup.exists());
assert!(
std::fs::read_to_string(&backup)
.unwrap()
.contains("forecast_days"),
"the backup must be the original"
);
let now = std::fs::read_to_string(&path).unwrap();
assert!(now.contains("forecast_hours"));
assert!(now.contains("Oslo"), "settings must survive");
toml::from_str::<crate::config::Config>(&now).expect("the result must load");
}
#[test]
fn a_migration_does_not_overwrite_an_earlier_backup() {
let dir = TempDir::new("migrate4");
let path = dir.join("config.toml");
let earlier = dir.join("config.toml.bak");
std::fs::write(&earlier, "# the config a reset set aside\n").unwrap();
std::fs::write(&path, "[weather]\nlocation = \"Oslo\"\nforecast_days = 4\n").unwrap();
let report = migrate_file(&path).expect("must migrate");
let kept = std::fs::read_to_string(&earlier).unwrap();
let backup = report.backup.expect("a backup must be written");
let original = std::fs::read_to_string(&backup).unwrap();
assert_eq!(
kept, "# the config a reset set aside\n",
"the earlier backup was overwritten"
);
assert_ne!(backup, earlier, "the new backup needed a name of its own");
assert!(
original.contains("forecast_days"),
"the new backup must be the original: {original:?}"
);
}
#[test]
fn an_unrecognisable_failure_leaves_the_file_alone() {
let dir = TempDir::new("migrate3");
let path = dir.join("config.toml");
let original = "[weather]\nthis is not toml =\n";
std::fs::write(&path, original).unwrap();
assert!(migrate_file(&path).is_err());
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
original,
"a file we cannot fix must not be touched"
);
assert!(!path.with_extension("toml.bak").exists());
}
}