use crate::app::Message;
use crate::theme;
use bombadil_core::model::{Config, EnvVar};
use bombadil_core::secrets::SecretKey;
use std::collections::HashMap;
use uuid::Uuid;
const SECRET_DOT_COUNT: usize = 8;
fn secret_dots() -> String {
"\u{2022}".repeat(SECRET_DOT_COUNT)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EnvScope {
Global,
Project(usize),
}
pub type ValueDrafts = HashMap<(EnvScope, usize), String>;
pub fn vars_mut(config: &mut Config, scope: EnvScope) -> Option<&mut Vec<EnvVar>> {
match scope {
EnvScope::Global => Some(&mut config.global_env),
EnvScope::Project(i) => config.projects.get_mut(i).map(|p| &mut p.env),
}
}
pub fn vars(config: &Config, scope: EnvScope) -> Option<&Vec<EnvVar>> {
match scope {
EnvScope::Global => Some(&config.global_env),
EnvScope::Project(i) => config.projects.get(i).map(|p| &p.env),
}
}
pub fn secret_key(scope: EnvScope, project_id: Option<Uuid>, key: &str) -> SecretKey {
match scope {
EnvScope::Global => SecretKey::GlobalEnv {
key: key.to_string(),
},
EnvScope::Project(_) => SecretKey::ProjectEnv {
project_id: project_id.expect("a project scope always carries a project id"),
key: key.to_string(),
},
}
}
pub fn display_value(var: &EnvVar) -> String {
if var.secret {
secret_dots()
} else {
var.value.clone()
}
}
pub fn add_var(vars: &mut Vec<EnvVar>) {
vars.push(EnvVar {
key: String::new(),
value: String::new(),
secret: false,
});
}
pub fn remove_var(vars: &mut Vec<EnvVar>, index: usize) {
if index < vars.len() {
vars.remove(index);
}
}
pub fn set_key(vars: &mut [EnvVar], index: usize, key: String) {
if let Some(var) = vars.get_mut(index)
&& !var.secret
{
var.key = key;
}
}
pub fn set_value(vars: &mut [EnvVar], index: usize, value: String) {
if let Some(var) = vars.get_mut(index)
&& !var.secret
{
var.value = value;
}
}
pub fn value_box_text<'a>(
drafts: &'a ValueDrafts,
scope: EnvScope,
index: usize,
var: &'a EnvVar,
) -> &'a str {
drafts
.get(&(scope, index))
.map(String::as_str)
.unwrap_or(&var.value)
}
pub fn is_uncommitted(drafts: &ValueDrafts, scope: EnvScope, index: usize, var: &EnvVar) -> bool {
matches!(drafts.get(&(scope, index)), Some(draft) if *draft != var.value)
}
pub fn commit_value(
vars: &mut [EnvVar],
drafts: &mut ValueDrafts,
scope: EnvScope,
index: usize,
) -> bool {
let Some(value) = drafts.remove(&(scope, index)) else {
return false;
};
let committed = matches!(vars.get(index), Some(var) if !var.secret);
set_value(vars, index, value);
committed
}
pub fn drop_draft_row(drafts: &mut ValueDrafts, scope: EnvScope, removed: usize) {
let mut shifted = ValueDrafts::new();
drafts.retain(|(s, i), value| {
if *s != scope || *i < removed {
return true;
}
if *i > removed {
shifted.insert((*s, *i - 1), std::mem::take(value));
}
false
});
drafts.extend(shifted);
}
pub fn mark_secret(vars: &mut [EnvVar], index: usize) -> Option<String> {
let var = vars.get_mut(index)?;
if var.secret {
return None;
}
var.secret = true;
Some(std::mem::take(&mut var.value))
}
pub fn take_secret_value(
vars: &mut [EnvVar],
drafts: &mut ValueDrafts,
scope: EnvScope,
index: usize,
) -> Option<String> {
let drafted = drafts.remove(&(scope, index));
let committed = mark_secret(vars, index)?;
Some(
drafted
.filter(|value| !value.is_empty())
.unwrap_or(committed),
)
}
pub fn unmark_secret(vars: &mut [EnvVar], index: usize) -> bool {
match vars.get_mut(index) {
Some(var) if var.secret => {
var.secret = false;
true
}
_ => false,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Override {
pub key: String,
pub global_display: String,
}
pub fn overrides(global: &[EnvVar], project_env: &[EnvVar]) -> Vec<Override> {
project_env
.iter()
.filter_map(|pvar| {
global.iter().find(|g| g.key == pvar.key).map(|g| Override {
key: pvar.key.clone(),
global_display: display_value(g),
})
})
.collect()
}
fn rows<'a>(scope: EnvScope, vars: &[EnvVar], drafts: &ValueDrafts) -> iced::Element<'a, Message> {
let mut column = iced::widget::column![].spacing(theme::SPACE_2);
for (index, var) in vars.iter().enumerate() {
let key_input = iced::widget::text_input("KEY", &var.key)
.font(theme::FONT_DATA)
.size(theme::DATA)
.on_input_maybe(
(!var.secret).then_some(move |key| Message::EnvVarKeyChanged(scope, index, key)),
);
let value_widget: iced::Element<'_, Message> = if var.secret {
iced::widget::text(display_value(var))
.font(theme::FONT_DATA)
.size(theme::DATA)
.color(theme::SLATE)
.into()
} else {
iced::widget::text_input("value", value_box_text(drafts, scope, index, var))
.font(theme::FONT_DATA)
.size(theme::DATA)
.on_input(move |value| Message::EnvVarValueChanged(scope, index, value))
.on_submit(Message::EnvVarValueCommitted(scope, index))
.into()
};
let save_value = iced::widget::button(iced::widget::text("save value")).on_press_maybe(
(!var.secret && is_uncommitted(drafts, scope, index, var))
.then_some(Message::EnvVarValueCommitted(scope, index)),
);
let secret_toggle = iced::widget::checkbox(var.secret)
.label("secret")
.on_toggle(move |_| Message::EnvVarSecretToggled(scope, index));
let remove = iced::widget::button(iced::widget::text("remove"))
.on_press(Message::EnvVarRemoved(scope, index));
let row = iced::widget::row![key_input, value_widget, save_value, secret_toggle, 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, drafts: &ValueDrafts) -> iced::Element<'a, Message> {
iced::widget::column![
iced::widget::text("Every project sees these, unless it sets its own with the same key.")
.size(theme::BODY)
.color(theme::SLATE),
rows(EnvScope::Global, &config.global_env, drafts),
iced::widget::button(iced::widget::text("Add global variable").size(theme::BODY))
.on_press(Message::EnvVarAdded(EnvScope::Global))
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_quiet),
]
.spacing(theme::SPACE_3)
.into()
}
pub fn project_view<'a>(
config: &Config,
project_index: usize,
drafts: &ValueDrafts,
) -> 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("These layer over the global variables. A key set here wins.")
.size(theme::BODY)
.color(theme::SLATE),
rows(EnvScope::Project(project_index), &project.env, drafts),
iced::widget::button(iced::widget::text("Add project variable").size(theme::BODY))
.on_press(Message::EnvVarAdded(EnvScope::Project(project_index)))
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_quiet),
]
.spacing(theme::SPACE_3);
let shadowed: Vec<String> = overrides(&config.global_env, &project.env)
.into_iter()
.map(|over| over.key)
.collect();
if !config.global_env.is_empty() {
column = column.push(
iced::widget::text("Inherited from global")
.font(theme::FONT_PROSE_SEMIBOLD)
.size(theme::LABEL)
.color(theme::SLATE),
);
for var in &config.global_env {
let overridden = shadowed.contains(&var.key);
let mut line = iced::widget::row![
iced::widget::text(var.key.clone())
.font(theme::FONT_DATA)
.size(theme::DATA)
.color(theme::SLATE),
iced::widget::text(display_value(var))
.font(theme::FONT_DATA)
.size(theme::DATA)
.color(theme::SLATE),
]
.spacing(theme::SPACE_2);
if overridden {
line = line.push(
iced::widget::text("(overridden above)")
.size(theme::LABEL)
.color(theme::SLATE),
);
}
column = column.push(
iced::widget::container(line)
.padding(theme::SPACE_1)
.style(theme::surface),
);
}
}
for over in overrides(&config.global_env, &project.env) {
column = column.push(
iced::widget::row![
iced::widget::text(over.key.clone())
.font(theme::FONT_DATA)
.size(theme::DATA)
.color(theme::SLATE),
iced::widget::text("overrides the global value (")
.size(theme::BODY)
.color(theme::SLATE),
iced::widget::text(over.global_display.clone())
.font(theme::FONT_DATA)
.size(theme::DATA)
.color(theme::SLATE),
iced::widget::text(")")
.size(theme::BODY)
.color(theme::SLATE),
]
.spacing(theme::SPACE_1),
);
}
column.into()
}
#[cfg(test)]
mod tests {
use super::*;
use bombadil_core::model::Project;
use std::path::PathBuf;
fn var(key: &str, value: &str, secret: bool) -> EnvVar {
EnvVar {
key: key.to_string(),
value: value.to_string(),
secret,
}
}
fn project_with_env(env: Vec<EnvVar>) -> 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,
env,
..Project::default()
}
}
#[test]
fn a_project_variable_overriding_a_global_one_is_reported_with_the_globals_value() {
let global = vec![var("SHARED", "global-value", false)];
let project_env = vec![var("SHARED", "project-value", false)];
let got = overrides(&global, &project_env);
assert_eq!(got.len(), 1, "got {got:?}");
assert_eq!(got[0].key, "SHARED");
assert_eq!(
got[0].global_display, "global-value",
"the global's own value must still be visible, not just that it lost"
);
}
#[test]
fn a_project_variable_with_no_matching_global_is_not_reported_as_overriding() {
let global = vec![var("OTHER", "g", false)];
let project_env = vec![var("SHARED", "p", false)];
assert!(overrides(&global, &project_env).is_empty());
}
#[test]
fn an_overridden_secret_global_never_shows_its_value() {
let global = vec![var("TOKEN", "leaked-if-shown", true)];
let project_env = vec![var("TOKEN", "project-value", false)];
let got = overrides(&global, &project_env);
assert_eq!(got.len(), 1);
assert_ne!(got[0].global_display, "leaked-if-shown");
assert!(!got[0].global_display.contains("leaked-if-shown"));
}
#[test]
fn marking_a_variable_secret_blanks_its_config_value_and_returns_the_plaintext() {
let mut vars = vec![var("TOKEN", "hunter2", false)];
let taken = mark_secret(&mut vars, 0);
assert_eq!(taken.as_deref(), Some("hunter2"));
assert_eq!(vars[0].value, "", "got {:?}", vars[0].value);
assert!(vars[0].secret);
}
#[test]
fn marking_secret_takes_the_typed_draft_the_user_never_committed() {
let mut vars = vec![var("TOKEN", "", false)];
let mut drafts = ValueDrafts::new();
drafts.insert((EnvScope::Global, 0), "hunter2".to_string());
let taken = take_secret_value(&mut vars, &mut drafts, EnvScope::Global, 0);
assert_eq!(taken.as_deref(), Some("hunter2"));
assert!(vars[0].secret);
assert_eq!(vars[0].value, "");
assert!(
drafts.is_empty(),
"the plaintext must not be left in App state either; got {drafts:?}"
);
}
#[test]
fn marking_secret_falls_back_to_a_value_the_user_had_already_committed() {
let mut vars = vec![var("TOKEN", "committed-earlier", false)];
let mut drafts = ValueDrafts::new();
let taken = take_secret_value(&mut vars, &mut drafts, EnvScope::Global, 0);
assert_eq!(taken.as_deref(), Some("committed-earlier"));
assert_eq!(vars[0].value, "");
}
#[test]
fn a_draft_beats_a_stale_committed_value_when_marking_secret() {
let mut vars = vec![var("TOKEN", "old", false)];
let mut drafts = ValueDrafts::new();
drafts.insert((EnvScope::Global, 0), "new".to_string());
assert_eq!(
take_secret_value(&mut vars, &mut drafts, EnvScope::Global, 0).as_deref(),
Some("new")
);
}
#[test]
fn marking_an_already_secret_row_takes_nothing_and_leaves_no_draft() {
let mut vars = vec![var("TOKEN", "", true)];
let mut drafts = ValueDrafts::new();
drafts.insert((EnvScope::Global, 0), "stray".to_string());
assert_eq!(
take_secret_value(&mut vars, &mut drafts, EnvScope::Global, 0),
None
);
assert!(drafts.is_empty());
}
#[test]
fn marking_an_already_secret_variable_is_a_no_op() {
let mut vars = vec![var("TOKEN", "", true)];
assert_eq!(mark_secret(&mut vars, 0), None);
}
#[test]
fn unmarking_a_secret_variable_flips_the_flag_and_reports_a_delete_is_owed() {
let mut vars = vec![var("TOKEN", "", true)];
let owed = unmark_secret(&mut vars, 0);
assert!(owed, "a delete must be reported as owed");
assert!(!vars[0].secret);
}
#[test]
fn unmarking_a_non_secret_variable_reports_nothing_owed() {
let mut vars = vec![var("TOKEN", "plain", false)];
assert!(!unmark_secret(&mut vars, 0));
}
#[test]
fn unmarking_never_restores_the_real_value() {
let mut vars = vec![var("TOKEN", "", true)];
unmark_secret(&mut vars, 0);
assert_eq!(vars[0].value, "");
}
#[test]
fn display_value_never_returns_a_secrets_real_text() {
let secret = var("TOKEN", "should-never-render", true);
let shown = display_value(&secret);
assert_ne!(shown, "should-never-render");
assert!(!shown.contains("should-never-render"), "got {shown}");
}
#[test]
fn display_value_shows_a_non_secrets_real_text() {
let plain = var("HTTP_PROXY", "http://proxy:8080", false);
assert_eq!(display_value(&plain), "http://proxy:8080");
}
#[test]
fn a_secret_renders_as_dots_not_a_word_shaped_placeholder() {
let secret = var("TOKEN", "hunter2", true);
let shown = display_value(&secret);
assert!(
shown.chars().all(|c| c == '\u{2022}'),
"a secret's display must be made of dots only; got {shown:?}"
);
}
#[test]
fn an_empty_secret_renders_identically_to_any_other_secret() {
let empty = var("TOKEN", "", true);
let long = var("TOKEN", "a-much-longer-secret-value", true);
assert_eq!(display_value(&empty), display_value(&long));
assert!(!display_value(&empty).is_empty());
}
#[test]
fn global_scope_addresses_the_global_list() {
let mut config = Config {
global_env: vec![var("A", "1", false)],
..Config::default()
};
let vars = vars_mut(&mut config, EnvScope::Global).expect("global always exists");
assert_eq!(vars.len(), 1);
assert_eq!(vars[0].key, "A");
}
#[test]
fn project_scope_addresses_that_projects_own_list_not_global() {
let mut config = Config {
global_env: vec![var("GLOBAL", "g", false)],
projects: vec![project_with_env(vec![var("PROJECT", "p", false)])],
..Config::default()
};
let vars = vars_mut(&mut config, EnvScope::Project(0)).expect("project 0 exists");
assert_eq!(vars.len(), 1);
assert_eq!(
vars[0].key, "PROJECT",
"must address the project's own env, not global_env"
);
}
#[test]
fn a_stale_project_index_addresses_nothing() {
let mut config = Config::default();
assert!(vars_mut(&mut config, EnvScope::Project(0)).is_none());
}
#[test]
fn the_global_secret_key_names_no_project() {
assert_eq!(
secret_key(EnvScope::Global, None, "TOKEN"),
SecretKey::GlobalEnv {
key: "TOKEN".into()
}
);
}
#[test]
fn the_project_secret_key_carries_the_projects_id() {
let id = Uuid::from_u128(42);
assert_eq!(
secret_key(EnvScope::Project(0), Some(id), "TOKEN"),
SecretKey::ProjectEnv {
project_id: id,
key: "TOKEN".into(),
}
);
}
#[test]
fn add_var_appends_a_blank_non_secret_row() {
let mut vars = Vec::new();
add_var(&mut vars);
assert_eq!(vars, vec![var("", "", false)]);
}
#[test]
fn remove_var_drops_the_row_at_index() {
let mut vars = vec![var("A", "1", false), var("B", "2", false)];
remove_var(&mut vars, 0);
assert_eq!(vars, vec![var("B", "2", false)]);
}
#[test]
fn remove_var_out_of_range_is_a_no_op() {
let mut vars = vec![var("A", "1", false)];
remove_var(&mut vars, 5);
assert_eq!(vars.len(), 1);
}
#[test]
fn set_key_renames_the_row() {
let mut vars = vec![var("OLD", "1", false)];
set_key(&mut vars, 0, "NEW".to_string());
assert_eq!(vars[0].key, "NEW");
}
#[test]
fn set_key_never_renames_a_secret_row_out_from_under_its_keychain_entry() {
let mut vars = vec![var("TOKEN", "", true)];
set_key(&mut vars, 0, "RENAMED".to_string());
assert_eq!(
vars[0].key, "TOKEN",
"a secret row's key must not move while its keychain entry stays put"
);
}
#[test]
fn set_value_updates_a_non_secret_row() {
let mut vars = vec![var("A", "old", false)];
set_value(&mut vars, 0, "new".to_string());
assert_eq!(vars[0].value, "new");
}
#[test]
fn a_row_with_a_draft_shows_the_draft_and_one_without_shows_config() {
let vars = [var("A", "committed", false)];
let mut drafts = ValueDrafts::new();
assert_eq!(
value_box_text(&drafts, EnvScope::Global, 0, &vars[0]),
"committed"
);
drafts.insert((EnvScope::Global, 0), "typing".to_string());
assert_eq!(
value_box_text(&drafts, EnvScope::Global, 0, &vars[0]),
"typing",
"the box must show what the user typed, not what Config still holds"
);
}
#[test]
fn a_draft_for_one_scope_never_shows_in_the_other() {
let vars = [var("A", "project-value", false)];
let mut drafts = ValueDrafts::new();
drafts.insert((EnvScope::Global, 0), "global-typing".to_string());
assert_eq!(
value_box_text(&drafts, EnvScope::Project(0), 0, &vars[0]),
"project-value"
);
}
#[test]
fn committing_a_draft_moves_it_into_config_and_clears_it() {
let mut vars = vec![var("A", "old", false)];
let mut drafts = ValueDrafts::new();
drafts.insert((EnvScope::Global, 0), "new".to_string());
let committed = commit_value(&mut vars, &mut drafts, EnvScope::Global, 0);
assert!(committed, "a real commit must report that there is work");
assert_eq!(vars[0].value, "new");
assert!(
drafts.is_empty(),
"the draft must not linger once committed; got {drafts:?}"
);
}
#[test]
fn committing_a_row_with_no_draft_reports_nothing_to_do() {
let mut vars = vec![var("A", "old", false)];
let mut drafts = ValueDrafts::new();
assert!(!commit_value(&mut vars, &mut drafts, EnvScope::Global, 0));
assert_eq!(vars[0].value, "old");
}
#[test]
fn committing_never_writes_a_draft_into_a_secret_row() {
let mut vars = vec![var("TOKEN", "", true)];
let mut drafts = ValueDrafts::new();
drafts.insert((EnvScope::Global, 0), "smuggled".to_string());
let committed = commit_value(&mut vars, &mut drafts, EnvScope::Global, 0);
assert!(!committed);
assert_eq!(vars[0].value, "", "got {:?}", vars[0].value);
assert!(
drafts.is_empty(),
"the draft must not be left sitting in App state either"
);
}
#[test]
fn a_row_is_uncommitted_only_while_its_draft_differs_from_config() {
let vars = [var("A", "same", false)];
let mut drafts = ValueDrafts::new();
assert!(!is_uncommitted(&drafts, EnvScope::Global, 0, &vars[0]));
drafts.insert((EnvScope::Global, 0), "same".to_string());
assert!(!is_uncommitted(&drafts, EnvScope::Global, 0, &vars[0]));
drafts.insert((EnvScope::Global, 0), "different".to_string());
assert!(is_uncommitted(&drafts, EnvScope::Global, 0, &vars[0]));
}
#[test]
fn removing_a_row_drops_its_draft_and_shifts_the_later_ones_down() {
let mut drafts = ValueDrafts::new();
drafts.insert((EnvScope::Global, 0), "zero".to_string());
drafts.insert((EnvScope::Global, 1), "one".to_string());
drafts.insert((EnvScope::Global, 2), "two".to_string());
drafts.insert((EnvScope::Project(0), 1), "other-scope".to_string());
drop_draft_row(&mut drafts, EnvScope::Global, 1);
assert_eq!(
drafts.get(&(EnvScope::Global, 0)).map(String::as_str),
Some("zero")
);
assert_eq!(
drafts.get(&(EnvScope::Global, 1)).map(String::as_str),
Some("two"),
"row 2's draft must follow it down to row 1; got {drafts:?}"
);
assert_eq!(drafts.get(&(EnvScope::Global, 2)), None);
assert_eq!(
drafts.get(&(EnvScope::Project(0), 1)).map(String::as_str),
Some("other-scope"),
"another scope's rows did not move"
);
}
#[test]
fn set_value_never_writes_into_a_secret_row() {
let mut vars = vec![var("A", "", true)];
set_value(&mut vars, 0, "smuggled".to_string());
assert_eq!(vars[0].value, "", "got {:?}", vars[0].value);
}
}