mod app;
mod environment_warning;
mod install_screen;
mod items_editor;
mod line_picker;
mod list_screen;
mod main_menu;
mod placeholder;
mod preview;
mod raw_value_editor;
mod theme_picker;
mod type_picker;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use ratatui::crossterm::event::{self as cevent, Event as CtEvent, KeyEventKind};
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::Terminal;
use toml_edit::DocumentMut;
use crate::config;
use crate::logging::{CapturedSink, SinkGuard};
use app::{update, Event, Model};
#[allow(clippy::redundant_pub_crate)]
pub(super) fn run(
config_path: Option<&Path>,
install_explicit_config: Option<&Path>,
color_override: Option<crate::cli::ColorOverride>,
stderr: &mut dyn Write,
env: &crate::driver::CliEnv,
) -> u8 {
let load = match load_config(config_path) {
Ok(out) => out,
Err(err) => {
let _ = writeln!(stderr, "linesmith config: load: {err}");
return 1;
}
};
if let Some(warning) = &load.warning {
let _ = writeln!(stderr, "linesmith config: {warning}");
}
let xdg = crate::driver::cli_env_to_xdg(env);
let user_themes_dir = crate::runtime::themes::user_themes_dir(&xdg);
let theme_registry =
crate::runtime::themes::build_theme_registry(user_themes_dir.as_deref(), |msg| {
let _ = writeln!(stderr, "linesmith config: {msg}");
});
let theme = resolve_theme(load.config.theme.as_deref(), &theme_registry, stderr).clone();
let capability =
crate::driver::resolve_color_capability(color_override, env, Some(&load.config));
let captured_sink = Arc::new(CapturedSink::default());
let _sink_guard = SinkGuard::install(captured_sink.clone());
let install_settings_path = crate::claude_settings::default_settings_path(env);
let install_config = crate::driver::effective_install_config(install_explicit_config, env)
.and_then(|p| {
if p.to_str().is_some() {
Some(p)
} else {
linesmith_core::lsm_warn!(
"install: --config path contains non-UTF-8 bytes; the install screen will offer the bare `linesmith` command instead",
);
None
}
});
let install_command = crate::driver::json_command_value(install_config.as_deref());
let model = Model::new(
load.config,
load.document,
load.original_text,
load.save_target,
theme,
theme_registry,
capability,
Some(Arc::clone(&captured_sink)),
install_settings_path,
install_command,
);
install_panic_hook();
if let Err(err) = enter_terminal() {
flush_captured_to_stderr(&captured_sink, stderr);
let _ = writeln!(stderr, "linesmith config: terminal setup: {err}");
return 1;
}
let outcome = run_loop(model);
if let Err(err) = leave_terminal() {
let _ = writeln!(stderr, "linesmith config: terminal restore: {err}");
}
match outcome {
Ok(()) => 0,
Err(err) => {
flush_captured_to_stderr(&captured_sink, stderr);
let _ = writeln!(stderr, "linesmith config: event loop: {err}");
1
}
}
}
#[allow(clippy::redundant_pub_crate)]
pub(super) use crate::atomic::atomic_write;
fn flush_captured_to_stderr(captured: &CapturedSink, stderr: &mut dyn Write) {
for entry in captured.drain() {
let _ = writeln!(stderr, "linesmith config: {entry}");
}
}
const POLL_INTERVAL: Duration = Duration::from_millis(100);
fn run_loop(mut model: Model) -> io::Result<()> {
let backend = ratatui::backend::CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend)?;
draw_with_retry(|op| terminal.draw(op).map(|_| ()), &model)?;
loop {
let Some(event) = poll_event()? else {
continue;
};
model = update(model, event);
if model.quit {
return Ok(());
}
draw_with_retry(|op| terminal.draw(op).map(|_| ()), &model)?;
}
}
fn poll_event() -> io::Result<Option<Event>> {
if !retry_on_interrupt(|| cevent::poll(POLL_INTERVAL))? {
return Ok(None);
}
Ok(classify_event(retry_on_interrupt(cevent::read)?))
}
fn is_recoverable_io_error(err: &io::Error) -> bool {
matches!(err.kind(), io::ErrorKind::Interrupted)
}
const EINTR_STORM_THRESHOLD: u32 = 64;
fn retry_on_interrupt<T>(mut op: impl FnMut() -> io::Result<T>) -> io::Result<T> {
let mut consecutive_eintr = 0_u32;
loop {
match op() {
Ok(value) => return Ok(value),
Err(err) if is_recoverable_io_error(&err) => {
consecutive_eintr = consecutive_eintr.saturating_add(1);
if consecutive_eintr == EINTR_STORM_THRESHOLD {
linesmith_core::lsm_warn!(
"tui: {EINTR_STORM_THRESHOLD} consecutive EINTRs from poll/read; likely signal storm — editor may be unresponsive to keystrokes"
);
}
}
Err(err) => return Err(err),
}
}
}
fn draw_with_retry<F>(mut draw: F, model: &Model) -> io::Result<()>
where
F: FnMut(&mut dyn FnMut(&mut ratatui::Frame<'_>)) -> io::Result<()>,
{
let mut op = |frame: &mut ratatui::Frame<'_>| app::view(model, frame);
match draw(&mut op) {
Ok(()) => Ok(()),
Err(first) => {
linesmith_core::lsm_warn!("tui: draw failed ({first}); retrying once");
match draw(&mut op) {
Ok(()) => Ok(()),
Err(second) => {
linesmith_core::lsm_error!(
"tui: draw failed twice ({first}; then {second}); aborting editor"
);
Err(second)
}
}
}
}
}
fn classify_event(event: CtEvent) -> Option<Event> {
match event {
CtEvent::Key(key) if key.kind == KeyEventKind::Press => Some(Event::Key(key)),
CtEvent::Resize(_, _) => Some(Event::Resize),
_ => None,
}
}
fn enter_terminal() -> io::Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
if let Err(err) = execute!(
stdout,
EnterAlternateScreen,
ratatui::crossterm::cursor::Hide,
) {
let _ = execute!(
stdout,
LeaveAlternateScreen,
ratatui::crossterm::cursor::Show,
);
let _ = disable_raw_mode();
return Err(err);
}
Ok(())
}
fn leave_terminal() -> io::Result<()> {
let mut stdout = io::stdout();
let screen = execute!(
stdout,
LeaveAlternateScreen,
ratatui::crossterm::cursor::Show,
);
let raw = disable_raw_mode();
screen.and(raw)
}
fn install_panic_hook() {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = leave_terminal();
prev(info);
}));
}
fn resolve_theme<'a>(
name: Option<&str>,
registry: &'a crate::theme::ThemeRegistry,
stderr: &mut dyn Write,
) -> &'a crate::theme::Theme {
let Some(name) = name.filter(|n| !n.is_empty()) else {
return registry
.lookup("default")
.expect("default theme is always in the registry");
};
match registry.lookup(name) {
Some(t) => t,
None => {
let _ = writeln!(
stderr,
"linesmith config: unknown theme '{name}'; using 'default'",
);
registry
.lookup("default")
.expect("default theme is always in the registry")
}
}
}
#[allow(clippy::redundant_pub_crate)]
#[derive(Debug)]
pub(super) struct LoadOutcome {
pub(super) config: config::Config,
pub(super) document: DocumentMut,
pub(super) original_text: String,
pub(super) save_target: Option<PathBuf>,
pub(super) warning: Option<String>,
}
fn load_config(path: Option<&Path>) -> io::Result<LoadOutcome> {
let Some(path) = path else {
return Ok(LoadOutcome {
config: config::Config::default(),
document: DocumentMut::new(),
original_text: String::new(),
save_target: None,
warning: None,
});
};
match std::fs::read_to_string(path) {
Ok(text) => {
let mut warnings: Vec<String> = Vec::new();
match config::Config::from_str_validated(&text, |w| warnings.push(w.to_string())) {
Ok(cfg) => {
match text.parse::<DocumentMut>() {
Ok(document) => {
let warning = (!warnings.is_empty()).then(|| warnings.join("\n"));
Ok(LoadOutcome {
config: cfg,
document,
original_text: text,
save_target: Some(path.to_path_buf()),
warning,
})
}
Err(err) => Ok(LoadOutcome {
config: cfg,
document: DocumentMut::new(),
original_text: String::new(),
save_target: None,
warning: Some(format!(
"TOML parser skew in {}: {err} — editor opened read-only (save disabled)",
path.display()
)),
}),
}
}
Err(err) => Ok(LoadOutcome {
config: config::Config::default(),
document: DocumentMut::new(),
original_text: String::new(),
save_target: None,
warning: Some(format!(
"parse error in {}: {err} — opening with defaults (save disabled)",
path.display()
)),
}),
}
}
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(LoadOutcome {
config: config::Config::default(),
document: DocumentMut::new(),
original_text: String::new(),
save_target: Some(path.to_path_buf()),
warning: None,
}),
Err(err) => Err(err),
}
}
#[cfg(test)]
#[allow(clippy::redundant_pub_crate)]
pub(crate) fn buffer_to_string(buf: &ratatui::buffer::Buffer) -> String {
let mut out = String::with_capacity((buf.area.width as usize + 1) * buf.area.height as usize);
for y in 0..buf.area.height {
for x in 0..buf.area.width {
let sym = buf[(x, y)].symbol();
if sym.is_empty() {
out.push(' ');
} else {
out.push_str(sym);
}
}
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventState, KeyModifiers};
use std::fs;
use tempfile::TempDir;
fn key_event(kind: KeyEventKind) -> KeyEvent {
KeyEvent::new_with_kind_and_state(
KeyCode::Char('a'),
KeyModifiers::NONE,
kind,
KeyEventState::NONE,
)
}
#[test]
fn flush_captured_to_stderr_drains_with_boot_path_prefix() {
use crate::logging::{Level, LogSink};
let _serial = crate::logging::_test_serial_lock();
let captured = CapturedSink::default();
captured.emit(Level::Warn, "first");
captured.emit_error("oops");
let mut stderr = Vec::<u8>::new();
flush_captured_to_stderr(&captured, &mut stderr);
let written = String::from_utf8(stderr).expect("utf8");
assert!(
written.contains("linesmith config: [warn] first"),
"missing warn prefix in {written:?}",
);
assert!(
written.contains("linesmith config: [error] oops"),
"missing error prefix in {written:?}",
);
let mut second = Vec::<u8>::new();
flush_captured_to_stderr(&captured, &mut second);
assert!(second.is_empty(), "second flush leaked: {second:?}");
}
#[test]
fn classify_press_key_routes_to_event_key() {
let outcome = classify_event(CtEvent::Key(key_event(KeyEventKind::Press)));
assert!(matches!(outcome, Some(Event::Key(_))));
}
#[test]
fn classify_release_key_is_filtered() {
let outcome = classify_event(CtEvent::Key(key_event(KeyEventKind::Release)));
assert!(outcome.is_none());
}
#[test]
fn classify_repeat_key_is_filtered() {
let outcome = classify_event(CtEvent::Key(key_event(KeyEventKind::Repeat)));
assert!(outcome.is_none());
}
#[test]
fn classify_resize_routes_to_event_resize() {
let outcome = classify_event(CtEvent::Resize(80, 24));
assert!(matches!(outcome, Some(Event::Resize)));
}
#[test]
fn classify_focus_and_paste_are_filtered() {
assert!(classify_event(CtEvent::FocusGained).is_none());
assert!(classify_event(CtEvent::FocusLost).is_none());
assert!(classify_event(CtEvent::Paste("ignored".to_string())).is_none());
}
#[test]
fn load_config_none_path_refuses_save() {
let out = load_config(None).expect("ok");
assert!(out.warning.is_none());
assert_eq!(out.config, config::Config::default());
assert!(out.save_target.is_none(), "no path → no save target");
assert!(out.original_text.is_empty());
}
#[test]
fn load_config_missing_file_allows_save_to_path() {
let tmp = TempDir::new().expect("tempdir");
let missing = tmp.path().join("does_not_exist.toml");
let out = load_config(Some(&missing)).expect("ok");
assert!(out.warning.is_none());
assert_eq!(out.config, config::Config::default());
assert_eq!(out.save_target.as_deref(), Some(missing.as_path()));
assert!(out.original_text.is_empty());
}
#[test]
fn load_config_unknown_keys_surface_as_warning() {
let tmp = TempDir::new().expect("tempdir");
let path = tmp.path().join("typo.toml");
fs::write(
&path,
"bogus_top_level_key = 42\n[line]\nsegments = [\"model\"]\n",
)
.expect("write");
let out = load_config(Some(&path)).expect("ok");
let ids: Vec<String> = out
.config
.line
.as_ref()
.map(|l| {
l.segments
.iter()
.filter_map(|e| e.segment_id().map(str::to_string))
.collect()
})
.unwrap_or_default();
assert_eq!(ids, vec!["model".to_string()]);
let msg = out.warning.expect("unknown-key warning present");
assert!(msg.contains("bogus_top_level_key"), "got {msg:?}");
assert_eq!(out.save_target.as_deref(), Some(path.as_path()));
}
#[test]
fn load_config_valid_toml_carries_document_and_original_text() {
let tmp = TempDir::new().expect("tempdir");
let path = tmp.path().join("config.toml");
let raw = "# header comment kept\n[line]\nsegments = [\"model\"]\n";
fs::write(&path, raw).expect("write");
let out = load_config(Some(&path)).expect("ok");
assert!(out.warning.is_none(), "valid TOML emits no warning");
let ids: Vec<String> = out
.config
.line
.as_ref()
.map(|l| {
l.segments
.iter()
.filter_map(|e| e.segment_id().map(str::to_string))
.collect()
})
.unwrap_or_default();
assert_eq!(ids, vec!["model".to_string()]);
assert_eq!(out.original_text, raw);
assert_eq!(out.document.to_string(), raw);
assert_eq!(out.save_target.as_deref(), Some(path.as_path()));
}
#[test]
fn load_config_malformed_toml_disables_save_with_warning() {
let tmp = TempDir::new().expect("tempdir");
let path = tmp.path().join("broken.toml");
fs::write(&path, "this = is not = valid TOML\n").expect("write");
let out = load_config(Some(&path)).expect("ok");
assert_eq!(out.config, config::Config::default());
let msg = out.warning.expect("warning present");
assert!(msg.contains("parse error"), "got {msg:?}");
assert!(
msg.contains("broken.toml"),
"warning names the path: {msg:?}"
);
assert!(msg.contains("opening with defaults"), "got {msg:?}");
assert!(msg.contains("save disabled"), "got {msg:?}");
assert!(
out.save_target.is_none(),
"parse error must refuse save target",
);
assert!(out.original_text.is_empty());
}
#[cfg(unix)]
#[test]
fn load_config_unreadable_file_propagates_error() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().expect("tempdir");
let path = tmp.path().join("locked.toml");
fs::write(&path, "irrelevant").expect("write");
let mut perms = fs::metadata(&path).expect("metadata").permissions();
perms.set_mode(0o000);
fs::set_permissions(&path, perms).expect("chmod");
let outcome = load_config(Some(&path));
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
assert!(outcome.is_err(), "expected error, got {outcome:?}");
}
#[test]
fn is_recoverable_io_error_only_for_interrupted() {
assert!(is_recoverable_io_error(&io::Error::from(
io::ErrorKind::Interrupted,
)));
for kind in [
io::ErrorKind::BrokenPipe,
io::ErrorKind::WouldBlock,
io::ErrorKind::TimedOut,
io::ErrorKind::Other,
io::ErrorKind::PermissionDenied,
] {
assert!(
!is_recoverable_io_error(&io::Error::from(kind)),
"kind {kind:?} should NOT be classified as recoverable",
);
}
}
#[test]
fn retry_on_interrupt_returns_value_on_first_success() {
let mut calls = 0_u32;
let out = retry_on_interrupt(|| {
calls += 1;
Ok::<u32, io::Error>(42)
})
.expect("ok");
assert_eq!(out, 42);
assert_eq!(calls, 1, "no retry needed when first call succeeds");
}
#[test]
fn retry_on_interrupt_retries_past_eintr_and_returns_value() {
let mut calls = 0_u32;
let out = retry_on_interrupt(|| {
calls += 1;
if calls < 3 {
Err(io::Error::from(io::ErrorKind::Interrupted))
} else {
Ok(7_i32)
}
})
.expect("ok");
assert_eq!(out, 7);
assert_eq!(calls, 3, "should retry through both EINTRs");
}
#[test]
fn retry_on_interrupt_propagates_non_interrupted_errors() {
let mut calls = 0_u32;
let outcome: io::Result<()> = retry_on_interrupt(|| {
calls += 1;
Err(io::Error::from(io::ErrorKind::BrokenPipe))
});
assert_eq!(calls, 1, "BrokenPipe should not retry");
let err = outcome.expect_err("expected error");
assert_eq!(err.kind(), io::ErrorKind::BrokenPipe);
}
#[test]
fn retry_on_interrupt_emits_storm_breadcrumb_once_at_threshold() {
let _serial = crate::logging::_test_serial_lock();
let captured = std::sync::Arc::new(CapturedSink::default());
let _sink = SinkGuard::install(captured.clone());
let mut calls = 0_u32;
retry_on_interrupt(|| {
calls += 1;
if calls <= EINTR_STORM_THRESHOLD + 5 {
Err(io::Error::from(io::ErrorKind::Interrupted))
} else {
Ok(())
}
})
.expect("ok after storm");
let entries = captured.drain();
let storm_hits = entries
.iter()
.filter(|e| e.contains("consecutive EINTRs"))
.count();
assert_eq!(
storm_hits, 1,
"storm breadcrumb must fire exactly once, got {entries:?}",
);
}
fn stub_model() -> Model {
use crate::theme::{Capability, ThemeRegistry};
Model::new(
config::Config::default(),
DocumentMut::new(),
String::new(),
None,
crate::theme::default_theme().clone(),
ThemeRegistry::with_built_ins(),
Capability::None,
None,
None,
"linesmith".to_string(),
)
}
#[test]
fn draw_with_retry_calls_draw_once_on_success() {
let _serial = crate::logging::_test_serial_lock();
let model = stub_model();
let mut calls = 0_u32;
let drawer = |_op: &mut dyn FnMut(&mut ratatui::Frame<'_>)| -> io::Result<()> {
calls += 1;
Ok(())
};
draw_with_retry(drawer, &model).expect("ok");
assert_eq!(calls, 1);
}
#[test]
fn draw_with_retry_retries_once_on_transient_failure() {
let _serial = crate::logging::_test_serial_lock();
let captured = std::sync::Arc::new(CapturedSink::default());
let _sink = SinkGuard::install(captured.clone());
let model = stub_model();
let mut calls = 0_u32;
let drawer = |_op: &mut dyn FnMut(&mut ratatui::Frame<'_>)| -> io::Result<()> {
calls += 1;
if calls == 1 {
Err(io::Error::other("backpressure"))
} else {
Ok(())
}
};
draw_with_retry(drawer, &model).expect("retry succeeds");
assert_eq!(calls, 2, "should retry once after transient failure");
let entries = captured.drain();
assert!(
entries.iter().any(|e| e.contains("retrying once")),
"expected retry warn in captured sink, got {entries:?}",
);
}
#[test]
fn draw_with_retry_bails_on_persistent_failure() {
let _serial = crate::logging::_test_serial_lock();
let captured = std::sync::Arc::new(CapturedSink::default());
let _sink = SinkGuard::install(captured.clone());
let model = stub_model();
let mut calls = 0_u32;
let drawer = |_op: &mut dyn FnMut(&mut ratatui::Frame<'_>)| -> io::Result<()> {
calls += 1;
if calls == 1 {
Err(io::Error::from(io::ErrorKind::BrokenPipe))
} else {
Err(io::Error::from(io::ErrorKind::ConnectionReset))
}
};
let outcome = draw_with_retry(drawer, &model);
assert_eq!(calls, 2, "should give up after one retry");
let err = outcome.expect_err("expected error after retry");
assert_eq!(
err.kind(),
io::ErrorKind::ConnectionReset,
"must surface the second failure, not the cached first one",
);
let entries = captured.drain();
assert!(
entries.iter().any(|e| e.contains("retrying once")),
"expected pre-retry warn in captured sink, got {entries:?}",
);
assert!(
entries.iter().any(|e| e.contains("aborting editor")),
"expected post-failure error in captured sink, got {entries:?}",
);
}
}