#![forbid(unsafe_code)]
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::sync::mpsc;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::{info, warn};
use tear_types::{KeyBind, KeyChord, StatusBar, TearTheme};
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("config file not found: {0}")]
NotFound(PathBuf),
#[error("io error reading config: {0}")]
Io(#[from] std::io::Error),
#[error("yaml parse error: {0}")]
Yaml(#[from] serde_yaml_ng::Error),
#[error("watcher failed: {0}")]
Watch(#[from] notify::Error),
#[error("shikumi error: {0}")]
Shikumi(#[from] shikumi::ShikumiError),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TearConfig {
#[serde(default = "default_prefix")]
pub prefix: String,
#[serde(default = "default_shell")]
pub default_shell: String,
#[serde(default = "default_mouse")]
pub mouse: bool,
#[serde(default = "default_base_index")]
pub base_index: u16,
#[serde(default)]
pub keys: Vec<KeyBind>,
#[serde(default)]
pub status: StatusBar,
#[serde(default)]
pub theme: TearTheme,
#[serde(default = "default_debounce")]
pub reload_debounce_ms: u64,
#[serde(default)]
pub recording_auto_dir: Option<String>,
#[serde(default)]
pub ai: Option<AiConfig>,
#[serde(default)]
pub audit_log: Option<String>,
#[serde(default)]
pub auth_token_env: Option<String>,
#[serde(default)]
pub scrollback: ScrollbackConfig,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ScrollbackConfig {
#[serde(default = "default_scrollback_rows")]
pub rows: usize,
#[serde(default)]
pub max_bytes: Option<usize>,
#[serde(default = "default_keep_on_clear")]
pub keep_on_clear: bool,
#[serde(default)]
pub on_alt_screen: bool,
#[serde(default)]
pub skip_blank_rows: bool,
#[serde(default = "default_reflow_on_resize")]
pub reflow_on_resize: bool,
}
impl Default for ScrollbackConfig {
fn default() -> Self {
Self {
rows: default_scrollback_rows(),
max_bytes: None,
keep_on_clear: default_keep_on_clear(),
on_alt_screen: false,
skip_blank_rows: false,
reflow_on_resize: default_reflow_on_resize(),
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AiConfig {
#[serde(default = "default_ai_provider")]
pub provider: String,
#[serde(default = "default_ai_model")]
pub model: String,
#[serde(default = "default_ai_endpoint")]
pub endpoint: String,
#[serde(default)]
pub api_key_env: Option<String>,
#[serde(default = "default_ai_context_bytes")]
pub context_bytes: usize,
}
impl Default for AiConfig {
fn default() -> Self {
Self {
provider: default_ai_provider(),
model: default_ai_model(),
endpoint: default_ai_endpoint(),
api_key_env: None,
context_bytes: default_ai_context_bytes(),
}
}
}
fn default_ai_provider() -> String {
"ollama".into()
}
fn default_ai_model() -> String {
"llama3.2".into()
}
fn default_ai_endpoint() -> String {
"http://127.0.0.1:11434".into()
}
fn default_ai_context_bytes() -> usize {
2000
}
impl Default for TearConfig {
fn default() -> Self {
Self {
prefix: default_prefix(),
default_shell: default_shell(),
mouse: default_mouse(),
base_index: default_base_index(),
keys: default_keybinds(),
status: default_status(),
theme: TearTheme::default(),
reload_debounce_ms: default_debounce(),
recording_auto_dir: None,
ai: None,
audit_log: None,
auth_token_env: None,
scrollback: ScrollbackConfig::default(),
}
}
}
fn default_prefix() -> String {
tear_types::KeyChord::from_tmux(ishou_tokens::FleetKeybinds::prescribed().multiplexer_prefix).0
}
fn default_shell() -> String {
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into())
}
fn default_mouse() -> bool {
true
}
fn default_base_index() -> u16 {
1
}
fn default_scrollback_rows() -> usize {
usize::MAX
}
fn default_keep_on_clear() -> bool {
true
}
fn default_reflow_on_resize() -> bool {
true
}
fn default_debounce() -> u64 {
250
}
fn default_keybinds() -> Vec<KeyBind> {
use tear_types::{Action, Direction, KeyTableName};
vec![
KeyBind {
chord: KeyChord::from_tmux("C-b c"),
action: Action::NewWindow,
note: "new window".into(),
},
KeyBind {
chord: KeyChord::from_tmux("C-b n"),
action: Action::NextWindow,
note: "next window".into(),
},
KeyBind {
chord: KeyChord::from_tmux("C-b p"),
action: Action::PreviousWindow,
note: "previous window".into(),
},
KeyBind {
chord: KeyChord::from_tmux("C-b %"),
action: Action::SplitPane {
direction: Direction::Right,
},
note: "split right".into(),
},
KeyBind {
chord: KeyChord::from_tmux("C-b \""),
action: Action::SplitPane {
direction: Direction::Below,
},
note: "split below".into(),
},
KeyBind {
chord: KeyChord::from_tmux("C-b d"),
action: Action::Detach,
note: "detach client".into(),
},
KeyBind {
chord: KeyChord::from_tmux("C-b R"),
action: Action::ReloadConfig,
note: "reload tear-config".into(),
},
KeyBind {
chord: KeyChord::from_tmux("C-b :"),
action: Action::EnterTable {
table: KeyTableName("command".into()),
},
note: "open command prompt".into(),
},
]
}
fn default_status() -> StatusBar {
use tear_types::{Segment, SignalRenderMode, TearSignalKind};
StatusBar {
left: vec![
Segment::Signal {
signal: TearSignalKind::SessionActive,
mode: SignalRenderMode::Emoji,
},
Segment::Text {
value: " [".into(),
},
Segment::SessionName,
Segment::Text {
value: ":".into(),
},
Segment::WindowName,
Segment::Text {
value: "] ".into(),
},
],
center: vec![],
right: vec![
Segment::PaneCommand,
Segment::Text {
value: " · ".into(),
},
Segment::Time {
format: "%H:%M".into(),
},
Segment::Text {
value: " · ".into(),
},
Segment::Hostname { short: true },
],
refresh_interval_seconds: 5,
visible: true,
}
}
#[must_use]
pub fn default_config_path() -> PathBuf {
if let Ok(explicit) = std::env::var("TEAR_CONFIG_FILE") {
return PathBuf::from(explicit);
}
let xdg = std::env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.ok()
.or_else(|| {
std::env::var("HOME").ok().map(|h| {
let mut p = PathBuf::from(h);
p.push(".config");
p
})
})
.unwrap_or_else(|| PathBuf::from("."));
xdg.join("tear").join("tear.yaml")
}
pub fn load_from(path: &Path) -> Result<TearConfig, ConfigError> {
if !path.exists() {
return Err(ConfigError::NotFound(path.to_path_buf()));
}
let text = std::fs::read_to_string(path)?;
let cfg: TearConfig = serde_yaml_ng::from_str(&text)?;
Ok(cfg)
}
pub fn load_or_default() -> Arc<TearConfig> {
let path = default_config_path();
match load_from(&path) {
Ok(cfg) => Arc::new(cfg),
Err(ConfigError::NotFound(_)) => {
info!(?path, "no tear config found — using defaults");
Arc::new(TearConfig::default())
}
Err(e) => {
warn!(error = %e, "tear config parse failed — falling back to defaults");
Arc::new(TearConfig::default())
}
}
}
#[derive(Clone)]
pub struct LiveConfig {
store: Arc<shikumi::ConfigStore<TearConfig>>,
subscribers: Arc<Mutex<Vec<mpsc::Sender<Arc<TearConfig>>>>>,
}
impl Default for LiveConfig {
fn default() -> Self {
let path = default_config_path();
let store = match shikumi::ConfigStore::<TearConfig>::load(&path, "TEAR_") {
Ok(s) => s,
Err(err) => {
warn!(error = %err, path = %path.display(),
"tear-config: shikumi load failed; using defaults");
shikumi::ConfigStore::<TearConfig>::load(
std::path::Path::new("/dev/null/tear-defaults-missing"),
"TEAR_",
)
.unwrap_or_else(|_| panic!(
"shikumi defaults-only load failed (both real path \
and synthetic path errored); please file a bug"
))
}
};
Self {
store: Arc::new(store),
subscribers: Arc::new(Mutex::new(Vec::new())),
}
}
}
impl LiveConfig {
#[must_use]
pub fn load(&self) -> Arc<TearConfig> {
Arc::clone(&self.store.get())
}
pub fn subscribe(&self) -> mpsc::Receiver<Arc<TearConfig>> {
let (tx, rx) = mpsc::channel();
self.subscribers.lock().expect("subscribers poisoned").push(tx);
rx
}
pub fn replace(&self, cfg: TearConfig) {
info!("tear-config: applying new config");
self.store.replace(cfg);
let new_arc = self.load();
Self::fan_out(&self.subscribers, new_arc);
}
pub fn reload(&self) -> Result<(), ConfigError> {
self.store.reload()?;
let new_arc = self.load();
Self::fan_out(&self.subscribers, new_arc);
Ok(())
}
fn fan_out(
subscribers: &Arc<Mutex<Vec<mpsc::Sender<Arc<TearConfig>>>>>,
snap: Arc<TearConfig>,
) {
let mut subs = subscribers.lock().expect("subscribers poisoned");
let mut i = 0;
while i < subs.len() {
if subs[i].send(snap.clone()).is_err() {
subs.swap_remove(i);
} else {
i += 1;
}
}
}
pub fn spawn_watcher(&self) -> Result<notify::RecommendedWatcher, ConfigError> {
use notify::{EventKind, RecursiveMode, Watcher};
let path = default_config_path();
let parent = path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
let live = self.clone();
let debounce_ms = self.load().reload_debounce_ms;
let last_reload = Arc::new(std::sync::Mutex::new(std::time::Instant::now()));
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
let Ok(ev) = res else {
return;
};
if !matches!(
ev.kind,
EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
) {
return;
}
{
let mut last = last_reload.lock().unwrap();
if last.elapsed() < Duration::from_millis(debounce_ms) {
return;
}
*last = std::time::Instant::now();
}
if let Err(e) = live.reload() {
warn!(error = %e, "tear-config reload failed; keeping previous config");
}
})?;
watcher.watch(&parent, RecursiveMode::NonRecursive)?;
info!(?parent, "tear-config: watching for changes");
Ok(watcher)
}
}
impl shikumi::TieredConfig for TearConfig {
fn bare() -> Self {
Self {
prefix: String::new(),
default_shell: String::new(),
mouse: false,
base_index: 0,
keys: Vec::new(),
status: StatusBar::default(),
theme: TearTheme::default(),
reload_debounce_ms: 0,
recording_auto_dir: None,
ai: None,
audit_log: None,
auth_token_env: None,
scrollback: <ScrollbackConfig as shikumi::TieredConfig>::bare(),
}
}
fn prescribed_default() -> Self {
Self::default()
}
}
impl shikumi::TieredConfig for ScrollbackConfig {
fn bare() -> Self {
Self {
rows: 0,
max_bytes: None,
keep_on_clear: false,
on_alt_screen: false,
skip_blank_rows: false,
reflow_on_resize: false,
}
}
fn prescribed_default() -> Self {
Self::default()
}
}
impl shikumi::TieredConfig for AiConfig {
fn bare() -> Self {
Self {
provider: String::new(),
model: String::new(),
endpoint: String::new(),
api_key_env: None,
context_bytes: 0,
}
}
fn prescribed_default() -> Self {
Self::default()
}
}
#[cfg(test)]
mod tiered_tests {
use super::*;
use shikumi::{ConfigTier, TieredConfig};
#[test]
fn tear_config_bare_is_zero_opinion() {
let b = <TearConfig as TieredConfig>::bare();
assert_eq!(b.prefix, "");
assert_eq!(b.default_shell, "");
assert!(!b.mouse);
assert_eq!(b.base_index, 0);
assert!(b.keys.is_empty());
assert_eq!(b.reload_debounce_ms, 0);
assert!(b.recording_auto_dir.is_none());
assert!(b.ai.is_none());
assert!(b.audit_log.is_none());
assert!(b.auth_token_env.is_none());
assert_eq!(b.scrollback.rows, 0);
}
#[test]
fn tear_config_prescribed_matches_default() {
let p = <TearConfig as TieredConfig>::prescribed_default();
let d = TearConfig::default();
assert_eq!(p.prefix, d.prefix);
assert_eq!(p.base_index, d.base_index);
assert_eq!(p.mouse, d.mouse);
assert_eq!(p.keys.len(), d.keys.len());
}
#[test]
fn tear_config_diff_bare_vs_default_is_non_empty() {
let b = <TearConfig as TieredConfig>::bare();
let d = <TearConfig as TieredConfig>::prescribed_default();
let diff = d.diff_against(&b);
assert!(
!diff.is_empty_diff(),
"bare and prescribed_default must differ"
);
}
#[test]
fn tear_config_resolve_tier_dispatches_correctly() {
let bare = <TearConfig as TieredConfig>::resolve_tier(ConfigTier::Bare);
assert_eq!(bare.prefix, "");
assert_eq!(bare.base_index, 0);
let default = <TearConfig as TieredConfig>::resolve_tier(ConfigTier::Default);
assert_eq!(default.prefix, "ctrl+b");
assert_eq!(default.base_index, 1);
}
#[test]
fn scrollback_config_bare_and_prescribed_differ() {
let b = <ScrollbackConfig as TieredConfig>::bare();
assert_eq!(b.rows, 0);
assert!(!b.keep_on_clear);
assert!(!b.reflow_on_resize);
let p = <ScrollbackConfig as TieredConfig>::prescribed_default();
assert_eq!(p.rows, usize::MAX);
assert!(p.keep_on_clear);
assert!(p.reflow_on_resize);
}
#[test]
fn ai_config_bare_and_prescribed_differ() {
let b = <AiConfig as TieredConfig>::bare();
assert_eq!(b.provider, "");
assert_eq!(b.model, "");
assert_eq!(b.context_bytes, 0);
let p = <AiConfig as TieredConfig>::prescribed_default();
assert_eq!(p.provider, "ollama");
assert_eq!(p.context_bytes, 2000);
}
#[test]
fn prefix_chord_converges_with_fleet_keybinds_atlas() {
let atlas_chord = ishou_tokens::FleetKeybinds::prescribed().multiplexer_prefix;
let normalized = tear_types::KeyChord::from_tmux(atlas_chord).0;
let prescribed = <TearConfig as TieredConfig>::prescribed_default();
assert_eq!(
prescribed.prefix, normalized,
"tear prefix drifted from FleetKeybinds atlas: prescribed={:?}, atlas-normalized={:?}",
prescribed.prefix, normalized,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_is_constructible() {
let cfg = TearConfig::default();
assert_eq!(cfg.prefix, "ctrl+b");
assert!(!cfg.keys.is_empty());
assert!(cfg.status.visible);
}
#[test]
fn default_keybinds_include_split_and_reload() {
use tear_types::Action;
let cfg = TearConfig::default();
assert!(
cfg.keys.iter().any(|k| matches!(k.action, Action::SplitPane { .. })),
"default keys should include a split-pane binding"
);
assert!(
cfg.keys.iter().any(|k| matches!(k.action, Action::ReloadConfig)),
"default keys should include a reload-config binding"
);
}
#[test]
fn live_config_swap_is_atomic() {
let live = LiveConfig::default();
let a = live.load();
let mut b = (*a).clone();
b.prefix = "ctrl+space".into();
live.replace(b.clone());
let after = live.load();
assert_eq!(after.prefix, "ctrl+space");
}
#[test]
fn auth_token_env_round_trips_through_yaml() {
let mut cfg = TearConfig::default();
cfg.auth_token_env = Some("TEAR_AUTH_TOKEN".into());
let y = serde_yaml_ng::to_string(&cfg).unwrap();
assert!(y.contains("TEAR_AUTH_TOKEN"), "yaml: {y}");
let back: TearConfig = serde_yaml_ng::from_str(&y).unwrap();
assert_eq!(back.auth_token_env, Some("TEAR_AUTH_TOKEN".into()));
}
#[test]
fn audit_log_round_trips_through_yaml() {
let mut cfg = TearConfig::default();
cfg.audit_log = Some("~/.local/share/tear/audit.log".into());
let y = serde_yaml_ng::to_string(&cfg).unwrap();
let back: TearConfig = serde_yaml_ng::from_str(&y).unwrap();
assert_eq!(back.audit_log.as_deref(), Some("~/.local/share/tear/audit.log"));
}
#[test]
fn ai_config_round_trips_through_yaml() {
let mut cfg = TearConfig::default();
cfg.ai = Some(AiConfig {
provider: "openai".into(),
model: "gpt-5-codex".into(),
endpoint: "https://api.openai.com/v1".into(),
api_key_env: Some("OPENAI_API_KEY".into()),
context_bytes: 4000,
});
let y = serde_yaml_ng::to_string(&cfg).unwrap();
let back: TearConfig = serde_yaml_ng::from_str(&y).unwrap();
assert_eq!(back.ai, cfg.ai);
}
#[test]
fn empty_yaml_yields_defaults_for_new_fields() {
let cfg: TearConfig = serde_yaml_ng::from_str("{}").unwrap();
assert_eq!(cfg.auth_token_env, None);
assert_eq!(cfg.audit_log, None);
assert_eq!(cfg.ai, None);
}
}