use crate::app::Message;
use crate::theme;
use bombadil_core::model::{Config, Index, IndexAuth, IndexKind};
use bombadil_core::secrets::SecretKey;
use bombadil_core::validate;
pub const CREDENTIAL_PLACEHOLDER: &str = "credential (write-only; never shown once saved)";
#[derive(Clone)]
pub struct CredentialInput(pub String);
impl std::fmt::Debug for CredentialInput {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "CredentialInput(<redacted>)")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthChoice {
None,
Basic,
Token,
}
pub fn add_index(indexes: &mut Vec<Index>) {
indexes.push(Index {
name: String::new(),
url: String::new(),
kind: IndexKind::Extra,
default_for_new_projects: false,
auth: IndexAuth::None,
});
}
pub fn remove_index(indexes: &mut Vec<Index>, i: usize) {
if i < indexes.len() {
indexes.remove(i);
}
}
pub fn set_name(indexes: &mut [Index], i: usize, name: String) {
if let Some(index) = indexes.get_mut(i) {
index.name = name;
}
}
pub fn set_url(indexes: &mut [Index], i: usize, url: String) {
if let Some(index) = indexes.get_mut(i) {
index.url = url;
}
}
pub fn set_kind(indexes: &mut [Index], i: usize, kind: IndexKind) {
if let Some(index) = indexes.get_mut(i) {
index.kind = kind;
}
}
pub fn toggle_default_for_new_projects(indexes: &mut [Index], i: usize) {
if let Some(index) = indexes.get_mut(i) {
index.default_for_new_projects = !index.default_for_new_projects;
}
}
pub fn set_auth_choice(indexes: &mut [Index], i: usize, choice: AuthChoice) {
let Some(index) = indexes.get_mut(i) else {
return;
};
let existing_username = match &index.auth {
IndexAuth::Basic { username } => username.clone(),
_ => String::new(),
};
index.auth = match choice {
AuthChoice::None => IndexAuth::None,
AuthChoice::Basic => IndexAuth::Basic {
username: existing_username,
},
AuthChoice::Token => IndexAuth::Token,
};
}
pub fn set_username(indexes: &mut [Index], i: usize, username: String) {
if let Some(index) = indexes.get_mut(i)
&& let IndexAuth::Basic { username: u } = &mut index.auth
{
*u = username;
}
}
pub fn collision_message(config: &Config, name: &str) -> Option<String> {
if name.is_empty() {
return None;
}
validate::validate(config)
.into_iter()
.map(|p| p.message)
.find(|m| m.contains("become") && m.contains(name))
}
pub fn credential_save(
config: &Config,
drafts: &[String],
i: usize,
) -> Option<(SecretKey, String)> {
let index = config.indexes.get(i)?;
if collision_message(config, &index.name).is_some() {
return None;
}
let plain_value = drafts.get(i)?;
if plain_value.is_empty() {
return None;
}
Some((
SecretKey::Index {
name: index.name.clone(),
},
plain_value.clone(),
))
}
pub fn toggle_selected(index_names: &mut Vec<String>, name: &str) {
if let Some(pos) = index_names.iter().position(|n| n == name) {
index_names.remove(pos);
} else {
index_names.push(name.to_string());
}
}
pub fn selected_are_subset(project_index_names: &[String], indexes: &[Index]) -> bool {
project_index_names
.iter()
.all(|name| indexes.iter().any(|index| &index.name == name))
}
pub fn state(project_index_names: &[String], name: &str) -> theme::State {
if project_index_names.iter().any(|n| n == name) {
theme::State::Present
} else {
theme::State::Absent
}
}
fn auth_button<'a>(
label: &'static str,
choice: AuthChoice,
current: AuthChoice,
i: usize,
) -> iced::Element<'a, Message> {
iced::widget::button(iced::widget::text(label).size(theme::BODY))
.on_press_maybe((choice != current).then_some(Message::IndexAuthChoiceChanged(i, choice)))
.padding([theme::SPACE_1, theme::SPACE_2])
.style(theme::button_choice(choice == current))
.into()
}
fn field<'a>(
label: &'static str,
control: iced::Element<'a, Message>,
) -> iced::Element<'a, Message> {
iced::widget::row![
iced::widget::text(label)
.size(theme::LABEL)
.color(theme::SLATE)
.width(theme::INDEX_LABEL_WIDTH),
control,
]
.spacing(theme::SPACE_2)
.align_y(iced::Alignment::Center)
.into()
}
fn index_row<'a>(
i: usize,
index: &Index,
config: &Config,
draft: &str,
) -> iced::Element<'a, Message> {
let name_input = iced::widget::text_input("internal", &index.name)
.font(theme::FONT_DATA)
.size(theme::DATA)
.padding(theme::SPACE_1)
.on_input(move |v| Message::IndexNameChanged(i, v))
.on_submit(Message::IndexEditCommitted(i));
let url_input = iced::widget::text_input("https://nexus.example/simple", &index.url)
.font(theme::FONT_DATA)
.size(theme::DATA)
.padding(theme::SPACE_1)
.on_input(move |v| Message::IndexUrlChanged(i, v))
.on_submit(Message::IndexEditCommitted(i));
let kind_choice = iced::widget::row![
kind_button("replaces PyPI", IndexKind::Default, index.kind, i),
kind_button("supplements PyPI", IndexKind::Extra, index.kind, i),
]
.spacing(theme::SPACE_1);
let current_choice = match &index.auth {
IndexAuth::None => AuthChoice::None,
IndexAuth::Basic { .. } => AuthChoice::Basic,
IndexAuth::Token => AuthChoice::Token,
};
let auth_buttons = iced::widget::row![
auth_button("none", AuthChoice::None, current_choice, i),
auth_button("basic", AuthChoice::Basic, current_choice, i),
auth_button("token", AuthChoice::Token, current_choice, i),
]
.spacing(theme::SPACE_1);
let collision = collision_message(config, &index.name);
let mut card = iced::widget::column![
field("name", name_input.into()),
field("url", url_input.into()),
field("kind", kind_choice.into()),
field(
"",
iced::widget::checkbox(index.default_for_new_projects)
.label("use for new projects")
.text_size(theme::BODY)
.on_toggle(move |_| Message::IndexDefaultToggled(i))
.into()
),
field("auth", auth_buttons.into()),
]
.spacing(theme::SPACE_2);
if let IndexAuth::Basic { username } = &index.auth {
card = card.push(field(
"username",
iced::widget::text_input("alice", username)
.font(theme::FONT_DATA)
.size(theme::DATA)
.padding(theme::SPACE_1)
.on_input(move |v| Message::IndexUsernameChanged(i, v))
.on_submit(Message::IndexEditCommitted(i))
.into(),
));
}
if !matches!(index.auth, IndexAuth::None) {
let credential_input = iced::widget::text_input(CREDENTIAL_PLACEHOLDER, draft)
.font(theme::FONT_DATA)
.size(theme::DATA)
.padding(theme::SPACE_1)
.secure(true)
.on_input(move |v| Message::IndexCredentialChanged(i, CredentialInput(v)));
let can_save = collision.is_none() && !draft.is_empty();
card = card.push(field(
match index.auth {
IndexAuth::Token => "token",
_ => "password",
},
iced::widget::row![
credential_input,
iced::widget::button(iced::widget::text("save to keychain").size(theme::BODY))
.on_press_maybe(can_save.then_some(Message::IndexCredentialSaveRequested(i)))
.padding([theme::SPACE_1, theme::SPACE_2])
.style(theme::button_quiet),
]
.spacing(theme::SPACE_2)
.into(),
));
}
if let Some(message) = collision {
card = card.push(iced::widget::text(message).size(theme::BODY));
}
card = card.push(
iced::widget::row![
iced::widget::Space::new().width(iced::Length::Fill),
iced::widget::button(iced::widget::text("save").size(theme::BODY))
.on_press(Message::IndexEditCommitted(i))
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_primary),
iced::widget::button(iced::widget::text("remove").size(theme::BODY))
.on_press(Message::IndexRemoved(i))
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_quiet),
]
.spacing(theme::SPACE_2),
);
iced::widget::container(card)
.padding(theme::SPACE_3)
.style(theme::panel)
.into()
}
fn kind_button<'a>(
label: &'static str,
kind: IndexKind,
current: IndexKind,
i: usize,
) -> iced::Element<'a, Message> {
iced::widget::button(iced::widget::text(label).size(theme::BODY))
.on_press_maybe((kind != current).then_some(Message::IndexKindChanged(i, kind)))
.padding([theme::SPACE_1, theme::SPACE_2])
.style(theme::button_choice(kind == current))
.into()
}
pub fn global_view<'a>(
config: &Config,
credential_drafts: &[String],
) -> iced::Element<'a, Message> {
let mut column = iced::widget::column![
iced::widget::text(
"Defined once and used by any project. Credentials live in the OS keychain, \
keyed by the index name."
)
.size(theme::BODY)
.color(theme::SLATE),
]
.spacing(theme::SPACE_3);
for (i, index) in config.indexes.iter().enumerate() {
let draft = credential_drafts.get(i).map(String::as_str).unwrap_or("");
column = column.push(index_row(i, index, config, draft));
}
column
.push(
iced::widget::button(iced::widget::text("Add index").size(theme::BODY))
.on_press(Message::IndexAdded)
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_quiet),
)
.into()
}
pub fn project_view<'a>(config: &Config, project_index: usize) -> iced::Element<'a, Message> {
let Some(project) = config.projects.get(project_index) else {
return iced::widget::text("").into();
};
let mut column = iced::widget::column![
iced::widget::text("Which of the indexes above this project resolves against.")
.size(theme::BODY)
.color(theme::SLATE),
]
.spacing(theme::SPACE_2);
for index in &config.indexes {
let glyph = theme::state_glyph(state(&project.index_names, &index.name));
let selected = project.index_names.contains(&index.name);
let name = index.name.clone();
let checkbox = iced::widget::checkbox(selected)
.label(index.name.clone())
.font(theme::FONT_DATA)
.text_size(theme::DATA)
.on_toggle(move |_| Message::ProjectIndexToggled(project_index, name.clone()));
let 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);
column = column.push(
iced::widget::container(line)
.padding(theme::SPACE_1)
.style(theme::surface),
);
}
if !selected_are_subset(&project.index_names, &config.indexes) {
column = column.push(
iced::widget::text("this project selects an index that no longer exists")
.size(theme::BODY),
);
}
column.into()
}
#[cfg(test)]
mod tests {
use super::*;
use bombadil_core::model::Project;
use std::path::PathBuf;
use uuid::Uuid;
fn index(name: &str, kind: IndexKind, auth: IndexAuth) -> Index {
Index {
name: name.into(),
url: "https://example.com/simple".into(),
kind,
default_for_new_projects: false,
auth,
}
}
fn config_with(indexes: Vec<Index>) -> Config {
Config {
indexes,
..Config::default()
}
}
fn project_with_indexes(index_names: Vec<String>) -> Project {
Project {
id: Uuid::from_u128(1),
label: "api".into(),
pyproject_path: PathBuf::from("/p/api/pyproject.toml"),
index_names,
environments: vec![bombadil_core::model::Environment {
location: bombadil_core::model::VenvLocation::Alongside,
python: bombadil_core::model::PythonPin::Unpinned,
}],
active: bombadil_core::model::VenvLocation::Alongside,
..Project::default()
}
}
#[test]
fn add_index_carries_no_auth_and_no_credential_field_to_carry_one_in() {
let mut indexes = Vec::new();
add_index(&mut indexes);
assert_eq!(indexes.len(), 1);
assert_eq!(indexes[0].auth, IndexAuth::None);
}
#[test]
fn set_username_never_touches_a_non_basic_row() {
let mut indexes = vec![index("priv", IndexKind::Extra, IndexAuth::Token)];
set_username(&mut indexes, 0, "smuggled".to_string());
assert_eq!(
indexes[0].auth,
IndexAuth::Token,
"a Token row has no username field to write into"
);
}
#[test]
fn re_choosing_basic_while_already_basic_does_not_clear_the_username() {
let mut indexes = vec![index(
"priv",
IndexKind::Extra,
IndexAuth::Basic {
username: "alice".into(),
},
)];
set_auth_choice(&mut indexes, 0, AuthChoice::Basic);
assert_eq!(
indexes[0].auth,
IndexAuth::Basic {
username: "alice".into()
},
"pressing the already-selected choice must not wipe what was typed"
);
}
#[test]
fn switching_away_from_basic_and_back_starts_the_username_blank() {
let mut indexes = vec![index(
"priv",
IndexKind::Extra,
IndexAuth::Basic {
username: "alice".into(),
},
)];
set_auth_choice(&mut indexes, 0, AuthChoice::Token);
set_auth_choice(&mut indexes, 0, AuthChoice::Basic);
assert_eq!(
indexes[0].auth,
IndexAuth::Basic {
username: String::new()
}
);
}
#[test]
fn set_auth_choice_to_none_carries_no_username() {
let mut indexes = vec![index(
"priv",
IndexKind::Extra,
IndexAuth::Basic {
username: "alice".into(),
},
)];
set_auth_choice(&mut indexes, 0, AuthChoice::None);
assert_eq!(indexes[0].auth, IndexAuth::None);
}
#[test]
fn a_colliding_name_yields_a_message_naming_both() {
let config = config_with(vec![
index("my-index", IndexKind::Extra, IndexAuth::None),
index("my_index", IndexKind::Extra, IndexAuth::None),
]);
let got = collision_message(&config, "my-index").expect("a collision must be reported");
assert!(got.contains("my-index"), "got {got:?}");
assert!(got.contains("my_index"), "got {got:?}");
}
#[test]
fn a_non_colliding_name_yields_no_message() {
let config = config_with(vec![
index("priv", IndexKind::Extra, IndexAuth::None),
index("public", IndexKind::Extra, IndexAuth::None),
]);
assert_eq!(collision_message(&config, "priv"), None);
}
#[test]
fn an_empty_name_never_collides() {
let config = config_with(vec![index("", IndexKind::Extra, IndexAuth::None)]);
assert_eq!(collision_message(&config, ""), None);
}
#[test]
fn credential_save_names_the_index_under_secretkey_index() {
let config = config_with(vec![index("priv", IndexKind::Extra, IndexAuth::None)]);
let drafts = vec!["hunter2".to_string()];
let (key, value) = credential_save(&config, &drafts, 0).expect("nothing should block this");
assert_eq!(
key,
SecretKey::Index {
name: "priv".to_string()
}
);
assert_eq!(value, "hunter2");
}
#[test]
fn credential_save_refuses_a_colliding_name() {
let config = config_with(vec![
index("my-index", IndexKind::Extra, IndexAuth::None),
index("my_index", IndexKind::Extra, IndexAuth::None),
]);
let drafts = vec!["a-secret".to_string(), "b-secret".to_string()];
assert_eq!(credential_save(&config, &drafts, 0), None);
assert_eq!(credential_save(&config, &drafts, 1), None);
}
#[test]
fn credential_save_refuses_an_empty_draft() {
let config = config_with(vec![index("priv", IndexKind::Extra, IndexAuth::None)]);
let drafts = vec![String::new()];
assert_eq!(credential_save(&config, &drafts, 0), None);
}
#[test]
fn credential_save_refuses_an_out_of_range_row() {
let config = config_with(vec![]);
assert_eq!(credential_save(&config, &[], 0), None);
}
#[test]
fn toggling_an_unselected_index_selects_it() {
let mut selected = vec!["a".to_string()];
toggle_selected(&mut selected, "b");
assert_eq!(selected, vec!["a".to_string(), "b".to_string()]);
}
#[test]
fn toggling_a_selected_index_deselects_it() {
let mut selected = vec!["a".to_string(), "b".to_string()];
toggle_selected(&mut selected, "a");
assert_eq!(selected, vec!["b".to_string()]);
}
#[test]
fn deselecting_never_touches_the_configured_indexes() {
let indexes = [index("priv", IndexKind::Extra, IndexAuth::None)];
let mut project = project_with_indexes(vec!["priv".to_string()]);
toggle_selected(&mut project.index_names, "priv");
assert!(
project.index_names.is_empty(),
"the association must be gone"
);
assert_eq!(
indexes.len(),
1,
"the configured index itself must still exist"
);
assert_eq!(indexes[0].name, "priv");
}
#[test]
fn a_projects_selection_consistent_with_config_is_a_subset() {
let indexes = vec![
index("priv", IndexKind::Extra, IndexAuth::None),
index("public", IndexKind::Extra, IndexAuth::None),
];
let project = project_with_indexes(vec!["priv".to_string()]);
assert!(selected_are_subset(&project.index_names, &indexes));
}
#[test]
fn a_selection_naming_a_since_removed_index_is_not_a_subset() {
let indexes = vec![index("public", IndexKind::Extra, IndexAuth::None)];
let project = project_with_indexes(vec!["priv".to_string()]);
assert!(!selected_are_subset(&project.index_names, &indexes));
}
#[test]
fn an_empty_selection_is_always_a_subset() {
let indexes = vec![index("priv", IndexKind::Extra, IndexAuth::None)];
assert!(selected_are_subset(&[], &indexes));
}
#[test]
fn a_credential_input_never_prints_its_wrapped_text_in_debug() {
let input = CredentialInput("hunter2".to_string());
let rendered = format!("{input:?}");
assert!(
!rendered.contains("hunter2"),
"a CredentialInput's Debug rendering must never carry the real text; got {rendered:?}"
);
}
#[test]
fn the_index_credential_changed_message_never_carries_its_text_in_debug() {
let msg = Message::IndexCredentialChanged(0, CredentialInput("hunter2".to_string()));
let rendered = format!("{msg:?}");
assert!(!rendered.contains("hunter2"), "got {rendered:?}");
}
#[test]
fn the_credential_placeholder_never_contains_a_real_looking_secret() {
assert!(!CREDENTIAL_PLACEHOLDER.is_empty());
assert!(!CREDENTIAL_PLACEHOLDER.to_lowercase().contains("hunter"));
}
#[test]
fn a_selected_index_is_present() {
assert_eq!(state(&["priv".to_string()], "priv"), theme::State::Present);
}
#[test]
fn an_unselected_index_is_absent() {
assert_eq!(state(&["public".to_string()], "priv"), theme::State::Absent);
}
}