use std::io;
use std::path::PathBuf;
use std::sync::Arc;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
use toml_edit::DocumentMut;
use crate::config;
use crate::logging::CapturedSink;
use crate::theme::{Capability, Theme, ThemeRegistry};
use super::environment_warning::{prepend_env_warnings, EnvironmentSnapshot};
use super::install_screen::{self, InstallScreenState};
use super::items_editor::{self, ItemsEditorState};
use super::line_picker::{self, LinePickerState};
use super::main_menu::{self, MainMenuState};
use super::placeholder::{self, PlaceholderState};
use super::preview;
use super::raw_value_editor::{self, RawValueEditorState};
use super::theme_picker::{self, ThemePickerState};
use super::type_picker::{self, TypePickerState};
#[non_exhaustive]
#[derive(Debug)]
pub(super) enum AppScreen {
MainMenu(MainMenuState),
Placeholder(PlaceholderState),
ItemsEditor(ItemsEditorState),
LinePicker(LinePickerState),
TypePicker(TypePickerState),
RawValueEditor(RawValueEditorState),
ThemePicker(ThemePickerState),
InstallToClaudeCode(InstallScreenState),
}
impl AppScreen {
fn captures_text_input(&self) -> bool {
matches!(self, AppScreen::RawValueEditor(_))
}
fn footer_hint(&self) -> &'static str {
match self {
AppScreen::MainMenu(_) => " [Enter] activate [Esc] quit [Ctrl+C] force-quit ",
AppScreen::RawValueEditor(_) => " [Enter] commit [Esc] cancel [Ctrl+C] force-quit ",
AppScreen::Placeholder(_)
| AppScreen::ItemsEditor(_)
| AppScreen::LinePicker(_)
| AppScreen::TypePicker(_)
| AppScreen::ThemePicker(_)
| AppScreen::InstallToClaudeCode(_) => {
" [Enter] confirm [Esc] back [q] quit [Ctrl+C] force-quit "
}
}
}
}
#[derive(Debug, Default)]
pub(super) enum SaveFeedback {
#[default]
None,
Saved,
Error(String),
}
pub(super) struct Model {
pub(super) screen: AppScreen,
#[allow(dead_code)]
pub(super) config: config::Config,
pub(super) document: DocumentMut,
pub(super) original_text: String,
pub(super) save_target: Option<PathBuf>,
pub(super) theme: Theme,
theme_registry: ThemeRegistry,
pub(super) capability: Capability,
pub(super) sink: Option<Arc<CapturedSink>>,
pub(super) save_feedback: SaveFeedback,
pub(super) install_settings_path: Option<PathBuf>,
pub(super) install_command: String,
pub(super) quit: bool,
}
impl Model {
#[allow(clippy::too_many_arguments)]
pub(super) fn new(
config: config::Config,
document: DocumentMut,
original_text: String,
save_target: Option<PathBuf>,
theme: Theme,
theme_registry: ThemeRegistry,
capability: Capability,
sink: Option<Arc<CapturedSink>>,
install_settings_path: Option<PathBuf>,
install_command: String,
) -> Self {
Self {
screen: AppScreen::MainMenu(MainMenuState::default()),
config,
document,
original_text,
save_target,
theme,
theme_registry,
capability,
sink,
install_settings_path,
install_command,
save_feedback: SaveFeedback::None,
quit: false,
}
}
pub(super) fn save(&mut self) -> SaveOutcome {
let Some(path) = self.save_target.clone() else {
return SaveOutcome::NoTarget;
};
let serialized = self.document.to_string();
let dirty = serialized != self.original_text;
if !dirty && path.exists() {
return SaveOutcome::Clean;
}
match super::atomic_write(&path, &serialized) {
Ok(()) => {
self.original_text = serialized;
SaveOutcome::Saved(path)
}
Err(error) => SaveOutcome::Error { path, error },
}
}
}
#[non_exhaustive]
#[derive(Debug)]
pub(super) enum SaveOutcome {
NoTarget,
Clean,
Saved(PathBuf),
Error { path: PathBuf, error: io::Error },
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy)]
pub(super) enum Event {
Key(KeyEvent),
Resize,
}
#[non_exhaustive]
#[derive(Debug)]
pub(super) enum ScreenOutcome {
Stay,
Committed,
NavigateTo(AppScreen),
CommitAndNavigate(AppScreen),
Quit,
}
#[must_use]
pub(super) fn update(mut model: Model, event: Event) -> Model {
let key = match event {
Event::Key(key) => key,
Event::Resize => return model,
};
if matches!(model.save_feedback, SaveFeedback::Saved) {
model.save_feedback = SaveFeedback::None;
}
if is_save_key(&key) {
if model.screen.captures_text_input() {
linesmith_core::lsm_warn!("press Enter to commit your edit before saving",);
return model;
}
if !matches!(model.save_feedback, SaveFeedback::Error(_)) {
linesmith_core::lsm_warn!("Ctrl+S is no longer needed; changes save automatically",);
}
apply_commit_save(&mut model);
return model;
}
if is_force_quit(&key) {
if let SaveFeedback::Error(msg) = &model.save_feedback {
linesmith_core::lsm_warn!("force-quit with unresolved save error: {msg}");
}
model.quit = true;
return model;
}
if !model.screen.captures_text_input() && is_quit_attempt(&key) {
log_quit_with_unresolved_error(&model, "quit");
model.quit = true;
return model;
}
let outcome = match &mut model.screen {
AppScreen::MainMenu(state) => {
let install_ctx = main_menu::InstallContext {
settings_path: model.install_settings_path.as_deref(),
install_command: &model.install_command,
};
main_menu::update(
state,
&model.config,
&model.theme_registry,
install_ctx,
key,
)
}
AppScreen::Placeholder(state) => placeholder::update(state, key),
AppScreen::ItemsEditor(state) => {
items_editor::update(state, &mut model.document, &mut model.config, key)
}
AppScreen::LinePicker(state) => {
line_picker::update(state, &mut model.document, &mut model.config, key)
}
AppScreen::TypePicker(state) => {
type_picker::update(state, &mut model.document, &mut model.config, key)
}
AppScreen::RawValueEditor(state) => {
raw_value_editor::update(state, &mut model.document, &mut model.config, key)
}
AppScreen::ThemePicker(state) => theme_picker::update(
state,
&mut model.document,
&mut model.config,
&mut model.theme,
key,
),
AppScreen::InstallToClaudeCode(state) => install_screen::update(state, key),
};
match outcome {
ScreenOutcome::Stay => {}
ScreenOutcome::Committed => apply_commit_save(&mut model),
ScreenOutcome::NavigateTo(screen) => model.screen = screen,
ScreenOutcome::CommitAndNavigate(screen) => {
apply_commit_save(&mut model);
model.screen = screen;
}
ScreenOutcome::Quit => {
log_quit_with_unresolved_error(&model, "quit");
model.quit = true;
}
}
model
}
fn apply_commit_save(model: &mut Model) {
match model.save() {
SaveOutcome::Saved(path) => {
model.save_feedback = SaveFeedback::Saved;
linesmith_core::lsm_debug!("saved {}", path.display());
}
SaveOutcome::Clean => {
if matches!(model.save_feedback, SaveFeedback::Error(_)) {
model.save_feedback = SaveFeedback::None;
linesmith_core::lsm_debug!("cleared stale save error banner");
}
}
SaveOutcome::NoTarget => {
let already_shown = matches!(
&model.save_feedback,
SaveFeedback::Error(prior) if prior == NO_TARGET_BANNER,
);
if !already_shown {
linesmith_core::lsm_warn!("{NO_TARGET_BANNER}");
}
model.save_feedback = SaveFeedback::Error(NO_TARGET_BANNER.to_string());
}
SaveOutcome::Error { path, error } => {
let hint = save_error_hint(&error);
let msg = format!("couldn't save to {}: {error}{hint}", path.display());
linesmith_core::lsm_error!("{msg}");
model.save_feedback = SaveFeedback::Error(msg);
}
}
}
const NO_TARGET_BANNER: &str =
"save not available — no config path supplied or file failed to parse at load";
fn save_error_hint(error: &io::Error) -> &'static str {
match error.kind() {
io::ErrorKind::PermissionDenied => {
" (try `chmod` on the parent directory or rerun with a different `--config`)"
}
io::ErrorKind::NotFound => {
" (parent directory missing — create it or rerun with a valid `--config`)"
}
_ => "",
}
}
fn log_quit_with_unresolved_error(model: &Model, kind: &str) {
if let SaveFeedback::Error(msg) = &model.save_feedback {
linesmith_core::lsm_error!("{kind} with unresolved save error: {msg}");
}
}
fn is_quit_attempt(key: &KeyEvent) -> bool {
matches!(
(key.code, key.modifiers),
(KeyCode::Char('q'), KeyModifiers::NONE),
)
}
fn is_force_quit(key: &KeyEvent) -> bool {
matches!(
(key.code, key.modifiers),
(KeyCode::Char('c' | 'C'), m) if m.contains(KeyModifiers::CONTROL),
)
}
fn is_save_key(key: &KeyEvent) -> bool {
matches!(
(key.code, key.modifiers),
(KeyCode::Char('s' | 'S'), m) if m.contains(KeyModifiers::CONTROL),
)
}
pub(super) fn view(model: &Model, frame: &mut Frame) {
let area = frame.area();
let inner_width = area.width.saturating_sub(2);
let preview_theme: &Theme = match &model.screen {
AppScreen::ThemePicker(state) => state.cursor_theme(),
_ => &model.theme,
};
let (preview_lines, mut warnings) = preview::render_lines(
&model.config,
preview_theme,
model.capability,
inner_width,
model.sink.as_deref(),
);
let env_snapshot = EnvironmentSnapshot::from_process();
let color_policy = model
.config
.layout_options
.as_ref()
.map_or(config::ColorPolicy::Auto, |lo| lo.color);
prepend_env_warnings(&mut warnings, model.capability, color_policy, &env_snapshot);
let feedback_row = match &model.save_feedback {
SaveFeedback::None => 0u16,
SaveFeedback::Saved | SaveFeedback::Error(_) => 1u16,
};
let line_rows = u16::try_from(preview_lines.len().max(1)).unwrap_or(u16::MAX);
let warn_rows = u16::try_from(warnings.len()).unwrap_or(u16::MAX);
let preview_height = line_rows
.saturating_add(warn_rows)
.saturating_add(feedback_row)
.saturating_add(2)
.min(16);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(preview_height),
Constraint::Min(1),
Constraint::Length(1),
])
.split(area);
render_preview(
&preview_lines,
&warnings,
&model.save_feedback,
chunks[0],
frame,
);
match &model.screen {
AppScreen::MainMenu(state) => main_menu::view(state, frame, chunks[1]),
AppScreen::Placeholder(state) => placeholder::view(state, frame, chunks[1]),
AppScreen::ItemsEditor(state) => {
items_editor::view(state, &model.document, frame, chunks[1])
}
AppScreen::LinePicker(state) => line_picker::view(state, &model.document, frame, chunks[1]),
AppScreen::TypePicker(state) => type_picker::view(state, frame, chunks[1]),
AppScreen::RawValueEditor(state) => raw_value_editor::view(state, frame, chunks[1]),
AppScreen::ThemePicker(state) => theme_picker::view(state, frame, chunks[1]),
AppScreen::InstallToClaudeCode(state) => install_screen::view(state, frame, chunks[1]),
}
render_footer_hints(&model.screen, frame, chunks[2]);
}
fn render_footer_hints(screen: &AppScreen, frame: &mut Frame, area: ratatui::layout::Rect) {
if area.height == 0 {
return;
}
let hint = Paragraph::new(Line::styled(
screen.footer_hint(),
Style::default().add_modifier(Modifier::DIM),
));
frame.render_widget(hint, area);
}
fn render_preview(
lines: &[Line<'static>],
warnings: &[String],
feedback: &SaveFeedback,
area: ratatui::layout::Rect,
frame: &mut Frame,
) {
let block = Block::default().borders(Borders::ALL).title(Span::styled(
" preview ",
Style::default().add_modifier(Modifier::BOLD),
));
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 {
return;
}
let line_rows = u16::try_from(lines.len().max(1)).unwrap_or(u16::MAX);
let warn_rows = u16::try_from(warnings.len()).unwrap_or(u16::MAX);
let feedback_rows = match feedback {
SaveFeedback::None => 0u16,
SaveFeedback::Saved | SaveFeedback::Error(_) => 1u16,
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(line_rows),
Constraint::Length(warn_rows),
Constraint::Length(feedback_rows),
])
.split(inner);
if lines.is_empty() {
let body = Paragraph::new(Line::from(
"(no preview — `[line].segments` resolved to empty; check warnings below)",
));
frame.render_widget(body, chunks[0]);
} else {
let body = Paragraph::new(lines.to_vec());
frame.render_widget(body, chunks[0]);
}
if !warnings.is_empty() {
let style = Style::default()
.add_modifier(Modifier::DIM)
.add_modifier(Modifier::ITALIC);
let warn_lines: Vec<Line<'static>> = warnings
.iter()
.map(|w| Line::styled(format!("⚠ {w}"), style))
.collect();
let body = Paragraph::new(warn_lines);
frame.render_widget(body, chunks[1]);
}
match feedback {
SaveFeedback::None => {}
SaveFeedback::Saved => {
let body = Paragraph::new(Line::styled(
"✓ saved",
Style::default().add_modifier(Modifier::BOLD),
));
frame.render_widget(body, chunks[2]);
}
SaveFeedback::Error(msg) => {
let body = Paragraph::new(Line::styled(
format!("✗ {msg}"),
Style::default().add_modifier(Modifier::BOLD),
));
frame.render_widget(body, chunks[2]);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode, mods: KeyModifiers) -> Event {
Event::Key(KeyEvent::new(code, mods))
}
fn model() -> Model {
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(),
)
}
fn model_with_loaded_text(original: &str, save_target: PathBuf) -> Model {
let document: DocumentMut = original.parse().expect("test text must parse");
Model::new(
config::Config::default(),
document,
original.to_string(),
Some(save_target),
crate::theme::default_theme().clone(),
ThemeRegistry::with_built_ins(),
Capability::None,
None,
None,
"linesmith".to_string(),
)
}
#[test]
fn save_returns_no_target_when_save_target_unset() {
let mut m = model();
m.original_text = "old".to_string();
let outcome = m.save();
assert!(matches!(outcome, SaveOutcome::NoTarget));
}
#[test]
fn save_creates_missing_file_even_when_document_unchanged() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("new.toml");
assert!(!path.exists(), "test setup must start with no file");
let mut m = Model::new(
config::Config::default(),
DocumentMut::new(),
String::new(),
Some(path.clone()),
crate::theme::default_theme().clone(),
ThemeRegistry::with_built_ins(),
Capability::None,
None,
None,
"linesmith".to_string(),
);
let outcome = m.save();
assert!(
matches!(&outcome, SaveOutcome::Saved(p) if p == &path),
"missing-file save must take the Saved arm, got {outcome:?}",
);
assert!(path.exists(), "Saved must have created the file");
let outcome = m.save();
assert!(
matches!(outcome, SaveOutcome::Clean),
"second save on unchanged document with file present must be Clean, got {outcome:?}",
);
}
#[test]
fn save_recreates_file_deleted_externally() {
let raw = "[line]\nsegments = [\"model\"]\n";
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("config.toml");
let mut m = model_with_loaded_text(raw, path.clone());
std::fs::write(&path, raw).expect("seed");
assert_eq!(
m.document.to_string(),
m.original_text,
"loaded model must start with document matching original_text",
);
std::fs::remove_file(&path).expect("rm");
let outcome = m.save();
assert!(
matches!(&outcome, SaveOutcome::Saved(p) if p == &path),
"deleted-target save must recreate, got {outcome:?}",
);
let written = std::fs::read_to_string(&path).expect("read");
assert_eq!(
written, raw,
"recreated file must match the loaded document"
);
}
#[test]
fn save_returns_clean_when_no_edits_and_file_present() {
let raw = "[line]\nsegments = [\"model\"]\n";
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("config.toml");
std::fs::write(&path, raw).expect("seed");
let metadata_before = std::fs::metadata(&path).expect("stat");
let mut m = model_with_loaded_text(raw, path.clone());
let outcome = m.save();
assert!(matches!(outcome, SaveOutcome::Clean), "got {outcome:?}");
let metadata_after = std::fs::metadata(&path).expect("stat");
assert_eq!(
metadata_before.modified().expect("mtime"),
metadata_after.modified().expect("mtime"),
"Clean must not touch the file",
);
}
#[test]
fn save_writes_dirty_document_atomically_and_clears_dirty_flag() {
let raw = "[line]\nsegments = [\"model\"]\n";
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("config.toml");
let mut m = model_with_loaded_text(raw, path.clone());
m.document["theme"] = toml_edit::value("dracula");
let expected_serialized = m.document.to_string();
let outcome = m.save();
assert!(
matches!(&outcome, SaveOutcome::Saved(p) if p == &path),
"got {outcome:?}",
);
assert_eq!(
m.original_text, expected_serialized,
"post-save original_text must advance to the just-written bytes",
);
let written = std::fs::read_to_string(&path).expect("read");
assert_eq!(
written, expected_serialized,
"on-disk bytes must match in-memory document",
);
assert_eq!(
written, m.original_text,
"original_text must be the just-written bytes",
);
}
#[test]
fn save_preserves_comments_and_blank_lines_on_round_trip() {
let raw = "# top comment\n\n[line] # inline section comment\nsegments = [\"model\"]\n";
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("config.toml");
let mut m = model_with_loaded_text(raw, path.clone());
m.document["theme"] = toml_edit::value("dracula");
let _ = m.save();
let written = std::fs::read_to_string(&path).expect("read");
assert!(
written.contains("# top comment"),
"lost top comment: {written:?}",
);
assert!(
written.contains("# inline section comment"),
"lost inline comment: {written:?}",
);
}
#[test]
fn save_returns_error_when_path_unwritable() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let blocker = tmp.path().join("not-a-dir");
std::fs::write(&blocker, "x").expect("write");
let path = blocker.join("config.toml");
let raw = "[line]\nsegments = [\"model\"]\n";
let mut m = model_with_loaded_text(raw, path.clone());
m.document["theme"] = toml_edit::value("dracula");
let outcome = m.save();
assert!(
matches!(&outcome, SaveOutcome::Error { path: p, .. } if p == &path),
"got {outcome:?}",
);
assert_eq!(
m.original_text, raw,
"error path must not advance original_text — that would silently mark the failed write as the new baseline so the next commit wouldn't retry",
);
}
#[test]
fn apply_commit_save_sets_error_feedback_on_no_target() {
use crate::logging::{self, Level};
let _serial = logging::_test_serial_lock();
let captured = std::sync::Arc::new(crate::logging::CapturedSink::default());
let _restore = logging::SinkGuard::install(captured.clone());
logging::set_level(Level::Warn);
let mut m = model();
m.original_text = "old".to_string();
apply_commit_save(&mut m);
match &m.save_feedback {
SaveFeedback::Error(msg) => assert!(
msg.contains("save not available"),
"banner must name the failure reason: {msg}",
),
other => panic!("expected Error feedback, got {other:?}"),
}
let entries = captured.drain();
assert!(
entries
.iter()
.any(|e| e.starts_with("[warn]") && e.contains("save not available")),
"captured sink must mirror the banner via lsm_warn! in {entries:?}",
);
}
#[test]
fn no_target_dedup_emits_warn_only_on_first_emission() {
use crate::logging::{self, Level};
let _serial = logging::_test_serial_lock();
let captured = std::sync::Arc::new(crate::logging::CapturedSink::default());
let _restore = logging::SinkGuard::install(captured.clone());
logging::set_level(Level::Warn);
let mut m = model();
m.original_text = "old".to_string();
apply_commit_save(&mut m);
apply_commit_save(&mut m);
let warn_count = captured
.drain()
.iter()
.filter(|e| e.starts_with("[warn]") && e.contains("save not available"))
.count();
assert_eq!(
warn_count, 1,
"lsm_warn! must fire exactly once across N consecutive NoTarget commits",
);
assert!(
matches!(m.save_feedback, SaveFeedback::Error(_)),
"banner must persist across both commits: {:?}",
m.save_feedback,
);
}
#[test]
fn save_error_hint_maps_known_kinds_and_falls_through_for_others() {
use std::io::{Error, ErrorKind};
let perm = save_error_hint(&Error::from(ErrorKind::PermissionDenied));
assert!(
perm.contains("chmod") || perm.contains("--config"),
"PermissionDenied hint must mention chmod or --config: {perm:?}",
);
let nf = save_error_hint(&Error::from(ErrorKind::NotFound));
assert!(
nf.contains("parent directory") || nf.contains("--config"),
"NotFound hint must mention parent directory or --config: {nf:?}",
);
let other = save_error_hint(&Error::from(ErrorKind::Other));
assert_eq!(
other, "",
"unmapped kinds must return empty so the raw OS message stands alone: {other:?}",
);
}
#[test]
fn apply_commit_save_sets_error_feedback_on_io_failure() {
use crate::logging::{self, Level};
let _serial = logging::_test_serial_lock();
let captured = std::sync::Arc::new(crate::logging::CapturedSink::default());
let _restore = logging::SinkGuard::install(captured.clone());
logging::set_level(Level::Warn);
let raw = "[line]\nsegments = [\"model\"]\n";
let tmp = tempfile::TempDir::new().expect("tempdir");
let blocker = tmp.path().join("not-a-dir");
std::fs::write(&blocker, "x").expect("write");
let path = blocker.join("config.toml");
let mut m = model_with_loaded_text(raw, path);
m.document["theme"] = toml_edit::value("dracula");
apply_commit_save(&mut m);
match &m.save_feedback {
SaveFeedback::Error(msg) => assert!(
msg.contains("couldn't save"),
"banner must lead with the failure: {msg}",
),
other => panic!("expected Error feedback, got {other:?}"),
}
let entries = captured.drain();
assert!(
entries
.iter()
.any(|e| e.starts_with("[error]") && e.contains("couldn't save")),
"captured sink must mirror the banner via lsm_error! in {entries:?}",
);
}
#[test]
fn ctrl_s_after_failure_retries_and_lands_saved() {
let raw = "[line]\nsegments = [\"model\"]\n";
let tmp = tempfile::TempDir::new().expect("tempdir");
let blocker = tmp.path().join("blocker");
std::fs::write(&blocker, "x").expect("seed blocker");
let path = blocker.join("config.toml");
let mut m = model_with_loaded_text(raw, path.clone());
m.document["theme"] = toml_edit::value("dracula");
apply_commit_save(&mut m);
assert!(
matches!(m.save_feedback, SaveFeedback::Error(_)),
"first commit must surface Error",
);
std::fs::remove_file(&blocker).expect("rm blocker");
std::fs::create_dir(&blocker).expect("mkdir blocker");
let m = update(m, key(KeyCode::Char('s'), KeyModifiers::CONTROL));
assert!(
matches!(m.save_feedback, SaveFeedback::Saved),
"Ctrl+S after failure must flip feedback to Saved on success: {:?}",
m.save_feedback,
);
let written = std::fs::read_to_string(&path).expect("read");
assert!(
written.contains("dracula"),
"retry must persist the dracula edit: {written}",
);
}
#[test]
fn apply_commit_save_sets_saved_feedback_on_success() {
use crate::logging::{self, Level};
let _serial = logging::_test_serial_lock();
let captured = std::sync::Arc::new(crate::logging::CapturedSink::default());
let _restore = logging::SinkGuard::install(captured.clone());
logging::set_level(Level::Debug);
let raw = "[line]\nsegments = [\"model\"]\n";
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("config.toml");
let mut m = model_with_loaded_text(raw, path);
m.document["theme"] = toml_edit::value("dracula");
apply_commit_save(&mut m);
assert!(
matches!(m.save_feedback, SaveFeedback::Saved),
"successful save must flip feedback to Saved",
);
let entries = captured.drain();
assert!(
entries
.iter()
.any(|e| e.starts_with("[debug]") && e.contains("saved")),
"expected saved debug in {entries:?}",
);
logging::set_level(Level::Warn);
}
#[test]
fn successful_commit_after_error_clears_error_banner() {
let raw = "[line]\nsegments = [\"model\"]\n";
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("config.toml");
let mut m = model_with_loaded_text(raw, path);
m.save_feedback = SaveFeedback::Error("simulated prior failure".to_string());
m.document["theme"] = toml_edit::value("dracula");
apply_commit_save(&mut m);
assert!(
matches!(m.save_feedback, SaveFeedback::Saved),
"successful save must replace error banner with saved toast",
);
}
#[test]
fn next_event_clears_saved_toast_but_not_error_banner() {
let mut m = model();
m.save_feedback = SaveFeedback::Saved;
let m = update(m, key(KeyCode::Down, KeyModifiers::NONE));
assert!(
matches!(m.save_feedback, SaveFeedback::None),
"Saved toast must clear on next key event",
);
let mut m = model();
m.save_feedback = SaveFeedback::Error("disk full".to_string());
let m = update(m, key(KeyCode::Down, KeyModifiers::NONE));
assert!(
matches!(m.save_feedback, SaveFeedback::Error(_)),
"Error banner must persist across events: {:?}",
m.save_feedback,
);
}
#[test]
fn ctrl_s_emits_deprecation_warn_and_force_flushes() {
use crate::logging::{self, Level};
let _serial = logging::_test_serial_lock();
let captured = std::sync::Arc::new(crate::logging::CapturedSink::default());
let _restore = logging::SinkGuard::install(captured.clone());
logging::set_level(Level::Warn);
let raw = "[line]\nsegments = [\"model\"]\n";
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("config.toml");
let mut m = model_with_loaded_text(raw, path.clone());
m.document["theme"] = toml_edit::value("dracula");
let _ = update(m, key(KeyCode::Char('s'), KeyModifiers::CONTROL));
let entries = captured.drain();
assert!(
entries
.iter()
.any(|e| e.starts_with("[warn]") && e.contains("save automatically")),
"expected deprecation warn in {entries:?}",
);
let written = std::fs::read_to_string(&path).expect("read");
assert!(
written.contains("dracula"),
"force-flush must write the in-memory document: {written}",
);
}
#[test]
fn quit_is_unconditional_under_instant_apply() {
let raw = "[line]\nsegments = [\"model\"]\n";
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("config.toml");
let mut m = model_with_loaded_text(raw, path);
m.document["theme"] = toml_edit::value("dracula");
let m = update(m, key(KeyCode::Char('q'), KeyModifiers::NONE));
assert!(
m.quit,
"q must quit immediately, no modal under instant-apply",
);
}
#[test]
fn esc_on_main_menu_quits() {
let m = update(model(), key(KeyCode::Esc, KeyModifiers::NONE));
assert!(m.quit);
}
#[test]
fn lowercase_q_sets_quit() {
let m = update(model(), key(KeyCode::Char('q'), KeyModifiers::NONE));
assert!(m.quit);
}
#[test]
fn ctrl_c_sets_quit() {
let m = update(model(), key(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert!(m.quit);
}
#[test]
fn unrelated_keys_do_not_quit() {
let m = update(model(), key(KeyCode::Char('c'), KeyModifiers::NONE));
assert!(!m.quit);
let m = update(model(), key(KeyCode::Char('Q'), KeyModifiers::SHIFT));
assert!(!m.quit);
}
#[test]
fn ctrl_c_uppercase_also_quits() {
let m = update(
model(),
key(
KeyCode::Char('c'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
),
);
assert!(m.quit);
}
#[test]
fn non_quit_key_routes_to_screen_without_quitting() {
let m = update(model(), key(KeyCode::F(12), KeyModifiers::NONE));
assert!(!m.quit, "non-quit key must not set quit");
assert!(
matches!(m.screen, AppScreen::MainMenu(_)),
"screen must remain MainMenu",
);
}
#[test]
fn resize_event_does_not_change_state() {
let m = update(model(), Event::Resize);
assert!(!m.quit);
assert!(matches!(m.screen, AppScreen::MainMenu(_)));
}
#[test]
fn implicit_default_commit_keeps_feedback_none() {
let raw = "[line]\nsegments = [\"model\"]\n";
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("config.toml");
std::fs::write(&path, raw).expect("seed");
let m = model_with_loaded_text(raw, path);
let m = update(m, key(KeyCode::Down, KeyModifiers::NONE));
let m = update(m, key(KeyCode::Enter, KeyModifiers::NONE));
let m = update(m, key(KeyCode::Enter, KeyModifiers::NONE));
assert!(
matches!(m.save_feedback, SaveFeedback::None),
"implicit-default commit must NOT set Saved feedback: {:?}",
m.save_feedback,
);
}
#[test]
fn clean_commit_clears_prior_error_banner() {
let raw = "[line]\nsegments = [\"model\"]\n";
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("config.toml");
std::fs::write(&path, raw).expect("seed");
let mut m = model_with_loaded_text(raw, path);
m.save_feedback = SaveFeedback::Error("simulated prior failure".to_string());
apply_commit_save(&mut m);
assert!(
matches!(m.save_feedback, SaveFeedback::None),
"Clean must clear stale Error banner: {:?}",
m.save_feedback,
);
}
#[test]
fn ctrl_s_suppresses_deprecation_warn_when_save_error_active() {
use crate::logging::{self, Level};
let _serial = logging::_test_serial_lock();
let captured = std::sync::Arc::new(crate::logging::CapturedSink::default());
let _restore = logging::SinkGuard::install(captured.clone());
logging::set_level(Level::Warn);
let raw = "[line]\nsegments = [\"model\"]\n";
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("config.toml");
std::fs::write(&path, raw).expect("seed");
let mut m = model_with_loaded_text(raw, path);
m.save_feedback = SaveFeedback::Error("prior failure".to_string());
let m = update(m, key(KeyCode::Char('s'), KeyModifiers::CONTROL));
let entries = captured.drain();
assert!(
!entries
.iter()
.any(|e| e.contains("Ctrl+S is no longer needed")),
"deprecation warn must be suppressed while Error active: {entries:?}",
);
assert!(
matches!(m.save_feedback, SaveFeedback::None),
"Ctrl+S with Clean SaveOutcome must clear the stale Error banner: {:?}",
m.save_feedback,
);
}
#[test]
fn quit_with_unresolved_error_emits_lsm_error_for_post_exit_trail() {
use crate::logging::{self, Level};
let _serial = logging::_test_serial_lock();
let captured = std::sync::Arc::new(crate::logging::CapturedSink::default());
let _restore = logging::SinkGuard::install(captured.clone());
logging::set_level(Level::Warn);
let mut m = model();
m.save_feedback = SaveFeedback::Error("simulated PermissionDenied".to_string());
let m = update(m, key(KeyCode::Char('q'), KeyModifiers::NONE));
assert!(m.quit, "q must still quit unconditionally");
let entries = captured.drain();
assert!(
entries
.iter()
.any(|e| e.starts_with("[error]") && e.contains("unresolved save error")),
"q-with-Error must log lsm_error! to captured sink: {entries:?}",
);
}
#[test]
fn ctrl_c_with_unresolved_error_emits_lsm_warn_not_lsm_error() {
use crate::logging::{self, Level};
let _serial = logging::_test_serial_lock();
let captured = std::sync::Arc::new(crate::logging::CapturedSink::default());
let _restore = logging::SinkGuard::install(captured.clone());
logging::set_level(Level::Warn);
let mut m = model();
m.save_feedback = SaveFeedback::Error("simulated failure".to_string());
let m = update(m, key(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert!(m.quit);
let entries = captured.drain();
assert!(
entries
.iter()
.any(|e| e.starts_with("[warn]")
&& e.contains("force-quit with unresolved save error")),
"Ctrl+C-with-Error must log lsm_warn! (not lsm_error!): {entries:?}",
);
assert!(
!entries
.iter()
.any(|e| e.starts_with("[error]") && e.contains("unresolved save error")),
"Ctrl+C path must NOT emit lsm_error! — that's reserved for clean-intent quits: {entries:?}",
);
}
#[test]
fn screen_driven_quit_with_unresolved_error_also_logs() {
use crate::logging::{self, Level};
let _serial = logging::_test_serial_lock();
let captured = std::sync::Arc::new(crate::logging::CapturedSink::default());
let _restore = logging::SinkGuard::install(captured.clone());
logging::set_level(Level::Warn);
let mut m = model();
m.save_feedback = SaveFeedback::Error("simulated failure".to_string());
let m = update(m, key(KeyCode::Esc, KeyModifiers::NONE));
assert!(m.quit, "MainMenu Esc must quit");
let entries = captured.drain();
assert!(
entries
.iter()
.any(|e| e.starts_with("[error]") && e.contains("unresolved save error")),
"screen-driven Quit with Error must log to captured sink: {entries:?}",
);
}
#[test]
fn footer_hint_text_matches_screen_keybind_semantics() {
let main = AppScreen::MainMenu(MainMenuState::default());
let hint = main.footer_hint();
assert!(
hint.contains("[Esc] quit") && !hint.contains("[Esc] back"),
"MainMenu footer must say Esc quits, not back-navs: {hint}",
);
let editor = AppScreen::RawValueEditor(super::raw_value_editor::RawValueEditorState::new(
String::new(),
0,
super::raw_value_editor::RawTarget::SegmentId,
ItemsEditorState::default(),
));
let hint = editor.footer_hint();
assert!(
!hint.contains("[q] quit"),
"RawValueEditor footer must NOT advertise [q] quit — q is a literal character: {hint}",
);
assert!(
hint.contains("Ctrl+C"),
"RawValueEditor footer must surface Ctrl+C as the universal escape: {hint}",
);
let items = AppScreen::ItemsEditor(ItemsEditorState::default());
let hint = items.footer_hint();
assert!(
hint.contains("[Esc] back") && hint.contains("[q] quit"),
"ItemsEditor footer must advertise Esc-back + q-quit: {hint}",
);
}
#[test]
fn resize_event_preserves_saved_toast() {
let mut m = model();
m.save_feedback = SaveFeedback::Saved;
let m = update(m, Event::Resize);
assert!(
matches!(m.save_feedback, SaveFeedback::Saved),
"Resize must NOT clear the Saved toast: {:?}",
m.save_feedback,
);
}
#[test]
fn theme_picker_cursor_diverges_from_committed_theme_on_navigation() {
let m = update(model(), key(KeyCode::Down, KeyModifiers::NONE)); let m = update(m, key(KeyCode::Enter, KeyModifiers::NONE)); match &m.screen {
AppScreen::ThemePicker(_) => {}
other => panic!("expected ThemePicker, got {other:?}"),
}
let m = update(m, key(KeyCode::Down, KeyModifiers::NONE));
let cursor_theme_name = match &m.screen {
AppScreen::ThemePicker(state) => state.cursor_theme().name().to_string(),
other => panic!("expected ThemePicker, got {other:?}"),
};
assert_ne!(
cursor_theme_name,
m.theme.name(),
"cursor must move off the committed theme on Down",
);
}
#[test]
fn enter_on_main_menu_navigates_to_placeholder() {
let m = update(model(), key(KeyCode::Down, KeyModifiers::NONE));
let m = update(m, key(KeyCode::Down, KeyModifiers::NONE));
let m = update(m, key(KeyCode::Enter, KeyModifiers::NONE));
assert!(!m.quit);
assert!(
matches!(m.screen, AppScreen::Placeholder(_)),
"screen should transition to Placeholder",
);
}
#[test]
fn q_on_placeholder_quits() {
let m = update(model(), key(KeyCode::Down, KeyModifiers::NONE));
let m = update(m, key(KeyCode::Down, KeyModifiers::NONE));
let m = update(m, key(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(m.screen, AppScreen::Placeholder(_)));
let m = update(m, key(KeyCode::Char('q'), KeyModifiers::NONE));
assert!(m.quit);
}
#[test]
fn items_editor_swap_through_app_dispatch_mutates_document_and_config() {
let raw = "[line]\nsegments = [\"a\", \"b\"]\n";
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("config.toml");
let m = model_with_loaded_text(raw, path);
let m = update(m, key(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(m.screen, AppScreen::ItemsEditor(_)));
let m = update(m, key(KeyCode::Enter, KeyModifiers::NONE));
let m = update(m, key(KeyCode::Down, KeyModifiers::NONE));
let line = m.config.line.clone().expect("line config reparsed");
let ids: Vec<&str> = line
.segments
.iter()
.filter_map(linesmith_core::config::LineEntry::segment_id)
.collect();
assert_eq!(ids, vec!["b", "a"]);
let written = m.document.to_string();
assert!(
written.contains("\"b\"") && written.contains("\"a\""),
"document should retain both segments: {written}",
);
}
#[test]
fn q_keypress_on_raw_value_editor_inserts_text_does_not_quit() {
let raw = "[line]\nsegments = [\"alpha\"]\n";
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("config.toml");
let m = model_with_loaded_text(raw, path);
let m = update(m, key(KeyCode::Enter, KeyModifiers::NONE));
let m = update(m, key(KeyCode::Char('r'), KeyModifiers::NONE));
assert!(matches!(m.screen, AppScreen::RawValueEditor(_)));
let m = update(m, key(KeyCode::Char('q'), KeyModifiers::NONE));
assert!(!m.quit, "q on text-entry screen must not quit");
assert!(matches!(m.screen, AppScreen::RawValueEditor(_)));
let m = update(m, key(KeyCode::Enter, KeyModifiers::NONE));
let line = m.config.line.clone().expect("line reparsed");
let ids: Vec<&str> = line
.segments
.iter()
.filter_map(linesmith_core::config::LineEntry::segment_id)
.collect();
assert_eq!(ids, vec!["alphaq"]);
}
#[test]
fn ctrl_s_on_raw_value_editor_suppresses_save_and_warns() {
use crate::logging::{self, Level};
let _serial = logging::_test_serial_lock();
let captured = std::sync::Arc::new(crate::logging::CapturedSink::default());
let _restore = logging::SinkGuard::install(captured.clone());
logging::set_level(Level::Warn);
let raw = "[line]\nsegments = [\"alpha\"]\n";
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("config.toml");
let m = model_with_loaded_text(raw, path.clone());
let m = update(m, key(KeyCode::Enter, KeyModifiers::NONE));
let m = update(m, key(KeyCode::Char('r'), KeyModifiers::NONE));
assert!(matches!(m.screen, AppScreen::RawValueEditor(_)));
let m = update(m, key(KeyCode::Char('x'), KeyModifiers::NONE));
let pre_save_doc = m.document.to_string();
let m = update(m, key(KeyCode::Char('s'), KeyModifiers::CONTROL));
assert!(
matches!(m.screen, AppScreen::RawValueEditor(_)),
"Ctrl+S must not navigate away from the text-entry screen",
);
assert_eq!(
m.document.to_string(),
pre_save_doc,
"document must not change — buffer hasn't been committed",
);
assert!(
!path.exists(),
"Ctrl+S on a text-entry screen must not write to disk",
);
let entries = captured.drain();
assert!(
entries
.iter()
.any(|e| e.starts_with("[warn]") && e.contains("commit")),
"expected commit-warning in {entries:?}",
);
}
#[test]
fn raw_verb_on_default_segment_seeds_with_runtime_default() {
let raw = "";
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("config.toml");
let m = model_with_loaded_text(raw, path);
let m = update(m, key(KeyCode::Enter, KeyModifiers::NONE));
let m = update(m, key(KeyCode::Char('r'), KeyModifiers::NONE));
assert!(matches!(m.screen, AppScreen::RawValueEditor(_)));
let m = update(m, key(KeyCode::Enter, KeyModifiers::NONE));
let line = m.config.line.clone().expect("line reparsed");
let first_id = line.segments[0]
.segment_id()
.expect("first entry is a segment id");
assert_eq!(
first_id, "model",
"first runtime default must round-trip through r → Enter",
);
assert_eq!(line.segments.len(), 6);
}
#[test]
fn raw_verb_preserves_literal_non_string_string_id() {
let raw = "[line]\nsegments = [\"<non-string>\"]\n";
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("config.toml");
let m = model_with_loaded_text(raw, path);
let m = update(m, key(KeyCode::Enter, KeyModifiers::NONE));
let m = update(m, key(KeyCode::Char('r'), KeyModifiers::NONE));
assert!(matches!(m.screen, AppScreen::RawValueEditor(_)));
let m = update(m, key(KeyCode::Enter, KeyModifiers::NONE));
let line = m.config.line.clone().expect("line reparsed");
let ids: Vec<&str> = line
.segments
.iter()
.filter_map(linesmith_core::config::LineEntry::segment_id)
.collect();
assert_eq!(
ids,
vec!["<non-string>"],
"literal '<non-string>' must round-trip; placeholder check must inspect TOML type",
);
}
#[test]
fn raw_verb_on_non_string_entry_seeds_empty_buffer_through_dispatch() {
let raw = "[line]\nsegments = [\"a\", 42]\n";
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("config.toml");
let m = model_with_loaded_text(raw, path);
let m = update(m, key(KeyCode::Enter, KeyModifiers::NONE));
let m = update(m, key(KeyCode::Down, KeyModifiers::NONE));
let m = update(m, key(KeyCode::Char('r'), KeyModifiers::NONE));
assert!(matches!(m.screen, AppScreen::RawValueEditor(_)));
let m = update(m, key(KeyCode::Enter, KeyModifiers::NONE));
let line = m.config.line.clone().expect("line reparsed");
let ids: Vec<&str> = line
.segments
.iter()
.filter_map(linesmith_core::config::LineEntry::segment_id)
.collect();
assert_eq!(
ids,
vec!["a", ""],
"non-string entry replaced with empty seed",
);
}
#[test]
fn add_verb_through_app_dispatch_opens_picker_and_inserts_on_enter() {
let raw = "[line]\nsegments = [\"a\"]\n";
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("config.toml");
let m = model_with_loaded_text(raw, path);
let m = update(m, key(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(m.screen, AppScreen::ItemsEditor(_)));
let m = update(m, key(KeyCode::Char('a'), KeyModifiers::NONE));
assert!(matches!(m.screen, AppScreen::TypePicker(_)));
let m = update(m, key(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(m.screen, AppScreen::ItemsEditor(_)));
let line = m.config.line.clone().expect("line reparsed");
assert_eq!(line.segments.len(), 2);
assert_eq!(line.segments[0].segment_id(), Some("a"));
}
#[test]
fn items_editor_swap_auto_saves_without_ctrl_s() {
let raw = "[line]\nsegments = [\"a\", \"b\"]\n";
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("config.toml");
let m = model_with_loaded_text(raw, path.clone());
let m = update(m, key(KeyCode::Enter, KeyModifiers::NONE));
let m = update(m, key(KeyCode::Enter, KeyModifiers::NONE));
let m = update(m, key(KeyCode::Down, KeyModifiers::NONE));
let written = std::fs::read_to_string(&path).expect("read");
assert!(
written.contains("\"b\", \"a\"") || written.contains("\"b\",\"a\""),
"swap must auto-save to disk; got: {written:?}",
);
assert!(
matches!(m.save_feedback, SaveFeedback::Saved),
"save_feedback must flip to Saved after the auto-save",
);
}
#[test]
fn esc_on_placeholder_returns_to_main_menu() {
let m = update(model(), key(KeyCode::Down, KeyModifiers::NONE));
let m = update(m, key(KeyCode::Down, KeyModifiers::NONE));
let m = update(m, key(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(m.screen, AppScreen::Placeholder(_)));
let m = update(m, key(KeyCode::Esc, KeyModifiers::NONE));
assert!(!m.quit);
assert!(matches!(m.screen, AppScreen::MainMenu(_)));
}
}