use std::collections::BTreeMap;
use std::process::Command;
fn herdr_bin() -> String {
std::env::var("HERDR_BIN_PATH")
.ok()
.filter(|path| !path.is_empty())
.unwrap_or_else(|| "herdr".to_string())
}
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_bin()).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 parsed: toml::Value = toml::from_str(&format!("value = {value}")).ok()?;
match parsed.get("value")? {
toml::Value::String(scalar) => Some(if scalar.is_empty() {
Vec::new()
} else {
vec![scalar.clone()]
}),
toml::Value::Array(values) => Some(
values
.iter()
.filter_map(|value| value.as_str())
.filter(|key| !key.trim().is_empty())
.map(str::to_string)
.collect(),
),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::sync::Mutex;
use tempfile::tempdir;
static HERDR_BIN_LOCK: Mutex<()> = Mutex::new(());
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 parses_inline_comments_after_binding_values() {
let actions = parse_default_config(
"[keys]\n# remote_image_paste = \"ctrl+v\" # only active in herdr --remote\n# split_vertical = [\"prefix+v\", \"prefix+|\"] # aliases\n",
);
assert_eq!(
actions.get("remote_image_paste"),
Some(&vec!["ctrl+v".to_string()])
);
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();
}
#[test]
fn herdr_bin_prefers_non_empty_herdr_bin_path() {
let _guard = HERDR_BIN_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let previous = std::env::var_os("HERDR_BIN_PATH");
std::env::set_var("HERDR_BIN_PATH", "/tmp/custom-herdr-bin");
assert_eq!(herdr_bin(), "/tmp/custom-herdr-bin");
std::env::set_var("HERDR_BIN_PATH", "");
assert_eq!(herdr_bin(), "herdr");
match previous {
Some(value) => std::env::set_var("HERDR_BIN_PATH", value),
None => std::env::remove_var("HERDR_BIN_PATH"),
}
}
#[test]
fn discover_uses_herdr_bin_path_when_set() {
let _guard = HERDR_BIN_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let previous = std::env::var_os("HERDR_BIN_PATH");
let tmp = tempdir().expect("tempdir");
let script = tmp.path().join("fake_herdr");
fs::write(
&script,
"#!/bin/sh\nif [ \"$1\" = \"--default-config\" ]; then\ncat <<'EOF'\n[keys]\n# help = \"prefix+?\"\n# unique_from_fake_herdr = \"prefix+zz\"\nEOF\nexit 0\nfi\nexit 1\n",
)
.expect("write fake herdr");
let mut perms = fs::metadata(&script).expect("meta").permissions();
perms.set_mode(0o755);
fs::set_permissions(&script, perms).expect("chmod");
std::env::set_var("HERDR_BIN_PATH", &script);
let actions = discover_default_config_actions();
match previous {
Some(value) => std::env::set_var("HERDR_BIN_PATH", value),
None => std::env::remove_var("HERDR_BIN_PATH"),
}
assert_eq!(
actions.get("unique_from_fake_herdr"),
Some(&vec!["prefix+zz".to_string()]),
"discovery must invoke HERDR_BIN_PATH, not a bare PATH herdr"
);
assert_eq!(actions.get("help"), Some(&vec!["prefix+?".to_string()]));
}
}