use std::collections::BTreeMap;
use std::ffi::OsString;
use std::fmt;
use std::path::{Path, PathBuf};
use crossterm::event::KeyCode;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub ui: UiConfig,
pub mcp: McpConfig,
pub editor: EditorConfig,
pub keys: KeysConfig,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FileLayout {
List,
Tree,
}
impl FileLayout {
fn from_str(value: &str, key: &str, default: Self) -> (Self, Option<String>) {
match value {
"list" => (Self::List, None),
"tree" => (Self::Tree, None),
other => (
default,
Some(format!("unknown {key} \"{other}\", using \"{default}\"")),
),
}
}
}
impl fmt::Display for FileLayout {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::List => "list",
Self::Tree => "tree",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct UiConfig {
pub theme: String,
pub context_lines: u32,
pub recent_commits: usize,
pub status_file_layout: FileLayout,
pub diff_file_layout: FileLayout,
pub side_by_side: bool,
}
impl Default for UiConfig {
fn default() -> Self {
Self {
theme: "github-dark".to_owned(),
context_lines: 3,
recent_commits: 10,
status_file_layout: FileLayout::List,
diff_file_layout: FileLayout::Tree,
side_by_side: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct McpConfig {
pub enabled: bool,
pub port: u16,
}
impl Default for McpConfig {
fn default() -> Self {
Self {
enabled: true,
port: 8417,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct EditorConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct KeysConfig {
pub status: BTreeMap<String, String>,
pub diff: BTreeMap<String, String>,
pub log: BTreeMap<String, String>,
pub commit: BTreeMap<String, String>,
pub branch: BTreeMap<String, String>,
pub log_menu: BTreeMap<String, String>,
pub push: BTreeMap<String, String>,
pub pull: BTreeMap<String, String>,
pub fetch: BTreeMap<String, String>,
pub stash: BTreeMap<String, String>,
}
impl KeysConfig {
pub fn transient(&self, kind: crate::transient::TransientKind) -> &BTreeMap<String, String> {
match kind {
crate::transient::TransientKind::Commit => &self.commit,
crate::transient::TransientKind::Branch => &self.branch,
crate::transient::TransientKind::Log => &self.log_menu,
crate::transient::TransientKind::Push => &self.push,
crate::transient::TransientKind::Pull => &self.pull,
crate::transient::TransientKind::Fetch => &self.fetch,
crate::transient::TransientKind::Stash => &self.stash,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct CliOverrides {
pub port: Option<u16>,
pub mcp_enabled: Option<bool>,
pub theme: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Origin {
Default,
Global(PathBuf),
Project(PathBuf),
Cli,
}
impl fmt::Display for Origin {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Default => f.write_str("default"),
Self::Global(path) => write!(f, "global:{}", path.display()),
Self::Project(path) => write!(f, "project:{}", path.display()),
Self::Cli => f.write_str("cli"),
}
}
}
#[derive(Debug, Default)]
pub struct LoadedConfig {
pub config: Config,
pub origins: BTreeMap<String, Origin>,
pub warnings: Vec<String>,
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("cannot read config file {}: {source}", path.display())]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("invalid TOML in {}: {message}", path.display())]
Parse { path: PathBuf, message: String },
#[error("cannot render config as TOML: {0}")]
Render(#[from] toml::ser::Error),
}
pub fn load(repo_root: Option<&Path>, cli: &CliOverrides) -> Result<LoadedConfig, ConfigError> {
let global = global_config_path_from(
std::env::var_os("XDG_CONFIG_HOME"),
std::env::var_os("HOME"),
);
let project = repo_root.map(|root| root.join(".diffler").join("config.toml"));
load_layers(global.as_deref(), project.as_deref(), cli)
}
fn global_config_path_from(xdg: Option<OsString>, home: Option<OsString>) -> Option<PathBuf> {
let base = match xdg {
Some(dir) if !dir.is_empty() => PathBuf::from(dir),
_ => PathBuf::from(home?).join(".config"),
};
Some(base.join("diffler").join("config.toml"))
}
fn load_layers(
global: Option<&Path>,
project: Option<&Path>,
cli: &CliOverrides,
) -> Result<LoadedConfig, ConfigError> {
let mut config = Config::default();
let mut origins = BTreeMap::new();
let mut warnings = Vec::new();
let layers = [
(global, Origin::Global as fn(PathBuf) -> Origin),
(project, Origin::Project as fn(PathBuf) -> Origin),
];
for (path, make_origin) in layers {
if let Some(path) = path
&& path.is_file()
{
let layer = read_layer(path, &mut warnings)?;
apply_layer(
layer,
&mut config,
&mut origins,
&make_origin(path.to_path_buf()),
&mut warnings,
);
}
}
apply_cli(cli, &mut config, &mut origins);
Ok(LoadedConfig {
config,
origins,
warnings,
})
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct PartialConfig {
ui: PartialUi,
mcp: PartialMcp,
editor: PartialEditor,
keys: KeysConfig,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct PartialUi {
theme: Option<String>,
context_lines: Option<u32>,
recent_commits: Option<usize>,
status_file_layout: Option<String>,
diff_file_layout: Option<String>,
side_by_side: Option<bool>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct PartialMcp {
enabled: Option<bool>,
port: Option<u16>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct PartialEditor {
command: Option<String>,
}
fn read_layer(path: &Path, warnings: &mut Vec<String>) -> Result<PartialConfig, ConfigError> {
let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
path: path.to_path_buf(),
source,
})?;
let parse_err = |err: toml::de::Error| ConfigError::Parse {
path: path.to_path_buf(),
message: err.to_string(),
};
let de = toml::de::Deserializer::parse(&text).map_err(parse_err)?;
serde_ignored::deserialize(de, |unknown| {
warnings.push(format!("{}: unknown key `{unknown}`", path.display()));
})
.map_err(parse_err)
}
fn set_layout(
value: Option<String>,
target: &mut FileLayout,
key: &str,
origin: &Origin,
origins: &mut BTreeMap<String, Origin>,
warnings: &mut Vec<String>,
) {
let Some(value) = value else {
return;
};
let (layout, warning) = FileLayout::from_str(&value, key, *target);
if let Some(warning) = warning {
warnings.push(warning);
return;
}
*target = layout;
origins.insert(key.to_owned(), origin.clone());
}
fn apply_layer(
layer: PartialConfig,
config: &mut Config,
origins: &mut BTreeMap<String, Origin>,
origin: &Origin,
warnings: &mut Vec<String>,
) {
fn set<T>(
value: Option<T>,
target: &mut T,
key: &str,
origin: &Origin,
origins: &mut BTreeMap<String, Origin>,
) {
if let Some(value) = value {
*target = value;
origins.insert(key.to_owned(), origin.clone());
}
}
set(
layer.ui.theme,
&mut config.ui.theme,
"ui.theme",
origin,
origins,
);
set(
layer.ui.context_lines,
&mut config.ui.context_lines,
"ui.context_lines",
origin,
origins,
);
set(
layer.ui.recent_commits,
&mut config.ui.recent_commits,
"ui.recent_commits",
origin,
origins,
);
set_layout(
layer.ui.status_file_layout,
&mut config.ui.status_file_layout,
"ui.status_file_layout",
origin,
origins,
warnings,
);
set_layout(
layer.ui.diff_file_layout,
&mut config.ui.diff_file_layout,
"ui.diff_file_layout",
origin,
origins,
warnings,
);
set(
layer.ui.side_by_side,
&mut config.ui.side_by_side,
"ui.side_by_side",
origin,
origins,
);
set(
layer.mcp.enabled,
&mut config.mcp.enabled,
"mcp.enabled",
origin,
origins,
);
set(
layer.mcp.port,
&mut config.mcp.port,
"mcp.port",
origin,
origins,
);
if let Some(command) = layer.editor.command {
config.editor.command = Some(command);
origins.insert("editor.command".to_owned(), origin.clone());
}
let key_sections = [
(layer.keys.status, &mut config.keys.status, "status"),
(layer.keys.diff, &mut config.keys.diff, "diff"),
(layer.keys.log, &mut config.keys.log, "log"),
(layer.keys.commit, &mut config.keys.commit, "commit"),
(layer.keys.branch, &mut config.keys.branch, "branch"),
(layer.keys.log_menu, &mut config.keys.log_menu, "log_menu"),
(layer.keys.push, &mut config.keys.push, "push"),
(layer.keys.pull, &mut config.keys.pull, "pull"),
(layer.keys.fetch, &mut config.keys.fetch, "fetch"),
(layer.keys.stash, &mut config.keys.stash, "stash"),
];
for (entries, target, section) in key_sections {
for (action, chord) in entries {
let key_path = format!("keys.{section}.{action}");
if let Err(err) = parse_chord(&chord) {
warnings.push(format!("invalid chord \"{chord}\" for {key_path}: {err}"));
continue;
}
origins.insert(key_path, origin.clone());
target.insert(action, chord);
}
}
}
fn apply_cli(cli: &CliOverrides, config: &mut Config, origins: &mut BTreeMap<String, Origin>) {
if let Some(port) = cli.port {
config.mcp.port = port;
origins.insert("mcp.port".to_owned(), Origin::Cli);
}
if let Some(enabled) = cli.mcp_enabled {
config.mcp.enabled = enabled;
origins.insert("mcp.enabled".to_owned(), Origin::Cli);
}
if let Some(theme) = &cli.theme {
config.ui.theme.clone_from(theme);
origins.insert("ui.theme".to_owned(), Origin::Cli);
}
}
const SCALAR_KEYS: [&str; 9] = [
"ui.theme",
"ui.context_lines",
"ui.recent_commits",
"ui.status_file_layout",
"ui.diff_file_layout",
"ui.side_by_side",
"mcp.enabled",
"mcp.port",
"editor.command",
];
pub fn render_dump(loaded: &LoadedConfig) -> Result<String, ConfigError> {
use std::fmt::Write as _;
let mut out = toml::to_string(&loaded.config)?;
out.push_str("\n## origins\n");
for key in SCALAR_KEYS {
let origin = loaded
.origins
.get(key)
.map_or_else(|| Origin::Default.to_string(), ToString::to_string);
let _ = writeln!(out, "# {key} = {origin}");
}
for (key, origin) in &loaded.origins {
if key.starts_with("keys.") {
let _ = writeln!(out, "# {key} = {origin}");
}
}
if !loaded.warnings.is_empty() {
out.push_str("\n## warnings\n");
for warning in &loaded.warnings {
let _ = writeln!(out, "# {warning}");
}
}
Ok(out)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeyPress {
pub code: KeyCode,
pub ctrl: bool,
pub alt: bool,
pub shift: bool,
}
pub type Chord = Vec<KeyPress>;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ChordError {
#[error("empty key chord")]
Empty,
#[error("unterminated `<` in key chord {0:?}")]
Unterminated(String),
#[error("unknown key {0:?} in key chord")]
UnknownKey(String),
}
pub fn parse_chord(s: &str) -> Result<Chord, ChordError> {
if s.is_empty() {
return Err(ChordError::Empty);
}
let mut presses = Vec::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '<' {
let mut token = String::new();
let mut closed = false;
for inner in chars.by_ref() {
if inner == '>' {
closed = true;
break;
}
token.push(inner);
}
if !closed {
return Err(ChordError::Unterminated(s.to_owned()));
}
presses.push(parse_bracketed(&token)?);
} else {
presses.push(plain_press(c));
}
}
Ok(presses)
}
fn plain_press(c: char) -> KeyPress {
KeyPress {
code: KeyCode::Char(c),
ctrl: false,
alt: false,
shift: c.is_uppercase(),
}
}
fn parse_bracketed(token: &str) -> Result<KeyPress, ChordError> {
let mut rest = token;
let (mut ctrl, mut alt, mut shift) = (false, false, false);
loop {
if let Some(stripped) = strip_modifier(rest, 'c') {
ctrl = true;
rest = stripped;
} else if let Some(stripped) = strip_modifier(rest, 'a') {
alt = true;
rest = stripped;
} else if let Some(stripped) = strip_modifier(rest, 's') {
shift = true;
rest = stripped;
} else {
break;
}
}
let code = match rest.to_ascii_lowercase().as_str() {
"cr" => KeyCode::Enter,
"tab" => KeyCode::Tab,
"esc" => KeyCode::Esc,
"space" => KeyCode::Char(' '),
_ => {
let mut rest_chars = rest.chars();
match (rest_chars.next(), rest_chars.next()) {
(Some(c), None) => {
shift = shift || c.is_uppercase();
let c = if shift && c.is_ascii_lowercase() {
c.to_ascii_uppercase()
} else {
c
};
KeyCode::Char(c)
}
_ => return Err(ChordError::UnknownKey(format!("<{token}>"))),
}
}
};
Ok(KeyPress {
code,
ctrl,
alt,
shift,
})
}
fn strip_modifier(rest: &str, modifier: char) -> Option<&str> {
let mut chars = rest.chars();
if chars.next()?.to_ascii_lowercase() != modifier {
return None;
}
if chars.next()? != '-' {
return None;
}
Some(chars.as_str())
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use std::fs;
use super::*;
fn press(code: KeyCode, ctrl: bool, alt: bool, shift: bool) -> KeyPress {
KeyPress {
code,
ctrl,
alt,
shift,
}
}
#[test]
fn defaults_are_the_documented_values() {
let config = Config::default();
assert_eq!(config.ui.theme, "github-dark");
assert_eq!(config.ui.context_lines, 3);
assert_eq!(config.ui.recent_commits, 10);
assert_eq!(config.ui.status_file_layout, FileLayout::List);
assert_eq!(config.ui.diff_file_layout, FileLayout::Tree);
assert!(!config.ui.side_by_side);
assert!(config.mcp.enabled);
assert_eq!(config.mcp.port, 8417);
assert_eq!(config.editor.command, None);
assert!(config.keys.status.is_empty());
assert!(config.keys.diff.is_empty());
assert!(config.keys.log.is_empty());
assert!(config.keys.commit.is_empty());
assert!(config.keys.branch.is_empty());
assert!(config.keys.log_menu.is_empty());
assert!(config.keys.push.is_empty());
assert!(config.keys.pull.is_empty());
assert!(config.keys.fetch.is_empty());
}
#[test]
fn no_files_no_cli_yields_defaults_with_empty_origins() {
let loaded = load_layers(None, None, &CliOverrides::default()).unwrap();
assert_eq!(loaded.config, Config::default());
assert!(loaded.origins.is_empty());
assert!(loaded.warnings.is_empty());
}
#[test]
fn missing_files_are_fine() {
let dir = tempfile::tempdir().unwrap();
let loaded = load_layers(
Some(&dir.path().join("nope.toml")),
Some(&dir.path().join("also-nope.toml")),
&CliOverrides::default(),
)
.unwrap();
assert_eq!(loaded.config, Config::default());
}
#[test]
fn precedence_default_global_project_cli_per_field() {
let dir = tempfile::tempdir().unwrap();
let global = dir.path().join("global.toml");
let project = dir.path().join("project.toml");
fs::write(
&global,
"[ui]\ntheme = \"global-theme\"\ncontext_lines = 5\n\n[mcp]\nport = 9000\n",
)
.unwrap();
fs::write(&project, "[ui]\ntheme = \"project-theme\"\n").unwrap();
let cli = CliOverrides {
port: Some(9999),
mcp_enabled: Some(false),
theme: None,
};
let loaded = load_layers(Some(&global), Some(&project), &cli).unwrap();
assert_eq!(loaded.config.ui.theme, "project-theme");
assert_eq!(loaded.config.ui.context_lines, 5);
assert_eq!(loaded.config.ui.recent_commits, 10);
assert_eq!(loaded.config.mcp.port, 9999);
assert!(!loaded.config.mcp.enabled);
assert_eq!(loaded.origins["ui.theme"], Origin::Project(project));
assert_eq!(loaded.origins["ui.context_lines"], Origin::Global(global));
assert_eq!(loaded.origins["mcp.port"], Origin::Cli);
assert_eq!(loaded.origins["mcp.enabled"], Origin::Cli);
assert!(!loaded.origins.contains_key("ui.recent_commits"));
}
#[test]
fn cli_theme_overrides_files() {
let dir = tempfile::tempdir().unwrap();
let project = dir.path().join("project.toml");
fs::write(&project, "[ui]\ntheme = \"project-theme\"\n").unwrap();
let cli = CliOverrides {
theme: Some("cli-theme".to_owned()),
..CliOverrides::default()
};
let loaded = load_layers(None, Some(&project), &cli).unwrap();
assert_eq!(loaded.config.ui.theme, "cli-theme");
assert_eq!(loaded.origins["ui.theme"], Origin::Cli);
}
#[test]
fn keys_maps_merge_per_entry() {
let dir = tempfile::tempdir().unwrap();
let global = dir.path().join("global.toml");
let project = dir.path().join("project.toml");
fs::write(
&global,
"[keys.status]\nquit = \"q\"\nrefresh = \"<c-r>\"\n\n[keys.diff]\nfold = \"<tab>\"\n",
)
.unwrap();
fs::write(&project, "[keys.status]\nrefresh = \"R\"\n").unwrap();
let loaded = load_layers(Some(&global), Some(&project), &CliOverrides::default()).unwrap();
assert_eq!(loaded.config.keys.status["quit"], "q");
assert_eq!(loaded.config.keys.status["refresh"], "R");
assert_eq!(loaded.config.keys.diff["fold"], "<tab>");
assert_eq!(loaded.origins["keys.status.quit"], Origin::Global(global));
assert_eq!(
loaded.origins["keys.status.refresh"],
Origin::Project(project)
);
}
#[test]
fn editor_command_layers() {
let dir = tempfile::tempdir().unwrap();
let global = dir.path().join("global.toml");
fs::write(&global, "[editor]\ncommand = \"hx\"\n").unwrap();
let loaded = load_layers(Some(&global), None, &CliOverrides::default()).unwrap();
assert_eq!(loaded.config.editor.command.as_deref(), Some("hx"));
assert_eq!(loaded.origins["editor.command"], Origin::Global(global));
}
#[test]
fn file_layouts_default_to_status_list_and_diff_tree() {
let loaded = load_layers(None, None, &CliOverrides::default()).unwrap();
assert_eq!(loaded.config.ui.status_file_layout, FileLayout::List);
assert_eq!(loaded.config.ui.diff_file_layout, FileLayout::Tree);
}
#[test]
fn file_layouts_override_in_either_direction() {
let dir = tempfile::tempdir().unwrap();
let project = dir.path().join("project.toml");
fs::write(
&project,
"[ui]\nstatus_file_layout = \"tree\"\ndiff_file_layout = \"list\"\n",
)
.unwrap();
let loaded = load_layers(None, Some(&project), &CliOverrides::default()).unwrap();
assert_eq!(loaded.config.ui.status_file_layout, FileLayout::Tree);
assert_eq!(loaded.config.ui.diff_file_layout, FileLayout::List);
assert_eq!(
loaded.origins["ui.status_file_layout"],
Origin::Project(project.clone())
);
assert_eq!(
loaded.origins["ui.diff_file_layout"],
Origin::Project(project)
);
assert!(loaded.warnings.is_empty());
}
#[test]
fn unknown_file_layout_warns_and_keeps_the_default() {
let dir = tempfile::tempdir().unwrap();
let project = dir.path().join("project.toml");
fs::write(&project, "[ui]\nstatus_file_layout = \"nope\"\n").unwrap();
let loaded = load_layers(None, Some(&project), &CliOverrides::default()).unwrap();
assert_eq!(loaded.config.ui.status_file_layout, FileLayout::List);
assert!(!loaded.origins.contains_key("ui.status_file_layout"));
assert_eq!(loaded.warnings.len(), 1);
let warning = &loaded.warnings[0];
assert!(warning.contains("nope"), "names the bad value: {warning}");
assert!(warning.contains("list"), "names the fallback: {warning}");
}
#[test]
fn bad_toml_error_includes_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("broken.toml");
fs::write(&path, "[ui\ntheme = ").unwrap();
let err = load_layers(Some(&path), None, &CliOverrides::default()).unwrap_err();
let message = err.to_string();
assert!(
message.contains(&path.display().to_string()),
"error display should name the file: {message}"
);
}
#[test]
fn type_error_includes_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("typed.toml");
fs::write(&path, "[mcp]\nport = \"not-a-number\"\n").unwrap();
let err = load_layers(Some(&path), None, &CliOverrides::default()).unwrap_err();
assert!(err.to_string().contains(&path.display().to_string()));
}
#[test]
fn unknown_keys_warn_but_do_not_fail() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("extra.toml");
fs::write(
&path,
"[ui]\ntheme = \"t\"\ntypo_key = 1\n\n[surprise]\nx = 1\n",
)
.unwrap();
let loaded = load_layers(Some(&path), None, &CliOverrides::default()).unwrap();
assert_eq!(loaded.config.ui.theme, "t");
assert_eq!(loaded.warnings.len(), 2);
assert!(loaded.warnings.iter().any(|w| w.contains("ui.typo_key")));
assert!(loaded.warnings.iter().any(|w| w.contains("surprise")));
assert!(
loaded
.warnings
.iter()
.all(|w| w.contains(&path.display().to_string()))
);
}
#[test]
fn xdg_config_home_wins_over_home() {
let path = global_config_path_from(
Some(OsString::from("/xdg")),
Some(OsString::from("/home/u")),
);
assert_eq!(path, Some(PathBuf::from("/xdg/diffler/config.toml")));
}
#[test]
fn empty_xdg_falls_back_to_home_dot_config() {
let path = global_config_path_from(Some(OsString::new()), Some(OsString::from("/home/u")));
assert_eq!(
path,
Some(PathBuf::from("/home/u/.config/diffler/config.toml"))
);
let path = global_config_path_from(None, Some(OsString::from("/home/u")));
assert_eq!(
path,
Some(PathBuf::from("/home/u/.config/diffler/config.toml"))
);
}
#[test]
fn no_home_no_xdg_means_no_global_file() {
assert_eq!(global_config_path_from(None, None), None);
}
#[test]
fn dump_lists_merged_values_and_origins() {
let dir = tempfile::tempdir().unwrap();
let project = dir.path().join("project.toml");
fs::write(
&project,
"[ui]\ntheme = \"dark\"\n\n[keys.status]\nquit = \"q\"\n",
)
.unwrap();
let cli = CliOverrides {
port: Some(1234),
..CliOverrides::default()
};
let loaded = load_layers(None, Some(&project), &cli).unwrap();
let dump = render_dump(&loaded).unwrap();
assert!(dump.contains("theme = \"dark\""));
assert!(dump.contains("port = 1234"));
assert!(dump.contains(&format!("# ui.theme = project:{}", project.display())));
assert!(dump.contains("# mcp.port = cli"));
assert!(dump.contains("# ui.context_lines = default"));
assert!(dump.contains(&format!(
"# keys.status.quit = project:{}",
project.display()
)));
}
#[test]
fn chord_plain_char() {
assert_eq!(
parse_chord("q").unwrap(),
vec![press(KeyCode::Char('q'), false, false, false)]
);
}
#[test]
fn chord_uppercase_implies_shift() {
assert_eq!(
parse_chord("V").unwrap(),
vec![press(KeyCode::Char('V'), false, false, true)]
);
}
#[test]
fn chord_ctrl_combo() {
assert_eq!(
parse_chord("<c-r>").unwrap(),
vec![press(KeyCode::Char('r'), true, false, false)]
);
}
#[test]
fn chord_alt_combo() {
assert_eq!(
parse_chord("<a-x>").unwrap(),
vec![press(KeyCode::Char('x'), false, true, false)]
);
}
#[test]
fn chord_named_keys() {
assert_eq!(
parse_chord("<cr>").unwrap(),
vec![press(KeyCode::Enter, false, false, false)]
);
assert_eq!(
parse_chord("<tab>").unwrap(),
vec![press(KeyCode::Tab, false, false, false)]
);
assert_eq!(
parse_chord("<esc>").unwrap(),
vec![press(KeyCode::Esc, false, false, false)]
);
assert_eq!(
parse_chord("<space>").unwrap(),
vec![press(KeyCode::Char(' '), false, false, false)]
);
}
#[test]
fn chord_shift_enter() {
assert_eq!(
parse_chord("<s-cr>").unwrap(),
vec![press(KeyCode::Enter, false, false, true)]
);
}
#[test]
fn chord_stacked_modifiers() {
assert_eq!(
parse_chord("<c-s-x>").unwrap(),
vec![press(KeyCode::Char('X'), true, false, true)]
);
}
#[test]
fn chord_two_key_sequences() {
assert_eq!(
parse_chord("cc").unwrap(),
vec![
press(KeyCode::Char('c'), false, false, false),
press(KeyCode::Char('c'), false, false, false),
]
);
assert_eq!(
parse_chord("<c-x><c-c>").unwrap(),
vec![
press(KeyCode::Char('x'), true, false, false),
press(KeyCode::Char('c'), true, false, false),
]
);
assert_eq!(
parse_chord("g<cr>").unwrap(),
vec![
press(KeyCode::Char('g'), false, false, false),
press(KeyCode::Enter, false, false, false),
]
);
}
#[test]
fn chord_invalid_inputs() {
assert_eq!(parse_chord(""), Err(ChordError::Empty));
assert_eq!(
parse_chord("<c-"),
Err(ChordError::Unterminated("<c-".to_owned()))
);
assert_eq!(
parse_chord("<weird>"),
Err(ChordError::UnknownKey("<weird>".to_owned()))
);
assert_eq!(
parse_chord("<>"),
Err(ChordError::UnknownKey("<>".to_owned()))
);
}
#[test]
fn chord_shift_letter_normalizes_to_uppercase() {
assert_eq!(parse_chord("<s-a>").unwrap(), parse_chord("A").unwrap());
assert_eq!(parse_chord("<s-z>").unwrap(), parse_chord("Z").unwrap());
assert_eq!(parse_chord("<s-A>").unwrap(), parse_chord("A").unwrap());
}
#[test]
fn bad_chord_in_keys_warns_and_drops_entry() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("keys.toml");
fs::write(
&path,
"[keys.status]\nquit = \"q\"\nrefresh = \"<ctlr-r>\"\n",
)
.unwrap();
let loaded = load_layers(Some(&path), None, &CliOverrides::default()).unwrap();
assert_eq!(loaded.config.keys.status["quit"], "q");
assert!(!loaded.config.keys.status.contains_key("refresh"));
assert_eq!(loaded.warnings.len(), 1, "expected exactly one warning");
let w = &loaded.warnings[0];
assert!(
w.contains("keys.status.refresh"),
"warning should name the key path: {w}"
);
assert!(
w.contains("<ctlr-r>"),
"warning should quote the bad chord: {w}"
);
}
#[test]
fn valid_chords_in_keys_stored_without_warnings() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("keys_ok.toml");
fs::write(&path, "[keys.diff]\nfold = \"<tab>\"\nnext = \"j\"\n").unwrap();
let loaded = load_layers(Some(&path), None, &CliOverrides::default()).unwrap();
assert_eq!(loaded.config.keys.diff["fold"], "<tab>");
assert_eq!(loaded.config.keys.diff["next"], "j");
assert!(loaded.warnings.is_empty());
}
}