use ratatui::{
Frame,
buffer::Buffer,
layout::{Position, Rect},
style::Style,
text::Line,
widgets::Paragraph,
};
use repon_core::{ActionSpec, Applicability, Filter, Step};
use crate::{
config::document::{ActionConfig, StepConfig},
degrade::{self, Priority},
edit_buffer::{EditBuffer, Motion},
footer,
glyphs::{BorderScratch, GlyphSet},
keys::BindingTable,
management::{self, Operation},
selection::RunScope,
theme::{Meaning, Role, Theme},
};
pub(crate) const NO_MATCHES_MESSAGE: &str = "no matches";
pub(crate) const NO_ACTIONS_CONFIGURED_MESSAGE: &str = "no actions; see [[action]]";
pub(crate) const RUNS_AS_COMMAND_MESSAGE: &str = "enter runs this as a command";
pub(crate) const QUERY_PLACEHOLDER: &str = "; select action or type a command";
const PROMPT_WIDTH: u16 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum ShellMode {
Off,
#[default]
Shell,
Interactive,
}
impl ShellMode {
fn next(self) -> Self {
match self {
ShellMode::Shell => ShellMode::Off,
ShellMode::Off => ShellMode::Interactive,
ShellMode::Interactive => ShellMode::Shell,
}
}
fn shell(self) -> bool {
!matches!(self, ShellMode::Off)
}
fn interactive(self) -> bool {
matches!(self, ShellMode::Interactive)
}
}
const SHELL_ON_CORE: &str = "shell on";
const SHELL_ON_MECHANISM: &str = ": $VAR and $(cmd) expand";
const SHELL_ON_TOGGLE: &str = "; alt+s turns it off";
const SHELL_OFF_CORE: &str = "shell off";
const SHELL_OFF_MECHANISM: &str = ": $VAR and $(cmd) are literal";
const SHELL_OFF_TOGGLE: &str = "; alt+s makes it interactive";
const INTERACTIVE_ON_CORE: &str = "interactive";
const INTERACTIVE_ON_MECHANISM: &str = ": aliases resolve too";
const INTERACTIVE_ON_TOGGLE: &str = "; alt+s stops being interactive";
fn shell_mode_hint(mode: ShellMode, frame_width: u16) -> String {
let (core, mechanism, toggle) = match mode {
ShellMode::Shell => (SHELL_ON_CORE, SHELL_ON_MECHANISM, SHELL_ON_TOGGLE),
ShellMode::Off => (SHELL_OFF_CORE, SHELL_OFF_MECHANISM, SHELL_OFF_TOGGLE),
ShellMode::Interactive => (
INTERACTIVE_ON_CORE,
INTERACTIVE_ON_MECHANISM,
INTERACTIVE_ON_TOGGLE,
),
};
let items = [
degrade::Item {
content: core,
priority: Priority::Pinned,
},
degrade::Item {
content: mechanism,
priority: Priority::Drop(1),
},
degrade::Item {
content: toggle,
priority: Priority::Drop(2),
},
];
let budget = (frame_width as usize).saturating_sub(4);
let line = degrade::budget(&items, budget, "", "");
if line.items.is_empty() {
String::new()
} else {
format!(" {} ", line.render("", ""))
}
}
fn shell_mode_word(mode: ShellMode) -> &'static str {
match mode {
ShellMode::Shell => SHELL_ON_CORE,
ShellMode::Off => SHELL_OFF_CORE,
ShellMode::Interactive => INTERACTIVE_ON_CORE,
}
}
pub(crate) const QUERY_MAX_ROWS: usize = 8;
fn query_height(lines: usize, interior_height: u16) -> u16 {
let room = interior_height.saturating_sub(2).max(1);
(lines.clamp(1, QUERY_MAX_ROWS) as u16).min(room)
}
fn first_visible_line(cursor_line: usize, height: u16) -> usize {
cursor_line.saturating_sub(height.saturating_sub(1) as usize)
}
fn cursor_line(before_cursor: &str) -> usize {
before_cursor.matches('\n').count()
}
fn last_line(text: &str) -> &str {
match text.rfind('\n') {
Some(index) => &text[index + 1..],
None => text,
}
}
pub(crate) const CONFIRM_HINT: &str = "y run n cancel";
fn one_line(label: &str) -> String {
label.replace('\n', "; ")
}
fn elided_line(hidden: usize) -> String {
format!("{hidden} more not shown")
}
fn fit_confirm_rows(lines: &[String], height: usize) -> Vec<String> {
if lines.len() <= height {
return lines.to_vec();
}
let last = || lines[lines.len() - 1].clone();
match height {
0 => Vec::new(),
1 => vec![last()],
2 => vec![lines[0].clone(), last()],
_ => {
let shown_middle = height - 3;
let mut fitted = vec![lines[0].clone()];
fitted.extend(lines[1..1 + shown_middle].iter().cloned());
fitted.push(elided_line(lines.len() - 2 - shown_middle));
fitted.push(last());
fitted
}
}
}
fn draw_row(frame: &mut Frame, interior: Rect, row: u16, line: &str, style: Style) {
if row >= interior.height {
return;
}
let area = Rect::new(interior.x, interior.y + row, interior.width, 1);
frame.render_widget(Paragraph::new(line.to_string()).style(style), area);
}
pub(crate) const BUILT_IN_MARK: &str = "(built-in)";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Scope {
Everything,
ManagementOnly,
}
#[derive(Debug, Clone)]
pub(crate) enum Entry<'a> {
Builtin(Operation),
Configured(&'a ActionConfig),
}
impl Entry<'_> {
pub(crate) fn name(&self) -> &str {
match self {
Entry::Builtin(operation) => operation.name(),
Entry::Configured(action) => action.name.get_ref(),
}
}
fn description(&self) -> &str {
match self {
Entry::Builtin(operation) => operation.description(),
Entry::Configured(action) => action.description.as_deref().unwrap_or(""),
}
}
}
pub(crate) fn entries<'a>(
actions: &'a [ActionConfig],
scope: Scope,
query: &str,
) -> Vec<Entry<'a>> {
let query = query.to_lowercase();
let configured: Vec<Entry<'a>> = match scope {
Scope::Everything => actions.iter().map(Entry::Configured).collect(),
Scope::ManagementOnly => Vec::new(),
};
configured
.into_iter()
.chain(management::OPERATIONS.into_iter().map(Entry::Builtin))
.filter(|entry| entry.name().to_lowercase().contains(&query))
.collect()
}
fn to_steps(steps: &[StepConfig]) -> Vec<Step> {
steps
.iter()
.map(|step| Step {
argv: step.args.clone(),
shell: step.shell,
interactive: step.interactive,
env: step
.env
.iter()
.map(|(name, value)| (name.clone(), value.clone()))
.collect(),
})
.collect()
}
pub(crate) fn to_action_spec(config: &ActionConfig) -> ActionSpec {
let name: std::sync::Arc<str> = std::sync::Arc::from(config.name.get_ref().as_str());
ActionSpec {
label: std::sync::Arc::clone(&name),
name: Some(name),
steps: to_steps(&config.steps),
concurrency: config.concurrency,
when: config.when.as_deref().map(Filter::parse),
}
}
const AD_HOC_CONCURRENCY: u32 = 4;
fn ad_hoc_steps(text: &str, mode: ShellMode) -> Option<Vec<Step>> {
text.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| -> Result<Step, shell_words::ParseError> {
if mode.shell() {
Ok(Step {
argv: vec![line.to_string()],
shell: true,
interactive: mode.interactive(),
env: Vec::new(),
})
} else {
Ok(Step {
argv: shell_words::split(line)?,
shell: false,
interactive: false,
env: Vec::new(),
})
}
})
.collect::<Result<Vec<_>, _>>()
.ok()
}
fn to_ad_hoc_action_spec(text: &str, steps: Vec<Step>) -> ActionSpec {
ActionSpec {
label: std::sync::Arc::from(text.trim()),
name: None,
steps,
concurrency: AD_HOC_CONCURRENCY,
when: None,
}
}
#[derive(Debug, Clone)]
pub(crate) enum Stage {
Choosing,
Confirming(Chosen),
}
#[derive(Debug, Clone)]
pub(crate) enum Chosen {
Configured(ActionConfig),
AdHoc {
spec: ActionSpec,
mode: ShellMode,
},
Management(Operation),
}
#[derive(Debug, Clone)]
pub(crate) enum Decision {
Refused,
RunImmediately(ActionSpec),
NeedsConfirm,
}
#[derive(Debug, Clone)]
pub(crate) struct ActionPalette {
query: EditBuffer,
cursor: usize,
stage: Stage,
refusal: Option<String>,
scope: Scope,
mode: ShellMode,
}
pub(crate) struct Run<'a> {
pub(crate) actions: &'a [ActionConfig],
pub(crate) count: Count,
pub(crate) management_lines: &'a [String],
pub(crate) bindings: &'a BindingTable,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Count {
pub(crate) operable: usize,
pub(crate) scope: RunScope,
pub(crate) narrowed: Option<Narrowed>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Narrowed {
pub(crate) label: String,
pub(crate) applicability: Applicability,
}
impl Count {
pub(crate) fn unnarrowed(scope: RunScope, operable: usize) -> Self {
Count {
operable,
scope,
narrowed: None,
}
}
pub(crate) fn phrase(&self) -> String {
let scope = self.scope.word();
match &self.narrowed {
Some(narrowed) => {
let applicable = narrowed.applicability.applicable;
format!("{applicable} of {} {scope}", self.operable)
}
None => format!("{} {scope}", self.operable),
}
}
}
impl ActionPalette {
pub(crate) fn new() -> Self {
Self::scoped(Scope::Everything)
}
pub(crate) fn management() -> Self {
Self::scoped(Scope::ManagementOnly)
}
fn scoped(scope: Scope) -> Self {
Self {
query: EditBuffer::new(),
cursor: 0,
stage: Stage::Choosing,
refusal: None,
scope,
mode: ShellMode::default(),
}
}
pub(crate) fn stage(&self) -> &Stage {
&self.stage
}
pub(crate) fn toggle_shell(&mut self) {
self.mode = self.mode.next();
}
#[cfg(test)]
pub(crate) fn mode(&self) -> ShellMode {
self.mode
}
#[cfg(test)]
pub(crate) fn refusal(&self) -> Option<&str> {
self.refusal.as_deref()
}
pub(crate) fn matches<'a>(&self, actions: &'a [ActionConfig]) -> Vec<Entry<'a>> {
entries(actions, self.scope, self.query.as_str())
}
pub(crate) fn highlighted<'a>(&self, actions: &'a [ActionConfig]) -> Option<Entry<'a>> {
self.matches(actions).into_iter().nth(self.cursor)
}
fn clamp_cursor(&mut self, actions: &[ActionConfig]) {
let len = self.matches(actions).len();
self.cursor = if len == 0 {
0
} else {
self.cursor.min(len - 1)
};
}
pub(crate) fn type_char(&mut self, c: char, actions: &[ActionConfig]) {
self.query.insert_char(c);
self.refusal = None;
self.clamp_cursor(actions);
}
pub(crate) fn delete_previous_char(&mut self, actions: &[ActionConfig]) {
self.query.delete_previous_char();
self.refusal = None;
self.clamp_cursor(actions);
}
pub(crate) fn delete_previous_word(&mut self, actions: &[ActionConfig]) {
self.query.delete_previous_word();
self.refusal = None;
self.clamp_cursor(actions);
}
pub(crate) fn move_cursor(&mut self, motion: Motion) {
self.query.move_cursor(motion);
}
pub(crate) fn clear_line(&mut self, actions: &[ActionConfig]) {
self.query.clear();
self.refusal = None;
self.clamp_cursor(actions);
}
pub(crate) fn paste(&mut self, text: &str, actions: &[ActionConfig]) {
self.query.insert_str(text);
self.refusal = None;
self.clamp_cursor(actions);
}
pub(crate) fn insert_newline(&mut self, actions: &[ActionConfig]) {
self.type_char('\n', actions);
}
pub(crate) fn text(&self) -> &str {
self.query.as_str()
}
pub(crate) fn set_text(&mut self, text: String, actions: &[ActionConfig]) {
self.query.set_text(text);
self.refusal = None;
self.clamp_cursor(actions);
}
pub(crate) fn move_highlight(&mut self, delta: isize, actions: &[ActionConfig]) {
let len = self.matches(actions).len();
if len == 0 {
self.cursor = 0;
return;
}
let moved = self.cursor as isize + delta;
self.cursor = moved.clamp(0, len as isize - 1) as usize;
}
pub(crate) fn choose(
&mut self,
actions: &[ActionConfig],
operable_count: usize,
) -> Option<Decision> {
match self.highlighted(actions) {
Some(Entry::Builtin(operation)) => {
self.refusal = None;
self.stage = Stage::Confirming(Chosen::Management(operation));
Some(Decision::NeedsConfirm)
}
Some(Entry::Configured(action)) => {
if operable_count == 0 {
self.refusal = Some(format!(
"\"{}\" targets 0 repos and was not run",
one_line(action.name.get_ref())
));
return Some(Decision::Refused);
}
self.refusal = None;
let action = action.clone();
if action.confirm {
self.stage = Stage::Confirming(Chosen::Configured(action));
Some(Decision::NeedsConfirm)
} else {
Some(Decision::RunImmediately(to_action_spec(&action)))
}
}
None => {
let steps = ad_hoc_steps(self.query.as_str(), self.mode)?;
if steps.is_empty() {
return None;
}
if operable_count == 0 {
self.refusal = Some(format!(
"\"{}\" targets 0 repos and was not run",
one_line(self.query.as_str().trim())
));
return Some(Decision::Refused);
}
self.refusal = None;
let spec = to_ad_hoc_action_spec(self.query.as_str(), steps);
self.stage = Stage::Confirming(Chosen::AdHoc {
spec,
mode: self.mode,
});
Some(Decision::NeedsConfirm)
}
}
}
pub(crate) fn confirm_run(&self) -> Option<ActionSpec> {
match &self.stage {
Stage::Confirming(Chosen::Configured(entry)) => Some(to_action_spec(entry)),
Stage::Confirming(Chosen::AdHoc { spec, .. }) => Some(spec.clone()),
Stage::Confirming(Chosen::Management(_)) | Stage::Choosing => None,
}
}
pub(crate) fn confirm_management(&self) -> Option<Operation> {
match &self.stage {
Stage::Confirming(Chosen::Management(operation)) => Some(*operation),
Stage::Confirming(Chosen::Configured(_) | Chosen::AdHoc { .. }) | Stage::Choosing => {
None
}
}
}
pub(crate) fn decline(&mut self) {
self.stage = Stage::Choosing;
}
pub(crate) fn narrowing_entry<'a>(
&'a self,
actions: &'a [ActionConfig],
) -> Option<&'a ActionConfig> {
match &self.stage {
Stage::Confirming(Chosen::Configured(entry)) => Some(entry),
Stage::Confirming(Chosen::AdHoc { .. } | Chosen::Management(_)) => None,
Stage::Choosing => match self.highlighted(actions)? {
Entry::Configured(action) => Some(action),
Entry::Builtin(_) => None,
},
}
}
pub(crate) fn border_title(count: &Count) -> String {
let phrase = count.phrase();
match &count.narrowed {
None => format!(" run on {phrase} "),
Some(Narrowed {
label,
applicability,
}) => {
let tail = if applicability.unresolved == 0 {
String::new()
} else {
format!(", {} unresolved", applicability.unresolved)
};
format!(" run \"{label}\" on {phrase}{tail} ")
}
}
}
fn draw_query(&self, frame: &mut Frame, interior: Rect, theme: &Theme, capped_rows: u16) {
let row_right = interior.x + interior.width;
let style = theme.style_for(Role::Text);
let caret_line = cursor_line(self.query.before_cursor());
let first = first_visible_line(caret_line, capped_rows);
let column_before = last_line(self.query.before_cursor());
let column_after = self.query.after_cursor().split('\n').next().unwrap_or("");
let mut caret = None;
for (index, line) in self
.query
.as_str()
.split('\n')
.enumerate()
.skip(first)
.take(capped_rows as usize)
{
let y = interior.y + (index - first) as u16;
let buf: &mut Buffer = frame.buffer_mut();
let prompt = if index == 0 { "; " } else { " " };
let (x, _) = buf.set_stringn(
interior.x,
y,
prompt,
row_right.saturating_sub(interior.x) as usize,
style,
);
if index == caret_line {
let (caret_x, _) = buf.set_stringn(
x,
y,
column_before,
row_right.saturating_sub(x) as usize,
style,
);
buf.set_stringn(
caret_x,
y,
column_after,
row_right.saturating_sub(caret_x) as usize,
style,
);
caret = Some(Position::new(caret_x.min(row_right), y));
} else {
buf.set_stringn(x, y, line, row_right.saturating_sub(x) as usize, style);
}
}
if let Some(position) = caret {
frame.set_cursor_position(position);
}
}
pub(crate) fn draw(
&self,
frame: &mut Frame,
area: Rect,
theme: &Theme,
run: Run<'_>,
glyphs: &'static GlyphSet,
) {
let Run {
actions,
count,
management_lines,
bindings,
} = run;
let run_phrase = count.phrase();
let mut scratch = BorderScratch::new();
let mut block = glyphs
.bordered_block(&mut scratch)
.border_style(theme.style_for(Meaning::ActionPaletteBorder.role()))
.title(Self::border_title(&count));
if matches!(self.stage, Stage::Choosing) && self.highlighted(actions).is_none() {
let hint = shell_mode_hint(self.mode, area.width);
if !hint.is_empty() {
block = block.title_bottom(Line::from(hint));
}
}
let interior = block.inner(area);
frame.render_widget(block, area);
match &self.stage {
Stage::Confirming(chosen) => {
let rows: Vec<String> = match chosen {
Chosen::Configured(entry) => {
vec![format!("run \"{}\" on {run_phrase}?", entry.name.get_ref())]
}
Chosen::AdHoc { spec, mode } => vec![format!(
"run \"{}\" ({}) on {run_phrase}?",
one_line(&spec.label),
shell_mode_word(*mode)
)],
Chosen::Management(_) => management_lines.to_vec(),
};
let body_height = interior.height.saturating_sub(1) as usize;
for (row, line) in fit_confirm_rows(&rows, body_height).iter().enumerate() {
draw_row(frame, interior, row as u16, line, Style::new());
}
if interior.height > 0 {
draw_row(
frame,
interior,
interior.height - 1,
CONFIRM_HINT,
theme.style_for(Role::Dim),
);
}
}
Stage::Choosing => {
let query_rows =
query_height(self.query.as_str().split('\n').count(), interior.height);
let row_right = interior.x + interior.width;
if self.query.is_empty() {
draw_row(
frame,
interior,
0,
QUERY_PLACEHOLDER,
theme.style_for(Role::Dim),
);
if interior.height > 0 {
frame.set_cursor_position(Position::new(
(interior.x + PROMPT_WIDTH).min(row_right),
interior.y,
));
}
} else if interior.height > 0 {
self.draw_query(frame, interior, theme, query_rows);
}
let refusal_rows: u16 = if self.refusal.is_some() { 1 } else { 0 };
let list_top = query_rows + refusal_rows;
let matches = self.matches(actions);
let rows_below_query =
interior.height.saturating_sub(list_top).saturating_sub(1) as usize;
if matches.is_empty() {
let runs_as_command = ad_hoc_steps(self.query.as_str(), self.mode)
.is_some_and(|steps| !steps.is_empty());
draw_row(
frame,
interior,
list_top,
if runs_as_command {
RUNS_AS_COMMAND_MESSAGE
} else {
NO_MATCHES_MESSAGE
},
theme.style_for(Role::Dim),
);
} else {
for (row, entry) in matches.iter().enumerate().take(rows_below_query) {
let marker = if row == self.cursor { "> " } else { " " };
let line = format!(
"{marker}{} {}{}",
entry.name(),
entry.description(),
match entry {
Entry::Builtin(_) => format!(" {BUILT_IN_MARK}"),
Entry::Configured(_) => String::new(),
}
);
let style = match entry {
Entry::Builtin(_) => theme.style_for(Role::Accent),
Entry::Configured(_) => Style::new(),
};
let y_row = list_top + row as u16;
draw_row(frame, interior, y_row, &line, style);
if row == self.cursor && y_row < interior.height {
frame.buffer_mut().set_style(
Rect::new(interior.x, interior.y + y_row, interior.width, 1),
theme.selection_style(),
);
}
}
}
if self.scope == Scope::Everything
&& actions.is_empty()
&& matches.len() < rows_below_query
{
draw_row(
frame,
interior,
list_top + matches.len() as u16,
NO_ACTIONS_CONFIGURED_MESSAGE,
theme.style_for(Role::Dim),
);
}
if let Some(refusal) = &self.refusal {
draw_row(
frame,
interior,
query_rows,
refusal,
theme.style_for(Role::Danger),
);
}
if interior.height > 0 {
footer::draw_action_palette(
frame,
Rect::new(
interior.x,
interior.y + interior.height - 1,
interior.width,
1,
),
bindings,
theme,
);
}
}
}
}
}
#[cfg(test)]
mod tests {
use std::sync::LazyLock;
use super::*;
static BINDINGS_FOR_TESTS: LazyLock<BindingTable> =
LazyLock::new(BindingTable::compiled_default);
fn action(name: &str, confirm: bool) -> ActionConfig {
ActionConfig {
name: toml::Spanned::new(0..0, name.to_string()),
description: None,
steps: vec![StepConfig {
args: vec!["true".to_string()],
shell: false,
interactive: false,
env: Default::default(),
}],
confirm,
concurrency: 4,
when: None,
}
}
#[test]
fn a_query_naming_a_launcher_never_matches_any_action_palette_entry() {
let actions = vec![action("reinstall", true), action("deploy", true)];
let launcher_only_name = "lazygit";
let matches = entries(&actions, Scope::Everything, launcher_only_name);
assert!(
matches.is_empty(),
"a Launcher's own name must not match anything in the Action palette's list, \
since the two palettes search two entirely separate lists"
);
}
fn listed(configured: usize) -> usize {
configured + management::OPERATIONS.len()
}
fn names(entries: &[Entry<'_>]) -> Vec<String> {
entries
.iter()
.map(|entry| entry.name().to_string())
.collect()
}
#[test]
fn matching_is_case_insensitive_substring_and_empty_query_matches_everything() {
let actions = vec![action("reinstall", true), action("deploy", true)];
assert_eq!(
names(&entries(&actions, Scope::Everything, "INSTALL")),
vec!["reinstall"]
);
assert_eq!(
names(&entries(&actions, Scope::Everything, "")),
vec!["reinstall", "deploy", "ignore", "delete", "sync"],
"an empty query lists everything, config-defined first and the built-ins after"
);
assert!(entries(&actions, Scope::Everything, "nothing-named-this").is_empty());
}
#[test]
fn border_title_matches_theming_mds_own_quoted_example_for_the_same_count() {
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let theming = std::fs::read_to_string(manifest_dir.join("../../docs/spec/theming.md"))
.expect("read docs/spec/theming.md");
let quoted = theming
.split("so it reads `")
.nth(1)
.and_then(|rest| rest.split('`').next())
.expect("theming.md still carries the quoted `run on 12 selected` example");
assert_eq!(
ActionPalette::border_title(&Count::unnarrowed(RunScope::CheckedRows, 12)).trim(),
quoted
);
}
fn border_title_readings_actions_md_fixes() -> Vec<String> {
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let actions_md = std::fs::read_to_string(manifest_dir.join("../../docs/spec/actions.md"))
.expect("read docs/spec/actions.md");
let table = actions_md
.split("| the border title reads |")
.nth(1)
.expect("actions.md must carry the border-title table");
let readings: Vec<String> = table
.lines()
.skip_while(|line| !line.starts_with('|'))
.take_while(|line| line.starts_with('|'))
.filter(|line| !line.contains("---"))
.map(|line| {
let mut cells = line.rsplit('`');
cells.next();
cells
.next()
.expect("every body row must quote its own title in backticks")
.to_string()
})
.collect();
assert_eq!(
readings.len(),
3,
"actions.md's border-title table no longer fixes three readings, so this test \
would assert less than the document says: {readings:?}"
);
readings
}
fn narrowed(applicable: usize, inapplicable: usize, unresolved: usize) -> Count {
Count {
operable: applicable + inapplicable + unresolved,
scope: RunScope::CheckedRows,
narrowed: Some(Narrowed {
label: "reinstall".to_string(),
applicability: Applicability {
applicable,
inapplicable,
unresolved,
},
}),
}
}
#[test]
fn the_border_title_reproduces_every_reading_actions_md_fixes() {
let readings = border_title_readings_actions_md_fixes();
assert_eq!(
ActionPalette::border_title(&Count::unnarrowed(RunScope::CheckedRows, 12)).trim(),
readings[0]
);
assert_eq!(
ActionPalette::border_title(&narrowed(8, 4, 0)).trim(),
readings[1]
);
assert_eq!(
ActionPalette::border_title(&narrowed(8, 1, 3)).trim(),
readings[2]
);
}
#[test]
fn the_narrowed_title_and_its_unresolved_tail_reach_the_drawn_border() {
use ratatui::{Terminal, backend::TestBackend};
let actions = vec![action("reinstall", true)];
let expected = border_title_readings_actions_md_fixes()[2].clone();
let mut terminal =
Terminal::new(TestBackend::new(80, 6)).expect("create the test terminal");
terminal
.draw(|frame| {
ActionPalette::new().draw(
frame,
frame.area(),
&Theme::default(),
Run {
actions: &actions,
count: narrowed(8, 1, 3),
management_lines: &[],
bindings: &BINDINGS_FOR_TESTS,
},
&crate::glyphs::FULL,
)
})
.expect("draw the frame");
let top: String = (0..80)
.map(|x| terminal.backend().buffer()[(x, 0)].symbol().to_string())
.collect();
assert!(
top.contains(&expected),
"the drawn border must carry the whole reading {expected:?}, got: {top:?}"
);
}
#[test]
fn the_shell_mode_hint_matches_keybindings_mds_own_quoted_sentences() {
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let spec = std::fs::read_to_string(manifest_dir.join("../../docs/spec/keybindings.md"))
.expect("read docs/spec/keybindings.md");
let quoted = |marker: &str| {
spec.split(marker)
.nth(1)
.and_then(|rest| rest.split('`').next())
.unwrap_or_else(|| panic!("keybindings.md still carries {marker:?}"))
.to_string()
};
assert_eq!(
format!("{SHELL_ON_CORE}{SHELL_ON_MECHANISM}{SHELL_ON_TOGGLE}"),
quoted("it draws: `"),
"the Shell-mode constants must join into keybindings.md's own quoted sentence"
);
assert_eq!(
format!("{SHELL_OFF_CORE}{SHELL_OFF_MECHANISM}{SHELL_OFF_TOGGLE}"),
quoted("once toggled, `"),
"the Off-mode constants must join into keybindings.md's own quoted sentence"
);
assert_eq!(
format!("{INTERACTIVE_ON_CORE}{INTERACTIVE_ON_MECHANISM}{INTERACTIVE_ON_TOGGLE}"),
quoted("toggled again, `"),
"the Interactive-mode constants must join into keybindings.md's own quoted sentence"
);
}
#[test]
fn the_shell_mode_hint_reads_the_whole_sentence_when_the_frame_is_wide_enough() {
let expected = format!("{SHELL_ON_CORE}{SHELL_ON_MECHANISM}{SHELL_ON_TOGGLE}");
let mut palette = ActionPalette::new();
for c in "zz".chars() {
palette.type_char(c, &[]);
}
let buf = draw_sized(&palette, &[], &Theme::default(), 70, 6);
let bottom = row_text(&buf, 5, 70);
assert!(
bottom.contains(&expected),
"expected the whole shell-on sentence on the bottom border at 70 columns: \
{bottom:?}"
);
}
#[test]
fn toggling_shell_cycles_through_all_three_modes_and_flips_the_field_chromes_hint() {
let mut palette = ActionPalette::new();
assert_eq!(
palette.mode(),
ShellMode::Shell,
"a freshly opened palette defaults to Shell"
);
palette.toggle_shell();
assert_eq!(palette.mode(), ShellMode::Off);
for c in "zz".chars() {
palette.type_char(c, &[]);
}
let expected_off = format!("{SHELL_OFF_CORE}{SHELL_OFF_MECHANISM}{SHELL_OFF_TOGGLE}");
let buf = draw_sized(&palette, &[], &Theme::default(), 70, 6);
let bottom = row_text(&buf, 5, 70);
assert!(
bottom.contains(&expected_off),
"expected the whole shell-off sentence once toggled: {bottom:?}"
);
palette.toggle_shell();
assert_eq!(
palette.mode(),
ShellMode::Interactive,
"a second toggle reaches Interactive, not back to the plain default"
);
let expected_interactive =
format!("{INTERACTIVE_ON_CORE}{INTERACTIVE_ON_MECHANISM}{INTERACTIVE_ON_TOGGLE}");
let buf = draw_sized(&palette, &[], &Theme::default(), 70, 6);
let bottom = row_text(&buf, 5, 70);
assert!(
bottom.contains(&expected_interactive),
"expected the whole interactive sentence once toggled twice: {bottom:?}"
);
palette.toggle_shell();
assert_eq!(
palette.mode(),
ShellMode::Shell,
"a third toggle completes the cycle back to Shell"
);
}
#[test]
fn the_shell_mode_hint_drops_the_mechanism_clause_before_the_toggle_clause() {
let mut palette = ActionPalette::new();
for c in "zz".chars() {
palette.type_char(c, &[]);
}
let buf = draw_sized(&palette, &[], &Theme::default(), 40, 6);
let bottom = row_text(&buf, 5, 40);
assert!(
bottom.contains(SHELL_ON_CORE) && bottom.contains("alt+s"),
"expected \"shell on\" and the toggle clause to survive at 40 columns: {bottom:?}"
);
assert!(
!bottom.contains("expand"),
"expected the mechanism clause dropped before the toggle clause at 40 columns: \
{bottom:?}"
);
}
#[test]
fn the_shell_mode_hint_keeps_the_bare_words_once_the_toggle_clause_no_longer_fits() {
let mut palette = ActionPalette::new();
for c in "zz".chars() {
palette.type_char(c, &[]);
}
let buf = draw_sized(&palette, &[], &Theme::default(), 20, 6);
let bottom = row_text(&buf, 5, 20);
assert!(
bottom.contains(SHELL_ON_CORE),
"expected the bare \"shell on\" words to survive at 20 columns: {bottom:?}"
);
assert!(
!bottom.contains("alt+s"),
"expected the toggle clause dropped before \"shell on\" itself at 20 columns: \
{bottom:?}"
);
}
#[test]
fn the_shell_mode_hint_disappears_whole_rather_than_clipping_when_nothing_fits() {
assert_eq!(
shell_mode_hint(ShellMode::Shell, 6),
"",
"6 columns must be too narrow for any tier"
);
let mut palette = ActionPalette::new();
for c in "zz".chars() {
palette.type_char(c, &[]);
}
let buf = draw_sized(&palette, &[], &Theme::default(), 6, 6);
let bottom = row_text(&buf, 5, 6);
assert!(
!bottom.contains("she"),
"expected no fragment of the hint at 6 columns: {bottom:?}"
);
}
#[test]
fn the_shell_mode_hint_is_absent_while_the_confirm_gate_is_showing() {
let mut palette = ActionPalette::new();
let actions = vec![action("reinstall", true)];
palette.choose(&actions, 3);
let buf = draw_sized(&palette, &actions, &Theme::default(), 70, 6);
let bottom = row_text(&buf, 5, 70);
assert!(
!bottom.contains(SHELL_ON_CORE),
"expected no shell-mode hint while confirming: {bottom:?}"
);
}
#[test]
fn the_shell_mode_hint_is_absent_while_a_built_in_is_highlighted_on_a_freshly_opened_palette() {
let mut palette = ActionPalette::new();
let actions: Vec<ActionConfig> = Vec::new();
assert!(
matches!(palette.highlighted(&actions), Some(Entry::Builtin(_))),
"the fixture must leave a built-in highlighted for the claim below to mean \
anything"
);
let buf = draw_sized(&palette, &actions, &Theme::default(), 70, 6);
let bottom = row_text(&buf, 5, 70);
assert!(
!bottom.contains(SHELL_ON_CORE),
"expected no shell-mode hint while a built-in is highlighted: {bottom:?}"
);
palette.toggle_shell();
let buf = draw_sized(&palette, &actions, &Theme::default(), 70, 6);
let bottom = row_text(&buf, 5, 70);
assert!(
!bottom.contains(SHELL_OFF_CORE),
"expected no shell-mode hint while a built-in is highlighted, even with shell \
off: {bottom:?}"
);
}
#[test]
fn the_shell_mode_hint_is_absent_while_a_typed_query_narrows_to_a_configured_entry() {
let actions = vec![action("reinstall", true)];
let mut palette = ActionPalette::new();
for c in "reinstall".chars() {
palette.type_char(c, &actions);
}
assert!(
matches!(palette.highlighted(&actions), Some(Entry::Configured(_))),
"the fixture must leave the configured entry highlighted for the claim below to \
mean anything"
);
let buf = draw_sized(&palette, &actions, &Theme::default(), 70, 6);
let bottom = row_text(&buf, 5, 70);
assert!(
!bottom.contains(SHELL_ON_CORE),
"expected no shell-mode hint while a configured entry is highlighted: {bottom:?}"
);
palette.toggle_shell();
let buf = draw_sized(&palette, &actions, &Theme::default(), 70, 6);
let bottom = row_text(&buf, 5, 70);
assert!(
!bottom.contains(SHELL_OFF_CORE),
"expected no shell-mode hint while a configured entry is highlighted, even with \
shell off: {bottom:?}"
);
}
#[test]
fn the_shell_mode_hint_appears_once_the_typed_query_matches_no_entry_and_would_build_an_ad_hoc_command()
{
let actions = vec![action("reinstall", true)];
let mut palette = ActionPalette::new();
for c in "zz".chars() {
palette.type_char(c, &actions);
}
assert!(
palette.highlighted(&actions).is_none(),
"the fixture must leave nothing highlighted for the claim below to mean anything"
);
let buf = draw_sized(&palette, &actions, &Theme::default(), 70, 6);
let bottom = row_text(&buf, 5, 70);
assert!(
bottom.contains(SHELL_ON_CORE),
"expected the shell-mode hint once the query matches nothing: {bottom:?}"
);
}
#[test]
fn only_a_configured_entry_narrows_the_title_and_a_live_gate_names_its_own() {
let actions = vec![action("reinstall", true), action("deploy", true)];
let mut palette = ActionPalette::new();
assert_eq!(
palette
.narrowing_entry(&actions)
.map(|entry| entry.name.get_ref().as_str()),
Some("reinstall")
);
palette.move_highlight(2, &actions);
assert!(
matches!(palette.highlighted(&actions), Some(Entry::Builtin(_))),
"the fixture must leave a built-in highlighted for the claim below to mean \
anything"
);
assert!(palette.narrowing_entry(&actions).is_none());
let mut confirming = ActionPalette::new();
confirming.move_highlight(1, &actions);
confirming.choose(&actions, 3);
assert_eq!(
confirming
.narrowing_entry(&actions)
.map(|entry| entry.name.get_ref().as_str()),
Some("deploy"),
"a live gate narrows by the entry it is asking about"
);
}
#[test]
fn choosing_an_entry_with_a_nonzero_operable_count_and_confirm_true_needs_confirmation() {
let actions = vec![action("reinstall", true)];
let mut palette = ActionPalette::new();
let decision = palette.choose(&actions, 3);
assert!(matches!(decision, Some(Decision::NeedsConfirm)));
assert!(
matches!(palette.stage(), Stage::Confirming(Chosen::Configured(entry)) if entry.name.get_ref() == "reinstall")
);
}
#[test]
fn choosing_an_entry_with_confirm_false_runs_immediately_without_entering_the_confirm_stage() {
let actions = vec![action("fetch", false)];
let mut palette = ActionPalette::new();
let decision = palette.choose(&actions, 3);
assert!(matches!(decision, Some(Decision::RunImmediately(_))));
assert!(matches!(palette.stage(), Stage::Choosing));
}
#[test]
fn a_zero_operable_count_refuses_regardless_of_the_entrys_own_confirm_flag() {
for confirm in [true, false] {
let actions = vec![action("reinstall", confirm)];
let mut palette = ActionPalette::new();
let decision = palette.choose(&actions, 0);
assert!(
matches!(decision, Some(Decision::Refused)),
"confirm={confirm} must not change a zero count's refusal"
);
assert!(matches!(palette.stage(), Stage::Choosing));
let refusal = palette.refusal().expect("a refusal message");
assert!(
refusal.contains("reinstall"),
"the refusal must name the Action the user chose, got {refusal:?}"
);
assert!(
refusal.contains('0'),
"the refusal must say how many repos it would have run against, got \
{refusal:?}"
);
}
}
#[test]
fn choosing_text_that_matches_no_configured_action_opens_the_confirm_gate_on_an_ad_hoc_command()
{
let actions = vec![action("reinstall", true)];
let mut palette = ActionPalette::new();
for c in "zz".chars() {
palette.type_char(c, &actions);
}
let decision = palette.choose(&actions, 5);
assert!(
matches!(decision, Some(Decision::NeedsConfirm)),
"an ad hoc command must open the confirm gate rather than run immediately, got \
{decision:?}"
);
let spec = palette
.confirm_run()
.expect("the confirm gate must carry the built ActionSpec");
assert_eq!(spec.steps.len(), 1);
assert_eq!(spec.steps[0].argv, vec!["zz".to_string()]);
assert!(spec.steps[0].shell, "an ad hoc step defaults to shell on");
assert!(
spec.name.is_none(),
"REPON_ACTION must stay unset for an ad hoc run, exactly as for a Launcher"
);
}
#[test]
fn toggling_shell_off_before_choosing_builds_an_ad_hoc_run_with_shell_off() {
let actions: Vec<ActionConfig> = Vec::new();
let mut palette = ActionPalette::new();
palette.toggle_shell();
for c in "zz".chars() {
palette.type_char(c, &actions);
}
palette.choose(&actions, 5);
let spec = palette.confirm_run().expect("a confirm gate was opened");
assert!(
!spec.steps[0].shell,
"the toggle must carry through to the step the confirm gate holds"
);
}
#[test]
fn the_shell_toggle_resets_to_on_every_time_a_new_palette_is_opened() {
let mut first = ActionPalette::new();
first.toggle_shell();
assert_eq!(first.mode(), ShellMode::Off);
let second = ActionPalette::new();
assert_eq!(
second.mode(),
ShellMode::Shell,
"a freshly opened palette must default to Shell regardless of a previous one's \
toggle"
);
}
#[test]
fn choosing_blank_or_whitespace_only_text_with_no_match_does_nothing() {
let actions = vec![action("reinstall", true)];
let mut palette = ActionPalette::new();
palette.type_char(' ', &actions);
palette.type_char(' ', &actions);
let decision = palette.choose(&actions, 5);
assert!(decision.is_none());
assert!(matches!(palette.stage(), Stage::Choosing));
}
#[test]
fn ad_hoc_steps_with_shell_off_skips_blank_lines_and_respects_quoting_in_the_remaining_ones() {
let text = "false\n\necho \"a b\"";
let steps = ad_hoc_steps(text, ShellMode::Off).expect("well-formed quoting must parse");
assert_eq!(
steps.len(),
2,
"the blank middle line must contribute no step"
);
assert_eq!(steps[0].argv, vec!["false".to_string()]);
assert!(!steps[0].shell);
assert_eq!(
steps[1].argv,
vec!["echo".to_string(), "a b".to_string()],
"the quoted argument must survive as one argv element, not split on its own space"
);
assert!(!steps[1].shell);
}
#[test]
fn ad_hoc_steps_with_shell_on_keeps_each_line_whole_and_unsplit() {
let text = "false\n\necho \"a b\"";
let steps = ad_hoc_steps(text, ShellMode::Shell).expect("shell mode never fails to parse");
assert_eq!(
steps.len(),
2,
"the blank middle line must contribute no step"
);
assert_eq!(steps[0].argv, vec!["false".to_string()]);
assert!(steps[0].shell);
assert_eq!(
steps[1].argv,
vec!["echo \"a b\"".to_string()],
"shell mode must hand the whole line to the shell unsplit, quoting intact"
);
assert!(steps[1].shell);
}
#[test]
fn ad_hoc_steps_with_interactive_mode_sets_interactive_on_every_step() {
let steps = ad_hoc_steps("true", ShellMode::Interactive)
.expect("interactive mode never fails to parse");
assert_eq!(steps.len(), 1);
assert!(steps[0].shell, "interactive is still a shell step");
assert!(steps[0].interactive);
}
#[test]
fn a_line_that_fails_to_word_split_aborts_the_whole_ad_hoc_command_with_shell_off() {
let actions: Vec<ActionConfig> = Vec::new();
let mut palette = ActionPalette::new();
palette.toggle_shell();
for c in "echo \"unterminated".chars() {
palette.type_char(c, &actions);
}
let decision = palette.choose(&actions, 5);
assert!(
decision.is_none(),
"malformed quoting must refuse the whole command rather than run a truncated \
version of what was typed"
);
}
#[test]
fn a_line_that_would_fail_to_word_split_still_parses_with_shell_on() {
let actions: Vec<ActionConfig> = Vec::new();
let mut palette = ActionPalette::new();
for c in "echo \"unterminated".chars() {
palette.type_char(c, &actions);
}
let decision = palette.choose(&actions, 5);
assert!(matches!(decision, Some(Decision::NeedsConfirm)));
let spec = palette.confirm_run().expect("a confirm gate was opened");
assert_eq!(spec.steps[0].argv, vec!["echo \"unterminated".to_string()]);
}
#[test]
fn an_ad_hoc_command_targeting_zero_repos_refuses_and_names_the_typed_command() {
let actions: Vec<ActionConfig> = Vec::new();
let mut palette = ActionPalette::new();
for c in "echo hi".chars() {
palette.type_char(c, &actions);
}
let decision = palette.choose(&actions, 0);
assert!(matches!(decision, Some(Decision::Refused)));
let refusal = palette.refusal().expect("a refusal message");
assert!(refusal.contains("echo hi"), "got {refusal:?}");
assert!(refusal.contains('0'), "got {refusal:?}");
}
#[test]
fn a_multi_line_ad_hoc_command_targeting_zero_repos_refuses_on_a_single_line() {
let actions: Vec<ActionConfig> = Vec::new();
let mut palette = ActionPalette::new();
for c in "ls".chars() {
palette.type_char(c, &actions);
}
palette.insert_newline(&actions);
for c in "wc".chars() {
palette.type_char(c, &actions);
}
let decision = palette.choose(&actions, 0);
assert!(matches!(decision, Some(Decision::Refused)));
let refusal = palette.refusal().expect("a refusal message");
assert!(
!refusal.contains('\n'),
"the refusal must be exactly one line whatever was typed, got {refusal:?}"
);
assert!(
refusal.contains("ls") && refusal.contains("wc"),
"both typed lines must still be identifiable in the refusal, got {refusal:?}"
);
}
#[test]
fn paste_appends_the_whole_text_verbatim_including_embedded_newlines() {
let actions: Vec<ActionConfig> = Vec::new();
let mut palette = ActionPalette::new();
palette.type_char('x', &actions);
palette.paste("first\nsecond", &actions);
assert_eq!(palette.text(), "xfirst\nsecond");
}
#[test]
fn set_text_replaces_the_buffer_wholesale_the_way_the_dollar_editor_round_trip_needs() {
let actions: Vec<ActionConfig> = Vec::new();
let mut palette = ActionPalette::new();
for c in "stale".chars() {
palette.type_char(c, &actions);
}
palette.set_text("edited\ntext".to_string(), &actions);
assert_eq!(palette.text(), "edited\ntext");
}
#[test]
fn the_newline_chord_and_the_two_it_was_chosen_over_are_recorded_beside_the_widget() {
let source = crate::test_support::production_source_at(
&std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/action_palette.rs"),
);
assert!(
source.contains("Alt+Enter"),
"expected the module doc to name the chord that inserts a newline"
);
assert!(
source.contains("kitty keyboard protocol"),
"expected the module doc to record why Shift+Enter and Ctrl+Enter were not used"
);
assert!(
source.contains("Ctrl+J is the newline byte itself"),
"expected the module doc to name Ctrl+J as the obvious, unusable control chord"
);
}
#[test]
fn the_newline_key_inserts_a_newline_at_the_cursor_rather_than_at_the_end() {
let actions: Vec<ActionConfig> = Vec::new();
let mut palette = ActionPalette::new();
for c in "ab".chars() {
palette.type_char(c, &actions);
}
palette.move_cursor(Motion::Left);
palette.insert_newline(&actions);
assert_eq!(palette.text(), "a\nb");
assert_eq!(
palette.query.after_cursor(),
"b",
"the cursor must follow the newline it inserted, not jump to the end"
);
}
#[test]
fn a_typed_newline_makes_the_second_line_a_step_of_its_own_sharing_the_first_lines_shell_mode()
{
let actions: Vec<ActionConfig> = Vec::new();
let mut palette = ActionPalette::new();
palette.toggle_shell();
for c in "echo one".chars() {
palette.type_char(c, &actions);
}
palette.insert_newline(&actions);
for c in "echo two".chars() {
palette.type_char(c, &actions);
}
match palette.choose(&actions, 5) {
Some(Decision::NeedsConfirm) => {
let spec = palette.confirm_run().expect("a confirm gate was opened");
assert_eq!(spec.steps.len(), 2, "each line is one step");
assert_eq!(
spec.steps[0].argv,
vec!["echo".to_string(), "one".to_string()]
);
assert_eq!(
spec.steps[1].argv,
vec!["echo".to_string(), "two".to_string()]
);
assert!(
spec.steps.iter().all(|step| !step.shell),
"a newline must not change the shell mode from what the toggle held"
);
}
other => panic!("expected an ad hoc NeedsConfirm decision, got {other:?}"),
}
}
#[test]
fn the_query_row_cap_is_the_number_keybindings_md_documents() {
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let spec = std::fs::read_to_string(manifest_dir.join("../../docs/spec/keybindings.md"))
.expect("read the keybinding spec");
let sentence = format!("capped at {QUERY_MAX_ROWS} rows");
assert!(
spec.contains(&sentence),
"keybindings.md no longer documents the query row cap as {sentence:?}"
);
}
#[test]
fn a_multi_line_query_grows_the_query_rows_and_starts_the_candidate_list_below_them() {
let actions = vec![action("reinstall", true), action("deploy", true)];
let theme = Theme::default();
let mut palette = ActionPalette::new();
for c in "zzq".chars() {
palette.type_char(c, &actions);
}
let one_line = draw_to_buffer(
&palette,
&actions,
&theme,
Count::unnarrowed(RunScope::CheckedRows, 3),
);
assert!(
row_text(&one_line, 2, 40).contains(RUNS_AS_COMMAND_MESSAGE),
"a one-line query leaves the interior's second row to the list: {:?}",
row_text(&one_line, 2, 40)
);
palette.insert_newline(&actions);
for c in "zzq".chars() {
palette.type_char(c, &actions);
}
let two_lines = draw_to_buffer(
&palette,
&actions,
&theme,
Count::unnarrowed(RunScope::CheckedRows, 3),
);
assert!(
row_text(&two_lines, 2, 40).contains("zzq"),
"the second query line must own the row the list used to start on: {:?}",
row_text(&two_lines, 2, 40)
);
assert!(
row_text(&two_lines, 3, 40).contains(RUNS_AS_COMMAND_MESSAGE),
"the list must start one row lower, not be painted over: {:?}",
row_text(&two_lines, 3, 40)
);
}
#[test]
fn a_query_past_the_cap_stops_growing_and_leaves_the_candidate_list_its_rows() {
let actions = vec![action("reinstall", true)];
let mut palette = ActionPalette::new();
type_lines(&mut palette, &actions, 20);
let buf = draw_sized(&palette, &actions, &Theme::default(), 40, 20);
assert!(
row_text(&buf, 1 + QUERY_MAX_ROWS as u16, 40).contains(RUNS_AS_COMMAND_MESSAGE),
"the list must begin exactly {QUERY_MAX_ROWS} rows below the query's own first \
row: {:?}",
row_text(&buf, 1 + QUERY_MAX_ROWS as u16, 40)
);
}
#[test]
fn a_query_past_the_cap_scrolls_to_keep_the_cursors_own_line_on_screen() {
let actions = vec![action("reinstall", true)];
let theme = Theme::default();
let mut palette = ActionPalette::new();
type_lines(&mut palette, &actions, 20);
let at_end = draw_sized(&palette, &actions, &theme, 40, 20);
assert!(
row_text(&at_end, QUERY_MAX_ROWS as u16, 40).contains("line19"),
"the cursor's own last line must be the bottom query row: {:?}",
row_text(&at_end, QUERY_MAX_ROWS as u16, 40)
);
for _ in 1..20 {
palette.move_cursor(Motion::LineStart);
palette.move_cursor(Motion::Left);
}
palette.move_cursor(Motion::LineStart);
let at_start = draw_sized(&palette, &actions, &theme, 40, 20);
assert!(
row_text(&at_start, 1, 40).contains("line0"),
"moving the cursor back to the first line must scroll it into view: {:?}",
row_text(&at_start, 1, 40)
);
}
#[test]
fn the_palette_draws_its_own_footer_naming_the_newline_key_on_its_last_interior_row() {
let actions = vec![action("reinstall", true)];
let palette = ActionPalette::new();
let buf = draw_sized(&palette, &actions, &Theme::default(), 60, 10);
let last_interior_row = row_text(&buf, 8, 60);
assert!(
last_interior_row.contains("alt-enter newline"),
"expected the newline hint on the palette's own footer row: {last_interior_row:?}"
);
assert!(
last_interior_row.contains("esc cancel"),
"expected the way out on the same row: {last_interior_row:?}"
);
}
#[test]
fn draw_places_the_refusal_directly_below_the_query_leaving_the_footer_row_untouched() {
let actions: Vec<ActionConfig> = Vec::new();
let mut palette = ActionPalette::new();
for c in "ls".chars() {
palette.type_char(c, &actions);
}
palette.insert_newline(&actions);
for c in "wc".chars() {
palette.type_char(c, &actions);
}
palette.choose(&actions, 0);
let buf = draw_sized(&palette, &actions, &Theme::default(), 60, 10);
let interior = crate::glyphs::bordered_interior(Rect::new(0, 0, 60, 10));
let query_rows = 2;
let refusal_row = row_text(&buf, interior.y + query_rows, 60);
assert!(
refusal_row.contains("targets 0 repos"),
"expected the refusal on the row right below the two-line query: {refusal_row:?}"
);
let footer_row = row_text(&buf, interior.y + interior.height - 1, 60);
assert!(
footer_row.contains("alt-enter newline"),
"the refusal must not have pushed the footer's own hints off its row: \
{footer_row:?}"
);
assert!(
!footer_row.contains("targets 0 repos"),
"the refusal must not land on the footer row: {footer_row:?}"
);
}
#[test]
fn confirm_run_reads_back_the_exact_entry_choose_moved_into_the_confirming_stage() {
let actions = vec![action("reinstall", true)];
let mut palette = ActionPalette::new();
palette.choose(&actions, 7);
let spec = palette.confirm_run().expect("a chosen entry to confirm");
assert_eq!(&*spec.label, "reinstall");
assert_eq!(spec.name.as_deref(), Some("reinstall"));
}
#[test]
fn decline_returns_to_choosing_without_losing_the_typed_query() {
let actions = vec![action("reinstall", true)];
let mut palette = ActionPalette::new();
palette.type_char('r', &actions);
palette.choose(&actions, 7);
assert!(matches!(palette.stage(), Stage::Confirming(_)));
palette.decline();
assert!(matches!(palette.stage(), Stage::Choosing));
assert_eq!(palette.text(), "r", "the query survives decline");
assert!(
palette.matches(&actions).len() < listed(1),
"and still narrows the list it survived into"
);
}
#[test]
fn delete_previous_char_removes_the_last_character_and_re_narrows_the_match_list() {
let actions = vec![action("reinstall", true), action("deploy", true)];
let mut palette = ActionPalette::new();
for c in "reinstallx".chars() {
palette.type_char(c, &actions);
}
assert_eq!(
palette.matches(&actions).len(),
0,
"\"reinstallx\" must match no configured action"
);
palette.delete_previous_char(&actions);
assert_eq!(
palette.matches(&actions).len(),
1,
"removing the trailing \"x\" must restore the \"reinstall\" match"
);
}
#[test]
fn delete_previous_char_on_an_empty_query_does_not_panic_and_leaves_it_empty() {
let actions = vec![action("reinstall", true)];
let mut palette = ActionPalette::new();
palette.delete_previous_char(&actions);
assert_eq!(
palette.matches(&actions).len(),
listed(1),
"an empty query still matches everything"
);
}
#[test]
fn delete_previous_word_removes_one_trailing_whitespace_delimited_word() {
let actions = vec![action("reinstall", true)];
let mut palette = ActionPalette::new();
for c in "re install".chars() {
palette.type_char(c, &actions);
}
palette.delete_previous_word(&actions);
assert_eq!(
palette.matches(&actions).len(),
0,
"query is now just \"re \""
);
}
#[test]
fn delete_previous_word_cuts_on_a_character_boundary_after_a_multi_byte_whitespace() {
let actions = vec![action("reinstall", true)];
let mut palette = ActionPalette::new();
for c in "café\u{00A0}naïve".chars() {
palette.type_char(c, &actions);
}
palette.delete_previous_word(&actions);
assert_eq!(palette.text(), "café\u{00A0}");
for c in "naïve\u{2003}encore".chars() {
palette.type_char(c, &actions);
}
palette.delete_previous_word(&actions);
assert_eq!(palette.text(), "café\u{00A0}naïve\u{2003}");
}
#[test]
fn clear_line_empties_the_query_and_restores_every_match() {
let actions = vec![action("reinstall", true), action("deploy", true)];
let mut palette = ActionPalette::new();
palette.type_char('r', &actions);
let narrowed = palette.matches(&actions).len();
assert!(narrowed < listed(2), "the query has to narrow something");
palette.clear_line(&actions);
assert_eq!(palette.matches(&actions).len(), listed(2));
}
#[test]
fn move_highlight_clamps_at_both_ends_rather_than_wrapping() {
let actions: Vec<ActionConfig> = Vec::new();
let mut palette = ActionPalette::management();
palette.move_highlight(-1, &actions);
assert_eq!(palette.highlighted(&actions).unwrap().name(), "ignore");
palette.move_highlight(1, &actions);
assert_eq!(palette.highlighted(&actions).unwrap().name(), "delete");
palette.move_highlight(3, &actions);
assert_eq!(
palette.highlighted(&actions).unwrap().name(),
"sync",
"moving past the last entry must clamp, not wrap back to the first"
);
}
#[test]
fn typing_a_character_that_narrows_the_match_list_clamps_a_cursor_sitting_past_the_new_end() {
let actions = vec![action("aa", true), action("ab", true), action("cc", true)];
let mut palette = ActionPalette::new();
palette.move_highlight(1, &actions);
palette.type_char('a', &actions); assert_eq!(palette.highlighted(&actions).unwrap().name(), "ab");
palette.type_char('b', &actions); assert_eq!(palette.highlighted(&actions).unwrap().name(), "ab");
}
#[test]
fn to_action_spec_carries_the_name_as_both_label_and_the_environments_action_name() {
let config = action("reinstall", true);
let spec = to_action_spec(&config);
assert_eq!(&*spec.label, "reinstall");
assert_eq!(spec.name.as_deref(), Some("reinstall"));
assert_eq!(spec.concurrency, 4);
assert_eq!(spec.steps.len(), 1);
assert_eq!(spec.steps[0].argv, vec!["true".to_string()]);
assert!(!spec.steps[0].shell);
}
#[test]
fn to_action_spec_carries_shell_and_env_through_unresolved() {
let mut config = action("deploy", true);
config.steps = vec![StepConfig {
args: vec!["deploy.sh --prod".to_string()],
shell: true,
interactive: true,
env: std::collections::BTreeMap::from([("STAGE".to_string(), "prod".to_string())]),
}];
let spec = to_action_spec(&config);
assert!(spec.steps[0].shell);
assert!(
spec.steps[0].interactive,
"interactive must cross to repon_core::Step unresolved, the same way shell does"
);
assert_eq!(
spec.steps[0].env,
vec![("STAGE".to_string(), "prod".to_string())]
);
}
#[test]
fn draw_frames_the_palette_with_the_active_glyph_tables_own_border() {
use ratatui::{Terminal, backend::TestBackend};
for glyphs in [&crate::glyphs::FULL, &crate::glyphs::ASCII] {
let actions = vec![action("reinstall", true)];
let palette = ActionPalette::new();
let backend = TestBackend::new(40, 10);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
palette.draw(
frame,
frame.area(),
&Theme::default(),
Run {
actions: &actions,
count: Count::unnarrowed(RunScope::CheckedRows, 3),
management_lines: &[],
bindings: &BINDINGS_FOR_TESTS,
},
glyphs,
);
})
.expect("draw the frame");
crate::test_support::assert_bordered_frame_and_top_title_drawn_with(
terminal.backend().buffer(),
Rect::new(0, 0, 40, 10),
glyphs.border,
&ActionPalette::border_title(&Count::unnarrowed(RunScope::CheckedRows, 3)),
"the Action palette's frame",
);
}
}
#[test]
fn draw_paints_the_border_in_the_themes_warn_colour() {
use ratatui::{Terminal, backend::TestBackend};
let theme = Theme {
warn: ratatui::style::Color::Rgb(1, 2, 3),
..Theme::default()
};
let actions = vec![action("reinstall", true)];
let palette = ActionPalette::new();
let backend = TestBackend::new(40, 10);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
palette.draw(
frame,
frame.area(),
&theme,
Run {
actions: &actions,
count: Count::unnarrowed(RunScope::CheckedRows, 3),
management_lines: &[],
bindings: &BINDINGS_FOR_TESTS,
},
&crate::glyphs::FULL,
);
})
.expect("draw the frame");
let buf = terminal.backend().buffer();
assert_eq!(buf[(0, 0)].fg, theme.warn);
}
#[test]
fn draw_in_stage_choosing_marks_the_highlighted_row_and_lists_every_match() {
use ratatui::{Terminal, backend::TestBackend};
let theme = Theme::default();
let actions = vec![action("reinstall", true), action("deploy", true)];
let mut palette = ActionPalette::new();
palette.move_highlight(1, &actions);
let backend = TestBackend::new(40, 10);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
palette.draw(
frame,
frame.area(),
&theme,
Run {
actions: &actions,
count: Count::unnarrowed(RunScope::CheckedRows, 2),
management_lines: &[],
bindings: &BINDINGS_FOR_TESTS,
},
&crate::glyphs::FULL,
);
})
.expect("draw the frame");
let buf = terminal.backend().buffer();
let row_text =
|y: u16| -> String { (0..40).map(|x| buf[(x, y)].symbol().to_string()).collect() };
assert!(
row_text(1).contains(QUERY_PLACEHOLDER),
"an untouched query row must show the placeholder: {:?}",
row_text(1)
);
assert!(row_text(2).contains("reinstall"));
assert!(row_text(3).contains("deploy"));
assert!(
row_text(3).contains("> deploy"),
"the highlighted row (index 1, \"deploy\") must carry the highlight marker: {:?}",
row_text(3)
);
assert!(
!row_text(2).contains('>'),
"only the highlighted row carries the marker: {:?}",
row_text(2)
);
}
#[test]
fn draw_places_the_caret_at_the_end_of_the_typed_query_not_the_placeholder() {
use ratatui::{Terminal, backend::TestBackend};
let actions = vec![action("reinstall", true)];
let mut palette = ActionPalette::new();
let interior = crate::glyphs::bordered_interior(Rect::new(0, 0, 40, 10));
let backend = TestBackend::new(40, 10);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
palette.draw(
frame,
frame.area(),
&Theme::default(),
Run {
actions: &actions,
count: Count::unnarrowed(RunScope::CheckedRows, 3),
management_lines: &[],
bindings: &BINDINGS_FOR_TESTS,
},
&crate::glyphs::FULL,
)
})
.expect("draw an empty query");
assert!(
terminal.backend().cursor_visible(),
"ratatui shows the caret only on a frame that set one"
);
assert_eq!(
terminal.backend().cursor_position(),
Position::new(interior.x + 2, interior.y),
"an empty query's caret sits right after \"; \", not at the placeholder's end"
);
for c in "rei".chars() {
palette.type_char(c, &actions);
}
terminal
.draw(|frame| {
palette.draw(
frame,
frame.area(),
&Theme::default(),
Run {
actions: &actions,
count: Count::unnarrowed(RunScope::CheckedRows, 3),
management_lines: &[],
bindings: &BINDINGS_FOR_TESTS,
},
&crate::glyphs::FULL,
)
})
.expect("draw the typed query");
assert_eq!(
terminal.backend().cursor_position(),
Position::new(interior.x + 2 + 3, interior.y),
"the caret must move to the end of the three typed characters"
);
}
#[test]
fn draw_places_the_caret_at_the_cursor_rather_than_after_the_last_character() {
use ratatui::{Terminal, backend::TestBackend};
let actions = vec![action("reinstall", true)];
let mut palette = ActionPalette::new();
for c in "café".chars() {
palette.type_char(c, &actions);
}
palette.move_cursor(Motion::WordLeft);
let interior = crate::glyphs::bordered_interior(Rect::new(0, 0, 40, 10));
let backend = TestBackend::new(40, 10);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
palette.draw(
frame,
frame.area(),
&Theme::default(),
Run {
actions: &actions,
count: Count::unnarrowed(RunScope::CheckedRows, 3),
management_lines: &[],
bindings: &BINDINGS_FOR_TESTS,
},
&crate::glyphs::FULL,
)
})
.expect("draw the typed query");
assert_eq!(
terminal.backend().cursor_position(),
Position::new(interior.x + 2, interior.y),
"the caret must sit at the cursor, right after \"; \""
);
let row: String = (0..interior.width)
.map(|offset| {
terminal.backend().buffer()[(interior.x + offset, interior.y)]
.symbol()
.to_string()
})
.collect();
assert!(
row.starts_with("; café"),
"the text after the caret must still be painted: {row:?}"
);
}
#[test]
fn the_caret_column_counts_painted_cells_rather_than_the_bytes_before_the_cursor() {
use ratatui::{Terminal, backend::TestBackend};
let actions = vec![action("reinstall", true)];
let mut palette = ActionPalette::new();
for c in "café".chars() {
palette.type_char(c, &actions);
}
palette.move_cursor(Motion::Left);
let interior = crate::glyphs::bordered_interior(Rect::new(0, 0, 40, 10));
let backend = TestBackend::new(40, 10);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
palette.draw(
frame,
frame.area(),
&Theme::default(),
Run {
actions: &actions,
count: Count::unnarrowed(RunScope::CheckedRows, 3),
management_lines: &[],
bindings: &BINDINGS_FOR_TESTS,
},
&crate::glyphs::FULL,
)
})
.expect("draw the typed query");
assert_eq!(
terminal.backend().cursor_position(),
Position::new(interior.x + 2 + 3, interior.y),
"\"caf\" is three cells, however many bytes `é` costs after it"
);
}
#[test]
fn typing_after_moving_the_cursor_back_inserts_at_the_caret() {
let actions = vec![action("reinstall", true)];
let mut palette = ActionPalette::new();
for c in "ac".chars() {
palette.type_char(c, &actions);
}
palette.move_cursor(Motion::Left);
palette.type_char('b', &actions);
assert_eq!(palette.text(), "abc");
}
#[test]
fn backspace_and_ctrl_w_act_at_the_cursor_rather_than_at_the_end_of_the_query() {
let actions = vec![action("reinstall", true)];
let mut palette = ActionPalette::new();
for c in "one two".chars() {
palette.type_char(c, &actions);
}
palette.move_cursor(Motion::WordLeft);
palette.delete_previous_char(&actions);
assert_eq!(palette.text(), "onetwo");
palette.move_cursor(Motion::LineEnd);
palette.move_cursor(Motion::LineStart);
palette.delete_previous_word(&actions);
assert_eq!(
palette.text(),
"onetwo",
"`Ctrl+W` at the start of the line has nothing before the caret to cut"
);
}
#[test]
fn a_live_confirm_gate_sets_no_caret_at_all() {
use ratatui::{Terminal, backend::TestBackend};
let actions = vec![action("reinstall", true)];
let mut palette = ActionPalette::new();
palette.choose(&actions, 12);
let backend = TestBackend::new(40, 10);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
palette.draw(
frame,
frame.area(),
&Theme::default(),
Run {
actions: &actions,
count: Count::unnarrowed(RunScope::CheckedRows, 12),
management_lines: &[],
bindings: &BINDINGS_FOR_TESTS,
},
&crate::glyphs::FULL,
)
})
.expect("draw the frame");
assert!(
!terminal.backend().cursor_visible(),
"a confirm gate has no text field, so no caret must be set"
);
}
#[test]
fn the_cursor_rows_highlight_covers_every_cell_of_its_full_interior_width_and_no_other_row() {
use ratatui::{Terminal, backend::TestBackend};
let actions = vec![action("reinstall", true), action("deploy", true)];
let mut palette = ActionPalette::new();
palette.move_highlight(1, &actions);
let interior = crate::glyphs::bordered_interior(Rect::new(0, 0, 40, 10));
let backend = TestBackend::new(40, 10);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
palette.draw(
frame,
frame.area(),
&Theme::default(),
Run {
actions: &actions,
count: Count::unnarrowed(RunScope::CheckedRows, 2),
management_lines: &[],
bindings: &BINDINGS_FOR_TESTS,
},
&crate::glyphs::FULL,
);
})
.expect("draw the frame");
let buf = terminal.backend().buffer();
for x in interior.x..interior.right() {
assert!(
buf[(x, interior.y + 2)]
.modifier
.contains(ratatui::style::Modifier::REVERSED),
"cursor row cell at x={x} must be reversed, not just the cells with text"
);
}
for row in [interior.y, interior.y + 1] {
for x in interior.x..interior.right() {
assert!(
!buf[(x, row)]
.modifier
.contains(ratatui::style::Modifier::REVERSED),
"row at y={row} is not the cursor row and must not be reversed"
);
}
}
let row_text: String = (interior.x..interior.right())
.map(|x| buf[(x, interior.y + 2)].symbol().to_string())
.collect();
assert!(
row_text.starts_with("> deploy"),
"the `> ` marker must survive inside the reversed bar, got {row_text:?}"
);
}
#[test]
fn draw_in_stage_confirming_shows_the_actions_md_confirm_sentence() {
use ratatui::{Terminal, backend::TestBackend};
let theme = Theme::default();
let actions = vec![action("reinstall", true)];
let mut palette = ActionPalette::new();
palette.choose(&actions, 12);
let backend = TestBackend::new(40, 10);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
palette.draw(
frame,
frame.area(),
&theme,
Run {
actions: &actions,
count: Count::unnarrowed(RunScope::CheckedRows, 12),
management_lines: &[],
bindings: &BINDINGS_FOR_TESTS,
},
&crate::glyphs::FULL,
);
})
.expect("draw the frame");
let buf = terminal.backend().buffer();
let row_text =
|y: u16| -> String { (0..40).map(|x| buf[(x, y)].symbol().to_string()).collect() };
assert!(
row_text(1).contains("run \"reinstall\" on 12 selected?"),
"expected actions.md's own confirm sentence, got: {:?}",
row_text(1)
);
}
#[test]
fn draw_in_stage_confirming_over_a_narrowed_entry_asks_about_the_applicable_count() {
use ratatui::{Terminal, backend::TestBackend};
let theme = Theme::default();
let actions = vec![action("reinstall", true)];
let mut palette = ActionPalette::new();
palette.choose(&actions, 8);
let backend = TestBackend::new(40, 10);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
palette.draw(
frame,
frame.area(),
&theme,
Run {
actions: &actions,
count: narrowed(8, 3, 1),
management_lines: &[],
bindings: &BINDINGS_FOR_TESTS,
},
&crate::glyphs::FULL,
);
})
.expect("draw the frame");
let buf = terminal.backend().buffer();
let row_text =
|y: u16| -> String { (0..40).map(|x| buf[(x, y)].symbol().to_string()).collect() };
assert!(
row_text(1).contains("run \"reinstall\" on 8 of 12 selected?"),
"expected the confirm gate to name the applicable count against the total it \
was narrowed from, got: {:?}",
row_text(1)
);
assert!(
!row_text(1).contains("on 12 selected?"),
"the operable total must never be what the gate asks to run, only what it \
narrowed from, got: {:?}",
row_text(1)
);
}
#[test]
fn the_refusal_to_merge_the_two_palettes_and_its_reason_are_recorded_in_this_module() {
let source = crate::test_support::production_source_at(
&std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/action_palette.rs"),
);
assert!(
source.contains("reopen the exact failure the split exists to prevent"),
"expected this module's own doc comment to record the refusal to merge"
);
assert!(
source.contains("run this across 99 repos"),
"expected this module's own doc comment to name the specific failure mode \
merging would reopen, not merely gesture at ADR 0008"
);
}
#[test]
fn the_action_palette_reuses_an_existing_role_rather_than_a_new_tenth_one() {
assert_eq!(
Role::ALL.len(),
9,
"the Action palette's border must be one of theming.md's existing nine roles"
);
}
#[test]
fn a_gate_taller_than_the_palette_keeps_its_no_undo_sentence_its_hint_and_a_count() {
use ratatui::{Terminal, backend::TestBackend};
let mut lines = vec!["delete on 25 selected?".to_string()];
lines.extend((0..25).map(|nth| format!("repo-{nth}: uncommitted changes")));
lines.push(crate::management::NO_UNDO.to_string());
let mut palette = ActionPalette::management();
palette.choose(&[], 25);
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
palette.draw(
frame,
frame.area(),
&crate::theme::DEFAULT,
Run {
actions: &[],
count: Count::unnarrowed(RunScope::CheckedRows, 25),
management_lines: &lines,
bindings: &BINDINGS_FOR_TESTS,
},
&crate::glyphs::FULL,
);
})
.expect("draw the frame");
let buf = terminal.backend().buffer();
let rendered: String = (0..24)
.map(|y| {
(0..80)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered.contains("delete on 25 selected?"),
"the headline's own count survives, got:\n{rendered}"
);
assert!(
rendered.contains(crate::management::NO_UNDO),
"the gate must say there is no undo and no trash in as many words, got:\n{rendered}"
);
assert!(
rendered.contains(CONFIRM_HINT),
"and must say how to answer it, got:\n{rendered}"
);
assert!(
rendered.contains("more not shown"),
"the rows that did not fit are counted rather than dropped silently, got:\n\
{rendered}"
);
}
#[test]
fn fit_confirm_rows_elides_only_once_the_lines_outnumber_the_rows() {
let lines: Vec<String> = (0..6).map(|nth| format!("line-{nth}")).collect();
assert_eq!(
fit_confirm_rows(&lines, 6),
lines,
"exactly as many rows as lines is not a truncation"
);
let fitted = fit_confirm_rows(&lines, 5);
assert_eq!(
fitted,
vec![
"line-0".to_string(),
"line-1".to_string(),
"line-2".to_string(),
elided_line(2),
"line-5".to_string(),
],
"one row short keeps both ends and counts what it dropped"
);
assert_eq!(fitted.len(), 5, "and fills the rows it was given exactly");
}
#[test]
fn a_line_longer_than_the_interior_never_paints_over_the_right_border() {
use ratatui::{Terminal, backend::TestBackend};
let long = "repo-a: uncommitted changes, 12 commits unpushed on 3 branches, 2 linked \
worktrees"
.to_string();
let lines = vec![
"delete on 1 at the cursor?".to_string(),
long,
"no undo".to_string(),
];
let mut palette = ActionPalette::management();
palette.choose(&[], 1);
let backend = TestBackend::new(40, 10);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
palette.draw(
frame,
frame.area(),
&crate::theme::DEFAULT,
Run {
actions: &[],
count: Count::unnarrowed(RunScope::CheckedRows, 1),
management_lines: &lines,
bindings: &BINDINGS_FOR_TESTS,
},
&crate::glyphs::FULL,
);
})
.expect("draw the frame");
let buf = terminal.backend().buffer();
for y in 1..9 {
assert_eq!(
buf[(39, y)].symbol(),
"\u{2502}",
"row {y}'s right border column must still hold the border, got {:?}",
(0..40)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
);
}
}
#[test]
fn stripped_of_colour_the_highlighted_row_is_still_distinguishable_by_its_own_marker() {
use ratatui::{Terminal, backend::TestBackend};
let monochrome = Theme {
text: ratatui::style::Color::White,
dim: ratatui::style::Color::White,
accent: ratatui::style::Color::White,
ok: ratatui::style::Color::White,
warn: ratatui::style::Color::White,
danger: ratatui::style::Color::White,
behind: ratatui::style::Color::White,
border: ratatui::style::Color::White,
border_focused: ratatui::style::Color::White,
selection_bg: None,
selection_fg: None,
};
let actions = vec![action("reinstall", true), action("deploy", true)];
let mut palette = ActionPalette::new();
palette.move_highlight(1, &actions);
let backend = TestBackend::new(40, 10);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
palette.draw(
frame,
frame.area(),
&monochrome,
Run {
actions: &actions,
count: Count::unnarrowed(RunScope::CheckedRows, 2),
management_lines: &[],
bindings: &BINDINGS_FOR_TESTS,
},
&crate::glyphs::FULL,
);
})
.expect("draw the frame");
let buf = terminal.backend().buffer();
let row_text =
|y: u16| -> String { (0..40).map(|x| buf[(x, y)].symbol().to_string()).collect() };
assert!(
row_text(1).contains(QUERY_PLACEHOLDER),
"the placeholder must still read as text even with every role identical: {:?}",
row_text(1)
);
assert!(
row_text(3).contains("> deploy"),
"with every colour identical, the highlighted row must still read as \
highlighted from its text alone: {:?}",
row_text(3)
);
assert!(
!row_text(2).contains('>'),
"and the non-highlighted row must still read as not highlighted: {:?}",
row_text(2)
);
assert!(
buf.area.width > 0,
"sanity: the border itself still drew something even with every role identical"
);
}
#[test]
fn the_glossarys_action_entry_no_longer_promises_a_per_repo_defining_count() {
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let glossary = std::fs::read_to_string(manifest_dir.join("../../GLOSSARY.md"))
.expect("read GLOSSARY.md");
let entry = glossary
.split("**Action**:")
.nth(1)
.and_then(|rest| rest.split("**Action spec**:").next())
.expect("GLOSSARY.md still carries an Action glossary entry");
assert!(
!entry.to_lowercase().contains("define"),
"GLOSSARY.md's Action entry must not promise a per-Repo \"defines it\" count \
the `[[action]]` schema cannot compute, got: {entry:?}"
);
}
#[test]
fn actions_md_records_the_settled_answer_for_per_repo_applicability() {
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let actions_md = std::fs::read_to_string(manifest_dir.join("../../docs/spec/actions.md"))
.expect("read docs/spec/actions.md");
assert!(
actions_md.contains("Per-Repo applicability"),
"expected actions.md to still name the requirement"
);
assert!(
actions_md.contains("Filter grammar"),
"expected actions.md to name the Filter grammar as where applicability comes from"
);
assert!(
!actions_md.contains("stays open rather than settled as never"),
"actions.md still records applicability as an open want, which it no longer is"
);
let register = std::fs::read_to_string(manifest_dir.join("../../docs/open-questions.md"))
.expect("read docs/open-questions.md");
assert!(
!register.contains("## Per-Repo Action applicability"),
"the register keeps an entry its owning document has now answered"
);
}
#[test]
fn no_per_repo_action_defining_count_is_computed_or_faked_anywhere_in_either_crate() {
for needle in [
"repos_defining",
"defining_repos",
"applicable_repos",
"defines_action",
"action_applicability",
] {
let offending = crate::test_support::production_lines_containing(needle);
assert!(
offending.is_empty(),
"found `{needle}`; the per-Repo Action-defining count is a dropped \
requirement (docs/spec/actions.md's \"Not built\"), never smuggled back in \
as a palette annotation, at: {offending:?}"
);
}
}
fn row_text(buf: &ratatui::buffer::Buffer, y: u16, width: u16) -> String {
(0..width)
.map(|x| buf[(x, y)].symbol().to_string())
.collect()
}
fn all_rows(buf: &ratatui::buffer::Buffer) -> String {
(0..10).map(|y| row_text(buf, y, 40)).collect()
}
fn draw_to_buffer(
palette: &ActionPalette,
actions: &[ActionConfig],
theme: &Theme,
count: Count,
) -> ratatui::buffer::Buffer {
draw_to_buffer_sized(palette, actions, theme, count, 40, 10)
}
fn draw_sized(
palette: &ActionPalette,
actions: &[ActionConfig],
theme: &Theme,
width: u16,
height: u16,
) -> ratatui::buffer::Buffer {
draw_to_buffer_sized(
palette,
actions,
theme,
Count::unnarrowed(RunScope::CheckedRows, 3),
width,
height,
)
}
fn type_lines(palette: &mut ActionPalette, actions: &[ActionConfig], count: usize) {
for index in 0..count {
if index > 0 {
palette.insert_newline(actions);
}
for c in format!("line{index}").chars() {
palette.type_char(c, actions);
}
}
}
fn draw_to_buffer_sized(
palette: &ActionPalette,
actions: &[ActionConfig],
theme: &Theme,
count: Count,
width: u16,
height: u16,
) -> ratatui::buffer::Buffer {
use ratatui::{Terminal, backend::TestBackend};
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
palette.draw(
frame,
frame.area(),
theme,
Run {
actions,
count,
management_lines: &[],
bindings: &BINDINGS_FOR_TESTS,
},
&crate::glyphs::FULL,
)
})
.expect("draw the frame");
terminal.backend().buffer().clone()
}
#[test]
fn the_typed_query_is_visible_and_updates_as_characters_are_added_and_removed() {
let actions = vec![action("reinstall", true), action("deploy", true)];
let mut palette = ActionPalette::new();
let theme = Theme::default();
let empty = draw_to_buffer(
&palette,
&actions,
&theme,
Count::unnarrowed(RunScope::CheckedRows, 3),
);
assert!(
!row_text(&empty, 1, 40).contains("zzq"),
"an unopened query must not already show text nobody typed"
);
assert!(
row_text(&empty, 1, 40).contains(QUERY_PLACEHOLDER),
"expected the placeholder on the empty query row: {:?}",
row_text(&empty, 1, 40)
);
assert_eq!(
empty[(1, 1)].fg,
theme.dim,
"the placeholder must paint in the dim role"
);
for c in "zzq".chars() {
palette.type_char(c, &actions);
}
let typed = draw_to_buffer(
&palette,
&actions,
&theme,
Count::unnarrowed(RunScope::CheckedRows, 3),
);
assert!(
row_text(&typed, 1, 40).contains("zzq"),
"expected the typed query on the interior's first row: {:?}",
row_text(&typed, 1, 40)
);
assert!(
!row_text(&typed, 1, 40).contains("select action or type a command"),
"the placeholder must not linger once there is typed text: {:?}",
row_text(&typed, 1, 40)
);
assert_eq!(
typed[(1, 1)].fg,
theme.text,
"typed text must paint in the text role, not dim"
);
palette.delete_previous_word(&actions);
let cleared = draw_to_buffer(
&palette,
&actions,
&theme,
Count::unnarrowed(RunScope::CheckedRows, 3),
);
assert!(
!row_text(&cleared, 1, 40).contains("zzq"),
"removing the typed characters must remove them from the query row too: {:?}",
row_text(&cleared, 1, 40)
);
assert!(
row_text(&cleared, 1, 40).contains(QUERY_PLACEHOLDER),
"the placeholder must return once the query is emptied again: {:?}",
row_text(&cleared, 1, 40)
);
}
#[test]
fn the_query_placeholder_names_both_choosing_an_action_and_typing_a_command() {
assert!(
QUERY_PLACEHOLDER.contains("select action"),
"the placeholder must name choosing an Action, in the verb-then-object shape its \
two siblings share: {QUERY_PLACEHOLDER:?}"
);
assert!(
QUERY_PLACEHOLDER.contains("command"),
"the placeholder must also name the ad hoc command the field accepts: \
{QUERY_PLACEHOLDER:?}"
);
}
#[test]
fn the_query_placeholder_reads_whole_at_the_narrow_screen_width() {
const NARROW_SCREEN_WIDTH: u16 = 88;
let actions = vec![action("reinstall", true)];
let palette = ActionPalette::new();
let buf = draw_to_buffer_sized(
&palette,
&actions,
&Theme::default(),
Count::unnarrowed(RunScope::CheckedRows, 3),
NARROW_SCREEN_WIDTH,
10,
);
assert!(
row_text(&buf, 1, NARROW_SCREEN_WIDTH).contains(QUERY_PLACEHOLDER),
"expected the whole placeholder on the query row at {NARROW_SCREEN_WIDTH} \
columns: {:?}",
row_text(&buf, 1, NARROW_SCREEN_WIDTH)
);
}
#[test]
fn a_query_matching_no_action_says_so_without_leaving_stale_rows() {
let actions = vec![action("reinstall", true), action("deploy", true)];
let mut palette = ActionPalette::new();
palette.type_char(' ', &actions);
palette.type_char(' ', &actions);
let buf = draw_to_buffer(
&palette,
&actions,
&Theme::default(),
Count::unnarrowed(RunScope::CheckedRows, 3),
);
assert!(
row_text(&buf, 2, 40).contains(NO_MATCHES_MESSAGE),
"expected the no-matches message, got: {:?}",
row_text(&buf, 2, 40)
);
for name in ["reinstall", "deploy"] {
assert!(
!row_text(&buf, 2, 40).contains(name) && !row_text(&buf, 3, 40).contains(name),
"a no-matches render must not also list a stale row for {name:?}"
);
}
}
#[test]
fn no_actions_configured_at_all_says_so_and_names_where_to_declare_one() {
let palette = ActionPalette::new();
let buf = draw_to_buffer(
&palette,
&[],
&Theme::default(),
Count::unnarrowed(RunScope::CheckedRows, 0),
);
assert!(
all_rows(&buf).contains(NO_ACTIONS_CONFIGURED_MESSAGE),
"expected the nothing-configured message naming `[[action]]`, got: {:?}",
all_rows(&buf)
);
}
#[test]
fn no_matches_and_nothing_configured_render_differently_from_each_other() {
let theme = Theme::default();
let some_actions = vec![action("reinstall", true)];
let mut no_match = ActionPalette::new();
no_match.type_char(' ', &some_actions);
no_match.type_char(' ', &some_actions);
let nothing_configured = ActionPalette::new();
let no_match_buf = draw_to_buffer(
&no_match,
&some_actions,
&theme,
Count::unnarrowed(RunScope::CheckedRows, 3),
);
let nothing_configured_buf = draw_to_buffer(
¬hing_configured,
&[],
&theme,
Count::unnarrowed(RunScope::CheckedRows, 0),
);
assert_ne!(
all_rows(&no_match_buf),
all_rows(¬hing_configured_buf),
"a query matching nothing and an empty Action list must render differently"
);
}
#[test]
fn the_three_states_matches_no_matches_and_nothing_configured_are_pairwise_distinct() {
let theme = Theme::default();
let actions = vec![action("reinstall", true)];
let matching_state = draw_to_buffer(
&ActionPalette::new(),
&actions,
&theme,
Count::unnarrowed(RunScope::CheckedRows, 3),
);
let mut no_match = ActionPalette::new();
no_match.type_char(' ', &actions);
no_match.type_char(' ', &actions);
let no_match_state = draw_to_buffer(
&no_match,
&actions,
&theme,
Count::unnarrowed(RunScope::CheckedRows, 3),
);
let nothing_configured_state = draw_to_buffer(
&ActionPalette::new(),
&[],
&theme,
Count::unnarrowed(RunScope::CheckedRows, 0),
);
assert!(
all_rows(&matching_state).contains("reinstall"),
"the matches state must list the configured entry"
);
assert!(all_rows(&no_match_state).contains(NO_MATCHES_MESSAGE));
assert!(all_rows(¬hing_configured_state).contains(NO_ACTIONS_CONFIGURED_MESSAGE));
assert_ne!(all_rows(&matching_state), all_rows(&no_match_state));
assert_ne!(
all_rows(&matching_state),
all_rows(¬hing_configured_state)
);
assert_ne!(
all_rows(&no_match_state),
all_rows(¬hing_configured_state)
);
}
#[test]
fn a_query_matching_no_configured_entry_that_still_runs_through_shell_shows_the_runs_as_command_message_not_no_matches()
{
let actions = vec![action("reinstall", true), action("deploy", true)];
let mut palette = ActionPalette::new();
for c in "zzq".chars() {
palette.type_char(c, &actions);
}
let buf = draw_to_buffer(
&palette,
&actions,
&Theme::default(),
Count::unnarrowed(RunScope::CheckedRows, 3),
);
assert!(
row_text(&buf, 2, 40).contains(RUNS_AS_COMMAND_MESSAGE),
"shell mode is on by default, so \"zzq\" is a runnable ad hoc command and Enter \
is about to run it: expected the runs-as-command message, got: {:?}",
row_text(&buf, 2, 40)
);
assert!(
!row_text(&buf, 2, 40).contains(NO_MATCHES_MESSAGE),
"the stale no-matches wording must not also appear: {:?}",
row_text(&buf, 2, 40)
);
}
#[test]
fn a_blank_or_whitespace_only_query_still_shows_no_matches_since_enter_does_nothing() {
let actions = vec![action("reinstall", true)];
let mut palette = ActionPalette::new();
palette.type_char(' ', &actions);
palette.type_char(' ', &actions);
let buf = draw_to_buffer(
&palette,
&actions,
&Theme::default(),
Count::unnarrowed(RunScope::CheckedRows, 3),
);
assert!(
row_text(&buf, 2, 40).contains(NO_MATCHES_MESSAGE),
"whitespace-only text contributes no ad hoc step, so Enter does nothing and the \
row must keep the true no-matches wording: {:?}",
row_text(&buf, 2, 40)
);
}
#[test]
fn with_shell_off_a_query_that_fails_to_word_split_still_shows_no_matches_since_enter_refuses_it()
{
let actions = vec![action("reinstall", true)];
let mut palette = ActionPalette::new();
palette.toggle_shell();
for c in "echo \"unterminated".chars() {
palette.type_char(c, &actions);
}
let buf = draw_to_buffer(
&palette,
&actions,
&Theme::default(),
Count::unnarrowed(RunScope::CheckedRows, 3),
);
assert!(
row_text(&buf, 2, 40).contains(NO_MATCHES_MESSAGE),
"an unterminated quote fails to word-split with shell off, so Enter refuses the \
whole command and the row must keep the true no-matches wording: {:?}",
row_text(&buf, 2, 40)
);
assert!(
shell_words::split("echo \"unterminated").is_err(),
"the fixture must independently fail to word-split, not merely match what \
ad_hoc_steps happens to encode"
);
}
#[test]
fn the_runs_as_command_message_and_the_no_matches_message_and_nothing_configured_are_pairwise_distinct()
{
let theme = Theme::default();
let actions = vec![action("reinstall", true)];
let mut runs_as_command = ActionPalette::new();
for c in "zzq".chars() {
runs_as_command.type_char(c, &actions);
}
let runs_as_command_state = draw_to_buffer(
&runs_as_command,
&actions,
&theme,
Count::unnarrowed(RunScope::CheckedRows, 3),
);
let mut no_match = ActionPalette::new();
no_match.type_char(' ', &actions);
no_match.type_char(' ', &actions);
let no_match_state = draw_to_buffer(
&no_match,
&actions,
&theme,
Count::unnarrowed(RunScope::CheckedRows, 3),
);
let nothing_configured_state = draw_to_buffer(
&ActionPalette::new(),
&[],
&theme,
Count::unnarrowed(RunScope::CheckedRows, 0),
);
assert!(all_rows(&runs_as_command_state).contains(RUNS_AS_COMMAND_MESSAGE));
assert!(all_rows(&no_match_state).contains(NO_MATCHES_MESSAGE));
assert!(all_rows(¬hing_configured_state).contains(NO_ACTIONS_CONFIGURED_MESSAGE));
assert_ne!(all_rows(&runs_as_command_state), all_rows(&no_match_state));
assert_ne!(
all_rows(&runs_as_command_state),
all_rows(¬hing_configured_state)
);
assert_ne!(
all_rows(&no_match_state),
all_rows(¬hing_configured_state)
);
}
#[test]
fn a_query_showing_the_runs_as_command_message_actually_opens_the_confirm_gate_on_it_when_chosen()
{
let actions = vec![action("reinstall", true), action("deploy", true)];
let mut palette = ActionPalette::new();
for c in "zzq".chars() {
palette.type_char(c, &actions);
}
let buf = draw_to_buffer(
&palette,
&actions,
&Theme::default(),
Count::unnarrowed(RunScope::CheckedRows, 3),
);
assert!(row_text(&buf, 2, 40).contains(RUNS_AS_COMMAND_MESSAGE));
let decision = palette.choose(&actions, 5);
assert!(
matches!(decision, Some(Decision::NeedsConfirm)),
"the row promising Enter runs this as a command must be backed by choose() \
actually opening the confirm gate on it, got {decision:?}"
);
let spec = palette
.confirm_run()
.expect("the confirm gate must carry the ad hoc ActionSpec the row promised");
assert_eq!(spec.steps[0].argv, vec!["zzq".to_string()]);
}
#[test]
fn a_query_showing_no_matches_because_it_is_blank_or_whitespace_only_actually_does_nothing_when_chosen()
{
let actions = vec![action("reinstall", true)];
let mut palette = ActionPalette::new();
palette.type_char(' ', &actions);
palette.type_char(' ', &actions);
let buf = draw_to_buffer(
&palette,
&actions,
&Theme::default(),
Count::unnarrowed(RunScope::CheckedRows, 3),
);
assert!(row_text(&buf, 2, 40).contains(NO_MATCHES_MESSAGE));
let decision = palette.choose(&actions, 5);
assert!(
decision.is_none(),
"the row promising no matches must be backed by choose() actually doing \
nothing, got {decision:?}"
);
assert!(matches!(palette.stage(), Stage::Choosing));
}
#[test]
fn a_query_showing_no_matches_because_shell_off_fails_to_word_split_actually_does_nothing_when_chosen()
{
let actions = vec![action("reinstall", true)];
let mut palette = ActionPalette::new();
palette.toggle_shell();
for c in "echo \"unterminated".chars() {
palette.type_char(c, &actions);
}
let buf = draw_to_buffer(
&palette,
&actions,
&Theme::default(),
Count::unnarrowed(RunScope::CheckedRows, 3),
);
assert!(row_text(&buf, 2, 40).contains(NO_MATCHES_MESSAGE));
let decision = palette.choose(&actions, 5);
assert!(
decision.is_none(),
"the row promising no matches must be backed by choose() actually refusing the \
unparsable command, got {decision:?}"
);
assert!(matches!(palette.stage(), Stage::Choosing));
}
#[test]
fn a_query_that_still_matches_a_configured_action_or_built_in_never_shows_the_runs_as_command_message()
{
let theme = Theme::default();
let actions = vec![action("reinstall", true)];
let mut matches_action = ActionPalette::new();
matches_action.type_char('r', &actions);
let matches_action_buf = draw_to_buffer(
&matches_action,
&actions,
&theme,
Count::unnarrowed(RunScope::CheckedRows, 3),
);
assert!(
!all_rows(&matches_action_buf).contains(RUNS_AS_COMMAND_MESSAGE),
"a query still matching the configured Action must never show the \
runs-as-command message: {:?}",
all_rows(&matches_action_buf)
);
let mut matches_builtin = ActionPalette::new();
matches_builtin.type_char('d', &actions); let matches_builtin_buf = draw_to_buffer(
&matches_builtin,
&actions,
&theme,
Count::unnarrowed(RunScope::CheckedRows, 3),
);
assert!(
!all_rows(&matches_builtin_buf).contains(RUNS_AS_COMMAND_MESSAGE),
"a query still matching a built-in must never show the runs-as-command message: \
{:?}",
all_rows(&matches_builtin_buf)
);
}
#[test]
fn clearing_the_query_restores_the_full_list_on_screen() {
let actions = vec![action("reinstall", true), action("deploy", true)];
let mut palette = ActionPalette::new();
palette.type_char('r', &actions);
let narrowed = draw_to_buffer(
&palette,
&actions,
&Theme::default(),
Count::unnarrowed(RunScope::CheckedRows, 3),
);
assert!(!row_text(&narrowed, 2, 40).contains("deploy"));
palette.clear_line(&actions);
let restored = draw_to_buffer(
&palette,
&actions,
&Theme::default(),
Count::unnarrowed(RunScope::CheckedRows, 3),
);
assert!(row_text(&restored, 2, 40).contains("reinstall"));
assert!(row_text(&restored, 3, 40).contains("deploy"));
}
#[test]
fn the_no_matches_message_and_its_placement_read_the_same_way_in_both_palettes() {
use crate::launcher_palette::{LauncherPalette, NO_MATCHES_MESSAGE as LAUNCHER_NO_MATCHES};
assert_eq!(
NO_MATCHES_MESSAGE, LAUNCHER_NO_MATCHES,
"the two palettes must use identical no-matches wording"
);
let theme = Theme::default();
let launchers = vec![crate::launcher::Launcher {
name: "lazygit".to_string(),
source: crate::launcher::Source::Args(vec!["true".to_string()]),
shell: false,
interactive: false,
takes_terminal: true,
env: std::collections::BTreeMap::new(),
}];
let mut launcher_palette = LauncherPalette::new();
launcher_palette.type_char('z', &launchers);
launcher_palette.type_char('z', &launchers);
let actions = vec![action("reinstall", true)];
let mut action_palette = ActionPalette::new();
action_palette.type_char(' ', &actions);
action_palette.type_char(' ', &actions);
use ratatui::{Terminal, backend::TestBackend};
let backend = TestBackend::new(40, 10);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
launcher_palette.draw(
frame,
frame.area(),
&theme,
&launchers,
"repo-a",
&crate::glyphs::FULL,
)
})
.expect("draw the launcher palette");
let launcher_buf = terminal.backend().buffer().clone();
let launcher_popup =
launcher_palette.popup_area(Rect::new(0, 0, 40, 10), &launchers, "repo-a");
let launcher_message_row = launcher_popup.y + 2;
let action_buf = draw_to_buffer(
&action_palette,
&actions,
&theme,
Count::unnarrowed(RunScope::CheckedRows, 3),
);
let action_message_row = 2;
assert!(
row_text(&launcher_buf, launcher_message_row, 40).contains(NO_MATCHES_MESSAGE),
"expected the Launcher palette's own no-matches row to read the message"
);
assert!(
row_text(&action_buf, action_message_row, 40).contains(NO_MATCHES_MESSAGE),
"expected the Action palette's own no-matches row to read the message"
);
}
#[test]
fn no_production_code_measures_a_caret_column_with_unicode_width_str() {
let offending = crate::test_support::production_lines_containing("UnicodeWidthStr");
assert!(
offending.is_empty(),
"expected every caret to be read back from its own paint, the way \
`filter_line.rs` already does, found `UnicodeWidthStr` still measuring one \
separately at: {offending:?}"
);
}
#[test]
fn no_caret_position_adds_a_literal_prefix_width_to_a_separately_measured_query_width() {
let offending = crate::test_support::production_lines_containing("+ 2 + ");
assert!(
offending.is_empty(),
"expected no caret column built from a literal prefix width plus a separately \
measured query width, found: {offending:?}"
);
}
#[test]
fn unicode_width_is_a_normal_dependency_only_if_production_code_still_uses_it() {
let offending = crate::test_support::production_lines_containing("unicode_width");
let manifest = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"),
)
.expect("read this crate's own Cargo.toml");
let dependencies_start = manifest
.find("[dependencies]")
.expect("this crate's manifest to declare a [dependencies] table");
let dev_dependencies_start = manifest
.find("[dev-dependencies]")
.expect("this crate's manifest to declare a [dev-dependencies] table");
assert!(
dev_dependencies_start > dependencies_start,
"expected [dev-dependencies] to follow [dependencies] in Cargo.toml"
);
let dependencies_section = &manifest[dependencies_start..dev_dependencies_start];
let dev_dependencies_section = &manifest[dev_dependencies_start..];
if offending.is_empty() {
assert!(
!dependencies_section.contains("unicode-width"),
"no production code uses unicode-width any more, so it must not sit in \
[dependencies]: {dependencies_section}"
);
assert!(
dev_dependencies_section.contains("unicode-width"),
"no production code uses unicode-width any more, so it must sit in \
[dev-dependencies] instead: {dev_dependencies_section}"
);
} else {
assert!(
dependencies_section.contains("unicode-width"),
"production code still uses unicode-width ({offending:?}), so it must stay a \
normal dependency: {dependencies_section}"
);
}
}
}