use std::borrow::Cow;
use std::mem;
use std::num::NonZeroU32;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::Rect;
use ratatui::Frame;
use toml_edit::{DocumentMut, InlineTable, Value};
use crate::config;
use super::app::{AppScreen, ScreenOutcome};
use super::line_picker::LinePickerState;
use super::list_screen::{
self, ListOutcome, ListRowData, ListScreenState, ListScreenView, VerbHint,
};
use super::main_menu::MainMenuState;
use super::raw_value_editor::{RawTarget, RawValueEditorState};
use super::type_picker::TypePickerState;
const VERB_LETTERS: &[char] = &['d', 'c', 'k', 'r', 'm'];
const VERBS: &[VerbHint<'static>] = &[
VerbHint {
letter: 'a',
label: "add",
},
VerbHint {
letter: 'i',
label: "insert",
},
VerbHint {
letter: ' ',
label: "Space sep",
},
VerbHint {
letter: 'r',
label: "raw",
},
VerbHint {
letter: 'm',
label: "merge",
},
VerbHint {
letter: 'd',
label: "delete",
},
VerbHint {
letter: 'c',
label: "clear",
},
VerbHint {
letter: 'k',
label: "clone",
},
];
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(super) enum LineKey {
#[default]
Single,
#[allow(dead_code)]
Numbered(NonZeroU32),
}
#[derive(Debug, Clone, Copy)]
pub(super) enum InsertTarget {
Before(usize),
After(usize),
}
#[derive(Debug)]
pub(super) enum ItemsEditorPrev {
MainMenu(MainMenuState),
LinePicker(LinePickerState),
}
impl Default for ItemsEditorPrev {
fn default() -> Self {
Self::MainMenu(MainMenuState::default())
}
}
#[derive(Debug, Default)]
pub(super) struct ItemsEditorState {
line: LineKey,
list: ListScreenState,
pub(super) prev: ItemsEditorPrev,
}
impl ItemsEditorState {
pub(super) fn new(line: LineKey, prev: ItemsEditorPrev) -> Self {
Self {
line,
list: ListScreenState::default(),
prev,
}
}
#[allow(dead_code)]
pub(super) fn line(&self) -> LineKey {
self.line
}
#[allow(dead_code)]
pub(super) fn set_cursor(&mut self, idx: usize, num_rows: usize) {
self.list.set_cursor(idx, num_rows);
}
#[allow(dead_code)]
pub(super) fn cursor(&self) -> usize {
self.list.cursor()
}
}
pub(super) fn update(
state: &mut ItemsEditorState,
document: &mut DocumentMut,
config: &mut config::Config,
key: KeyEvent,
) -> ScreenOutcome {
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc {
return back_nav_to_prev(&mut state.prev);
}
if key.modifiers == KeyModifiers::NONE && !state.list.move_mode() {
match key.code {
KeyCode::Left | KeyCode::Char('i') => {
let cursor = state.list.cursor();
return open_type_picker(state, InsertTarget::Before(cursor));
}
KeyCode::Right | KeyCode::Char('a') => {
let cursor = state.list.cursor();
return open_type_picker(state, InsertTarget::After(cursor));
}
KeyCode::Char(' ') => {
let cursor = state.list.cursor();
let line = state.line;
let target = if segment_count(document, line) == 0 {
InsertTarget::Before(0)
} else {
InsertTarget::After(cursor)
};
if insert_separator(document, line, target) {
refresh_config(document, config);
let new_count = segment_count(document, line);
let new_cursor = match target {
InsertTarget::Before(idx) => idx,
InsertTarget::After(idx) => idx + 1,
};
state.list.set_cursor(new_cursor, new_count);
return ScreenOutcome::Committed;
}
linesmith_core::lsm_warn!(
"items editor: insert separator failed (line={line:?}); editor unchanged",
);
return ScreenOutcome::Stay;
}
_ => {}
}
}
let line = state.line;
let row_count = segment_count(document, line);
let cursor = state.list.cursor();
let mut committed = false;
match list_screen::handle_key(&mut state.list, key, row_count, VERB_LETTERS, true) {
ListOutcome::MoveSwap { from, to } => {
if swap_segments(document, line, from, to) {
let new_count = segment_count(document, line);
state.list.set_cursor(to, new_count);
refresh_config(document, config);
committed = true;
}
}
ListOutcome::Action('r') => {
return open_raw_value_editor(state, document, cursor);
}
ListOutcome::Action('m') => {
if toggle_merge_at(document, line, cursor) {
let new_count = segment_count(document, line);
state.list.set_cursor(cursor, new_count);
refresh_config(document, config);
committed = true;
} else {
linesmith_core::lsm_warn!(
"items editor: merge toggle inert at index {cursor} (line={line:?}); merge applies to segment entries only",
);
}
}
ListOutcome::Action('d') => {
if delete_segment_at(document, line, cursor) {
let new_count = segment_count(document, line);
state.list.set_cursor(cursor, new_count);
refresh_config(document, config);
committed = true;
}
}
ListOutcome::Action('c') => {
if clear_segments(document, line) {
state.list.set_cursor(0, 0);
refresh_config(document, config);
committed = true;
}
}
ListOutcome::Action('k') => {
if clone_segment_at(document, line, cursor) {
let new_count = segment_count(document, line);
state.list.set_cursor(cursor + 1, new_count);
refresh_config(document, config);
committed = true;
}
}
ListOutcome::Activate
| ListOutcome::Action(_)
| ListOutcome::Consumed
| ListOutcome::Unhandled => {}
}
if committed {
ScreenOutcome::Committed
} else {
ScreenOutcome::Stay
}
}
fn back_nav_to_prev(prev: &mut ItemsEditorPrev) -> ScreenOutcome {
match mem::take(prev) {
ItemsEditorPrev::MainMenu(state) => ScreenOutcome::NavigateTo(AppScreen::MainMenu(state)),
ItemsEditorPrev::LinePicker(state) => {
ScreenOutcome::NavigateTo(AppScreen::LinePicker(state))
}
}
}
fn open_type_picker(state: &mut ItemsEditorState, target: InsertTarget) -> ScreenOutcome {
let prev = mem::take(state);
ScreenOutcome::NavigateTo(AppScreen::TypePicker(TypePickerState::new(target, prev)))
}
fn open_raw_value_editor(
state: &mut ItemsEditorState,
document: &DocumentMut,
target_idx: usize,
) -> ScreenOutcome {
let line = state.line;
let (initial, target) = match classify_entry(document, line, target_idx) {
EntryKind::Separator => {
let initial = segments_array(document, line)
.and_then(|arr| arr.get(target_idx).and_then(|v| v.as_inline_table()))
.and_then(|t| t.get("character").and_then(|v| v.as_str()))
.map(str::to_string)
.unwrap_or_default();
(initial, RawTarget::SeparatorCharacter)
}
EntryKind::Segment | EntryKind::Malformed => {
let initial = if let Some(arr) = segments_array(document, line) {
arr.get(target_idx)
.and_then(|v| v.as_str())
.map(str::to_string)
.unwrap_or_else(|| inline_segment_id(arr.get(target_idx)))
} else if matches!(line, LineKey::Single) {
linesmith_core::segments::DEFAULT_SEGMENT_IDS
.get(target_idx)
.map(|s| (*s).to_string())
.unwrap_or_default()
} else {
String::new()
};
(initial, RawTarget::SegmentId)
}
};
let prev = mem::take(state);
ScreenOutcome::NavigateTo(AppScreen::RawValueEditor(RawValueEditorState::new(
initial, target_idx, target, prev,
)))
}
fn inline_segment_id(value: Option<&Value>) -> String {
value
.and_then(toml_edit::Value::as_inline_table)
.and_then(|t| t.get("type").and_then(|v| v.as_str()))
.map(str::to_string)
.unwrap_or_default()
}
pub(super) fn apply_replace(
mut prev: ItemsEditorState,
document: &mut DocumentMut,
config: &mut config::Config,
target_idx: usize,
target: RawTarget,
new_value: &str,
) -> ScreenOutcome {
let line = prev.line;
let success = match target {
RawTarget::SegmentId => replace_segment(document, line, target_idx, new_value),
RawTarget::SeparatorCharacter => {
replace_separator_character(document, line, target_idx, new_value)
}
};
if !success {
linesmith_core::lsm_warn!(
"items editor: replace failed at index {target_idx} (line={line:?}); editor unchanged",
);
return ScreenOutcome::NavigateTo(AppScreen::ItemsEditor(prev));
}
refresh_config(document, config);
let new_count = segment_count(document, line);
prev.list.set_cursor(target_idx, new_count);
ScreenOutcome::CommitAndNavigate(AppScreen::ItemsEditor(prev))
}
fn replace_segment(document: &mut DocumentMut, line: LineKey, idx: usize, new_value: &str) -> bool {
let Some(arr) = ensure_segments_array_mut(document, line) else {
return false;
};
let Some(entry) = arr.get_mut(idx) else {
return false;
};
match entry {
Value::InlineTable(table) => {
if table.get("type").and_then(|v| v.as_str()) == Some("separator") {
return false;
}
table.insert("type", Value::from(new_value));
true
}
_ => {
arr.replace(idx, new_value);
true
}
}
}
fn replace_separator_character(
document: &mut DocumentMut,
line: LineKey,
idx: usize,
new_value: &str,
) -> bool {
let Some(arr) = ensure_segments_array_mut(document, line) else {
return false;
};
let Some(value) = arr.get_mut(idx) else {
return false;
};
let Some(table) = value.as_inline_table_mut() else {
return false;
};
if table.get("type").and_then(|v| v.as_str()) != Some("separator") {
return false;
}
table.insert("character", Value::from(new_value));
true
}
fn toggle_merge_at(document: &mut DocumentMut, line: LineKey, idx: usize) -> bool {
let Some(arr) = ensure_segments_array_mut(document, line) else {
return false;
};
let Some(value) = arr.get(idx).cloned() else {
return false;
};
match value {
Value::String(s) => {
let mut table = InlineTable::new();
table.insert("type", Value::from(s.value().as_str()));
table.insert("merge", Value::from(true));
arr.replace(idx, Value::InlineTable(table));
true
}
Value::InlineTable(t) => {
let type_field = t.get("type").and_then(|v| v.as_str());
if type_field == Some("separator") {
return false;
}
let Some(_) = type_field else {
return false;
};
let kind = t.get("type").and_then(|v| v.as_str()).map(str::to_string);
let currently_merging = t.get("merge").and_then(|v| v.as_bool()).unwrap_or(false);
if currently_merging {
let mut new_table = t.clone();
new_table.remove("merge");
let only_type_left = new_table.iter().count() == 1
&& new_table.get("type").and_then(|v| v.as_str()).is_some();
if only_type_left {
if let Some(id) = kind {
arr.replace(idx, Value::from(id.as_str()));
return true;
}
}
arr.replace(idx, Value::InlineTable(new_table));
} else {
let mut new_table = t.clone();
new_table.insert("merge", Value::from(true));
arr.replace(idx, Value::InlineTable(new_table));
}
true
}
_ => false,
}
}
fn insert_separator(document: &mut DocumentMut, line: LineKey, target: InsertTarget) -> bool {
let Some(arr) = ensure_segments_array_mut(document, line) else {
return false;
};
let idx = match target {
InsertTarget::Before(i) => i.min(arr.len()),
InsertTarget::After(i) => i.saturating_add(1).min(arr.len()),
};
let mut table = InlineTable::new();
table.insert("type", Value::from("separator"));
arr.insert(idx, Value::InlineTable(table));
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EntryKind {
Segment,
Separator,
Malformed,
}
fn classify_entry(document: &DocumentMut, line: LineKey, idx: usize) -> EntryKind {
let Some(arr) = segments_array(document, line) else {
return if matches!(line, LineKey::Single) {
EntryKind::Segment
} else {
EntryKind::Malformed
};
};
let Some(v) = arr.get(idx) else {
return EntryKind::Malformed;
};
if v.as_str().is_some() {
return EntryKind::Segment;
}
if let Some(t) = v.as_inline_table() {
return match t.get("type").and_then(|tv| tv.as_str()) {
Some("separator") => EntryKind::Separator,
Some(_) => EntryKind::Segment,
None => EntryKind::Malformed,
};
}
EntryKind::Malformed
}
pub(super) fn apply_insert(
mut prev: ItemsEditorState,
document: &mut DocumentMut,
config: &mut config::Config,
target: InsertTarget,
segment_id: &str,
) -> ScreenOutcome {
let line = prev.line;
if !insert_segment(document, line, target, segment_id) {
linesmith_core::lsm_warn!(
"items editor: insert failed for segment {segment_id:?} (line={line:?}); editor unchanged",
);
return ScreenOutcome::NavigateTo(AppScreen::ItemsEditor(prev));
}
refresh_config(document, config);
let inserted_at = match target {
InsertTarget::Before(idx) => idx,
InsertTarget::After(idx) => idx + 1,
};
let new_count = segment_count(document, line);
prev.list.set_cursor(inserted_at, new_count);
ScreenOutcome::CommitAndNavigate(AppScreen::ItemsEditor(prev))
}
pub(super) fn view(
state: &ItemsEditorState,
document: &DocumentMut,
frame: &mut Frame,
area: Rect,
) {
let row_data = display_rows(document, state.line);
let view = ListScreenView {
title: " edit lines ",
rows: &row_data,
verbs: VERBS,
move_mode_supported: true,
};
list_screen::render(&state.list, &view, area, frame);
}
fn display_rows(document: &DocumentMut, line: LineKey) -> Vec<ListRowData<'static>> {
if let Some(arr) = segments_array(document, line) {
return arr.iter().map(value_to_row).collect();
}
if matches!(line, LineKey::Single) {
return linesmith_core::segments::DEFAULT_SEGMENT_IDS
.iter()
.map(|s| ListRowData {
label: Cow::Owned((*s).to_string()),
description: Cow::Borrowed(""),
})
.collect();
}
Vec::new()
}
fn value_to_row(value: &Value) -> ListRowData<'static> {
if let Some(s) = value.as_str() {
return ListRowData {
label: Cow::Owned(s.to_string()),
description: Cow::Borrowed(""),
};
}
if let Some(t) = value.as_inline_table() {
let kind = t.get("type").and_then(|v| v.as_str()).unwrap_or("");
if kind == "separator" {
let glyph = t.get("character").and_then(|v| v.as_str());
return ListRowData {
label: Cow::Borrowed("· separator"),
description: Cow::Owned(match glyph {
Some("") => "(empty)".to_string(),
Some(g) => format!("'{g}'"),
None => "(default)".to_string(),
}),
};
}
let label = if kind.is_empty() { "<no type>" } else { kind };
let merge = t.get("merge").and_then(|v| v.as_bool()).unwrap_or(false);
return ListRowData {
label: Cow::Owned(label.to_string()),
description: Cow::Borrowed(if merge { "merge →" } else { "" }),
};
}
ListRowData {
label: Cow::Borrowed("<non-string>"),
description: Cow::Borrowed(""),
}
}
fn segment_count(document: &DocumentMut, line: LineKey) -> usize {
if let Some(arr) = segments_array(document, line) {
return arr.len();
}
if matches!(line, LineKey::Single) {
linesmith_core::segments::DEFAULT_SEGMENT_IDS.len()
} else {
0
}
}
pub(super) fn numbered_line_key(document: &DocumentMut, n: std::num::NonZeroU32) -> Option<String> {
let table = document.get("line")?.as_table()?;
table
.iter()
.find(|(k, v)| v.is_table() && k.parse::<u32>().ok() == Some(n.get()))
.map(|(k, _)| k.to_string())
}
fn segments_array(document: &DocumentMut, line: LineKey) -> Option<&toml_edit::Array> {
match line {
LineKey::Single => document.get("line")?.get("segments")?.as_array(),
LineKey::Numbered(n) => {
let key = numbered_line_key(document, n)?;
document.get("line")?.get(&key)?.get("segments")?.as_array()
}
}
}
fn segments_array_mut(document: &mut DocumentMut, line: LineKey) -> Option<&mut toml_edit::Array> {
match line {
LineKey::Single => document
.get_mut("line")?
.get_mut("segments")?
.as_array_mut(),
LineKey::Numbered(n) => {
let key = numbered_line_key(document, n)?;
document
.get_mut("line")?
.get_mut(&key)?
.get_mut("segments")?
.as_array_mut()
}
}
}
#[cfg(test)]
fn segment_labels(document: &DocumentMut, line: LineKey) -> Vec<String> {
if let Some(arr) = segments_array(document, line) {
return arr
.iter()
.map(|v| {
v.as_str()
.map_or_else(|| "<non-string>".to_string(), str::to_string)
})
.collect();
}
if matches!(line, LineKey::Single) {
linesmith_core::segments::DEFAULT_SEGMENT_IDS
.iter()
.map(|s| (*s).to_string())
.collect()
} else {
Vec::new()
}
}
fn ensure_segments_array_mut(
document: &mut DocumentMut,
line: LineKey,
) -> Option<&mut toml_edit::Array> {
if segments_array(document, line).is_none() {
if !matches!(line, LineKey::Single) {
return None;
}
materialize_default_single_line_segments(document);
}
segments_array_mut(document, line)
}
fn swap_segments(document: &mut DocumentMut, line: LineKey, from: usize, to: usize) -> bool {
let Some(arr) = ensure_segments_array_mut(document, line) else {
return false;
};
if from >= arr.len() || to >= arr.len() {
return false;
}
let item = arr.remove(from);
arr.insert(to, item);
true
}
fn delete_segment_at(document: &mut DocumentMut, line: LineKey, idx: usize) -> bool {
let Some(arr) = ensure_segments_array_mut(document, line) else {
return false;
};
if idx >= arr.len() {
return false;
}
arr.remove(idx);
true
}
fn clear_segments(document: &mut DocumentMut, line: LineKey) -> bool {
let Some(arr) = ensure_segments_array_mut(document, line) else {
return false;
};
arr.clear();
true
}
fn insert_segment(
document: &mut DocumentMut,
line: LineKey,
target: InsertTarget,
segment_id: &str,
) -> bool {
let Some(arr) = ensure_segments_array_mut(document, line) else {
return false;
};
let idx = match target {
InsertTarget::Before(i) => i.min(arr.len()),
InsertTarget::After(i) => i.saturating_add(1).min(arr.len()),
};
arr.insert(idx, segment_id);
true
}
fn clone_segment_at(document: &mut DocumentMut, line: LineKey, idx: usize) -> bool {
let Some(arr) = ensure_segments_array_mut(document, line) else {
return false;
};
let Some(value) = arr.get(idx).cloned() else {
return false;
};
arr.insert(idx + 1, value);
true
}
fn materialize_default_single_line_segments(document: &mut DocumentMut) {
use toml_edit::{Array, Item, Table, Value};
let mut arr = Array::new();
for id in linesmith_core::segments::DEFAULT_SEGMENT_IDS {
arr.push(*id);
}
let segments = Item::Value(Value::Array(arr));
match document.get_mut("line") {
Some(item) if item.is_table() => {
if let Some(table) = item.as_table_mut() {
table["segments"] = segments;
}
}
_ => {
let mut table = Table::new();
table["segments"] = segments;
document["line"] = Item::Table(table);
}
}
}
fn refresh_config(document: &DocumentMut, config: &mut config::Config) {
match config::Config::from_str_validated(&document.to_string(), |_| {}) {
Ok(new_config) => *config = new_config,
Err(err) => linesmith_core::lsm_warn!(
"items editor: reparse failed, preview frozen at last-good state: {err}",
),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn document(toml: &str) -> DocumentMut {
toml.parse().expect("test toml must parse")
}
fn config_default() -> config::Config {
config::Config::default()
}
fn state() -> ItemsEditorState {
ItemsEditorState::new(
LineKey::Single,
ItemsEditorPrev::MainMenu(MainMenuState::default()),
)
}
#[test]
fn esc_back_navigates_to_main_menu_carrying_prior_state() {
let mut s = state();
let mut doc = document("");
let mut cfg = config_default();
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Esc));
assert!(matches!(
outcome,
ScreenOutcome::NavigateTo(AppScreen::MainMenu(_))
));
}
#[test]
fn esc_with_modifier_does_not_back_navigate() {
let mut s = state();
let mut doc = document(
r#"[line]
segments = ["model", "cwd"]
"#,
);
let mut cfg = config_default();
let chord = KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT);
let outcome = update(&mut s, &mut doc, &mut cfg, chord);
assert!(matches!(outcome, ScreenOutcome::Stay));
}
#[test]
fn move_swap_reorders_segments_and_acks_cursor() {
let mut s = state();
let mut doc = document(
r#"[line]
segments = ["model", "cwd", "git"]
"#,
);
let mut cfg = config_default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Enter));
assert!(s.list.move_mode());
assert_eq!(s.list.cursor(), 0);
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Down));
assert!(
matches!(outcome, ScreenOutcome::Committed),
"move-swap is a commit: {outcome:?}",
);
assert_eq!(s.list.cursor(), 1, "cursor must follow the moved row");
let labels = segment_labels(&doc, LineKey::Single);
assert_eq!(labels, vec!["cwd", "model", "git"]);
}
#[test]
fn move_swap_refreshes_config_to_match_document() {
let mut s = state();
let mut doc = document(
r#"[line]
segments = ["model", "cwd"]
"#,
);
let mut cfg = config_default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Enter));
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Down));
let line = cfg.line.expect("config must reparse with [line]");
let ids: Vec<&str> = line
.segments
.iter()
.filter_map(config::LineEntry::segment_id)
.collect();
assert_eq!(ids, vec!["cwd", "model"]);
}
#[test]
fn move_swap_preserves_comments_and_blanks() {
let raw = "# top comment\n\n[line] # inline\nsegments = [\"model\", \"cwd\"]\n";
let mut s = state();
let mut doc = document(raw);
let mut cfg = config_default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Enter));
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Down));
let written = doc.to_string();
assert!(
written.contains("# top comment"),
"lost top comment: {written:?}"
);
assert!(
written.contains("# inline"),
"lost inline comment: {written:?}"
);
}
#[test]
fn empty_document_falls_back_to_runtime_default_segments() {
let doc = document("");
let expected = linesmith_core::segments::DEFAULT_SEGMENT_IDS;
assert_eq!(segment_count(&doc, LineKey::Single), expected.len());
assert_eq!(
segment_labels(&doc, LineKey::Single),
expected
.iter()
.map(|s| (*s).to_string())
.collect::<Vec<_>>(),
);
}
#[test]
fn missing_segments_key_falls_back_to_runtime_defaults() {
let doc = document("[line]\n");
let expected = linesmith_core::segments::DEFAULT_SEGMENT_IDS;
assert_eq!(segment_count(&doc, LineKey::Single), expected.len());
}
#[test]
fn explicitly_empty_segments_array_renders_zero_rows() {
let doc = document("[line]\nsegments = []\n");
assert_eq!(segment_count(&doc, LineKey::Single), 0);
assert!(segment_labels(&doc, LineKey::Single).is_empty());
}
#[test]
fn first_swap_against_silent_document_materializes_defaults() {
let mut s = state();
let mut doc = document("");
let mut cfg = config_default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Enter));
assert!(s.list.move_mode());
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Down));
let arr = segments_array(&doc, LineKey::Single).expect("array materialized");
let defaults = linesmith_core::segments::DEFAULT_SEGMENT_IDS;
assert_eq!(arr.len(), defaults.len());
assert_eq!(arr.get(0).and_then(|v| v.as_str()), Some(defaults[1]));
assert_eq!(arr.get(1).and_then(|v| v.as_str()), Some(defaults[0]));
for (i, expected) in defaults.iter().enumerate().skip(2) {
assert_eq!(arr.get(i).and_then(|v| v.as_str()), Some(*expected));
}
}
#[test]
fn swap_on_numbered_line_with_missing_array_does_not_materialize() {
let mut doc = document(
r#"layout = "multi-line"
[line]
"#,
);
let one = NonZeroU32::new(1).expect("nonzero");
let before = doc.to_string();
assert!(!swap_segments(&mut doc, LineKey::Numbered(one), 0, 1));
assert_eq!(doc.to_string(), before);
}
#[test]
fn segment_labels_marks_non_string_entries_with_placeholder() {
let doc = document(
r#"[line]
segments = ["a", 42, "b"]
"#,
);
assert_eq!(
segment_labels(&doc, LineKey::Single),
vec!["a", "<non-string>", "b"],
);
assert_eq!(
segment_count(&doc, LineKey::Single),
3,
"row count must match label count to keep cursor aligned",
);
}
#[test]
fn move_swap_with_non_string_entry_reorders_correct_array_position() {
let mut s = state();
let mut doc = document(
r#"[line]
segments = ["a", 42, "b"]
"#,
);
let mut cfg = config_default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Down));
assert_eq!(s.list.cursor(), 1);
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Enter));
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Down));
let arr = segments_array(&doc, LineKey::Single).expect("array");
assert_eq!(arr.len(), 3);
assert_eq!(arr.get(0).and_then(|v| v.as_str()), Some("a"));
assert_eq!(arr.get(1).and_then(|v| v.as_str()), Some("b"));
assert!(
arr.get(2).is_some_and(|v| v.as_str().is_none()),
"non-string moved to index 2"
);
}
#[test]
fn segments_array_resolves_numbered_line_path() {
let doc = document(
r#"layout = "multi-line"
[line]
[line.1]
segments = ["model"]
[line.2]
segments = ["cwd", "git"]
"#,
);
let one = NonZeroU32::new(1).expect("nonzero");
let two = NonZeroU32::new(2).expect("nonzero");
assert_eq!(segment_count(&doc, LineKey::Numbered(one)), 1);
assert_eq!(segment_count(&doc, LineKey::Numbered(two)), 2);
assert_eq!(
segment_labels(&doc, LineKey::Numbered(two)),
vec!["cwd", "git"]
);
}
#[test]
fn verb_helpers_on_numbered_with_present_array_mutate_only_targeted_line() {
let initial = r#"layout = "multi-line"
[line]
[line.1]
segments = ["a", "b", "c"]
[line.2]
segments = ["x", "y", "z"]
"#;
let one = NonZeroU32::new(1).expect("nonzero");
let two = NonZeroU32::new(2).expect("nonzero");
let mut doc = document(initial);
assert!(delete_segment_at(&mut doc, LineKey::Numbered(one), 1));
assert_eq!(segment_labels(&doc, LineKey::Numbered(one)), vec!["a", "c"]);
assert_eq!(
segment_labels(&doc, LineKey::Numbered(two)),
vec!["x", "y", "z"]
);
let mut doc = document(initial);
assert!(clear_segments(&mut doc, LineKey::Numbered(two)));
assert_eq!(segment_count(&doc, LineKey::Numbered(two)), 0);
assert_eq!(
segment_labels(&doc, LineKey::Numbered(one)),
vec!["a", "b", "c"]
);
let mut doc = document(initial);
assert!(clone_segment_at(&mut doc, LineKey::Numbered(one), 0));
assert_eq!(
segment_labels(&doc, LineKey::Numbered(one)),
vec!["a", "a", "b", "c"]
);
assert_eq!(
segment_labels(&doc, LineKey::Numbered(two)),
vec!["x", "y", "z"]
);
}
#[test]
fn swap_segments_returns_false_for_numbered_with_missing_array() {
let mut doc = document("");
let one = NonZeroU32::new(1).expect("nonzero");
assert!(!swap_segments(&mut doc, LineKey::Numbered(one), 0, 1));
}
#[test]
fn swap_segments_numbered_path_isolates_to_targeted_line() {
let mut doc = document(
r#"layout = "multi-line"
[line]
[line.1]
segments = ["a", "b"]
[line.2]
segments = ["x", "y"]
"#,
);
let one = NonZeroU32::new(1).expect("nonzero");
let two = NonZeroU32::new(2).expect("nonzero");
assert!(swap_segments(&mut doc, LineKey::Numbered(one), 0, 1));
assert_eq!(segment_labels(&doc, LineKey::Numbered(one)), vec!["b", "a"]);
assert_eq!(
segment_labels(&doc, LineKey::Numbered(two)),
vec!["x", "y"],
"swap on line 1 must not affect line 2"
);
}
#[test]
fn swap_segments_returns_false_for_out_of_range_index() {
let mut doc = document(
r#"[line]
segments = ["a", "b"]
"#,
);
assert!(!swap_segments(&mut doc, LineKey::Single, 0, 5));
assert!(!swap_segments(&mut doc, LineKey::Single, 5, 0));
}
#[test]
fn move_mode_up_at_top_does_not_mutate_document() {
let raw = "[line]\nsegments = [\"a\", \"b\"]\n";
let mut s = state();
let mut doc = document(raw);
let mut cfg = config_default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Enter));
assert!(s.list.move_mode());
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Up));
assert_eq!(doc.to_string(), raw);
assert_eq!(s.list.cursor(), 0);
}
#[test]
fn move_swap_with_only_one_segment_is_noop() {
let raw = "[line]\nsegments = [\"only\"]\n";
let mut s = state();
let mut doc = document(raw);
let mut cfg = config_default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Enter));
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Down));
assert_eq!(doc.to_string(), raw);
}
#[test]
fn delete_verb_removes_cursor_segment_and_keeps_cursor_in_range() {
let mut s = state();
let mut doc = document(
r#"[line]
segments = ["a", "b", "c"]
"#,
);
let mut cfg = config_default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Down));
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char('d')));
assert!(
matches!(outcome, ScreenOutcome::Committed),
"delete verb is a commit; dispatcher must auto-save: {outcome:?}",
);
assert_eq!(segment_labels(&doc, LineKey::Single), vec!["a", "c"]);
assert_eq!(
s.list.cursor(),
1,
"cursor stays at 1, now pointing at \"c\""
);
let line = cfg.line.expect("line config reparsed");
let ids: Vec<&str> = line
.segments
.iter()
.filter_map(config::LineEntry::segment_id)
.collect();
assert_eq!(ids, vec!["a", "c"]);
}
#[test]
fn delete_verb_at_last_row_clamps_cursor_back_one() {
let mut s = state();
let mut doc = document(
r#"[line]
segments = ["a", "b", "c"]
"#,
);
let mut cfg = config_default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Down));
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Down));
assert_eq!(s.list.cursor(), 2);
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char('d')));
assert_eq!(segment_labels(&doc, LineKey::Single), vec!["a", "b"]);
assert_eq!(s.list.cursor(), 1);
}
#[test]
fn delete_verb_against_silent_document_materializes_then_deletes() {
let mut s = state();
let mut doc = document("");
let mut cfg = config_default();
let defaults = linesmith_core::segments::DEFAULT_SEGMENT_IDS;
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char('d')));
let labels = segment_labels(&doc, LineKey::Single);
assert_eq!(labels.len(), defaults.len() - 1);
assert_eq!(labels[0], defaults[1]);
}
#[test]
fn clear_verb_empties_segments_to_explicit_empty_array() {
let mut s = state();
let mut doc = document(
r#"[line]
segments = ["a", "b", "c"]
"#,
);
let mut cfg = config_default();
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char('c')));
assert!(
matches!(outcome, ScreenOutcome::Committed),
"clear verb is a commit: {outcome:?}",
);
assert_eq!(segment_count(&doc, LineKey::Single), 0);
assert_eq!(s.list.cursor(), 0);
let arr = segments_array(&doc, LineKey::Single).expect("explicit empty array");
assert_eq!(arr.len(), 0);
assert!(cfg.line.expect("line reparsed").segments.is_empty());
}
#[test]
fn clear_verb_on_already_empty_array_is_idempotent() {
let mut s = state();
let mut doc = document("[line]\nsegments = []\n");
let mut cfg = config_default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char('c')));
assert_eq!(segment_count(&doc, LineKey::Single), 0);
let arr = segments_array(&doc, LineKey::Single).expect("explicit empty preserved");
assert_eq!(arr.len(), 0);
}
#[test]
fn clone_verb_inserts_copy_after_cursor_and_advances_cursor() {
let mut s = state();
let mut doc = document(
r#"[line]
segments = ["a", "b", "c"]
"#,
);
let mut cfg = config_default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Down));
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char('k')));
assert!(
matches!(outcome, ScreenOutcome::Committed),
"clone verb is a commit: {outcome:?}",
);
assert_eq!(
segment_labels(&doc, LineKey::Single),
vec!["a", "b", "b", "c"],
);
assert_eq!(s.list.cursor(), 2, "cursor lands on the fresh clone");
let segments = cfg.line.expect("line reparsed").segments;
assert_eq!(segments.len(), 4);
}
#[test]
fn clone_verb_at_last_index_inserts_at_end_and_advances_cursor() {
let mut s = state();
let mut doc = document(
r#"[line]
segments = ["a", "b"]
"#,
);
let mut cfg = config_default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Down));
assert_eq!(s.list.cursor(), 1);
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char('k')));
assert_eq!(segment_labels(&doc, LineKey::Single), vec!["a", "b", "b"]);
assert_eq!(s.list.cursor(), 2);
}
#[test]
fn clone_verb_on_non_string_entry_clones_through_placeholder() {
let mut s = state();
let mut doc = document(
r#"[line]
segments = ["a", 42, "b"]
"#,
);
let mut cfg = config_default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Down));
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char('k')));
let arr = segments_array(&doc, LineKey::Single).expect("array");
assert_eq!(arr.len(), 4);
assert_eq!(arr.get(1).and_then(|v| v.as_str()), None);
assert_eq!(arr.get(2).and_then(|v| v.as_str()), None);
}
#[test]
fn add_verb_navigates_to_type_picker_with_after_target() {
let mut s = state();
let mut doc = document(
r#"[line]
segments = ["a", "b"]
"#,
);
let mut cfg = config_default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Down));
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char('a')));
assert!(matches!(
outcome,
ScreenOutcome::NavigateTo(AppScreen::TypePicker(_))
));
}
#[test]
fn insert_verb_navigates_to_type_picker_with_before_target() {
let mut s = state();
let mut doc = document(
r#"[line]
segments = ["a", "b"]
"#,
);
let mut cfg = config_default();
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char('i')));
assert!(matches!(
outcome,
ScreenOutcome::NavigateTo(AppScreen::TypePicker(_))
));
}
#[test]
fn right_arrow_opens_picker_in_normal_mode() {
let mut s = state();
let mut doc = document(
r#"[line]
segments = ["a"]
"#,
);
let mut cfg = config_default();
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Right));
assert!(matches!(
outcome,
ScreenOutcome::NavigateTo(AppScreen::TypePicker(_))
));
}
#[test]
fn left_arrow_opens_picker_in_normal_mode() {
let mut s = state();
let mut doc = document(
r#"[line]
segments = ["a"]
"#,
);
let mut cfg = config_default();
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Left));
assert!(matches!(
outcome,
ScreenOutcome::NavigateTo(AppScreen::TypePicker(_))
));
}
#[test]
fn raw_verb_opens_editor_seeded_with_cursor_segment_label() {
let mut s = state();
let mut doc = document(
r#"[line]
segments = ["alpha", "beta"]
"#,
);
let mut cfg = config_default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Down));
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char('r')));
assert!(matches!(
outcome,
ScreenOutcome::NavigateTo(AppScreen::RawValueEditor(_))
));
}
#[test]
fn raw_verb_inert_on_empty_array() {
let mut s = state();
let mut doc = document("[line]\nsegments = []\n");
let mut cfg = config_default();
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char('r')));
assert!(matches!(outcome, ScreenOutcome::Stay));
}
#[test]
fn apply_replace_emits_warning_on_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 prev = ItemsEditorState::new(
LineKey::Numbered(NonZeroU32::new(1).expect("nonzero")),
ItemsEditorPrev::MainMenu(MainMenuState::default()),
);
let mut doc = document(
r#"layout = "multi-line"
[line]
"#,
);
let mut cfg = config_default();
let _ = apply_replace(prev, &mut doc, &mut cfg, 0, RawTarget::SegmentId, "model");
let entries = captured.drain();
assert!(
entries
.iter()
.any(|e| e.starts_with("[warn]") && e.contains("replace failed")),
"expected replace-failed warn in {entries:?}",
);
}
#[test]
fn apply_replace_accepts_empty_string_at_target() {
let mut doc = document(
r#"[line]
segments = ["alpha", "beta"]
"#,
);
let mut cfg = config_default();
let outcome = apply_replace(
ItemsEditorState::default(),
&mut doc,
&mut cfg,
0,
RawTarget::SegmentId,
"",
);
assert!(matches!(
outcome,
ScreenOutcome::CommitAndNavigate(AppScreen::ItemsEditor(_))
));
let labels = segment_labels(&doc, LineKey::Single);
assert_eq!(labels, vec!["", "beta"]);
}
#[test]
fn apply_replace_swaps_value_at_index_and_advances_cursor() {
let mut doc = document(
r#"[line]
segments = ["alpha", "beta", "gamma"]
"#,
);
let mut cfg = config_default();
let outcome = apply_replace(
ItemsEditorState::default(),
&mut doc,
&mut cfg,
1,
RawTarget::SegmentId,
"BETA-renamed",
);
let restored = match outcome {
ScreenOutcome::CommitAndNavigate(AppScreen::ItemsEditor(s)) => s,
other => panic!("expected NavigateTo(ItemsEditor), got {other:?}"),
};
let labels = segment_labels(&doc, LineKey::Single);
assert_eq!(labels, vec!["alpha", "BETA-renamed", "gamma"]);
assert_eq!(restored.cursor(), 1);
}
#[test]
fn add_verb_works_on_empty_segment_array() {
let mut s = state();
let mut doc = document("[line]\nsegments = []\n");
let mut cfg = config_default();
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char('a')));
assert!(matches!(
outcome,
ScreenOutcome::NavigateTo(AppScreen::TypePicker(_))
));
}
#[test]
fn insert_verb_works_on_empty_segment_array() {
let mut s = state();
let mut doc = document("[line]\nsegments = []\n");
let mut cfg = config_default();
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char('i')));
assert!(matches!(
outcome,
ScreenOutcome::NavigateTo(AppScreen::TypePicker(_))
));
}
#[test]
fn picker_keybindings_in_move_mode_are_inert() {
let raw = "[line]\nsegments = [\"a\", \"b\"]\n";
let mut s = state();
let mut doc = document(raw);
let mut cfg = config_default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Enter));
assert!(s.list.move_mode());
for code in [
KeyCode::Left,
KeyCode::Right,
KeyCode::Char('a'),
KeyCode::Char('i'),
] {
let outcome = update(&mut s, &mut doc, &mut cfg, key(code));
assert!(
matches!(outcome, ScreenOutcome::Stay),
"{code:?} in move-mode should be Stay, got {outcome:?}",
);
}
assert_eq!(doc.to_string(), raw);
}
#[test]
fn apply_insert_at_last_index_appends_and_advances_cursor() {
let mut doc = document(
r#"[line]
segments = ["a", "b"]
"#,
);
let mut cfg = config_default();
let outcome = apply_insert(
ItemsEditorState::default(),
&mut doc,
&mut cfg,
InsertTarget::After(1),
"model",
);
let restored = match outcome {
ScreenOutcome::CommitAndNavigate(AppScreen::ItemsEditor(s)) => s,
other => panic!("expected NavigateTo(ItemsEditor), got {other:?}"),
};
assert_eq!(
segment_labels(&doc, LineKey::Single),
vec!["a", "b", "model"],
);
assert_eq!(restored.cursor(), 2);
}
#[test]
fn apply_insert_into_empty_explicit_array_lands_at_index_zero() {
for target in [InsertTarget::Before(0), InsertTarget::After(0)] {
let mut doc = document("[line]\nsegments = []\n");
let mut cfg = config_default();
let outcome = apply_insert(
ItemsEditorState::default(),
&mut doc,
&mut cfg,
target,
"model",
);
let restored = match outcome {
ScreenOutcome::CommitAndNavigate(AppScreen::ItemsEditor(s)) => s,
other => panic!("expected NavigateTo(ItemsEditor), got {other:?}"),
};
assert_eq!(
segment_labels(&doc, LineKey::Single),
vec!["model"],
"target={target:?}",
);
assert_eq!(restored.cursor(), 0, "target={target:?}");
}
}
#[test]
fn apply_insert_emits_warning_on_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 prev = ItemsEditorState::new(
LineKey::Numbered(NonZeroU32::new(1).expect("nonzero")),
ItemsEditorPrev::MainMenu(MainMenuState::default()),
);
let mut doc = document(
r#"layout = "multi-line"
[line]
"#,
);
let mut cfg = config_default();
let _ = apply_insert(prev, &mut doc, &mut cfg, InsertTarget::After(0), "model");
let entries = captured.drain();
assert!(
entries
.iter()
.any(|e| e.starts_with("[warn]") && e.contains("insert failed")),
"expected insert-failed warn in {entries:?}",
);
}
#[test]
fn apply_insert_lands_segment_at_target_and_advances_cursor() {
let mut doc = document(
r#"[line]
segments = ["a", "b"]
"#,
);
let mut cfg = config_default();
let outcome = apply_insert(
ItemsEditorState::default(),
&mut doc,
&mut cfg,
InsertTarget::After(0),
"model",
);
let restored = match outcome {
ScreenOutcome::CommitAndNavigate(AppScreen::ItemsEditor(s)) => s,
other => panic!("expected NavigateTo(ItemsEditor), got {other:?}"),
};
assert_eq!(
segment_labels(&doc, LineKey::Single),
vec!["a", "model", "b"]
);
assert_eq!(restored.cursor(), 1);
}
#[test]
fn verb_letters_in_move_mode_are_inert() {
let raw = "[line]\nsegments = [\"a\", \"b\"]\n";
let mut s = state();
let mut doc = document(raw);
let mut cfg = config_default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Enter));
assert!(s.list.move_mode());
for verb in ['d', 'c', 'k'] {
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char(verb)));
}
assert_eq!(doc.to_string(), raw);
}
#[test]
fn space_inserts_separator_after_cursor_segment() {
let mut s = state();
let mut doc = document(
r#"[line]
segments = ["model", "git_branch"]
"#,
);
let mut cfg = config_default();
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char(' ')));
assert!(
matches!(outcome, ScreenOutcome::Committed),
"Space-insert is a commit; dispatcher must auto-save: {outcome:?}",
);
assert_eq!(
s.list.cursor(),
1,
"cursor advances onto inserted separator"
);
let arr = segments_array(&doc, LineKey::Single).expect("array");
assert_eq!(arr.len(), 3);
assert_eq!(arr.get(0).and_then(|v| v.as_str()), Some("model"));
let sep = arr
.get(1)
.and_then(|v| v.as_inline_table())
.expect("inline table");
assert_eq!(sep.get("type").and_then(|v| v.as_str()), Some("separator"));
assert!(
sep.get("character").is_none(),
"character omitted; uses global default",
);
assert_eq!(arr.get(2).and_then(|v| v.as_str()), Some("git_branch"));
}
#[test]
fn space_on_empty_array_inserts_at_zero() {
let mut s = state();
let mut doc = document("[line]\nsegments = []\n");
let mut cfg = config_default();
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char(' ')));
assert!(
matches!(outcome, ScreenOutcome::Committed),
"Space-insert on empty array is a commit: {outcome:?}",
);
let arr = segments_array(&doc, LineKey::Single).expect("array");
assert_eq!(arr.len(), 1);
let sep = arr.get(0).and_then(|v| v.as_inline_table()).expect("table");
assert_eq!(sep.get("type").and_then(|v| v.as_str()), Some("separator"));
assert_eq!(s.list.cursor(), 0);
}
#[test]
fn space_in_move_mode_is_inert() {
let raw = "[line]\nsegments = [\"a\", \"b\"]\n";
let mut s = state();
let mut doc = document(raw);
let mut cfg = config_default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Enter));
assert!(s.list.move_mode());
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char(' ')));
assert!(matches!(outcome, ScreenOutcome::Stay));
assert_eq!(doc.to_string(), raw, "Space in move-mode must not mutate");
}
#[test]
fn merge_verb_promotes_string_segment_to_inline_table_with_merge_flag() {
let mut s = state();
let mut doc = document(
r#"[line]
segments = ["model", "git_branch"]
"#,
);
let mut cfg = config_default();
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char('m')));
assert!(
matches!(outcome, ScreenOutcome::Committed),
"merge toggle is a commit: {outcome:?}",
);
let arr = segments_array(&doc, LineKey::Single).expect("array");
let table = arr
.get(0)
.and_then(|v| v.as_inline_table())
.expect("inline table after merge");
assert_eq!(table.get("type").and_then(|v| v.as_str()), Some("model"));
assert_eq!(table.get("merge").and_then(|v| v.as_bool()), Some(true));
assert_eq!(s.list.cursor(), 0);
}
#[test]
fn merge_verb_toggles_off_and_demotes_to_bare_string() {
let mut s = state();
let mut doc = document(
r#"[line]
segments = ["model"]
"#,
);
let mut cfg = config_default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char('m')));
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char('m')));
let arr = segments_array(&doc, LineKey::Single).expect("array");
assert_eq!(
arr.get(0).and_then(|v| v.as_str()),
Some("model"),
"second `m` demotes back to bare string",
);
}
#[test]
fn merge_verb_inert_on_separator_entry() {
let raw = "[line]\nsegments = [{ type = \"separator\" }]\n";
let mut s = state();
let mut doc = document(raw);
let mut cfg = config_default();
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char('m')));
assert!(
matches!(outcome, ScreenOutcome::Stay),
"inert merge must NOT signal Committed (no document mutation): {outcome:?}",
);
assert_eq!(doc.to_string(), raw, "m on a separator must not mutate");
}
#[test]
fn classify_entry_distinguishes_separator_inline_table_from_segment() {
let doc = document(
r#"[line]
segments = ["model", { type = "separator", character = " | " }, { type = "git_branch", merge = true }]
"#,
);
assert_eq!(classify_entry(&doc, LineKey::Single, 0), EntryKind::Segment);
assert_eq!(
classify_entry(&doc, LineKey::Single, 1),
EntryKind::Separator
);
assert_eq!(classify_entry(&doc, LineKey::Single, 2), EntryKind::Segment);
assert_eq!(
classify_entry(&doc, LineKey::Single, 99),
EntryKind::Malformed
);
}
#[test]
fn raw_verb_on_separator_seeds_with_character_field() {
let mut s = state();
let mut doc = document(
r#"[line]
segments = ["a", { type = "separator", character = " | " }]
"#,
);
let mut cfg = config_default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Down));
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char('r')));
assert!(matches!(
outcome,
ScreenOutcome::NavigateTo(AppScreen::RawValueEditor(_))
));
}
#[test]
fn apply_replace_with_separator_target_updates_character_field() {
let mut doc = document(
r#"[line]
segments = [{ type = "separator", character = " | " }]
"#,
);
let mut cfg = config_default();
let outcome = apply_replace(
ItemsEditorState::default(),
&mut doc,
&mut cfg,
0,
RawTarget::SeparatorCharacter,
" > ",
);
assert!(matches!(
outcome,
ScreenOutcome::CommitAndNavigate(AppScreen::ItemsEditor(_))
));
let arr = segments_array(&doc, LineKey::Single).expect("array");
let sep = arr.get(0).and_then(|v| v.as_inline_table()).expect("table");
assert_eq!(sep.get("type").and_then(|v| v.as_str()), Some("separator"));
assert_eq!(sep.get("character").and_then(|v| v.as_str()), Some(" > "));
}
#[test]
fn apply_replace_with_separator_target_and_empty_value_sets_explicit_empty() {
let mut doc = document(
r#"[line]
segments = [{ type = "separator", character = " | " }]
"#,
);
let mut cfg = config_default();
apply_replace(
ItemsEditorState::default(),
&mut doc,
&mut cfg,
0,
RawTarget::SeparatorCharacter,
"",
);
let arr = segments_array(&doc, LineKey::Single).expect("array");
let sep = arr.get(0).and_then(|v| v.as_inline_table()).expect("table");
assert_eq!(
sep.get("character").and_then(|v| v.as_str()),
Some(""),
"empty buffer must commit explicit empty, not remove the field",
);
}
#[test]
fn apply_replace_with_separator_target_preserves_default_state_via_space() {
let mut s = state();
let mut doc = document(
r#"[line]
segments = ["model", "workspace"]
"#,
);
let mut cfg = config_default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char(' ')));
let arr = segments_array(&doc, LineKey::Single).expect("array");
let sep = arr.get(1).and_then(|v| v.as_inline_table()).expect("table");
assert_eq!(sep.get("type").and_then(|v| v.as_str()), Some("separator"));
assert!(
sep.get("character").is_none(),
"Space must produce a defaulted separator (no `character` field)",
);
}
#[test]
fn numbered_line_resolves_zero_padded_keys_to_same_index() {
let mut doc = document(
r#"layout = "multi-line"
[line]
[line.01]
segments = ["model", "workspace"]
"#,
);
let one = NonZeroU32::new(1).expect("nonzero");
let arr = segments_array(&doc, LineKey::Numbered(one)).expect("array");
assert_eq!(arr.len(), 2);
assert!(delete_segment_at(&mut doc, LineKey::Numbered(one), 1));
assert_eq!(segment_labels(&doc, LineKey::Numbered(one)), vec!["model"]);
let serialized = doc.to_string();
assert!(
serialized.contains("[line.01]"),
"original zero-padded key must survive the edit: {serialized}",
);
}
#[test]
fn raw_replace_preserves_inline_table_fields_on_segment_rename() {
let mut doc = document(
r#"[line]
segments = [{ type = "git_branch", merge = true }, "cost"]
"#,
);
let mut cfg = config_default();
let outcome = apply_replace(
ItemsEditorState::default(),
&mut doc,
&mut cfg,
0,
RawTarget::SegmentId,
"model",
);
assert!(matches!(
outcome,
ScreenOutcome::CommitAndNavigate(AppScreen::ItemsEditor(_))
));
let arr = segments_array(&doc, LineKey::Single).expect("array");
let table = arr
.get(0)
.and_then(|v| v.as_inline_table())
.expect("entry must remain an inline table");
assert_eq!(
table.get("type").and_then(|v| v.as_str()),
Some("model"),
"type field must reflect the rename",
);
assert_eq!(
table.get("merge").and_then(|v| v.as_bool()),
Some(true),
"merge flag must survive the rename",
);
}
#[test]
fn toml_round_trip_preserves_inline_table_separator_entry() {
let raw = "[line]\nsegments = [\"model\", { type = \"separator\", character = \" | \" }, \"workspace\"]\n";
let doc: DocumentMut = raw.parse().expect("parse");
let serialized = doc.to_string();
assert!(
serialized.contains("{ type = \"separator\", character = \" | \" }"),
"inline-table separator must round-trip without reformatting: {serialized:?}",
);
let cfg: config::Config = serialized.parse().expect("reparse");
let line = cfg.line.as_ref().expect("line present");
match &line.segments[1] {
config::LineEntry::Item(item) => {
assert_eq!(item.kind.as_deref(), Some("separator"));
assert_eq!(item.character.as_deref(), Some(" | "));
}
other => panic!("expected LineEntry::Item, got {other:?}"),
}
}
fn assert_arm_warn_fires(initial_doc: &str, key_code: KeyCode, expected: &str) {
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 s = ItemsEditorState::new(
LineKey::Numbered(NonZeroU32::new(1).expect("nonzero")),
ItemsEditorPrev::MainMenu(MainMenuState::default()),
);
let mut doc = document(initial_doc);
let mut cfg = config_default();
update(&mut s, &mut doc, &mut cfg, key(key_code));
let entries = captured.drain();
assert!(
entries
.iter()
.any(|e| e.starts_with("[warn]") && e.contains(expected)),
"expected warn containing {expected:?} in {entries:?}",
);
}
#[test]
fn merge_verb_warns_when_inert_on_separator_entry() {
let raw =
"layout = \"multi-line\"\n[line]\n\n[line.1]\nsegments = [{ type = \"separator\" }]\n";
assert_arm_warn_fires(raw, KeyCode::Char('m'), "merge toggle inert");
}
#[test]
fn merge_verb_inert_on_kindless_inline_table_entry() {
let raw = "[line]\nsegments = [\"model\", { character = \" | \" }]\n";
let mut s = state();
let mut doc = document(raw);
let mut cfg = config_default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Down));
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char('m')));
let arr = segments_array(&doc, LineKey::Single).expect("array");
let table = arr
.get(1)
.and_then(|v| v.as_inline_table())
.expect("kindless table preserved");
assert!(
table.get("merge").is_none(),
"m on a kindless entry must not add `merge`",
);
}
#[test]
fn replace_segment_refuses_to_rename_separator_entry_under_kind_drift() {
let mut doc = document(
r#"[line]
segments = [{ type = "separator", character = " | " }]
"#,
);
assert!(
!replace_segment(&mut doc, LineKey::Single, 0, "model"),
"replace_segment must refuse the rename when the entry is now a separator",
);
let arr = segments_array(&doc, LineKey::Single).expect("array");
let table = arr
.get(0)
.and_then(|v| v.as_inline_table())
.expect("entry preserved");
assert_eq!(
table.get("type").and_then(|v| v.as_str()),
Some("separator"),
"type field unchanged after refused rename",
);
assert_eq!(
table.get("character").and_then(|v| v.as_str()),
Some(" | "),
"character field preserved",
);
}
#[test]
fn replace_segment_preserves_multiple_inline_table_fields_on_rename() {
let mut doc = document(
r#"[line]
segments = [{ type = "git_branch", merge = true, color = "red", custom = 42 }, "cost"]
"#,
);
assert!(replace_segment(&mut doc, LineKey::Single, 0, "model"));
let arr = segments_array(&doc, LineKey::Single).expect("array");
let table = arr
.get(0)
.and_then(|v| v.as_inline_table())
.expect("entry preserved as inline table");
assert_eq!(table.get("type").and_then(|v| v.as_str()), Some("model"));
assert_eq!(table.get("merge").and_then(|v| v.as_bool()), Some(true));
assert_eq!(table.get("color").and_then(|v| v.as_str()), Some("red"));
assert_eq!(table.get("custom").and_then(|v| v.as_integer()), Some(42));
}
fn render_to_string(
state: &ItemsEditorState,
doc: &DocumentMut,
width: u16,
height: u16,
) -> String {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("backend");
terminal
.draw(|frame| view(state, doc, frame, frame.area()))
.expect("draw");
crate::tui::buffer_to_string(terminal.backend().buffer())
}
#[test]
fn snapshot_items_editor_empty_segments() {
let s = state();
let doc = document("[line]\nsegments = []\n");
insta::assert_snapshot!("items_editor_empty", render_to_string(&s, &doc, 60, 14));
}
#[test]
fn snapshot_items_editor_default_fallback_when_no_line_table() {
let s = state();
let doc = document("");
insta::assert_snapshot!(
"items_editor_default_fallback",
render_to_string(&s, &doc, 60, 14)
);
}
#[test]
fn snapshot_items_editor_move_mode() {
let mut s = state();
let mut doc = document(
r#"[line]
segments = ["model", "context_window", "git_branch"]
"#,
);
let mut cfg = config_default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Enter));
assert!(s.list.move_mode(), "expected move-mode after Enter");
insta::assert_snapshot!("items_editor_move_mode", render_to_string(&s, &doc, 60, 14));
}
#[test]
fn snapshot_items_editor_populated_segments() {
let s = state();
let doc = document(
r#"[line]
segments = ["model", "context_window", "git_branch", "cwd"]
"#,
);
insta::assert_snapshot!("items_editor_populated", render_to_string(&s, &doc, 60, 14));
}
#[test]
fn snapshot_items_editor_narrow_width() {
let s = state();
let doc = document(
r#"[line]
segments = ["model", "context_window", "git_branch", "cwd"]
"#,
);
insta::assert_snapshot!("items_editor_narrow", render_to_string(&s, &doc, 40, 14));
}
#[test]
fn snapshot_items_editor_wide_width() {
let s = state();
let doc = document(
r#"[line]
segments = ["model", "context_window", "git_branch", "cwd"]
"#,
);
insta::assert_snapshot!("items_editor_wide", render_to_string(&s, &doc, 100, 14));
}
}