aboutsummaryrefslogtreecommitdiff
path: root/src/config.rs
diff options
context:
space:
mode:
authorrottedfm <rottedfm@proton.me>2026-08-19 11:21:47 -0400
committerrottedfm <rottedfm@proton.me>2026-08-19 11:21:47 -0400
commitea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (patch)
treebe1267972b5de2f1ae592577dfabce67f1fe6e87 /src/config.rs
parent8c0b4c53b130555f040884c1f52b90f16b23e241 (diff)
feat: implement Helix-style modal buffer under DO-178C DAL-C
The repository was an unmodified ratatui component template: no editor code, JSON5 config, and placeholder widgets. This establishes the first working baseline — `moji <file>` opens a file into a ropey rope and edits it with Helix selection-first semantics. Requirements, implementation and tests land together because they must: the traceability check rejects requirements with no implementation and tests naming requirements that do not exist, so neither half is a valid commit on its own. Package renamed to mojibake-editor (mojibake was taken on crates.io); binary is moji, library target stays mojibake. Class: New behaviour Requirements: MJB-HLR-001..019, MJB-LLR-001..205 Derived: MJB-DR-001..007 (DR-001 resolved, six open for review) Verified: cargo build; clippy --all-targets -D warnings clean; cargo test 294 passing; ./scripts/check-trace.sh 98/98/98; cargo package clean Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/config.rs')
-rw-r--r--src/config.rs888
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)
+ );
+ }
+}