use crate::app::Message;
use crate::theme;
use bombadil_core::members::{self, DiscoveredMember, WorkspaceKind};
use bombadil_core::model::{MemberOrigin, MemberRef, MemberSelection};
use bombadil_core::uv::results::InstalledPackage;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemberRow {
pub path: PathBuf,
pub name: Option<String>,
pub origin: MemberOrigin,
pub selected: bool,
pub installed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct MembersTab {
pub rows: Vec<MemberRow>,
}
fn candidates(discovered: &[DiscoveredMember], kind: &WorkspaceKind) -> Vec<DiscoveredMember> {
match kind {
WorkspaceKind::Workspace { .. } => discovered
.iter()
.filter(|m| m.is_workspace_member)
.cloned()
.collect(),
WorkspaceKind::Loose => discovered.to_vec(),
}
}
pub fn is_installed(
project_dir: &Path,
member_path: &Path,
installed: &[InstalledPackage],
) -> bool {
let absolute = if member_path.is_absolute() {
member_path.to_path_buf()
} else {
project_dir.join(member_path)
};
installed
.iter()
.any(|pkg| pkg.editable_project_location.as_deref() == Some(absolute.as_path()))
}
pub fn build(
project_dir: &Path,
selection: &MemberSelection,
kind: &WorkspaceKind,
discovered: &[DiscoveredMember],
installed: &[InstalledPackage],
) -> MembersTab {
let candidates = candidates(discovered, kind);
let effective = members::effective(selection, &candidates);
let rows = effective
.into_iter()
.map(|m| MemberRow {
installed: is_installed(project_dir, &m.path, installed),
path: m.path,
name: m.name,
origin: m.origin,
selected: m.selected,
})
.collect();
MembersTab { rows }
}
pub fn will_remove(rows: &[MemberRow]) -> Vec<&MemberRow> {
rows.iter().filter(|r| r.installed && !r.selected).collect()
}
fn display_label(row: &MemberRow) -> String {
row.name
.clone()
.unwrap_or_else(|| row.path.display().to_string())
}
pub fn removal_warning(rows: &[MemberRow]) -> Option<String> {
let removed = will_remove(rows);
if removed.is_empty() {
return None;
}
let names: Vec<String> = removed.iter().map(|r| display_label(r)).collect();
Some(format!(
"Syncing now will remove the following from the environment, since they are no longer selected: {}.",
names.join(", ")
))
}
pub fn toggle(
selection: &mut MemberSelection,
discovered: &[DiscoveredMember],
kind: &WorkspaceKind,
path: &Path,
) {
let candidates = candidates(discovered, kind);
let effective = members::effective(selection, &candidates);
let Some(current) = effective.iter().find(|m| m.path == path) else {
return;
};
let new_selected = !current.selected;
if let Some(entry) = selection.entries.iter_mut().find(|e| e.path == path) {
entry.selected = new_selected;
} else {
selection.entries.push(MemberRef {
path: path.to_path_buf(),
origin: current.origin,
selected: new_selected,
name: current.name.clone(),
});
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SyncPlan {
Plain,
AllPackages,
Named(Vec<String>),
}
pub fn sync_plan(
selection: &MemberSelection,
kind: &WorkspaceKind,
discovered: &[DiscoveredMember],
) -> SyncPlan {
let candidates = candidates(discovered, kind);
if candidates.is_empty() && selection.entries.is_empty() {
return SyncPlan::Plain;
}
let effective = members::effective(selection, &candidates);
if !effective.is_empty() && effective.iter().all(|m| m.selected) {
SyncPlan::AllPackages
} else {
SyncPlan::Named(members::selected_package_names(&effective))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfirmMemberSync {
pub project_index: usize,
pub label: String,
pub warning: String,
}
pub fn state(row: &MemberRow) -> theme::State {
if !row.installed {
theme::State::Absent
} else if row.selected {
theme::State::Present
} else {
theme::State::Drifted
}
}
pub fn confirm_view<'a>(confirm: &ConfirmMemberSync) -> iced::Element<'a, Message> {
iced::widget::column![
iced::widget::text(format!("{}\n\n{}", confirm.label, confirm.warning)),
iced::widget::button(iced::widget::text("Sync anyway"))
.on_press(Message::MembersSyncConfirmed),
iced::widget::button(iced::widget::text("Cancel")).on_press(Message::MembersSyncCancelled),
]
.spacing(theme::SPACE_2)
.into()
}
fn member_row<'a>(row: &MemberRow, project_index: usize) -> iced::Element<'a, Message> {
let glyph = theme::state_glyph(state(row));
let path = row.path.clone();
let checkbox = iced::widget::checkbox(row.selected)
.label(display_label(row))
.font(theme::FONT_DATA)
.text_size(theme::DATA)
.on_toggle_maybe(
row.name
.is_some()
.then_some(move |_| Message::MemberToggled(project_index, path.clone())),
);
let mut line = iced::widget::row![
iced::widget::text(glyph.glyph.to_string())
.size(theme::BODY)
.color(glyph.colour)
.width(theme::GLYPH_COLUMN_WIDTH),
checkbox,
]
.spacing(theme::SPACE_1);
if row.name.is_none() {
line = line.push(
iced::widget::text("(no [project].name -- cannot be installed)")
.font(theme::FONT_PROSE)
.size(theme::DATA)
.color(theme::SLATE),
);
}
iced::widget::container(line)
.padding(theme::SPACE_1)
.style(|_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(theme::BARK)),
..iced::widget::container::Style::default()
})
.into()
}
pub fn view<'a>(tab: &MembersTab, project_index: usize) -> iced::Element<'a, Message> {
let mut column = iced::widget::column![
iced::widget::text("Members")
.font(theme::FONT_PROSE_SEMIBOLD)
.size(theme::TITLE)
]
.spacing(theme::SPACE_2);
if tab.rows.is_empty() {
column = column.push(
iced::widget::text("No additional pyproject.toml files found below this project.")
.size(theme::BODY)
.color(theme::SLATE),
);
}
for row in &tab.rows {
column = column.push(member_row(row, project_index));
}
if let Some(warning) = removal_warning(&tab.rows) {
column = column.push(
iced::widget::text(warning)
.size(theme::BODY)
.color(theme::BOOT),
);
}
column = column.push(
iced::widget::button(iced::widget::text("Sync members"))
.on_press(Message::MembersSyncRequested(project_index)),
);
column.into()
}
#[cfg(test)]
mod tests {
use super::*;
fn discovered(path: &str, name: Option<&str>, is_workspace_member: bool) -> DiscoveredMember {
DiscoveredMember {
relative_path: PathBuf::from(path),
name: name.map(String::from),
is_workspace_member,
}
}
fn pkg(path: &str) -> InstalledPackage {
InstalledPackage {
name: "irrelevant-for-the-join".to_string(),
version: "1.0".to_string(),
editable_project_location: Some(PathBuf::from(path)),
}
}
fn workspace(globs: &[&str], exclude: &[&str]) -> WorkspaceKind {
WorkspaceKind::Workspace {
member_globs: globs.iter().map(|s| s.to_string()).collect(),
exclude_globs: exclude.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn a_nameless_member_is_still_reported_installed_when_its_path_matches() {
let installed = vec![pkg("/work/repo/tooling")];
let got = is_installed(Path::new("/work/repo"), Path::new("tooling"), &installed);
assert!(got, "a nameless member must still join on its path");
}
#[test]
fn a_named_member_at_a_different_path_is_not_reported_installed() {
let installed = vec![pkg("/work/repo/packages/other")];
let got = is_installed(
Path::new("/work/repo"),
Path::new("packages/alpha"),
&installed,
);
assert!(!got);
}
#[test]
fn an_added_members_absolute_path_is_used_as_is() {
let installed = vec![pkg("/work/shared-lib")];
let got = is_installed(
Path::new("/work/repo"),
Path::new("/work/shared-lib"),
&installed,
);
assert!(
got,
"an absolute member path must not be re-joined onto project_dir"
);
}
#[test]
fn build_marks_a_nameless_discovered_member_installed_via_the_path_join() {
let discovered_members = [discovered("tooling", None, true)];
let installed = vec![pkg("/work/repo/tooling")];
let tab = build(
Path::new("/work/repo"),
&MemberSelection::default(),
&workspace(&["*"], &[]),
&discovered_members,
&installed,
);
let row = tab.rows.first().expect("one row");
assert_eq!(row.name, None);
assert!(
row.installed,
"a nameless member's installed state must still be reported, joined by path"
);
}
#[test]
fn every_discovered_member_is_selected_by_default() {
let discovered_members = [
discovered("packages/alpha", Some("alpha"), true),
discovered("packages/beta", Some("beta"), true),
];
let tab = build(
Path::new("/work/repo"),
&MemberSelection::default(),
&workspace(&["packages/*"], &[]),
&discovered_members,
&[],
);
assert_eq!(tab.rows.len(), 2, "got {:?}", tab.rows);
assert!(
tab.rows.iter().all(|r| r.selected),
"a fresh selection must not leave anything deselected -- an empty \
default means uv sync installs no members at all; got {:?}",
tab.rows
);
}
#[test]
fn an_explicit_deselection_survives_even_though_all_discovered_is_true() {
let discovered_members = [discovered("packages/alpha", Some("alpha"), true)];
let selection = MemberSelection {
all_discovered: true,
entries: vec![MemberRef {
path: PathBuf::from("packages/alpha"),
origin: MemberOrigin::Discovered,
selected: false,
name: None,
}],
};
let tab = build(
Path::new("/work/repo"),
&selection,
&workspace(&["packages/*"], &[]),
&discovered_members,
&[],
);
assert!(!tab.rows[0].selected, "got {:?}", tab.rows);
}
#[test]
fn toggle_reads_the_current_effective_state_before_flipping_it() {
let discovered_members = [discovered("packages/alpha", Some("alpha"), true)];
let kind = workspace(&["packages/*"], &[]);
let mut selection = MemberSelection::default();
toggle(
&mut selection,
&discovered_members,
&kind,
Path::new("packages/alpha"),
);
let tab = build(
Path::new("/work/repo"),
&selection,
&kind,
&discovered_members,
&[],
);
assert!(
!tab.rows[0].selected,
"toggling a selected-by-default member must deselect it"
);
toggle(
&mut selection,
&discovered_members,
&kind,
Path::new("packages/alpha"),
);
let tab = build(
Path::new("/work/repo"),
&selection,
&kind,
&discovered_members,
&[],
);
assert!(tab.rows[0].selected, "toggling again must reselect it");
}
#[test]
fn a_deselected_installed_member_is_reported_as_something_syncing_will_remove() {
let rows = vec![MemberRow {
path: PathBuf::from("packages/alpha"),
name: Some("alpha".to_string()),
origin: MemberOrigin::Discovered,
selected: false,
installed: true,
}];
let warning = removal_warning(&rows).expect("must warn before the user confirms");
assert!(warning.contains("alpha"), "got {warning:?}");
}
#[test]
fn a_deselected_member_that_was_never_installed_is_not_reported_as_removed() {
let rows = vec![MemberRow {
path: PathBuf::from("packages/alpha"),
name: Some("alpha".to_string()),
origin: MemberOrigin::Discovered,
selected: false,
installed: false,
}];
assert!(removal_warning(&rows).is_none());
}
#[test]
fn a_selected_installed_member_is_not_reported_as_removed() {
let rows = vec![MemberRow {
path: PathBuf::from("packages/alpha"),
name: Some("alpha".to_string()),
origin: MemberOrigin::Discovered,
selected: true,
installed: true,
}];
assert!(removal_warning(&rows).is_none());
}
#[test]
fn deselecting_a_member_shrinks_the_named_sync_plan_to_omit_it() {
let discovered_members = [
discovered("packages/alpha", Some("alpha"), true),
discovered("packages/beta", Some("beta"), true),
];
let kind = workspace(&["packages/*"], &[]);
let mut selection = MemberSelection::default();
toggle(
&mut selection,
&discovered_members,
&kind,
Path::new("packages/alpha"),
);
match sync_plan(&selection, &kind, &discovered_members) {
SyncPlan::Named(names) => {
assert_eq!(names, vec!["beta".to_string()], "got {names:?}");
}
other => panic!("expected Named, got {other:?}"),
}
}
#[test]
fn selecting_everything_syncs_with_all_packages_not_a_hand_assembled_list() {
let discovered_members = [
discovered("packages/alpha", Some("alpha"), true),
discovered("packages/beta", Some("beta"), true),
];
let kind = workspace(&["packages/*"], &[]);
let selection = MemberSelection::default();
assert_eq!(
sync_plan(&selection, &kind, &discovered_members),
SyncPlan::AllPackages
);
}
#[test]
fn an_ordinary_project_with_no_members_at_all_needs_no_member_aware_sync() {
let selection = MemberSelection::default();
assert_eq!(
sync_plan(&selection, &WorkspaceKind::Loose, &[]),
SyncPlan::Plain
);
}
#[test]
fn an_excluded_workspace_member_never_becomes_a_row() {
let discovered_members = [
discovered("packages/alpha", Some("alpha"), true),
discovered("packages/skipme", Some("skipme"), false),
];
let tab = build(
Path::new("/work/repo"),
&MemberSelection::default(),
&workspace(&["packages/*"], &["packages/skipme"]),
&discovered_members,
&[],
);
let names: Vec<Option<String>> = tab.rows.iter().map(|r| r.name.clone()).collect();
assert_eq!(
names,
vec![Some("alpha".to_string())],
"an excluded member must never be offered -- passing it to --package \
would abort the entire sync; got {names:?}"
);
}
#[test]
fn an_excluded_member_never_appears_in_the_package_list_even_with_a_stale_selection_entry() {
let discovered_members = [
discovered("packages/alpha", Some("alpha"), true),
discovered("packages/beta", Some("beta"), true),
discovered("packages/skipme", Some("skipme"), false),
];
let kind = workspace(&["packages/*"], &["packages/skipme"]);
let selection = MemberSelection {
all_discovered: true,
entries: vec![
MemberRef {
path: PathBuf::from("packages/alpha"),
origin: MemberOrigin::Discovered,
selected: false,
name: Some("alpha".to_string()),
},
MemberRef {
path: PathBuf::from("packages/skipme"),
origin: MemberOrigin::Discovered,
selected: true,
name: Some("skipme".to_string()),
},
],
};
match sync_plan(&selection, &kind, &discovered_members) {
SyncPlan::Named(names) => {
assert_eq!(names, vec!["beta".to_string()], "got {names:?}");
}
other => panic!("expected Named, got {other:?}"),
}
}
#[test]
fn a_loose_project_offers_every_discovered_manifest_despite_is_workspace_member_being_false() {
let discovered_members = [
discovered("services/api", Some("api"), false),
discovered("services/worker", Some("worker"), false),
];
let tab = build(
Path::new("/work/repo"),
&MemberSelection::default(),
&WorkspaceKind::Loose,
&discovered_members,
&[],
);
assert_eq!(tab.rows.len(), 2, "got {:?}", tab.rows);
}
fn member_row(selected: bool, installed: bool) -> MemberRow {
MemberRow {
path: PathBuf::from("packages/alpha"),
name: Some("alpha".to_string()),
origin: MemberOrigin::Discovered,
selected,
installed,
}
}
#[test]
fn a_selected_installed_member_is_present() {
assert_eq!(state(&member_row(true, true)), theme::State::Present);
}
#[test]
fn a_discovered_deselected_never_installed_member_is_absent() {
assert_eq!(state(&member_row(false, false)), theme::State::Absent);
}
#[test]
fn a_selected_not_yet_installed_member_is_absent() {
assert_eq!(state(&member_row(true, false)), theme::State::Absent);
}
#[test]
fn a_deselected_but_still_installed_member_is_drifted() {
assert_eq!(state(&member_row(false, true)), theme::State::Drifted);
}
}