use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Widget;
use super::BookmarkAssignment;
use super::bookmark_gen;
use super::graph_layout::LayoutNode;
use super::tfidf;
use crate::jj::types::Signature;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputMode {
Normal,
Editing,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CustomNameState {
Loading,
Ready(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TfidfNameState {
pub name: String,
pub variation: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RowState {
UseExisting(usize),
UseGenerated,
UseTfidf(TfidfNameState),
UseCustom(CustomNameState),
UserInput(String),
Unchecked,
}
#[derive(Debug, Clone)]
pub struct BookmarkRow {
pub change_id: String,
pub short_change_id: String,
pub commit_id: String,
pub summary: String,
pub description: String,
pub existing_bookmarks: Vec<String>,
pub state: RowState,
pub generated_name: Option<String>,
pub custom_name: Option<String>,
pub tfidf_name: Option<(String, usize)>,
pub user_input_name: Option<String>,
pub existing_bookmark_idx: usize,
pub is_trunk: bool,
pub author: Signature,
pub files: Vec<String>,
pub has_bookmark_command: bool,
}
impl BookmarkRow {
#[cfg_attr(not(test), expect(dead_code, reason = "used in tests for validation"))]
pub fn effective_name(&self) -> Option<&str> {
if self.is_trunk {
return None;
}
match &self.state {
RowState::UseExisting(idx) => self.existing_bookmarks.get(*idx).map(String::as_str),
RowState::UseGenerated => self.generated_name.as_deref(),
RowState::UseTfidf(ts) => Some(ts.name.as_str()),
RowState::UseCustom(CustomNameState::Ready(name)) => Some(name.as_str()),
RowState::UserInput(s) if !s.is_empty() => Some(s.as_str()),
RowState::UserInput(_)
| RowState::UseCustom(CustomNameState::Loading)
| RowState::Unchecked => None,
}
}
}
#[derive(Debug)]
pub enum SelectionError {
DuplicateName(String),
StillLoading,
InvalidName(String),
}
#[derive(Debug, PartialEq, Eq)]
pub enum VaryResult {
Noop,
ExistingCycled,
TfidfCycled,
TfidfNoVariation,
NeedsRefire,
}
fn make_use_custom(row: &BookmarkRow) -> RowState {
match &row.custom_name {
Some(name) => RowState::UseCustom(CustomNameState::Ready(name.clone())),
None => RowState::UseCustom(CustomNameState::Loading),
}
}
fn compute_tfidf_for_segment(
rows: &[BookmarkRow],
row_idx: usize,
variation: usize,
auto_prefix: Option<&str>,
) -> Option<String> {
let segment = bookmark_gen::dynamic_segment_commits(rows, row_idx);
let commit_data: Vec<tfidf::CommitData<'_>> = segment
.iter()
.map(|r| tfidf::CommitData {
description: &r.description,
files: &r.files,
})
.collect();
let prefix_len = auto_prefix.map_or(0, str::len);
let max_length = bookmark_gen::MAX_BOOKMARK_LENGTH.saturating_sub(prefix_len);
let name = tfidf::tfidf_bookmark_name(
&commit_data,
3,
variation,
max_length,
bookmark_gen::DISALLOWED_CHARS,
)?;
match auto_prefix {
Some(prefix) => Some(format!("{prefix}{name}")),
None => Some(name),
}
}
#[derive(Debug)]
pub struct BookmarkAssignmentState {
pub rows: Vec<BookmarkRow>,
pub cursor: usize,
auto_prefix: Option<String>,
pub input_mode: InputMode,
}
impl BookmarkAssignmentState {
pub fn from_path(
path: &[&LayoutNode],
has_bookmark_command: bool,
auto_prefix: Option<&str>,
) -> Self {
let rows: Vec<BookmarkRow> = path
.iter()
.map(|node| {
let existing_bookmarks = node.bookmark_names.clone();
let generated_name = if node.is_trunk {
None
} else {
Some(bookmark_gen::default_bookmark_name(&node.change_id))
};
let state = if existing_bookmarks.is_empty() {
RowState::Unchecked
} else {
RowState::UseExisting(0)
};
BookmarkRow {
change_id: node.change_id.clone(),
short_change_id: node.short_change_id.clone(),
commit_id: node.commit_id.clone(),
summary: node.summary.clone(),
description: node.description.clone(),
existing_bookmarks,
state,
generated_name,
custom_name: None,
tfidf_name: None,
user_input_name: None,
is_trunk: node.is_trunk,
author: node.author.clone(),
files: node.files.clone(),
has_bookmark_command,
existing_bookmark_idx: 0,
}
})
.collect();
let cursor = rows.iter().position(|r| !r.is_trunk).unwrap_or(0);
Self {
rows,
cursor,
auto_prefix: auto_prefix.map(String::from),
input_mode: InputMode::Normal,
}
}
pub fn toggle_current(&mut self) {
let cursor = self.cursor;
let Some(row) = self.rows.get(cursor) else {
return;
};
if row.is_trunk {
return;
}
let has_distinct_generated = match &row.generated_name {
Some(generated) => !row.existing_bookmarks.iter().any(|e| e == generated),
None => false,
};
let has_distinct_custom = row.has_bookmark_command
&& match &row.custom_name {
Some(custom) => {
let matches_generated = row.generated_name.as_ref() == Some(custom);
let matches_existing = row.existing_bookmarks.iter().any(|e| e == custom);
!matches_generated && !matches_existing
}
None => true,
};
let current_state = row.state.clone();
let next = match ¤t_state {
RowState::UseExisting(idx) => {
self.rows[cursor].existing_bookmark_idx = *idx;
self.next_after_existing(cursor)
}
RowState::UseTfidf(_) => self.next_after_tfidf(cursor),
RowState::UserInput(text) => {
self.rows[cursor].user_input_name = Some(text.clone());
self.next_after_user_input(cursor, has_distinct_generated, has_distinct_custom)
}
RowState::UseGenerated => {
if has_distinct_custom {
make_use_custom(&self.rows[cursor])
} else {
RowState::Unchecked
}
}
RowState::UseCustom(_) => RowState::Unchecked,
RowState::Unchecked => {
if row.existing_bookmarks.is_empty() {
self.next_after_existing(cursor)
} else {
RowState::UseExisting(row.existing_bookmark_idx)
}
}
};
self.rows[cursor].state = next;
self.refresh_tfidf_names();
}
pub fn toggle_current_reverse(&mut self) {
let cursor = self.cursor;
let Some(row) = self.rows.get(cursor) else {
return;
};
if row.is_trunk {
return;
}
let has_distinct_generated = match &row.generated_name {
Some(generated) => !row.existing_bookmarks.iter().any(|e| e == generated),
None => false,
};
let has_distinct_custom = row.has_bookmark_command
&& match &row.custom_name {
Some(custom) => {
let matches_generated = row.generated_name.as_ref() == Some(custom);
let matches_existing = row.existing_bookmarks.iter().any(|e| e == custom);
!matches_generated && !matches_existing
}
None => true,
};
let current_state = row.state.clone();
let prev = match ¤t_state {
RowState::Unchecked => {
self.prev_before_unchecked(cursor, has_distinct_generated, has_distinct_custom)
}
RowState::UseCustom(_) => {
if has_distinct_generated {
RowState::UseGenerated
} else {
self.prev_before_generated(cursor)
}
}
RowState::UseGenerated => self.prev_before_generated(cursor),
RowState::UserInput(text) => {
self.rows[cursor].user_input_name = Some(text.clone());
self.prev_before_user_input(cursor)
}
RowState::UseTfidf(_) => {
if row.existing_bookmarks.is_empty() {
RowState::Unchecked
} else {
RowState::UseExisting(row.existing_bookmark_idx)
}
}
RowState::UseExisting(idx) => {
self.rows[cursor].existing_bookmark_idx = *idx;
RowState::Unchecked
}
};
self.rows[cursor].state = prev;
self.refresh_tfidf_names();
}
fn prev_before_unchecked(
&mut self,
cursor: usize,
has_distinct_generated: bool,
has_distinct_custom: bool,
) -> RowState {
if has_distinct_custom {
return make_use_custom(&self.rows[cursor]);
}
if has_distinct_generated {
return RowState::UseGenerated;
}
self.prev_before_generated(cursor)
}
fn prev_before_generated(&mut self, cursor: usize) -> RowState {
RowState::UserInput(
self.rows[cursor]
.user_input_name
.clone()
.unwrap_or_default(),
)
}
fn prev_before_user_input(&mut self, cursor: usize) -> RowState {
let cached_variation = self.rows[cursor].tfidf_name.as_ref().map_or(0, |(_, v)| *v);
if let Some(tfidf_state) = self.try_make_tfidf(cursor, cached_variation) {
return RowState::UseTfidf(tfidf_state);
}
let row = &self.rows[cursor];
if row.existing_bookmarks.is_empty() {
RowState::Unchecked
} else {
RowState::UseExisting(row.existing_bookmark_idx)
}
}
fn next_after_existing(&mut self, cursor: usize) -> RowState {
let cached_variation = self.rows[cursor].tfidf_name.as_ref().map_or(0, |(_, v)| *v);
if let Some(tfidf_state) = self.try_make_tfidf(cursor, cached_variation) {
return RowState::UseTfidf(tfidf_state);
}
self.next_after_tfidf(cursor)
}
fn next_after_tfidf(&mut self, cursor: usize) -> RowState {
RowState::UserInput(
self.rows[cursor]
.user_input_name
.clone()
.unwrap_or_default(),
)
}
fn next_after_user_input(
&self,
cursor: usize,
has_distinct_generated: bool,
has_distinct_custom: bool,
) -> RowState {
if has_distinct_generated {
return RowState::UseGenerated;
}
if has_distinct_custom {
return make_use_custom(&self.rows[cursor]);
}
RowState::Unchecked
}
fn try_make_tfidf(&mut self, cursor: usize, variation: usize) -> Option<TfidfNameState> {
let name =
compute_tfidf_for_segment(&self.rows, cursor, variation, self.auto_prefix.as_deref())?;
let row = &self.rows[cursor];
if row.generated_name.as_ref() == Some(&name) {
return None;
}
if row.existing_bookmarks.iter().any(|e| e == &name) {
return None;
}
self.rows[cursor].tfidf_name = Some((name.clone(), variation));
Some(TfidfNameState { name, variation })
}
pub fn vary_current(&mut self) -> VaryResult {
let cursor = self.cursor;
let Some(row) = self.rows.get(cursor) else {
return VaryResult::Noop;
};
if row.is_trunk {
return VaryResult::Noop;
}
match &row.state {
RowState::UseExisting(idx) => {
let count = self.rows[cursor].existing_bookmarks.len();
if count <= 1 {
return VaryResult::Noop;
}
let new_idx = (idx + 1) % count;
self.rows[cursor].state = RowState::UseExisting(new_idx);
self.rows[cursor].existing_bookmark_idx = new_idx;
VaryResult::ExistingCycled
}
RowState::UseTfidf(ts) => {
let old_variation = ts.variation;
for delta in 1..=6 {
let new_variation = (old_variation + delta) % 6;
if let Some(tfidf_state) = self.try_make_tfidf(cursor, new_variation) {
self.rows[cursor].state = RowState::UseTfidf(tfidf_state);
return VaryResult::TfidfCycled;
}
}
VaryResult::TfidfNoVariation
}
RowState::UseCustom(_) => {
self.rows[cursor].custom_name = None;
self.rows[cursor].state = RowState::UseCustom(CustomNameState::Loading);
VaryResult::NeedsRefire
}
_ => VaryResult::Noop,
}
}
pub fn vary_current_reverse(&mut self) -> VaryResult {
let cursor = self.cursor;
let Some(row) = self.rows.get(cursor) else {
return VaryResult::Noop;
};
if row.is_trunk {
return VaryResult::Noop;
}
match &row.state {
RowState::UseExisting(idx) => {
let count = self.rows[cursor].existing_bookmarks.len();
if count <= 1 {
return VaryResult::Noop;
}
let new_idx = (idx + count - 1) % count;
self.rows[cursor].state = RowState::UseExisting(new_idx);
self.rows[cursor].existing_bookmark_idx = new_idx;
VaryResult::ExistingCycled
}
RowState::UseTfidf(ts) => {
let old_variation = ts.variation;
for delta in 1..=6 {
let new_variation = (old_variation + 6 - delta) % 6;
if let Some(tfidf_state) = self.try_make_tfidf(cursor, new_variation) {
self.rows[cursor].state = RowState::UseTfidf(tfidf_state);
return VaryResult::TfidfCycled;
}
}
VaryResult::TfidfNoVariation
}
RowState::UseCustom(_) => {
self.rows[cursor].custom_name = None;
self.rows[cursor].state = RowState::UseCustom(CustomNameState::Loading);
VaryResult::NeedsRefire
}
_ => VaryResult::Noop,
}
}
pub fn refresh_tfidf_names(&mut self) {
let tfidf_indices: Vec<(usize, usize)> = self
.rows
.iter()
.enumerate()
.filter_map(|(i, row)| match &row.state {
RowState::UseTfidf(ts) => Some((i, ts.variation)),
_ => None,
})
.collect();
for (idx, variation) in tfidf_indices {
let old_name = match &self.rows[idx].state {
RowState::UseTfidf(ts) => ts.name.clone(),
_ => continue,
};
match compute_tfidf_for_segment(&self.rows, idx, variation, self.auto_prefix.as_deref())
{
Some(new_name) if new_name != old_name => {
self.rows[idx].tfidf_name = Some((new_name.clone(), variation));
self.rows[idx].state = RowState::UseTfidf(TfidfNameState {
name: new_name,
variation,
});
}
None => {
self.rows[idx].tfidf_name = None;
self.rows[idx].state = RowState::Unchecked;
}
Some(_) => {} }
}
}
pub fn cursor_up(&mut self) {
if self.cursor < self.rows.len().saturating_sub(1) {
self.cursor += 1;
}
}
pub fn cursor_down(&mut self) {
if self.cursor > 0 {
let next = self.cursor - 1;
if self.rows.get(next).is_some_and(|r| r.is_trunk) && self.rows.len() > 1 {
return;
}
self.cursor = next;
}
}
pub fn enter_edit_mode(&mut self) -> bool {
let cursor = self.cursor;
if let Some(row) = self.rows.get(cursor)
&& matches!(row.state, RowState::UserInput(_))
{
self.input_mode = InputMode::Editing;
true
} else {
false
}
}
pub fn exit_edit_mode(&mut self) {
self.input_mode = InputMode::Normal;
}
pub fn insert_char(&mut self, ch: char) {
if ch.is_ascii_control() || bookmark_gen::DISALLOWED_CHARS.contains(ch) {
return;
}
let cursor = self.cursor;
if let Some(row) = self.rows.get_mut(cursor)
&& let RowState::UserInput(ref mut buf) = row.state
&& buf.len() < bookmark_gen::MAX_BOOKMARK_LENGTH
{
buf.push(ch);
}
}
pub fn delete_char(&mut self) {
let cursor = self.cursor;
if let Some(row) = self.rows.get_mut(cursor)
&& let RowState::UserInput(ref mut buf) = row.state
{
buf.pop();
}
}
pub fn is_editing(&self) -> bool {
self.input_mode == InputMode::Editing
}
pub fn build_result(&self) -> Result<Vec<BookmarkAssignment>, SelectionError> {
let mut assignments = Vec::new();
let mut seen = std::collections::HashSet::new();
for r in &self.rows {
if r.is_trunk || r.state == RowState::Unchecked {
continue;
}
let (bookmark_name, is_new) = match &r.state {
RowState::UseExisting(idx) => (
r.existing_bookmarks
.get(*idx)
.cloned()
.expect("UseExisting index in bounds"),
false,
),
RowState::UseGenerated => (
r.generated_name
.clone()
.expect("UseGenerated requires name"),
true,
),
RowState::UseTfidf(ts) => (ts.name.clone(), true),
RowState::UseCustom(CustomNameState::Loading) => {
return Err(SelectionError::StillLoading);
}
RowState::UseCustom(CustomNameState::Ready(name)) => (name.clone(), true),
RowState::UserInput(s) if s.is_empty() => {
return Err(SelectionError::InvalidName(
"bookmark name is empty".to_string(),
));
}
RowState::UserInput(s) => {
bookmark_gen::validate_bookmark_name(s)
.map_err(|e| SelectionError::InvalidName(format!("{s}: {e}")))?;
(s.clone(), true)
}
RowState::Unchecked => unreachable!("filtered above"),
};
if !seen.insert(bookmark_name.clone()) {
return Err(SelectionError::DuplicateName(bookmark_name));
}
assignments.push(BookmarkAssignment {
change_id: r.change_id.clone(),
bookmark_name,
is_new,
});
}
Ok(assignments)
}
}
fn shorten_middle(s: &str, max: usize) -> String {
let len = s.chars().count();
if len <= max {
return s.to_string();
}
let budget = max.saturating_sub(1);
let head = budget.div_ceil(2);
let tail = budget / 2;
let start: String = s.chars().take(head).collect();
let end: String = s.chars().skip(len - tail).collect();
format!("{start}\u{2026}{end}")
}
pub struct BookmarkWidget<'a> {
state: &'a BookmarkAssignmentState,
spinner_tick: usize,
bookmark_command: Option<&'a str>,
editing_row: Option<usize>,
}
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const COMMAND_LABEL_MAX: usize = 16;
impl<'a> BookmarkWidget<'a> {
pub fn new(
state: &'a BookmarkAssignmentState,
spinner_tick: usize,
bookmark_command: Option<&'a str>,
editing_row: Option<usize>,
) -> Self {
Self {
state,
spinner_tick,
bookmark_command,
editing_row,
}
}
fn build_lines(&self) -> Vec<Line<'a>> {
let mut lines = Vec::new();
for (idx, row) in self.state.rows.iter().enumerate().rev() {
let is_selected = idx == self.state.cursor;
if row.is_trunk {
let style = Style::default().fg(Color::DarkGray);
lines.push(Line::from(vec![
Span::styled(" ", style),
Span::styled("\u{25c6} ", style), Span::styled("trunk", style),
]));
continue;
}
let node_char = "\u{25cb}"; let cursor_indicator = if is_selected { "> " } else { " " };
let (checkbox, state_color, state_bold) = match &row.state {
RowState::UseExisting(_) => ("[x]", Color::Green, true),
RowState::UseGenerated => ("[+]", Color::Yellow, true),
RowState::UseTfidf(_) => ("[~]", Color::Blue, true),
RowState::UseCustom(_) => ("[*]", Color::Cyan, true),
RowState::UserInput(_) => ("[>]", Color::LightYellow, true),
RowState::Unchecked => ("[ ]", Color::DarkGray, false),
};
let name_str = match &row.state {
RowState::UseExisting(idx) => row
.existing_bookmarks
.get(*idx)
.cloned()
.unwrap_or_default(),
RowState::UseGenerated => row
.generated_name
.as_ref()
.map(|n| format!("{n} (generated)"))
.unwrap_or_default(),
RowState::UseTfidf(ts) => {
format!("{} (auto [{}])", ts.name, ts.variation)
}
RowState::UseCustom(CustomNameState::Loading) => {
let frame = SPINNER_FRAMES[self.spinner_tick % SPINNER_FRAMES.len()];
let label = self
.bookmark_command
.map(|cmd| shorten_middle(cmd, COMMAND_LABEL_MAX))
.unwrap_or_default();
format!("{frame}{label}{frame}")
}
RowState::UseCustom(CustomNameState::Ready(name)) => {
format!("{name} (custom)")
}
RowState::UserInput(s) => {
let is_editing = self.editing_row == Some(idx);
if is_editing {
format!("{s}\u{2502}") } else if s.is_empty() {
"(i to type)".to_string()
} else {
format!("{s} (user)")
}
}
RowState::Unchecked => {
if let Some(first) = row.existing_bookmarks.first() {
first.clone()
} else {
"(Space to assign)".to_string()
}
}
};
let cursor_style = if is_selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let state_style = {
let base = Style::default().fg(state_color);
if state_bold {
base.add_modifier(Modifier::BOLD)
} else {
base
}
};
let summary_style = if is_selected {
Style::default().fg(Color::White)
} else {
Style::default().fg(Color::DarkGray)
};
let mut spans = vec![
Span::styled(cursor_indicator.to_string(), cursor_style),
Span::styled(format!("{checkbox} "), state_style),
Span::styled(format!("{node_char} "), state_style),
];
if !name_str.is_empty() {
let name_style = if let RowState::UserInput(s) = &row.state
&& !s.is_empty()
&& self.editing_row == Some(idx)
&& bookmark_gen::validate_bookmark_name(s).is_err()
{
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
} else {
state_style
};
spans.push(Span::styled(format!("{name_str} "), name_style));
}
let change_id_style = if is_selected {
Style::default().fg(Color::Magenta)
} else {
Style::default().fg(Color::DarkGray)
};
spans.push(Span::styled(
format!("{:<4} ", row.short_change_id),
change_id_style,
));
if row.summary == "(no description)" {
spans.push(Span::styled(
"(no description set)",
Style::default().fg(Color::DarkGray),
));
} else {
spans.push(Span::styled(row.summary.clone(), summary_style));
}
lines.push(Line::from(spans));
}
lines
}
}
impl Widget for BookmarkWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let lines = self.build_lines();
for (i, line) in lines.iter().take(area.height as usize).enumerate() {
let y = area.y + u16::try_from(i).expect("line index fits in u16");
buf.set_line(area.x, y, line, area.width);
}
}
}
pub fn bookmark_help_line(
has_bookmark_command: bool,
editing: bool,
current_row_state: Option<&RowState>,
existing_count: usize,
) -> Line<'static> {
let key_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
if editing {
return Line::from(vec![
Span::raw(" Type name "),
Span::styled("Backspace", key_style),
Span::raw(" delete "),
Span::styled("Esc/Enter", key_style),
Span::raw(" done"),
]);
}
let cycle = if has_bookmark_command {
" [x]use \u{2192} [~]auto \u{2192} [>]type \u{2192} [+]new \u{2192} [*]custom \u{2192} [ \
]skip "
} else {
" [x]use \u{2192} [~]auto \u{2192} [>]type \u{2192} [+]new \u{2192} [ ]skip "
};
let mut spans = vec![
Span::styled(" \u{2191}\u{2193}/jk", key_style),
Span::raw(" navigate "),
Span::styled("Space/b", key_style),
Span::raw(cycle),
];
if matches!(current_row_state, Some(RowState::UserInput(_))) {
spans.push(Span::styled("i", key_style));
spans.push(Span::raw(" edit "));
}
match current_row_state {
Some(RowState::UseExisting(_)) if existing_count > 1 => {
spans.push(Span::styled("r/R", key_style));
spans.push(Span::raw(" cycle "));
}
Some(RowState::UseTfidf(_)) => {
spans.push(Span::styled("r/R", key_style));
spans.push(Span::raw(" vary "));
}
Some(RowState::UseCustom(_)) => {
spans.push(Span::styled("r/R", key_style));
spans.push(Span::raw(" regenerate "));
}
_ => {}
}
spans.push(Span::styled("Enter", key_style));
spans.push(Span::raw(" confirm "));
spans.push(Span::styled("Esc/q", key_style));
spans.push(Span::raw(" back"));
Line::from(spans)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::select::graph_layout::LayoutNode;
fn make_node(
change_id: &str,
summary: &str,
bookmarks: &[&str],
is_trunk: bool,
is_leaf: bool,
) -> LayoutNode {
LayoutNode {
row: 0,
col: 0,
change_id: change_id.to_string(),
commit_id: format!("commit_{change_id}"),
summary: summary.to_string(),
description: summary.to_string(),
bookmark_names: bookmarks.iter().map(ToString::to_string).collect(),
is_trunk,
is_leaf,
stack_index: 0,
short_change_id: change_id[..4.min(change_id.len())].to_string(),
author: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
files: vec![],
}
}
#[test]
fn generate_name_from_change_id() {
assert_eq!(
bookmark_gen::default_bookmark_name("abcdefghijklmnop"),
"stakk-abcdefghijkl"
);
assert_eq!(bookmark_gen::default_bookmark_name("short"), "stakk-short");
}
#[test]
fn state_from_path_marks_existing_bookmarks() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node("ch_a", "add base", &["base"], false, false),
make_node("ch_b", "add feature", &[], false, true),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let state = BookmarkAssignmentState::from_path(&refs, false, None);
assert_eq!(state.rows.len(), 3);
assert!(state.rows[0].is_trunk);
assert_eq!(state.rows[1].state, RowState::UseExisting(0));
assert_eq!(state.rows[1].existing_bookmarks, vec!["base".to_string()]);
assert_eq!(state.rows[1].generated_name, Some("stakk-ch_a".to_string()));
assert_eq!(state.rows[2].state, RowState::Unchecked);
assert!(state.rows[2].existing_bookmarks.is_empty());
assert!(state.rows[2].generated_name.is_some());
}
#[test]
fn toggle_checks_and_unchecks() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node("ch_a", "work", &["feat"], false, true),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, false, None);
assert_eq!(state.cursor, 1);
assert_eq!(state.rows[1].state, RowState::UseExisting(0));
state.toggle_current();
assert!(
matches!(&state.rows[1].state, RowState::UseTfidf(_)),
"expected UseTfidf, got {:?}",
state.rows[1].state
);
state.toggle_current();
assert_eq!(state.rows[1].state, RowState::UserInput(String::new()));
state.toggle_current();
assert_eq!(state.rows[1].state, RowState::UseGenerated);
state.toggle_current();
assert_eq!(state.rows[1].state, RowState::Unchecked);
state.toggle_current();
assert_eq!(state.rows[1].state, RowState::UseExisting(0));
}
#[test]
fn reverse_toggle_checks_and_unchecks() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node("ch_a", "work", &["feat"], false, true),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, false, None);
assert_eq!(state.rows[1].state, RowState::UseExisting(0));
state.toggle_current_reverse();
assert_eq!(state.rows[1].state, RowState::Unchecked);
state.toggle_current_reverse();
assert_eq!(state.rows[1].state, RowState::UseGenerated);
state.toggle_current_reverse();
assert_eq!(state.rows[1].state, RowState::UserInput(String::new()));
state.toggle_current_reverse();
assert!(
matches!(&state.rows[1].state, RowState::UseTfidf(_)),
"expected UseTfidf, got {:?}",
state.rows[1].state
);
state.toggle_current_reverse();
assert_eq!(state.rows[1].state, RowState::UseExisting(0));
}
#[test]
fn reverse_toggle_multiple_existing() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node(
"ch_a",
"work",
&["feature", "wip", "experiment"],
false,
true,
),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, false, None);
state.toggle_current();
state.toggle_current();
state.toggle_current();
state.toggle_current();
assert_eq!(state.rows[1].state, RowState::Unchecked);
state.toggle_current_reverse();
assert_eq!(state.rows[1].state, RowState::UseGenerated);
state.toggle_current_reverse();
assert_eq!(state.rows[1].state, RowState::UserInput(String::new()));
state.toggle_current_reverse();
assert!(matches!(&state.rows[1].state, RowState::UseTfidf(_)));
state.toggle_current_reverse();
assert_eq!(state.rows[1].state, RowState::UseExisting(0));
state.toggle_current_reverse();
assert_eq!(state.rows[1].state, RowState::Unchecked);
}
#[test]
fn reverse_toggle_no_existing() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node("ch_x", "feature", &[], false, true),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, false, None);
assert_eq!(state.rows[1].state, RowState::Unchecked);
state.toggle_current_reverse();
assert_eq!(state.rows[1].state, RowState::UseGenerated);
state.toggle_current_reverse();
assert_eq!(state.rows[1].state, RowState::UserInput(String::new()));
state.toggle_current_reverse();
assert!(matches!(&state.rows[1].state, RowState::UseTfidf(_)));
state.toggle_current_reverse();
assert_eq!(state.rows[1].state, RowState::Unchecked);
}
#[test]
fn reverse_toggle_tfidf_skipped_when_all_stop_words() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node("ch_x", "add update remove", &[], false, true),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, false, None);
assert_eq!(state.rows[1].state, RowState::Unchecked);
state.toggle_current_reverse();
assert_eq!(state.rows[1].state, RowState::UseGenerated);
state.toggle_current_reverse();
assert_eq!(state.rows[1].state, RowState::UserInput(String::new()));
state.toggle_current_reverse();
assert_eq!(state.rows[1].state, RowState::Unchecked);
}
#[test]
fn reverse_toggle_trunk_is_noop() {
let nodes = [make_node("", "trunk", &[], true, false)];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, false, None);
state.cursor = 0;
let state_before = state.rows[0].state.clone();
state.toggle_current_reverse();
assert_eq!(state.rows[0].state, state_before);
}
#[test]
fn forward_then_reverse_is_identity() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node("ch_a", "work", &["feat"], false, true),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, false, None);
let initial = state.rows[1].state.clone();
state.toggle_current();
state.toggle_current_reverse();
assert_eq!(state.rows[1].state, initial);
}
#[test]
fn toggle_trunk_is_noop() {
let nodes = [make_node("", "trunk", &[], true, false)];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, false, None);
state.cursor = 0;
let state_before = state.rows[0].state.clone();
state.toggle_current();
assert_eq!(state.rows[0].state, state_before);
}
#[test]
fn toggle_two_state_when_names_match() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node("abcdefghijkl", "work", &["stakk-abcdefghijkl"], false, true),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, false, None);
assert_eq!(state.rows[1].state, RowState::UseExisting(0));
state.toggle_current();
assert!(matches!(&state.rows[1].state, RowState::UseTfidf(_)));
state.toggle_current();
assert_eq!(state.rows[1].state, RowState::UserInput(String::new()));
state.toggle_current();
assert_eq!(state.rows[1].state, RowState::Unchecked);
state.toggle_current();
assert_eq!(state.rows[1].state, RowState::UseExisting(0));
}
#[test]
fn toggle_no_existing_includes_tfidf() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node("ch_x", "feature", &[], false, true),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, false, None);
assert_eq!(state.rows[1].state, RowState::Unchecked);
state.toggle_current();
assert!(matches!(&state.rows[1].state, RowState::UseTfidf(_)));
state.toggle_current();
assert_eq!(state.rows[1].state, RowState::UserInput(String::new()));
state.toggle_current();
assert_eq!(state.rows[1].state, RowState::UseGenerated);
state.toggle_current();
assert_eq!(state.rows[1].state, RowState::Unchecked);
}
#[test]
fn toggle_tfidf_skipped_when_all_stop_words() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node("ch_x", "add update remove", &[], false, true),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, false, None);
assert_eq!(state.rows[1].state, RowState::Unchecked);
state.toggle_current();
assert_eq!(state.rows[1].state, RowState::UserInput(String::new()));
state.toggle_current();
assert_eq!(state.rows[1].state, RowState::UseGenerated);
state.toggle_current();
assert_eq!(state.rows[1].state, RowState::Unchecked);
}
#[test]
fn build_result_includes_only_checked() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node("ch_a", "base", &["base"], false, false),
make_node("ch_b", "middle", &[], false, false),
make_node("ch_c", "leaf", &["leaf"], false, true),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, false, None);
state.cursor = 2;
state.toggle_current();
let result = state.build_result().unwrap();
assert_eq!(result.len(), 3);
assert_eq!(result[0].bookmark_name, "base");
assert!(!result[0].is_new);
assert!(!result[1].bookmark_name.starts_with("stakk-"));
assert!(result[1].is_new);
assert_eq!(result[2].bookmark_name, "leaf");
assert!(!result[2].is_new);
}
#[test]
fn build_result_empty_when_all_unchecked() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node("ch_a", "work", &["feat"], false, true),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, false, None);
state.cursor = 1;
state.toggle_current(); state.toggle_current(); state.toggle_current(); state.toggle_current();
let result = state.build_result().unwrap();
assert!(result.is_empty());
}
fn make_bare_row(state: RowState) -> BookmarkRow {
BookmarkRow {
change_id: "a".to_string(),
short_change_id: "a".to_string(),
commit_id: "commit_a".to_string(),
summary: "work".to_string(),
description: "work".to_string(),
existing_bookmarks: vec!["feat".to_string()],
state,
generated_name: Some("stakk-aaaaaaaaaaaa".to_string()),
user_input_name: None,
existing_bookmark_idx: 0,
custom_name: None,
tfidf_name: None,
is_trunk: false,
author: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
files: vec![],
has_bookmark_command: false,
}
}
#[test]
fn effective_name_returns_correct_values() {
let row_existing = make_bare_row(RowState::UseExisting(0));
assert_eq!(row_existing.effective_name(), Some("feat"));
let mut row_generated = make_bare_row(RowState::UseGenerated);
row_generated.existing_bookmarks = vec![];
row_generated.generated_name = Some("stakk-bbbbbbbbb".to_string());
assert_eq!(row_generated.effective_name(), Some("stakk-bbbbbbbbb"));
let row_unchecked = make_bare_row(RowState::Unchecked);
assert_eq!(row_unchecked.effective_name(), None);
let row_custom = make_bare_row(RowState::UseCustom(CustomNameState::Ready(
"my-branch".to_string(),
)));
assert_eq!(row_custom.effective_name(), Some("my-branch"));
let row_loading = make_bare_row(RowState::UseCustom(CustomNameState::Loading));
assert_eq!(row_loading.effective_name(), None);
}
#[test]
fn build_result_blocks_when_loading() {
let mut row = make_bare_row(RowState::UseCustom(CustomNameState::Loading));
row.is_trunk = false;
let state = BookmarkAssignmentState {
rows: vec![row],
cursor: 0,
auto_prefix: None,
input_mode: InputMode::Normal,
};
assert!(matches!(
state.build_result(),
Err(SelectionError::StillLoading)
));
}
#[test]
fn bookmark_widget_renders_to_buffer() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node("ch_a", "add feature", &["feat"], false, true),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let state = BookmarkAssignmentState::from_path(&refs, false, None);
let widget = BookmarkWidget::new(&state, 0, None, None);
let area = Rect::new(0, 0, 60, 10);
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
let content: String = (0..area.height)
.map(|y| {
(0..area.width)
.map(|x| buf.cell((x, y)).unwrap().symbol().to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(content.contains("[x]"), "expected checkbox in output");
assert!(content.contains("feat"), "expected bookmark name in output");
}
#[test]
fn toggle_multiple_existing_bookmarks() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node(
"ch_a",
"work",
&["feature", "wip", "experiment"],
false,
true,
),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, false, None);
assert_eq!(state.rows[1].state, RowState::UseExisting(0));
state.toggle_current();
assert!(matches!(&state.rows[1].state, RowState::UseTfidf(_)));
state.toggle_current();
assert_eq!(state.rows[1].state, RowState::UserInput(String::new()));
state.toggle_current();
assert_eq!(state.rows[1].state, RowState::UseGenerated);
state.toggle_current();
assert_eq!(state.rows[1].state, RowState::Unchecked);
state.toggle_current();
assert_eq!(state.rows[1].state, RowState::UseExisting(0));
let r = state.vary_current();
assert_eq!(r, VaryResult::ExistingCycled);
assert_eq!(state.rows[1].state, RowState::UseExisting(1));
let r = state.vary_current();
assert_eq!(r, VaryResult::ExistingCycled);
assert_eq!(state.rows[1].state, RowState::UseExisting(2));
let r = state.vary_current();
assert_eq!(r, VaryResult::ExistingCycled);
assert_eq!(state.rows[1].state, RowState::UseExisting(0));
}
#[test]
fn toggle_multiple_existing_one_matches_generated() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node(
"abcdefghijkl",
"work",
&["feature", "stakk-abcdefghijkl"],
false,
true,
),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, false, None);
assert_eq!(state.rows[1].state, RowState::UseExisting(0));
state.toggle_current();
assert!(matches!(&state.rows[1].state, RowState::UseTfidf(_)));
state.toggle_current();
assert_eq!(state.rows[1].state, RowState::UserInput(String::new()));
state.toggle_current();
assert_eq!(state.rows[1].state, RowState::Unchecked);
state.toggle_current();
assert_eq!(state.rows[1].state, RowState::UseExisting(0));
}
#[test]
fn build_result_with_second_existing_bookmark() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node("ch_a", "work", &["alpha", "beta"], false, true),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, false, None);
let vary = state.vary_current();
assert_eq!(vary, VaryResult::ExistingCycled);
let result = state.build_result().unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].bookmark_name, "beta");
assert!(!result[0].is_new);
}
#[test]
fn state_from_path_preserves_all_bookmarks() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node("ch_a", "work", &["alpha", "beta", "gamma"], false, true),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let state = BookmarkAssignmentState::from_path(&refs, false, None);
assert_eq!(state.rows[1].existing_bookmarks.len(), 3);
assert_eq!(state.rows[1].existing_bookmarks[0], "alpha");
assert_eq!(state.rows[1].existing_bookmarks[1], "beta");
assert_eq!(state.rows[1].existing_bookmarks[2], "gamma");
}
#[test]
fn build_result_extracts_tfidf_name() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node(
"ch_a",
"implement caching layer for database queries",
&[],
false,
true,
),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, false, None);
state.toggle_current();
assert!(matches!(&state.rows[1].state, RowState::UseTfidf(_)));
let result = state.build_result().unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].is_new);
assert!(
!result[0].bookmark_name.starts_with("stakk-"),
"expected TF-IDF name, got: {}",
result[0].bookmark_name
);
}
#[test]
fn vary_cycles_tfidf_variation() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node(
"ch_a",
"implement caching layer for database queries",
&[],
false,
true,
),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, false, None);
state.toggle_current();
let v0_name = match &state.rows[1].state {
RowState::UseTfidf(ts) => {
assert_eq!(ts.variation, 0);
ts.name.clone()
}
other => panic!("expected UseTfidf, got {other:?}"),
};
let result = state.vary_current();
assert_ne!(result, VaryResult::NeedsRefire);
match &state.rows[1].state {
RowState::UseTfidf(ts) => {
assert!(ts.variation != 0 || ts.name == v0_name);
}
other => panic!("expected UseTfidf after vary, got {other:?}"),
}
}
#[test]
fn effective_name_for_tfidf() {
let mut row = make_bare_row(RowState::UseTfidf(TfidfNameState {
name: "caching-database-layer".to_string(),
variation: 0,
}));
row.existing_bookmarks = vec![];
assert_eq!(row.effective_name(), Some("caching-database-layer"));
}
#[test]
fn auto_prefix_prepended_to_tfidf_name() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node(
"ch_a",
"implement caching layer for database queries",
&[],
false,
true,
),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, false, Some("gb-"));
state.toggle_current();
match &state.rows[1].state {
RowState::UseTfidf(ts) => {
assert!(
ts.name.starts_with("gb-"),
"expected prefix 'gb-', got: {}",
ts.name
);
}
other => panic!("expected UseTfidf, got {other:?}"),
}
}
#[test]
fn tfidf_refreshes_when_earlier_row_toggled() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node("ch_mid", "authentication middleware", &[], false, false),
make_node("ch_leaf", "rate limiting endpoints", &[], false, true),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, false, None);
state.cursor = 2;
state.toggle_current();
let leaf_name_with_middle = match &state.rows[2].state {
RowState::UseTfidf(ts) => ts.name.clone(),
other => panic!("expected UseTfidf on leaf, got {other:?}"),
};
state.cursor = 1;
state.toggle_current();
match &state.rows[2].state {
RowState::UseTfidf(ts) => {
assert!(
!ts.name.is_empty(),
"refreshed TF-IDF name should not be empty"
);
let _ = leaf_name_with_middle; }
RowState::Unchecked => {
}
other => panic!("expected UseTfidf or Unchecked on leaf after refresh, got {other:?}"),
}
}
#[test]
fn user_input_edit_mode_insert_and_delete() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node("ch_a", "work", &[], false, true),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, false, None);
state.toggle_current(); while !matches!(state.rows[1].state, RowState::UserInput(_)) {
state.toggle_current();
}
assert!(state.enter_edit_mode());
assert!(state.is_editing());
state.insert_char('m');
state.insert_char('y');
state.insert_char('-');
state.insert_char('b');
assert_eq!(state.rows[1].state, RowState::UserInput("my-b".to_string()));
state.delete_char();
assert_eq!(state.rows[1].state, RowState::UserInput("my-".to_string()));
state.insert_char(' ');
assert_eq!(state.rows[1].state, RowState::UserInput("my-".to_string()));
state.exit_edit_mode();
assert!(!state.is_editing());
}
#[test]
fn user_input_text_preserved_across_cycles() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node("ch_a", "feature", &[], false, true),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, false, None);
while !matches!(state.rows[1].state, RowState::UserInput(_)) {
state.toggle_current();
}
state.enter_edit_mode();
state.insert_char('x');
state.insert_char('y');
state.exit_edit_mode();
state.toggle_current(); assert_eq!(state.rows[1].state, RowState::UseGenerated);
state.toggle_current(); state.toggle_current(); state.toggle_current(); assert_eq!(state.rows[1].state, RowState::UserInput("xy".to_string()));
}
#[test]
fn user_input_empty_is_error_on_confirm() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node("ch_a", "work", &[], false, true),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, false, None);
while !matches!(state.rows[1].state, RowState::UserInput(_)) {
state.toggle_current();
}
assert!(matches!(
state.build_result(),
Err(SelectionError::InvalidName(_))
));
}
#[test]
fn user_input_valid_name_in_build_result() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node("ch_a", "work", &[], false, true),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, false, None);
while !matches!(state.rows[1].state, RowState::UserInput(_)) {
state.toggle_current();
}
state.enter_edit_mode();
for c in "my-branch".chars() {
state.insert_char(c);
}
state.exit_edit_mode();
let result = state.build_result().unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].bookmark_name, "my-branch");
assert!(result[0].is_new);
}
#[test]
fn effective_name_for_user_input() {
let row_empty = make_bare_row(RowState::UserInput(String::new()));
assert_eq!(row_empty.effective_name(), None);
let row_filled = make_bare_row(RowState::UserInput("my-branch".to_string()));
assert_eq!(row_filled.effective_name(), Some("my-branch"));
}
#[test]
fn enter_edit_mode_fails_on_non_user_input() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node("ch_a", "work", &["feat"], false, true),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, false, None);
assert!(!state.enter_edit_mode());
assert!(!state.is_editing());
}
#[test]
fn toggle_with_custom_command() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node("ch_a", "work", &["feat"], false, true),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, true, None);
assert_eq!(state.cursor, 1);
assert_eq!(state.rows[1].state, RowState::UseExisting(0));
state.toggle_current();
assert!(
matches!(&state.rows[1].state, RowState::UseTfidf(_)),
"expected UseTfidf, got {:?}",
state.rows[1].state
);
state.toggle_current();
assert_eq!(state.rows[1].state, RowState::UserInput(String::new()));
state.toggle_current();
assert_eq!(state.rows[1].state, RowState::UseGenerated);
state.toggle_current();
assert!(
matches!(
&state.rows[1].state,
RowState::UseCustom(CustomNameState::Loading)
),
"expected UseCustom(Loading), got {:?}",
state.rows[1].state
);
state.toggle_current();
assert_eq!(state.rows[1].state, RowState::Unchecked);
state.toggle_current();
assert_eq!(state.rows[1].state, RowState::UseExisting(0));
}
#[test]
fn reverse_toggle_with_custom_command() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node("ch_a", "work", &["feat"], false, true),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, true, None);
assert_eq!(state.rows[1].state, RowState::UseExisting(0));
state.toggle_current_reverse();
assert_eq!(state.rows[1].state, RowState::Unchecked);
state.toggle_current_reverse();
assert!(
matches!(
&state.rows[1].state,
RowState::UseCustom(CustomNameState::Loading)
),
"expected UseCustom(Loading), got {:?}",
state.rows[1].state
);
state.toggle_current_reverse();
assert_eq!(state.rows[1].state, RowState::UseGenerated);
state.toggle_current_reverse();
assert_eq!(state.rows[1].state, RowState::UserInput(String::new()));
state.toggle_current_reverse();
assert!(matches!(&state.rows[1].state, RowState::UseTfidf(_)));
state.toggle_current_reverse();
assert_eq!(state.rows[1].state, RowState::UseExisting(0));
}
#[test]
fn toggle_custom_skipped_when_matches_existing() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node("ch_a", "work", &["feat"], false, true),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, true, None);
state.rows[1].custom_name = Some("feat".to_string());
state.toggle_current(); state.toggle_current(); state.toggle_current(); state.toggle_current(); assert_eq!(state.rows[1].state, RowState::Unchecked);
}
#[test]
fn toggle_custom_skipped_when_matches_generated() {
let nodes = [
make_node("", "trunk", &[], true, false),
make_node("ch_a", "work", &["feat"], false, true),
];
let refs: Vec<&LayoutNode> = nodes.iter().collect();
let mut state = BookmarkAssignmentState::from_path(&refs, true, None);
let gen_name = state.rows[1].generated_name.clone().unwrap();
state.rows[1].custom_name = Some(gen_name);
state.toggle_current(); state.toggle_current(); state.toggle_current(); state.toggle_current(); assert_eq!(state.rows[1].state, RowState::Unchecked);
}
}