use crossterm::event::Event;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders};
use ratatui_textarea::TextArea;
use super::screens::ButtonScreen;
use super::widgets::{self, KeyResult, TabStatus};
use crate::Env;
use crate::github::GitHubRepo;
use crate::model::config::{PackageManager, Strategy};
use crate::path::AbsolutePath;
mod confirm_overwrite;
mod edit_github;
mod enable_git;
mod enable_github;
mod git_strategy;
mod manifest_path;
mod open_editor;
mod select_pms;
#[derive(Debug, Clone)]
pub struct InitResult {
pub cargo_enabled: bool,
pub npm_enabled: bool,
pub cargo_path: Option<String>,
pub npm_path: Option<String>,
pub git_enabled: bool,
pub git_strategy: Option<Strategy>,
pub github_enabled: bool,
pub github_owner: Option<String>,
pub github_repo: Option<String>,
pub detected_github_owner: Option<String>,
pub detected_github_repo: Option<String>,
pub open_editor: bool,
}
#[derive(Debug, Clone)]
struct WizardState {
env: crate::Env,
dry_run: bool,
cargo_enabled: bool,
npm_enabled: bool,
cargo_path: Option<String>,
npm_path: Option<String>,
git_enabled: bool,
git_strategy: Option<Strategy>,
github_enabled: bool,
github_owner: Option<String>,
github_repo: Option<String>,
detected_github: Option<GitHubRepo>,
remaining_manifest_pms: Vec<PackageManager>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PmFocus {
Cargo,
Npm,
}
impl PmFocus {
fn toggle(self) -> Self {
match self {
Self::Cargo => Self::Npm,
Self::Npm => Self::Cargo,
}
}
}
#[derive(Debug)]
enum Screen {
ConfirmOverwrite(bool),
SelectPackageManagers {
cargo: bool,
npm: bool,
focus: PmFocus,
},
ManifestPath {
pm: PackageManager,
textarea: TextArea<'static>,
},
EnableGit(bool),
GitStrategy(Strategy),
EnableGitHub(bool),
EditGitHub {
textarea: TextArea<'static>,
error: bool,
},
OpenEditor(bool),
}
type HandleResult = anyhow::Result<KeyResult<(WizardState, Screen), InitResult>>;
fn bordered_textarea() -> TextArea<'static> {
let mut ta = TextArea::default();
ta.set_block(Block::default().borders(Borders::ALL));
ta
}
fn advance_from_manifest_queue(mut state: WizardState) -> (WizardState, Screen) {
if state.remaining_manifest_pms.is_empty() {
return (state, Screen::EnableGit(true));
}
let pm = state.remaining_manifest_pms.remove(0);
(
state,
Screen::ManifestPath {
pm,
textarea: bordered_textarea(),
},
)
}
fn complete(state: WizardState, open_editor: bool) -> InitResult {
let detected_github_owner = state.detected_github.as_ref().map(|gh| gh.owner.clone());
let detected_github_repo = state.detected_github.as_ref().map(|gh| gh.repo.clone());
InitResult {
cargo_enabled: state.cargo_enabled,
npm_enabled: state.npm_enabled,
cargo_path: state.cargo_path,
npm_path: state.npm_path,
git_enabled: state.git_enabled,
git_strategy: state.git_strategy,
github_enabled: state.github_enabled,
github_owner: state.github_owner,
github_repo: state.github_repo,
detected_github_owner,
detected_github_repo,
open_editor,
}
}
fn detect_package_managers(git_workdir: &AbsolutePath) -> (bool, bool) {
let cargo = git_workdir.child("Cargo.toml").as_path().exists();
let npm = git_workdir.child("package.json").as_path().exists();
(cargo, npm)
}
fn handle_event(
state: WizardState,
screen: Screen,
event: Event,
content_area: Rect,
) -> HandleResult {
let result = match screen {
Screen::ConfirmOverwrite(yes) => confirm_overwrite::ConfirmOverwriteButtons { yes }
.handle_event(state, event, content_area),
Screen::SelectPackageManagers { cargo, npm, focus } => {
select_pms::handle_select_pms(state, cargo, npm, focus, event, content_area)
}
Screen::ManifestPath { pm, textarea } => {
manifest_path::handle_manifest_path(state, pm, textarea, event)
}
Screen::EnableGit(yes) => {
enable_git::EnableGitButtons { yes }.handle_event(state, event, content_area)
}
Screen::GitStrategy(strategy) => {
git_strategy::GitStrategyButtons { strategy }.handle_event(state, event, content_area)
}
Screen::EnableGitHub(yes) => {
enable_github::EnableGitHubButtons { yes }.handle_event(state, event, content_area)
}
Screen::EditGitHub { textarea, error } => {
edit_github::handle_edit_github(state, textarea, error, event)
}
Screen::OpenEditor(yes) => {
open_editor::OpenEditorButtons { yes }.handle_event(state, event, content_area)
}
}?;
Ok(match result {
KeyResult::Continue((state, Screen::OpenEditor(_))) if state.dry_run => {
KeyResult::Complete(complete(state, false))
}
other => other,
})
}
fn tab_states(screen: &Screen) -> [TabStatus; 3] {
match screen {
Screen::ConfirmOverwrite(_)
| Screen::SelectPackageManagers { .. }
| Screen::ManifestPath { .. } => [TabStatus::Current, TabStatus::Future, TabStatus::Future],
Screen::EnableGit(_) | Screen::GitStrategy(_) => {
[TabStatus::Completed, TabStatus::Current, TabStatus::Future]
}
Screen::EnableGitHub(_) | Screen::EditGitHub { .. } | Screen::OpenEditor(_) => [
TabStatus::Completed,
TabStatus::Completed,
TabStatus::Current,
],
}
}
const TAB_HEIGHT: u16 = 3;
fn render_tab_bar(frame: &mut Frame, tab_area: Rect, screen: &Screen) {
let [managers, git, github] = tab_states(screen);
let managers_label_long = crate::t!("select-pms-tab-long");
let managers_label_short = crate::t!("select-pms-tab-short");
let managers_label = if tab_area.width >= 72 {
managers_label_long.as_str()
} else {
managers_label_short.as_str()
};
let tab_git = crate::t!("tab-git");
let tab_github = crate::t!("tab-github");
widgets::render_tabs(
frame,
tab_area,
&[
(managers_label, managers),
(tab_git.as_str(), git),
(tab_github.as_str(), github),
],
);
}
fn ui(frame: &mut Frame, _state: &WizardState, screen: &Screen) {
let full = frame.area();
let tab_area = Rect {
height: TAB_HEIGHT,
..full
};
render_tab_bar(frame, tab_area, screen);
let content_area = Rect {
y: full.y + TAB_HEIGHT,
height: full.height.saturating_sub(TAB_HEIGHT),
..full
};
match screen {
Screen::ConfirmOverwrite(yes) => {
confirm_overwrite::ConfirmOverwriteButtons { yes: *yes }.render(frame, content_area)
}
Screen::SelectPackageManagers { cargo, npm, focus } => {
select_pms::render_select_pms(frame, content_area, *cargo, *npm, *focus)
}
Screen::ManifestPath { pm, textarea } => {
manifest_path::render_manifest_path(frame, content_area, *pm, textarea)
}
Screen::EnableGit(yes) => {
enable_git::EnableGitButtons { yes: *yes }.render(frame, content_area)
}
Screen::GitStrategy(strategy) => git_strategy::GitStrategyButtons {
strategy: *strategy,
}
.render(frame, content_area),
Screen::EnableGitHub(yes) => {
enable_github::EnableGitHubButtons { yes: *yes }.render(frame, content_area)
}
Screen::EditGitHub { textarea, error } => {
edit_github::render_edit_github(frame, content_area, textarea, *error)
}
Screen::OpenEditor(yes) => {
open_editor::OpenEditorButtons { yes: *yes }.render(frame, content_area)
}
}
}
pub fn run(
env: &Env,
dry_run: bool,
detected_github: Option<crate::github::remote::GitHubRepo>,
) -> anyhow::Result<Option<InitResult>> {
let git = env.git();
let (cargo_detected, npm_detected) = detect_package_managers(git.path());
let initial_state = WizardState {
env: env.clone(),
dry_run,
cargo_enabled: false,
npm_enabled: false,
cargo_path: None,
npm_path: None,
git_enabled: false,
git_strategy: None,
github_enabled: false,
github_owner: None,
github_repo: None,
detected_github,
remaining_manifest_pms: Vec::new(),
};
let config_path = git.path().child(".cursus/config.toml");
let initial_screen = if !dry_run && config_path.as_path().exists() {
Screen::ConfirmOverwrite(false)
} else {
Screen::SelectPackageManagers {
cargo: cargo_detected,
npm: npm_detected,
focus: PmFocus::Cargo,
}
};
widgets::run_tui(
(initial_state, initial_screen),
|frame, (state, screen)| ui(frame, state, screen),
|(state, screen), event, frame_area| {
let content_area = Rect {
y: frame_area.y + TAB_HEIGHT,
height: frame_area.height.saturating_sub(TAB_HEIGHT),
..frame_area
};
handle_event(state, screen, event, content_area)
},
)
}
#[cfg(test)]
fn handle_key(state: WizardState, screen: Screen, k: crossterm::event::KeyEvent) -> HandleResult {
handle_event(
state,
screen,
Event::Key(k),
Rect::new(0, TAB_HEIGHT, 80, 24 - TAB_HEIGHT),
)
}
#[cfg(test)]
pub(super) mod test_helpers {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use tempfile::TempDir;
pub(super) use crate::tui::test_utils::mouse_click;
pub(super) const fn test_content_area() -> Rect {
Rect::new(0, TAB_HEIGHT, 80, 24 - TAB_HEIGHT)
}
pub(super) fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
pub(super) fn temp_dir() -> TempDir {
tempfile::tempdir().expect("Failed to create temp dir")
}
pub(super) fn make_state(dir: &TempDir) -> WizardState {
use std::sync::Arc;
let path = crate::path::AbsolutePath::new(dir.path()).unwrap();
let runner: Arc<dyn crate::command::CommandRunner> =
Arc::new(crate::command::test_support::RecordingCommandRunner::new(0));
let git = Arc::new(crate::git::GitWorkdir::new(Arc::clone(&runner), path));
let env = crate::Env::new(runner, Arc::new(crate::filesystem::LocalFilesystem), git);
WizardState {
env,
dry_run: false,
cargo_enabled: false,
npm_enabled: false,
cargo_path: None,
npm_path: None,
git_enabled: false,
git_strategy: None,
github_enabled: false,
github_owner: None,
github_repo: None,
detected_github: None,
remaining_manifest_pms: Vec::new(),
}
}
pub(super) fn unwrap_continue(result: HandleResult) -> (WizardState, Screen) {
match result.unwrap() {
KeyResult::Continue(s) => s,
other => panic!("Expected Continue, got {other:?}"),
}
}
pub(super) fn unwrap_complete(result: HandleResult) -> InitResult {
match result.unwrap() {
KeyResult::Complete(r) => r,
other => panic!("Expected Complete, got {other:?}"),
}
}
pub(super) fn assert_cancelled(result: HandleResult) {
assert!(
matches!(result.unwrap(), KeyResult::Cancelled),
"Expected Cancelled"
);
}
}
#[cfg(test)]
mod tests {
use crossterm::event::KeyCode;
use super::test_helpers::*;
use super::*;
#[test]
fn tab_states_managers_screens_show_current_managers() {
let [m, g, gh] = tab_states(&Screen::SelectPackageManagers {
cargo: true,
npm: false,
focus: PmFocus::Cargo,
});
assert_eq!(m, TabStatus::Current);
assert_eq!(g, TabStatus::Future);
assert_eq!(gh, TabStatus::Future);
}
#[test]
fn tab_states_git_screens_show_completed_managers() {
let [m, g, gh] = tab_states(&Screen::EnableGit(true));
assert_eq!(m, TabStatus::Completed);
assert_eq!(g, TabStatus::Current);
assert_eq!(gh, TabStatus::Future);
}
#[test]
fn tab_states_github_screens_show_completed_git() {
let [m, g, gh] = tab_states(&Screen::OpenEditor(false));
assert_eq!(m, TabStatus::Completed);
assert_eq!(g, TabStatus::Completed);
assert_eq!(gh, TabStatus::Current);
}
#[test]
fn detect_package_managers_defaults_to_neither() {
let dir = temp_dir();
let (cargo, npm) =
detect_package_managers(&crate::path::AbsolutePath::new(dir.path()).unwrap());
assert!(!cargo);
assert!(!npm);
}
#[test]
fn detect_package_managers_detects_cargo() {
let dir = temp_dir();
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
let (cargo, npm) =
detect_package_managers(&crate::path::AbsolutePath::new(dir.path()).unwrap());
assert!(cargo);
assert!(!npm);
}
#[test]
fn detect_package_managers_detects_npm() {
let dir = temp_dir();
std::fs::write(dir.path().join("package.json"), "{}").unwrap();
let (cargo, npm) =
detect_package_managers(&crate::path::AbsolutePath::new(dir.path()).unwrap());
assert!(!cargo);
assert!(npm);
}
#[test]
fn detect_package_managers_detects_both() {
let dir = temp_dir();
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
std::fs::write(dir.path().join("package.json"), "{}").unwrap();
let (cargo, npm) =
detect_package_managers(&crate::path::AbsolutePath::new(dir.path()).unwrap());
assert!(cargo);
assert!(npm);
}
#[test]
fn workflow_cargo_only_git_disabled() {
let dir = temp_dir();
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
let state = make_state(&dir);
let screen = Screen::SelectPackageManagers {
cargo: true,
npm: false,
focus: PmFocus::Cargo,
};
let (state, screen) = unwrap_continue(handle_key(state, screen, key(KeyCode::Enter)));
assert!(matches!(screen, Screen::EnableGit(_)));
let (state, screen) = unwrap_continue(handle_key(
state,
Screen::EnableGit(false),
key(KeyCode::Enter),
));
assert!(matches!(screen, Screen::OpenEditor(_)));
let result = unwrap_complete(handle_key(
state,
Screen::OpenEditor(false),
key(KeyCode::Enter),
));
assert!(result.cargo_enabled);
assert!(!result.npm_enabled);
assert!(!result.git_enabled);
assert!(!result.github_enabled);
assert!(!result.open_editor);
}
#[test]
fn workflow_branch_strategy_skips_enable_github() {
let dir = temp_dir();
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
let mut state = make_state(&dir);
state.git_enabled = true;
let (state, screen) = unwrap_continue(handle_key(
state,
Screen::GitStrategy(Strategy::Branch),
key(KeyCode::Enter),
));
assert!(state.github_enabled);
assert!(matches!(screen, Screen::EditGitHub { .. }));
let (_, screen) = unwrap_continue(handle_key(state, screen, key(KeyCode::Enter)));
assert!(matches!(screen, Screen::OpenEditor(_)));
}
#[test]
fn workflow_complete_state_preserved_in_result() {
let dir = temp_dir();
let mut state = make_state(&dir);
state.cargo_enabled = true;
state.npm_enabled = true;
state.git_enabled = true;
state.git_strategy = Some(Strategy::Push);
state.github_enabled = true;
state.github_owner = Some("acme".to_string());
state.github_repo = Some("my-app".to_string());
let result = unwrap_complete(handle_key(
state,
Screen::OpenEditor(true),
key(KeyCode::Enter),
));
assert!(result.cargo_enabled);
assert!(result.npm_enabled);
assert!(result.git_enabled);
assert_eq!(result.git_strategy, Some(Strategy::Push));
assert!(result.github_enabled);
assert_eq!(result.github_owner, Some("acme".to_string()));
assert_eq!(result.github_repo, Some("my-app".to_string()));
assert!(result.open_editor);
}
#[test]
fn dry_run_skips_open_editor_and_completes() {
let dir = temp_dir();
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
let mut state = make_state(&dir);
state.dry_run = true;
let result = handle_key(state, Screen::EnableGit(false), key(KeyCode::Enter));
let init_result = unwrap_complete(result);
assert!(!init_result.open_editor, "dry_run must skip the editor");
}
#[test]
fn narrow_terminal_uses_short_managers_label() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(71, 24);
let mut terminal = Terminal::new(backend).unwrap();
let dir = temp_dir();
let state = make_state(&dir);
let screen = Screen::SelectPackageManagers {
cargo: true,
npm: false,
focus: PmFocus::Cargo,
};
terminal.draw(|frame| ui(frame, &state, &screen)).unwrap();
let content = crate::tui::test_utils::buffer_to_string(terminal.backend().buffer());
let tab_area: String = content
.lines()
.take(TAB_HEIGHT as usize)
.collect::<Vec<_>>()
.join("\n");
assert!(
tab_area.contains("Managers"),
"Short label must appear in tab on narrow terminal, got: {tab_area:?}"
);
assert!(
!tab_area.contains("Package Managers"),
"Long label must not appear in tab on narrow terminal, got: {tab_area:?}"
);
}
}