use anyhow::Context;
use crossterm::event::Event;
use ratatui::prelude::*;
use ratatui_textarea::TextArea;
use super::screens::ButtonScreen;
use super::widgets::{self, KeyResult};
use crate::model::changeset::ChangeType;
use crate::package_manager::Project;
mod enter_message;
mod select_projects;
mod single_package;
#[derive(Debug, Clone)]
pub struct ChangeResult {
pub projects: Vec<(Project, ChangeType)>,
pub message: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ChangeOptions {
pub change_type: Option<ChangeType>,
pub projects: Option<Vec<usize>>,
}
#[derive(Debug)]
struct SelectProjectsState {
selected: Vec<bool>,
levels: Vec<ChangeType>,
cursor: usize,
error: bool,
changed_count: usize,
}
enum BackState {
MultiPackage(SelectProjectsState),
SinglePackage { level: ChangeType },
}
enum Screen {
SelectProjects(SelectProjectsState),
SinglePackage {
level: ChangeType,
},
EnterMessage {
textarea: Box<TextArea<'static>>,
projects: Vec<(Project, ChangeType)>,
back: BackState,
},
}
type HandleResult = KeyResult<Screen, ChangeResult>;
struct ReorderedProjects {
projects: Vec<Project>,
changed_count: usize,
orig_to_new: Vec<usize>,
}
fn reorder_projects(projects: &[Project], changed_flags: &[bool]) -> ReorderedProjects {
let changed_count = changed_flags.iter().filter(|&&c| c).count();
let mut changed_pairs: Vec<(usize, Project)> = projects
.iter()
.enumerate()
.filter(|(i, _)| changed_flags[*i])
.map(|(i, p)| (i, p.clone()))
.collect();
let mut unchanged_pairs: Vec<(usize, Project)> = projects
.iter()
.enumerate()
.filter(|(i, _)| !changed_flags[*i])
.map(|(i, p)| (i, p.clone()))
.collect();
changed_pairs.sort_by(|a, b| a.1.name().cmp(b.1.name()));
unchanged_pairs.sort_by(|a, b| a.1.name().cmp(b.1.name()));
let reordered_pairs: Vec<(usize, Project)> =
changed_pairs.into_iter().chain(unchanged_pairs).collect();
let mut orig_to_new = vec![0usize; projects.len()];
for (new_idx, (orig_idx, _)) in reordered_pairs.iter().enumerate() {
orig_to_new[*orig_idx] = new_idx;
}
let reordered = reordered_pairs.into_iter().map(|(_, p)| p).collect();
ReorderedProjects {
projects: reordered,
changed_count,
orig_to_new,
}
}
fn handle_event(
screen: Screen,
event: Event,
area: Rect,
projects: &[Project],
) -> anyhow::Result<HandleResult> {
match screen {
Screen::SelectProjects(state) => Ok(select_projects::handle_event_select_projects(
state, event, area, projects,
)),
Screen::SinglePackage { level } => {
let project = projects
.first()
.context("SinglePackage screen requires at least one project")?
.clone();
let buttons = single_package::SinglePackageButtons { level };
match buttons.handle_event(vec![project], event, area)? {
KeyResult::Continue((_, screen)) => Ok(KeyResult::Continue(screen)),
KeyResult::Complete(cr) => Ok(KeyResult::Complete(cr)),
KeyResult::Cancelled => Ok(KeyResult::Cancelled),
}
}
Screen::EnterMessage {
textarea,
projects: proj,
back,
} => enter_message::handle_event_enter_message(textarea, proj, back, event),
}
}
fn ui(frame: &mut Frame, screen: &Screen, project_names: &[&str]) {
let area = frame.area();
match screen {
Screen::SelectProjects(state) => {
select_projects::render_select_projects(frame, area, project_names, state);
}
Screen::SinglePackage { level } => {
single_package::SinglePackageButtons { level: *level }.render(frame, area);
}
Screen::EnterMessage { textarea, .. } => {
enter_message::render_enter_message(frame, area, textarea);
}
}
}
fn build_initial_screen(
ro: &ReorderedProjects,
project_indices: &[usize],
have_projects: bool,
) -> Screen {
if ro.projects.len() == 1 {
return Screen::SinglePackage {
level: ChangeType::Patch,
};
}
if have_projects {
let mut selected = vec![false; ro.projects.len()];
for &i in project_indices {
selected[i] = true;
}
Screen::SelectProjects(SelectProjectsState {
selected,
levels: vec![ChangeType::Patch; ro.projects.len()],
cursor: 0,
error: false,
changed_count: ro.changed_count,
})
} else {
let selected = (0..ro.projects.len())
.map(|i| i < ro.changed_count)
.collect();
Screen::SelectProjects(SelectProjectsState {
selected,
levels: vec![ChangeType::Patch; ro.projects.len()],
cursor: 0,
error: false,
changed_count: ro.changed_count,
})
}
}
pub fn run(
projects: &[Project],
options: &ChangeOptions,
changed: &[bool],
) -> anyhow::Result<Option<ChangeResult>> {
let changed_flags: Vec<bool> = if changed.len() == projects.len() {
changed.to_vec()
} else {
vec![true; projects.len()] };
let ro = reorder_projects(projects, &changed_flags);
let project_indices: Vec<usize> = match &options.projects {
Some(indices) => indices.iter().map(|&i| ro.orig_to_new[i]).collect(),
None if ro.projects.len() == 1 => vec![0],
_ => vec![], };
let have_projects = !project_indices.is_empty();
if let Some(change_type) = options.change_type {
let indices = if have_projects {
project_indices
} else {
(0..ro.projects.len()).collect()
};
return Ok(Some(ChangeResult {
projects: indices
.into_iter()
.map(|i| (ro.projects[i].clone(), change_type))
.collect(),
message: None,
}));
}
let project_names: Vec<&str> = ro.projects.iter().map(|p| p.name()).collect();
let initial_screen = build_initial_screen(&ro, &project_indices, have_projects);
let result = widgets::run_tui(
initial_screen,
|frame, screen| ui(frame, screen, &project_names),
|screen, event, area| handle_event(screen, event, area, &ro.projects),
)?;
Ok(result)
}
#[cfg(test)]
fn handle_key(
screen: Screen,
key: crossterm::event::KeyCode,
projects: &[Project],
) -> anyhow::Result<HandleResult> {
use crossterm::event::{KeyEvent, KeyModifiers};
handle_event(
screen,
Event::Key(KeyEvent::new(key, KeyModifiers::NONE)),
Rect::new(0, 0, 80, 24),
projects,
)
}
#[cfg(test)]
pub(super) mod test_helpers {
use crate::package_manager::Project;
pub(super) fn dummy_projects(n: usize) -> Vec<Project> {
(0..n)
.map(|i| {
Project::new_test(
&format!("project-{i}"),
&format!("/nonexistent/projects/project-{i}"),
)
})
.collect()
}
}
#[cfg(test)]
mod tests {
use crossterm::event::KeyCode;
use crate::model::changeset::ChangeType;
use crate::package_manager::Project;
use super::test_helpers::dummy_projects;
use super::*;
#[test]
fn reorder_projects_mixed_changed_and_unchanged() {
let projects = dummy_projects(3); let changed_flags = vec![false, true, false];
let ro = reorder_projects(&projects, &changed_flags);
assert_eq!(ro.changed_count, 1);
assert_eq!(ro.projects[0].name(), "project-1"); assert_eq!(ro.projects[1].name(), "project-0"); assert_eq!(ro.projects[2].name(), "project-2");
assert_eq!(ro.orig_to_new[0], 1); assert_eq!(ro.orig_to_new[1], 0); assert_eq!(ro.orig_to_new[2], 2); }
#[test]
fn reorder_projects_all_changed() {
let projects = dummy_projects(2);
let ro = reorder_projects(&projects, &[true, true]);
assert_eq!(ro.changed_count, 2);
assert_eq!(ro.projects[0].name(), "project-0");
assert_eq!(ro.projects[1].name(), "project-1");
assert_eq!(ro.orig_to_new[0], 0);
assert_eq!(ro.orig_to_new[1], 1);
}
#[test]
fn reorder_projects_all_unchanged() {
let projects = dummy_projects(2);
let ro = reorder_projects(&projects, &[false, false]);
assert_eq!(ro.changed_count, 0);
assert_eq!(ro.projects[0].name(), "project-0");
assert_eq!(ro.projects[1].name(), "project-1");
assert_eq!(ro.orig_to_new[0], 0);
assert_eq!(ro.orig_to_new[1], 1);
}
#[test]
fn reorder_projects_empty() {
let ro = reorder_projects(&[], &[]);
assert_eq!(ro.changed_count, 0);
assert!(ro.projects.is_empty());
assert!(ro.orig_to_new.is_empty());
}
#[test]
fn reorder_projects_single_project() {
let projects = dummy_projects(1);
let ro = reorder_projects(&projects, &[true]);
assert_eq!(ro.changed_count, 1);
assert_eq!(ro.projects[0].name(), "project-0");
assert_eq!(ro.orig_to_new[0], 0);
}
#[test]
fn reorder_projects_sorts_changed_group_by_name() {
let projects = vec![
Project::new_test("beta", "/nonexistent/beta"),
Project::new_test("alpha", "/nonexistent/alpha"),
];
let ro = reorder_projects(&projects, &[true, true]);
assert_eq!(ro.changed_count, 2);
assert_eq!(ro.projects[0].name(), "alpha");
assert_eq!(ro.projects[1].name(), "beta");
assert_eq!(ro.orig_to_new[0], 1); assert_eq!(ro.orig_to_new[1], 0); }
#[test]
fn reorder_projects_sorts_unchanged_group_by_name() {
let projects = vec![
Project::new_test("zeta", "/nonexistent/zeta"),
Project::new_test("gamma", "/nonexistent/gamma"),
];
let ro = reorder_projects(&projects, &[false, false]);
assert_eq!(ro.changed_count, 0);
assert_eq!(ro.projects[0].name(), "gamma");
assert_eq!(ro.projects[1].name(), "zeta");
assert_eq!(ro.orig_to_new[0], 1); assert_eq!(ro.orig_to_new[1], 0); }
fn unwrap_select_projects(
result: anyhow::Result<HandleResult>,
) -> (Vec<bool>, Vec<ChangeType>, usize, bool, usize) {
match result.unwrap() {
KeyResult::Continue(Screen::SelectProjects(SelectProjectsState {
selected,
levels,
cursor,
error,
changed_count,
})) => (selected, levels, cursor, error, changed_count),
other => panic!(
"Expected Continue(SelectProjects), got different variant: {:?}",
std::mem::discriminant(&other)
),
}
}
fn unwrap_enter_message(result: anyhow::Result<HandleResult>) -> Vec<(Project, ChangeType)> {
match result.unwrap() {
KeyResult::Continue(Screen::EnterMessage { projects, .. }) => projects,
_ => panic!("Expected Continue(EnterMessage)"),
}
}
#[test]
fn workflow_select_projects_then_enter_message() {
let projects = dummy_projects(3);
let screen = Screen::SelectProjects(SelectProjectsState {
selected: vec![true, true, true],
levels: vec![ChangeType::Patch; 3],
cursor: 0,
error: false,
changed_count: 3,
});
let (selected, levels, cursor, error, changed_count) =
unwrap_select_projects(handle_key(screen, KeyCode::Char(' '), &projects));
assert_eq!(selected, vec![false, true, true]);
assert_eq!(levels, vec![ChangeType::Patch; 3]);
assert_eq!(cursor, 0);
assert!(!error);
assert_eq!(changed_count, 3);
let screen = Screen::SelectProjects(SelectProjectsState {
selected: vec![false, true, true],
levels: vec![ChangeType::Patch; 3],
cursor: 1,
error: false,
changed_count: 3,
});
let (selected2, levels2, ..) =
unwrap_select_projects(handle_key(screen, KeyCode::Right, &projects));
assert_eq!(selected2, vec![false, true, true]);
assert_eq!(levels2[1], ChangeType::Major);
let screen = Screen::SelectProjects(SelectProjectsState {
selected: vec![false, true, true],
levels: vec![ChangeType::Patch, ChangeType::Major, ChangeType::Patch],
cursor: 0,
error: false,
changed_count: 3,
});
let proj = unwrap_enter_message(handle_key(screen, KeyCode::Enter, &projects));
assert_eq!(proj.len(), 2);
assert_eq!(proj[0].0.name(), "project-1");
assert_eq!(proj[0].1, ChangeType::Major);
assert_eq!(proj[1].0.name(), "project-2");
assert_eq!(proj[1].1, ChangeType::Patch);
}
#[test]
fn build_initial_screen_single_project_returns_single_package() {
let projects = dummy_projects(1);
let ro = reorder_projects(&projects, &[true]);
let screen = build_initial_screen(&ro, &[], false);
assert!(
matches!(screen, Screen::SinglePackage { .. }),
"Expected SinglePackage for one project"
);
}
#[test]
fn build_initial_screen_no_pre_selection_selects_only_changed() {
let projects = dummy_projects(2);
let ro = reorder_projects(&projects, &[true, false]); let screen = build_initial_screen(&ro, &[], false);
match screen {
Screen::SelectProjects(SelectProjectsState {
selected,
changed_count,
..
}) => {
assert_eq!(changed_count, 1);
assert!(selected[0], "first project (changed) should be selected");
assert!(
!selected[1],
"second project (unchanged) should not be selected"
);
}
_ => panic!("Expected SelectProjects"),
}
}
}