//! Where cumulus keeps its files ([`paths`]), and the files in its config //! folder: config.toml ([`settings`]), keys.toml ([`keys_file`]) and themes //! ([`theme_file`]). Each one only states what it changes; a missing file //! changes nothing, and a mistake only drops the line it's on, with a //! message saying what's wrong. cumulus applies a file again when it's //! saved. pub mod keys_file; pub mod paths; pub mod settings; pub mod theme_file; use std::fs; use std::path::PathBuf; use paths::Paths; use settings::Settings; use crate::input::keymap::{DEFAULT_LEADER, Keymap}; use crate::ui::theme::Theme; /// One of the config folder's files. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConfigFile { /// config.toml: the settings. Settings, /// keys.toml: your keys. Keys, /// The theme's file, `themes/.toml`. Theme, } impl ConfigFile { pub const ALL: [ConfigFile; 3] = [ConfigFile::Settings, ConfigFile::Keys, ConfigFile::Theme]; /// Where it is, for the theme named `theme`. pub fn path(self, paths: &Paths, theme: &str) -> PathBuf { match self { ConfigFile::Settings => paths.config.join("config.toml"), ConfigFile::Keys => paths.config.join("keys.toml"), ConfigFile::Theme => theme_file::path(paths, theme), } } /// What's in it, in a few words. pub fn about(self) -> &'static str { match self { ConfigFile::Settings => "the settings", ConfigFile::Keys => "your keys", ConfigFile::Theme => "the theme", } } /// The file as `:config` first writes it: everything it can hold, each /// at its default, commented out. pub fn template(self) -> String { match self { ConfigFile::Settings => Settings::template(), ConfigFile::Keys => keys_file::template(), ConfigFile::Theme => theme_file::template(&Theme::default()), } } } /// Everything the config folder sets. #[derive(Debug, Clone)] pub struct Config { pub settings: Settings, pub keymap: Keymap, pub theme: Theme, /// What was wrong in the files, in words. pub problems: Vec, } impl Default for Config { /// No files: everything as cumulus has it. fn default() -> Config { Config { settings: Settings::default(), keymap: Keymap::new(DEFAULT_LEADER), theme: Theme::default(), problems: Vec::new(), } } } impl Config { /// Reads the config folder: config.toml, keys.toml, and the theme /// config.toml names. pub fn load(paths: &Paths) -> Config { let (settings, mut problems) = read(&ConfigFile::Settings.path(paths, "")) .map_or_else(|| (Settings::default(), Vec::new()), |text| Settings::from_toml(&text)); let (keymap, keys_problems) = keys(paths); problems.extend(keys_problems); let (theme, theme_problems) = theme_file::load(paths, &settings.theme, settings.glyphs); problems.extend(theme_problems); Config { settings, keymap, theme, problems } } } /// The keys: keys.toml on top of the defaults. pub fn keys(paths: &Paths) -> (Keymap, Vec) { match read(&ConfigFile::Keys.path(paths, "")) { Some(text) => { let (keymap, problems) = Keymap::from_toml(&text); (keymap, problems.into_iter().map(|problem| format!("keys.toml: {problem}")).collect()) } None => (Keymap::new(DEFAULT_LEADER), Vec::new()), } } /// A config file's text, when there's a file. fn read(path: &std::path::Path) -> Option { fs::read_to_string(path).ok() } /// Text as comment lines for a config file: `# ` before each, and at most /// 76 columns. pub(crate) fn comment(text: &str) -> String { let mut lines: Vec = Vec::new(); for word in text.split_whitespace() { match lines.last_mut() { Some(line) if line.len() + 1 + word.len() <= 76 => { line.push(' '); line.push_str(word); } _ => lines.push(format!("# {word}")), } } lines.iter().map(|line| format!("{line}\n")).collect() } /// Text that doesn't read as TOML, in one line: where it went wrong, and /// what's wrong there. pub fn toml_problem(text: &str, error: &toml::de::Error) -> String { match error.span() { Some(span) => { let before = text.char_indices().take_while(|&(at, _)| at < span.start); let line = before.filter(|&(_, c)| c == '\n').count() + 1; format!("line {line}: {}", error.message().trim()) } None => error.message().trim().to_string(), } } #[cfg(test)] mod tests { use super::*; use crate::storage::test_support::TempDir; use crate::ui::glyphs::Glyphs; #[test] fn the_folder_sets_settings_keys_and_theme() { let folder = TempDir::new(); let paths = Paths::inside(folder.path()); let config = Config::load(&paths); assert!(config.problems.is_empty()); assert_eq!(config.settings, Settings::default()); fs::create_dir_all(paths.config.join("themes")).unwrap(); fs::write(paths.config.join("config.toml"), "theme = \"night\"\nglyphs = \"ascii\"\n").unwrap(); fs::write(paths.config.join("keys.toml"), "[normal]\n\"Q\" = \"close\"\n\"x\" = \"frobnicate\"\n") .unwrap(); fs::write(paths.config.join("themes").join("night.toml"), "accent = \"magenta\"\n").unwrap(); let config = Config::load(&paths); assert_eq!(config.theme.accent, ratatui::style::Color::Magenta); assert_eq!(config.theme.glyphs, Glyphs::Ascii, "the theme draws with the glyphs setting"); assert_eq!(config.problems.len(), 1, "{:?}", config.problems); assert!(config.problems[0].contains("frobnicate"), "{:?}", config.problems); } #[test] fn comments_wrap_at_76_columns() { let text = comment(&"word ".repeat(30)); assert!(text.lines().all(|line| line.starts_with("# ") && line.len() <= 76), "{text}"); assert_eq!(text.split_whitespace().filter(|word| *word == "word").count(), 30); } }