use std::path::{Path, PathBuf};
use thiserror::Error;
use toml_edit::{DocumentMut, Item, Table, Value};
use super::DEFAULT_CONFIG_TOML;
pub const CONFIG_FILE_NAME: &str = "trail.toml";
#[derive(Debug, Error)]
pub enum PersistError {
#[error("could not read {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("{path} is not valid TOML, so it was left alone: {source}")]
Parse {
path: PathBuf,
#[source]
source: Box<toml_edit::TomlError>,
},
#[error("could not write {path}: {source}")]
Write {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
#[derive(Debug, Default)]
pub struct SessionSettings {
pub file: Option<PathBuf>,
pub remember_in: Option<PathBuf>,
changes: Vec<(String, String)>,
}
impl SessionSettings {
pub fn record(&mut self, key: &str, value: &str) {
let Some(full) = canonical_key(key) else {
return;
};
self.changes.retain(|(k, _)| *k != full);
self.changes.push((full, value.trim().to_owned()));
}
pub fn changes(&self) -> &[(String, String)] {
&self.changes
}
pub fn clear(&mut self) {
self.changes.clear();
}
}
const ALIASES: &[(&str, &str)] = &[
("editor", "general"),
("shell", "general"),
("shell_pause", "general"),
("delete_mode", "general"),
("text_sync_threshold_kb", "general"),
("show_version", "general"),
("git_status_enabled", "general"),
("fs_watch_debounce_ms", "general"),
("sort_by", "navigation"),
("sort_reverse", "navigation"),
("dirs_first", "navigation"),
("entry_details", "navigation"),
("scroll_margin", "navigation"),
("image_protocol", "preview"),
("image_cell_width", "preview"),
("image_cell_height", "preview"),
("max_lines", "preview"),
("confirm_quit", "terminal"),
("confirm_close", "terminal"),
("default_profile", "terminal"),
];
pub fn canonical_key(key: &str) -> Option<String> {
if key.contains('.') {
return Some(key.to_owned());
}
ALIASES
.iter()
.find(|(alias, _)| *alias == key)
.map(|(alias, section)| format!("{section}.{alias}"))
}
pub fn target_file(loaded: Option<&Path>, config_dir: Option<&Path>) -> Option<PathBuf> {
loaded
.map(Path::to_path_buf)
.or_else(|| config_dir.map(|dir| dir.join(CONFIG_FILE_NAME)))
}
pub fn write_settings(path: &Path, changes: &[(String, String)]) -> Result<(), PersistError> {
let existing = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(source) => {
return Err(PersistError::Read {
path: path.to_owned(),
source,
})
}
};
let mut doc: DocumentMut = existing.parse().map_err(|source| PersistError::Parse {
source: Box::new(source),
path: path.to_owned(),
})?;
let defaults: DocumentMut = DEFAULT_CONFIG_TOML.parse().unwrap_or_default();
for (key, raw) in changes {
let (sections, name) = match key.rsplit_once('.') {
Some(split) => split,
None => continue,
};
let value = typed_value(lookup(&defaults, sections, name), raw);
if let Some(table) = table_at(doc.as_table_mut(), sections) {
table[name] = Item::Value(value);
}
}
let write_err = |source| PersistError::Write {
path: path.to_owned(),
source,
};
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(write_err)?;
}
let tmp = path.with_extension("toml.tmp");
std::fs::write(&tmp, doc.to_string()).map_err(write_err)?;
std::fs::rename(&tmp, path).map_err(write_err)
}
fn lookup<'a>(doc: &'a DocumentMut, sections: &str, name: &str) -> Option<&'a Item> {
let mut item = doc.as_item();
for part in sections.split('.') {
item = item.get(part)?;
}
item.get(name)
}
fn table_at<'a>(table: &'a mut Table, sections: &str) -> Option<&'a mut Table> {
let (head, rest) = match sections.split_once('.') {
Some((head, rest)) => (head, Some(rest)),
None => (sections, None),
};
let entry = table.entry(head).or_insert_with(|| {
let mut new = Table::new();
new.set_implicit(true);
Item::Table(new)
});
if !entry.is_table() {
*entry = Item::Table(Table::new());
}
let child = entry.as_table_mut()?;
match rest {
Some(rest) => table_at(child, rest),
None => Some(child),
}
}
fn typed_value(default: Option<&Item>, raw: &str) -> Value {
match default.and_then(Item::as_value) {
Some(Value::Boolean(_)) => {
let truthy = matches!(
raw.to_ascii_lowercase().as_str(),
"true" | "yes" | "on" | "1"
);
Value::from(truthy)
}
Some(Value::Integer(_)) => match raw.parse::<i64>() {
Ok(n) => Value::from(n),
Err(_) => Value::from(raw),
},
_ => Value::from(raw),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writing_keeps_comments_and_untouched_keys() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("trail.toml");
std::fs::write(
&path,
"# my settings\n[general]\n# which editor\neditor = \"nvim\"\n\n[navigation]\nsort_by = \"name\" # tidy\n",
)
.unwrap();
write_settings(
&path,
&[
("navigation.sort_by".to_owned(), "size".to_owned()),
("navigation.scroll_margin".to_owned(), "5".to_owned()),
("general.show_version".to_owned(), "yes".to_owned()),
],
)
.unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("# my settings"), "{text}");
assert!(text.contains("# which editor"), "{text}");
assert!(text.contains("editor = \"nvim\""), "{text}");
assert!(text.contains("sort_by = \"size\""), "{text}");
assert!(
text.contains("scroll_margin = 5"),
"typed as an integer: {text}"
);
assert!(
text.contains("show_version = true"),
"typed as a bool: {text}"
);
let config = crate::config::load(Some(&path)).unwrap();
assert_eq!(config.navigation.sort_by, "size");
assert_eq!(config.navigation.scroll_margin, 5);
assert!(config.general.show_version);
}
#[test]
fn a_missing_file_is_created_with_just_the_changes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sub").join("trail.toml");
write_settings(
&path,
&[("keymap.navigation.quit".to_owned(), "Q".to_owned())],
)
.unwrap();
let config = crate::config::load(Some(&path)).unwrap();
assert_eq!(config.keymap.navigation.get("quit"), Some(&"Q".to_owned()));
}
#[test]
fn a_broken_file_is_left_alone() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("trail.toml");
std::fs::write(&path, "[general\neditor = ").unwrap();
let err = write_settings(&path, &[("general.editor".to_owned(), "vi".to_owned())]);
assert!(matches!(err, Err(PersistError::Parse { .. })));
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"[general\neditor = "
);
}
#[test]
fn every_alias_names_the_field_its_full_name_does() {
let base = crate::config::load(None).unwrap();
for (alias, section) in ALIASES {
let full = format!("{section}.{alias}");
let mut by_alias = base.clone();
let mut by_full = base.clone();
let value = sample_value(&full);
let alias_result = by_alias.set_value(alias, &value);
let full_result = by_full.set_value(&full, &value);
assert!(
!matches!(
full_result,
Err(crate::config::SetConfigError::UnknownKey(_))
),
"{full} is not a key set_value knows"
);
assert_eq!(
alias_result.is_ok(),
full_result.is_ok(),
"{alias} vs {full}"
);
assert_eq!(by_alias, by_full, "{alias} vs {full}");
assert_eq!(canonical_key(alias).as_deref(), Some(full.as_str()));
}
}
fn sample_value(full: &str) -> String {
let defaults: DocumentMut = DEFAULT_CONFIG_TOML.parse().unwrap();
let (sections, name) = full.rsplit_once('.').unwrap();
match lookup(&defaults, sections, name).and_then(Item::as_value) {
Some(Value::String(s)) if s.value().is_empty() => "x".to_owned(),
Some(Value::String(s)) => s.value().clone(),
Some(Value::Boolean(b)) => (!*b.value()).to_string(),
Some(Value::Integer(n)) => (n.value() + 1).to_string(),
other => panic!("no default for {full}: {other:?}"),
}
}
#[test]
fn the_target_is_the_loaded_file_else_the_config_dir() {
let loaded = Path::new("/a/mine.toml");
let dir = Path::new("/cfg");
assert_eq!(
target_file(Some(loaded), Some(dir)),
Some(loaded.to_path_buf())
);
assert_eq!(target_file(None, Some(dir)), Some(dir.join("trail.toml")));
assert_eq!(target_file(None, None), None);
}
}