use anyhow::{Context, Result};
use bmux_config::{BmuxConfig, ConfigPaths};
use bmux_keybind::{RuntimeAction, parse_action};
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use crate::input::{ModalModeConfig, canonical_chord_key};
use crate::runtime::attach::runtime::filtered_attach_keybindings;
fn config_file_path() -> std::path::PathBuf {
ConfigPaths::default().config_file()
}
pub(super) fn run_config_path(as_json: bool) -> Result<u8> {
let paths = ConfigPaths::default();
let path = paths.config_file();
let exists = path.exists();
if as_json {
let candidates: Vec<serde_json::Value> = paths
.config_dir_candidates()
.iter()
.map(|dir| {
let file = dir.join("bmux.toml");
let file_exists = file.exists();
serde_json::json!({
"path": file,
"exists": file_exists,
})
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"path": path,
"exists": exists,
"candidates": candidates,
}))
.context("failed to encode config path json")?
);
return Ok(0);
}
if exists {
println!("{}", path.display());
} else {
println!("{} (does not exist)", path.display());
}
Ok(0)
}
pub(super) fn run_config_show(as_json: bool) -> Result<u8> {
let config = BmuxConfig::load().map_err(|e| anyhow::anyhow!("{e}"))?;
if as_json {
println!(
"{}",
serde_json::to_string_pretty(
&toml::Value::try_from(&config).context("failed to serialize config")?
)
.context("failed to encode config json")?
);
return Ok(0);
}
let toml_str = toml::to_string_pretty(&config).context("failed to serialize config to TOML")?;
print!("{toml_str}");
Ok(0)
}
pub(super) fn run_config_get(key: &str, as_json: bool) -> Result<u8> {
let config = BmuxConfig::load().map_err(|e| anyhow::anyhow!("{e}"))?;
let table =
toml::Value::try_from(&config).context("failed to serialize config to TOML value")?;
let value = resolve_dotted_key(&table, key).with_context(|| format!("key not found: {key}"))?;
if as_json {
println!(
"{}",
serde_json::to_string_pretty(&toml_value_to_json(value))
.context("failed to encode value as json")?
);
return Ok(0);
}
match value {
toml::Value::String(s) => println!("{s}"),
toml::Value::Integer(i) => println!("{i}"),
toml::Value::Float(f) => println!("{f}"),
toml::Value::Boolean(b) => println!("{b}"),
toml::Value::Array(_) | toml::Value::Table(_) => {
print!(
"{}",
toml::to_string_pretty(value).context("failed to format value as TOML")?
);
}
toml::Value::Datetime(dt) => println!("{dt}"),
}
Ok(0)
}
pub(super) fn run_config_set(key: &str, raw_value: &str) -> Result<u8> {
let paths = ConfigPaths::default();
let path = paths.config_file();
let source = if path.exists() {
std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?
} else {
String::new()
};
let mut doc: toml_edit::DocumentMut = source
.parse()
.with_context(|| format!("failed to parse {}", path.display()))?;
let parsed_value = parse_cli_value(raw_value);
set_dotted_key(&mut doc, key, parsed_value)
.with_context(|| format!("failed to set key: {key}"))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create config directory {}", parent.display()))?;
}
std::fs::write(&path, doc.to_string())
.with_context(|| format!("failed to write {}", path.display()))?;
println!("{key} = {raw_value}");
Ok(0)
}
pub(super) fn run_config_profiles_list(as_json: bool) -> Result<u8> {
let (_config, resolution) =
BmuxConfig::load_with_resolution().map_err(|e| anyhow::anyhow!("{e}"))?;
if as_json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"profiles": resolution.available_profiles,
}))
.context("failed to encode profiles json")?
);
} else {
for profile in resolution.available_profiles {
println!("{profile}");
}
}
Ok(0)
}
pub(super) fn run_config_profiles_show(profile: &str, as_json: bool) -> Result<u8> {
let path = config_file_path();
let (config, resolution) = BmuxConfig::load_from_path_with_resolution(&path, Some(profile))
.map_err(|e| anyhow::anyhow!("{e}"))?;
let value = toml::Value::try_from(&config).context("failed to serialize config")?;
if as_json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"profile": profile,
"resolution": resolution,
"resolved_config": value,
}))
.context("failed to encode profile json")?
);
} else {
println!("profile: {profile}");
if let Some(source) = resolution.selected_profile_source {
println!("source: {source}");
}
let rendered = toml::to_string_pretty(&value).context("failed to render profile config")?;
print!("{rendered}");
}
Ok(0)
}
pub(super) fn run_config_profiles_resolve(profile: Option<&str>, as_json: bool) -> Result<u8> {
let path = config_file_path();
let (_config, resolution) = BmuxConfig::load_from_path_with_resolution(&path, profile)
.map_err(|e| anyhow::anyhow!("{e}"))?;
if as_json {
println!(
"{}",
serde_json::to_string_pretty(&resolution)
.context("failed to encode resolution json")?
);
} else {
println!(
"selected_profile: {}",
resolution.selected_profile.as_deref().unwrap_or("<none>")
);
println!(
"source: {}",
resolution
.selected_profile_source
.as_deref()
.unwrap_or("<none>")
);
if let Some(index) = resolution.matched_auto_select_index {
println!("matched_auto_select_index: {index}");
}
println!("layer_order: {}", resolution.layer_order.join(" -> "));
}
Ok(0)
}
pub(super) fn run_config_profiles_explain(profile: Option<&str>, as_json: bool) -> Result<u8> {
let path = config_file_path();
let (_config, explain) = BmuxConfig::load_from_path_with_explain(&path, profile)
.map_err(|e| anyhow::anyhow!("{e}"))?;
if as_json {
println!(
"{}",
serde_json::to_string_pretty(&explain).context("failed to encode explain json")?
);
return Ok(0);
}
println!(
"selected_profile: {}",
explain
.resolution
.selected_profile
.as_deref()
.unwrap_or("<none>")
);
println!(
"source: {}",
explain
.resolution
.selected_profile_source
.as_deref()
.unwrap_or("<none>")
);
println!(
"layer_order: {}",
explain.resolution.layer_order.join(" -> ")
);
println!("applied layers:");
for layer in &explain.applied_layers {
if layer.changed_paths.is_empty() {
println!(" - {}: no changes", layer.layer);
continue;
}
println!(
" - {}: {} changed paths",
layer.layer,
layer.changed_paths.len()
);
for path in layer.changed_paths.iter().take(12) {
println!(" {path}");
}
if layer.changed_paths.len() > 12 {
println!(" ... {} more", layer.changed_paths.len() - 12);
}
}
Ok(0)
}
pub(super) fn run_config_profiles_diff(from: &str, to: &str, as_json: bool) -> Result<u8> {
let path = config_file_path();
let (from_config, _) = BmuxConfig::load_from_path_with_resolution(&path, Some(from))
.map_err(|e| anyhow::anyhow!("{e}"))?;
let (to_config, _) = BmuxConfig::load_from_path_with_resolution(&path, Some(to))
.map_err(|e| anyhow::anyhow!("{e}"))?;
let from_value =
toml::Value::try_from(&from_config).context("failed to serialize from config")?;
let to_value = toml::Value::try_from(&to_config).context("failed to serialize to config")?;
let changed_paths = diff_toml_paths(&from_value, &to_value);
let top_level_changes = summarize_top_level_changes(&changed_paths);
if as_json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"from": from,
"to": to,
"changed_paths": changed_paths,
"top_level_changes": top_level_changes,
"from_config": from_value,
"to_config": to_value,
}))
.context("failed to encode diff json")?
);
} else {
println!("from profile: {from}");
println!("to profile: {to}");
if changed_paths.is_empty() {
println!("no differences");
} else {
println!("resolved configurations differ");
println!("changed paths: {}", changed_paths.len());
if !top_level_changes.is_empty() {
println!("top-level sections changed:");
for (section, count) in top_level_changes {
println!(" - {section}: {count}");
}
}
for path in changed_paths.iter().take(20) {
println!(" - {path}");
}
if changed_paths.len() > 20 {
println!(" ... {} more", changed_paths.len() - 20);
}
}
}
Ok(0)
}
pub(super) fn run_config_profiles_lint(as_json: bool) -> Result<u8> {
let path = config_file_path();
let (config, resolution) = BmuxConfig::load_from_path_with_resolution(&path, None)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let mut warnings = Vec::new();
warnings.extend(unreachable_mode_warnings(&config));
warnings.extend(global_vs_mode_conflict_warnings(&config));
let timeout_ms = config
.keybindings
.resolve_timeout()
.map_err(anyhow::Error::msg)
.context("failed resolving keymap timeout")?
.timeout_ms();
let (_runtime_bindings, global_bindings, scroll_bindings) =
filtered_attach_keybindings(&config);
let modal_modes = config
.keybindings
.modes
.iter()
.map(|(mode_id, mode)| {
(
mode_id.clone(),
ModalModeConfig {
label: mode.label.clone(),
passthrough: mode.passthrough,
bindings: mode.bindings.clone(),
},
)
})
.collect::<BTreeMap<_, _>>();
let keymap = crate::input::Keymap::from_modal_parts_with_scroll(
timeout_ms,
&config.keybindings.initial_mode,
&modal_modes,
&global_bindings,
&scroll_bindings,
)
.context("failed compiling modal keymap for lint")?;
warnings.extend(keymap.overlap_warnings());
if as_json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"ok": true,
"available_profiles": resolution.available_profiles,
"warning_count": warnings.len(),
"warnings": warnings,
}))
.context("failed to encode lint json")?
);
} else {
println!(
"ok: {} profiles validated",
resolution.available_profiles.len()
);
if warnings.is_empty() {
println!("lint warnings: none");
} else {
println!("lint warnings: {}", warnings.len());
for warning in warnings {
println!(" - {warning}");
}
}
}
Ok(0)
}
pub(super) fn run_config_profiles_evaluate(as_json: bool) -> Result<u8> {
let path = config_file_path();
let (_config, resolution) = BmuxConfig::load_from_path_with_resolution(&path, None)
.map_err(|e| anyhow::anyhow!("{e}"))?;
if as_json {
println!(
"{}",
serde_json::to_string_pretty(&resolution).context("failed to encode evaluate json")?
);
} else {
println!(
"selected profile: {}",
resolution.selected_profile.as_deref().unwrap_or("<none>")
);
if let Some(index) = resolution.matched_auto_select_index {
println!("matched auto_select rule: {index}");
}
}
Ok(0)
}
pub(super) fn run_config_profiles_switch(
profile: &str,
dry_run: bool,
as_json: bool,
) -> Result<u8> {
let path = config_file_path();
let (before_config, before_resolution) =
BmuxConfig::load_from_path_with_resolution(&path, None)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let (after_config, after_resolution) =
BmuxConfig::load_from_path_with_resolution(&path, Some(profile))
.map_err(|e| anyhow::anyhow!("{e}"))?;
let before_value =
toml::Value::try_from(&before_config).context("failed to serialize previous config")?;
let after_value =
toml::Value::try_from(&after_config).context("failed to serialize switched config")?;
let changed_paths = diff_toml_paths(&before_value, &after_value);
if !dry_run {
run_config_profiles_set_active(profile)?;
}
if as_json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"dry_run": dry_run,
"requested_profile": profile,
"previous_profile": before_resolution.selected_profile,
"next_profile": after_resolution.selected_profile,
"changed_paths": changed_paths,
"changed_path_count": changed_paths.len(),
"wrote_config": !dry_run,
}))
.context("failed to encode switch json")?
);
return Ok(0);
}
if dry_run {
println!("dry-run: switch_profile {profile}");
} else {
println!("switched active profile to {profile}");
}
println!("changed paths: {}", changed_paths.len());
for path in changed_paths.iter().take(16) {
println!(" - {path}");
}
if changed_paths.len() > 16 {
println!(" ... {} more", changed_paths.len() - 16);
}
Ok(0)
}
pub(super) fn run_config_profiles_set_active(profile: &str) -> Result<()> {
let path = ConfigPaths::default().config_file();
run_config_profiles_set_active_at_path(profile, &path)
}
pub(super) fn run_config_profiles_set_active_at_path(
profile: &str,
path: &std::path::Path,
) -> Result<()> {
let source = if path.exists() {
std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?
} else {
String::new()
};
let mut doc: toml_edit::DocumentMut = source
.parse()
.with_context(|| format!("failed to parse {}", path.display()))?;
set_dotted_key(
&mut doc,
"composition.active_profile",
toml_edit::value(profile)
.into_value()
.expect("string value"),
)
.context("failed setting composition.active_profile")?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create config directory {}", parent.display()))?;
}
std::fs::write(path, doc.to_string())
.with_context(|| format!("failed to write {}", path.display()))?;
Ok(())
}
fn resolve_dotted_key<'a>(table: &'a toml::Value, key: &str) -> Option<&'a toml::Value> {
let mut current = table;
for segment in key.split('.') {
current = current.as_table()?.get(segment)?;
}
Some(current)
}
fn toml_value_to_json(value: &toml::Value) -> serde_json::Value {
match value {
toml::Value::String(s) => serde_json::Value::String(s.clone()),
toml::Value::Integer(i) => serde_json::json!(i),
toml::Value::Float(f) => serde_json::json!(f),
toml::Value::Boolean(b) => serde_json::json!(b),
toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
toml::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(toml_value_to_json).collect())
}
toml::Value::Table(tbl) => {
let map = tbl
.iter()
.map(|(k, v)| (k.clone(), toml_value_to_json(v)))
.collect();
serde_json::Value::Object(map)
}
}
}
fn collect_toml_diff_paths(
before: Option<&toml::Value>,
after: Option<&toml::Value>,
prefix: &str,
out: &mut BTreeSet<String>,
) {
match (before, after) {
(None, None) => {}
(Some(left), Some(right)) if left == right => {}
(Some(toml::Value::Table(left)), Some(toml::Value::Table(right))) => {
let keys = left
.keys()
.chain(right.keys())
.cloned()
.collect::<BTreeSet<_>>();
for key in keys {
let next_prefix = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
collect_toml_diff_paths(left.get(&key), right.get(&key), &next_prefix, out);
}
}
_ => {
if prefix.is_empty() {
out.insert("<root>".to_string());
} else {
out.insert(prefix.to_string());
}
}
}
}
fn diff_toml_paths(before: &toml::Value, after: &toml::Value) -> Vec<String> {
let mut out = BTreeSet::new();
collect_toml_diff_paths(Some(before), Some(after), "", &mut out);
out.into_iter().collect()
}
fn summarize_top_level_changes(changed_paths: &[String]) -> Vec<(String, usize)> {
let mut counts = BTreeMap::<String, usize>::new();
for path in changed_paths {
let section = path
.split('.')
.next()
.map_or_else(|| path.clone(), std::string::ToString::to_string);
*counts.entry(section).or_default() += 1;
}
counts.into_iter().collect()
}
fn unreachable_mode_warnings(config: &BmuxConfig) -> Vec<String> {
let known_modes = config
.keybindings
.modes
.keys()
.map(|id| id.to_ascii_lowercase())
.collect::<BTreeSet<_>>();
let mode_lookup = config
.keybindings
.modes
.iter()
.map(|(id, mode)| (id.to_ascii_lowercase(), mode))
.collect::<BTreeMap<_, _>>();
let mut visited = BTreeSet::new();
let mut queue = VecDeque::new();
let initial = config.keybindings.initial_mode.to_ascii_lowercase();
queue.push_back(initial.clone());
visited.insert(initial);
while let Some(mode_id) = queue.pop_front() {
let Some(mode) = mode_lookup.get(&mode_id) else {
continue;
};
for action_name in mode.bindings.values() {
if let Ok(RuntimeAction::EnterMode(target_mode)) = parse_action(action_name) {
let canonical = target_mode.to_ascii_lowercase();
if known_modes.contains(&canonical) && !visited.contains(&canonical) {
visited.insert(canonical.clone());
queue.push_back(canonical);
}
}
}
}
known_modes
.iter()
.filter(|mode_id| !visited.contains(*mode_id))
.map(|mode_id| {
format!(
"mode '{mode_id}' is unreachable from initial_mode '{}'",
config.keybindings.initial_mode
)
})
.collect()
}
fn global_vs_mode_conflict_warnings(config: &BmuxConfig) -> Vec<String> {
let global = config
.keybindings
.global
.keys()
.map(|key| canonical_chord_key(key))
.collect::<BTreeSet<_>>();
let mut warnings = Vec::new();
for (mode_id, mode) in &config.keybindings.modes {
for key in mode.bindings.keys() {
let canonical = canonical_chord_key(key);
if global.contains(&canonical) {
warnings.push(format!(
"global chord '{canonical}' overrides mode '{mode_id}' chord '{key}'"
));
}
}
}
warnings
}
fn parse_cli_value(raw: &str) -> toml_edit::Value {
if raw.eq_ignore_ascii_case("true") {
return toml_edit::value(true).into_value().unwrap();
}
if raw.eq_ignore_ascii_case("false") {
return toml_edit::value(false).into_value().unwrap();
}
if let Ok(i) = raw.parse::<i64>() {
return toml_edit::value(i).into_value().unwrap();
}
if let Ok(f) = raw.parse::<f64>()
&& raw.contains('.')
{
return toml_edit::value(f).into_value().unwrap();
}
toml_edit::value(raw).into_value().unwrap()
}
fn set_dotted_key(
doc: &mut toml_edit::DocumentMut,
key: &str,
value: toml_edit::Value,
) -> Result<()> {
let segments: Vec<&str> = key.split('.').collect();
if segments.is_empty() {
anyhow::bail!("key must not be empty");
}
let mut table = doc.as_table_mut();
for &segment in &segments[..segments.len() - 1] {
if !table.contains_key(segment) {
table.insert(segment, toml_edit::Item::Table(toml_edit::Table::new()));
}
match table.get_mut(segment) {
Some(toml_edit::Item::Table(t)) => table = t,
Some(_) => anyhow::bail!(
"cannot traverse into non-table key '{segment}' while setting '{key}'"
),
None => unreachable!(),
}
}
let leaf = segments.last().unwrap();
table[*leaf] = toml_edit::Item::Value(value);
Ok(())
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
#[test]
fn resolve_dotted_key_traverses_nested_tables() {
let input: toml::Value = toml::from_str(
r"
[status_bar]
max_tabs = 8
[behavior]
[behavior.mouse]
enabled = true
",
)
.unwrap();
assert_eq!(
resolve_dotted_key(&input, "status_bar.max_tabs"),
Some(&toml::Value::Integer(8))
);
assert_eq!(
resolve_dotted_key(&input, "behavior.mouse.enabled"),
Some(&toml::Value::Boolean(true))
);
assert!(resolve_dotted_key(&input, "nonexistent.key").is_none());
}
#[test]
#[allow(clippy::approx_constant)] fn parse_cli_value_detects_types() {
assert!(parse_cli_value("true").as_bool().unwrap());
assert!(!parse_cli_value("false").as_bool().unwrap());
assert!(!parse_cli_value("FALSE").as_bool().unwrap());
assert_eq!(parse_cli_value("42").as_integer().unwrap(), 42);
assert!((parse_cli_value("3.14").as_float().unwrap() - 3.14).abs() < f64::EPSILON);
assert_eq!(parse_cli_value("hello").as_str().unwrap(), "hello");
}
#[test]
fn set_dotted_key_creates_intermediate_tables() {
let mut doc: toml_edit::DocumentMut = "".parse().unwrap();
set_dotted_key(
&mut doc,
"status_bar.colors.tab_active_bg",
toml_edit::value("dark").into_value().unwrap(),
)
.unwrap();
let output = doc.to_string();
assert!(output.contains("[status_bar.colors]"));
assert!(output.contains("tab_active_bg = \"dark\""));
}
#[test]
fn set_dotted_key_preserves_existing_content() {
let mut doc: toml_edit::DocumentMut = "[general]\nshell = \"bash\"\n".parse().unwrap();
set_dotted_key(
&mut doc,
"general.scrollback_limit",
toml_edit::value(5000).into_value().unwrap(),
)
.unwrap();
let output = doc.to_string();
assert!(output.contains("shell = \"bash\""));
assert!(output.contains("scrollback_limit = 5000"));
}
}