use crate::hooks::{Hook, HookParseError, RawHook};
use crate::keymap::{apply_keymap_section, load_defaults, Keymap, KeymapLoadError};
use crate::render::BorderStyle;
use crate::terminal_state::{ClipboardPolicy, Osc52GetPolicy, Osc52SetPolicy};
use crate::theme::Theme;
use serde::Deserialize;
use std::path::{Path, PathBuf};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ScrollbackEviction {
#[default]
OldestLine,
LargestLine,
}
impl ScrollbackEviction {
pub fn as_str(self) -> &'static str {
match self {
ScrollbackEviction::OldestLine => "oldest_line",
ScrollbackEviction::LargestLine => "largest_line",
}
}
fn from_str(s: &str) -> Option<Self> {
match s {
"oldest_line" => Some(ScrollbackEviction::OldestLine),
"largest_line" => Some(ScrollbackEviction::LargestLine),
_ => None,
}
}
}
pub struct EzpnConfig {
pub border: BorderStyle,
pub shell: String,
pub scrollback: usize,
pub scrollback_bytes: usize,
pub scrollback_eviction: ScrollbackEviction,
pub show_status_bar: bool,
pub show_tab_bar: bool,
pub prefix_key: char,
pub clipboard: ClipboardPolicy,
pub clipboard_copy_command: Vec<String>,
pub clipboard_paste_command: Vec<String>,
pub theme: Theme,
pub status_bar: StatusBarConfig,
pub persist_scrollback: bool,
}
impl Default for EzpnConfig {
fn default() -> Self {
Self {
border: BorderStyle::Rounded,
shell: std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()),
scrollback: 10_000,
scrollback_bytes: DEFAULT_SCROLLBACK_BYTES,
scrollback_eviction: ScrollbackEviction::default(),
show_status_bar: true,
show_tab_bar: true,
prefix_key: 'b',
clipboard: ClipboardPolicy::default(),
clipboard_copy_command: Vec::new(),
clipboard_paste_command: Vec::new(),
theme: Theme::default_theme(),
status_bar: StatusBarConfig::default(),
persist_scrollback: false,
}
}
}
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StatusBarSide {
Left,
Right,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum HintMode {
#[default]
Auto,
Normal,
Prefix,
CopyMode,
Search,
}
impl HintMode {
fn from_str(s: &str) -> Option<Self> {
match s {
"auto" => Some(Self::Auto),
"normal" => Some(Self::Normal),
"prefix" => Some(Self::Prefix),
"copy_mode" => Some(Self::CopyMode),
"search" => Some(Self::Search),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KeyHint {
pub key: String,
pub label: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SegmentKind {
Builtin(String),
KeyHints {
mode: HintMode,
max_width: u16,
keys: Vec<KeyHint>,
},
Literal(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StatusBarSegment {
pub name: String,
pub kind: SegmentKind,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct StatusBarConfig {
pub left: Vec<String>,
pub right: Vec<String>,
pub segments: Vec<StatusBarSegment>,
}
impl StatusBarConfig {
#[allow(dead_code)]
pub fn segment(&self, name: &str) -> Option<&StatusBarSegment> {
self.segments.iter().find(|s| s.name == name)
}
}
const SCROLLBACK_MAX: usize = 100_000;
pub const DEFAULT_SCROLLBACK_BYTES: usize = 32 * 1024 * 1024;
pub const SCROLLBACK_BYTES_MAX: usize = 4 * 1024 * 1024 * 1024;
const KNOWN_GLOBAL_KEYS: &[&str] = &[
"border",
"shell",
"scrollback",
"scrollback_bytes",
"scrollback_eviction",
"status_bar",
"tab_bar",
"persist_scrollback",
];
const KNOWN_KEYS_KEYS: &[&str] = &["prefix"];
const KNOWN_CLIPBOARD_KEYS: &[&str] = &[
"osc52_set",
"osc52_get",
"osc52_max_bytes",
"copy_command",
"paste_command",
];
const KNOWN_THEME_KEYS: &[&str] = &[
"name",
"fg",
"bg",
"border",
"border_active",
"status_bg",
"status_fg",
"tab_active_bg",
"tab_active_fg",
"tab_inactive_fg",
"selection",
"search_match",
"broadcast_indicator",
"copy_mode_indicator",
];
const KNOWN_STATUS_BAR_KEYS: &[&str] = &["left", "right", "segments"];
const KNOWN_TOP_LEVEL: &[&str] = &[
"global",
"keys",
"hooks",
"keymap",
"clipboard",
"theme",
"status_bar",
];
#[derive(Debug, Default, Deserialize)]
struct RawConfig {
#[serde(default)]
global: Option<toml::Table>,
#[serde(default)]
keys: Option<toml::Table>,
#[serde(default)]
hooks: Vec<RawHook>,
#[serde(default)]
keymap: Option<toml::Table>,
#[serde(default)]
clipboard: Option<toml::Table>,
#[serde(default)]
theme: Option<toml::Table>,
#[serde(default)]
status_bar: Option<toml::Table>,
}
#[derive(Debug, Default, Deserialize)]
struct GlobalSection {
border: Option<String>,
shell: Option<String>,
scrollback: Option<i64>,
scrollback_bytes: Option<toml::Value>,
scrollback_eviction: Option<String>,
status_bar: Option<bool>,
tab_bar: Option<bool>,
persist_scrollback: Option<bool>,
}
#[derive(Debug, Default, Deserialize)]
struct KeysSection {
prefix: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct ClipboardSection {
osc52_set: Option<Osc52SetPolicy>,
osc52_get: Option<Osc52GetPolicy>,
osc52_max_bytes: Option<i64>,
copy_command: Option<Vec<String>>,
paste_command: Option<Vec<String>>,
}
pub fn load_config() -> EzpnConfig {
let path = match config_path() {
Some(p) => p,
None => return EzpnConfig::default(),
};
let contents = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => return EzpnConfig::default(),
};
parse_config(&contents, Some(&path))
}
pub fn load_hooks() -> Vec<Hook> {
let path = match config_path() {
Some(p) => p,
None => return Vec::new(),
};
let contents = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => return Vec::new(),
};
parse_hooks(&contents, Some(&path))
}
fn parse_hooks(contents: &str, source: Option<&Path>) -> Vec<Hook> {
if contents.trim().is_empty() {
return Vec::new();
}
let label = source
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<config>".into());
let raw: RawConfig = match toml::from_str(contents) {
Ok(r) => r,
Err(e) => {
let (line, col) = error_line_col(&e, contents);
eprintln!(
"config: {label}: malformed [[hooks]] at line {line}, column {col}: {msg}",
msg = e.message()
);
return Vec::new();
}
};
let mut out = Vec::with_capacity(raw.hooks.len());
for (i, raw_hook) in raw.hooks.into_iter().enumerate() {
match Hook::from_raw(raw_hook) {
Ok(h) => out.push(h),
Err(e) => emit_hook_error(&label, i, &e),
}
}
out
}
fn emit_hook_error(label: &str, index: usize, err: &HookParseError) {
eprintln!("config: {label}: [[hooks]][{index}] rejected: {err} — dropping entry");
}
pub fn load_keymap() -> Result<Keymap, KeymapLoadError> {
let mut km = load_defaults();
let path = match config_path() {
Some(p) => p,
None => return Ok(km),
};
let contents = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => return Ok(km),
};
apply_keymap_overrides(&mut km, &contents)?;
Ok(km)
}
fn apply_keymap_overrides(km: &mut Keymap, contents: &str) -> Result<(), KeymapLoadError> {
if !has_toml_table_header(contents) {
return Ok(());
}
let raw: RawConfig = match toml::from_str(contents) {
Ok(r) => r,
Err(_) => return Ok(()),
};
if let Some(km_tbl) = raw.keymap {
for warning in apply_keymap_section(km, &km_tbl)? {
eprintln!("config: {warning}");
}
}
Ok(())
}
fn parse_config(contents: &str, source: Option<&Path>) -> EzpnConfig {
let mut config = EzpnConfig::default();
let label = source
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<config>".into());
if has_toml_table_header(contents) {
match toml::from_str::<RawConfig>(contents) {
Ok(raw) => apply_raw(&mut config, raw, contents, &label),
Err(e) => {
let (line, col) = error_line_col(&e, contents);
eprintln!(
"config: malformed TOML in {label} at line {line}, column {col}: {msg}",
msg = e.message()
);
}
}
} else if !contents.trim().is_empty() {
eprintln!(
"config: {label} uses the legacy flat `key = value` format; \
please migrate to the [global] / [keys] schema. \
Legacy support will be removed in a future release."
);
apply_legacy_flat(&mut config, contents, &label);
}
config
}
fn apply_raw(config: &mut EzpnConfig, raw: RawConfig, source: &str, label: &str) {
if let Ok(top) = toml::from_str::<toml::Table>(source) {
for key in top.keys() {
if !KNOWN_TOP_LEVEL.contains(&key.as_str()) {
let (line, col) = locate_key(source, &[key]);
warn_unknown(label, key, line, col, "top-level");
}
}
}
if let Some(global_tbl) = raw.global {
for key in global_tbl.keys() {
if !KNOWN_GLOBAL_KEYS.contains(&key.as_str()) {
let (line, col) = locate_key(source, &["global", key]);
warn_unknown(label, key, line, col, "[global]");
}
}
match global_tbl.try_into::<GlobalSection>() {
Ok(g) => apply_global(config, g, label),
Err(e) => {
eprintln!("config: invalid [global] in {label}: {e}");
}
}
}
if let Some(keys_tbl) = raw.keys {
for key in keys_tbl.keys() {
if !KNOWN_KEYS_KEYS.contains(&key.as_str()) {
let (line, col) = locate_key(source, &["keys", key]);
warn_unknown(label, key, line, col, "[keys]");
}
}
match keys_tbl.try_into::<KeysSection>() {
Ok(k) => apply_keys(config, k, label),
Err(e) => {
eprintln!("config: invalid [keys] in {label}: {e}");
}
}
}
if let Some(clip_tbl) = raw.clipboard {
for key in clip_tbl.keys() {
if !KNOWN_CLIPBOARD_KEYS.contains(&key.as_str()) {
let (line, col) = locate_key(source, &["clipboard", key]);
warn_unknown(label, key, line, col, "[clipboard]");
}
}
match clip_tbl.try_into::<ClipboardSection>() {
Ok(c) => apply_clipboard(config, c, label),
Err(e) => {
eprintln!("config: invalid [clipboard] in {label}: {e}");
}
}
}
if let Some(theme_tbl) = raw.theme {
apply_theme(config, theme_tbl, source, label);
}
if let Some(sb_tbl) = raw.status_bar {
for key in sb_tbl.keys() {
if !KNOWN_STATUS_BAR_KEYS.contains(&key.as_str()) {
let (line, col) = locate_key(source, &["status_bar", key]);
warn_unknown(label, key, line, col, "[status_bar]");
}
}
apply_status_bar(config, sb_tbl, label);
}
}
fn apply_theme(config: &mut EzpnConfig, tbl: toml::Table, source: &str, label: &str) {
let only_name = tbl.len() == 1 && tbl.contains_key("name");
let has_name_only_no_fields =
tbl.contains_key("name") && tbl.keys().all(|k| matches!(k.as_str(), "name"));
if only_name || has_name_only_no_fields {
let raw_name = match tbl.get("name").and_then(|v| v.as_str()) {
Some(n) => n.to_string(),
None => {
eprintln!("config: {label}: [theme] name must be a string, ignoring");
return;
}
};
match Theme::builtin(&raw_name) {
Some(t) => config.theme = t,
None => eprintln!(
"config: {label}: unknown theme '{raw_name}' — built-ins are {known:?}",
known = Theme::builtin_names()
),
}
return;
}
for key in tbl.keys() {
if !KNOWN_THEME_KEYS.contains(&key.as_str()) {
let (line, col) = locate_key(source, &["theme", key]);
warn_unknown(label, key, line, col, "[theme]");
}
}
let mut wrapped = toml::Table::new();
wrapped.insert("theme".into(), toml::Value::Table(tbl));
let serialized = match toml::to_string(&wrapped) {
Ok(s) => s,
Err(e) => {
eprintln!("config: {label}: failed to re-encode [theme]: {e}");
return;
}
};
match Theme::from_toml(&serialized) {
Ok(t) => config.theme = t,
Err(e) => eprintln!("config: {label}: [theme] rejected: {e}"),
}
}
fn apply_status_bar(config: &mut EzpnConfig, tbl: toml::Table, label: &str) {
let mut sb = StatusBarConfig::default();
if let Some(v) = tbl.get("left") {
match value_as_string_array(v) {
Ok(list) => sb.left = list,
Err(why) => {
eprintln!("config: {label}: [status_bar] left invalid ({why}), ignoring");
}
}
}
if let Some(v) = tbl.get("right") {
match value_as_string_array(v) {
Ok(list) => sb.right = list,
Err(why) => {
eprintln!("config: {label}: [status_bar] right invalid ({why}), ignoring");
}
}
}
if let Some(toml::Value::Table(seg_tbl)) = tbl.get("segments") {
for (name, raw) in seg_tbl {
match parse_segment(name, raw) {
Ok(seg) => sb.segments.push(seg),
Err(why) => eprintln!(
"config: {label}: [status_bar.segments.{name}] rejected: {why} — dropping"
),
}
}
} else if let Some(other) = tbl.get("segments") {
eprintln!(
"config: {label}: [status_bar].segments must be a table, got {} — ignoring",
other.type_str()
);
}
config.status_bar = sb;
}
fn value_as_string_array(v: &toml::Value) -> Result<Vec<String>, String> {
let arr = v
.as_array()
.ok_or_else(|| format!("expected array, got {}", v.type_str()))?;
let mut out = Vec::with_capacity(arr.len());
for (i, item) in arr.iter().enumerate() {
let s = item
.as_str()
.ok_or_else(|| format!("entry {i} must be a string, got {}", item.type_str()))?;
out.push(strip_braces(s).to_string());
}
Ok(out)
}
fn strip_braces(s: &str) -> &str {
let trimmed = s.trim();
if let Some(inner) = trimmed.strip_prefix('{').and_then(|x| x.strip_suffix('}')) {
inner.trim()
} else {
trimmed
}
}
fn parse_segment(name: &str, raw: &toml::Value) -> Result<StatusBarSegment, String> {
let tbl = raw
.as_table()
.ok_or_else(|| format!("expected table, got {}", raw.type_str()))?;
let kind_str = tbl
.get("type")
.and_then(|v| v.as_str())
.ok_or_else(|| "missing required `type` field".to_string())?;
let kind = match kind_str {
"builtin" => {
let inner = tbl
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| "builtin segment requires `name`".to_string())?;
SegmentKind::Builtin(inner.to_string())
}
"literal" => {
let text = tbl
.get("text")
.and_then(|v| v.as_str())
.ok_or_else(|| "literal segment requires `text`".to_string())?;
SegmentKind::Literal(text.to_string())
}
"key_hints" => {
let mode = match tbl.get("mode").and_then(|v| v.as_str()) {
Some(s) => {
HintMode::from_str(s).ok_or_else(|| format!("unknown key_hints mode '{s}'"))?
}
None => HintMode::Auto,
};
let max_width = match tbl.get("max_width") {
Some(toml::Value::Integer(n)) => {
if *n < 0 || *n > u16::MAX as i64 {
return Err(format!("max_width out of range: {n}"));
}
*n as u16
}
Some(other) => {
return Err(format!(
"max_width must be a non-negative integer, got {}",
other.type_str()
))
}
None => 60,
};
let mut keys = Vec::new();
if let Some(toml::Value::Array(arr)) = tbl.get("keys") {
for (i, entry) in arr.iter().enumerate() {
let entry_tbl = entry
.as_table()
.ok_or_else(|| format!("keys[{i}] must be a table"))?;
let key = entry_tbl
.get("key")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("keys[{i}] missing `key`"))?
.to_string();
let label = entry_tbl
.get("label")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("keys[{i}] missing `label`"))?
.to_string();
keys.push(KeyHint { key, label });
}
}
SegmentKind::KeyHints {
mode,
max_width,
keys,
}
}
other => return Err(format!("unknown segment type '{other}'")),
};
Ok(StatusBarSegment {
name: name.to_string(),
kind,
})
}
fn apply_clipboard(config: &mut EzpnConfig, c: ClipboardSection, label: &str) {
if let Some(s) = c.osc52_set {
config.clipboard.set = s;
}
if let Some(g) = c.osc52_get {
config.clipboard.get = g;
}
if let Some(n) = c.osc52_max_bytes {
if n <= 0 {
eprintln!("config: {label}: clipboard.osc52_max_bytes must be positive, ignoring");
} else {
const HARD_CAP: i64 = 16 * 1024 * 1024;
config.clipboard.max_bytes = n.min(HARD_CAP) as usize;
}
}
if let Some(argv) = c.copy_command {
if argv.iter().any(|s| s.is_empty()) {
eprintln!("config: {label}: clipboard.copy_command contains an empty entry, ignoring");
} else {
config.clipboard_copy_command = argv;
}
}
if let Some(argv) = c.paste_command {
if argv.iter().any(|s| s.is_empty()) {
eprintln!("config: {label}: clipboard.paste_command contains an empty entry, ignoring");
} else {
config.clipboard_paste_command = argv;
}
}
}
fn apply_global(config: &mut EzpnConfig, g: GlobalSection, label: &str) {
if let Some(b) = g.border {
match BorderStyle::from_str(&b) {
Some(style) => config.border = style,
None => eprintln!("config: {label}: unknown border style '{b}', using default"),
}
}
if let Some(s) = g.shell {
config.shell = s;
}
if let Some(n) = g.scrollback {
if n < 0 {
eprintln!("config: {label}: scrollback must be non-negative, ignoring");
} else {
config.scrollback = (n as usize).min(SCROLLBACK_MAX);
}
}
if let Some(v) = g.scrollback_bytes {
match parse_scrollback_bytes(&v) {
Ok(n) => config.scrollback_bytes = n.min(SCROLLBACK_BYTES_MAX),
Err(msg) => {
eprintln!("config: {label}: scrollback_bytes invalid ({msg}), ignoring");
}
}
}
if let Some(s) = g.scrollback_eviction {
match ScrollbackEviction::from_str(&s) {
Some(e) => config.scrollback_eviction = e,
None => eprintln!(
"config: {label}: scrollback_eviction must be 'oldest_line' or 'largest_line', \
got '{s}' — ignoring"
),
}
}
if let Some(b) = g.status_bar {
config.show_status_bar = b;
}
if let Some(b) = g.tab_bar {
config.show_tab_bar = b;
}
if let Some(b) = g.persist_scrollback {
config.persist_scrollback = b;
}
}
fn apply_keys(config: &mut EzpnConfig, k: KeysSection, label: &str) {
if let Some(p) = k.prefix {
let lower = p.to_lowercase();
match lower.chars().next() {
Some(c) if c.is_ascii_lowercase() => config.prefix_key = c,
_ => eprintln!("config: {label}: keys.prefix must be an ASCII letter, ignoring"),
}
}
}
fn apply_legacy_flat(config: &mut EzpnConfig, contents: &str, label: &str) {
for (idx, raw_line) in contents.lines().enumerate() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
let value = strip_quotes(value.trim());
match key {
"border" => {
if let Some(style) = BorderStyle::from_str(value) {
config.border = style;
}
}
"shell" => config.shell = value.to_string(),
"scrollback" => {
if let Ok(n) = value.parse::<usize>() {
config.scrollback = n.min(SCROLLBACK_MAX);
}
}
"status_bar" => config.show_status_bar = value == "true",
"tab_bar" => config.show_tab_bar = value == "true",
"prefix" => {
let lower = value.to_lowercase();
if let Some(c) = lower.chars().next() {
if c.is_ascii_lowercase() {
config.prefix_key = c;
}
}
}
_ => {
eprintln!(
"config: {label}: unknown key '{key}' at line {n} \
(legacy format) — ignored",
n = idx + 1
);
}
}
}
}
pub fn parse_scrollback_bytes(value: &toml::Value) -> Result<usize, String> {
match value {
toml::Value::Integer(n) => {
if *n < 0 {
Err("must be non-negative".into())
} else {
Ok(*n as usize)
}
}
toml::Value::String(s) => parse_byte_size_str(s),
other => Err(format!(
"expected integer or string, got {}",
other.type_str()
)),
}
}
fn parse_byte_size_str(s: &str) -> Result<usize, String> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Err("empty string".into());
}
let split = trimmed
.find(|c: char| !c.is_ascii_digit() && c != '.' && c != '-')
.unwrap_or(trimmed.len());
let (num_part, suffix) = trimmed.split_at(split);
let num_part = num_part.trim();
let suffix = suffix.trim();
if num_part.is_empty() {
return Err(format!("missing numeric prefix in '{s}'"));
}
if num_part.starts_with('-') {
return Err("must be non-negative".into());
}
let multiplier: u64 = match suffix.to_ascii_lowercase().as_str() {
"" | "b" => 1,
"k" | "kb" | "kib" => 1024,
"m" | "mb" | "mib" => 1024 * 1024,
"g" | "gb" | "gib" => 1024 * 1024 * 1024,
other => return Err(format!("unknown suffix '{other}'")),
};
if num_part.contains('.') {
let v: f64 = num_part
.parse()
.map_err(|_| format!("cannot parse number '{num_part}'"))?;
if v < 0.0 {
return Err("must be non-negative".into());
}
let bytes = (v * multiplier as f64).round();
if bytes < 0.0 {
return Err("must be non-negative".into());
}
Ok(bytes as usize)
} else {
let v: u64 = num_part
.parse()
.map_err(|_| format!("cannot parse integer '{num_part}'"))?;
Ok(v.saturating_mul(multiplier) as usize)
}
}
fn strip_quotes(value: &str) -> &str {
if value.len() >= 2 {
let bytes = value.as_bytes();
let first = bytes[0];
let last = bytes[value.len() - 1];
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
return &value[1..value.len() - 1];
}
}
value
}
fn warn_unknown(label: &str, key: &str, line: usize, col: usize, scope: &str) {
eprintln!(
"config: {label}: unknown {scope} key '{key}' at line {line}, column {col} — ignored"
);
}
fn has_toml_table_header(contents: &str) -> bool {
contents.lines().any(|l| {
let t = l.trim_start();
t.starts_with('[') && !t.starts_with("[[")
})
}
fn locate_key(source: &str, path: &[&str]) -> (usize, usize) {
if path.is_empty() {
return (1, 1);
}
let (section, leaf) = if path.len() == 1 {
(None, path[0])
} else {
(Some(path[0]), path[path.len() - 1])
};
let mut current_section: Option<String> = None;
for (idx, line) in source.lines().enumerate() {
let trimmed = line.trim_start();
if let Some(rest) = trimmed.strip_prefix('[') {
if let Some(end) = rest.find(']') {
let name = rest[..end].trim().to_string();
current_section = Some(name);
continue;
}
}
let same_section = match (section, current_section.as_deref()) {
(None, None) => true,
(Some(s), Some(cur)) => s == cur,
_ => false,
};
if !same_section {
continue;
}
let leading_ws = line.len() - trimmed.len();
let candidates = [leaf.to_string(), format!("\"{leaf}\""), format!("'{leaf}'")];
for cand in &candidates {
if trimmed.starts_with(cand.as_str()) {
let after = &trimmed[cand.len()..];
if after.trim_start().starts_with('=') {
return (idx + 1, leading_ws + 1);
}
}
}
}
(1, 1)
}
fn error_line_col(err: &toml::de::Error, source: &str) -> (usize, usize) {
let span = match err.span() {
Some(s) => s,
None => return (1, 1),
};
let offset = span.start.min(source.len());
let mut line = 1usize;
let mut col = 1usize;
for (i, ch) in source.char_indices() {
if i >= offset {
break;
}
if ch == '\n' {
line += 1;
col = 1;
} else {
col += 1;
}
}
(line, col)
}
fn config_path() -> Option<PathBuf> {
let dir = std::env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
let mut home = dirs_fallback();
home.push(".config");
home
});
let path = dir.join("ezpn").join("config.toml");
if path.exists() {
Some(path)
} else {
None
}
}
fn dirs_fallback() -> PathBuf {
std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/tmp"))
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(s: &str) -> EzpnConfig {
parse_config(s, None)
}
#[test]
fn strip_quotes_handles_bare_and_quoted() {
assert_eq!(strip_quotes("rounded"), "rounded");
assert_eq!(strip_quotes("\"rounded\""), "rounded");
assert_eq!(strip_quotes("'/bin/zsh'"), "/bin/zsh");
assert_eq!(strip_quotes(""), "");
assert_eq!(strip_quotes("\""), "\"");
}
#[test]
fn parses_full_modern_schema() {
let src = r#"
[global]
border = "double"
shell = "/bin/zsh"
scrollback = 5000
status_bar = false
tab_bar = false
[keys]
prefix = "a"
"#;
let c = parse(src);
assert_eq!(c.border, BorderStyle::Double);
assert_eq!(c.shell, "/bin/zsh");
assert_eq!(c.scrollback, 5000);
assert!(!c.show_status_bar);
assert!(!c.show_tab_bar);
assert_eq!(c.prefix_key, 'a');
}
#[test]
fn nested_keys_table_parsed_correctly() {
let src = r#"
[keys]
prefix = "x"
"#;
let c = parse(src);
assert_eq!(c.prefix_key, 'x');
let d = EzpnConfig::default();
assert_eq!(c.border, d.border);
assert_eq!(c.scrollback, d.scrollback);
}
#[test]
fn scrollback_clamped_to_max() {
let src = "[global]\nscrollback = 999999\n";
let c = parse(src);
assert_eq!(c.scrollback, SCROLLBACK_MAX);
}
#[test]
fn negative_scrollback_falls_back_to_default() {
let src = "[global]\nscrollback = -10\n";
let c = parse(src);
assert_eq!(c.scrollback, EzpnConfig::default().scrollback);
}
#[test]
fn unknown_global_key_uses_default_and_does_not_panic() {
let src = r#"
[global]
bordr = "double"
border = "heavy"
"#;
let c = parse(src);
assert_eq!(c.border, BorderStyle::Heavy);
}
#[test]
fn unknown_top_level_section_ignored() {
let src = r#"
[global]
shell = "/bin/fish"
[mystery]
foo = 1
"#;
let c = parse(src);
assert_eq!(c.shell, "/bin/fish");
}
#[test]
fn malformed_toml_returns_defaults() {
let src = "[global]\nshell = \"/bin/zsh\nscrollback = 1000\n";
let c = parse(src);
let d = EzpnConfig::default();
assert_eq!(c.shell, d.shell);
assert_eq!(c.scrollback, d.scrollback);
}
#[test]
fn malformed_toml_error_has_line_and_column() {
let src = "[global]\nshell = =\n";
let err = toml::from_str::<RawConfig>(src).unwrap_err();
let (line, col) = error_line_col(&err, src);
assert!(line >= 1, "line must be 1-indexed, got {line}");
assert!(col >= 1, "col must be 1-indexed, got {col}");
assert_eq!(line, 2, "expected error on line 2, got {line}");
}
#[test]
fn locate_key_finds_unknown_global_key() {
let src = "[global]\nborder = \"rounded\"\nbogus = 1\n";
let (line, col) = locate_key(src, &["global", "bogus"]);
assert_eq!(line, 3);
assert_eq!(col, 1);
}
#[test]
fn locate_key_handles_indented_keys() {
let src = "[global]\n bogus = 1\n";
let (line, col) = locate_key(src, &["global", "bogus"]);
assert_eq!(line, 2);
assert_eq!(col, 5);
}
#[test]
fn legacy_flat_parses_v05_format_identically() {
let src = "border = rounded\n\
shell = /bin/zsh\n\
scrollback = 10000\n\
status_bar = true\n\
tab_bar = true\n\
prefix = b\n";
let c = parse(src);
let d = EzpnConfig::default();
assert_eq!(c.border, BorderStyle::Rounded);
assert_eq!(c.shell, "/bin/zsh");
assert_eq!(c.scrollback, 10_000);
assert!(c.show_status_bar);
assert!(c.show_tab_bar);
assert_eq!(c.prefix_key, 'b');
assert_eq!(c.border, d.border);
assert_eq!(c.prefix_key, d.prefix_key);
}
#[test]
fn legacy_flat_with_quoted_values() {
let src = "border = \"heavy\"\nshell = '/bin/fish'\n";
let c = parse(src);
assert_eq!(c.border, BorderStyle::Heavy);
assert_eq!(c.shell, "/bin/fish");
}
#[test]
fn legacy_flat_unknown_key_ignored() {
let src = "prefex = b\nborder = double\n";
let c = parse(src);
assert_eq!(c.prefix_key, EzpnConfig::default().prefix_key);
assert_eq!(c.border, BorderStyle::Double);
}
#[test]
fn empty_input_returns_defaults() {
let c = parse("");
let d = EzpnConfig::default();
assert_eq!(c.border, d.border);
assert_eq!(c.scrollback, d.scrollback);
assert_eq!(c.prefix_key, d.prefix_key);
}
#[test]
fn comments_only_returns_defaults() {
let c = parse("# just a comment\n# another\n");
let d = EzpnConfig::default();
assert_eq!(c.scrollback, d.scrollback);
}
#[test]
fn parses_scrollback_bytes_string_suffixes() {
assert_eq!(parse_byte_size_str("32M").unwrap(), 32 * 1024 * 1024);
assert_eq!(parse_byte_size_str("512K").unwrap(), 512 * 1024);
assert_eq!(parse_byte_size_str("2G").unwrap(), 2 * 1024 * 1024 * 1024);
assert_eq!(parse_byte_size_str("0").unwrap(), 0);
assert_eq!(parse_byte_size_str("1024B").unwrap(), 1024);
assert_eq!(parse_byte_size_str("4mib").unwrap(), 4 * 1024 * 1024);
}
#[test]
fn rejects_negative_or_garbage_scrollback_bytes() {
assert!(parse_byte_size_str("-1").is_err());
assert!(parse_byte_size_str("-32M").is_err());
assert!(parse_byte_size_str("garbage").is_err());
assert!(parse_byte_size_str("12X").is_err());
assert!(parse_byte_size_str("").is_err());
}
#[test]
fn parses_fractional_scrollback_bytes() {
assert_eq!(
parse_byte_size_str("1.5G").unwrap(),
(1.5 * 1024.0 * 1024.0 * 1024.0) as usize
);
}
#[test]
fn applies_scrollback_bytes_string_from_toml() {
let src = "[global]\nscrollback_bytes = \"64M\"\n";
let c = parse(src);
assert_eq!(c.scrollback_bytes, 64 * 1024 * 1024);
}
#[test]
fn applies_scrollback_bytes_integer_from_toml() {
let src = "[global]\nscrollback_bytes = 1048576\n";
let c = parse(src);
assert_eq!(c.scrollback_bytes, 1024 * 1024);
}
#[test]
fn scrollback_bytes_zero_disables_cap() {
let src = "[global]\nscrollback_bytes = 0\n";
let c = parse(src);
assert_eq!(c.scrollback_bytes, 0);
}
#[test]
fn scrollback_bytes_default_when_unset() {
let src = "[global]\nshell = \"/bin/zsh\"\n";
let c = parse(src);
assert_eq!(c.scrollback_bytes, DEFAULT_SCROLLBACK_BYTES);
}
#[test]
fn scrollback_bytes_clamped_to_ceiling() {
let src = "[global]\nscrollback_bytes = \"8G\"\n";
let c = parse(src);
assert_eq!(c.scrollback_bytes, SCROLLBACK_BYTES_MAX);
}
#[test]
fn applies_scrollback_eviction_largest_line() {
let src = "[global]\nscrollback_eviction = \"largest_line\"\n";
let c = parse(src);
assert_eq!(c.scrollback_eviction, ScrollbackEviction::LargestLine);
}
#[test]
fn unknown_scrollback_eviction_falls_back_to_default() {
let src = "[global]\nscrollback_eviction = \"random\"\n";
let c = parse(src);
assert_eq!(c.scrollback_eviction, ScrollbackEviction::OldestLine);
}
#[test]
fn persist_scrollback_default_is_false() {
let c = parse("");
assert!(!c.persist_scrollback);
}
#[test]
fn persist_scrollback_can_be_enabled_globally() {
let src = "[global]\npersist_scrollback = true\n";
let c = parse(src);
assert!(c.persist_scrollback);
}
#[test]
fn persist_scrollback_explicit_false_overrides_default() {
let src = "[global]\npersist_scrollback = false\n";
let c = parse(src);
assert!(!c.persist_scrollback);
}
#[test]
fn clipboard_copy_command_override_parsed() {
let src = r#"
[clipboard]
copy_command = ["wl-copy"]
"#;
let c = parse(src);
assert_eq!(c.clipboard_copy_command, vec!["wl-copy".to_string()]);
}
#[test]
fn clipboard_copy_command_with_args_parsed() {
let src = r#"
[clipboard]
copy_command = ["xclip", "-selection", "clipboard"]
"#;
let c = parse(src);
assert_eq!(
c.clipboard_copy_command,
vec![
"xclip".to_string(),
"-selection".to_string(),
"clipboard".to_string(),
]
);
}
#[test]
fn clipboard_copy_command_empty_means_auto_detect() {
let src = r#"
[clipboard]
copy_command = []
"#;
let c = parse(src);
assert!(c.clipboard_copy_command.is_empty());
}
#[test]
fn clipboard_copy_command_with_empty_entry_rejected() {
let src = r#"
[clipboard]
copy_command = ["", "--clip"]
"#;
let c = parse(src);
assert!(c.clipboard_copy_command.is_empty());
}
#[test]
fn clipboard_paste_command_override_parsed() {
let src = r#"
[clipboard]
paste_command = ["wl-paste", "-n"]
"#;
let c = parse(src);
assert_eq!(
c.clipboard_paste_command,
vec!["wl-paste".to_string(), "-n".to_string()]
);
}
#[test]
fn has_toml_table_header_detects_sections() {
assert!(has_toml_table_header("[global]\nx = 1\n"));
assert!(!has_toml_table_header("x = 1\n"));
assert!(!has_toml_table_header("# [global]\nx = 1\n"));
assert!(!has_toml_table_header("[[arr]]\nx = 1\n"));
}
#[test]
fn parse_hooks_extracts_well_formed_entries() {
let src = r#"
[[hooks]]
event = "after_pane_exit"
exec = ["notify-send", "exit", "${pane.exit_code}"]
[[hooks]]
event = "on_cwd_change"
exec = ["sh", "-c", "echo cwd=${pane.cwd}"]
when = "${pane.exit_code} != 0"
"#;
let hooks = parse_hooks(src, None);
assert_eq!(hooks.len(), 2);
assert_eq!(hooks[0].event, crate::hooks::HookEvent::AfterPaneExit);
assert_eq!(hooks[0].exec[0], "notify-send");
assert!(hooks[1].when.is_some());
}
#[test]
fn parse_hooks_drops_invalid_entries_without_panicking() {
let src = r#"
[[hooks]]
event = "after_pane_exit"
exec = ["true"]
[[hooks]]
event = "after_typo"
exec = ["true"]
"#;
let hooks = parse_hooks(src, None);
assert_eq!(hooks.len(), 1, "bad entry must be dropped, good kept");
}
#[test]
fn parse_hooks_returns_empty_when_no_section() {
let src = "[global]\nshell = \"/bin/sh\"\n";
assert!(parse_hooks(src, None).is_empty());
}
#[test]
fn apply_keymap_overrides_layers_user_on_defaults() {
let mut km = load_defaults();
let before_prefix = km.len(crate::keymap::KeymapTable::Prefix);
let src = r#"
[keymap.prefix]
"z" = "kill-pane"
"#;
apply_keymap_overrides(&mut km, src).unwrap();
assert_eq!(
km.len(crate::keymap::KeymapTable::Prefix),
before_prefix + 1
);
}
#[test]
fn apply_keymap_overrides_propagates_load_errors() {
let mut km = load_defaults();
let src = r#"
[keymap.prefix]
"z" = "frobnicate"
"#;
let err = apply_keymap_overrides(&mut km, src).unwrap_err();
assert_eq!(err.table, "prefix");
assert!(err.message.contains("unknown action"), "{err}");
}
#[test]
fn theme_name_loads_builtin() {
let src = "[theme]\nname = \"nord\"\n";
let c = parse(src);
assert_eq!(c.theme.name, "nord");
}
#[test]
fn theme_unknown_name_keeps_default() {
let src = "[theme]\nname = \"hot-pink-2099\"\n";
let c = parse(src);
assert_eq!(c.theme.name, EzpnConfig::default().theme.name);
}
#[test]
fn theme_inline_palette_overrides_default() {
let src = "[theme]\n\
name = \"custom\"\n\
fg = \"#ffffff\"\n\
bg = \"#000000\"\n\
border = \"#888888\"\n\
border_active = \"#aabbcc\"\n\
status_bg = \"#111111\"\n\
status_fg = \"#eeeeee\"\n\
tab_active_bg = \"#222222\"\n\
tab_active_fg = \"#ffffff\"\n\
tab_inactive_fg = \"#777777\"\n\
selection = \"#333333\"\n\
search_match = \"#ffd700\"\n\
broadcast_indicator = \"#ff0000\"\n\
copy_mode_indicator = \"#00ff00\"\n";
let c = parse(src);
assert_eq!(c.theme.name, "custom");
assert_eq!(c.theme.fg.r, 0xff);
}
#[test]
fn theme_inline_bad_hex_keeps_default() {
let src = "[theme]\n\
name = \"broken\"\n\
fg = \"not-a-hex\"\n\
bg = \"#000000\"\n\
border = \"#888888\"\n\
border_active = \"#aabbcc\"\n\
status_bg = \"#111111\"\n\
status_fg = \"#eeeeee\"\n\
tab_active_bg = \"#222222\"\n\
tab_active_fg = \"#ffffff\"\n\
tab_inactive_fg = \"#777777\"\n\
selection = \"#333333\"\n\
search_match = \"#ffd700\"\n\
broadcast_indicator = \"#ff0000\"\n\
copy_mode_indicator = \"#00ff00\"\n";
let c = parse(src);
assert_eq!(c.theme.name, EzpnConfig::default().theme.name);
}
#[test]
fn status_bar_left_right_strip_braces() {
let src = "[status_bar]\n\
left = [\"{session}\", \"{tab_count}\", \"{mode}\"]\n\
right = [\"{key_hints}\", \"{time}\"]\n";
let c = parse(src);
assert_eq!(c.status_bar.left, vec!["session", "tab_count", "mode"]);
assert_eq!(c.status_bar.right, vec!["key_hints", "time"]);
}
#[test]
fn status_bar_key_hints_segment_parsed() {
let src = "[status_bar]\n\
left = []\n\
right = [\"{key_hints}\"]\n\
\n\
[status_bar.segments.key_hints]\n\
type = \"key_hints\"\n\
mode = \"prefix\"\n\
max_width = 60\n\
keys = [\n\
{ key = \"C-b d\", label = \"detach\" },\n\
{ key = \"C-b c\", label = \"new tab\" },\n\
]\n";
let c = parse(src);
let seg = c
.status_bar
.segment("key_hints")
.expect("key_hints segment must be registered");
match &seg.kind {
SegmentKind::KeyHints {
mode,
max_width,
keys,
} => {
assert_eq!(*mode, HintMode::Prefix);
assert_eq!(*max_width, 60);
assert_eq!(keys.len(), 2);
assert_eq!(keys[0].key, "C-b d");
assert_eq!(keys[0].label, "detach");
}
other => panic!("expected KeyHints, got {other:?}"),
}
}
#[test]
fn status_bar_unknown_segment_type_dropped() {
let src = "[status_bar]\n\
left = []\n\
right = [\"{bogus}\"]\n\
\n\
[status_bar.segments.bogus]\n\
type = \"frobnicate\"\n";
let c = parse(src);
assert!(c.status_bar.segment("bogus").is_none());
}
#[test]
fn status_bar_literal_segment_parsed() {
let src = "[status_bar]\n\
left = [\"{brand}\"]\n\
right = []\n\
\n\
[status_bar.segments.brand]\n\
type = \"literal\"\n\
text = \"ezpn>\"\n";
let c = parse(src);
let seg = c.status_bar.segment("brand").expect("brand segment");
match &seg.kind {
SegmentKind::Literal(s) => assert_eq!(s, "ezpn>"),
other => panic!("expected Literal, got {other:?}"),
}
}
#[test]
fn status_bar_missing_returns_default_empty() {
let c = parse("[global]\nshell = \"/bin/sh\"\n");
assert!(c.status_bar.left.is_empty());
assert!(c.status_bar.right.is_empty());
assert!(c.status_bar.segments.is_empty());
}
}