use crate::add_project;
use crate::banner;
use crate::context_menu;
use crate::deps;
use crate::detail;
use crate::drawer;
use crate::env_editor;
use crate::index_editor;
use crate::interpreter;
use crate::job;
use crate::members_editor;
use crate::preferences;
use crate::scripts_editor;
use crate::settings_editor;
use crate::shell;
use crate::sidebar;
use crate::theme;
use crate::tray;
use bombadil_core::envspec::{self, EnvInputs};
use bombadil_core::ide;
use bombadil_core::model::{
Config, DefaultVenvLocation, Override, Project, PythonPin, Settings, TerminalChoice, UvSource,
VenvLocation,
};
use bombadil_core::process::CommandRunner;
use bombadil_core::pyproject::PyProject;
use bombadil_core::secrets::{KeyringSecretStore, SecretKey, SecretStore};
use bombadil_core::store::Loaded;
use bombadil_core::terminal::open::secret_values;
use bombadil_core::terminal::{self, Os, TerminalKind};
use bombadil_core::uv;
use bombadil_core::uv::results::{InstalledPackage, Interpreter, TreeOutcome};
use bombadil_core::venv_path;
use futures::StreamExt;
use iced::Task;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
const VENV_PROBE_TIMEOUT: Duration = Duration::from_secs(3);
const SYNC_TIMEOUT: Duration = Duration::from_secs(300);
const DEPS_TREE_DEPTH: u32 = 20;
pub trait ConfigPersist: Send + Sync {
fn save(&self, config: &Config) -> Result<(), String>;
}
impl ConfigPersist for bombadil_core::store::Store {
fn save(&self, config: &Config) -> Result<(), String> {
bombadil_core::store::Store::save(self, config).map_err(|e| e.to_string())
}
}
pub struct NullPersist;
impl ConfigPersist for NullPersist {
fn save(&self, _config: &Config) -> Result<(), String> {
Err("no config store configured".to_string())
}
}
pub struct App {
pub tray_is_running: bool,
pub config: Config,
pub selected: Option<usize>,
pub entries: Vec<sidebar::Entry>,
pub drawer: drawer::Drawer,
pub load_problems: banner::Problems,
pub syncing: Option<String>,
pub add_project: Option<add_project::Draft>,
pub persist: Arc<dyn ConfigPersist>,
pub context_menu: Option<context_menu::ContextMenu>,
pub confirm_recreate: Option<context_menu::ConfirmRecreate>,
pub confirm_pin_change: Option<context_menu::ConfirmPinChange>,
pub confirm_environment_removal: Option<context_menu::ConfirmEnvironmentRemoval>,
pub open_menu: Option<shell::TopMenu>,
pub modal: Option<shell::Modal>,
pub preferences: Option<preferences::State>,
pub deps: Option<(usize, Result<deps::DepsTab, String>)>,
pub index_credential_drafts: Vec<String>,
pub index_credential_names: Vec<String>,
pub env_value_drafts: env_editor::ValueDrafts,
pub members: Option<(usize, MembersFetch)>,
pub confirm_member_sync: Option<members_editor::ConfirmMemberSync>,
pub uv_custom_path_draft: String,
pub uv_custom_validation: Option<Result<String, String>>,
pub tab: Tab,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Tab {
#[default]
Dependencies,
Members,
EnvVars,
Scripts,
Indexes,
}
impl Tab {
pub const ALL: [Tab; 5] = [
Tab::Dependencies,
Tab::Members,
Tab::EnvVars,
Tab::Scripts,
Tab::Indexes,
];
pub fn label(self) -> &'static str {
match self {
Tab::Dependencies => "Dependencies",
Tab::Members => "Members",
Tab::EnvVars => "Env vars",
Tab::Scripts => "Scripts",
Tab::Indexes => "Indexes",
}
}
}
#[derive(Debug, Clone)]
pub enum Message {
ProjectSelected(usize),
TabSelected(Tab),
VenvProbed {
index: usize,
location: VenvLocation,
python_version: Option<String>,
requires_python: Option<String>,
},
DepsLoaded(usize, Result<DepsFetch, String>),
SyncRequested(usize),
DrawerToggled,
ProjectExpandToggled(usize),
EnvironmentActivated(usize, VenvLocation),
SyncLine(String),
SyncFinished {
success: bool,
lines: Vec<String>,
},
AddProjectPickManifestRequested,
AddProjectManifestPicked(Option<PathBuf>),
AddProjectManifestLoaded(add_project::Draft),
AddProjectInterpretersLoaded(Result<Vec<Interpreter>, String>),
AddProjectInstallInterpreterRequested,
AddProjectVenvChoiceSelected(add_project::VenvChoice),
AddProjectVenvOutcomeProbed(PathBuf, add_project::VenvOutcome),
AddProjectPickVenvFolderRequested,
AddProjectVenvFolderPicked(Option<PathBuf>),
AddProjectCancelled,
AddProjectConfirmed,
AddProjectConfirmFinished {
success: bool,
lines: Vec<String>,
index: usize,
},
ContextMenuOpened(usize),
ContextMenuClosed,
ContextMenuTerminalsDetected(Vec<TerminalKind>),
OpenTerminalRequested(usize, TerminalChoice),
OpenTerminalHereRequested(usize),
OpenTerminalHereDetected(usize, Vec<TerminalKind>),
OpenTerminalFinished(Result<(), String>),
Tray(tray::TrayCommand),
OpenPycharmRequested(usize),
OpenPycharmFinished(Result<(), String>),
RemoveProjectRequested(usize),
RemoveProjectFinished {
success: bool,
},
RecreateVenvRequested(usize),
RecreateVenvConfirmed,
RecreateVenvCancelled,
PinChangeConfirmed,
PinChangeCancelled,
OpenFolderRequested(usize),
OpenFolderFinished(Result<(), String>),
MenuToggled(shell::TopMenu),
MenuDismissed,
ModalOpened(shell::Modal),
ModalClosed,
PreferencesSectionOpened(preferences::Section),
PreferencesClosed,
PreferencesScopeSelected(preferences::Scope),
PreferencesSectionSelected(preferences::Section),
PreferencesPythonPinChanged(preferences::Scope, String),
PreferencesInheritToggled(preferences::Scope, preferences::Section),
PreferencesUvSourceOverridden(preferences::Scope, UvSource),
PreferencesTerminalOverridden(preferences::Scope, TerminalChoice),
EnvironmentLocationSelected(uuid::Uuid, VenvLocation, VenvLocation),
EnvironmentPickFolderRequested(uuid::Uuid, VenvLocation),
EnvironmentFolderPicked(uuid::Uuid, VenvLocation, Option<PathBuf>),
EnvironmentAdded(uuid::Uuid),
EnvironmentAddFolderPicked(uuid::Uuid, Option<PathBuf>),
EnvironmentPinChanged(uuid::Uuid, VenvLocation, String),
EnvironmentRemoveRequested(uuid::Uuid, VenvLocation),
EnvironmentRemovalConfirmed,
EnvironmentRemovalCancelled,
EnvVarAdded(env_editor::EnvScope),
EnvVarRemoved(env_editor::EnvScope, usize),
EnvVarKeyChanged(env_editor::EnvScope, usize, String),
EnvVarValueChanged(env_editor::EnvScope, usize, String),
EnvVarValueCommitted(env_editor::EnvScope, usize),
EnvVarSecretToggled(env_editor::EnvScope, usize),
EnvVarPersistFinished {
success: bool,
},
EnvVarSecretWritten {
scope: env_editor::EnvScope,
index: usize,
success: bool,
},
ScriptAddRequested(scripts_editor::ScriptScope),
ScriptFilePicked(scripts_editor::ScriptScope, Option<PathBuf>),
ScriptRemoved(scripts_editor::ScriptScope, usize),
ScriptEnabledToggled(scripts_editor::ScriptScope, usize),
ScriptMovedUp(scripts_editor::ScriptScope, usize),
ScriptMovedDown(scripts_editor::ScriptScope, usize),
ScriptPersistFinished {
success: bool,
},
IndexAdded,
IndexRemoved(usize),
IndexNameChanged(usize, String),
IndexUrlChanged(usize, String),
IndexEditCommitted(usize),
IndexKindChanged(usize, bombadil_core::model::IndexKind),
IndexDefaultToggled(usize),
IndexAuthChoiceChanged(usize, index_editor::AuthChoice),
IndexUsernameChanged(usize, String),
IndexCredentialChanged(usize, index_editor::CredentialInput),
IndexCredentialSaveRequested(usize),
IndexCredentialWritten {
index: usize,
success: bool,
},
IndexPersistFinished {
success: bool,
},
ProjectIndexToggled(usize, String),
MembersLoaded(usize, MembersFetch),
MemberToggled(usize, PathBuf),
MembersPersistFinished {
success: bool,
},
MembersSyncRequested(usize),
MembersSyncConfirmed,
MembersSyncCancelled,
SettingsUvSourceChoiceChanged(settings_editor::UvSourceChoice),
SettingsUvCustomPathChanged(String),
SettingsUvCustomValidateRequested,
SettingsUvCustomValidated(Result<uv::ResolvedUv, String>),
SettingsPersistFinished {
success: bool,
},
SettingsDefaultVenvLocationAlongsideSelected,
SettingsDefaultVenvLocationPickFolderRequested,
SettingsDefaultVenvLocationFolderPicked(Option<PathBuf>),
}
#[derive(Debug, Clone)]
pub struct MembersFetch {
kind: bombadil_core::members::WorkspaceKind,
discovered: Vec<bombadil_core::members::DiscoveredMember>,
installed: Vec<InstalledPackage>,
}
impl App {
pub fn new(config: Config) -> Self {
let selected = (!config.projects.is_empty()).then_some(0);
let entries = sidebar::entries(&config);
let index_credential_drafts = vec![String::new(); config.indexes.len()];
let index_credential_names = config
.indexes
.iter()
.map(|index| index.name.clone())
.collect();
let uv_custom_path_draft = match &config.settings.uv_source {
UvSource::Custom { path } => path.display().to_string(),
_ => String::new(),
};
Self {
tray_is_running: false,
config,
selected,
entries,
drawer: drawer::Drawer::default(),
load_problems: Vec::new(),
syncing: None,
add_project: None,
persist: Arc::new(NullPersist),
context_menu: None,
confirm_recreate: None,
confirm_pin_change: None,
confirm_environment_removal: None,
open_menu: None,
modal: None,
preferences: None,
deps: None,
index_credential_drafts,
index_credential_names,
env_value_drafts: env_editor::ValueDrafts::new(),
members: None,
confirm_member_sync: None,
uv_custom_path_draft,
uv_custom_validation: None,
tab: Tab::default(),
}
}
}
pub fn boot(loaded: Loaded) -> (App, Task<Message>) {
let mut app = App::new(loaded.config().clone());
app.load_problems = banner::load_problems(&loaded);
let mut probes = vec![probe_every_venv(&app)];
if let Some(i) = app.selected {
probes.push(Task::done(Message::ProjectSelected(i)));
}
(app, Task::batch(probes))
}
fn probe_every_venv(app: &App) -> Task<Message> {
Task::batch(
app.config
.projects
.iter()
.enumerate()
.flat_map(|(i, project)| {
project.environments.iter().filter_map(move |environment| {
let path =
venv_path::resolve(project, environment, &app.config.settings).ok()?;
let pyproject_path = project.pyproject_path.clone();
let location = environment.location.clone();
Some(Task::perform(
probe_venv_and_requirement(path, pyproject_path),
move |(python_version, requires_python)| Message::VenvProbed {
index: i,
location: location.clone(),
python_version,
requires_python,
},
))
})
})
.collect::<Vec<_>>(),
)
}
async fn probe_venv(path: PathBuf) -> Option<String> {
match job::run(move || venv_path::probe(&path), VENV_PROBE_TIMEOUT).await {
job::JobResult::Done(version) => version,
job::JobResult::TimedOut | job::JobResult::Failed => None,
}
}
async fn probe_venv_and_requirement(
venv_path: PathBuf,
pyproject_path: PathBuf,
) -> (Option<String>, Option<String>) {
match job::run(
move || {
let python_version = venv_path::probe(&venv_path);
let requires_python = bombadil_core::pyproject::load(&pyproject_path)
.ok()
.and_then(|p| p.requires_python);
(python_version, requires_python)
},
VENV_PROBE_TIMEOUT,
)
.await
{
job::JobResult::Done(result) => result,
job::JobResult::TimedOut | job::JobResult::Failed => (None, None),
}
}
#[derive(Debug, Clone)]
pub struct DepsFetch {
pyproject: PyProject,
installed: Vec<InstalledPackage>,
tree: Result<TreeOutcome, String>,
}
fn deps_command_context(project: &Project, config: &Config) -> Option<uv::command::ProjectContext> {
let project_dir = project
.pyproject_path
.parent()
.filter(|p| !p.as_os_str().is_empty())?;
let venv_path =
venv_path::resolve(project, project.active_environment()?, &config.settings).ok()?;
Some(uv::command::ProjectContext {
project_dir: project_dir.to_path_buf(),
venv_path,
})
}
fn uv_refusal(outcome: &bombadil_core::process::CommandOutcome, fallback: &str) -> String {
match outcome.stderr.trim() {
"" => fallback.to_string(),
stderr => stderr.to_string(),
}
}
fn fetch_deps_blocking(
project: &Project,
config: &Config,
runner: &dyn CommandRunner,
) -> Result<DepsFetch, String> {
let resolved_uv = resolve_uv_for(config, runner)?;
let ctx = deps_command_context(project, config)
.ok_or_else(|| "this project has no directory or no resolvable venv path".to_string())?;
let installed = match runner.run(&uv::command::pip_list(&resolved_uv, &ctx)) {
Ok(outcome) if outcome.success() => uv::results::parse_pip_list(&outcome.stdout)
.map_err(|err| format!("could not read uv's installed package list: {err}"))?,
Ok(outcome) => return Err(uv_refusal(&outcome, "uv pip list failed")),
Err(err) => return Err(err.to_string()),
};
let tree = match runner.run(&uv::command::tree(&resolved_uv, &ctx, DEPS_TREE_DEPTH)) {
Ok(outcome) if outcome.success() => uv::results::parse_tree(&outcome.stdout)
.map_err(|err| format!("could not read uv's dependency tree: {err}")),
Ok(outcome) => Err(uv_refusal(&outcome, "uv tree failed")),
Err(err) => Err(err.to_string()),
};
let pyproject = bombadil_core::pyproject::load(&project.pyproject_path)
.map_err(|err| format!("could not read this project's manifest: {err}"))?;
Ok(DepsFetch {
pyproject,
installed,
tree,
})
}
async fn load_deps(
project: Project,
config: Config,
runner: Arc<dyn CommandRunner>,
) -> Result<DepsFetch, String> {
match job::run(
move || fetch_deps_blocking(&project, &config, runner.as_ref()),
SYNC_TIMEOUT,
)
.await
{
job::JobResult::Done(fetch) => fetch,
job::JobResult::TimedOut => Err("reading dependencies timed out".to_string()),
job::JobResult::Failed => Err("reading dependencies failed unexpectedly".to_string()),
}
}
fn fetch_deps_task(
index: usize,
project: Project,
config: Config,
runner: Arc<dyn CommandRunner>,
) -> Task<Message> {
Task::perform(load_deps(project, config, runner), move |fetch| {
Message::DepsLoaded(index, fetch)
})
}
fn fetch_members_blocking(
project: &Project,
config: &Config,
runner: &dyn CommandRunner,
) -> MembersFetch {
let project_dir = project
.pyproject_path
.parent()
.filter(|p| !p.as_os_str().is_empty());
let kind = project_dir
.and_then(|_| std::fs::read_to_string(&project.pyproject_path).ok())
.map(|text| bombadil_core::members::parse_workspace(&text))
.unwrap_or(bombadil_core::members::WorkspaceKind::Loose);
let discovered = match project_dir {
Some(dir) => bombadil_core::members::discover(dir, &kind),
None => Vec::new(),
};
let installed = match (
resolve_uv_for(config, runner).ok(),
deps_command_context(project, config),
) {
(Some(resolved_uv), Some(ctx)) => runner
.run(&uv::command::pip_list(&resolved_uv, &ctx))
.ok()
.filter(|outcome| outcome.success())
.and_then(|outcome| uv::results::parse_pip_list(&outcome.stdout).ok())
.unwrap_or_default(),
_ => Vec::new(),
};
MembersFetch {
kind,
discovered,
installed,
}
}
async fn load_members(
project: Project,
config: Config,
runner: Arc<dyn CommandRunner>,
) -> MembersFetch {
match job::run(
move || fetch_members_blocking(&project, &config, runner.as_ref()),
SYNC_TIMEOUT,
)
.await
{
job::JobResult::Done(fetch) => fetch,
job::JobResult::TimedOut | job::JobResult::Failed => MembersFetch {
kind: bombadil_core::members::WorkspaceKind::Loose,
discovered: Vec::new(),
installed: Vec::new(),
},
}
}
fn fetch_members_task(
index: usize,
project: Project,
config: Config,
runner: Arc<dyn CommandRunner>,
) -> Task<Message> {
Task::perform(load_members(project, config, runner), move |fetch| {
Message::MembersLoaded(index, fetch)
})
}
pub fn update(app: &mut App, message: Message, runner: Arc<dyn CommandRunner>) -> Task<Message> {
match message {
Message::ProjectSelected(i) if i < app.config.projects.len() => {
app.selected = Some(i);
return Task::batch([
fetch_deps_task(
i,
app.config.projects[i].clone(),
app.config.clone(),
runner.clone(),
),
fetch_members_task(
i,
app.config.projects[i].clone(),
app.config.clone(),
runner,
),
]);
}
Message::ProjectSelected(_) => {}
Message::TabSelected(tab) => {
app.tab = tab;
}
Message::VenvProbed {
index,
location,
python_version,
requires_python,
} if index < app.entries.len() => {
if let Some(row) = app.entries[index]
.environments
.iter_mut()
.find(|row| row.location == location)
{
row.python_version = python_version;
}
app.entries[index].requires_python = requires_python;
}
Message::VenvProbed { .. } => {}
Message::DepsLoaded(i, fetch) if i < app.config.projects.len() => {
app.deps = Some((
i,
fetch.map(|fetch| deps::build(&fetch.pyproject, &fetch.installed, fetch.tree)),
));
}
Message::DepsLoaded(_, _) => {}
Message::MembersLoaded(i, fetch) if i < app.config.projects.len() => {
app.members = Some((i, fetch));
}
Message::MembersLoaded(_, _) => {}
Message::DrawerToggled => {
app.drawer.expanded = !app.drawer.expanded;
}
Message::ProjectExpandToggled(i) => {
let Some(project) = app.config.projects.get_mut(i) else {
return Task::none();
};
project.expanded = !project.expanded;
if let Some(entry) = app.entries.get_mut(i) {
entry.expanded = project.expanded;
}
return persist_env_edit(app);
}
Message::EnvironmentActivated(i, location) => {
let Some(project) = app.config.projects.get_mut(i) else {
return Task::none();
};
if !project
.environments
.iter()
.any(|environment| environment.location == location)
{
return Task::none();
}
project.active = location.clone();
for row in app
.entries
.get_mut(i)
.into_iter()
.flat_map(|entry| entry.environments.iter_mut())
{
row.active = row.location == location;
}
app.selected = Some(i);
return Task::batch([persist_env_edit(app), refresh_after_sync(app, runner)]);
}
Message::SyncRequested(i) => {
app.context_menu = None;
if app.syncing.is_none()
&& let Some(project) = sync_target(app, i)
{
if let Some((kind, discovered)) = members_context(app, i) {
return dispatch_members_sync(app, project, kind, discovered, runner);
}
let pin = project
.active_environment()
.map(|environment| environment.python.clone())
.unwrap_or_default();
let venv_version = app.entries.get(i).and_then(|entry| {
entry
.environments
.iter()
.find(|row| row.active)
.and_then(|row| row.python_version.clone())
});
if sync_would_delete(&pin, venv_version.as_deref())
&& let PythonPin::Version(to) = &pin
&& let Some(from) = &venv_version
&& let Some(environment) = project.active_environment()
&& let Ok(venv_path) =
venv_path::resolve(&project, environment, &app.config.settings)
{
app.confirm_pin_change = Some(context_menu::ConfirmPinChange {
project_id: project.id,
label: project.label.clone(),
venv_path,
from: from.clone(),
to: to.clone(),
});
return Task::none();
}
let config = app.config.clone();
app.syncing = Some(project.id.to_string());
app.drawer = drawer::Drawer {
status: format!("syncing {}...", project.label),
..drawer::Drawer::default()
};
return Task::stream(sync_project(project, config, runner));
}
}
Message::SyncLine(line) => {
drawer::push_line(&mut app.drawer, &line, &[]);
}
Message::SyncFinished { success, lines } => {
app.syncing = None;
for line in &lines {
drawer::push_line(&mut app.drawer, line, &[]);
}
let status = if success {
"sync succeeded".to_string()
} else {
"sync failed".to_string()
};
drawer::set_idle(&mut app.drawer, status);
return refresh_after_sync(app, runner);
}
Message::AddProjectPickManifestRequested => {
return Task::perform(pick_pyproject(), Message::AddProjectManifestPicked);
}
Message::AddProjectManifestPicked(None) => {}
Message::AddProjectManifestPicked(Some(path)) => {
return Task::perform(load_manifest_draft(path), Message::AddProjectManifestLoaded);
}
Message::AddProjectManifestLoaded(draft) => {
let outcome_probe = probe_draft_venv(&draft, &app.config.settings);
app.add_project = Some(draft);
return Task::batch([
Task::perform(
list_interpreters(app.config.clone(), runner),
Message::AddProjectInterpretersLoaded,
),
outcome_probe,
]);
}
Message::AddProjectInterpretersLoaded(result) => {
if let Some(draft) = &mut app.add_project {
draft.interpreters = match result {
Ok(interpreters) => add_project::Interpreters::Loaded(interpreters),
Err(why) => add_project::Interpreters::Failed(why),
};
}
}
Message::AddProjectInstallInterpreterRequested => {
if let Some(draft) = &app.add_project
&& let Some(version) =
interpreter::suggested_install_version(draft.requires_python.as_deref())
{
return Task::perform(
install_and_relist(version, app.config.clone(), runner),
Message::AddProjectInterpretersLoaded,
);
}
}
Message::AddProjectVenvChoiceSelected(choice) => {
if let Some(draft) = &mut app.add_project {
draft.venv_choice = choice;
draft.venv_outcome = None;
return probe_draft_venv(draft, &app.config.settings);
}
}
Message::AddProjectVenvOutcomeProbed(path, outcome) => {
if let Some(draft) = &mut app.add_project
&& add_project::resolved_venv_path(draft, &app.config.settings)
.is_ok_and(|current| current == path)
{
draft.venv_outcome = Some(outcome);
}
}
Message::AddProjectPickVenvFolderRequested => {
return Task::perform(pick_folder(), Message::AddProjectVenvFolderPicked);
}
Message::AddProjectVenvFolderPicked(None) => {}
Message::AddProjectVenvFolderPicked(Some(path)) => {
if let Some(draft) = &mut app.add_project {
draft.venv_choice = add_project::VenvChoice::Chosen(path);
draft.venv_outcome = None;
return probe_draft_venv(draft, &app.config.settings);
}
}
Message::AddProjectCancelled => {
app.add_project = None;
}
Message::AddProjectConfirmed => {
if let Some(draft) = app.add_project.take() {
let project = add_project::to_project(&draft, &app.config.settings);
let python_version = interpreter::preselect(
draft.interpreters.available(),
draft.requires_python.as_deref(),
)
.map(|i| i.version.clone());
app.config.projects.push(project.clone());
app.entries.push(sidebar::Entry {
id: project.id,
label: project.label.clone(),
requires_python: draft.requires_python.clone(),
environments: project
.environments
.iter()
.map(|environment| sidebar::EnvironmentRow {
location: environment.location.clone(),
path: venv_path::resolve(&project, environment, &app.config.settings)
.unwrap_or_default(),
python_version: None,
pin: environment.python.clone(),
active: environment.location == project.active,
})
.collect(),
expanded: project.expanded,
});
let index = app.config.projects.len() - 1;
app.selected = Some(index);
app.syncing = Some(project.id.to_string());
let config = app.config.clone();
let persist = app.persist.clone();
return Task::perform(
confirm_add_project(project, config, python_version, persist, runner),
move |(success, lines)| Message::AddProjectConfirmFinished {
success,
lines,
index,
},
);
}
}
Message::AddProjectConfirmFinished {
success,
lines,
index,
} => {
app.syncing = None;
for line in &lines {
drawer::push_line(&mut app.drawer, line, &[]);
}
let status = if success {
"project added".to_string()
} else {
"add project failed".to_string()
};
drawer::set_idle(&mut app.drawer, status);
if !success && !app.drawer.lines.is_empty() {
app.drawer.expanded = true;
}
if let Some(project) = app.config.projects.get(index).cloned()
&& let Some(environment) = project.active_environment()
&& let Ok(path) = venv_path::resolve(&project, environment, &app.config.settings)
{
let pyproject_path = project.pyproject_path.clone();
let location = environment.location.clone();
return Task::perform(
probe_venv_and_requirement(path, pyproject_path),
move |(python_version, requires_python)| Message::VenvProbed {
index,
location: location.clone(),
python_version,
requires_python,
},
);
}
}
Message::ContextMenuOpened(i) if i < app.config.projects.len() => {
app.context_menu = Some(context_menu::ContextMenu {
project_index: i,
terminals: Vec::new(),
});
return Task::perform(
detect_terminals(Os::host(), runner),
Message::ContextMenuTerminalsDetected,
);
}
Message::ContextMenuOpened(_) => {}
Message::ContextMenuClosed => {
app.context_menu = None;
}
Message::ContextMenuTerminalsDetected(terminals) => {
if let Some(menu) = &mut app.context_menu {
menu.terminals = terminals;
}
}
Message::OpenTerminalRequested(i, choice) if i < app.config.projects.len() => {
app.context_menu = None;
if let Some(project) = app.config.projects.get(i).cloned() {
let config = app.config.clone();
return Task::perform(
open_terminal(choice, project, config, runner),
Message::OpenTerminalFinished,
);
}
}
Message::OpenTerminalRequested(_, _) => {}
Message::OpenTerminalHereRequested(i) if i < app.config.projects.len() => {
let default = app.config.terminal_defaults.get(&Os::host()).cloned();
let Some(project) = app.config.projects.get(i).cloned() else {
return Task::none();
};
match context_menu::one_press_plan(default.as_ref(), None) {
context_menu::OpenPlan::Open(choice) => {
let config = app.config.clone();
return Task::perform(
open_terminal(choice, project, config, runner),
Message::OpenTerminalFinished,
);
}
context_menu::OpenPlan::Detect => {
return Task::perform(detect_terminals(Os::host(), runner), move |kinds| {
Message::OpenTerminalHereDetected(i, kinds)
});
}
context_menu::OpenPlan::Nothing => report_no_terminal(app),
}
}
Message::OpenTerminalHereRequested(_) => {}
Message::OpenTerminalHereDetected(i, detected) => {
let Some(project) = app.config.projects.get(i).cloned() else {
return Task::none();
};
match context_menu::one_press_plan(None, Some(&detected)) {
context_menu::OpenPlan::Open(choice) => {
let config = app.config.clone();
return Task::perform(
open_terminal(choice, project, config, runner),
Message::OpenTerminalFinished,
);
}
context_menu::OpenPlan::Nothing => report_no_terminal(app),
context_menu::OpenPlan::Detect => {}
}
}
Message::OpenTerminalFinished(Ok(())) => {}
Message::OpenTerminalFinished(Err(message)) => {
drawer::push_line(&mut app.drawer, &message, &[]);
drawer::set_idle(&mut app.drawer, "open terminal failed".to_string());
}
Message::Tray(command) => {
return match tray::window_action(command, app.tray_is_running) {
tray::WindowAction::Show => iced::window::latest().and_then(|id| {
iced::window::set_mode(id, iced::window::Mode::Windowed)
.chain(iced::window::gain_focus(id))
}),
tray::WindowAction::Hide => iced::window::latest()
.and_then(|id| iced::window::set_mode(id, iced::window::Mode::Hidden)),
tray::WindowAction::Close => iced::window::latest().and_then(iced::window::close),
};
}
Message::OpenPycharmRequested(i) => {
let Some(project) = app.config.projects.get(i).cloned() else {
return Task::none();
};
let config = app.config.clone();
return Task::perform(
open_pycharm(project, config, runner),
Message::OpenPycharmFinished,
);
}
Message::OpenPycharmFinished(Ok(())) => {}
Message::OpenPycharmFinished(Err(message)) => {
drawer::push_line(&mut app.drawer, &message, &[]);
drawer::set_idle(&mut app.drawer, "open PyCharm failed".to_string());
app.drawer.expanded = true;
}
Message::RemoveProjectRequested(i) if i < app.config.projects.len() => {
app.config.projects.remove(i);
app.entries.remove(i);
app.deps = None;
app.members = None;
app.confirm_member_sync = None;
app.confirm_recreate = None;
app.selected = match app.selected {
Some(s) if s == i => (!app.config.projects.is_empty())
.then_some(i.min(app.config.projects.len().saturating_sub(1))),
Some(s) if s > i => Some(s - 1),
other => other,
};
app.context_menu = None;
let config = app.config.clone();
let persist = app.persist.clone();
return Task::perform(persist_config(config, persist), |success| {
Message::RemoveProjectFinished { success }
});
}
Message::RemoveProjectRequested(_) => {}
Message::RemoveProjectFinished { success } => {
if !success {
drawer::set_idle(&mut app.drawer, "remove failed to persist".to_string());
}
}
Message::RecreateVenvRequested(i) => {
app.context_menu = None;
if let Some(project) = app.config.projects.get(i)
&& let Some(environment) = project.active_environment()
&& let Ok(venv_path) =
venv_path::resolve(project, environment, &app.config.settings)
{
app.confirm_recreate = Some(context_menu::ConfirmRecreate {
project_id: project.id,
label: project.label.clone(),
venv_path,
});
}
}
Message::RecreateVenvCancelled => {
app.confirm_recreate = None;
}
Message::PinChangeCancelled => {
app.confirm_pin_change = None;
}
Message::PinChangeConfirmed => {
if let Some(confirm) = app.confirm_pin_change.take()
&& app.syncing.is_none()
&& let Some(project) = app
.config
.projects
.iter()
.find(|p| p.id == confirm.project_id)
.cloned()
{
let config = app.config.clone();
app.syncing = Some(project.id.to_string());
app.drawer = drawer::Drawer {
status: format!("syncing {}...", project.label),
..drawer::Drawer::default()
};
return Task::stream(sync_project(project, config, runner));
}
}
Message::RecreateVenvConfirmed => {
if let Some(confirm) = app.confirm_recreate.take()
&& app.syncing.is_none()
&& let Some(project) = app
.config
.projects
.iter()
.find(|p| p.id == confirm.project_id)
.cloned()
{
let config = app.config.clone();
app.syncing = Some(project.id.to_string());
app.drawer = drawer::Drawer {
status: format!("recreating {}...", project.label),
..drawer::Drawer::default()
};
return Task::stream(recreate_venv(project, config, runner));
}
}
Message::OpenFolderRequested(i) => {
app.context_menu = None;
if let Some(project) = app.config.projects.get(i).cloned() {
return Task::perform(
open_folder(Os::host(), project, runner),
Message::OpenFolderFinished,
);
}
}
Message::OpenFolderFinished(Ok(())) => {}
Message::OpenFolderFinished(Err(message)) => {
drawer::push_line(&mut app.drawer, &message, &[]);
drawer::set_idle(&mut app.drawer, "open folder failed".to_string());
}
Message::MenuToggled(menu) => {
app.open_menu = if app.open_menu == Some(menu) {
None
} else {
Some(menu)
};
}
Message::MenuDismissed => {
app.open_menu = None;
}
Message::ModalOpened(modal) => {
app.modal = Some(modal);
app.open_menu = None;
}
Message::ModalClosed => {
app.modal = None;
}
Message::PreferencesSectionOpened(section) => {
app.preferences = Some(preferences::State {
scope: preferences::Scope::Global,
section,
});
app.open_menu = None;
}
Message::PreferencesClosed => {
app.preferences = None;
}
Message::PreferencesScopeSelected(scope) => {
if let Some(state) = &mut app.preferences {
state.scope = scope;
}
}
Message::PreferencesSectionSelected(section) => {
if let Some(state) = &mut app.preferences {
state.section = section;
}
}
Message::PreferencesPythonPinChanged(scope, version) => {
let pin = preferences::pin_from_input(&version);
match scope {
preferences::Scope::Global => app.config.settings.python = pin,
preferences::Scope::Project(id) => {
let Some(project) = app.config.projects.iter_mut().find(|p| p.id == id) else {
return Task::none();
};
let active = project.active.clone();
let index = project
.environments
.iter()
.position(|environment| environment.location == active)
.or(if project.environments.is_empty() {
None
} else {
Some(0)
});
let Some(index) = index else {
return Task::none();
};
project.environments[index].python = pin;
}
}
return persist_env_edit(app);
}
Message::PreferencesUvSourceOverridden(scope, source) => {
match scope {
preferences::Scope::Global => app.config.settings.uv_source = source,
preferences::Scope::Project(id) => {
let Some(project) = app.config.projects.iter_mut().find(|p| p.id == id) else {
return Task::none();
};
project.uv_source = Override::Set(source);
}
}
return persist_env_edit(app);
}
Message::PreferencesTerminalOverridden(scope, choice) => {
let os = Os::host();
match scope {
preferences::Scope::Global => {
app.config.terminal_defaults.insert(os, choice);
}
preferences::Scope::Project(id) => {
let Some(project) = app.config.projects.iter_mut().find(|p| p.id == id) else {
return Task::none();
};
project.terminals.insert(os, choice);
}
}
return persist_env_edit(app);
}
Message::EnvironmentAdded(id) => {
return Task::perform(pick_folder(), move |path| {
Message::EnvironmentAddFolderPicked(id, path)
});
}
Message::EnvironmentAddFolderPicked(_, None) => {}
Message::EnvironmentAddFolderPicked(id, Some(path)) => {
let seed = app.config.settings.python.clone();
let Some(project) = app.config.projects.iter_mut().find(|p| p.id == id) else {
return Task::none();
};
let location = VenvLocation::Custom { path };
if project
.environments
.iter()
.any(|environment| environment.location == location)
{
return Task::none();
}
project
.environments
.push(bombadil_core::model::Environment {
location,
python: seed,
});
rebuild_entries(app);
return Task::batch([persist_env_edit(app), probe_every_venv(app)]);
}
Message::EnvironmentPinChanged(id, location, version) => {
let pin = preferences::pin_from_input(&version);
let Some(project) = app.config.projects.iter_mut().find(|p| p.id == id) else {
return Task::none();
};
let Some(environment) = project
.environments
.iter_mut()
.find(|environment| environment.location == location)
else {
return Task::none();
};
environment.python = pin;
rebuild_entries(app);
return persist_env_edit(app);
}
Message::EnvironmentRemoveRequested(id, location) => {
let Some(project) = app.config.projects.iter().find(|p| p.id == id) else {
return Task::none();
};
let Some(environment) = project
.environments
.iter()
.find(|environment| environment.location == location)
else {
return Task::none();
};
let Ok(venv_path) = venv_path::resolve(project, environment, &app.config.settings)
else {
return Task::none();
};
app.confirm_environment_removal = Some(context_menu::ConfirmEnvironmentRemoval {
project_id: id,
label: project.label.clone(),
location,
venv_path,
});
}
Message::EnvironmentRemovalCancelled => {
app.confirm_environment_removal = None;
}
Message::EnvironmentRemovalConfirmed => {
let Some(confirm) = app.confirm_environment_removal.take() else {
return Task::none();
};
let Some(project) = app
.config
.projects
.iter_mut()
.find(|p| p.id == confirm.project_id)
else {
return Task::none();
};
project
.environments
.retain(|environment| environment.location != confirm.location);
if project.active == confirm.location {
project.active = project
.environments
.first()
.map(|environment| environment.location.clone())
.unwrap_or_default();
}
rebuild_entries(app);
return Task::batch([persist_env_edit(app), probe_every_venv(app)]);
}
Message::EnvironmentLocationSelected(id, from, to) => {
let Some(project) = app.config.projects.iter_mut().find(|p| p.id == id) else {
return Task::none();
};
if from != to
&& project
.environments
.iter()
.any(|environment| environment.location == to)
{
return Task::none();
}
let Some(environment) = project
.environments
.iter_mut()
.find(|environment| environment.location == from)
else {
return Task::none();
};
environment.location = to.clone();
if project.active == from {
project.active = to;
}
rebuild_entries(app);
return Task::batch([persist_env_edit(app), probe_every_venv(app)]);
}
Message::EnvironmentPickFolderRequested(id, from) => {
return Task::perform(pick_folder(), move |path| {
Message::EnvironmentFolderPicked(id, from.clone(), path)
});
}
Message::EnvironmentFolderPicked(_, _, None) => {}
Message::EnvironmentFolderPicked(id, from, Some(path)) => {
return Task::done(Message::EnvironmentLocationSelected(
id,
from,
VenvLocation::Custom { path },
));
}
Message::PreferencesInheritToggled(scope, section) => {
let preferences::Scope::Project(id) = scope else {
return Task::none();
};
let Some(project) = app.config.projects.iter_mut().find(|p| p.id == id) else {
return Task::none();
};
match section {
preferences::Section::Python => return Task::none(),
preferences::Section::Uv => project.uv_source = Override::Inherit,
preferences::Section::Terminal => {
project.terminals.remove(&Os::host());
}
preferences::Section::Environments
| preferences::Section::Scripts
| preferences::Section::EnvVars
| preferences::Section::Indexes => return Task::none(),
}
return persist_env_edit(app);
}
Message::EnvVarAdded(scope) => {
if let Some(vars) = env_editor::vars_mut(&mut app.config, scope) {
env_editor::add_var(vars);
}
return persist_env_edit(app);
}
Message::EnvVarRemoved(scope, index) => {
let owed = env_removal_secret_key(app, scope, index);
if let Some(vars) = env_editor::vars_mut(&mut app.config, scope) {
env_editor::remove_var(vars, index);
}
env_editor::drop_draft_row(&mut app.env_value_drafts, scope, index);
if let Some(key) = owed {
let config = app.config.clone();
let persist = app.persist.clone();
return Task::perform(unmark_secret_job(key, config, persist), |success| {
Message::EnvVarPersistFinished { success }
});
}
return persist_env_edit(app);
}
Message::EnvVarKeyChanged(scope, index, key) => {
if let Some(vars) = env_editor::vars_mut(&mut app.config, scope) {
env_editor::set_key(vars, index, key);
}
return persist_env_edit(app);
}
Message::EnvVarValueChanged(scope, index, value) => {
app.env_value_drafts.insert((scope, index), value);
}
Message::EnvVarValueCommitted(scope, index) => {
let committed = match env_editor::vars_mut(&mut app.config, scope) {
Some(vars) => {
env_editor::commit_value(vars, &mut app.env_value_drafts, scope, index)
}
None => false,
};
if committed {
return persist_env_edit(app);
}
}
Message::EnvVarSecretToggled(scope, index) => {
let project_id = match scope {
env_editor::EnvScope::Global => None,
env_editor::EnvScope::Project(i) => match app.config.projects.get(i) {
Some(project) => Some(project.id),
None => return Task::none(),
},
};
let Some(key_name) = env_editor::vars(&app.config, scope)
.and_then(|vars| vars.get(index))
.map(|var| var.key.clone())
else {
return Task::none();
};
let secret_key = env_editor::secret_key(scope, project_id, &key_name);
let persist = app.persist.clone();
match plan_secret_toggle(&app.config, &app.env_value_drafts, scope, index) {
Some(SecretToggle::Mark {
candidate,
plain_value,
}) => {
return Task::perform(
mark_secret_job(secret_key, plain_value, candidate, persist),
move |success| Message::EnvVarSecretWritten {
scope,
index,
success,
},
);
}
Some(SecretToggle::Unmark { candidate }) => {
return Task::perform(
unmark_secret_job(secret_key, candidate, persist),
move |success| Message::EnvVarSecretWritten {
scope,
index,
success,
},
);
}
None => {}
}
}
Message::EnvVarPersistFinished { success } => {
if !success {
drawer::set_idle(
&mut app.drawer,
"env var change failed to persist".to_string(),
);
}
}
Message::EnvVarSecretWritten {
scope,
index,
success,
} => {
if !success {
drawer::set_idle(
&mut app.drawer,
"env var change failed to persist".to_string(),
);
} else if let Some(vars) = env_editor::vars_mut(&mut app.config, scope)
&& env_editor::take_secret_value(vars, &mut app.env_value_drafts, scope, index)
.is_none()
{
env_editor::unmark_secret(vars, index);
}
}
Message::ScriptAddRequested(scope) => {
return Task::perform(pick_script(), move |path| {
Message::ScriptFilePicked(scope, path)
});
}
Message::ScriptFilePicked(_, None) => {}
Message::ScriptFilePicked(scope, Some(path)) => {
if let Some(scripts) = scripts_editor::scripts_mut(&mut app.config, scope) {
scripts_editor::add_script(scripts, path);
}
return persist_script_edit(app);
}
Message::ScriptRemoved(scope, index) => {
if let Some(scripts) = scripts_editor::scripts_mut(&mut app.config, scope) {
scripts_editor::remove_script(scripts, index);
}
return persist_script_edit(app);
}
Message::ScriptEnabledToggled(scope, index) => {
if let Some(scripts) = scripts_editor::scripts_mut(&mut app.config, scope) {
scripts_editor::toggle_enabled(scripts, index);
}
return persist_script_edit(app);
}
Message::ScriptMovedUp(scope, index) => {
if let Some(scripts) = scripts_editor::scripts_mut(&mut app.config, scope) {
scripts_editor::move_up(scripts, index);
}
return persist_script_edit(app);
}
Message::ScriptMovedDown(scope, index) => {
if let Some(scripts) = scripts_editor::scripts_mut(&mut app.config, scope) {
scripts_editor::move_down(scripts, index);
}
return persist_script_edit(app);
}
Message::ScriptPersistFinished { success } => {
if !success {
drawer::set_idle(
&mut app.drawer,
"script change failed to persist".to_string(),
);
}
}
Message::IndexAdded => {
index_editor::add_index(&mut app.config.indexes);
app.index_credential_drafts.push(String::new());
app.index_credential_names.push(String::new());
return persist_index_edit(app);
}
Message::IndexRemoved(i) => {
let owed = index_credential_key(app, i);
index_editor::remove_index(&mut app.config.indexes, i);
if i < app.index_credential_drafts.len() {
app.index_credential_drafts.remove(i);
}
if i < app.index_credential_names.len() {
app.index_credential_names.remove(i);
}
if let Some(key) = owed {
let config = app.config.clone();
let persist = app.persist.clone();
return Task::perform(unmark_secret_job(key, config, persist), |success| {
Message::IndexPersistFinished { success }
});
}
return persist_index_edit(app);
}
Message::IndexNameChanged(i, name) => {
index_editor::set_name(&mut app.config.indexes, i, name);
}
Message::IndexUrlChanged(i, url) => {
index_editor::set_url(&mut app.config.indexes, i, url);
}
Message::IndexUsernameChanged(i, username) => {
index_editor::set_username(&mut app.config.indexes, i, username);
}
Message::IndexEditCommitted(i) => return commit_index_edit(app, i),
Message::IndexKindChanged(i, kind) => {
index_editor::set_kind(&mut app.config.indexes, i, kind);
return commit_index_edit(app, i);
}
Message::IndexDefaultToggled(i) => {
index_editor::toggle_default_for_new_projects(&mut app.config.indexes, i);
return commit_index_edit(app, i);
}
Message::IndexAuthChoiceChanged(i, choice) => {
index_editor::set_auth_choice(&mut app.config.indexes, i, choice);
return commit_index_edit(app, i);
}
Message::IndexCredentialChanged(i, input) => {
if let Some(slot) = app.index_credential_drafts.get_mut(i) {
*slot = input.0;
}
}
Message::IndexCredentialSaveRequested(i) => {
if let Some((key, plain_value)) =
index_editor::credential_save(&app.config, &app.index_credential_drafts, i)
{
let config = app.config.clone();
let persist = app.persist.clone();
return Task::perform(
mark_secret_job(key, plain_value, config, persist),
move |success| Message::IndexCredentialWritten { index: i, success },
);
}
}
Message::IndexCredentialWritten { index: i, success } => {
if !success {
drawer::set_idle(
&mut app.drawer,
"index change failed to persist".to_string(),
);
} else {
if let Some(slot) = app.index_credential_drafts.get_mut(i) {
*slot = String::new();
}
if let (Some(index), Some(slot)) = (
app.config.indexes.get(i),
app.index_credential_names.get_mut(i),
) {
*slot = index.name.clone();
}
}
}
Message::IndexPersistFinished { success } => {
if !success {
drawer::set_idle(
&mut app.drawer,
"index change failed to persist".to_string(),
);
}
}
Message::ProjectIndexToggled(project_index, name) => {
if let Some(project) = app.config.projects.get_mut(project_index) {
index_editor::toggle_selected(&mut project.index_names, &name);
}
return persist_index_edit(app);
}
Message::MemberToggled(i, path) => {
if let Some((mi, fetch)) = &app.members
&& *mi == i
&& let Some(project) = app.config.projects.get_mut(i)
{
members_editor::toggle(&mut project.members, &fetch.discovered, &fetch.kind, &path);
return persist_members_edit(app);
}
}
Message::MembersPersistFinished { success } => {
if !success {
drawer::set_idle(
&mut app.drawer,
"member selection failed to persist".to_string(),
);
}
}
Message::MembersSyncRequested(i) => {
if let Some((mi, fetch)) = &app.members
&& *mi == i
&& let Some(project) = app.config.projects.get(i).cloned()
{
let project_dir = project
.pyproject_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_default();
let tab = members_editor::build(
&project_dir,
&project.members,
&fetch.kind,
&fetch.discovered,
&fetch.installed,
);
if let Some(warning) = members_editor::removal_warning(&tab.rows) {
app.confirm_member_sync = Some(members_editor::ConfirmMemberSync {
project_index: i,
label: project.label.clone(),
warning,
});
} else if app.syncing.is_none() {
let kind = fetch.kind.clone();
let discovered = fetch.discovered.clone();
return dispatch_members_sync(app, project, kind, discovered, runner);
}
}
}
Message::MembersSyncCancelled => {
app.confirm_member_sync = None;
}
Message::MembersSyncConfirmed => {
if let Some(confirm) = app.confirm_member_sync.take()
&& app.syncing.is_none()
&& let Some((mi, fetch)) = &app.members
&& *mi == confirm.project_index
&& let Some(project) = app.config.projects.get(confirm.project_index).cloned()
{
let kind = fetch.kind.clone();
let discovered = fetch.discovered.clone();
return dispatch_members_sync(app, project, kind, discovered, runner);
}
}
Message::SettingsUvSourceChoiceChanged(choice) => {
settings_editor::apply_choice(&mut app.config.settings, choice);
app.uv_custom_validation = None;
return persist_settings_edit(app);
}
Message::SettingsUvCustomPathChanged(text) => {
app.uv_custom_path_draft = text;
}
Message::SettingsUvCustomValidateRequested => {
let path = PathBuf::from(app.uv_custom_path_draft.trim());
let config = app.config.clone();
return Task::perform(
validate_uv_source(path, config, runner),
Message::SettingsUvCustomValidated,
);
}
Message::SettingsUvCustomValidated(Ok(resolved)) => {
app.config.settings.uv_source = UvSource::Custom {
path: resolved.path.clone(),
};
app.uv_custom_validation = Some(Ok(resolved.version.to_string()));
return persist_settings_edit(app);
}
Message::SettingsUvCustomValidated(Err(message)) => {
app.uv_custom_validation = Some(Err(message));
}
Message::SettingsPersistFinished { success } => {
if !success {
drawer::set_idle(
&mut app.drawer,
"settings change failed to persist".to_string(),
);
}
}
Message::SettingsDefaultVenvLocationAlongsideSelected => {
settings_editor::apply_default_venv_location(
&mut app.config.settings,
DefaultVenvLocation::Alongside,
);
return Task::batch([persist_settings_edit(app), probe_every_venv(app)]);
}
Message::SettingsDefaultVenvLocationPickFolderRequested => {
return Task::perform(
pick_folder(),
Message::SettingsDefaultVenvLocationFolderPicked,
);
}
Message::SettingsDefaultVenvLocationFolderPicked(None) => {}
Message::SettingsDefaultVenvLocationFolderPicked(Some(path)) => {
settings_editor::apply_default_venv_location(
&mut app.config.settings,
DefaultVenvLocation::Central { path },
);
return Task::batch([persist_settings_edit(app), probe_every_venv(app)]);
}
}
Task::none()
}
fn rebuild_entries(app: &mut App) {
type Probed = (
Option<String>,
Vec<(bombadil_core::model::VenvLocation, Option<String>)>,
);
let probed: Vec<Probed> = app
.entries
.iter()
.map(|entry| {
(
entry.requires_python.clone(),
entry
.environments
.iter()
.map(|row| (row.location.clone(), row.python_version.clone()))
.collect(),
)
})
.collect();
app.entries = sidebar::entries(&app.config);
for (entry, (requires_python, rows)) in app.entries.iter_mut().zip(probed) {
entry.requires_python = requires_python;
for row in &mut entry.environments {
if let Some((_, version)) = rows.iter().find(|(location, _)| *location == row.location)
{
row.python_version = version.clone();
}
}
}
}
fn refresh_after_sync(app: &App, runner: Arc<dyn CommandRunner>) -> Task<Message> {
let mut tasks = vec![probe_every_venv(app)];
if let Some(i) = app.selected
&& let Some(project) = app.config.projects.get(i)
{
tasks.push(fetch_deps_task(
i,
project.clone(),
app.config.clone(),
runner.clone(),
));
tasks.push(fetch_members_task(
i,
project.clone(),
app.config.clone(),
runner,
));
}
Task::batch(tasks)
}
fn sync_target(app: &App, i: usize) -> Option<Project> {
app.config.projects.get(i).cloned()
}
fn run_index_credential_rename(
from: &SecretKey,
to: &SecretKey,
config: &Config,
store: &dyn SecretStore,
persist: &dyn ConfigPersist,
) -> bool {
let moved = match store.get(from) {
Ok(Some(value)) => store.set(to, &value).is_ok() && store.delete(from).is_ok(),
Ok(None) => true,
Err(_) => false,
};
persist.save(config).is_ok() && moved
}
async fn rename_index_credential_job(
from: SecretKey,
to: SecretKey,
config: Config,
persist: Arc<dyn ConfigPersist>,
) -> bool {
match job::run(
move || {
run_index_credential_rename(
&from,
&to,
&config,
&KeyringSecretStore::new(),
persist.as_ref(),
)
},
SYNC_TIMEOUT,
)
.await
{
job::JobResult::Done(success) => success,
job::JobResult::TimedOut | job::JobResult::Failed => false,
}
}
fn index_credential_key(app: &App, i: usize) -> Option<SecretKey> {
let name = app.index_credential_names.get(i)?;
Some(SecretKey::Index { name: name.clone() })
}
fn index_rename_on_commit(app: &App, i: usize) -> Option<(SecretKey, SecretKey)> {
let name = app.config.indexes.get(i)?.name.clone();
let from = index_credential_key(app, i)?;
let to = SecretKey::Index { name };
(from != to).then_some((from, to))
}
fn commit_index_edit(app: &mut App, i: usize) -> Task<Message> {
if let Some((from, to)) = index_rename_on_commit(app, i) {
if let (Some(index), Some(slot)) = (
app.config.indexes.get(i),
app.index_credential_names.get_mut(i),
) {
*slot = index.name.clone();
}
let config = app.config.clone();
let persist = app.persist.clone();
return Task::perform(
rename_index_credential_job(from, to, config, persist),
|success| Message::IndexPersistFinished { success },
);
}
persist_index_edit(app)
}
fn env_removal_secret_key(
app: &App,
scope: env_editor::EnvScope,
index: usize,
) -> Option<SecretKey> {
let (project_id, vars) = match scope {
env_editor::EnvScope::Global => (None, &app.config.global_env),
env_editor::EnvScope::Project(i) => {
let project = app.config.projects.get(i)?;
(Some(project.id), &project.env)
}
};
let var = vars.get(index)?;
var.secret
.then(|| env_editor::secret_key(scope, project_id, &var.key))
}
fn members_context(
app: &App,
i: usize,
) -> Option<(
bombadil_core::members::WorkspaceKind,
Vec<bombadil_core::members::DiscoveredMember>,
)> {
let (fetched_for, fetch) = app.members.as_ref()?;
if *fetched_for != i {
return None;
}
let project = app.config.projects.get(i)?;
match members_editor::sync_plan(&project.members, &fetch.kind, &fetch.discovered) {
members_editor::SyncPlan::Plain => None,
_ => Some((fetch.kind.clone(), fetch.discovered.clone())),
}
}
fn persist_members_edit(app: &App) -> Task<Message> {
let config = app.config.clone();
let persist = app.persist.clone();
Task::perform(persist_config(config, persist), |success| {
Message::MembersPersistFinished { success }
})
}
fn dispatch_members_sync(
app: &mut App,
project: Project,
kind: bombadil_core::members::WorkspaceKind,
discovered: Vec<bombadil_core::members::DiscoveredMember>,
runner: Arc<dyn CommandRunner>,
) -> Task<Message> {
let config = app.config.clone();
app.syncing = Some(project.id.to_string());
app.drawer = drawer::Drawer {
status: format!("syncing members for {}...", project.label),
..drawer::Drawer::default()
};
Task::stream(members_sync(project, config, kind, discovered, runner))
}
fn probe_draft_venv(draft: &add_project::Draft, settings: &Settings) -> Task<Message> {
match add_project::resolved_venv_path(draft, settings) {
Ok(path) => Task::perform(probe_venv_outcome(path), |(path, outcome)| {
Message::AddProjectVenvOutcomeProbed(path, outcome)
}),
Err(_) => Task::none(),
}
}
async fn probe_venv_outcome(path: PathBuf) -> (PathBuf, add_project::VenvOutcome) {
let version = probe_venv(path.clone()).await;
let outcome = add_project::venv_outcome(&path, |_| version.clone());
(path, outcome)
}
async fn pick_pyproject() -> Option<PathBuf> {
rfd::AsyncFileDialog::new()
.add_filter("pyproject.toml", &["toml"])
.pick_file()
.await
.map(|handle| handle.path().to_path_buf())
}
async fn pick_folder() -> Option<PathBuf> {
rfd::AsyncFileDialog::new()
.pick_folder()
.await
.map(|handle| handle.path().to_path_buf())
}
async fn pick_script() -> Option<PathBuf> {
rfd::AsyncFileDialog::new()
.pick_file()
.await
.map(|handle| handle.path().to_path_buf())
}
async fn load_manifest_draft(path: PathBuf) -> add_project::Draft {
let read_path = path.clone();
let text = match job::run(
move || std::fs::read_to_string(&read_path).unwrap_or_default(),
VENV_PROBE_TIMEOUT,
)
.await
{
job::JobResult::Done(text) => text,
job::JobResult::TimedOut | job::JobResult::Failed => String::new(),
};
add_project::draft_from(&path, &text)
}
fn sync_stream<F>(
work: F,
timed_out: &'static str,
failed: &'static str,
) -> impl futures::Stream<Item = Message>
where
F: FnOnce(&mut dyn FnMut(String)) -> (bool, Vec<String>) + Send + 'static,
{
job::stream(
move |emit| {
let (success, lines) = work(&mut |line| emit(Message::SyncLine(line)));
emit(Message::SyncFinished { success, lines });
},
SYNC_TIMEOUT,
)
.map(move |result| match result {
job::JobResult::Done(message) => message,
job::JobResult::TimedOut => Message::SyncFinished {
success: false,
lines: vec![timed_out.to_string()],
},
job::JobResult::Failed => Message::SyncFinished {
success: false,
lines: vec![failed.to_string()],
},
})
}
fn sync_project(
project: Project,
config: Config,
runner: Arc<dyn CommandRunner>,
) -> impl futures::Stream<Item = Message> {
sync_stream(
move |on_line| {
run_sync(
&project,
&config,
&KeyringSecretStore::new(),
runner.as_ref(),
on_line,
)
},
"sync timed out",
"sync failed unexpectedly",
)
}
fn resolve_uv_for(config: &Config, runner: &dyn CommandRunner) -> Result<uv::ResolvedUv, String> {
let bundled = || -> Result<PathBuf, String> {
let data_dir =
bombadil_core::store::paths::default_data_dir().map_err(|e| e.to_string())?;
bombadil_uv_bin::ensure_extracted(&data_dir).map_err(|e| e.to_string())
};
uv::resolve(
&config.settings.uv_source,
runner,
&uv::locate_on_path,
&bundled,
)
.map_err(|err| err.to_string())
}
fn list_interpreters_blocking(
config: &Config,
runner: &dyn CommandRunner,
) -> Result<Vec<Interpreter>, String> {
let resolved = resolve_uv_for(config, runner)?;
let spec = uv::command::python_list(&resolved);
match runner.run(&spec) {
Ok(outcome) if outcome.success() => uv::results::parse_python_list(&outcome.stdout)
.map_err(|err| format!("could not read uv's interpreter list: {err}")),
Ok(outcome) => Err(uv_refusal(&outcome, "uv python list failed")),
Err(err) => Err(err.to_string()),
}
}
async fn list_interpreters(
config: Config,
runner: Arc<dyn CommandRunner>,
) -> Result<Vec<Interpreter>, String> {
match job::run(
move || list_interpreters_blocking(&config, runner.as_ref()),
SYNC_TIMEOUT,
)
.await
{
job::JobResult::Done(result) => result,
job::JobResult::TimedOut => Err("listing interpreters timed out".to_string()),
job::JobResult::Failed => Err("listing interpreters failed unexpectedly".to_string()),
}
}
fn install_interpreter_blocking(
version: &str,
config: &Config,
runner: &dyn CommandRunner,
) -> bool {
let Ok(resolved) = resolve_uv_for(config, runner) else {
return false;
};
let spec = uv::command::python_install(&resolved, version);
runner
.run(&spec)
.map(|outcome| outcome.success())
.unwrap_or(false)
}
async fn install_and_relist(
version: String,
config: Config,
runner: Arc<dyn CommandRunner>,
) -> Result<Vec<Interpreter>, String> {
let install_config = config.clone();
let install_runner = runner.clone();
let _ = job::run(
move || install_interpreter_blocking(&version, &install_config, install_runner.as_ref()),
SYNC_TIMEOUT,
)
.await;
list_interpreters(config, runner).await
}
fn run_sync(
project: &Project,
config: &Config,
store: &dyn SecretStore,
runner: &dyn CommandRunner,
on_line: &mut dyn FnMut(String),
) -> (bool, Vec<String>) {
let Some(project_dir) = project
.pyproject_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
else {
return (false, vec!["no project directory to run uv in".to_string()]);
};
let Some(environment) = project.active_environment().cloned() else {
return (false, vec!["this project has no environment".to_string()]);
};
let venv_path = match venv_path::resolve(project, &environment, &config.settings) {
Ok(path) => path,
Err(err) => return (false, vec![err.to_string()]),
};
let resolved_uv = match resolve_uv_for(config, runner) {
Ok(resolved) => resolved,
Err(err) => return (false, vec![err]),
};
let ctx = uv::command::ProjectContext {
project_dir: project_dir.to_path_buf(),
venv_path: venv_path.clone(),
};
let spec = match environment.python.clone() {
PythonPin::Version(version) => uv::command::sync_with_python(&resolved_uv, &ctx, &version),
PythonPin::Unpinned => uv::command::sync(&resolved_uv, &ctx),
};
compose_and_stream(spec, project, config, &venv_path, store, runner, on_line)
}
fn sync_would_delete(pin: &PythonPin, venv_version: Option<&str>) -> bool {
let Some(version) = venv_version else {
return false;
};
!interpreter::satisfies_pin(version, pin)
}
fn run_members_sync(
project: &Project,
config: &Config,
kind: &bombadil_core::members::WorkspaceKind,
discovered: &[bombadil_core::members::DiscoveredMember],
store: &dyn SecretStore,
runner: &dyn CommandRunner,
on_line: &mut dyn FnMut(String),
) -> (bool, Vec<String>) {
let Some(project_dir) = project
.pyproject_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
else {
return (false, vec!["no project directory to run uv in".to_string()]);
};
let Some(environment) = project.active_environment().cloned() else {
return (false, vec!["this project has no environment".to_string()]);
};
let venv_path = match venv_path::resolve(project, &environment, &config.settings) {
Ok(path) => path,
Err(err) => return (false, vec![err.to_string()]),
};
let resolved_uv = match resolve_uv_for(config, runner) {
Ok(resolved) => resolved,
Err(err) => return (false, vec![err]),
};
let ctx = uv::command::ProjectContext {
project_dir: project_dir.to_path_buf(),
venv_path: venv_path.clone(),
};
let spec = match members_editor::sync_plan(&project.members, kind, discovered) {
members_editor::SyncPlan::Plain => uv::command::sync(&resolved_uv, &ctx),
members_editor::SyncPlan::AllPackages => uv::command::sync_all_members(&resolved_uv, &ctx),
members_editor::SyncPlan::Named(names) => {
uv::command::sync_members(&resolved_uv, &ctx, &names)
}
};
compose_and_stream(spec, project, config, &venv_path, store, runner, on_line)
}
fn members_sync(
project: Project,
config: Config,
kind: bombadil_core::members::WorkspaceKind,
discovered: Vec<bombadil_core::members::DiscoveredMember>,
runner: Arc<dyn CommandRunner>,
) -> impl futures::Stream<Item = Message> {
sync_stream(
move |on_line| {
run_members_sync(
&project,
&config,
&kind,
&discovered,
&KeyringSecretStore::new(),
runner.as_ref(),
on_line,
)
},
"sync timed out",
"sync failed unexpectedly",
)
}
fn run_recreate_venv(
project: &Project,
config: &Config,
store: &dyn SecretStore,
runner: &dyn CommandRunner,
on_line: &mut dyn FnMut(String),
delete: impl Fn(&Path) -> Result<(), String>,
) -> (bool, Vec<String>) {
let Some(environment) = project.active_environment().cloned() else {
return (false, vec!["this project has no environment".to_string()]);
};
let venv_path = match venv_path::resolve(project, &environment, &config.settings) {
Ok(path) => path,
Err(err) => return (false, vec![err.to_string()]),
};
if let Err(err) = delete(&venv_path) {
return (false, vec![err, "nothing was deleted".to_string()]);
}
let (success, mut lines) = run_sync(project, config, store, runner, on_line);
if !success {
lines.push(format!(
"{} was deleted and could not be rebuilt; the environment no longer exists",
venv_path.display()
));
}
(success, lines)
}
fn recreate_venv(
project: Project,
config: Config,
runner: Arc<dyn CommandRunner>,
) -> impl futures::Stream<Item = Message> {
sync_stream(
move |on_line| {
let settings = config.settings.clone();
let target = &project;
run_recreate_venv(
&project,
&config,
&KeyringSecretStore::new(),
runner.as_ref(),
on_line,
|path| {
let environment = target.active_environment().cloned().unwrap_or_default();
venv_path::delete(target, &environment, &settings, path)
.map_err(|e| e.to_string())
},
)
},
"recreating the environment timed out",
"recreating the environment failed unexpectedly",
)
}
fn run_confirm_add_project(
project: &Project,
config: &Config,
python_version: Option<&str>,
persist: &dyn ConfigPersist,
store: &dyn SecretStore,
runner: &dyn CommandRunner,
probe: impl Fn(&Path) -> Option<String>,
) -> (bool, Vec<String>) {
if let Err(err) = persist.save(config) {
return (false, vec![err]);
}
let Some(environment) = project.active_environment().cloned() else {
return (false, vec!["this project has no environment".to_string()]);
};
let venv_path = match venv_path::resolve(project, &environment, &config.settings) {
Ok(path) => path,
Err(err) => return (false, vec![err.to_string()]),
};
if let add_project::VenvOutcome::Adopt { python_version } =
add_project::venv_outcome(&venv_path, probe)
{
return (
true,
vec![format!(
"existing environment adopted (python {python_version})"
)],
);
}
let Some(project_dir) = project
.pyproject_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
else {
return (false, vec!["no project directory to run uv in".to_string()]);
};
let resolved_uv = match resolve_uv_for(config, runner) {
Ok(resolved) => resolved,
Err(err) => return (false, vec![err]),
};
let ctx = uv::command::ProjectContext {
project_dir: project_dir.to_path_buf(),
venv_path: venv_path.clone(),
};
let spec = match python_version {
Some(python) => uv::command::sync_with_python(&resolved_uv, &ctx, python),
None => uv::command::sync(&resolved_uv, &ctx),
};
collect_lines(|on_line| {
compose_and_stream(spec, project, config, &venv_path, store, runner, on_line)
})
}
fn compose_and_stream(
mut spec: bombadil_core::process::CommandSpec,
project: &Project,
config: &Config,
venv_path: &Path,
store: &dyn SecretStore,
runner: &dyn CommandRunner,
on_line: &mut dyn FnMut(String),
) -> (bool, Vec<String>) {
let inputs = EnvInputs {
parent: std::env::vars().collect(),
config: config.clone(),
project: project.clone(),
venv_path: venv_path.to_path_buf(),
};
let env = match envspec::compose(&inputs, store, runner) {
Ok(env) => env,
Err(err) => return (false, vec![err.to_string()]),
};
let secrets = secret_values(&inputs, &env);
spec.env = env;
spec.clear_env = true;
match runner.stream(&spec, &mut |line| {
on_line(envspec::redact(line.text().to_string(), &secrets))
}) {
Ok(outcome) => (outcome.success(), Vec::new()),
Err(err) => (false, vec![envspec::redact(err.to_string(), &secrets)]),
}
}
fn collect_lines(
work: impl FnOnce(&mut dyn FnMut(String)) -> (bool, Vec<String>),
) -> (bool, Vec<String>) {
let mut lines = Vec::new();
let (success, rest) = work(&mut |line| lines.push(line));
lines.extend(rest);
(success, lines)
}
async fn confirm_add_project(
project: Project,
config: Config,
python_version: Option<String>,
persist: Arc<dyn ConfigPersist>,
runner: Arc<dyn CommandRunner>,
) -> (bool, Vec<String>) {
match job::run(
move || {
run_confirm_add_project(
&project,
&config,
python_version.as_deref(),
persist.as_ref(),
&KeyringSecretStore::new(),
runner.as_ref(),
venv_path::probe,
)
},
SYNC_TIMEOUT,
)
.await
{
job::JobResult::Done(result) => result,
job::JobResult::TimedOut => (false, vec!["add project timed out".to_string()]),
job::JobResult::Failed => (false, vec!["add project failed unexpectedly".to_string()]),
}
}
async fn detect_terminals(os: Os, runner: Arc<dyn CommandRunner>) -> Vec<TerminalKind> {
match job::run(
move || terminal::detect::available(os, runner.as_ref()),
VENV_PROBE_TIMEOUT,
)
.await
{
job::JobResult::Done(kinds) => kinds,
job::JobResult::TimedOut | job::JobResult::Failed => Vec::new(),
}
}
fn run_open_terminal(
choice: &TerminalChoice,
project: &Project,
config: &Config,
store: &dyn SecretStore,
runner: &dyn CommandRunner,
scratch_dir: &Path,
) -> Result<(), String> {
let environment = project
.active_environment()
.cloned()
.ok_or_else(|| "this project has no environment".to_string())?;
let venv_path =
venv_path::resolve(project, &environment, &config.settings).map_err(|e| e.to_string())?;
let inputs = EnvInputs {
parent: std::env::vars().collect(),
config: config.clone(),
project: project.clone(),
venv_path,
};
let shell = std::env::var("SHELL").unwrap_or_default();
terminal::open::open(
choice,
Os::host(),
&inputs,
&shell,
store,
runner,
scratch_dir,
)
.map_err(|err| err.to_string())
}
fn run_open_pycharm(
project: &Project,
config: &Config,
home: &Path,
store: &dyn SecretStore,
runner: &dyn CommandRunner,
) -> Result<(), String> {
let launcher = ide::detect(Os::host(), home, &|p| p.exists(), runner).ok_or_else(|| {
let looked: Vec<String> = ide::candidates(Os::host(), home)
.iter()
.map(|p| p.display().to_string())
.collect();
format!(
"PyCharm was not found. Looked in:\n {}\nand for `pycharm` on PATH.",
looked.join("\n ")
)
})?;
let environment = project
.active_environment()
.cloned()
.ok_or_else(|| "this project has no environment".to_string())?;
let venv_path =
venv_path::resolve(project, &environment, &config.settings).map_err(|e| e.to_string())?;
let inputs = EnvInputs {
parent: std::env::vars().collect(),
config: config.clone(),
project: project.clone(),
venv_path,
};
ide::open(&launcher, &inputs, store, runner).map_err(|err| err.to_string())
}
async fn open_pycharm(
project: Project,
config: Config,
runner: Arc<dyn CommandRunner>,
) -> Result<(), String> {
match job::run(
move || {
let home = home_dir();
run_open_pycharm(
&project,
&config,
&home,
&KeyringSecretStore::new(),
runner.as_ref(),
)
},
VENV_PROBE_TIMEOUT,
)
.await
{
job::JobResult::Done(result) => result,
job::JobResult::TimedOut => Err("opening PyCharm timed out".to_string()),
job::JobResult::Failed => Err("opening PyCharm failed unexpectedly".to_string()),
}
}
fn home_dir() -> PathBuf {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_default()
}
fn report_no_terminal(app: &mut App) {
drawer::push_line(
&mut app.drawer,
"no terminal emulator was found on this machine",
&[],
);
drawer::set_idle(&mut app.drawer, "open terminal failed".to_string());
app.drawer.expanded = true;
}
async fn open_terminal(
choice: TerminalChoice,
project: Project,
config: Config,
runner: Arc<dyn CommandRunner>,
) -> Result<(), String> {
match job::run(
move || {
let scratch_dir = std::env::temp_dir().join("bombadil-terminal-init");
run_open_terminal(
&choice,
&project,
&config,
&KeyringSecretStore::new(),
runner.as_ref(),
&scratch_dir,
)
},
VENV_PROBE_TIMEOUT,
)
.await
{
job::JobResult::Done(result) => result,
job::JobResult::TimedOut => Err("opening the terminal timed out".to_string()),
job::JobResult::Failed => Err("opening the terminal failed unexpectedly".to_string()),
}
}
fn run_open_folder(os: Os, project: &Project, runner: &dyn CommandRunner) -> Result<(), String> {
let dir = project
.pyproject_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.ok_or_else(|| {
format!(
"{} has no directory to open",
project.pyproject_path.display()
)
})?;
bombadil_core::folder::open(os, dir, runner).map_err(|err| err.to_string())
}
async fn open_folder(
os: Os,
project: Project,
runner: Arc<dyn CommandRunner>,
) -> Result<(), String> {
match job::run(
move || run_open_folder(os, &project, runner.as_ref()),
VENV_PROBE_TIMEOUT,
)
.await
{
job::JobResult::Done(result) => result,
job::JobResult::TimedOut => Err("opening the folder timed out".to_string()),
job::JobResult::Failed => Err("opening the folder failed unexpectedly".to_string()),
}
}
fn run_persist(config: &Config, persist: &dyn ConfigPersist) -> bool {
persist.save(config).is_ok()
}
fn persist_env_edit(app: &App) -> Task<Message> {
let config = app.config.clone();
let persist = app.persist.clone();
Task::perform(persist_config(config, persist), |success| {
Message::EnvVarPersistFinished { success }
})
}
fn persist_script_edit(app: &App) -> Task<Message> {
let config = app.config.clone();
let persist = app.persist.clone();
Task::perform(persist_config(config, persist), |success| {
Message::ScriptPersistFinished { success }
})
}
fn persist_index_edit(app: &App) -> Task<Message> {
let config = app.config.clone();
let persist = app.persist.clone();
Task::perform(persist_config(config, persist), |success| {
Message::IndexPersistFinished { success }
})
}
fn persist_settings_edit(app: &App) -> Task<Message> {
let config = app.config.clone();
let persist = app.persist.clone();
Task::perform(persist_config(config, persist), |success| {
Message::SettingsPersistFinished { success }
})
}
fn validate_uv_source_blocking(
path: PathBuf,
mut config: Config,
runner: &dyn CommandRunner,
) -> Result<uv::ResolvedUv, String> {
config.settings.uv_source = UvSource::Custom { path };
resolve_uv_for(&config, runner)
}
async fn validate_uv_source(
path: PathBuf,
config: Config,
runner: Arc<dyn CommandRunner>,
) -> Result<uv::ResolvedUv, String> {
match job::run(
move || validate_uv_source_blocking(path, config, runner.as_ref()),
SYNC_TIMEOUT,
)
.await
{
job::JobResult::Done(result) => result,
job::JobResult::TimedOut => Err("validating uv timed out".to_string()),
job::JobResult::Failed => Err("validating uv failed unexpectedly".to_string()),
}
}
async fn persist_config(config: Config, persist: Arc<dyn ConfigPersist>) -> bool {
match job::run(move || run_persist(&config, persist.as_ref()), SYNC_TIMEOUT).await {
job::JobResult::Done(success) => success,
job::JobResult::TimedOut | job::JobResult::Failed => false,
}
}
#[derive(Debug)]
enum SecretToggle {
Mark {
candidate: Config,
plain_value: String,
},
Unmark { candidate: Config },
}
fn plan_secret_toggle(
config: &Config,
drafts: &env_editor::ValueDrafts,
scope: env_editor::EnvScope,
index: usize,
) -> Option<SecretToggle> {
let mut candidate = config.clone();
let mut drafts = drafts.clone();
let plain_value = {
let vars = env_editor::vars_mut(&mut candidate, scope)?;
match env_editor::take_secret_value(vars, &mut drafts, scope, index) {
Some(value) => Some(value),
None if env_editor::unmark_secret(vars, index) => None,
None => return None,
}
};
Some(match plain_value {
Some(plain_value) => SecretToggle::Mark {
candidate,
plain_value,
},
None => SecretToggle::Unmark { candidate },
})
}
fn run_secret_write(
key: &SecretKey,
plain_value: &str,
config: &Config,
store: &dyn SecretStore,
persist: &dyn ConfigPersist,
) -> bool {
store.set(key, plain_value).is_ok() && persist.save(config).is_ok()
}
fn run_secret_delete(
key: &SecretKey,
config: &Config,
store: &dyn SecretStore,
persist: &dyn ConfigPersist,
) -> bool {
store.delete(key).is_ok() && persist.save(config).is_ok()
}
async fn mark_secret_job(
key: SecretKey,
plain_value: String,
config: Config,
persist: Arc<dyn ConfigPersist>,
) -> bool {
match job::run(
move || {
run_secret_write(
&key,
&plain_value,
&config,
&KeyringSecretStore::new(),
persist.as_ref(),
)
},
SYNC_TIMEOUT,
)
.await
{
job::JobResult::Done(success) => success,
job::JobResult::TimedOut | job::JobResult::Failed => false,
}
}
async fn unmark_secret_job(
key: SecretKey,
config: Config,
persist: Arc<dyn ConfigPersist>,
) -> bool {
match job::run(
move || run_secret_delete(&key, &config, &KeyringSecretStore::new(), persist.as_ref()),
SYNC_TIMEOUT,
)
.await
{
job::JobResult::Done(success) => success,
job::JobResult::TimedOut | job::JobResult::Failed => false,
}
}
pub fn view(app: &App) -> iced::Element<'_, Message> {
let selected = app
.selected
.and_then(|i| app.config.projects.get(i).map(|project| (i, project)));
let detail: iced::Element<'_, Message> = match selected {
Some((i, project)) => {
let entry = app.entries.get(i);
let venv_path = entry.and_then(|entry| {
entry
.environments
.iter()
.find(|row| row.active)
.map(|row| row.path.clone())
});
let header = detail::header(project, venv_path, &|_| {
entry.and_then(|entry| {
entry
.environments
.iter()
.find(|row| row.active)
.and_then(|row| row.python_version.clone())
})
});
detail::view(&header, i)
}
None => iced::widget::text("").into(),
};
let pane: iced::Element<'_, Message> = match app.tab {
Tab::Dependencies => match &app.deps {
Some((i, tab)) if Some(*i) == app.selected => deps::view(tab),
_ => iced::widget::text("").into(),
},
Tab::Members => match &app.members {
Some((i, fetch)) if Some(*i) == app.selected => match app.config.projects.get(*i) {
Some(project) => {
let project_dir = project
.pyproject_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_default();
let tab = members_editor::build(
&project_dir,
&project.members,
&fetch.kind,
&fetch.discovered,
&fetch.installed,
);
members_editor::view(&tab, *i)
}
None => iced::widget::text("").into(),
},
_ => iced::widget::text("").into(),
},
Tab::EnvVars => match app.selected {
Some(i) => env_editor::project_view(&app.config, i, &app.env_value_drafts),
None => iced::widget::text("").into(),
},
Tab::Scripts => scripts_editor::view(&app.config, app.selected),
Tab::Indexes => match app.selected {
Some(i) => index_editor::project_view(&app.config, i),
None => iced::widget::text("").into(),
},
};
let mut tab_bar = iced::widget::row![].spacing(theme::SPACE_3);
for tab in Tab::ALL {
let is_selected = tab == app.tab;
let text_color = if is_selected {
theme::PARCHMENT
} else {
theme::SLATE
};
let label = iced::widget::button(iced::widget::text(tab.label()).size(theme::TITLE))
.on_press_maybe((!is_selected).then_some(Message::TabSelected(tab)))
.padding([theme::SPACE_1, theme::SPACE_2])
.style(theme::button_bare(text_color));
let underline = iced::widget::container(
iced::widget::Space::new()
.width(iced::Length::Fill)
.height(theme::TAB_UNDERLINE),
)
.width(iced::Length::Fill)
.style(move |_theme| {
let style = iced::widget::container::Style::default();
if is_selected {
style.background(theme::JACKET)
} else {
style
}
});
tab_bar = tab_bar.push(
iced::widget::Column::from_vec(vec![label.into(), underline.into()])
.spacing(theme::SPACE_1),
);
}
let detail_area: iced::Element<'_, Message> = if app.config.projects.is_empty() {
shell::empty_state()
} else if selected.is_none() {
iced::widget::center(
iced::widget::text("Select a project")
.size(theme::BODY)
.color(theme::SLATE),
)
.into()
} else {
iced::widget::column![
detail,
theme::hairline_row(),
tab_bar,
iced::widget::scrollable(pane)
.width(iced::Length::Fill)
.height(iced::Length::Fill),
]
.spacing(theme::SPACE_3)
.into()
};
let sidebar_header = iced::widget::row![
iced::widget::text("Projects")
.font(theme::FONT_PROSE_SEMIBOLD)
.size(theme::LABEL)
.color(theme::SLATE),
iced::widget::Space::new().width(iced::Length::Fill),
iced::widget::button(iced::widget::text("Add").size(theme::LABEL))
.on_press(Message::AddProjectPickManifestRequested)
.padding([theme::SPACE_1, theme::SPACE_2])
.style(theme::button_quiet),
]
.align_y(iced::Alignment::Center);
let sidebar_body: iced::Element<'_, Message> = if app.entries.is_empty() {
iced::widget::text("None yet")
.size(theme::DATA)
.color(theme::SLATE)
.into()
} else {
iced::widget::scrollable(sidebar::view(&app.entries, app.selected))
.height(iced::Length::Fill)
.into()
};
let sidebar_panel = iced::widget::container(
iced::widget::column![sidebar_header, sidebar_body].spacing(theme::SPACE_2),
)
.width(theme::SIDEBAR_WIDTH)
.height(iced::Length::Fill)
.padding(theme::SPACE_2)
.style(theme::surface);
let body = iced::widget::row![
sidebar_panel,
theme::hairline_column(),
iced::widget::container(detail_area)
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.padding(theme::SPACE_3),
]
.height(iced::Length::Fill);
let add_project_dialog: iced::Element<'_, Message> = match &app.add_project {
Some(draft) => {
let resolved = add_project::resolved_venv_path_display(draft, &app.config.settings);
add_project::view(draft, &resolved)
}
None => iced::widget::text("").into(),
};
let context_menu_view: iced::Element<'_, Message> = match &app.context_menu {
Some(menu) => {
let default = app.config.terminal_defaults.get(&Os::host());
let label = app
.config
.projects
.get(menu.project_index)
.map(|project| project.label.as_str())
.unwrap_or_default();
context_menu::view(menu, label, default)
}
None => iced::widget::text("").into(),
};
let confirm_recreate_view: iced::Element<'_, Message> = match &app.confirm_recreate {
Some(confirm) => context_menu::confirm_view(confirm),
None => iced::widget::text("").into(),
};
let confirm_pin_view: iced::Element<'_, Message> = match &app.confirm_pin_change {
Some(confirm) => context_menu::confirm_pin_view(confirm),
None => iced::widget::text("").into(),
};
let confirm_member_sync_view: iced::Element<'_, Message> = match &app.confirm_member_sync {
Some(confirm) => members_editor::confirm_view(confirm),
None => iced::widget::text("").into(),
};
let mut problems = app.load_problems.clone();
problems.extend(banner::problems(&app.config));
let frame = iced::widget::column![
shell::topbar(app.open_menu),
banner::view(&problems),
body,
theme::hairline_row(),
drawer::view(&app.drawer),
]
.height(iced::Length::Fill);
let mut layers = iced::widget::stack![frame];
if app.add_project.is_some() {
layers = layers.push(shell::overlay_layer(add_project_dialog));
}
if app.context_menu.is_some() {
layers = layers.push(shell::anchored_layer(
context_menu_view,
Message::ContextMenuClosed,
));
}
if app.confirm_recreate.is_some() {
layers = layers.push(shell::overlay_layer(confirm_recreate_view));
}
if app.confirm_pin_change.is_some() {
layers = layers.push(shell::overlay_layer(confirm_pin_view));
}
if let Some(confirm) = &app.confirm_environment_removal {
layers = layers.push(shell::overlay_layer(
context_menu::confirm_environment_removal_view(confirm),
));
}
if app.confirm_member_sync.is_some() {
layers = layers.push(shell::overlay_layer(confirm_member_sync_view));
}
if let Some(menu) = app.open_menu {
layers = layers.push(shell::menu_layer(menu));
}
if let Some(state) = app.preferences {
let scope = preferences::resolve_scope(state.scope, &app.config);
layers = layers.push(preferences::view(
state.section,
scope,
&app.config,
&app.env_value_drafts,
&app.index_credential_drafts,
&app.uv_custom_path_draft,
app.uv_custom_validation.as_ref(),
));
}
if let Some(modal) = app.modal {
layers = layers.push(shell::modal_layer(modal));
}
layers.into()
}
#[cfg(test)]
mod tests {
use super::*;
use bombadil_core::model::Config;
use bombadil_core::process::FakeCommandRunner;
use std::path::Path;
fn set_probed_version(app: &mut App, project: usize, version: &str) {
let row = app.entries[project]
.environments
.iter_mut()
.find(|row| row.active)
.expect("the project must have an active environment to probe");
row.python_version = Some(version.to_string());
}
fn selected_label(app: &App) -> Option<&str> {
app.selected
.and_then(|i| app.config.projects.get(i))
.map(|p| p.label.as_str())
}
fn host_terminal_kind() -> TerminalKind {
*TerminalKind::for_os(Os::host())
.first()
.expect("every supported OS has at least one terminal kind")
}
fn unused_runner() -> Arc<dyn CommandRunner> {
Arc::new(FakeCommandRunner::new())
}
#[test]
fn an_empty_config_produces_no_selection() {
let app = App::new(Config::default());
assert!(app.selected.is_none());
assert!(app.config.projects.is_empty());
}
#[test]
fn the_first_project_is_selected_on_load() {
let config = Config {
projects: vec![project("api"), project("web")],
..Config::default()
};
let app = App::new(config);
assert_eq!(selected_label(&app), Some("api"));
}
#[test]
fn selecting_a_project_changes_the_selection() {
let config = Config {
projects: vec![project("api"), project("web")],
..Config::default()
};
let mut app = App::new(config);
let _ = update(&mut app, Message::ProjectSelected(1), unused_runner());
assert_eq!(selected_label(&app), Some("web"));
}
#[test]
fn the_detail_area_starts_on_the_dependencies_tab() {
assert_eq!(App::new(Config::default()).tab, Tab::Dependencies);
}
#[test]
fn selecting_a_tab_switches_which_pane_renders() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let task = update(
&mut app,
Message::TabSelected(Tab::Scripts),
unused_runner(),
);
assert_eq!(app.tab, Tab::Scripts);
assert_eq!(
task.units(),
0,
"switching tabs is pure state -- nothing to fetch, nothing to persist"
);
}
#[test]
fn every_tab_renders_with_both_confirmations_open() {
let mut app = App::new(Config {
projects: vec![project("api")],
indexes: vec![index_with_auth(
"corp",
bombadil_core::model::IndexAuth::Token,
)],
..Config::default()
});
app.deps = Some((0, Ok(deps::build(&PyProject::default(), &[], no_tree()))));
app.members = Some((
0,
MembersFetch {
kind: bombadil_core::members::WorkspaceKind::Loose,
discovered: vec![],
installed: vec![],
},
));
app.confirm_recreate = Some(context_menu::ConfirmRecreate {
project_id: app.config.projects[0].id,
label: "api".to_string(),
venv_path: PathBuf::from("/p/api/.venv"),
});
app.confirm_member_sync = Some(members_editor::ConfirmMemberSync {
project_index: 0,
label: "api".to_string(),
warning: "would remove alpha".to_string(),
});
app.context_menu = Some(context_menu::ContextMenu {
project_index: 0,
terminals: vec![host_terminal_kind()],
});
for tab in Tab::ALL {
app.tab = tab;
let _ = view(&app);
}
}
#[test]
fn selecting_a_project_fetches_its_dependencies_through_job_run_not_inline() {
let config = Config {
projects: vec![project("api")],
..Config::default()
};
let mut app = App::new(config);
let fake = Arc::new(FakeCommandRunner::new());
let task = update(&mut app, Message::ProjectSelected(0), fake.clone());
assert!(
fake.calls().is_empty(),
"the dependency fetch must not call the runner synchronously inside update; got {:?}",
fake.calls()
);
assert!(
task.units() > 0,
"selecting a project must dispatch a real background fetch, not Task::none()"
);
}
#[test]
fn booting_with_a_selected_project_also_fetches_its_dependencies_without_blocking() {
let (_app, task) = boot(Loaded::Fresh(Config {
projects: vec![project("api")],
..Config::default()
}));
assert!(
task.units() > 0,
"boot must dispatch work for the initially selected project's dependencies"
);
}
fn no_tree() -> Result<TreeOutcome, String> {
Err("this test is not about the tree".to_string())
}
#[test]
fn a_failed_deps_fetch_is_kept_as_the_failure_it_is() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let _ = update(
&mut app,
Message::DepsLoaded(0, Err("No virtual environment found".to_string())),
unused_runner(),
);
let (index, fetched) = app
.deps
.as_ref()
.expect("a failed fetch must still land, or the tab stays blank forever");
assert_eq!(*index, 0);
let cause = fetched
.as_ref()
.expect_err("the failure must not have been flattened into a tab");
assert_eq!(cause, "No virtual environment found");
assert!(
deps::unavailable_message(cause).contains("No virtual environment found"),
"what the tab renders must name the cause"
);
}
#[test]
fn an_unresolvable_uv_fails_the_deps_fetch_with_its_own_message() {
let config = Config {
settings: bombadil_core::model::Settings {
uv_source: bombadil_core::model::UvSource::Custom {
path: PathBuf::from("/definitely/not/a/real/uv"),
},
..Default::default()
},
..Config::default()
};
let got = fetch_deps_blocking(&project("api"), &config, &FakeCommandRunner::new());
let err = got.expect_err("an unresolvable uv must not look like an empty environment");
assert!(
err.contains("/definitely/not/a/real/uv"),
"the resolve error's own text must survive; got {err}"
);
}
#[test]
fn a_uv_pip_list_that_refuses_fails_the_fetch_carrying_uvs_stderr() {
let config = Config {
settings: fake_uv_settings(),
..Config::default()
};
let runner = FakeCommandRunner::new();
version_probe(&runner);
runner.expect(
"uv",
&["pip", "list"],
bombadil_core::process::CommandOutcome {
status: Some(2),
stdout: String::new(),
stderr: "error: No virtual environment found at /p/api/.venv".into(),
},
);
let got = fetch_deps_blocking(&project("api"), &config, &runner);
let err = got.expect_err("a non-zero exit must not read as nothing installed");
assert!(
err.contains("No virtual environment found"),
"uv's own stderr must survive; got {err}"
);
}
#[test]
fn a_deps_fetch_result_is_built_and_stored_against_its_project_index() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let fetch = DepsFetch {
pyproject: bombadil_core::pyproject::PyProject {
dependencies: vec!["httpx>=0.27".to_string()],
..Default::default()
},
installed: vec![],
tree: no_tree(),
};
let _ = update(&mut app, Message::DepsLoaded(0, Ok(fetch)), unused_runner());
let (index, fetched) = app
.deps
.as_ref()
.expect("a landed fetch must populate app.deps");
assert_eq!(*index, 0);
let tab = fetched.as_ref().expect("this fetch succeeded");
assert!(
tab.groups[0].rows.iter().any(|r| r.name == "httpx"),
"the tab must be built from the fetched manifest; got {:?}",
tab.groups
);
}
#[test]
fn a_stale_deps_fetch_for_a_project_that_no_longer_exists_is_dropped() {
let mut app = App::new(Config::default());
let fetch = DepsFetch {
pyproject: PyProject::default(),
installed: vec![],
tree: no_tree(),
};
let _ = update(&mut app, Message::DepsLoaded(0, Ok(fetch)), unused_runner());
assert!(
app.deps.is_none(),
"a fetch result for an index with no matching project must be dropped"
);
}
#[test]
fn removing_the_displayed_project_clears_its_stale_dependency_tab() {
let mut app = App::new(Config {
projects: vec![project("api"), project("web")],
..Config::default()
});
app.deps = Some((0, Ok(deps::build(&PyProject::default(), &[], no_tree()))));
let _ = update(
&mut app,
Message::RemoveProjectRequested(0),
unused_runner(),
);
assert!(
app.deps.is_none(),
"removal must clear a dependency tab that may now describe the wrong project"
);
}
#[test]
fn selecting_out_of_range_is_ignored_rather_than_clamped() {
let config = Config {
projects: vec![project("api"), project("web")],
..Config::default()
};
let mut app = App::new(config);
let _ = update(&mut app, Message::ProjectSelected(99), unused_runner());
assert_eq!(
selected_label(&app),
Some("api"),
"an out-of-range selection must be ignored, not clamped to the last project"
);
}
#[test]
fn a_sync_message_never_calls_the_runner_synchronously() {
let config = Config {
projects: vec![project("api")],
..Config::default()
};
let mut app = App::new(config);
let fake = Arc::new(FakeCommandRunner::new());
let runner: Arc<dyn CommandRunner> = fake.clone();
let task = update(&mut app, Message::SyncRequested(0), runner);
assert!(
fake.calls().is_empty(),
"runner.run must not be called synchronously inside update; got {:?}",
fake.calls()
);
assert!(
task.units() > 0,
"a Sync message must dispatch real background work, not Task::none()"
);
}
#[test]
fn a_project_without_a_venv_still_dispatches_sync() {
let config = Config {
projects: vec![project("api")],
..Config::default()
};
let mut app = App::new(config);
assert!(
app.entries[0]
.environments
.iter()
.all(|row| row.python_version.is_none()),
"a freshly constructed App has not been probed yet -- entries start hollow"
);
let task = update(&mut app, Message::SyncRequested(0), unused_runner());
assert!(
task.units() > 0,
"Sync must still dispatch work for a project with no venv"
);
}
#[test]
fn a_corrupt_config_boots_with_a_banner_rather_than_a_silently_empty_list() {
let (app, _task) = boot(Loaded::Unreadable {
config: Config::default(),
message: "expected `=` at line 4".into(),
});
assert!(app.config.projects.is_empty());
assert!(
app.load_problems
.iter()
.any(|p| p.message.contains("could not be read")),
"the load failure must reach the banner; got {:?}",
app.load_problems
);
}
#[test]
fn a_failed_sync_keeps_the_transcript_visible() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let _ = update(
&mut app,
Message::SyncFinished {
success: false,
lines: vec![
"Resolved 12 packages".to_string(),
"error: no solution found".to_string(),
],
},
unused_runner(),
);
assert!(
app.drawer.expanded,
"a failed sync must leave the transcript on screen; drawer was collapsed showing only {:?}",
app.drawer.status
);
assert!(
!app.drawer.lines.is_empty(),
"the transcript must survive the collapse-to-status step"
);
assert!(
app.drawer
.lines
.iter()
.any(|l| l.contains("no solution found")),
"the actionable line must be among them; got {:?}",
app.drawer.lines
);
}
#[test]
fn the_user_can_close_the_drawer_and_it_stays_closed() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
assert!(app.drawer.expanded, "it starts open");
let _ = update(&mut app, Message::DrawerToggled, unused_runner());
assert!(!app.drawer.expanded);
let _ = update(
&mut app,
Message::SyncLine("Resolved 12 packages".to_string()),
unused_runner(),
);
let _ = update(
&mut app,
Message::SyncFinished {
success: false,
lines: Vec::new(),
},
unused_runner(),
);
assert!(
!app.drawer.expanded,
"neither output nor a failure may reopen a drawer the user closed"
);
assert!(
!app.drawer.lines.is_empty(),
"the transcript is still collected, ready for when they open it"
);
let _ = update(&mut app, Message::DrawerToggled, unused_runner());
assert!(app.drawer.expanded, "and reopening shows it");
}
#[test]
fn a_successful_sync_leaves_its_transcript_on_screen() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let _ = update(
&mut app,
Message::SyncFinished {
success: true,
lines: vec!["Installed 12 packages".to_string()],
},
unused_runner(),
);
assert!(
app.drawer.expanded,
"a successful sync must leave its output readable"
);
assert_eq!(app.drawer.status, "sync succeeded");
assert!(!app.drawer.lines.is_empty(), "and its lines with it");
}
#[test]
fn a_sync_in_flight_shows_a_running_status_not_idle() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let _ = update(&mut app, Message::SyncRequested(0), unused_runner());
assert!(
app.drawer.status.contains("syncing"),
"a dispatched sync must show a running status; got {:?}",
app.drawer.status
);
assert_ne!(app.drawer.status, "idle");
}
#[test]
fn a_second_sync_while_one_is_in_flight_dispatches_nothing() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let fake = Arc::new(FakeCommandRunner::new());
let first = update(&mut app, Message::SyncRequested(0), fake.clone());
assert!(first.units() > 0, "the first Sync must dispatch work");
let second = update(&mut app, Message::SyncRequested(0), fake.clone());
assert_eq!(
second.units(),
0,
"a second Sync while one is in flight must dispatch nothing"
);
assert!(
fake.calls().is_empty(),
"no uv command may be issued by the ignored press; got {:?}",
fake.calls()
);
}
#[test]
fn syncing_from_the_context_menu_targets_the_right_clicked_project_not_the_selected_one() {
let mut app = App::new(Config {
projects: vec![project("api"), project("web")],
..Config::default()
});
assert_eq!(app.selected, Some(0), "api must be the selected project");
let web_id = app.config.projects[1].id.to_string();
let _ = update(&mut app, Message::ContextMenuOpened(1), unused_runner());
assert_eq!(
app.selected,
Some(0),
"right-pressing must NOT move the selection -- that would silently \
change what the detail pane shows"
);
let _ = update(&mut app, Message::SyncRequested(1), unused_runner());
assert_eq!(
app.syncing.as_deref(),
Some(web_id.as_str()),
"the sync must target the right-clicked project (web), not the selected one (api)"
);
assert!(
app.drawer.status.contains("web"),
"the running status must name the project actually being synced; got {:?}",
app.drawer.status
);
}
#[test]
fn syncing_from_the_context_menu_closes_it() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let _ = update(&mut app, Message::ContextMenuOpened(0), unused_runner());
assert!(app.context_menu.is_some());
let _ = update(&mut app, Message::SyncRequested(0), unused_runner());
assert!(app.context_menu.is_none());
}
#[test]
fn syncing_an_out_of_range_project_dispatches_nothing() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let task = update(&mut app, Message::SyncRequested(99), unused_runner());
assert_eq!(task.units(), 0);
assert!(app.syncing.is_none());
}
#[test]
fn a_sync_that_finishes_releases_the_in_flight_slot() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let _ = update(&mut app, Message::SyncRequested(0), unused_runner());
let _ = update(
&mut app,
Message::SyncFinished {
success: true,
lines: vec![],
},
unused_runner(),
);
let again = update(&mut app, Message::SyncRequested(0), unused_runner());
assert!(
again.units() > 0,
"Sync must dispatch again once the previous run has finished"
);
}
fn fake_uv_settings() -> bombadil_core::model::Settings {
bombadil_core::model::Settings {
uv_source: bombadil_core::model::UvSource::Custom {
path: PathBuf::from("/fake/bin/uv"),
},
..Default::default()
}
}
fn version_probe(runner: &FakeCommandRunner) {
runner.expect(
"uv",
&["--version"],
bombadil_core::process::CommandOutcome {
status: Some(0),
stdout: "uv 0.12.1".into(),
stderr: String::new(),
},
);
}
#[test]
fn sync_runs_uv_with_the_composed_environment_and_nothing_ambient() {
let project = bombadil_core::model::Project {
env: vec![bombadil_core::model::EnvVar {
key: "MARKER".into(),
value: "set".into(),
secret: false,
}],
..project("api")
};
let config = Config {
projects: vec![project.clone()],
settings: fake_uv_settings(),
..Config::default()
};
let store = bombadil_core::secrets::InMemorySecretStore::new();
let runner = FakeCommandRunner::new();
version_probe(&runner);
runner.expect(
"uv",
&["sync"],
bombadil_core::process::CommandOutcome {
status: Some(0),
stdout: String::new(),
stderr: String::new(),
},
);
let (success, _lines) =
collect_lines(|on_line| run_sync(&project, &config, &store, &runner, on_line));
assert!(success);
let calls = runner.calls();
let sync = calls
.iter()
.find(|c| c.args.iter().any(|a| a == "sync"))
.expect("uv sync must have been run");
assert!(
sync.clear_env,
"the composition must be authoritative, not layered on the ambient environment"
);
assert_eq!(
sync.env.get("MARKER").map(String::as_str),
Some("set"),
"a project env var must reach uv; got {:?}",
sync.env.keys().collect::<Vec<_>>()
);
assert!(
sync.env.contains_key("VIRTUAL_ENV"),
"the composed activation layer must be present"
);
}
#[test]
fn a_secret_in_the_composed_environment_never_reaches_the_drawer() {
let project = bombadil_core::model::Project {
env: vec![bombadil_core::model::EnvVar {
key: "TOKEN".into(),
value: String::new(),
secret: true,
}],
..project("api")
};
let config = Config {
projects: vec![project.clone()],
settings: fake_uv_settings(),
..Config::default()
};
let store = bombadil_core::secrets::InMemorySecretStore::new();
bombadil_core::secrets::SecretStore::set(
&store,
&bombadil_core::secrets::SecretKey::ProjectEnv {
project_id: project.id,
key: "TOKEN".into(),
},
"s3cr3t-sync-value",
)
.unwrap();
let runner = FakeCommandRunner::new();
version_probe(&runner);
runner.expect(
"uv",
&["sync"],
bombadil_core::process::CommandOutcome {
status: Some(1),
stdout: String::new(),
stderr: "error: 401 for index (token: s3cr3t-sync-value)".into(),
},
);
let (success, lines) =
collect_lines(|on_line| run_sync(&project, &config, &store, &runner, on_line));
assert!(!success);
let mut app = App::new(config);
let _ = update(
&mut app,
Message::SyncFinished { success, lines },
unused_runner(),
);
assert!(
app.drawer
.lines
.iter()
.all(|l| !l.contains("s3cr3t-sync-value")),
"a keychain secret leaked into the drawer transcript: {:?}",
app.drawer.lines
);
assert!(
app.drawer.lines.iter().any(|l| l.contains("***")),
"the failing line must still be shown, redacted; got {:?}",
app.drawer.lines
);
}
#[test]
fn a_pinned_project_syncs_with_that_interpreter() {
let project = Project {
environments: vec![bombadil_core::model::Environment {
location: VenvLocation::Alongside,
python: PythonPin::Version("3.12".into()),
}],
active: VenvLocation::Alongside,
..project("api")
};
let config = Config {
projects: vec![project.clone()],
settings: fake_uv_settings(),
..Config::default()
};
let store = bombadil_core::secrets::InMemorySecretStore::new();
let runner = FakeCommandRunner::new();
version_probe(&runner);
runner.expect(
"uv",
&["sync"],
bombadil_core::process::CommandOutcome {
status: Some(0),
stdout: String::new(),
stderr: String::new(),
},
);
let (success, lines) =
collect_lines(|on_line| run_sync(&project, &config, &store, &runner, on_line));
assert!(success, "got {lines:?}");
let sync = runner
.calls()
.into_iter()
.find(|c| c.args.first().map(String::as_str) == Some("sync"))
.expect("a sync must have run");
assert!(
sync.args.windows(2).any(|w| w == ["-p", "3.12"]),
"the pin must reach uv; got {:?}",
sync.args
);
}
#[test]
fn a_probe_result_lands_on_the_environment_it_probed() {
let second = VenvLocation::Custom {
path: std::path::PathBuf::from("/envs/api-311"),
};
let mut app = App::new(Config {
projects: vec![Project {
environments: vec![
bombadil_core::model::Environment {
location: VenvLocation::Alongside,
python: PythonPin::Unpinned,
},
bombadil_core::model::Environment {
location: second.clone(),
python: PythonPin::Unpinned,
},
],
active: VenvLocation::Alongside,
..project("api")
}],
..Config::default()
});
let _ = update(
&mut app,
Message::VenvProbed {
index: 0,
location: second.clone(),
python_version: Some("3.11.9".to_string()),
requires_python: None,
},
unused_runner(),
);
let rows = &app.entries[0].environments;
assert_eq!(
rows.iter()
.find(|row| row.location == second)
.and_then(|row| row.python_version.clone()),
Some("3.11.9".to_string()),
"the probed environment must carry its own result"
);
assert_eq!(
rows[0].python_version, None,
"and no other environment may pick it up"
);
}
#[test]
fn a_probe_for_an_environment_the_project_no_longer_has_is_dropped() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let _ = update(
&mut app,
Message::VenvProbed {
index: 0,
location: VenvLocation::Custom {
path: std::path::PathBuf::from("/gone"),
},
python_version: Some("3.11.9".to_string()),
requires_python: None,
},
unused_runner(),
);
assert!(
app.entries[0]
.environments
.iter()
.all(|row| row.python_version.is_none()),
"a result for an environment that is gone must land nowhere"
);
}
#[test]
fn a_projects_own_env_vars_and_indexes_are_reachable_from_its_tabs() {
for tab in [Tab::EnvVars, Tab::Indexes, Tab::Scripts] {
assert!(
Tab::ALL.contains(&tab),
"{tab:?} is a per-project surface and belongs in the tab bar"
);
}
assert_eq!(
Tab::ALL.len(),
5,
"Dependencies, Members, Env vars, Scripts, Indexes -- and Settings \
deliberately not, since it has no per-project half"
);
}
#[test]
fn adding_an_environment_creates_nothing_until_a_folder_is_picked() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let id = app.config.projects[0].id;
let before = app.config.projects[0].environments.len();
let task = update(&mut app, Message::EnvironmentAdded(id), unused_runner());
assert!(task.units() > 0, "the press must dispatch something");
assert_eq!(
app.config.projects[0].environments.len(),
before,
"nothing may be created before the user has said where"
);
assert!(
app.entries[0].environments.len() == before,
"and nothing may appear in the tree either"
);
}
#[test]
fn cancelling_the_folder_picker_adds_nothing() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let id = app.config.projects[0].id;
let before = app.config.projects[0].environments.len();
let task = update(
&mut app,
Message::EnvironmentAddFolderPicked(id, None),
unused_runner(),
);
assert_eq!(app.config.projects[0].environments.len(), before);
assert_eq!(task.units(), 0);
}
#[test]
fn the_chosen_folder_is_where_the_environment_goes() {
let chosen = std::path::PathBuf::from("/home/me/envs/shared");
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let id = app.config.projects[0].id;
let _ = update(
&mut app,
Message::EnvironmentAddFolderPicked(id, Some(chosen.clone())),
unused_runner(),
);
assert_eq!(
app.config.projects[0].environments.last().unwrap().location,
VenvLocation::Custom {
path: chosen.clone()
},
"the environment must live exactly where the user pointed"
);
assert!(
app.entries[0]
.environments
.iter()
.any(|row| row.path == chosen),
"and the tree must show that directory"
);
}
#[test]
fn choosing_a_folder_this_project_already_uses_adds_nothing() {
let chosen = std::path::PathBuf::from("/home/me/envs/shared");
let mut app = App::new(Config {
projects: vec![Project {
environments: vec![bombadil_core::model::Environment {
location: VenvLocation::Custom {
path: chosen.clone(),
},
python: PythonPin::Unpinned,
}],
active: VenvLocation::Custom {
path: chosen.clone(),
},
..project("api")
}],
..Config::default()
});
let id = app.config.projects[0].id;
let task = update(
&mut app,
Message::EnvironmentAddFolderPicked(id, Some(chosen)),
unused_runner(),
);
assert_eq!(app.config.projects[0].environments.len(), 1);
assert_eq!(task.units(), 0);
}
#[test]
fn an_added_environment_appears_in_the_sidebar_at_once() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let id = app.config.projects[0].id;
let before = app.entries[0].environments.len();
let _ = update(
&mut app,
Message::EnvironmentAddFolderPicked(id, Some(std::path::PathBuf::from("/envs/new"))),
unused_runner(),
);
assert_eq!(
app.config.projects[0].environments.len(),
before + 1,
"the config must carry it"
);
assert_eq!(
app.entries[0].environments.len(),
before + 1,
"and the sidebar must show it without waiting for a restart"
);
}
#[test]
fn a_removed_environment_leaves_the_sidebar_at_once() {
let spare = VenvLocation::Custom {
path: std::path::PathBuf::from("/envs/spare"),
};
let mut app = App::new(Config {
projects: vec![Project {
environments: vec![
bombadil_core::model::Environment {
location: VenvLocation::Alongside,
python: PythonPin::Unpinned,
},
bombadil_core::model::Environment {
location: spare.clone(),
python: PythonPin::Unpinned,
},
],
active: VenvLocation::Alongside,
..project("api")
}],
..Config::default()
});
let id = app.config.projects[0].id;
let _ = update(
&mut app,
Message::EnvironmentRemoveRequested(id, spare.clone()),
unused_runner(),
);
let _ = update(
&mut app,
Message::EnvironmentRemovalConfirmed,
unused_runner(),
);
assert_eq!(app.entries[0].environments.len(), 1);
assert!(
app.entries[0]
.environments
.iter()
.all(|row| row.location != spare),
"the removed environment must be gone from the tree too"
);
}
#[test]
fn rebuilding_the_sidebar_keeps_versions_already_probed() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let id = app.config.projects[0].id;
set_probed_version(&mut app, 0, "3.12.13");
let _ = update(
&mut app,
Message::EnvironmentAddFolderPicked(id, Some(std::path::PathBuf::from("/envs/new"))),
unused_runner(),
);
assert_eq!(
app.entries[0]
.environments
.iter()
.find(|row| row.active)
.and_then(|row| row.python_version.clone()),
Some("3.12.13".to_string()),
"an environment already probed must not blank on an unrelated add"
);
}
#[test]
fn a_new_environment_starts_from_the_global_pin() {
let mut app = App::new(Config {
projects: vec![project("api")],
settings: Settings {
python: PythonPin::Version("3.12".into()),
..Settings::default()
},
..Config::default()
});
let id = app.config.projects[0].id;
let before = app.config.projects[0].environments.len();
let _ = update(
&mut app,
Message::EnvironmentAddFolderPicked(id, Some(std::path::PathBuf::from("/envs/new"))),
unused_runner(),
);
assert_eq!(app.config.projects[0].environments.len(), before + 1);
assert_eq!(
app.config.projects[0].environments.last().unwrap().python,
PythonPin::Version("3.12".into())
);
}
#[test]
fn two_environments_of_one_project_never_share_a_directory() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let id = app.config.projects[0].id;
for n in 0..3 {
let _ = update(
&mut app,
Message::EnvironmentAddFolderPicked(
id,
Some(std::path::PathBuf::from(format!("/envs/api-{n}"))),
),
unused_runner(),
);
}
let locations: Vec<_> = app.config.projects[0]
.environments
.iter()
.map(|environment| environment.location.clone())
.collect();
for (i, location) in locations.iter().enumerate() {
assert!(
!locations[i + 1..].contains(location),
"two environments landed on {location:?}"
);
}
}
#[test]
fn moving_an_environment_onto_a_taken_directory_is_refused() {
let taken = VenvLocation::Custom {
path: std::path::PathBuf::from("/envs/taken"),
};
let mut app = App::new(Config {
projects: vec![Project {
environments: vec![
bombadil_core::model::Environment {
location: VenvLocation::Alongside,
python: PythonPin::Unpinned,
},
bombadil_core::model::Environment {
location: taken.clone(),
python: PythonPin::Unpinned,
},
],
active: VenvLocation::Alongside,
..project("api")
}],
..Config::default()
});
let id = app.config.projects[0].id;
let task = update(
&mut app,
Message::EnvironmentLocationSelected(id, VenvLocation::Alongside, taken.clone()),
unused_runner(),
);
assert_eq!(task.units(), 0);
assert_eq!(
app.config.projects[0].environments.len(),
2,
"neither environment may be lost to the collision"
);
assert_eq!(
app.config.projects[0].environments[0].location,
VenvLocation::Alongside,
"the one that was moved must stay where it is"
);
}
#[test]
fn removing_an_environment_asks_first() {
let spare = VenvLocation::Custom {
path: std::path::PathBuf::from("/envs/spare"),
};
let mut app = App::new(Config {
projects: vec![Project {
environments: vec![
bombadil_core::model::Environment {
location: VenvLocation::Alongside,
python: PythonPin::Unpinned,
},
bombadil_core::model::Environment {
location: spare.clone(),
python: PythonPin::Unpinned,
},
],
active: VenvLocation::Alongside,
..project("api")
}],
..Config::default()
});
let id = app.config.projects[0].id;
let task = update(
&mut app,
Message::EnvironmentRemoveRequested(id, spare),
unused_runner(),
);
assert_eq!(task.units(), 0, "nothing may go before the user answers");
assert_eq!(
app.config.projects[0].environments.len(),
2,
"it must still be there until confirmed"
);
assert!(app.confirm_environment_removal.is_some());
}
#[test]
fn removing_the_active_environment_moves_active_to_a_survivor() {
let spare = VenvLocation::Custom {
path: std::path::PathBuf::from("/envs/spare"),
};
let mut app = App::new(Config {
projects: vec![Project {
environments: vec![
bombadil_core::model::Environment {
location: VenvLocation::Alongside,
python: PythonPin::Unpinned,
},
bombadil_core::model::Environment {
location: spare.clone(),
python: PythonPin::Unpinned,
},
],
active: VenvLocation::Alongside,
..project("api")
}],
..Config::default()
});
let id = app.config.projects[0].id;
let _ = update(
&mut app,
Message::EnvironmentRemoveRequested(id, VenvLocation::Alongside),
unused_runner(),
);
let _ = update(
&mut app,
Message::EnvironmentRemovalConfirmed,
unused_runner(),
);
assert_eq!(app.config.projects[0].environments.len(), 1);
assert_eq!(
app.config.projects[0].active, spare,
"active must name an environment that still exists"
);
}
#[test]
fn declining_a_removal_removes_nothing() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
app.confirm_environment_removal = Some(context_menu::ConfirmEnvironmentRemoval {
project_id: app.config.projects[0].id,
label: "api".into(),
location: VenvLocation::Alongside,
venv_path: std::path::PathBuf::from("/p/api/.venv"),
});
let before = app.config.projects[0].environments.len();
let task = update(
&mut app,
Message::EnvironmentRemovalCancelled,
unused_runner(),
);
assert_eq!(task.units(), 0);
assert!(app.confirm_environment_removal.is_none());
assert_eq!(app.config.projects[0].environments.len(), before);
}
#[test]
fn collapsing_a_project_persists() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
assert!(app.config.projects[0].expanded, "projects start expanded");
let task = update(&mut app, Message::ProjectExpandToggled(0), unused_runner());
assert!(!app.config.projects[0].expanded);
assert!(!app.entries[0].expanded, "the sidebar must follow at once");
assert!(task.units() > 0, "a persisted setting must be persisted");
}
#[test]
fn activating_an_environment_moves_what_sync_acts_on() {
let target = VenvLocation::Custom {
path: std::path::PathBuf::from("/envs/api-311"),
};
let mut app = App::new(Config {
projects: vec![Project {
environments: vec![
bombadil_core::model::Environment {
location: VenvLocation::Alongside,
python: PythonPin::Unpinned,
},
bombadil_core::model::Environment {
location: target.clone(),
python: PythonPin::Version("3.11".into()),
},
],
active: VenvLocation::Alongside,
..project("api")
}],
..Config::default()
});
let _ = update(
&mut app,
Message::EnvironmentActivated(0, target.clone()),
unused_runner(),
);
assert_eq!(app.config.projects[0].active, target);
assert_eq!(
app.config.projects[0]
.active_environment()
.map(|e| e.python.clone()),
Some(PythonPin::Version("3.11".into())),
"what Sync pins from must move with it"
);
assert!(
app.entries[0]
.environments
.iter()
.any(|row| row.active && row.location == target),
"exactly the pressed row must read as active in the sidebar"
);
assert_eq!(
app.entries[0]
.environments
.iter()
.filter(|row| row.active)
.count(),
1,
"one active environment, never two"
);
}
#[test]
fn pressing_the_environment_a_project_already_uses_still_selects_it() {
let only = VenvLocation::Custom {
path: std::path::PathBuf::from("/envs/web"),
};
let mut app = App::new(Config {
projects: vec![
project("api"),
Project {
id: uuid::Uuid::from_u128(2),
environments: vec![bombadil_core::model::Environment {
location: only.clone(),
python: PythonPin::Unpinned,
}],
active: only.clone(),
..project("web")
},
],
..Config::default()
});
app.selected = Some(0);
let task = update(
&mut app,
Message::EnvironmentActivated(1, only.clone()),
unused_runner(),
);
assert_eq!(
app.selected,
Some(1),
"pressing a project's only environment must still reach that project"
);
assert_eq!(app.config.projects[1].active, only);
assert!(task.units() > 0, "and re-read its dependencies");
}
#[test]
fn activating_an_environment_selects_its_project_too() {
let target = VenvLocation::Custom {
path: std::path::PathBuf::from("/envs/web"),
};
let mut app = App::new(Config {
projects: vec![
project("api"),
Project {
id: uuid::Uuid::from_u128(2),
environments: vec![bombadil_core::model::Environment {
location: target.clone(),
python: PythonPin::Unpinned,
}],
active: target.clone(),
..project("web")
},
],
..Config::default()
});
app.selected = Some(0);
let task = update(
&mut app,
Message::EnvironmentActivated(1, target),
unused_runner(),
);
assert_eq!(
app.selected,
Some(1),
"the pressed environment's project must become the selected one"
);
assert!(
task.units() > 0,
"and its dependencies and members must be re-read"
);
}
#[test]
fn activating_an_environment_a_project_does_not_have_changes_nothing() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let before = app.config.projects[0].active.clone();
let task = update(
&mut app,
Message::EnvironmentActivated(
0,
VenvLocation::Custom {
path: std::path::PathBuf::from("/not/ours"),
},
),
unused_runner(),
);
assert_eq!(app.config.projects[0].active, before);
assert_eq!(task.units(), 0);
}
#[test]
fn sync_pins_from_the_active_environment_not_another_one() {
let target = VenvLocation::Custom {
path: std::path::PathBuf::from("/envs/api-312"),
};
let project = Project {
environments: vec![
bombadil_core::model::Environment {
location: VenvLocation::Alongside,
python: PythonPin::Version("3.11".into()),
},
bombadil_core::model::Environment {
location: target.clone(),
python: PythonPin::Version("3.12".into()),
},
],
active: target,
..project("api")
};
let config = Config {
projects: vec![project.clone()],
settings: fake_uv_settings(),
..Config::default()
};
let store = bombadil_core::secrets::InMemorySecretStore::new();
let runner = FakeCommandRunner::new();
version_probe(&runner);
runner.expect(
"uv",
&["sync"],
bombadil_core::process::CommandOutcome {
status: Some(0),
stdout: String::new(),
stderr: String::new(),
},
);
let (success, lines) =
collect_lines(|on_line| run_sync(&project, &config, &store, &runner, on_line));
assert!(success, "got {lines:?}");
let sync = runner
.calls()
.into_iter()
.find(|c| c.args.first().map(String::as_str) == Some("sync"))
.expect("a sync must have run");
assert!(
sync.args.windows(2).any(|w| w == ["-p", "3.12"]),
"the ACTIVE environment's pin must reach uv; got {:?}",
sync.args
);
assert!(
!sync.args.iter().any(|a| a == "3.11"),
"the inactive environment's pin must not; got {:?}",
sync.args
);
}
#[test]
fn an_unpinned_project_syncs_with_no_interpreter_argument() {
let project = project("api");
let config = Config {
projects: vec![project.clone()],
settings: fake_uv_settings(),
..Config::default()
};
let store = bombadil_core::secrets::InMemorySecretStore::new();
let runner = FakeCommandRunner::new();
version_probe(&runner);
runner.expect(
"uv",
&["sync"],
bombadil_core::process::CommandOutcome {
status: Some(0),
stdout: String::new(),
stderr: String::new(),
},
);
let _ = collect_lines(|on_line| run_sync(&project, &config, &store, &runner, on_line));
let sync = runner
.calls()
.into_iter()
.find(|c| c.args.first().map(String::as_str) == Some("sync"))
.expect("a sync must have run");
assert!(
!sync.args.iter().any(|a| a == "-p"),
"an unpinned project must not gain an interpreter argument; got {:?}",
sync.args
);
}
#[test]
fn syncing_a_venv_that_is_off_its_pin_would_delete_it() {
assert!(sync_would_delete(
&PythonPin::Version("3.12".into()),
Some("3.13.14")
));
}
#[test]
fn syncing_a_venv_that_is_on_its_pin_deletes_nothing() {
assert!(!sync_would_delete(
&PythonPin::Version("3.12.13".into()),
Some("3.12")
));
}
#[test]
fn syncing_without_a_pin_or_without_a_venv_deletes_nothing() {
assert!(!sync_would_delete(&PythonPin::Unpinned, Some("3.13.14")));
assert!(!sync_would_delete(&PythonPin::Version("3.12".into()), None));
}
#[test]
fn pressing_sync_on_a_venv_off_its_pin_asks_before_it_deletes() {
let project = Project {
environments: vec![bombadil_core::model::Environment {
location: VenvLocation::Alongside,
python: PythonPin::Version("3.12".into()),
}],
active: VenvLocation::Alongside,
..project("api")
};
let mut app = App::new(Config {
projects: vec![project],
..Config::default()
});
set_probed_version(&mut app, 0, "3.13.14");
let task = update(&mut app, Message::SyncRequested(0), unused_runner());
assert_eq!(task.units(), 0, "nothing may run before the user answers");
assert!(
app.syncing.is_none(),
"the in-flight slot must not be taken"
);
let confirm = app
.confirm_pin_change
.as_ref()
.expect("the press must open the confirmation");
assert_eq!(confirm.from, "3.13.14");
assert_eq!(confirm.to, "3.12");
}
#[test]
fn pressing_sync_on_a_venv_that_matches_its_pin_just_syncs() {
let project = Project {
environments: vec![bombadil_core::model::Environment {
location: VenvLocation::Alongside,
python: PythonPin::Version("3.12".into()),
}],
active: VenvLocation::Alongside,
..project("api")
};
let mut app = App::new(Config {
projects: vec![project],
..Config::default()
});
set_probed_version(&mut app, 0, "3.12");
let task = update(&mut app, Message::SyncRequested(0), unused_runner());
assert!(task.units() > 0, "the sync must dispatch");
assert!(app.confirm_pin_change.is_none(), "nothing to confirm");
}
#[test]
fn declining_the_pin_change_runs_nothing() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
app.confirm_pin_change = Some(context_menu::ConfirmPinChange {
project_id: app.config.projects[0].id,
label: "api".into(),
venv_path: std::path::PathBuf::from("/p/api/.venv"),
from: "3.13.14".into(),
to: "3.12".into(),
});
let task = update(&mut app, Message::PinChangeCancelled, unused_runner());
assert_eq!(task.units(), 0, "declining must not delete anything");
assert!(app.confirm_pin_change.is_none());
assert!(app.syncing.is_none());
}
#[test]
fn a_sync_runs_its_uv_command_through_stream_not_run() {
let project = project("api");
let config = Config {
projects: vec![project.clone()],
settings: fake_uv_settings(),
..Config::default()
};
let store = bombadil_core::secrets::InMemorySecretStore::new();
let runner = FakeCommandRunner::new();
version_probe(&runner);
runner.expect(
"uv",
&["sync"],
bombadil_core::process::CommandOutcome {
status: Some(0),
stdout: "Installed 3 packages".into(),
stderr: String::new(),
},
);
let (success, _lines) =
collect_lines(|on_line| run_sync(&project, &config, &store, &runner, on_line));
assert!(success);
assert_eq!(
runner.streaming_calls(),
1,
"the `uv sync` itself must go through `stream`; got {:?}",
runner.calls()
);
}
#[test]
fn the_sync_stream_emits_one_message_per_line_then_a_terminal_one() {
let got: Vec<Message> = futures::executor::block_on(
sync_stream(
|on_line| {
on_line("Resolved 3 packages".to_string());
on_line("Installed 3 packages".to_string());
(true, Vec::new())
},
"timed out",
"failed",
)
.collect(),
);
let rendered: Vec<String> = got.iter().map(|m| format!("{m:?}")).collect();
assert_eq!(got.len(), 3, "got {rendered:?}");
assert!(
matches!(&got[0], Message::SyncLine(line) if line == "Resolved 3 packages"),
"got {rendered:?}"
);
assert!(
matches!(&got[1], Message::SyncLine(line) if line == "Installed 3 packages"),
"got {rendered:?}"
);
assert!(
matches!(
&got[2],
Message::SyncFinished { success: true, lines } if lines.is_empty()
),
"the terminal message must not repeat the lines already streamed; got {rendered:?}"
);
}
#[test]
fn a_streamed_line_is_redacted_before_it_becomes_a_message() {
let project = bombadil_core::model::Project {
env: vec![bombadil_core::model::EnvVar {
key: "TOKEN".into(),
value: String::new(),
secret: true,
}],
..project("api")
};
let config = Config {
projects: vec![project.clone()],
settings: fake_uv_settings(),
..Config::default()
};
let store = bombadil_core::secrets::InMemorySecretStore::new();
bombadil_core::secrets::SecretStore::set(
&store,
&bombadil_core::secrets::SecretKey::ProjectEnv {
project_id: project.id,
key: "TOKEN".into(),
},
"s3cr3t-streamed-value",
)
.unwrap();
let runner = FakeCommandRunner::new();
version_probe(&runner);
runner.expect(
"uv",
&["sync"],
bombadil_core::process::CommandOutcome {
status: Some(1),
stdout: String::new(),
stderr: "error: 401 for index (token: s3cr3t-streamed-value)".into(),
},
);
let mut streamed = Vec::new();
let (success, _lines) = run_sync(&project, &config, &store, &runner, &mut |line| {
streamed.push(line)
});
assert!(!success);
assert!(
!streamed.is_empty(),
"the failing line must reach the drawer, not be swallowed"
);
assert!(
streamed
.iter()
.all(|l| !l.contains("s3cr3t-streamed-value")),
"a keychain secret reached the callback unredacted: {streamed:?}"
);
assert!(
streamed.iter().any(|l| l.contains("***")),
"the line must still be shown, redacted; got {streamed:?}"
);
}
#[test]
fn a_finished_sync_refreshes_what_it_just_changed() {
let mut app = App::new(Config {
projects: vec![project("api")],
settings: fake_uv_settings(),
..Config::default()
});
app.selected = Some(0);
app.deps = Some((0, Err("stale".to_string())));
let task = update(
&mut app,
Message::SyncFinished {
success: true,
lines: Vec::new(),
},
unused_runner(),
);
assert!(
task.units() > 0,
"a finished sync must dispatch the re-reads it invalidated"
);
assert!(app.syncing.is_none(), "the slot must still be released");
}
#[test]
fn a_failed_sync_refreshes_too() {
let mut app = App::new(Config {
projects: vec![project("api")],
settings: fake_uv_settings(),
..Config::default()
});
app.selected = Some(0);
let task = update(
&mut app,
Message::SyncFinished {
success: false,
lines: vec!["error: no solution found".to_string()],
},
unused_runner(),
);
assert!(task.units() > 0, "a failed sync must refresh as well");
}
#[test]
fn a_finished_sync_with_nothing_selected_still_reprobes_the_sidebar() {
let mut app = App::new(Config {
projects: vec![project("api")],
settings: fake_uv_settings(),
..Config::default()
});
app.selected = None;
let task = update(
&mut app,
Message::SyncFinished {
success: true,
lines: Vec::new(),
},
unused_runner(),
);
assert!(
task.units() > 0,
"the venv probe covers every project, selected or not"
);
}
#[test]
fn a_failed_sync_keeps_a_streamed_transcript_visible() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
for message in [
Message::SyncLine("Resolved 12 packages".to_string()),
Message::SyncLine("error: no solution found".to_string()),
Message::SyncFinished {
success: false,
lines: Vec::new(),
},
] {
let _ = update(&mut app, message, unused_runner());
}
assert!(
app.drawer.expanded,
"a failed sync must leave the streamed transcript on screen; \
drawer was collapsed showing only {:?}",
app.drawer.status
);
assert_eq!(
app.drawer.lines,
vec!["Resolved 12 packages", "error: no solution found"],
"the streamed lines must be kept, in arrival order"
);
assert_eq!(app.drawer.status, "sync failed");
}
#[test]
fn a_line_arriving_mid_sync_is_shown_immediately() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let _ = update(
&mut app,
Message::SyncLine("Resolved 12 packages".to_string()),
unused_runner(),
);
assert!(
app.drawer.expanded,
"output arriving means a command is running; the drawer must be open"
);
assert_eq!(app.drawer.lines, vec!["Resolved 12 packages"]);
}
#[test]
fn a_project_env_var_typed_this_session_reaches_the_very_next_sync() {
let config = Config {
projects: vec![project("api")],
settings: fake_uv_settings(),
..Config::default()
};
let mut app = App::new(config);
for message in [
Message::EnvVarAdded(env_editor::EnvScope::Project(0)),
Message::EnvVarKeyChanged(env_editor::EnvScope::Project(0), 0, "MARKER".into()),
Message::EnvVarValueChanged(env_editor::EnvScope::Project(0), 0, "set".into()),
Message::EnvVarValueCommitted(env_editor::EnvScope::Project(0), 0),
] {
let _ = update(&mut app, message, unused_runner());
}
let task = update(&mut app, Message::SyncRequested(0), unused_runner());
assert!(task.units() > 0, "the Sync press must dispatch real work");
let target = sync_target(&app, 0).expect("the press must resolve a project");
let store = bombadil_core::secrets::InMemorySecretStore::new();
let runner = FakeCommandRunner::new();
version_probe(&runner);
runner.expect(
"uv",
&["sync"],
bombadil_core::process::CommandOutcome {
status: Some(0),
stdout: String::new(),
stderr: String::new(),
},
);
let (success, lines) =
collect_lines(|on_line| run_sync(&target, &app.config, &store, &runner, on_line));
assert!(success, "got {lines:?}");
let calls = runner.calls();
let sync = calls
.iter()
.find(|c| c.args.iter().any(|a| a == "sync"))
.expect("uv sync must have been run");
assert_eq!(
sync.env.get("MARKER").map(String::as_str),
Some("set"),
"a variable typed this session must be in the environment the very next sync \
runs in, not only after a restart; got {:?}",
sync.env.keys().collect::<Vec<_>>()
);
}
#[test]
fn an_unresolvable_uv_reports_the_actionable_message() {
let project = project("api");
let config = Config {
projects: vec![project.clone()],
settings: bombadil_core::model::Settings {
uv_source: bombadil_core::model::UvSource::Custom {
path: PathBuf::from("/definitely/not/a/real/uv"),
},
..Default::default()
},
..Config::default()
};
let store = bombadil_core::secrets::InMemorySecretStore::new();
let runner = FakeCommandRunner::new();
let (success, lines) =
collect_lines(|on_line| run_sync(&project, &config, &store, &runner, on_line));
assert!(!success);
let joined = lines.join("\n");
assert!(
joined.contains("/definitely/not/a/real/uv"),
"the resolve error's own text must survive; got {joined}"
);
}
fn interpreter(version: &str) -> Interpreter {
Interpreter {
key: format!("cpython-{version}"),
version: version.to_string(),
path: Some(PathBuf::from(format!("/usr/bin/python{version}"))),
implementation: "cpython".to_string(),
}
}
fn manifest_draft(text: &str) -> add_project::Draft {
add_project::draft_from(Path::new("/p/my-api/pyproject.toml"), text)
}
#[test]
fn loading_a_manifest_dispatches_an_interpreter_fetch_without_blocking() {
let mut app = App::new(Config::default());
let fake = Arc::new(FakeCommandRunner::new());
let task = update(
&mut app,
Message::AddProjectManifestLoaded(manifest_draft("")),
fake.clone(),
);
assert!(
fake.calls().is_empty(),
"runner.run must not be called synchronously; got {:?}",
fake.calls()
);
assert!(
task.units() > 0,
"loading a manifest must dispatch the interpreter fetch"
);
}
#[test]
fn a_finished_interpreter_fetch_lands_on_the_open_draft() {
let mut app = App::new(Config::default());
app.add_project = Some(manifest_draft(""));
let interpreters = vec![interpreter("3.12.4")];
let _ = update(
&mut app,
Message::AddProjectInterpretersLoaded(Ok(interpreters.clone())),
unused_runner(),
);
assert_eq!(
app.add_project.as_ref().map(|d| d.interpreters.clone()),
Some(add_project::Interpreters::Loaded(interpreters))
);
}
#[test]
fn a_failed_interpreter_fetch_reaches_the_draft_as_a_failure_not_an_empty_list() {
let mut app = App::new(Config::default());
app.add_project = Some(manifest_draft(""));
let _ = update(
&mut app,
Message::AddProjectInterpretersLoaded(Err("no uv binary is available".to_string())),
unused_runner(),
);
assert_eq!(
app.add_project.as_ref().map(|d| d.interpreters.clone()),
Some(add_project::Interpreters::Failed(
"no uv binary is available".to_string()
)),
"a failed fetch must be representable on the draft, not collapse into Pending"
);
}
#[test]
fn an_unresolvable_uv_fails_the_interpreter_fetch_with_its_own_message() {
let config = Config {
settings: bombadil_core::model::Settings {
uv_source: bombadil_core::model::UvSource::Custom {
path: PathBuf::from("/definitely/not/a/real/uv"),
},
..Default::default()
},
..Config::default()
};
let got = list_interpreters_blocking(&config, &FakeCommandRunner::new());
let err = got.expect_err("an unresolvable uv must not look like an empty machine");
assert!(
err.contains("/definitely/not/a/real/uv"),
"the resolve error's own text must survive; got {err}"
);
}
#[test]
fn unparseable_interpreter_output_fails_rather_than_reading_as_no_interpreters() {
let config = Config {
settings: fake_uv_settings(),
..Config::default()
};
let runner = FakeCommandRunner::new();
version_probe(&runner);
runner.expect(
"uv",
&["python", "list", "--output-format", "json"],
bombadil_core::process::CommandOutcome {
status: Some(0),
stdout: "not json at all".into(),
stderr: String::new(),
},
);
let got = list_interpreters_blocking(&config, &runner);
assert!(
got.is_err(),
"unparseable output must be reported, not silently emptied; got {got:?}"
);
}
#[test]
fn changing_the_venv_choice_dispatches_an_adoption_probe_without_touching_disk_inline() {
let mut app = App::new(Config::default());
let mut draft = manifest_draft("");
draft.venv_outcome = Some(add_project::VenvOutcome::Adopt {
python_version: "3.9.18".to_string(),
});
app.add_project = Some(draft);
let task = update(
&mut app,
Message::AddProjectVenvChoiceSelected(add_project::VenvChoice::Chosen(PathBuf::from(
"/mnt/fast/envs/api",
))),
unused_runner(),
);
assert!(
task.units() > 0,
"changing the venv choice must dispatch a fresh adopt-or-create probe"
);
assert_eq!(
app.add_project
.as_ref()
.and_then(|d| d.venv_outcome.clone()),
None,
"the previous folder's statement must be cleared, not left on screen"
);
}
#[test]
fn opening_the_dialog_probes_the_default_venv_location_too() {
let mut app = App::new(Config::default());
let task = update(
&mut app,
Message::AddProjectManifestLoaded(manifest_draft("")),
unused_runner(),
);
assert!(
task.units() > 1,
"loading a manifest must dispatch both the interpreter fetch and the venv probe; \
got {} unit(s)",
task.units()
);
}
#[test]
fn a_landed_adoption_probe_reaches_the_open_draft() {
let mut app = App::new(Config::default());
app.add_project = Some(manifest_draft(""));
let probed = PathBuf::from("/p/my-api/.venv");
let _ = update(
&mut app,
Message::AddProjectVenvOutcomeProbed(
probed,
add_project::VenvOutcome::Adopt {
python_version: "3.9.18".to_string(),
},
),
unused_runner(),
);
assert_eq!(
app.add_project
.as_ref()
.and_then(|d| d.venv_outcome.clone()),
Some(add_project::VenvOutcome::Adopt {
python_version: "3.9.18".to_string()
})
);
}
#[test]
fn an_adoption_probe_for_a_folder_no_longer_chosen_is_dropped() {
let mut app = App::new(Config::default());
app.add_project = Some(manifest_draft(""));
let _ = update(
&mut app,
Message::AddProjectVenvOutcomeProbed(
PathBuf::from("/somewhere/else/.venv"),
add_project::VenvOutcome::Adopt {
python_version: "3.9.18".to_string(),
},
),
unused_runner(),
);
assert_eq!(
app.add_project
.as_ref()
.and_then(|d| d.venv_outcome.clone()),
None,
"a probe of a path the draft no longer resolves to must be dropped"
);
}
#[test]
fn a_stale_interpreter_fetch_after_cancel_is_dropped() {
let mut app = App::new(Config::default());
app.add_project = None;
let _ = update(
&mut app,
Message::AddProjectInterpretersLoaded(Ok(vec![interpreter("3.12.4")])),
unused_runner(),
);
assert!(app.add_project.is_none());
}
#[test]
fn the_install_button_dispatches_without_blocking_when_a_version_can_be_suggested() {
let mut app = App::new(Config::default());
let mut draft =
manifest_draft("[project]\nname = \"my-api\"\nrequires-python = \">=3.11\"\n");
draft.interpreters = add_project::Interpreters::Loaded(vec![interpreter("3.9.18")]);
app.add_project = Some(draft);
let fake = Arc::new(FakeCommandRunner::new());
let task = update(
&mut app,
Message::AddProjectInstallInterpreterRequested,
fake.clone(),
);
assert!(
fake.calls().is_empty(),
"runner.run must not be called synchronously; got {:?}",
fake.calls()
);
assert!(
task.units() > 0,
"the install button must dispatch background work"
);
}
#[test]
fn the_install_button_dispatches_nothing_without_a_requirement_to_suggest_a_version_from() {
let mut app = App::new(Config::default());
app.add_project = Some(manifest_draft("[tool.uv.workspace]\nmembers = []\n"));
let task = update(
&mut app,
Message::AddProjectInstallInterpreterRequested,
unused_runner(),
);
assert_eq!(
task.units(),
0,
"nothing to install for a manifest with no requires-python"
);
}
#[derive(Default)]
struct FakeConfigPersist {
saved: std::sync::Mutex<Vec<Config>>,
}
impl FakeConfigPersist {
fn saved(&self) -> Vec<Config> {
self.saved.lock().unwrap().clone()
}
}
impl ConfigPersist for FakeConfigPersist {
fn save(&self, config: &Config) -> Result<(), String> {
self.saved.lock().unwrap().push(config.clone());
Ok(())
}
}
#[test]
fn confirming_the_dialog_appends_the_project_to_the_in_memory_list_and_closes() {
let mut app = App::new(Config::default());
app.add_project = Some(manifest_draft(
"[project]\nname = \"my-api\"\nrequires-python = \">=3.11\"\n",
));
let _ = update(&mut app, Message::AddProjectConfirmed, unused_runner());
assert_eq!(
app.config.projects.len(),
1,
"got {:?}",
app.config.projects
);
assert_eq!(app.config.projects.len(), 1);
assert_eq!(app.config.projects[0].label, "my-api");
assert_eq!(app.entries.len(), 1);
assert!(
app.add_project.is_none(),
"the dialog must close once confirmed"
);
}
#[test]
fn a_sync_pressed_right_after_confirming_the_dialog_dispatches_nothing() {
let mut app = App::new(Config::default());
app.add_project = Some(manifest_draft(
"[project]\nname = \"my-api\"\nrequires-python = \">=3.11\"\n",
));
let fake = Arc::new(FakeCommandRunner::new());
let confirm = update(&mut app, Message::AddProjectConfirmed, fake.clone());
assert!(confirm.units() > 0, "confirming must dispatch work");
let sync = update(&mut app, Message::SyncRequested(0), fake.clone());
assert_eq!(
sync.units(),
0,
"a Sync pressed while the confirm's own uv sync is in flight must dispatch nothing"
);
assert!(
fake.calls().is_empty(),
"no uv command may be issued by the ignored press; got {:?}",
fake.calls()
);
}
#[test]
fn a_finished_confirm_releases_the_in_flight_slot() {
let mut app = App::new(Config::default());
app.add_project = Some(manifest_draft("[project]\nname = \"my-api\"\n"));
let _ = update(&mut app, Message::AddProjectConfirmed, unused_runner());
let _ = update(
&mut app,
Message::AddProjectConfirmFinished {
success: true,
lines: vec![],
index: 0,
},
unused_runner(),
);
assert!(app.syncing.is_none());
let again = update(&mut app, Message::SyncRequested(0), unused_runner());
assert!(
again.units() > 0,
"Sync must dispatch again once the confirm has finished"
);
}
#[test]
fn confirming_a_dialog_with_nothing_chosen_does_nothing() {
let mut app = App::new(Config::default());
app.add_project = None;
let task = update(&mut app, Message::AddProjectConfirmed, unused_runner());
assert!(app.config.projects.is_empty());
assert_eq!(task.units(), 0);
}
#[test]
fn confirming_the_dialog_never_persists_or_runs_uv_synchronously() {
let mut app = App::new(Config::default());
app.add_project = Some(manifest_draft(""));
let persist = Arc::new(FakeConfigPersist::default());
app.persist = persist.clone();
let fake = Arc::new(FakeCommandRunner::new());
let task = update(&mut app, Message::AddProjectConfirmed, fake.clone());
assert!(
persist.saved().is_empty(),
"persist.save must not be called synchronously inside update"
);
assert!(
fake.calls().is_empty(),
"runner.run must not be called synchronously inside update; got {:?}",
fake.calls()
);
assert!(
task.units() > 0,
"confirming must dispatch real background work, not Task::none()"
);
}
#[test]
fn confirming_the_dialog_persists_the_appended_project_to_disk() {
let project = project("api");
let config = Config {
projects: vec![project.clone()],
..Config::default()
};
let persist = FakeConfigPersist::default();
let store = bombadil_core::secrets::InMemorySecretStore::new();
let runner = FakeCommandRunner::new();
let (success, _lines) = run_confirm_add_project(
&project,
&config,
None,
&persist,
&store,
&runner,
|_| Some("3.12.0".to_string()), );
assert!(success);
let saved = persist.saved();
assert_eq!(saved.len(), 1, "persist.save must be called exactly once");
assert_eq!(
saved[0].projects,
vec![project],
"the project must be part of what gets persisted"
);
}
#[test]
fn creating_a_venv_on_confirm_runs_sync_with_python_using_the_composed_environment() {
let project = bombadil_core::model::Project {
env: vec![bombadil_core::model::EnvVar {
key: "MARKER".into(),
value: "set".into(),
secret: false,
}],
..project("api")
};
let config = Config {
projects: vec![project.clone()],
settings: fake_uv_settings(),
..Config::default()
};
let persist = FakeConfigPersist::default();
let store = bombadil_core::secrets::InMemorySecretStore::new();
let runner = FakeCommandRunner::new();
version_probe(&runner);
runner.expect(
"uv",
&["sync", "-p", "3.12.1"],
bombadil_core::process::CommandOutcome {
status: Some(0),
stdout: String::new(),
stderr: String::new(),
},
);
let (success, _lines) = run_confirm_add_project(
&project,
&config,
Some("3.12.1"),
&persist,
&store,
&runner,
|_| None, );
assert!(success);
let calls = runner.calls();
let sync = calls
.iter()
.find(|c| c.args.iter().any(|a| a == "sync"))
.expect("uv sync -p must have been run");
assert_eq!(
sync.args,
vec!["sync", "-p", "3.12.1"],
"the preselected interpreter must be pinned; got {:?}",
sync.args
);
assert!(
sync.clear_env,
"the composition must be authoritative, not layered on the ambient environment"
);
assert_eq!(
sync.env.get("MARKER").map(String::as_str),
Some("set"),
"a project env var must reach uv; got {:?}",
sync.env.keys().collect::<Vec<_>>()
);
assert!(
sync.env.contains_key("VIRTUAL_ENV"),
"the composed activation layer must be present"
);
}
#[test]
fn adopting_an_existing_venv_on_confirm_does_not_run_sync() {
let project = project("api");
let config = Config {
projects: vec![project.clone()],
settings: fake_uv_settings(),
..Config::default()
};
let persist = FakeConfigPersist::default();
let store = bombadil_core::secrets::InMemorySecretStore::new();
let runner = FakeCommandRunner::new();
let (success, lines) = run_confirm_add_project(
&project,
&config,
Some("3.12.1"),
&persist,
&store,
&runner,
|_| Some("3.11.9".to_string()), );
assert!(success);
assert!(
runner.calls().is_empty(),
"adoption must not run uv at all; got {:?}",
runner.calls()
);
assert!(
lines.iter().any(|l| l.contains("3.11.9")),
"the adopted version should be reported; got {lines:?}"
);
assert_eq!(
persist.saved().len(),
1,
"the config must still be persisted on adoption"
);
}
fn block_on<F: std::future::Future>(mut f: F) -> F::Output {
use std::task::{Context, Poll, Waker};
let mut f = unsafe { std::pin::Pin::new_unchecked(&mut f) };
let waker = Waker::noop();
let mut cx = Context::from_waker(waker);
loop {
if let Poll::Ready(v) = f.as_mut().poll(&mut cx) {
return v;
}
std::thread::yield_now();
}
}
#[test]
fn the_header_terminal_button_dispatches_without_spawning_inline() {
let mut app = App::new(Config {
projects: vec![project("api")],
terminal_defaults: std::collections::BTreeMap::from([(
Os::host(),
TerminalChoice::Detected(TerminalKind::Xterm),
)]),
..Config::default()
});
let fake = Arc::new(FakeCommandRunner::new());
let task = update(
&mut app,
Message::OpenTerminalHereRequested(0),
fake.clone(),
);
assert!(task.units() > 0, "the press must dispatch real work");
assert!(
fake.calls().is_empty(),
"nothing may be spawned synchronously inside update; got {:?}",
fake.calls()
);
assert!(
!app.drawer.status.contains("failed"),
"a configured default is not a failure; got {:?}",
app.drawer.status
);
}
#[test]
fn the_header_terminal_button_reports_when_no_terminal_exists() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let _ = update(
&mut app,
Message::OpenTerminalHereDetected(0, Vec::new()),
unused_runner(),
);
assert!(
app.drawer.lines.iter().any(|l| l.contains("no terminal")),
"the drawer must say why nothing opened; got {:?}",
app.drawer.lines
);
assert_eq!(app.drawer.status, "open terminal failed");
assert!(app.drawer.expanded, "the reason must be on screen");
}
#[test]
fn the_header_terminal_button_opens_the_first_detected_when_none_is_configured() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let task = update(
&mut app,
Message::OpenTerminalHereDetected(0, vec![TerminalKind::Kitty]),
unused_runner(),
);
assert!(task.units() > 0, "a detected terminal must be opened");
assert!(
app.drawer.lines.is_empty(),
"nothing failed, so nothing belongs in the transcript; got {:?}",
app.drawer.lines
);
}
#[test]
fn a_header_terminal_press_for_a_project_that_is_gone_does_nothing() {
let mut app = App::new(Config::default());
let task = update(
&mut app,
Message::OpenTerminalHereRequested(7),
unused_runner(),
);
assert_eq!(task.units(), 0);
let task = update(
&mut app,
Message::OpenTerminalHereDetected(7, vec![TerminalKind::Kitty]),
unused_runner(),
);
assert_eq!(task.units(), 0);
}
#[test]
fn opening_the_context_menu_dispatches_terminal_detection_without_blocking() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let fake = Arc::new(FakeCommandRunner::new());
let task = update(&mut app, Message::ContextMenuOpened(0), fake.clone());
assert!(
fake.calls().is_empty(),
"runner.run must not be called synchronously inside update; got {:?}",
fake.calls()
);
assert!(
task.units() > 0,
"opening the context menu must dispatch the terminal probe"
);
assert_eq!(
app.context_menu.as_ref().map(|m| m.project_index),
Some(0),
"the menu must open for the right-clicked project immediately"
);
}
#[test]
fn context_menu_terminal_detection_only_offers_what_the_probe_finds() {
let runner = FakeCommandRunner::new();
runner.expect(
"which",
&["kitty"],
bombadil_core::process::CommandOutcome {
status: Some(0),
stdout: "/usr/bin/kitty".into(),
stderr: String::new(),
},
);
let got = block_on(detect_terminals(
bombadil_core::terminal::Os::Linux,
Arc::new(runner),
));
assert_eq!(
got,
vec![bombadil_core::terminal::TerminalKind::Kitty],
"only the terminal the probe actually found must be offered"
);
}
#[test]
fn a_stale_detection_result_after_the_menu_closed_is_dropped() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
app.context_menu = None;
let _ = update(
&mut app,
Message::ContextMenuTerminalsDetected(vec![
bombadil_core::terminal::TerminalKind::Kitty,
]),
unused_runner(),
);
assert!(app.context_menu.is_none());
}
#[test]
fn the_configured_default_is_read_from_config_for_the_hosts_os() {
let kind = host_terminal_kind();
let config = Config {
projects: vec![project("api")],
terminal_defaults: std::collections::BTreeMap::from([(
Os::host(),
TerminalChoice::Detected(kind),
)]),
..Config::default()
};
let app = App::new(config);
assert_eq!(
app.config.terminal_defaults.get(&Os::host()),
Some(&TerminalChoice::Detected(kind)),
"the configured default for this OS must be reachable for view to order by"
);
}
#[test]
fn removing_a_project_deletes_it_from_the_in_memory_config_immediately() {
let config = Config {
projects: vec![project("api"), project("web")],
..Config::default()
};
let mut app = App::new(config);
let task = update(
&mut app,
Message::RemoveProjectRequested(0),
unused_runner(),
);
assert_eq!(
app.config.projects.len(),
1,
"got {:?}",
app.config.projects
);
assert_eq!(app.config.projects[0].label, "web");
assert_eq!(app.config.projects.len(), 1);
assert_eq!(app.config.projects[0].label, "web");
assert_eq!(app.entries.len(), 1);
assert!(
task.units() > 0,
"removal must still dispatch a background persist"
);
}
#[test]
fn removing_a_project_persists_the_config_without_it() {
let project_a = project("api");
let project_b = project("web");
let config = Config {
projects: vec![project_b.clone()],
..Config::default()
};
let persist = FakeConfigPersist::default();
let success = run_persist(&config, &persist);
assert!(success);
let saved = persist.saved();
assert_eq!(saved.len(), 1, "persist.save must be called exactly once");
assert_eq!(
saved[0].projects,
vec![project_b],
"the removed project must be absent from what gets persisted"
);
assert!(!saved[0].projects.contains(&project_a));
}
#[test]
fn removing_the_last_remaining_project_leaves_nothing_selected() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
app.selected = Some(0);
let _ = update(
&mut app,
Message::RemoveProjectRequested(0),
unused_runner(),
);
assert!(app.config.projects.is_empty());
assert!(app.selected.is_none());
}
#[test]
fn removing_a_project_never_touches_the_venv_on_disk() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let fake = Arc::new(FakeCommandRunner::new());
let _ = update(&mut app, Message::RemoveProjectRequested(0), fake.clone());
assert!(
fake.calls().is_empty(),
"removing a project must not run or spawn any process; got {:?}",
fake.calls()
);
}
#[test]
fn removing_an_out_of_range_project_is_ignored() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let task = update(
&mut app,
Message::RemoveProjectRequested(99),
unused_runner(),
);
assert_eq!(
app.config.projects.len(),
1,
"an invalid index must change nothing"
);
assert_eq!(task.units(), 0);
}
#[test]
fn removing_the_selected_project_clears_or_shifts_the_selection() {
let mut app = App::new(Config {
projects: vec![project("api"), project("web")],
..Config::default()
});
app.selected = Some(0);
let _ = update(
&mut app,
Message::RemoveProjectRequested(0),
unused_runner(),
);
assert_eq!(
selected_label(&app),
Some("web"),
"removing the selected project must not leave a dangling index"
);
}
#[test]
fn opening_a_terminal_never_calls_the_runner_synchronously() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let fake = Arc::new(FakeCommandRunner::new());
let task = update(
&mut app,
Message::OpenTerminalRequested(
0,
TerminalChoice::Detected(bombadil_core::terminal::TerminalKind::Kitty),
),
fake.clone(),
);
assert!(
fake.calls().is_empty(),
"runner.run/spawn must not be called synchronously inside update; got {:?}",
fake.calls()
);
assert!(
task.units() > 0,
"opening a terminal must dispatch real background work, not Task::none()"
);
assert!(
app.context_menu.is_none(),
"the menu should close once an action is taken from it"
);
}
#[test]
fn opening_a_terminal_for_an_out_of_range_project_dispatches_nothing() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let task = update(
&mut app,
Message::OpenTerminalRequested(
99,
TerminalChoice::Detected(bombadil_core::terminal::TerminalKind::Kitty),
),
unused_runner(),
);
assert_eq!(task.units(), 0);
}
#[test]
fn opening_a_terminal_spawns_the_chosen_kind_with_the_composed_environment() {
let project = bombadil_core::model::Project {
env: vec![bombadil_core::model::EnvVar {
key: "MARKER".into(),
value: "set".into(),
secret: false,
}],
..project("api")
};
let config = Config {
projects: vec![project.clone()],
..Config::default()
};
let store = bombadil_core::secrets::InMemorySecretStore::new();
let runner = FakeCommandRunner::new();
for program in ["gnome-terminal", "open", "wt"] {
runner.expect(
program,
&[],
bombadil_core::process::CommandOutcome {
status: Some(0),
stdout: String::new(),
stderr: String::new(),
},
);
}
let scratch = tempfile::tempdir().unwrap();
let result = run_open_terminal(
&TerminalChoice::Detected(host_terminal_kind()),
&project,
&config,
&store,
&runner,
scratch.path(),
);
assert!(result.is_ok(), "got {result:?}");
let calls = runner.calls();
assert_eq!(calls.len(), 1, "the terminal must actually be spawned");
let marker = project.id.to_string();
let referenced: Vec<&String> = calls[0]
.args
.iter()
.chain(calls[0].env.values())
.filter(|v| v.contains(&marker))
.collect();
let scratch_prefix = scratch.path().to_string_lossy().into_owned();
for value in referenced {
assert!(
value.starts_with(&scratch_prefix),
"an init file was written outside the scratch dir {scratch_prefix}: {value}"
);
}
assert!(
calls[0].clear_env,
"the composition must be authoritative, not layered on the ambient environment"
);
assert_eq!(
calls[0].env.get("MARKER").map(String::as_str),
Some("set"),
"a project env var must reach the terminal; got {:?}",
calls[0].env.keys().collect::<Vec<_>>()
);
}
#[test]
fn a_terminal_that_has_disappeared_reports_a_failure_not_a_panic() {
let config = Config {
projects: vec![project("api")],
..Config::default()
};
let store = bombadil_core::secrets::InMemorySecretStore::new();
let runner = FakeCommandRunner::new();
let scratch = tempfile::tempdir().unwrap();
let result = run_open_terminal(
&TerminalChoice::Detected(host_terminal_kind()),
&project("api"),
&config,
&store,
&runner,
scratch.path(),
);
let err =
result.expect_err("a terminal that will not spawn must not be reported as opened");
assert!(
!err.contains("not a terminal available"),
"the failure must come from the spawn, not the wrong-OS guard; got {err}"
);
}
fn recording_delete(
log: &std::sync::Mutex<Vec<PathBuf>>,
outcome: Result<(), String>,
) -> impl Fn(&Path) -> Result<(), String> + '_ {
move |path: &Path| {
log.lock().unwrap().push(path.to_path_buf());
outcome.clone()
}
}
#[test]
fn requesting_a_recreate_only_asks_for_confirmation_and_deletes_nothing_yet() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let fake = Arc::new(FakeCommandRunner::new());
let _ = update(&mut app, Message::ContextMenuOpened(0), fake.clone());
let task = update(&mut app, Message::RecreateVenvRequested(0), fake.clone());
assert_eq!(
task.units(),
0,
"requesting a recreate must dispatch NOTHING until it is confirmed"
);
assert!(
fake.calls().is_empty(),
"nothing may run before the user confirms; got {:?}",
fake.calls()
);
assert!(
app.confirm_recreate.is_some(),
"the confirmation must be on screen"
);
assert!(
app.context_menu.is_none(),
"the menu should close once an action is taken from it"
);
}
#[test]
fn the_confirmation_names_the_right_clicked_projects_own_venv_path() {
let mut app = App::new(Config {
projects: vec![project("api"), project("web")],
..Config::default()
});
assert_eq!(app.selected, Some(0));
let _ = update(&mut app, Message::RecreateVenvRequested(1), unused_runner());
let confirm = app.confirm_recreate.as_ref().expect("must be pending");
assert_eq!(confirm.project_id, app.config.projects[1].id);
assert_eq!(confirm.label, "web");
assert_eq!(
confirm.venv_path,
PathBuf::from("/p/web/.venv"),
"the confirmation must state the right-clicked project's resolved venv path"
);
}
#[test]
fn a_pending_recreate_never_deletes_a_project_the_user_did_not_name() {
let mut app = App::new(Config {
projects: vec![project("a"), project("b"), project("c")],
..Config::default()
});
let c_id = app.config.projects[2].id.to_string();
let _ = update(&mut app, Message::RecreateVenvRequested(1), unused_runner());
assert!(
app.confirm_recreate.is_some(),
"the confirmation for b must be pending"
);
let _ = update(
&mut app,
Message::RemoveProjectRequested(0),
unused_runner(),
);
let task = update(&mut app, Message::RecreateVenvConfirmed, unused_runner());
assert_ne!(
app.syncing.as_deref(),
Some(c_id.as_str()),
"confirming a recreate for b must never delete c's environment"
);
assert_eq!(
task.units(),
0,
"a confirmation whose list shifted underneath it must dispatch nothing"
);
assert!(app.syncing.is_none());
}
#[test]
fn a_recreate_confirmation_resolves_its_project_by_id_not_by_position() {
let mut app = App::new(Config {
projects: vec![project("a"), project("b")],
..Config::default()
});
let b_id = app.config.projects[1].id.to_string();
let _ = update(&mut app, Message::RecreateVenvRequested(1), unused_runner());
app.config.projects.swap(0, 1);
let _ = update(&mut app, Message::RecreateVenvConfirmed, unused_runner());
assert_eq!(
app.syncing.as_deref(),
Some(b_id.as_str()),
"the confirmation must act on the project it named, wherever that \
project now sits"
);
assert!(
app.drawer.status.contains('b'),
"the running status must name it too; got {:?}",
app.drawer.status
);
}
#[test]
fn cancelling_a_recreate_dispatches_nothing_and_clears_the_confirmation() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let fake = Arc::new(FakeCommandRunner::new());
let _ = update(&mut app, Message::RecreateVenvRequested(0), fake.clone());
let task = update(&mut app, Message::RecreateVenvCancelled, fake.clone());
assert_eq!(task.units(), 0);
assert!(fake.calls().is_empty(), "got {:?}", fake.calls());
assert!(app.confirm_recreate.is_none());
}
#[test]
fn confirming_a_recreate_dispatches_background_work_without_running_anything_inline() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let fake = Arc::new(FakeCommandRunner::new());
let _ = update(&mut app, Message::RecreateVenvRequested(0), fake.clone());
let task = update(&mut app, Message::RecreateVenvConfirmed, fake.clone());
assert!(
fake.calls().is_empty(),
"runner.run must not be called synchronously inside update; got {:?}",
fake.calls()
);
assert!(
task.units() > 0,
"a confirmed recreate must dispatch real background work"
);
assert!(
app.confirm_recreate.is_none(),
"the confirmation must close once it has been acted on"
);
assert!(
app.syncing.is_some(),
"a recreate runs uv sync, so it must claim the single in-flight slot"
);
}
#[test]
fn a_recreate_deletes_the_projects_venv_and_then_rebuilds_it() {
let project = project("api");
let config = Config {
projects: vec![project.clone()],
settings: fake_uv_settings(),
..Config::default()
};
let store = bombadil_core::secrets::InMemorySecretStore::new();
let runner = FakeCommandRunner::new();
version_probe(&runner);
runner.expect(
"uv",
&["sync"],
bombadil_core::process::CommandOutcome {
status: Some(0),
stdout: String::new(),
stderr: String::new(),
},
);
let deleted = std::sync::Mutex::new(Vec::new());
let (success, _lines) = collect_lines(|on_line| {
run_recreate_venv(
&project,
&config,
&store,
&runner,
on_line,
recording_delete(&deleted, Ok(())),
)
});
assert!(success);
assert_eq!(
deleted.lock().unwrap().as_slice(),
[PathBuf::from("/p/api/.venv")],
"exactly the project's own resolved venv path must be deleted, once"
);
assert!(
runner
.calls()
.iter()
.any(|c| c.args.iter().any(|a| a == "sync")),
"the environment must be rebuilt after being deleted; got {:?}",
runner.calls()
);
}
#[test]
fn a_recreate_whose_delete_is_refused_never_runs_uv_sync() {
let project = project("api");
let config = Config {
projects: vec![project.clone()],
settings: fake_uv_settings(),
..Config::default()
};
let store = bombadil_core::secrets::InMemorySecretStore::new();
let runner = FakeCommandRunner::new();
version_probe(&runner);
runner.expect(
"uv",
&["sync"],
bombadil_core::process::CommandOutcome {
status: Some(0),
stdout: String::new(),
stderr: String::new(),
},
);
let deleted = std::sync::Mutex::new(Vec::new());
let (success, lines) = collect_lines(|on_line| {
run_recreate_venv(
&project,
&config,
&store,
&runner,
on_line,
recording_delete(
&deleted,
Err("/p/api/.venv holds no pyvenv.cfg".to_string()),
),
)
});
assert!(!success);
assert!(
runner.calls().is_empty(),
"a refused delete must stop the recreate dead; got {:?}",
runner.calls()
);
assert!(
lines.iter().any(|l| l.contains("pyvenv.cfg")),
"core's own refusal text must reach the user; got {lines:?}"
);
}
#[test]
fn a_recreate_whose_rebuild_fails_says_the_environment_is_gone() {
let project = project("api");
let config = Config {
projects: vec![project.clone()],
settings: fake_uv_settings(),
..Config::default()
};
let store = bombadil_core::secrets::InMemorySecretStore::new();
let runner = FakeCommandRunner::new();
version_probe(&runner);
runner.expect(
"uv",
&["sync"],
bombadil_core::process::CommandOutcome {
status: Some(1),
stdout: String::new(),
stderr: "error: no solution found".into(),
},
);
let deleted = std::sync::Mutex::new(Vec::new());
let (success, lines) = collect_lines(|on_line| {
run_recreate_venv(
&project,
&config,
&store,
&runner,
on_line,
recording_delete(&deleted, Ok(())),
)
});
assert!(!success);
let joined = lines.join("\n");
assert!(
joined.contains("no solution found"),
"uv's own reason must survive; got {joined}"
);
let expected = venv_path::resolve(
&project,
project
.active_environment()
.expect("this project has an environment"),
&config.settings,
)
.expect("this project's venv path must resolve")
.display()
.to_string();
assert!(
joined.contains(&expected) && joined.contains("deleted"),
"the user must be told the environment was deleted and not rebuilt; \
expected {expected} to appear in {joined}"
);
}
#[test]
fn a_recreate_confirmed_while_a_sync_is_in_flight_dispatches_nothing() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let fake = Arc::new(FakeCommandRunner::new());
let _ = update(&mut app, Message::SyncRequested(0), fake.clone());
let _ = update(&mut app, Message::RecreateVenvRequested(0), fake.clone());
let task = update(&mut app, Message::RecreateVenvConfirmed, fake.clone());
assert_eq!(
task.units(),
0,
"a recreate must not start while a sync is already running"
);
assert!(fake.calls().is_empty(), "got {:?}", fake.calls());
}
#[test]
fn requesting_a_recreate_for_an_out_of_range_project_asks_nothing() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let task = update(
&mut app,
Message::RecreateVenvRequested(99),
unused_runner(),
);
assert_eq!(task.units(), 0);
assert!(app.confirm_recreate.is_none());
}
#[test]
fn opening_a_folder_never_calls_the_runner_synchronously() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let fake = Arc::new(FakeCommandRunner::new());
let _ = update(&mut app, Message::ContextMenuOpened(0), fake.clone());
let task = update(&mut app, Message::OpenFolderRequested(0), fake.clone());
assert!(
fake.calls().is_empty(),
"runner.spawn must not be called synchronously inside update; got {:?}",
fake.calls()
);
assert!(
task.units() > 0,
"opening a folder must dispatch real background work, not Task::none()"
);
assert!(
app.context_menu.is_none(),
"the menu should close once an action is taken from it"
);
}
#[test]
fn opening_a_folder_shows_the_project_directory_not_the_venv() {
let runner = FakeCommandRunner::new();
runner.expect(
"xdg-open",
&[],
bombadil_core::process::CommandOutcome {
status: Some(0),
stdout: String::new(),
stderr: String::new(),
},
);
let result = run_open_folder(Os::Linux, &project("api"), &runner);
assert!(result.is_ok(), "got {result:?}");
let calls = runner.calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].args, vec!["/p/api"]);
assert_eq!(
runner.blocking_calls(),
0,
"the file manager must be spawned, not waited on"
);
}
#[test]
fn opening_a_folder_uses_each_platforms_own_file_manager() {
for (os, program) in [
(Os::Linux, "xdg-open"),
(Os::MacOs, "open"),
(Os::Windows, "explorer"),
] {
let runner = FakeCommandRunner::new();
runner.expect(
program,
&[],
bombadil_core::process::CommandOutcome {
status: Some(0),
stdout: String::new(),
stderr: String::new(),
},
);
run_open_folder(os, &project("api"), &runner)
.unwrap_or_else(|e| panic!("{os:?} must open with {program}: {e}"));
assert_eq!(
runner.calls()[0].program.to_string_lossy(),
program,
"{os:?}"
);
}
}
#[test]
fn a_project_whose_manifest_has_no_directory_reports_rather_than_opening_nothing() {
let mut project = project("api");
project.pyproject_path = PathBuf::from("pyproject.toml");
let runner = FakeCommandRunner::new();
let err = run_open_folder(Os::Linux, &project, &runner)
.expect_err("there is no directory to open");
assert!(
runner.calls().is_empty(),
"nothing may be spawned; got {:?}",
runner.calls()
);
assert!(err.contains("pyproject.toml"), "got {err}");
}
#[test]
fn setting_a_global_pin_writes_it_through_and_persists() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let persist = Arc::new(FakeConfigPersist::default());
app.persist = persist.clone();
let _ = update(
&mut app,
Message::PreferencesSectionOpened(preferences::Section::default()),
unused_runner(),
);
let _ = update(
&mut app,
Message::PreferencesPythonPinChanged(preferences::Scope::Global, "3.12".into()),
unused_runner(),
);
assert_eq!(
app.config.settings.python,
PythonPin::Version("3.12".into())
);
assert!(block_on(persist_config(
app.config.clone(),
persist.clone()
)));
assert_eq!(
persist.saved()[0].settings.python,
PythonPin::Version("3.12".into())
);
}
#[test]
fn an_emptied_pin_box_unpins_rather_than_pinning_to_nothing() {
let mut app = App::new(Config::default());
let _ = update(
&mut app,
Message::PreferencesSectionOpened(preferences::Section::default()),
unused_runner(),
);
let _ = update(
&mut app,
Message::PreferencesPythonPinChanged(preferences::Scope::Global, "3.12".into()),
unused_runner(),
);
let _ = update(
&mut app,
Message::PreferencesPythonPinChanged(preferences::Scope::Global, " ".into()),
unused_runner(),
);
assert_eq!(app.config.settings.python, PythonPin::Unpinned);
}
#[test]
fn a_project_pin_overrides_the_global_one_without_touching_it() {
let mut app = App::new(Config {
projects: vec![project("api")],
settings: Settings {
python: PythonPin::Version("3.12".into()),
..Settings::default()
},
..Config::default()
});
let id = app.config.projects[0].id;
let _ = update(
&mut app,
Message::PreferencesSectionOpened(preferences::Section::default()),
unused_runner(),
);
let _ = update(
&mut app,
Message::PreferencesPythonPinChanged(preferences::Scope::Project(id), "3.11".into()),
unused_runner(),
);
assert_eq!(
app.config.projects[0]
.active_environment()
.map(|e| e.python.clone()),
Some(PythonPin::Version("3.11".into()))
);
assert_eq!(
app.config.settings.python,
PythonPin::Version("3.12".into()),
"a project override must not write into the global value"
);
}
#[test]
fn an_edit_in_a_scope_whose_project_is_gone_writes_nothing() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let gone = uuid::Uuid::from_u128(99);
let _ = update(
&mut app,
Message::PreferencesSectionOpened(preferences::Section::default()),
unused_runner(),
);
let _ = update(
&mut app,
Message::PreferencesPythonPinChanged(preferences::Scope::Project(gone), "3.11".into()),
unused_runner(),
);
assert_eq!(
app.config.projects[0]
.active_environment()
.map(|e| e.python.clone()),
Some(PythonPin::Unpinned)
);
assert_eq!(app.config.settings.python, PythonPin::Unpinned);
}
#[test]
fn a_project_venv_location_writes_through_and_reprobes() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let id = app.config.projects[0].id;
let from = app.config.projects[0].active.clone();
let task = update(
&mut app,
Message::EnvironmentLocationSelected(
id,
from,
VenvLocation::Custom {
path: std::path::PathBuf::from("/envs/api"),
},
),
unused_runner(),
);
let moved = VenvLocation::Custom {
path: std::path::PathBuf::from("/envs/api"),
};
assert_eq!(
app.config.projects[0].environments[0].location, moved,
"the environment itself must move"
);
assert_eq!(
app.config.projects[0].active, moved,
"and `active` with it, or it names a location that is gone"
);
assert!(
task.units() > 0,
"the change must dispatch a persist and a re-probe"
);
}
#[test]
fn a_cancelled_venv_folder_pick_leaves_the_location_alone() {
let mut app = App::new(Config {
projects: vec![Project {
environments: vec![bombadil_core::model::Environment {
location: VenvLocation::Alongside,
python: PythonPin::Unpinned,
}],
active: VenvLocation::Alongside,
..project("api")
}],
..Config::default()
});
let id = app.config.projects[0].id;
let task = update(
&mut app,
Message::EnvironmentFolderPicked(id, VenvLocation::Alongside, None),
unused_runner(),
);
assert_eq!(app.config.projects[0].active, VenvLocation::Alongside);
assert_eq!(task.units(), 0, "cancelling must change nothing");
}
#[test]
fn a_venv_edit_for_a_project_that_is_gone_writes_nothing() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let before = app.config.projects[0].environments[0].location.clone();
let task = update(
&mut app,
Message::EnvironmentLocationSelected(
uuid::Uuid::from_u128(99),
VenvLocation::Alongside,
VenvLocation::Custom {
path: std::path::PathBuf::from("/elsewhere"),
},
),
unused_runner(),
);
assert_eq!(app.config.projects[0].environments[0].location, before);
assert_eq!(task.units(), 0);
}
#[test]
fn a_project_uv_override_leaves_the_global_choice_alone() {
let mut app = App::new(Config {
projects: vec![project("api")],
settings: Settings {
uv_source: UvSource::Bundled,
..Settings::default()
},
..Config::default()
});
let id = app.config.projects[0].id;
let _ = update(
&mut app,
Message::PreferencesSectionOpened(preferences::Section::default()),
unused_runner(),
);
let _ = update(
&mut app,
Message::PreferencesUvSourceOverridden(
preferences::Scope::Project(id),
UvSource::FromPath,
),
unused_runner(),
);
assert_eq!(
app.config.projects[0].uv_source,
Override::Set(UvSource::FromPath)
);
assert_eq!(app.config.settings.uv_source, UvSource::Bundled);
}
#[test]
fn a_project_terminal_override_is_per_os_and_leaves_the_others_alone() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let id = app.config.projects[0].id;
let other = match Os::host() {
Os::Linux => Os::Windows,
_ => Os::Linux,
};
let _ = update(
&mut app,
Message::PreferencesSectionOpened(preferences::Section::default()),
unused_runner(),
);
let _ = update(
&mut app,
Message::PreferencesTerminalOverridden(
preferences::Scope::Project(id),
TerminalChoice::Detected(TerminalKind::Xterm),
),
unused_runner(),
);
let terminals = &app.config.projects[0].terminals;
assert_eq!(
terminals.get(&Os::host()),
Some(&TerminalChoice::Detected(TerminalKind::Xterm))
);
assert_eq!(
terminals.get(&other),
None,
"an override must speak only for the OS it was set on"
);
}
#[test]
fn a_pin_set_here_reaches_the_confirmation_when_sync_would_delete() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let id = app.config.projects[0].id;
set_probed_version(&mut app, 0, "3.13.14");
let _ = update(
&mut app,
Message::PreferencesSectionOpened(preferences::Section::default()),
unused_runner(),
);
let _ = update(
&mut app,
Message::PreferencesPythonPinChanged(preferences::Scope::Project(id), "3.12".into()),
unused_runner(),
);
let task = update(&mut app, Message::SyncRequested(0), unused_runner());
assert_eq!(task.units(), 0, "nothing may run before the user answers");
let confirm = app
.confirm_pin_change
.as_ref()
.expect("a pin set in Preferences must reach the same guard");
assert_eq!(confirm.from, "3.13.14");
assert_eq!(confirm.to, "3.12");
}
#[test]
fn preferences_opens_on_global_and_closes_the_menu() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let _ = update(
&mut app,
Message::MenuToggled(shell::TopMenu::Help),
unused_runner(),
);
let _ = update(
&mut app,
Message::PreferencesSectionOpened(preferences::Section::default()),
unused_runner(),
);
let state = app.preferences.expect("Preferences must open");
assert_eq!(state.scope, preferences::Scope::Global);
assert!(
app.open_menu.is_none(),
"an open Help menu must close behind it -- two layers claiming the \
pointer at once is how a press both dismisses and activates"
);
}
#[test]
fn switching_scope_keeps_the_section_you_were_reading() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let id = app.config.projects[0].id;
let _ = update(
&mut app,
Message::PreferencesSectionOpened(preferences::Section::default()),
unused_runner(),
);
let _ = update(
&mut app,
Message::PreferencesSectionSelected(preferences::Section::Python),
unused_runner(),
);
let _ = update(
&mut app,
Message::PreferencesScopeSelected(preferences::Scope::Project(id)),
unused_runner(),
);
let state = app.preferences.expect("still open");
assert_eq!(state.scope, preferences::Scope::Project(id));
assert_eq!(state.section, preferences::Section::Python);
}
#[test]
fn closing_preferences_keeps_every_edit_made_in_it() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let _ = update(
&mut app,
Message::PreferencesSectionOpened(preferences::Section::default()),
unused_runner(),
);
let _ = update(
&mut app,
Message::SettingsUvSourceChoiceChanged(settings_editor::UvSourceChoice::Bundled),
unused_runner(),
);
let _ = update(&mut app, Message::PreferencesClosed, unused_runner());
assert!(app.preferences.is_none());
assert_eq!(app.config.settings.uv_source, UvSource::Bundled);
}
#[test]
fn the_bundled_licence_is_reachable_through_the_help_menu() {
let mut app = App::new(Config::default());
assert_eq!(app.modal, None, "no modal is open on start");
let _ = update(
&mut app,
Message::MenuToggled(shell::TopMenu::Help),
unused_runner(),
);
assert_eq!(
app.open_menu,
Some(shell::TopMenu::Help),
"the Help menu must open"
);
let _ = update(
&mut app,
Message::ModalOpened(shell::Modal::Licences),
unused_runner(),
);
assert_eq!(app.modal, Some(shell::Modal::Licences));
assert!(
app.open_menu.is_none(),
"the menu must close behind the modal it opened; two layers \
claiming the pointer at once is how a press both dismisses and \
activates"
);
let _ = update(&mut app, Message::ModalClosed, unused_runner());
assert_eq!(app.modal, None);
}
#[test]
fn the_help_menu_closes_without_choosing_anything() {
let mut app = App::new(Config::default());
let _ = update(
&mut app,
Message::MenuToggled(shell::TopMenu::Help),
unused_runner(),
);
let _ = update(
&mut app,
Message::MenuToggled(shell::TopMenu::Help),
unused_runner(),
);
assert!(
app.open_menu.is_none(),
"the Help button must toggle, not only open"
);
assert_eq!(app.modal, None, "dismissing must not open anything");
let _ = update(
&mut app,
Message::MenuToggled(shell::TopMenu::Help),
unused_runner(),
);
let _ = update(&mut app, Message::MenuDismissed, unused_runner());
assert!(app.open_menu.is_none());
assert_eq!(app.modal, None);
}
#[test]
fn marking_a_variable_secret_writes_the_store_and_leaves_config_empty() {
let key = SecretKey::GlobalEnv {
key: "TOKEN".into(),
};
let mut config = Config {
global_env: vec![bombadil_core::model::EnvVar {
key: "TOKEN".into(),
value: "hunter2".into(),
secret: false,
}],
..Config::default()
};
let plain_value = env_editor::mark_secret(&mut config.global_env, 0)
.expect("a non-secret row must yield its plaintext");
assert_eq!(
config.global_env[0].value, "",
"got {:?}",
config.global_env[0].value
);
assert!(config.global_env[0].secret);
let store = bombadil_core::secrets::InMemorySecretStore::new();
let persist = FakeConfigPersist::default();
let success = run_secret_write(&key, &plain_value, &config, &store, &persist);
assert!(success);
assert_eq!(
store.get(&key).unwrap().as_deref(),
Some("hunter2"),
"the store must hold the value the user typed"
);
let saved = persist.saved();
assert_eq!(
saved.len(),
1,
"the config must be persisted alongside the write"
);
assert_eq!(
saved[0].global_env[0].value, "",
"the persisted config must never carry the secret's value"
);
}
#[test]
fn unmarking_a_variable_secret_deletes_the_keychain_entry() {
let project_id = uuid::Uuid::from_u128(9);
let key = SecretKey::ProjectEnv {
project_id,
key: "TOKEN".into(),
};
let store = bombadil_core::secrets::InMemorySecretStore::new();
store.set(&key, "hunter2").unwrap();
let mut proj = project("api");
proj.id = project_id;
proj.env = vec![bombadil_core::model::EnvVar {
key: "TOKEN".into(),
value: String::new(),
secret: true,
}];
let mut config = Config {
projects: vec![proj],
..Config::default()
};
let owed = env_editor::unmark_secret(&mut config.projects[0].env, 0);
assert!(owed, "a secret row must report a delete as owed");
assert!(!config.projects[0].env[0].secret);
let persist = FakeConfigPersist::default();
let success = run_secret_delete(&key, &config, &store, &persist);
assert!(success);
assert_eq!(
store.get(&key).unwrap(),
None,
"the keychain entry must be gone, not orphaned"
);
assert!(
!persist.saved()[0].projects[0].env[0].secret,
"the persisted config must record the row as no longer secret"
);
}
#[test]
fn the_message_that_triggers_marking_secret_never_carries_the_value() {
let mut app = App::new(Config {
global_env: vec![bombadil_core::model::EnvVar {
key: "TOKEN".into(),
value: "hunter2".into(),
secret: false,
}],
..Config::default()
});
let toggle = Message::EnvVarSecretToggled(env_editor::EnvScope::Global, 0);
assert!(
!format!("{toggle:?}").contains("hunter2"),
"the message that triggers the write must not carry the value"
);
let _ = update(&mut app, toggle, unused_runner());
let written = Message::EnvVarSecretWritten {
scope: env_editor::EnvScope::Global,
index: 0,
success: true,
};
assert!(!format!("{written:?}").contains("hunter2"));
let _ = update(&mut app, written, unused_runner());
assert_eq!(app.config.global_env[0].value, "");
assert!(app.config.global_env[0].secret);
}
#[test]
fn adding_a_global_env_var_persists_through_config_persist() {
let mut app = App::new(Config::default());
let persist = Arc::new(FakeConfigPersist::default());
app.persist = persist.clone();
let task = update(
&mut app,
Message::EnvVarAdded(env_editor::EnvScope::Global),
unused_runner(),
);
assert_eq!(
app.config.global_env.len(),
1,
"in-memory must reflect the add the instant update returns"
);
assert!(
task.units() > 0,
"the add must dispatch a background persist"
);
assert!(
persist.saved().is_empty(),
"persist.save must not be called synchronously inside update"
);
let success = block_on(persist_config(app.config.clone(), persist.clone()));
assert!(success);
assert_eq!(
persist.saved().last().unwrap().global_env.len(),
1,
"the persisted config must carry the edit, or it vanishes on restart"
);
}
fn plain_var(key: &str, value: &str) -> bombadil_core::model::EnvVar {
bombadil_core::model::EnvVar {
key: key.into(),
value: value.into(),
secret: false,
}
}
#[test]
fn typing_a_value_reaches_neither_config_nor_config_persist() {
let mut app = App::new(Config {
global_env: vec![plain_var("TOKEN", "")],
..Config::default()
});
let persist = Arc::new(FakeConfigPersist::default());
app.persist = persist.clone();
let mut task_units = 0;
for prefix in ["h", "hu", "hun", "hunt", "hunte", "hunter", "hunter2"] {
let task = update(
&mut app,
Message::EnvVarValueChanged(env_editor::EnvScope::Global, 0, prefix.into()),
unused_runner(),
);
task_units += task.units();
}
assert_eq!(
task_units, 0,
"typing must dispatch no work at all -- a dispatched persist is a write to config.toml"
);
assert_eq!(
app.config.global_env[0].value, "",
"the typed text must not be in Config, which is what any persist would write out"
);
assert!(
!format!("{:?}", app.config).contains("hunter2"),
"nothing a Debug render of Config could print may carry the typed text"
);
assert!(
persist.saved().is_empty(),
"no save may have happened, synchronously or otherwise"
);
}
#[test]
fn committing_a_typed_value_puts_it_in_config_and_persists_it() {
let mut app = App::new(Config {
global_env: vec![plain_var("HTTP_PROXY", "http://old")],
..Config::default()
});
let persist = Arc::new(FakeConfigPersist::default());
app.persist = persist.clone();
let _ = update(
&mut app,
Message::EnvVarValueChanged(env_editor::EnvScope::Global, 0, "http://new".into()),
unused_runner(),
);
let task = update(
&mut app,
Message::EnvVarValueCommitted(env_editor::EnvScope::Global, 0),
unused_runner(),
);
assert!(task.units() > 0, "the commit must dispatch a persist");
assert_eq!(app.config.global_env[0].value, "http://new");
assert!(
persist.saved().is_empty(),
"persist.save must not be called synchronously inside update"
);
let success = block_on(persist_config(app.config.clone(), persist.clone()));
assert!(success);
assert_eq!(
persist.saved().last().unwrap().global_env[0].value,
"http://new",
"the persisted config must carry the committed value"
);
}
#[test]
fn a_typed_value_marked_secret_reaches_the_keychain_having_never_been_in_config() {
let mut app = App::new(Config {
global_env: vec![plain_var("TOKEN", "")],
..Config::default()
});
let persist = FakeConfigPersist::default();
let _ = update(
&mut app,
Message::EnvVarValueChanged(env_editor::EnvScope::Global, 0, "hunter2".into()),
unused_runner(),
);
let task = update(
&mut app,
Message::EnvVarSecretToggled(env_editor::EnvScope::Global, 0),
unused_runner(),
);
assert!(task.units() > 0, "marking secret must dispatch its own job");
let Some(SecretToggle::Mark {
candidate,
plain_value,
}) = plan_secret_toggle(
&app.config,
&app.env_value_drafts,
env_editor::EnvScope::Global,
0,
)
else {
panic!("ticking secret on a typed row must plan a keychain write");
};
assert_eq!(
plain_value, "hunter2",
"the value handed to the keychain must be what the user typed"
);
assert_eq!(
candidate.global_env[0].value, "",
"and must already be out of the config that goes to disk with it"
);
assert!(candidate.global_env[0].secret);
let key = SecretKey::GlobalEnv {
key: "TOKEN".to_string(),
};
let store = bombadil_core::secrets::InMemorySecretStore::new();
assert!(run_secret_write(
&key,
&plain_value,
&candidate,
&store,
&persist
));
assert_eq!(
store.get(&key).unwrap().as_deref(),
Some("hunter2"),
"the value the user typed must be what the keychain ends up holding"
);
assert!(
persist
.saved()
.iter()
.all(|c| !format!("{c:?}").contains("hunter2")),
"no config that was ever handed to a persister may contain the plaintext"
);
let _ = update(
&mut app,
Message::EnvVarSecretWritten {
scope: env_editor::EnvScope::Global,
index: 0,
success: true,
},
unused_runner(),
);
assert!(app.config.global_env[0].secret);
assert_eq!(app.config.global_env[0].value, "");
assert!(
app.env_value_drafts.is_empty(),
"the draft is redundant once the keychain holds the value"
);
}
#[test]
fn a_secret_write_the_keychain_refused_destroys_nothing() {
let mut app = App::new(Config {
global_env: vec![plain_var("TOKEN", "hunter2")],
..Config::default()
});
let _ = update(
&mut app,
Message::EnvVarSecretToggled(env_editor::EnvScope::Global, 0),
unused_runner(),
);
assert_eq!(
app.config.global_env[0].value, "hunter2",
"nothing may be destroyed while the write is still in flight"
);
let _ = update(
&mut app,
Message::EnvVarSecretWritten {
scope: env_editor::EnvScope::Global,
index: 0,
success: false,
},
unused_runner(),
);
assert_eq!(
app.config.global_env[0].value, "hunter2",
"a refused write must leave the value exactly where it was"
);
assert!(
!app.config.global_env[0].secret,
"and must not flag the row secret -- there is no keychain entry behind it"
);
assert!(
app.drawer.status.contains("failed"),
"the failure must still be reported; got {:?}",
app.drawer.status
);
}
#[test]
fn unmarking_a_secret_the_keychain_refused_leaves_the_row_secret() {
let mut app = App::new(Config {
global_env: vec![bombadil_core::model::EnvVar {
key: "TOKEN".into(),
value: String::new(),
secret: true,
}],
..Config::default()
});
let _ = update(
&mut app,
Message::EnvVarSecretToggled(env_editor::EnvScope::Global, 0),
unused_runner(),
);
let _ = update(
&mut app,
Message::EnvVarSecretWritten {
scope: env_editor::EnvScope::Global,
index: 0,
success: false,
},
unused_runner(),
);
assert!(
app.config.global_env[0].secret,
"a refused delete must leave the row matching what disk and the keychain still say"
);
}
#[test]
fn unmarking_a_secret_that_landed_clears_the_flag() {
let mut app = App::new(Config {
global_env: vec![bombadil_core::model::EnvVar {
key: "TOKEN".into(),
value: String::new(),
secret: true,
}],
..Config::default()
});
let _ = update(
&mut app,
Message::EnvVarSecretToggled(env_editor::EnvScope::Global, 0),
unused_runner(),
);
let _ = update(
&mut app,
Message::EnvVarSecretWritten {
scope: env_editor::EnvScope::Global,
index: 0,
success: true,
},
unused_runner(),
);
assert!(!app.config.global_env[0].secret);
}
fn script(path: &str, enabled: bool) -> bombadil_core::model::Script {
bombadil_core::model::Script {
path: std::path::PathBuf::from(path),
args: Vec::new(),
enabled,
}
}
#[test]
fn requesting_to_add_a_script_dispatches_work_without_touching_config_synchronously() {
let mut app = App::new(Config::default());
let task = update(
&mut app,
Message::ScriptAddRequested(scripts_editor::ScriptScope::Global),
unused_runner(),
);
assert!(
task.units() > 0,
"requesting to add a script must dispatch background work"
);
assert!(
app.config.global_scripts.is_empty(),
"nothing may be added to the config before the picker has answered; got {:?}",
app.config.global_scripts
);
}
#[test]
fn a_picked_script_is_appended_and_persisted() {
let mut app = App::new(Config::default());
let persist = Arc::new(FakeConfigPersist::default());
app.persist = persist.clone();
let task = update(
&mut app,
Message::ScriptFilePicked(
scripts_editor::ScriptScope::Global,
Some(std::path::PathBuf::from("/etc/bombadil/pre.sh")),
),
unused_runner(),
);
assert_eq!(app.config.global_scripts.len(), 1);
assert_eq!(
app.config.global_scripts[0].path,
std::path::PathBuf::from("/etc/bombadil/pre.sh")
);
assert!(
task.units() > 0,
"the add must dispatch a background persist"
);
assert!(
persist.saved().is_empty(),
"persist.save must not be called synchronously inside update"
);
let success = block_on(persist_config(app.config.clone(), persist.clone()));
assert!(success);
assert_eq!(persist.saved().last().unwrap().global_scripts.len(), 1);
}
#[test]
fn cancelling_the_script_picker_adds_nothing() {
let mut app = App::new(Config::default());
let _ = update(
&mut app,
Message::ScriptFilePicked(scripts_editor::ScriptScope::Global, None),
unused_runner(),
);
assert!(app.config.global_scripts.is_empty());
}
#[test]
fn disabling_a_script_through_update_leaves_it_in_the_config() {
let mut app = App::new(Config {
global_scripts: vec![script("/etc/pre.sh", true)],
..Config::default()
});
let _ = update(
&mut app,
Message::ScriptEnabledToggled(scripts_editor::ScriptScope::Global, 0),
unused_runner(),
);
assert_eq!(
app.config.global_scripts.len(),
1,
"a disabled script must still be in the config, not removed"
);
assert!(!app.config.global_scripts[0].enabled);
}
#[test]
fn removing_a_script_through_update_actually_drops_it() {
let mut app = App::new(Config {
global_scripts: vec![script("/etc/pre.sh", true)],
..Config::default()
});
let _ = update(
&mut app,
Message::ScriptRemoved(scripts_editor::ScriptScope::Global, 0),
unused_runner(),
);
assert!(app.config.global_scripts.is_empty());
}
#[test]
fn moving_a_script_down_through_update_persists_the_new_order() {
let mut app = App::new(Config {
global_scripts: vec![script("/a.sh", true), script("/b.sh", true)],
..Config::default()
});
let persist = Arc::new(FakeConfigPersist::default());
app.persist = persist.clone();
let task = update(
&mut app,
Message::ScriptMovedDown(scripts_editor::ScriptScope::Global, 0),
unused_runner(),
);
assert_eq!(
app.config.global_scripts[0].path,
std::path::PathBuf::from("/b.sh"),
"the order must change the instant update returns; got {:?}",
app.config.global_scripts
);
assert!(task.units() > 0);
let success = block_on(persist_config(app.config.clone(), persist.clone()));
assert!(success);
assert_eq!(
persist.saved().last().unwrap().global_scripts[0].path,
std::path::PathBuf::from("/b.sh"),
"the persisted config must carry the new order, or it reverts on restart"
);
}
#[test]
fn moving_a_project_scripts_row_never_touches_global_scripts() {
let mut proj = project("api");
proj.scripts = vec![script("/p-a.sh", true), script("/p-b.sh", true)];
let mut app = App::new(Config {
global_scripts: vec![script("/g.sh", true)],
projects: vec![proj],
..Config::default()
});
let _ = update(
&mut app,
Message::ScriptMovedDown(scripts_editor::ScriptScope::Project(0), 0),
unused_runner(),
);
assert_eq!(
app.config.global_scripts,
vec![script("/g.sh", true)],
"a project-scope reorder must never touch global_scripts"
);
assert_eq!(
app.config.projects[0].scripts[0].path,
PathBuf::from("/p-b.sh")
);
}
fn index_with_auth(
name: &str,
auth: bombadil_core::model::IndexAuth,
) -> bombadil_core::model::Index {
bombadil_core::model::Index {
name: name.into(),
url: format!("https://{name}.example/simple"),
kind: bombadil_core::model::IndexKind::Extra,
default_for_new_projects: false,
auth,
}
}
#[test]
fn renaming_an_index_moves_its_credential_instead_of_orphaning_it() {
let config = Config {
indexes: vec![index_with_auth(
"corp",
bombadil_core::model::IndexAuth::Token,
)],
..Config::default()
};
let from = SecretKey::Index {
name: "internal".into(),
};
let to = SecretKey::Index {
name: "corp".into(),
};
let store = bombadil_core::secrets::InMemorySecretStore::new();
store.set(&from, "hunter2").unwrap();
let persist = FakeConfigPersist::default();
let success = run_index_credential_rename(&from, &to, &config, &store, &persist);
assert!(success);
assert_eq!(
store.get(&to).unwrap().as_deref(),
Some("hunter2"),
"the credential must follow the rename, or the next sync is a 401"
);
assert_eq!(
store.get(&from).unwrap(),
None,
"the old account must not stay in the user's keychain forever"
);
assert_eq!(
persist.saved().len(),
1,
"the renamed config must be persisted alongside the move"
);
}
#[test]
fn renaming_an_index_that_never_had_a_credential_still_persists() {
let config = Config::default();
let store = bombadil_core::secrets::InMemorySecretStore::new();
let persist = FakeConfigPersist::default();
let success = run_index_credential_rename(
&SecretKey::Index { name: "c".into() },
&SecretKey::Index { name: "co".into() },
&config,
&store,
&persist,
);
assert!(success);
assert_eq!(persist.saved().len(), 1);
}
#[test]
fn typing_an_index_name_neither_saves_the_config_nor_touches_the_keychain() {
let mut app = App::new(Config {
indexes: vec![index_with_auth(
"corp",
bombadil_core::model::IndexAuth::Token,
)],
..Config::default()
});
let persist = Arc::new(FakeConfigPersist::default());
app.persist = persist.clone();
let mut task_units = 0;
for prefix in ["c", "co", "cor", "corp", "corp-", "corp-e", "corp-eu"] {
let task = update(
&mut app,
Message::IndexNameChanged(0, prefix.to_string()),
unused_runner(),
);
task_units += task.units();
}
assert_eq!(
task_units, 0,
"typing a name must dispatch neither a persist nor a keychain job"
);
assert!(persist.saved().is_empty());
assert_eq!(
app.config.indexes[0].name, "corp-eu",
"the box must still be responsive -- the edit is in memory"
);
assert_eq!(
index_credential_key(&app, 0),
Some(SecretKey::Index {
name: "corp".into()
}),
"the credential has not moved yet, so the account it lives under must not have either"
);
}
#[test]
fn committing_a_renamed_index_moves_its_credential_once() {
let mut app = App::new(Config {
indexes: vec![index_with_auth(
"internal",
bombadil_core::model::IndexAuth::Token,
)],
..Config::default()
});
for prefix in ["c", "co", "cor", "corp"] {
let _ = update(
&mut app,
Message::IndexNameChanged(0, prefix.to_string()),
unused_runner(),
);
}
let (from, to) = index_rename_on_commit(&app, 0).expect("a renamed row owes a move");
assert_eq!(
from,
SecretKey::Index {
name: "internal".into()
}
);
assert_eq!(
to,
SecretKey::Index {
name: "corp".into()
}
);
let task = update(&mut app, Message::IndexEditCommitted(0), unused_runner());
assert!(task.units() > 0, "the commit must dispatch the rename job");
assert_eq!(
index_rename_on_commit(&app, 0),
None,
"the commit must record where the credential now lives, or the next one moves it again"
);
let store = bombadil_core::secrets::InMemorySecretStore::new();
store.set(&from, "hunter2").unwrap();
let persist = FakeConfigPersist::default();
assert!(run_index_credential_rename(
&from,
&to,
&app.config,
&store,
&persist
));
assert_eq!(store.get(&to).unwrap().as_deref(), Some("hunter2"));
assert_eq!(store.get(&from).unwrap(), None);
}
#[test]
fn typing_an_index_url_does_not_save_but_committing_it_does() {
let mut app = App::new(Config {
indexes: vec![index_with_auth(
"corp",
bombadil_core::model::IndexAuth::None,
)],
..Config::default()
});
let persist = Arc::new(FakeConfigPersist::default());
app.persist = persist.clone();
let typing = update(
&mut app,
Message::IndexUrlChanged(0, "https://corp.example/simple".to_string()),
unused_runner(),
);
assert_eq!(typing.units(), 0, "typing a URL must dispatch no save");
let task = update(&mut app, Message::IndexEditCommitted(0), unused_runner());
assert!(task.units() > 0, "the commit must dispatch one");
assert!(block_on(persist_config(
app.config.clone(),
persist.clone()
)));
assert_eq!(
persist.saved().last().unwrap().indexes[0].url,
"https://corp.example/simple",
"the committed URL must reach the persisted config"
);
}
#[test]
fn removing_an_index_deletes_the_credential_it_leaves_behind() {
let mut app = App::new(Config {
indexes: vec![index_with_auth(
"corp",
bombadil_core::model::IndexAuth::Token,
)],
..Config::default()
});
let key = index_credential_key(&app, 0)
.expect("a removed index must name the credential it owes a delete of");
assert_eq!(
key,
SecretKey::Index {
name: "corp".into()
}
);
let store = bombadil_core::secrets::InMemorySecretStore::new();
store.set(&key, "hunter2").unwrap();
let persist = FakeConfigPersist::default();
let task = update(&mut app, Message::IndexRemoved(0), unused_runner());
assert!(task.units() > 0, "the removal must dispatch its own job");
assert!(app.config.indexes.is_empty());
assert!(run_secret_delete(&key, &app.config, &store, &persist));
assert_eq!(
store.get(&key).unwrap(),
None,
"the credential must be gone, not orphaned"
);
}
#[test]
fn removing_a_secret_env_var_row_deletes_its_keychain_entry() {
let project_id = uuid::Uuid::from_u128(9);
let mut proj = project("api");
proj.id = project_id;
proj.env = vec![bombadil_core::model::EnvVar {
key: "TOKEN".into(),
value: String::new(),
secret: true,
}];
let mut app = App::new(Config {
projects: vec![proj],
..Config::default()
});
let key = env_removal_secret_key(&app, env_editor::EnvScope::Project(0), 0)
.expect("removing a secret row must name the entry it owes a delete of");
assert_eq!(
key,
SecretKey::ProjectEnv {
project_id,
key: "TOKEN".into(),
}
);
let store = bombadil_core::secrets::InMemorySecretStore::new();
store.set(&key, "hunter2").unwrap();
let persist = FakeConfigPersist::default();
let task = update(
&mut app,
Message::EnvVarRemoved(env_editor::EnvScope::Project(0), 0),
unused_runner(),
);
assert!(task.units() > 0, "the removal must dispatch its own job");
assert!(app.config.projects[0].env.is_empty());
assert!(run_secret_delete(&key, &app.config, &store, &persist));
assert_eq!(store.get(&key).unwrap(), None);
}
#[test]
fn removing_a_plain_env_var_row_owes_no_keychain_delete() {
let mut proj = project("api");
proj.env = vec![bombadil_core::model::EnvVar {
key: "HTTP_PROXY".into(),
value: "http://proxy:8080".into(),
secret: false,
}];
let app = App::new(Config {
projects: vec![proj],
..Config::default()
});
assert!(env_removal_secret_key(&app, env_editor::EnvScope::Project(0), 0).is_none());
}
#[test]
fn saving_an_index_credential_writes_the_store_and_leaves_config_untouched() {
let idx = index_with_auth(
"priv",
bombadil_core::model::IndexAuth::Basic {
username: "alice".into(),
},
);
let mut app = App::new(Config {
indexes: vec![idx.clone()],
..Config::default()
});
let persist = Arc::new(FakeConfigPersist::default());
app.persist = persist.clone();
app.index_credential_drafts = vec!["hunter2".to_string()];
let (key, value) =
index_editor::credential_save(&app.config, &app.index_credential_drafts, 0)
.expect("nothing here should block the save");
assert_eq!(
key,
SecretKey::Index {
name: "priv".into()
},
"a credential must be addressed by SecretKey::Index, not any other shape"
);
let task = update(
&mut app,
Message::IndexCredentialSaveRequested(0),
unused_runner(),
);
assert!(
task.units() > 0,
"the keychain write must be dispatched as background work, not run inline"
);
assert_eq!(
app.index_credential_drafts[0], "hunter2",
"the draft must survive until the write lands -- it is the only copy"
);
assert!(
persist.saved().is_empty(),
"persist.save must not be called synchronously inside update"
);
let store = bombadil_core::secrets::InMemorySecretStore::new();
let success = run_secret_write(&key, &value, &app.config, &store, persist.as_ref());
assert!(success);
assert_eq!(
store.get(&key).unwrap().as_deref(),
Some("hunter2"),
"the store must hold the value the user typed"
);
let saved = persist.saved();
assert_eq!(
saved.len(),
1,
"the config must be persisted alongside the write"
);
assert_eq!(
saved[0].indexes[0], idx,
"the persisted index must be unchanged -- no field gained the credential"
);
let rendered = format!("{:?}", saved[0]);
assert!(
!rendered.contains("hunter2"),
"the persisted config must never carry the credential, even in Debug; got:\n{rendered}"
);
let _ = update(
&mut app,
Message::IndexCredentialWritten {
index: 0,
success: true,
},
unused_runner(),
);
assert_eq!(
app.index_credential_drafts[0], "",
"once the keychain holds it, it must not stay in App state readable by a render"
);
assert_eq!(
app.index_credential_names[0], "priv",
"and the account it now lives under must be recorded"
);
}
#[test]
fn a_credential_the_keychain_refused_is_still_in_the_box() {
let mut app = App::new(Config {
indexes: vec![index_with_auth(
"renamed",
bombadil_core::model::IndexAuth::Basic {
username: "alice".into(),
},
)],
..Config::default()
});
app.index_credential_drafts = vec!["hunter2".to_string()];
app.index_credential_names = vec!["original".to_string()];
let _ = update(
&mut app,
Message::IndexCredentialWritten {
index: 0,
success: false,
},
unused_runner(),
);
assert_eq!(
app.index_credential_drafts[0], "hunter2",
"a refused write must leave the typed credential where the user can retry it"
);
assert_eq!(
app.index_credential_names[0], "original",
"and must not claim the credential moved to an account that holds nothing"
);
assert!(
app.drawer.status.contains("failed"),
"the failure must still be reported; got {:?}",
app.drawer.status
);
}
#[test]
fn a_colliding_index_name_dispatches_no_credential_save() {
let a = index_with_auth("my-index", bombadil_core::model::IndexAuth::None);
let b = index_with_auth(
"my_index",
bombadil_core::model::IndexAuth::Basic {
username: "alice".into(),
},
);
let mut app = App::new(Config {
indexes: vec![a, b],
..Config::default()
});
app.index_credential_drafts = vec![String::new(), "hunter2".to_string()];
let persist = Arc::new(FakeConfigPersist::default());
app.persist = persist.clone();
let task = update(
&mut app,
Message::IndexCredentialSaveRequested(1),
unused_runner(),
);
assert_eq!(
task.units(),
0,
"a colliding row must dispatch nothing, not attempt the write anyway"
);
assert_eq!(
app.index_credential_drafts[1], "hunter2",
"the draft must be left untouched since nothing was saved"
);
assert!(persist.saved().is_empty());
}
#[test]
fn no_index_message_ever_prints_the_credential_in_debug() {
let secret = "hunter2";
let messages: Vec<Message> = vec![
Message::IndexCredentialChanged(0, index_editor::CredentialInput(secret.to_string())),
Message::IndexCredentialSaveRequested(0),
Message::IndexPersistFinished { success: true },
Message::IndexPersistFinished { success: false },
];
for msg in &messages {
let rendered = format!("{msg:?}");
assert!(
!rendered.contains(secret),
"message must never print the credential; got {rendered:?}"
);
}
}
#[test]
fn toggling_a_project_index_updates_only_that_projects_selection() {
let idx = index_with_auth("priv", bombadil_core::model::IndexAuth::None);
let mut proj = project("api");
proj.index_names = vec!["priv".to_string()];
let mut app = App::new(Config {
indexes: vec![idx],
projects: vec![proj],
..Config::default()
});
let persist = Arc::new(FakeConfigPersist::default());
app.persist = persist.clone();
let task = update(
&mut app,
Message::ProjectIndexToggled(0, "priv".to_string()),
unused_runner(),
);
assert!(
app.config.projects[0].index_names.is_empty(),
"deselecting must remove the association"
);
assert_eq!(
app.config.indexes.len(),
1,
"deselecting must never remove the configured index itself"
);
assert_eq!(app.config.indexes[0].name, "priv");
assert!(
task.units() > 0,
"the change must dispatch a background persist"
);
assert!(persist.saved().is_empty());
let success = block_on(persist_config(app.config.clone(), persist.clone()));
assert!(success);
assert!(
persist.saved().last().unwrap().projects[0]
.index_names
.is_empty(),
"the persisted config must carry the deselection"
);
assert_eq!(
persist.saved().last().unwrap().indexes.len(),
1,
"the persisted config must still carry the configured index"
);
}
#[test]
fn adding_and_removing_an_index_keeps_the_credential_drafts_in_step() {
let mut app = App::new(Config::default());
let _ = update(&mut app, Message::IndexAdded, unused_runner());
let _ = update(&mut app, Message::IndexAdded, unused_runner());
assert_eq!(app.config.indexes.len(), 2);
assert_eq!(
app.index_credential_drafts.len(),
2,
"one draft slot per configured index"
);
let _ = update(
&mut app,
Message::IndexCredentialChanged(1, index_editor::CredentialInput("typing".into())),
unused_runner(),
);
assert_eq!(app.index_credential_drafts[1], "typing");
let _ = update(&mut app, Message::IndexRemoved(0), unused_runner());
assert_eq!(app.config.indexes.len(), 1);
assert_eq!(
app.index_credential_drafts.len(),
1,
"removing a row must remove its draft slot too"
);
}
fn workspace_kind(globs: &[&str]) -> bombadil_core::members::WorkspaceKind {
bombadil_core::members::WorkspaceKind::Workspace {
member_globs: globs.iter().map(|s| s.to_string()).collect(),
exclude_globs: Vec::new(),
}
}
fn discovered_member(
path: &str,
name: Option<&str>,
is_workspace_member: bool,
) -> bombadil_core::members::DiscoveredMember {
bombadil_core::members::DiscoveredMember {
relative_path: std::path::PathBuf::from(path),
name: name.map(String::from),
is_workspace_member,
}
}
fn installed_editable(location: &str) -> InstalledPackage {
InstalledPackage {
name: "whatever-uv-calls-it".to_string(),
version: "0.1.0".to_string(),
editable_project_location: Some(std::path::PathBuf::from(location)),
}
}
#[test]
fn selecting_a_project_fetches_its_members_through_job_run_not_inline() {
let config = Config {
projects: vec![project("api")],
..Config::default()
};
let mut app = App::new(config);
let fake = Arc::new(FakeCommandRunner::new());
let task = update(&mut app, Message::ProjectSelected(0), fake.clone());
assert!(
fake.calls().is_empty(),
"the members fetch must not call the runner synchronously inside update; got {:?}",
fake.calls()
);
assert!(
task.units() > 0,
"selecting a project must dispatch real background work, not Task::none()"
);
}
#[test]
fn a_members_fetch_result_is_stored_against_its_project_index() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let fetch = MembersFetch {
kind: bombadil_core::members::WorkspaceKind::Loose,
discovered: vec![discovered_member("packages/alpha", Some("alpha"), false)],
installed: vec![],
};
let _ = update(&mut app, Message::MembersLoaded(0, fetch), unused_runner());
let (index, stored) = app
.members
.as_ref()
.expect("a landed fetch must populate app.members");
assert_eq!(*index, 0);
assert_eq!(stored.discovered.len(), 1);
}
#[test]
fn a_stale_members_fetch_for_a_project_that_no_longer_exists_is_dropped() {
let mut app = App::new(Config::default());
let fetch = MembersFetch {
kind: bombadil_core::members::WorkspaceKind::Loose,
discovered: vec![],
installed: vec![],
};
let _ = update(&mut app, Message::MembersLoaded(0, fetch), unused_runner());
assert!(app.members.is_none());
}
#[test]
fn removing_the_displayed_project_clears_its_stale_members_state() {
let mut app = App::new(Config {
projects: vec![project("api"), project("web")],
..Config::default()
});
app.members = Some((
0,
MembersFetch {
kind: bombadil_core::members::WorkspaceKind::Loose,
discovered: vec![],
installed: vec![],
},
));
app.confirm_member_sync = Some(members_editor::ConfirmMemberSync {
project_index: 0,
label: "api".to_string(),
warning: "would remove alpha".to_string(),
});
let _ = update(
&mut app,
Message::RemoveProjectRequested(0),
unused_runner(),
);
assert!(app.members.is_none());
assert!(app.confirm_member_sync.is_none());
}
#[test]
fn toggling_a_member_updates_the_selection_in_memory_and_dispatches_a_persist() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
app.members = Some((
0,
MembersFetch {
kind: workspace_kind(&["packages/*"]),
discovered: vec![discovered_member("packages/alpha", Some("alpha"), true)],
installed: vec![],
},
));
let task = update(
&mut app,
Message::MemberToggled(0, std::path::PathBuf::from("packages/alpha")),
unused_runner(),
);
assert_eq!(
app.config.projects[0].members.entries.len(),
1,
"the deselection must land in the project's own selection, in memory, synchronously"
);
assert!(!app.config.projects[0].members.entries[0].selected);
assert!(
task.units() > 0,
"a selection change must dispatch a background persist"
);
}
#[test]
fn sync_requested_opens_a_confirmation_instead_of_syncing_when_something_would_be_removed() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
app.selected = Some(0);
app.config.projects[0].members = bombadil_core::model::MemberSelection {
all_discovered: true,
entries: vec![bombadil_core::model::MemberRef {
path: std::path::PathBuf::from("packages/alpha"),
origin: bombadil_core::model::MemberOrigin::Discovered,
selected: false,
name: None,
}],
};
app.members = Some((
0,
MembersFetch {
kind: workspace_kind(&["packages/*"]),
discovered: vec![discovered_member("packages/alpha", Some("alpha"), true)],
installed: vec![installed_editable("/p/api/packages/alpha")],
},
));
let task = update(&mut app, Message::MembersSyncRequested(0), unused_runner());
let confirm = app
.confirm_member_sync
.as_ref()
.expect("must open a confirmation before removing anything");
assert!(confirm.warning.contains("alpha"), "got {confirm:?}");
assert_eq!(
task.units(),
0,
"nothing must be synced before the user confirms"
);
assert!(app.syncing.is_none());
}
#[test]
fn the_sync_button_in_a_workspace_root_installs_its_members() {
let mut app = App::new(Config {
projects: vec![project("api")],
settings: fake_uv_settings(),
..Config::default()
});
app.members = Some((
0,
MembersFetch {
kind: workspace_kind(&["packages/*"]),
discovered: vec![discovered_member("packages/alpha", Some("alpha"), true)],
installed: vec![],
},
));
let task = update(&mut app, Message::SyncRequested(0), unused_runner());
assert!(task.units() > 0, "the Sync press must dispatch real work");
assert!(
app.drawer.status.contains("members"),
"the dispatched job must be the member-aware one -- the plain path \
names no members; got {:?}",
app.drawer.status
);
let target = sync_target(&app, 0).expect("the press must resolve a project");
let (kind, discovered) = members_context(&app, 0)
.expect("a Sync press must route through this project's member-aware plan");
let store = bombadil_core::secrets::InMemorySecretStore::new();
let runner = FakeCommandRunner::new();
version_probe(&runner);
runner.expect(
"uv",
&["sync", "--all-packages"],
bombadil_core::process::CommandOutcome {
status: Some(0),
stdout: String::new(),
stderr: String::new(),
},
);
let (success, lines) = collect_lines(|on_line| {
run_members_sync(
&target,
&app.config,
&kind,
&discovered,
&store,
&runner,
on_line,
)
});
assert!(success, "got {lines:?}");
let calls = runner.calls();
assert!(
calls
.iter()
.any(|c| c.args.iter().any(|a| a == "--all-packages")),
"a workspace root's Sync must install its members; got {:?}",
calls.iter().map(|c| c.args.clone()).collect::<Vec<_>>()
);
}
#[test]
fn an_ordinary_project_sync_is_left_exactly_as_it_was() {
let mut app = App::new(Config {
projects: vec![project("api")],
settings: fake_uv_settings(),
..Config::default()
});
app.members = Some((
0,
MembersFetch {
kind: bombadil_core::members::WorkspaceKind::Loose,
discovered: vec![],
installed: vec![],
},
));
let target = sync_target(&app, 0).expect("the press must resolve a project");
let store = bombadil_core::secrets::InMemorySecretStore::new();
let runner = FakeCommandRunner::new();
version_probe(&runner);
runner.expect(
"uv",
&["sync"],
bombadil_core::process::CommandOutcome {
status: Some(0),
stdout: String::new(),
stderr: String::new(),
},
);
assert!(
members_context(&app, 0).is_none(),
"a project whose plan is Plain must take the plain path, argv and \
status line included"
);
let (success, lines) =
collect_lines(|on_line| run_sync(&target, &app.config, &store, &runner, on_line));
assert!(success, "got {lines:?}");
let calls = runner.calls();
let sync = calls
.iter()
.find(|c| c.args.iter().any(|a| a == "sync"))
.expect("uv sync must have been run");
assert_eq!(
sync.args,
vec!["sync".to_string()],
"an ordinary project's sync must stay a bare `uv sync`"
);
}
#[test]
fn sync_requested_syncs_immediately_when_nothing_would_be_removed() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
app.selected = Some(0);
app.members = Some((
0,
MembersFetch {
kind: workspace_kind(&["packages/*"]),
discovered: vec![discovered_member("packages/alpha", Some("alpha"), true)],
installed: vec![],
},
));
let task = update(&mut app, Message::MembersSyncRequested(0), unused_runner());
assert!(
app.confirm_member_sync.is_none(),
"nothing selected is being removed, so no confirmation is needed"
);
assert!(
task.units() > 0,
"syncing must be dispatched immediately when nothing would be removed"
);
assert!(app.syncing.is_some());
}
#[test]
fn confirming_a_member_sync_dispatches_it_and_clears_the_confirmation() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
app.members = Some((
0,
MembersFetch {
kind: workspace_kind(&["packages/*"]),
discovered: vec![discovered_member("packages/alpha", Some("alpha"), true)],
installed: vec![installed_editable("/p/api/packages/alpha")],
},
));
app.confirm_member_sync = Some(members_editor::ConfirmMemberSync {
project_index: 0,
label: "api".to_string(),
warning: "would remove alpha".to_string(),
});
let task = update(&mut app, Message::MembersSyncConfirmed, unused_runner());
assert!(app.confirm_member_sync.is_none());
assert!(task.units() > 0, "confirming must dispatch the sync");
assert!(app.syncing.is_some());
}
#[test]
fn cancelling_a_member_sync_dispatches_nothing_and_clears_the_confirmation() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
app.confirm_member_sync = Some(members_editor::ConfirmMemberSync {
project_index: 0,
label: "api".to_string(),
warning: "would remove alpha".to_string(),
});
let task = update(&mut app, Message::MembersSyncCancelled, unused_runner());
assert!(app.confirm_member_sync.is_none());
assert_eq!(task.units(), 0);
assert!(app.syncing.is_none());
}
fn version_outcome(line: &str) -> bombadil_core::process::CommandOutcome {
bombadil_core::process::CommandOutcome {
status: Some(0),
stdout: line.into(),
stderr: String::new(),
}
}
#[test]
fn changing_uv_source_takes_effect_on_the_next_resolve_without_restarting() {
let mut app = App::new(Config {
settings: Settings {
uv_source: UvSource::Custom {
path: PathBuf::from("/bin/uv-old"),
},
..Settings::default()
},
..Config::default()
});
let old_runner = FakeCommandRunner::new();
old_runner.expect("uv-old", &["--version"], version_outcome("uv 0.12.1"));
let before =
resolve_uv_for(&app.config, &old_runner).expect("the configured old source resolves");
assert_eq!(before.path, PathBuf::from("/bin/uv-old"));
let _ = update(
&mut app,
Message::SettingsUvCustomValidated(Ok(uv::ResolvedUv {
path: PathBuf::from("/bin/uv-new"),
version: uv::UvVersion {
major: 0,
minor: 12,
patch: 1,
},
origin: uv::UvOrigin::Configured,
newer_than_bundled: false,
})),
unused_runner(),
);
assert_eq!(
app.config.settings.uv_source,
UvSource::Custom {
path: PathBuf::from("/bin/uv-new")
},
"the landed validation must write Settings::uv_source immediately"
);
let new_runner = FakeCommandRunner::new();
new_runner.expect("uv-new", &["--version"], version_outcome("uv 0.12.1"));
let after = resolve_uv_for(&app.config, &new_runner)
.expect("the new source must resolve on the very next call, with no restart");
assert_eq!(after.path, PathBuf::from("/bin/uv-new"));
}
#[test]
fn requesting_custom_uv_validation_never_probes_the_runner_synchronously() {
let mut app = App::new(Config::default());
app.uv_custom_path_draft = "/opt/uv".to_string();
let fake = Arc::new(FakeCommandRunner::new());
let task = update(
&mut app,
Message::SettingsUvCustomValidateRequested,
fake.clone(),
);
assert!(
fake.calls().is_empty(),
"validating a uv binary must spawn it through job::run, not inline inside update; got {:?}",
fake.calls()
);
assert!(
task.units() > 0,
"a validate request must dispatch real background work"
);
}
#[test]
fn a_custom_binary_below_the_minimum_version_is_refused_and_reported_as_such() {
let runner = FakeCommandRunner::new();
runner.expect("uv", &["--version"], version_outcome("uv 0.9.0"));
let err = validate_uv_source_blocking(PathBuf::from("/opt/uv"), Config::default(), &runner)
.expect_err("a version below MIN_UV_VERSION must be refused");
assert!(
err.contains("0.9.0"),
"the offending version must be named; got {err}"
);
assert!(
err.contains("0.12.1"),
"the minimum it fell short of must be named too; got {err}"
);
}
#[test]
fn a_refused_custom_validation_never_writes_settings_or_persists() {
let original = UvSource::Bundled;
let mut app = App::new(Config {
settings: Settings {
uv_source: original.clone(),
..Settings::default()
},
..Config::default()
});
let persist = Arc::new(FakeConfigPersist::default());
app.persist = persist.clone();
let runner = FakeCommandRunner::new();
runner.expect("uv", &["--version"], version_outcome("uv 0.9.0"));
let err =
validate_uv_source_blocking(PathBuf::from("/opt/uv"), app.config.clone(), &runner)
.unwrap_err();
let task = update(
&mut app,
Message::SettingsUvCustomValidated(Err(err)),
unused_runner(),
);
assert_eq!(
app.config.settings.uv_source, original,
"a refused path must never reach Settings"
);
assert_eq!(
task.units(),
0,
"a refusal must not dispatch a persist -- there is nothing new to save"
);
assert!(
persist.saved().is_empty(),
"a refused validation must never be persisted"
);
}
#[test]
fn an_accepted_custom_validation_writes_settings_and_persists_the_probed_path() {
let mut app = App::new(Config::default());
let persist = Arc::new(FakeConfigPersist::default());
app.persist = persist.clone();
let runner = FakeCommandRunner::new();
runner.expect("uv", &["--version"], version_outcome("uv 0.12.1"));
let resolved =
validate_uv_source_blocking(PathBuf::from("/opt/uv"), app.config.clone(), &runner)
.expect("uv 0.12.1 meets MIN_UV_VERSION");
let _ = update(
&mut app,
Message::SettingsUvCustomValidated(Ok(resolved)),
unused_runner(),
);
assert_eq!(
app.config.settings.uv_source,
UvSource::Custom {
path: PathBuf::from("/opt/uv")
}
);
let success = block_on(persist_config(app.config.clone(), persist.clone()));
assert!(success);
assert_eq!(
persist.saved().last().unwrap().settings.uv_source,
UvSource::Custom {
path: PathBuf::from("/opt/uv")
},
"the accepted, probed path must be what is persisted"
);
}
#[test]
fn changing_the_default_venv_location_changes_the_resolved_path_for_a_default_project() {
let project_at = |dir: &Path| Project {
environments: vec![bombadil_core::model::Environment {
location: bombadil_core::model::VenvLocation::Default,
python: PythonPin::Unpinned,
}],
active: bombadil_core::model::VenvLocation::Default,
pyproject_path: dir.join("pyproject.toml"),
..project("api")
};
let dir = tempfile::tempdir().unwrap();
let mut app = App::new(Config {
projects: vec![project_at(dir.path())],
settings: Settings {
default_venv_location: bombadil_core::model::DefaultVenvLocation::Alongside,
..Settings::default()
},
..Config::default()
});
let before = venv_path::resolve(
&app.config.projects[0],
app.config.projects[0]
.active_environment()
.expect("this project has an environment"),
&app.config.settings,
)
.unwrap();
assert_eq!(before, dir.path().join(".venv"));
let central = tempfile::tempdir().unwrap();
let _ = update(
&mut app,
Message::SettingsDefaultVenvLocationFolderPicked(Some(central.path().to_path_buf())),
unused_runner(),
);
assert_eq!(
app.config.settings.default_venv_location,
bombadil_core::model::DefaultVenvLocation::Central {
path: central.path().to_path_buf()
}
);
let after = venv_path::resolve(
&app.config.projects[0],
app.config.projects[0]
.active_environment()
.expect("this project has an environment"),
&app.config.settings,
)
.unwrap();
assert_ne!(
after, before,
"the resolved path for a Default project must change with the setting"
);
assert!(
after.starts_with(central.path()),
"got {after:?}, expected it under {central:?}",
central = central.path()
);
}
#[test]
fn changing_the_default_venv_location_reprobes_every_project() {
let default_project = |label: &str| Project {
environments: vec![bombadil_core::model::Environment {
location: bombadil_core::model::VenvLocation::Default,
python: PythonPin::Unpinned,
}],
active: bombadil_core::model::VenvLocation::Default,
..project(label)
};
let mut app = App::new(Config {
projects: vec![default_project("api"), default_project("web")],
..Config::default()
});
let alongside = update(
&mut app,
Message::SettingsDefaultVenvLocationAlongsideSelected,
unused_runner(),
);
assert_eq!(
alongside.units(),
3,
"the persist plus one venv probe per project; got {}",
alongside.units()
);
let picked = update(
&mut app,
Message::SettingsDefaultVenvLocationFolderPicked(Some(PathBuf::from("/central"))),
unused_runner(),
);
assert_eq!(
picked.units(),
3,
"picking a central folder must re-probe too; got {}",
picked.units()
);
}
#[test]
fn changing_the_default_venv_location_never_touches_an_existing_venv_on_disk() {
let dir = tempfile::tempdir().unwrap();
let old_venv = dir.path().join(".venv");
std::fs::create_dir_all(old_venv.join("lib")).unwrap();
std::fs::write(
old_venv.join("pyvenv.cfg"),
"home = /usr/bin\nversion_info = 3.12.1\n",
)
.unwrap();
std::fs::write(old_venv.join("lib").join("marker"), "still here").unwrap();
let mut app = App::new(Config {
projects: vec![Project {
environments: vec![bombadil_core::model::Environment {
location: bombadil_core::model::VenvLocation::Default,
python: PythonPin::Unpinned,
}],
active: bombadil_core::model::VenvLocation::Default,
pyproject_path: dir.path().join("pyproject.toml"),
..project("api")
}],
settings: Settings {
default_venv_location: bombadil_core::model::DefaultVenvLocation::Alongside,
..Settings::default()
},
..Config::default()
});
let central = tempfile::tempdir().unwrap();
let fake = Arc::new(FakeCommandRunner::new());
let _ = update(
&mut app,
Message::SettingsDefaultVenvLocationFolderPicked(Some(central.path().to_path_buf())),
fake.clone(),
);
let new_path = venv_path::resolve(
&app.config.projects[0],
app.config.projects[0]
.active_environment()
.expect("this project has an environment"),
&app.config.settings,
)
.unwrap();
assert!(
old_venv.exists() && old_venv.join("pyvenv.cfg").exists(),
"the venv at the OLD resolved location must survive the settings change untouched"
);
assert_eq!(
std::fs::read_to_string(old_venv.join("lib").join("marker")).unwrap(),
"still here",
"the old venv's contents must be byte-for-byte untouched, not just its directory"
);
assert!(
!new_path.exists(),
"nothing may be created at the new location either -- only the computed path changes, \
the venv is neither moved nor recreated"
);
assert!(
fake.calls().is_empty(),
"a settings change must spawn no process at all; got {:?}",
fake.calls()
);
}
#[test]
fn selecting_alongside_after_a_central_location_also_leaves_the_central_venv_alone() {
let central = tempfile::tempdir().unwrap();
let central_venv = central.path().join("api-000000");
std::fs::create_dir_all(central_venv.join("lib")).unwrap();
std::fs::write(
central_venv.join("pyvenv.cfg"),
"home = /usr/bin\nversion_info = 3.12.1\n",
)
.unwrap();
let dir = tempfile::tempdir().unwrap();
let mut app = App::new(Config {
projects: vec![Project {
environments: vec![bombadil_core::model::Environment {
location: bombadil_core::model::VenvLocation::Default,
python: PythonPin::Unpinned,
}],
active: bombadil_core::model::VenvLocation::Default,
pyproject_path: dir.path().join("pyproject.toml"),
..project("api")
}],
settings: Settings {
default_venv_location: bombadil_core::model::DefaultVenvLocation::Central {
path: central.path().to_path_buf(),
},
..Settings::default()
},
..Config::default()
});
let _ = update(
&mut app,
Message::SettingsDefaultVenvLocationAlongsideSelected,
unused_runner(),
);
assert_eq!(
app.config.settings.default_venv_location,
bombadil_core::model::DefaultVenvLocation::Alongside
);
assert!(
central_venv.join("pyvenv.cfg").exists(),
"the venv left behind under the old central location must survive"
);
}
#[test]
fn picking_a_default_venv_folder_dispatches_no_filesystem_or_process_work_synchronously() {
let mut app = App::new(Config::default());
let fake = Arc::new(FakeCommandRunner::new());
let before = app.config.settings.default_venv_location.clone();
let task = update(
&mut app,
Message::SettingsDefaultVenvLocationPickFolderRequested,
fake.clone(),
);
assert_eq!(app.config.settings.default_venv_location, before);
assert!(fake.calls().is_empty());
assert!(task.units() > 0, "must open the async folder picker");
}
fn pycharm_on_path() -> FakeCommandRunner {
let runner = FakeCommandRunner::new();
let found = bombadil_core::process::CommandOutcome {
status: Some(0),
stdout: "/opt/pycharm\n".into(),
stderr: String::new(),
};
runner.expect("which", &["pycharm"], found.clone());
runner.expect("where", &["pycharm"], found.clone());
runner.expect("pycharm", &[], found);
runner
}
#[test]
fn pycharm_opens_on_the_environment_the_other_two_buttons_act_on() {
let mut project = project("api");
let elsewhere = bombadil_core::model::VenvLocation::Custom {
path: std::path::PathBuf::from("/envs/api-312"),
};
project
.environments
.push(bombadil_core::model::Environment {
location: elsewhere.clone(),
python: PythonPin::Unpinned,
});
project.active = elsewhere;
let runner = pycharm_on_path();
run_open_pycharm(
&project,
&Config::default(),
std::path::Path::new("/nonexistent-home"),
&bombadil_core::secrets::InMemorySecretStore::new(),
&runner,
)
.expect("a programmed spawn must succeed");
let calls = runner.calls();
let launch = calls
.iter()
.find(|c| c.program == *std::path::Path::new("/opt/pycharm"))
.expect("PyCharm must be launched");
assert_eq!(
launch.env.get("VIRTUAL_ENV").map(String::as_str),
Some("/envs/api-312"),
"PyCharm must get the project's default environment, not its first"
);
}
#[test]
fn pycharm_opens_the_project_folder() {
let runner = pycharm_on_path();
run_open_pycharm(
&project("api"),
&Config::default(),
std::path::Path::new("/nonexistent-home"),
&bombadil_core::secrets::InMemorySecretStore::new(),
&runner,
)
.unwrap();
let calls = runner.calls();
let launch = calls
.iter()
.find(|c| c.program == *std::path::Path::new("/opt/pycharm"))
.expect("PyCharm must be launched");
assert_eq!(launch.args, vec!["/p/api".to_string()]);
}
#[test]
fn no_pycharm_says_where_it_looked() {
let runner = FakeCommandRunner::new();
let err = run_open_pycharm(
&project("api"),
&Config::default(),
std::path::Path::new("/nonexistent-home"),
&bombadil_core::secrets::InMemorySecretStore::new(),
&runner,
)
.expect_err("nothing is installed, so this cannot succeed");
assert!(err.contains("not found"), "got {err}");
assert!(
err.contains("Toolbox"),
"the message must name the places that were tried; got {err}"
);
assert!(
runner
.calls()
.iter()
.all(|c| !c.program.to_string_lossy().contains("/opt/pycharm")),
"a failed detection must not spawn a guessed path"
);
}
#[test]
fn pressing_the_pycharm_button_dispatches_background_work() {
let mut app = App::new(Config {
projects: vec![project("api")],
..Config::default()
});
let fake = Arc::new(FakeCommandRunner::new());
let task = update(&mut app, Message::OpenPycharmRequested(0), fake.clone());
assert!(task.units() > 0, "the launch must be background work");
assert!(
fake.calls().is_empty(),
"nothing may be spawned synchronously inside update"
);
}
#[test]
fn a_pycharm_button_press_for_a_project_that_is_gone_does_nothing() {
let mut app = App::new(Config::default());
let fake = Arc::new(FakeCommandRunner::new());
let task = update(&mut app, Message::OpenPycharmRequested(7), fake.clone());
assert_eq!(task.units(), 0);
assert!(fake.calls().is_empty());
}
#[test]
fn a_failed_pycharm_open_is_shown_rather_than_swallowed() {
let mut app = App::new(Config::default());
app.drawer.expanded = false;
let _ = update(
&mut app,
Message::OpenPycharmFinished(Err("PyCharm was not found. Looked in: ...".into())),
unused_runner(),
);
assert!(app.drawer.expanded, "the user has to be able to read it");
assert!(
app.drawer.status.contains("failed"),
"{:?}",
app.drawer.status
);
assert!(
app.drawer.lines.iter().any(|l| l.contains("not found")),
"the transcript must carry the reason; got {:?}",
app.drawer.lines
);
}
fn project(label: &str) -> bombadil_core::model::Project {
bombadil_core::model::Project {
id: uuid::Uuid::new_v4(),
label: label.into(),
pyproject_path: std::path::PathBuf::from(format!("/p/{label}/pyproject.toml")),
environments: vec![bombadil_core::model::Environment {
location: bombadil_core::model::VenvLocation::Alongside,
python: PythonPin::Unpinned,
}],
active: bombadil_core::model::VenvLocation::Alongside,
..bombadil_core::model::Project::default()
}
}
}