use std::collections::BTreeMap;
use std::process::Command;
const NON_ACTION_KEYS: &[&str] = &[
"prefix",
"key",
"type",
"command",
"name",
"args",
"directory",
"tabs",
"workspaces",
"agents",
];
pub fn discover_default_config_actions() -> BTreeMap<String, Vec<String>> {
let output = match Command::new("herdr").arg("--default-config").output() {
Ok(output) if output.status.success() => output,
_ => return BTreeMap::new(),
};
let Ok(text) = String::from_utf8(output.stdout) else {
return BTreeMap::new();
};
parse_default_config(&text)
}
pub fn parse_default_config(text: &str) -> BTreeMap<String, Vec<String>> {
let mut actions = BTreeMap::new();
let mut in_keys = false;
for raw in text.lines() {
let line = raw.trim_start();
if let Some(header) = line.strip_prefix('[') {
in_keys = header.trim_start_matches('[').trim() == "keys]";
continue;
}
if !in_keys {
continue;
}
let Some(rest) = line.strip_prefix('#') else {
continue;
};
let rest = rest.trim();
let Some(eq) = rest.find('=') else {
continue;
};
let name = rest[..eq].trim();
if !name.chars().next().is_some_and(|c| c.is_ascii_lowercase())
|| NON_ACTION_KEYS.contains(&name)
{
continue;
}
let value = rest[eq + 1..].trim();
let Some(keys) = parse_binding_value(value) else {
continue;
};
actions.insert(name.to_string(), keys);
}
actions
}
fn parse_binding_value(value: &str) -> Option<Vec<String>> {
let value = value.trim();
if let Some(inner) = value.strip_prefix('[').and_then(|v| v.strip_suffix(']')) {
let mut keys = Vec::new();
for part in inner.split(',') {
let key = part.trim().trim_matches('"').to_string();
if !key.is_empty() {
keys.push(key);
}
}
return Some(keys);
}
let scalar = value.trim_matches('"').to_string();
Some(if scalar.is_empty() {
Vec::new()
} else {
vec![scalar]
})
}
#[cfg(test)]
mod tests {
use super::*;
const FIXTURE: &str = "\
# herdr configuration
[theme]
# name = \"catppuccin\"
[keys]
# prefix = \"ctrl+b\"
# help = \"prefix+?\"
# split_vertical = \"prefix+v\"
# focus_agent = \"\"
# switch_tab = \"prefix+1..9\"
# Legacy indexed shortcut config is still parsed for compatibility.
[keys.indexed]
# tabs = \"\"
# workspaces = \"\"
# agents = \"\"
# Custom commands use the same binding syntax.
[[keys.command]]
# key = \"prefix+alt+g\"
# type = \"pane\"
# command = \"lazygit\"
[ui]
# sidebar_width = 26
";
#[test]
fn parses_keys_actions_and_skips_structural_keys() {
let actions = parse_default_config(FIXTURE);
assert_eq!(
actions.get("help"),
Some(&vec!["prefix+?".to_string()]),
"scalar bindings parse"
);
assert_eq!(
actions.get("split_vertical"),
Some(&vec!["prefix+v".to_string()])
);
assert_eq!(
actions.get("focus_agent"),
Some(&Vec::new()),
"empty optional bindings map to an empty key list"
);
assert!(!actions.contains_key("prefix"), "prefix is not an action");
assert!(
!actions.contains_key("tabs") && !actions.contains_key("workspaces"),
"[keys.indexed] fields are skipped"
);
assert!(
!actions.contains_key("key") && !actions.contains_key("command"),
"[[keys.command]] fields are skipped"
);
assert!(
!actions.contains_key("sidebar_width"),
"non-[keys] sections are skipped"
);
}
#[test]
fn parses_array_bindings() {
let actions =
parse_default_config("[keys]\n# split_vertical = [\"prefix+v\", \"prefix+|\"]\n");
assert_eq!(
actions.get("split_vertical"),
Some(&vec!["prefix+v".to_string(), "prefix+|".to_string()])
);
}
#[test]
fn empty_text_yields_empty_map() {
assert!(parse_default_config("").is_empty());
assert!(parse_default_config("[theme]\n# name = \"catppuccin\"\n").is_empty());
}
#[test]
fn missing_binary_returns_empty_map_without_panicking() {
let actions = discover_default_config_actions();
let _ = actions.len();
}
}