use std::path::{Path, PathBuf};
use anyhow::{Context as _, bail};
use argonauts_core::settings::mods::{check, save};
use toml_edit::{DocumentMut, Item, Table, TableLike, Value};
pub(crate) fn mod_path(dir: &Path, name: &str) -> PathBuf {
save::path_of(dir, name)
}
fn displaced_by(key: &str) -> &'static [&'static str] {
match key {
"data.source" => &["command", "every", "timeout"],
"data.command" => &["source"],
_ => &[],
}
}
fn put(table: &mut dyn TableLike, key: &str, mut value: Value) {
match table.get_mut(key).and_then(Item::as_value_mut) {
Some(old) => {
*value.decor_mut() = old.decor().clone();
*old = value;
}
None => {
table.insert(key, Item::Value(value));
}
}
}
fn apply(text: &str, pairs: &[(String, String)]) -> anyhow::Result<String> {
let mut doc: DocumentMut = text.parse().context("the file isn't valid TOML")?;
for (key, value) in pairs {
let value: Value = value
.parse()
.map_err(|err| anyhow::anyhow!("{key}: {value} isn't a TOML value: {err}"))?;
match key.split_once('.') {
None => put(doc.as_table_mut(), key, value),
Some((table, leaf)) => {
let slot = &mut doc[table];
if slot.is_none() {
*slot = Item::Table(Table::new());
}
let Some(table_like) = slot.as_table_like_mut() else {
bail!("{table} isn't a table in this file");
};
put(table_like, leaf, value);
for gone in displaced_by(key) {
table_like.remove(gone);
}
}
}
}
Ok(doc.to_string())
}
pub(crate) fn save(
dir: &Path,
name: &str,
start: &str,
pairs: &[(String, String)],
) -> anyhow::Result<PathBuf> {
let path = mod_path(dir, name);
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => start.to_owned(),
Err(err) => return Err(err).with_context(|| format!("reading {}", path.display())),
};
let text = apply(&text, pairs)?;
let found = check::problems(name, &text);
if !found.is_empty() {
bail!(
"that would leave a file that doesn't load: {}",
found.join("; ")
);
}
write(&path, &text)?;
Ok(path)
}
pub(crate) fn write(path: &Path, text: &str) -> anyhow::Result<()> {
save::write_whole(path, text).with_context(|| format!("saving {}", path.display()))
}
pub(crate) fn reset(dir: &Path, name: &str) -> anyhow::Result<bool> {
let path = mod_path(dir, name);
match std::fs::remove_file(&path) {
Ok(()) => Ok(true),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(err) => Err(err).with_context(|| format!("deleting {}", path.display())),
}
}
#[cfg(test)]
mod tests {
use argonauts_core::settings::mods::{ModBody, ModForm, ModSource, bottom_line, builtin};
use super::*;
fn pair(key: &str, value: &str) -> (String, String) {
(key.to_owned(), value.to_owned())
}
fn context() -> &'static str {
builtin::builtin("context").unwrap().text
}
#[test]
fn a_change_to_a_built_in_card_writes_a_commented_copy() {
let dir = tempfile::tempdir().unwrap();
let path = save(
dir.path(),
"context",
context(),
&[pair("form", "\"line\"")],
)
.unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(
text.starts_with("# How full the main session's context is"),
"{text}"
);
assert!(text.contains("# full · compact · line"), "{text}");
let file = check::read("context", &text).unwrap();
assert_eq!(file.form, ModForm::Line);
assert_eq!(file.body, ModBody::Stack);
}
#[test]
fn a_second_change_goes_into_the_file_that_is_there() {
let dir = tempfile::tempdir().unwrap();
save(
dir.path(),
"context",
context(),
&[pair("form", "\"line\"")],
)
.unwrap();
let path = save(
dir.path(),
"context",
"never read",
&[pair("body", "\"meters\""), pair("levels.warn", "60.0")],
)
.unwrap();
let file = check::read("context", &std::fs::read_to_string(path).unwrap()).unwrap();
assert_eq!(file.form, ModForm::Line);
assert_eq!(file.body, ModBody::Meters);
assert_eq!(file.levels.warn, 60.0);
}
#[test]
fn a_source_and_a_command_replace_each_other() {
let dir = tempfile::tempdir().unwrap();
let checks = builtin::STARTERS
.iter()
.find(|(name, _)| *name == "checks")
.unwrap()
.1;
let path = save(
dir.path(),
"checks",
checks,
&[pair("data.source", "\"spend\"")],
)
.unwrap();
let text = std::fs::read_to_string(&path).unwrap();
let file = check::read("checks", &text).unwrap();
assert!(matches!(file.data, ModSource::Builtin { .. }), "{text}");
let pairs = [
pair("data.command", "[\"./ci.sh\"]"),
pair("data.every", "\"5m\""),
];
let path = save(dir.path(), "checks", "", &pairs).unwrap();
let file = check::read("checks", &std::fs::read_to_string(path).unwrap()).unwrap();
assert!(
matches!(&file.data, ModSource::Command { command, .. } if command == &["./ci.sh"])
);
}
#[test]
fn nothing_is_written_when_the_result_wouldnt_load() {
let dir = tempfile::tempdir().unwrap();
let err = save(dir.path(), "context", context(), &[pair("body", "\"pie\"")]).unwrap_err();
assert!(
format!("{err:#}").contains("that would leave a file that doesn't load: body is"),
"{err:#}"
);
assert!(!mod_path(dir.path(), "context").exists());
let err = save(dir.path(), "context", context(), &[pair("form", "line")]).unwrap_err();
assert!(format!("{err:#}").contains("isn't a TOML value"), "{err:#}");
}
#[test]
fn the_bottom_line_s_file_is_checked_by_its_own_rules() {
let dir = tempfile::tempdir().unwrap();
let start = builtin::builtin(builtin::BOTTOM_LINE).unwrap().text;
let path = save(
dir.path(),
builtin::BOTTOM_LINE,
start,
&[
pair("plan-usage.as", "\"left\""),
pair("right", "[\"plan-usage\"]"),
],
)
.unwrap();
let text = std::fs::read_to_string(path).unwrap();
assert!(text.contains("# The line under the input."), "{text}");
let file = bottom_line::read(&text).unwrap();
assert_eq!(file.right, ["plan-usage"]);
assert_eq!(
file.plan_usage.r#as,
argonauts_core::settings::bottom_line::PlanUsageAs::Left
);
let err = save(
dir.path(),
builtin::BOTTOM_LINE,
start,
&[pair("plan-usage.windows", "\"all\"")],
);
let said = format!("{:#}", err.unwrap_err());
assert!(
said.contains("doesn't load: plan-usage.windows: unknown variant `all`"),
"{said}"
);
}
#[test]
fn a_reset_deletes_the_file_once() {
let dir = tempfile::tempdir().unwrap();
save(
dir.path(),
"context",
context(),
&[pair("form", "\"line\"")],
)
.unwrap();
assert!(reset(dir.path(), "context").unwrap());
assert!(!reset(dir.path(), "context").unwrap());
}
}