use crate::app::Message;
use crate::theme;
use bombadil_core::model::{Config, Script};
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScriptScope {
Global,
Project(usize),
}
pub fn scripts_mut(config: &mut Config, scope: ScriptScope) -> Option<&mut Vec<Script>> {
match scope {
ScriptScope::Global => Some(&mut config.global_scripts),
ScriptScope::Project(i) => config.projects.get_mut(i).map(|p| &mut p.scripts),
}
}
pub fn add_script(scripts: &mut Vec<Script>, path: PathBuf) {
scripts.push(Script {
path,
args: Vec::new(),
enabled: true,
});
}
pub fn remove_script(scripts: &mut Vec<Script>, index: usize) {
if index < scripts.len() {
scripts.remove(index);
}
}
pub fn toggle_enabled(scripts: &mut [Script], index: usize) {
if let Some(script) = scripts.get_mut(index) {
script.enabled = !script.enabled;
}
}
pub fn move_up(scripts: &mut [Script], index: usize) {
if index != 0 && index < scripts.len() {
scripts.swap(index - 1, index);
}
}
pub fn move_down(scripts: &mut [Script], index: usize) {
if index + 1 < scripts.len() {
scripts.swap(index, index + 1);
}
}
pub fn execution_order<'a>(global: &'a [Script], project: &'a [Script]) -> Vec<&'a Script> {
global.iter().chain(project.iter()).collect()
}
pub const ORDER_NOTE: &str = "Global scripts run first, in the order shown; project scripts run after them, in their own order.";
fn rows<'a>(scope: ScriptScope, scripts: &[Script]) -> iced::Element<'a, Message> {
let mut column = iced::widget::column![].spacing(theme::SPACE_2);
let last_index = scripts.len().saturating_sub(1);
for (index, script) in scripts.iter().enumerate() {
let path_text = iced::widget::text(script.path.display().to_string())
.font(theme::FONT_DATA)
.size(theme::DATA);
let enabled_toggle = iced::widget::checkbox(script.enabled)
.label("enabled")
.on_toggle(move |_| Message::ScriptEnabledToggled(scope, index));
let move_up_button = iced::widget::button(iced::widget::text("up"))
.on_press_maybe((index != 0).then_some(Message::ScriptMovedUp(scope, index)));
let move_down_button = iced::widget::button(iced::widget::text("down")).on_press_maybe(
(index != last_index).then_some(Message::ScriptMovedDown(scope, index)),
);
let remove = iced::widget::button(iced::widget::text("remove"))
.on_press(Message::ScriptRemoved(scope, index));
let row = iced::widget::row![
path_text,
enabled_toggle,
move_up_button,
move_down_button,
remove
]
.spacing(theme::SPACE_2);
column = column.push(iced::widget::container(row).padding(theme::SPACE_1).style(
|_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(theme::BARK)),
..iced::widget::container::Style::default()
},
));
}
column.into()
}
pub fn global_view<'a>(config: &Config) -> iced::Element<'a, Message> {
iced::widget::column![
iced::widget::text(ORDER_NOTE)
.size(theme::BODY)
.color(theme::SLATE),
rows(ScriptScope::Global, &config.global_scripts),
iced::widget::button(iced::widget::text("Add global script").size(theme::BODY))
.on_press(Message::ScriptAddRequested(ScriptScope::Global))
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_quiet),
]
.spacing(theme::SPACE_3)
.into()
}
pub fn view<'a>(config: &Config, selected_project: Option<usize>) -> iced::Element<'a, Message> {
let mut column = iced::widget::column![
iced::widget::text("Scripts")
.font(theme::FONT_PROSE_SEMIBOLD)
.size(theme::TITLE),
iced::widget::text(ORDER_NOTE)
.size(theme::BODY)
.color(theme::SLATE),
iced::widget::text("Global (runs first)")
.font(theme::FONT_PROSE)
.size(theme::LABEL)
.color(theme::SLATE),
rows(ScriptScope::Global, &config.global_scripts),
iced::widget::button(iced::widget::text("Add global script"))
.on_press(Message::ScriptAddRequested(ScriptScope::Global)),
]
.spacing(theme::SPACE_3);
if let Some(i) = selected_project
&& let Some(project) = config.projects.get(i)
{
column = column.push(
iced::widget::text("Project (runs after global)")
.font(theme::FONT_PROSE)
.size(theme::LABEL)
.color(theme::SLATE),
);
column = column.push(rows(ScriptScope::Project(i), &project.scripts));
column = column.push(
iced::widget::button(iced::widget::text("Add project script"))
.on_press(Message::ScriptAddRequested(ScriptScope::Project(i))),
);
let total = execution_order(&config.global_scripts, &project.scripts).len();
column = column.push(
iced::widget::text(format!(
"{total} script(s) will run for this project, in the order above"
))
.size(theme::BODY)
.color(theme::SLATE),
);
}
column.into()
}
#[cfg(test)]
mod tests {
use super::*;
use bombadil_core::model::Project;
use uuid::Uuid;
fn script(path: &str, enabled: bool) -> Script {
Script {
path: PathBuf::from(path),
args: Vec::new(),
enabled,
}
}
fn project_with_scripts(scripts: Vec<Script>) -> Project {
Project {
id: Uuid::from_u128(1),
label: "api".into(),
pyproject_path: PathBuf::from("/p/api/pyproject.toml"),
environments: vec![bombadil_core::model::Environment {
location: bombadil_core::model::VenvLocation::Alongside,
python: bombadil_core::model::PythonPin::Unpinned,
}],
active: bombadil_core::model::VenvLocation::Alongside,
scripts,
..Project::default()
}
}
#[test]
fn global_scope_addresses_the_global_list() {
let mut config = Config {
global_scripts: vec![script("/etc/pre.sh", true)],
..Config::default()
};
let scripts = scripts_mut(&mut config, ScriptScope::Global).expect("global always exists");
assert_eq!(scripts.len(), 1);
assert_eq!(scripts[0].path, PathBuf::from("/etc/pre.sh"));
}
#[test]
fn project_scope_addresses_that_projects_own_list_not_global() {
let mut config = Config {
global_scripts: vec![script("/etc/global.sh", true)],
projects: vec![project_with_scripts(vec![script("/p/api/local.sh", true)])],
..Config::default()
};
let scripts = scripts_mut(&mut config, ScriptScope::Project(0)).expect("project 0 exists");
assert_eq!(scripts.len(), 1);
assert_eq!(
scripts[0].path,
PathBuf::from("/p/api/local.sh"),
"must address the project's own scripts, not global_scripts"
);
}
#[test]
fn a_stale_project_index_addresses_nothing() {
let mut config = Config::default();
assert!(scripts_mut(&mut config, ScriptScope::Project(0)).is_none());
}
#[test]
fn add_script_appends_an_enabled_row_with_no_args() {
let mut scripts = Vec::new();
add_script(&mut scripts, PathBuf::from("/p/api/setup.sh"));
assert_eq!(scripts.len(), 1);
assert_eq!(scripts[0].path, PathBuf::from("/p/api/setup.sh"));
assert!(scripts[0].args.is_empty());
assert!(scripts[0].enabled, "a newly added script must run");
}
#[test]
fn remove_script_drops_the_row_at_index() {
let mut scripts = vec![script("/a.sh", true), script("/b.sh", true)];
remove_script(&mut scripts, 0);
assert_eq!(scripts, vec![script("/b.sh", true)]);
}
#[test]
fn remove_script_out_of_range_is_a_no_op() {
let mut scripts = vec![script("/a.sh", true)];
remove_script(&mut scripts, 5);
assert_eq!(scripts.len(), 1);
}
#[test]
fn toggling_enabled_flips_the_flag_without_removing_the_script() {
let mut scripts = vec![script("/a.sh", true)];
toggle_enabled(&mut scripts, 0);
assert_eq!(
scripts.len(),
1,
"a disabled script must still be in the list, not removed"
);
assert!(!scripts[0].enabled);
assert_eq!(
scripts[0].path,
PathBuf::from("/a.sh"),
"the same script must still be there, not replaced"
);
}
#[test]
fn toggling_enabled_twice_returns_to_the_original_state() {
let mut scripts = vec![script("/a.sh", true)];
toggle_enabled(&mut scripts, 0);
toggle_enabled(&mut scripts, 0);
assert!(scripts[0].enabled);
assert_eq!(scripts.len(), 1);
}
#[test]
fn toggling_enabled_out_of_range_is_a_no_op() {
let mut scripts = vec![script("/a.sh", true)];
toggle_enabled(&mut scripts, 5);
assert_eq!(scripts.len(), 1);
assert!(scripts[0].enabled);
}
#[test]
fn move_up_swaps_with_the_previous_row() {
let mut scripts = vec![script("/a.sh", true), script("/b.sh", true)];
move_up(&mut scripts, 1);
assert_eq!(
scripts,
vec![script("/b.sh", true), script("/a.sh", true)],
"got {scripts:?}"
);
}
#[test]
fn move_up_at_the_first_row_is_a_no_op() {
let mut scripts = vec![script("/a.sh", true), script("/b.sh", true)];
move_up(&mut scripts, 0);
assert_eq!(scripts, vec![script("/a.sh", true), script("/b.sh", true)]);
}
#[test]
fn move_down_swaps_with_the_next_row() {
let mut scripts = vec![script("/a.sh", true), script("/b.sh", true)];
move_down(&mut scripts, 0);
assert_eq!(
scripts,
vec![script("/b.sh", true), script("/a.sh", true)],
"got {scripts:?}"
);
}
#[test]
fn move_down_at_the_last_row_is_a_no_op() {
let mut scripts = vec![script("/a.sh", true), script("/b.sh", true)];
move_down(&mut scripts, 1);
assert_eq!(scripts, vec![script("/a.sh", true), script("/b.sh", true)]);
}
#[test]
fn move_up_out_of_range_is_a_no_op() {
let mut scripts = vec![script("/a.sh", true)];
move_up(&mut scripts, 5);
assert_eq!(scripts.len(), 1);
}
#[test]
fn move_down_out_of_range_is_a_no_op() {
let mut scripts = vec![script("/a.sh", true)];
move_down(&mut scripts, 5);
assert_eq!(scripts.len(), 1);
}
#[test]
fn execution_order_is_every_global_script_then_every_project_script() {
let global = vec![script("/g1.sh", true), script("/g2.sh", true)];
let project = vec![script("/p1.sh", true), script("/p2.sh", true)];
let order = execution_order(&global, &project);
let paths: Vec<_> = order.iter().map(|s| s.path.to_str().unwrap()).collect();
assert_eq!(
paths,
vec!["/g1.sh", "/g2.sh", "/p1.sh", "/p2.sh"],
"every global script must run before any project script"
);
}
#[test]
fn execution_order_preserves_each_scopes_own_order() {
let global = vec![script("/z-global.sh", true), script("/a-global.sh", true)];
let project: Vec<Script> = vec![];
let order = execution_order(&global, &project);
let paths: Vec<_> = order.iter().map(|s| s.path.to_str().unwrap()).collect();
assert_eq!(paths, vec!["/z-global.sh", "/a-global.sh"]);
}
#[test]
fn a_disabled_script_still_appears_in_execution_order() {
let global = vec![script("/enabled.sh", true), script("/disabled.sh", false)];
let order = execution_order(&global, &[]);
assert_eq!(order.len(), 2, "got {order:?}");
assert!(!order[1].enabled);
}
#[test]
fn the_order_note_says_global_scripts_run_first() {
assert!(ORDER_NOTE.to_lowercase().contains("global"));
assert!(ORDER_NOTE.to_lowercase().contains("first"));
assert!(ORDER_NOTE.to_lowercase().contains("project"));
}
}