diff options
| author | rottedfm <rottedfm@proton.me> | 2026-08-19 11:24:55 -0400 |
|---|---|---|
| committer | rottedfm <rottedfm@proton.me> | 2026-08-19 11:24:55 -0400 |
| commit | 8e16347b0eb329e84892af8ece36886324c95f62 (patch) | |
| tree | be1267972b5de2f1ae592577dfabce67f1fe6e87 /src/config.rs | |
| parent | c6ae4660d1cc2414b22c492c5e819d009c8187c2 (diff) | |
| parent | ea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (diff) | |
Establishes the first working baseline: moji <file> opens a file into a
ropey rope and edits it with Helix selection-first modal editing, under a
DO-178C DAL-C requirements and traceability process.
Prior to this, main tracked four files and src/main.rs was still
println!("Hello, world!") — there was no buildable state to build on.
Verified on a fresh clone of the branch with no untracked files:
cargo build; clippy --all-targets -D warnings clean; 294 tests passing;
scripts/check-trace.sh reports 98/98 requirements traced in both
directions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/config.rs')
| -rw-r--r-- | src/config.rs | 888 |
1 files changed, 888 insertions, 0 deletions
diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..328d1d6 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,888 @@ +use std::{collections::HashMap, env, path::PathBuf, sync::LazyLock}; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use directories::ProjectDirs; +use ratatui::style::{Color, Modifier, Style}; +use serde::{Deserialize, Serialize, de::Deserializer}; +use tracing::error; + +use crate::buffer::command::Command; + +/// MJB-LLR-180: the compiled-in default configuration is the very file the +/// editor also reads at runtime, so defaults and documentation cannot drift. +const CONFIG: &str = include_str!("../.config/config.toml"); + +/// Editor mode. Doubles as the scope key for both key bindings and styles. +/// +/// MJB-LLR-184. `Global` is consulted only by `App`, never by the buffer; see +/// MJB-DR-004 for why keymap ownership is split. +#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Mode { + #[default] + Normal, + Insert, + Select, + Command, + Global, +} + +impl std::fmt::Display for Mode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let name = match self { + Mode::Normal => "NOR", + Mode::Insert => "INS", + Mode::Select => "SEL", + Mode::Command => "CMD", + Mode::Global => "GLB", + }; + f.write_str(name) + } +} + +#[derive(Clone, Debug, Deserialize, Default)] +pub struct AppConfig { + #[serde(default)] + pub data_dir: PathBuf, + #[serde(default)] + pub config_dir: PathBuf, +} + +/// MJB-LLR-185. Kept in its own table rather than at top level: `Config` +/// flattens `AppConfig`, and config-rs stringifies values buffered through a +/// flattened map, which would break these non-string fields. +#[derive(Clone, Copy, Debug, Deserialize)] +pub struct EditorConfig { + #[serde(default = "default_scrolloff")] + pub scrolloff: usize, + #[serde(default = "default_insert_final_newline")] + pub insert_final_newline: bool, +} + +fn default_scrolloff() -> usize { + 5 +} + +fn default_insert_final_newline() -> bool { + true +} + +impl Default for EditorConfig { + fn default() -> Self { + Self { + scrolloff: default_scrolloff(), + insert_final_newline: default_insert_final_newline(), + } + } +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct Config { + #[serde(default, flatten)] + pub config: AppConfig, + #[serde(default)] + pub editor: EditorConfig, + #[serde(default)] + pub keybindings: KeyBindings, + #[serde(default)] + pub styles: Styles, +} + +/// The application's own name, independent of the Cargo package name. +/// +/// Stated explicitly rather than derived from `CARGO_PKG_NAME`, which is +/// `mojibake-editor` (the crate name on crates.io), or from +/// `CARGO_CRATE_NAME`, which differs between the `mojibake` library target and +/// the `moji` binary target. Deriving from either would silently move the +/// user's configuration directory and log file when a target is renamed. +pub const APP_NAME: &str = "mojibake"; + +pub static PROJECT_NAME: LazyLock<String> = LazyLock::new(|| APP_NAME.to_uppercase()); +pub static DATA_FOLDER: LazyLock<Option<PathBuf>> = LazyLock::new(|| { + env::var(format!("{}_DATA", PROJECT_NAME.clone())) + .ok() + .map(PathBuf::from) +}); +pub static CONFIG_FOLDER: LazyLock<Option<PathBuf>> = LazyLock::new(|| { + env::var(format!("{}_CONFIG", PROJECT_NAME.clone())) + .ok() + .map(PathBuf::from) +}); + +impl Config { + pub fn new() -> color_eyre::Result<Self, config::ConfigError> { + // MJB-LLR-180. A malformed baked-in default is a build defect, but it + // must still surface as an error rather than a panic (MJB-HLR-018). + let default_config: Config = toml::from_str(CONFIG).map_err(|e| { + config::ConfigError::Message(format!("built-in default config is invalid: {e}")) + })?; + + let data_dir = get_data_dir(); + let config_dir = get_config_dir(); + let mut builder = config::Config::builder() + .set_default("data_dir", path_to_setting(&data_dir)?)? + .set_default("config_dir", path_to_setting(&config_dir)?)?; + + // MJB-LLR-181: TOML is the only format consulted. The json5/yaml/ini + // readers are not merely unused here — `default-features = false` in + // Cargo.toml keeps them out of the dependency graph entirely. + let config_file = config_dir.join("config.toml"); + if !config_file.exists() { + error!( + "No configuration file at {}; built-in defaults will be used", + config_file.display() + ); + } + builder = builder.add_source( + config::File::from(config_file) + .format(config::FileFormat::Toml) + .required(false), + ); + + let mut cfg: Self = builder.build()?.try_deserialize()?; + + // MJB-LLR-182: merge per individual binding, so a user who rebinds one + // key keeps every default they did not mention. + for (mode, default_bindings) in default_config.keybindings.0.iter() { + let user_bindings = cfg.keybindings.0.entry(*mode).or_default(); + for (key, cmd) in default_bindings.iter() { + user_bindings.entry(key.clone()).or_insert(*cmd); + } + } + for (mode, default_styles) in default_config.styles.0.iter() { + let user_styles = cfg.styles.0.entry(*mode).or_default(); + for (style_key, style) in default_styles.iter() { + user_styles.entry(style_key.clone()).or_insert(*style); + } + } + + Ok(cfg) + } + + /// The style named `key` for `mode`, or [`Style::default`] if unset. + pub fn style(&self, mode: Mode, key: &str) -> Style { + self.styles + .0 + .get(&mode) + .and_then(|m| m.get(key)) + .copied() + .unwrap_or_default() + } +} + +/// A path becomes a config setting only if it is valid UTF-8. Reported rather +/// than unwrapped, per MJB-HLR-018. +fn path_to_setting(path: &std::path::Path) -> color_eyre::Result<String, config::ConfigError> { + path.to_str().map(str::to_owned).ok_or_else(|| { + config::ConfigError::Message(format!("path is not valid UTF-8: {}", path.display())) + }) +} + +pub fn get_data_dir() -> PathBuf { + if let Some(s) = DATA_FOLDER.clone() { + s + } else if let Some(proj_dirs) = project_directory() { + proj_dirs.data_local_dir().to_path_buf() + } else { + PathBuf::from(".").join(".data") + } +} + +pub fn get_config_dir() -> PathBuf { + if let Some(s) = CONFIG_FOLDER.clone() { + s + } else if let Some(proj_dirs) = project_directory() { + proj_dirs.config_local_dir().to_path_buf() + } else { + PathBuf::from(".").join(".config") + } +} + +fn project_directory() -> Option<ProjectDirs> { + // The qualifier was the application template author's; mojibake owns its + // own directories. `APP_NAME`, not the package name — see its doc comment. + ProjectDirs::from("wiki", "mojibake", APP_NAME) +} + +#[derive(Clone, Debug, Default)] +pub struct KeyBindings(pub HashMap<Mode, HashMap<Vec<KeyEvent>, Command>>); + +impl<'de> Deserialize<'de> for KeyBindings { + fn deserialize<D>(deserializer: D) -> color_eyre::Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let parsed_map = HashMap::<Mode, HashMap<String, Command>>::deserialize(deserializer)?; + + let mut keybindings = HashMap::new(); + for (mode, inner_map) in parsed_map { + let mut converted = HashMap::new(); + for (key_str, cmd) in inner_map { + // MJB-LLR-183: a bad key string names itself in the error + // rather than aborting the process. + let keys = parse_key_sequence(&key_str).map_err(|e| { + serde::de::Error::custom(format!( + "invalid key sequence {key_str:?} in [keybindings.{mode:?}]: {e}" + )) + })?; + converted.insert(keys, cmd); + } + keybindings.insert(mode, converted); + } + + Ok(KeyBindings(keybindings)) + } +} + +fn parse_key_event(raw: &str) -> color_eyre::Result<KeyEvent, String> { + // Modifier prefixes and named keys are matched case-insensitively, but the + // final token's case is significant and must survive: `<A>` and `<a>` are + // different bindings. Lowercasing the whole string collapsed them onto the + // same `KeyCode::Char('a')`, so every shifted binding silently shadowed its + // lowercase twin. + // + // `to_ascii_lowercase` preserves byte length, so an offset found in the + // lowercased copy indexes the original correctly. + let raw_lower = raw.to_ascii_lowercase(); + let (offset, modifiers) = extract_modifiers(&raw_lower); + parse_key_code_with_modifiers(&raw[offset..], &raw_lower[offset..], modifiers) +} + +/// Returns the byte offset past the modifier prefixes, and the modifiers found. +fn extract_modifiers(raw_lower: &str) -> (usize, KeyModifiers) { + let mut modifiers = KeyModifiers::empty(); + let mut offset = 0; + + loop { + let rest = &raw_lower[offset..]; + if rest.starts_with("ctrl-") { + modifiers.insert(KeyModifiers::CONTROL); + offset += 5; + } else if rest.starts_with("alt-") { + modifiers.insert(KeyModifiers::ALT); + offset += 4; + } else if rest.starts_with("shift-") { + modifiers.insert(KeyModifiers::SHIFT); + offset += 6; + } else { + break; + } + } + + (offset, modifiers) +} + +fn parse_key_code_with_modifiers( + raw: &str, + raw_lower: &str, + mut modifiers: KeyModifiers, +) -> color_eyre::Result<KeyEvent, String> { + let c = match raw_lower { + "esc" => KeyCode::Esc, + "enter" => KeyCode::Enter, + "left" => KeyCode::Left, + "right" => KeyCode::Right, + "up" => KeyCode::Up, + "down" => KeyCode::Down, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "pageup" => KeyCode::PageUp, + "pagedown" => KeyCode::PageDown, + "backtab" => { + modifiers.insert(KeyModifiers::SHIFT); + KeyCode::BackTab + } + "backspace" => KeyCode::Backspace, + "delete" => KeyCode::Delete, + "insert" => KeyCode::Insert, + "f1" => KeyCode::F(1), + "f2" => KeyCode::F(2), + "f3" => KeyCode::F(3), + "f4" => KeyCode::F(4), + "f5" => KeyCode::F(5), + "f6" => KeyCode::F(6), + "f7" => KeyCode::F(7), + "f8" => KeyCode::F(8), + "f9" => KeyCode::F(9), + "f10" => KeyCode::F(10), + "f11" => KeyCode::F(11), + "f12" => KeyCode::F(12), + "space" => KeyCode::Char(' '), + "hyphen" => KeyCode::Char('-'), + "minus" => KeyCode::Char('-'), + "tab" => KeyCode::Tab, + _ if raw.chars().count() == 1 => { + // Case comes from the original token, not the lowercased copy. + let mut c = raw.chars().next().ok_or("empty key")?; + if modifiers.contains(KeyModifiers::SHIFT) { + c = c.to_ascii_uppercase(); + } else if c.is_ascii_uppercase() { + // crossterm reports a capital as Char('A') with SHIFT held, so + // `<A>` must produce exactly that to ever match. + modifiers.insert(KeyModifiers::SHIFT); + } + KeyCode::Char(c) + } + _ => return Err(format!("Unable to parse {raw}")), + }; + Ok(KeyEvent::new(c, modifiers)) +} + +pub fn key_event_to_string(key_event: &KeyEvent) -> String { + let char; + let key_code = match key_event.code { + KeyCode::Backspace => "backspace", + KeyCode::Enter => "enter", + KeyCode::Left => "left", + KeyCode::Right => "right", + KeyCode::Up => "up", + KeyCode::Down => "down", + KeyCode::Home => "home", + KeyCode::End => "end", + KeyCode::PageUp => "pageup", + KeyCode::PageDown => "pagedown", + KeyCode::Tab => "tab", + KeyCode::BackTab => "backtab", + KeyCode::Delete => "delete", + KeyCode::Insert => "insert", + KeyCode::F(c) => { + char = format!("f({c})"); + &char + } + KeyCode::Char(' ') => "space", + KeyCode::Char(c) => { + char = c.to_string(); + &char + } + KeyCode::Esc => "esc", + KeyCode::Null => "", + KeyCode::CapsLock => "", + KeyCode::Menu => "", + KeyCode::ScrollLock => "", + KeyCode::Media(_) => "", + KeyCode::NumLock => "", + KeyCode::PrintScreen => "", + KeyCode::Pause => "", + KeyCode::KeypadBegin => "", + KeyCode::Modifier(_) => "", + }; + + let mut modifiers = Vec::with_capacity(3); + + if key_event.modifiers.intersects(KeyModifiers::CONTROL) { + modifiers.push("ctrl"); + } + + if key_event.modifiers.intersects(KeyModifiers::SHIFT) { + modifiers.push("shift"); + } + + if key_event.modifiers.intersects(KeyModifiers::ALT) { + modifiers.push("alt"); + } + + let mut key = modifiers.join("-"); + + if !key.is_empty() { + key.push('-'); + } + key.push_str(key_code); + + key +} + +pub fn parse_key_sequence(raw: &str) -> color_eyre::Result<Vec<KeyEvent>, String> { + if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() { + return Err(format!("Unable to parse `{}`", raw)); + } + let raw = if !raw.contains("><") { + let raw = raw.strip_prefix('<').unwrap_or(raw); + raw.strip_prefix('>').unwrap_or(raw) + } else { + raw + }; + let sequences = raw + .split("><") + .map(|seq| { + if let Some(s) = seq.strip_prefix('<') { + s + } else if let Some(s) = seq.strip_suffix('>') { + s + } else { + seq + } + }) + .collect::<Vec<_>>(); + + sequences.into_iter().map(parse_key_event).collect() +} + +#[derive(Clone, Debug, Default)] +pub struct Styles(pub HashMap<Mode, HashMap<String, Style>>); + +impl<'de> Deserialize<'de> for Styles { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let parsed_map = HashMap::<Mode, HashMap<String, String>>::deserialize(deserializer)?; + + let styles = parsed_map + .into_iter() + .map(|(mode, inner_map)| { + let converted_inner_map = inner_map + .into_iter() + .map(|(str, style)| (str, parse_style(&style))) + .collect(); + (mode, converted_inner_map) + }) + .collect(); + + Ok(Styles(styles)) + } +} + +pub fn parse_style(line: &str) -> Style { + let (foreground, background) = + line.split_at(line.to_lowercase().find("on ").unwrap_or(line.len())); + let foreground = process_color_string(foreground); + let background = process_color_string(&background.replace("on ", "")); + + let mut style = Style::default(); + if let Some(fg) = parse_color(&foreground.0) { + style = style.fg(fg); + } + if let Some(bg) = parse_color(&background.0) { + style = style.bg(bg); + } + style = style.add_modifier(foreground.1 | background.1); + style +} + +fn process_color_string(color_str: &str) -> (String, Modifier) { + let color = color_str + .replace("grey", "gray") + .replace("bright ", "") + .replace("bold ", "") + .replace("underline ", "") + .replace("inverse ", ""); + + let mut modifiers = Modifier::empty(); + if color_str.contains("underline") { + modifiers |= Modifier::UNDERLINED; + } + if color_str.contains("bold") { + modifiers |= Modifier::BOLD; + } + if color_str.contains("inverse") { + modifiers |= Modifier::REVERSED; + } + + (color, modifiers) +} + +fn parse_color(s: &str) -> Option<Color> { + let s = s.trim_start(); + let s = s.trim_end(); + // Every arm below must tolerate arbitrary user input: these strings come + // from `[styles]` in config.toml, and a panic here would take down the + // editor at startup (MJB-HLR-018). The original template code indexed + // `rgb` operands unchecked and added into `u8` unguarded, so `"rgb"`, + // `"rgb1"`, `"gray99"` and `"rgb999"` all aborted the process. + if s.contains("bright color") { + let c = s + .trim_start_matches("bright ") + .trim_start_matches("color") + .parse::<u8>() + .unwrap_or_default(); + // Bright ANSI colours are the base colour plus 8. The template wrote + // `wrapping_shl(8)`, which on a `u8` masks the shift to 8 % 8 == 0 and + // so returned the colour unchanged. + Some(Color::Indexed(c.saturating_add(8))) + } else if s.contains("color") { + let c = s + .trim_start_matches("color") + .parse::<u8>() + .unwrap_or_default(); + Some(Color::Indexed(c)) + } else if s.contains("gray") { + // The xterm grayscale ramp is 24 steps at indices 232..=255. + let step = s + .trim_start_matches("gray") + .parse::<u8>() + .unwrap_or_default() + .min(23); + Some(Color::Indexed(232 + step)) + } else if let Some(digits) = s.strip_prefix("rgb") { + // The xterm 216-colour cube at indices 16..=231: three components, + // each 0–5. `to_digit(6)` rejects anything outside that range, so the + // arithmetic below cannot exceed 231. + let mut components = digits.chars().map(|c| c.to_digit(6)); + match (components.next(), components.next(), components.next()) { + (Some(Some(r)), Some(Some(g)), Some(Some(b))) => { + let index = 16 + r * 36 + g * 6 + b; + debug_assert!(index <= 231); + Some(Color::Indexed(index as u8)) + } + _ => None, + } + } else if s == "bold black" { + Some(Color::Indexed(8)) + } else if s == "bold red" { + Some(Color::Indexed(9)) + } else if s == "bold green" { + Some(Color::Indexed(10)) + } else if s == "bold yellow" { + Some(Color::Indexed(11)) + } else if s == "bold blue" { + Some(Color::Indexed(12)) + } else if s == "bold magenta" { + Some(Color::Indexed(13)) + } else if s == "bold cyan" { + Some(Color::Indexed(14)) + } else if s == "bold white" { + Some(Color::Indexed(15)) + } else if s == "black" { + Some(Color::Indexed(0)) + } else if s == "red" { + Some(Color::Indexed(1)) + } else if s == "green" { + Some(Color::Indexed(2)) + } else if s == "yellow" { + Some(Color::Indexed(3)) + } else if s == "blue" { + Some(Color::Indexed(4)) + } else if s == "magenta" { + Some(Color::Indexed(5)) + } else if s == "cyan" { + Some(Color::Indexed(6)) + } else if s == "white" { + Some(Color::Indexed(7)) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_parse_style_default() { + let style = parse_style(""); + assert_eq!(style, Style::default()); + } + + #[test] + fn test_parse_style_foreground() { + let style = parse_style("red"); + assert_eq!(style.fg, Some(Color::Indexed(1))); + } + + #[test] + fn test_parse_style_background() { + let style = parse_style("on blue"); + assert_eq!(style.bg, Some(Color::Indexed(4))); + } + + #[test] + fn test_parse_style_modifiers() { + let style = parse_style("underline red on blue"); + assert_eq!(style.fg, Some(Color::Indexed(1))); + assert_eq!(style.bg, Some(Color::Indexed(4))); + } + + #[test] + fn test_process_color_string() { + let (color, modifiers) = process_color_string("underline bold inverse gray"); + assert_eq!(color, "gray"); + assert!(modifiers.contains(Modifier::UNDERLINED)); + assert!(modifiers.contains(Modifier::BOLD)); + assert!(modifiers.contains(Modifier::REVERSED)); + } + + #[test] + fn test_parse_color_rgb() { + let color = parse_color("rgb123"); + let expected = 16 + 36 + 2 * 6 + 3; + assert_eq!(color, Some(Color::Indexed(expected))); + } + + #[test] + fn test_parse_color_unknown() { + let color = parse_color("unknown"); + assert_eq!(color, None); + } + + /// MJB-HLR-018: colour strings come from user configuration, so no input + /// may abort the process. Regression guard — every case below panicked in + /// the template code this replaced, by unchecked indexing or by `u8` + /// overflow in a debug build. + #[test] + fn mjb_llr_183_malformed_colours_never_panic() { + for input in [ + "rgb", // indexed byte 3 of a 3-byte string + "rgb1", // indexed bytes 4 and 5 + "rgb12", // indexed byte 5 + "rgb999", // 16 + 9*36 + 9*6 + 9 = 403, overflows u8 + "rgb555", // the largest legal cube entry + "gray99", // 232 + 99 = 331, overflows u8 + "gray", // no digits at all + "color999", // does not fit u8 + "bright color999", + "", + "文字化け", // multi-byte: byte indexing would split a character + ] { + let _ = parse_color(input); + let _ = parse_style(input); + } + } + + #[test] + fn mjb_llr_183_colour_cube_bounds() { + // 216-colour cube occupies 16..=231. + assert_eq!(parse_color("rgb000"), Some(Color::Indexed(16))); + assert_eq!(parse_color("rgb555"), Some(Color::Indexed(231))); + // Components outside 0–5 are not cube coordinates. + assert_eq!(parse_color("rgb600"), None); + } + + #[test] + fn mjb_llr_183_grayscale_ramp_bounds() { + // Grayscale ramp occupies 232..=255. + assert_eq!(parse_color("gray0"), Some(Color::Indexed(232))); + assert_eq!(parse_color("gray23"), Some(Color::Indexed(255))); + assert_eq!( + parse_color("gray99"), + Some(Color::Indexed(255)), + "clamped to the end of the ramp rather than overflowing" + ); + } + + #[test] + fn mjb_llr_183_bright_colour_is_base_plus_eight() { + // The template's `wrapping_shl(8)` masked to a zero-bit shift, so + // bright colours were indistinguishable from their base. + assert_eq!(parse_color("bright color1"), Some(Color::Indexed(9))); + assert_ne!(parse_color("bright color1"), parse_color("color1")); + assert_eq!( + parse_color("bright color255"), + Some(Color::Indexed(255)), + "saturates instead of wrapping" + ); + } + + /// MJB-LLR-180: the compiled-in default TOML parses, and carries the + /// bindings the requirements mandate. + #[test] + fn mjb_llr_180_builtin_defaults_parse() { + let c: Config = toml::from_str(CONFIG).expect("built-in config.toml must parse"); + + let normal = c.keybindings.0.get(&Mode::Normal).expect("normal bindings"); + assert_eq!( + normal.get(&parse_key_sequence("<h>").unwrap()), + Some(&Command::MoveCharLeft) + ); + // A two-key sequence must survive parsing as two events. + let gg = parse_key_sequence("<g><g>").unwrap(); + assert_eq!(gg.len(), 2); + assert_eq!(normal.get(&gg), Some(&Command::GotoFileStart)); + + let global = c.keybindings.0.get(&Mode::Global).expect("global bindings"); + assert_eq!( + global.get(&parse_key_sequence("<ctrl-c>").unwrap()), + Some(&Command::Quit) + ); + } + + /// MJB-LLR-181: `config.toml` is the only file consulted. A config in any + /// other format sitting in the same directory must be ignored entirely. + #[test] + fn mjb_llr_181_only_config_toml_is_read() { + let dir = tempfile::tempdir().unwrap(); + + // Decoys in the formats the template used to accept. + std::fs::write( + dir.path().join("config.json5"), + "{ \"keybindings\": { \"normal\": { \"<q>\": \"Quit\" } } }", + ) + .unwrap(); + std::fs::write(dir.path().join("config.json"), "{\"editor\":{\"scrolloff\":99}}").unwrap(); + std::fs::write(dir.path().join("config.yaml"), "editor:\n scrolloff: 98\n").unwrap(); + std::fs::write(dir.path().join("config.ini"), "[editor]\nscrolloff=97\n").unwrap(); + std::fs::write(dir.path().join("config.toml"), "[editor]\nscrolloff = 7\n").unwrap(); + + let cfg: Config = config::Config::builder() + .add_source( + config::File::from(dir.path().join("config.toml")) + .format(config::FileFormat::Toml) + .required(false), + ) + .build() + .unwrap() + .try_deserialize() + .unwrap(); + + assert_eq!(cfg.editor.scrolloff, 7, "the TOML file must win"); + } + + /// MJB-LLR-185: editor settings deserialize with the documented defaults. + #[test] + fn mjb_llr_185_editor_defaults() { + let c: Config = toml::from_str(CONFIG).unwrap(); + assert_eq!(c.editor.scrolloff, 5); + assert!(c.editor.insert_final_newline); + + let empty: Config = toml::from_str("").unwrap(); + assert_eq!(empty.editor.scrolloff, 5); + assert!(empty.editor.insert_final_newline); + } + + /// MJB-LLR-182: a user binding overrides one default without disturbing + /// the rest of that mode. + #[test] + fn mjb_llr_182_user_bindings_merge_per_binding() { + let mut defaults: Config = toml::from_str(CONFIG).unwrap(); + let user: Config = + toml::from_str("[keybindings.normal]\n\"<h>\" = \"MoveCharRight\"\n").unwrap(); + + // Same merge direction as Config::new: user wins, defaults fill in. + let mut merged = user; + for (mode, default_bindings) in defaults.keybindings.0.drain() { + let entry = merged.keybindings.0.entry(mode).or_default(); + for (key, cmd) in default_bindings { + entry.entry(key).or_insert(cmd); + } + } + + let normal = merged.keybindings.0.get(&Mode::Normal).unwrap(); + assert_eq!( + normal.get(&parse_key_sequence("<h>").unwrap()), + Some(&Command::MoveCharRight), + "user binding must win" + ); + assert_eq!( + normal.get(&parse_key_sequence("<j>").unwrap()), + Some(&Command::MoveLineDown), + "untouched defaults must remain" + ); + } + + /// MJB-LLR-183: a malformed key sequence is a recoverable error naming the + /// offending string, not a panic. + #[test] + fn mjb_llr_183_invalid_keybinding_is_recoverable() { + let err = toml::from_str::<Config>("[keybindings.normal]\n\"<nonsense-key>\" = \"Undo\"\n") + .expect_err("must not deserialize"); + assert!( + err.to_string().contains("nonsense-key"), + "error must name the offending key, got: {err}" + ); + } + + /// MJB-LLR-184: modes deserialize from their lower-case names. + #[test] + fn mjb_llr_184_modes_deserialize_lowercase() { + let c: Config = toml::from_str( + "[keybindings.normal]\n\"<a>\" = \"Undo\"\n\ + [keybindings.insert]\n\"<b>\" = \"Undo\"\n\ + [keybindings.select]\n\"<c>\" = \"Undo\"\n\ + [keybindings.command]\n\"<d>\" = \"Undo\"\n\ + [keybindings.global]\n\"<e>\" = \"Undo\"\n", + ) + .unwrap(); + for mode in [ + Mode::Normal, + Mode::Insert, + Mode::Select, + Mode::Command, + Mode::Global, + ] { + assert!(c.keybindings.0.contains_key(&mode), "missing {mode:?}"); + } + } + + #[test] + fn test_simple_keys() { + assert_eq!( + parse_key_event("a").unwrap(), + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()) + ); + + assert_eq!( + parse_key_event("enter").unwrap(), + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()) + ); + + assert_eq!( + parse_key_event("esc").unwrap(), + KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()) + ); + } + + #[test] + fn test_with_modifiers() { + assert_eq!( + parse_key_event("ctrl-a").unwrap(), + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL) + ); + + assert_eq!( + parse_key_event("alt-enter").unwrap(), + KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT) + ); + + assert_eq!( + parse_key_event("shift-esc").unwrap(), + KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT) + ); + } + + #[test] + fn test_multiple_modifiers() { + assert_eq!( + parse_key_event("ctrl-alt-a").unwrap(), + KeyEvent::new( + KeyCode::Char('a'), + KeyModifiers::CONTROL | KeyModifiers::ALT + ) + ); + + assert_eq!( + parse_key_event("ctrl-shift-enter").unwrap(), + KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT) + ); + } + + #[test] + fn test_reverse_multiple_modifiers() { + assert_eq!( + key_event_to_string(&KeyEvent::new( + KeyCode::Char('a'), + KeyModifiers::CONTROL | KeyModifiers::ALT + )), + "ctrl-alt-a".to_string() + ); + } + + #[test] + fn test_invalid_keys() { + assert!(parse_key_event("invalid-key").is_err()); + assert!(parse_key_event("ctrl-invalid-key").is_err()); + } + + #[test] + fn test_case_insensitivity() { + assert_eq!( + parse_key_event("CTRL-a").unwrap(), + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL) + ); + + assert_eq!( + parse_key_event("AlT-eNtEr").unwrap(), + KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT) + ); + } +} |
