use crate::app::Message;
use crate::interpreter;
use crate::theme;
use bombadil_core::error::VenvPathError;
use bombadil_core::model::{Project, Settings, VenvLocation};
use bombadil_core::uv::results::Interpreter;
use bombadil_core::{pyproject, venv_path};
use std::path::{Path, PathBuf};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VenvChoice {
SettingsDefault,
Alongside,
Chosen(PathBuf),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Interpreters {
Pending,
Loaded(Vec<Interpreter>),
Failed(String),
}
impl Interpreters {
pub fn available(&self) -> &[Interpreter] {
match self {
Self::Loaded(interpreters) => interpreters,
Self::Pending | Self::Failed(_) => &[],
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Draft {
pub pyproject_path: PathBuf,
pub name: Option<String>,
pub requires_python: Option<String>,
pub venv_choice: VenvChoice,
pub interpreters: Interpreters,
pub venv_outcome: Option<VenvOutcome>,
}
pub fn draft_from(path: &Path, text: &str) -> Draft {
let parsed = pyproject::parse(text).ok();
Draft {
pyproject_path: path.to_path_buf(),
name: parsed.as_ref().and_then(|p| p.name.clone()),
requires_python: parsed.as_ref().and_then(|p| p.requires_python.clone()),
venv_choice: VenvChoice::SettingsDefault,
interpreters: Interpreters::Pending,
venv_outcome: None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InterpreterLine {
Pending,
Failed(String),
NoneCompatible,
Version(String),
}
fn interpreter_line(draft: &Draft) -> InterpreterLine {
match &draft.interpreters {
Interpreters::Pending => InterpreterLine::Pending,
Interpreters::Failed(why) => InterpreterLine::Failed(why.clone()),
Interpreters::Loaded(interpreters) => {
match interpreter::preselect(interpreters, draft.requires_python.as_deref()) {
Some(interpreter) => InterpreterLine::Version(interpreter.version.clone()),
None => InterpreterLine::NoneCompatible,
}
}
}
}
fn needs_install_offer(draft: &Draft) -> bool {
match &draft.interpreters {
Interpreters::Loaded(interpreters) => {
interpreter::preselect(interpreters, draft.requires_python.as_deref()).is_none()
}
Interpreters::Pending | Interpreters::Failed(_) => false,
}
}
fn fallback_label(pyproject_path: &Path) -> String {
pyproject_path
.parent()
.and_then(Path::file_name)
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default()
}
pub fn to_project(draft: &Draft, settings: &Settings) -> Project {
let location = match &draft.venv_choice {
VenvChoice::SettingsDefault => VenvLocation::Default,
VenvChoice::Alongside => VenvLocation::Alongside,
VenvChoice::Chosen(path) => VenvLocation::Custom { path: path.clone() },
};
Project {
id: Uuid::new_v4(),
label: draft
.name
.clone()
.unwrap_or_else(|| fallback_label(&draft.pyproject_path)),
pyproject_path: draft.pyproject_path.clone(),
environments: vec![bombadil_core::model::Environment {
location: location.clone(),
python: settings.python.clone(),
}],
active: location,
..Project::default()
}
}
pub fn resolved_venv_path(draft: &Draft, settings: &Settings) -> Result<PathBuf, VenvPathError> {
let project = to_project(draft, settings);
let environment = project.active_environment().cloned().unwrap_or_default();
venv_path::resolve(&project, &environment, settings)
}
pub fn resolved_venv_path_display(draft: &Draft, settings: &Settings) -> String {
match resolved_venv_path(draft, settings) {
Ok(path) => path.display().to_string(),
Err(err) => err.to_string(),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VenvOutcome {
Adopt { python_version: String },
Create,
}
pub fn venv_outcome(venv_path: &Path, probe: impl Fn(&Path) -> Option<String>) -> VenvOutcome {
match probe(venv_path) {
Some(python_version) => VenvOutcome::Adopt { python_version },
None => VenvOutcome::Create,
}
}
fn venv_outcome_status(draft: &Draft) -> Option<String> {
Some(match draft.venv_outcome.as_ref()? {
VenvOutcome::Adopt { python_version } => format!(
"this folder already holds a virtual environment (python {python_version}) -- \
it will be adopted as it is, and the interpreter selected above will not be used"
),
VenvOutcome::Create => "a new virtual environment will be created here".to_string(),
})
}
pub fn view<'a>(draft: &Draft, resolved: &str) -> iced::Element<'a, Message> {
let name = draft
.name
.clone()
.unwrap_or_else(|| "(no [project] table)".to_string());
let requires_python = draft
.requires_python
.clone()
.unwrap_or_else(|| "(none)".to_string());
let manifest = iced::widget::column![
iced::widget::text(draft.pyproject_path.display().to_string())
.font(theme::FONT_DATA)
.size(theme::DATA)
.color(theme::SLATE),
theme::labeled_value("name", name),
theme::labeled_value("requires-python", requires_python),
]
.spacing(theme::SPACE_1);
let mut interpreter_section = iced::widget::column![].spacing(theme::SPACE_2);
match interpreter_line(draft) {
InterpreterLine::Version(version) => {
interpreter_section = interpreter_section.push(
iced::widget::text(version)
.font(theme::FONT_DATA)
.size(theme::BODY),
);
}
InterpreterLine::Pending => {
interpreter_section = interpreter_section.push(
iced::widget::text("Checking which interpreters are available...")
.size(theme::BODY)
.color(theme::SLATE),
);
}
InterpreterLine::Failed(why) => {
interpreter_section = interpreter_section.push(
iced::widget::text(format!("Could not list interpreters: {why}"))
.size(theme::BODY)
.color(theme::SLATE),
);
}
InterpreterLine::NoneCompatible => {
interpreter_section = interpreter_section.push(
iced::widget::text("No installed interpreter satisfies requires-python.")
.size(theme::BODY)
.color(theme::SLATE),
);
}
}
if needs_install_offer(draft) {
interpreter_section = interpreter_section.push(
iced::widget::button(
iced::widget::text("Install a compatible interpreter").size(theme::BODY),
)
.on_press(Message::AddProjectInstallInterpreterRequested)
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_quiet),
);
}
let choice = |label: &'static str, selected: bool, message: Message| {
iced::widget::button(iced::widget::text(label).size(theme::BODY))
.on_press(message)
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_choice(selected))
};
let venv_row = iced::widget::row![
choice(
"Settings default",
draft.venv_choice == VenvChoice::SettingsDefault,
Message::AddProjectVenvChoiceSelected(VenvChoice::SettingsDefault),
),
choice(
"Alongside manifest",
draft.venv_choice == VenvChoice::Alongside,
Message::AddProjectVenvChoiceSelected(VenvChoice::Alongside),
),
choice(
"Chosen folder...",
matches!(draft.venv_choice, VenvChoice::Chosen(_)),
Message::AddProjectPickVenvFolderRequested,
),
]
.spacing(theme::SPACE_1);
let mut environment = iced::widget::column![
venv_row,
iced::widget::text(resolved.to_string())
.font(theme::FONT_DATA)
.size(theme::DATA)
.color(theme::SLATE),
]
.spacing(theme::SPACE_2);
if let Some(outcome) = venv_outcome_status(draft) {
environment = environment.push(
iced::widget::text(outcome)
.size(theme::BODY)
.color(theme::SLATE),
);
}
let actions = iced::widget::row![
iced::widget::Space::new().width(iced::Length::Fill),
iced::widget::button(iced::widget::text("Cancel").size(theme::BODY))
.on_press(Message::AddProjectCancelled)
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_quiet),
iced::widget::button(iced::widget::text("Add project").size(theme::BODY))
.on_press(Message::AddProjectConfirmed)
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_primary),
]
.spacing(theme::SPACE_2);
iced::widget::column![
iced::widget::text("Add project")
.font(theme::FONT_PROSE_SEMIBOLD)
.size(theme::DISPLAY),
theme::hairline_row(),
section("Manifest", manifest.into()),
section("Interpreter", interpreter_section.into()),
section("Virtual environment", environment.into()),
theme::hairline_row(),
actions,
]
.spacing(theme::SPACE_3)
.into()
}
fn section<'a>(
title: &'static str,
content: iced::Element<'a, Message>,
) -> iced::Element<'a, Message> {
iced::widget::column![
iced::widget::text(title)
.font(theme::FONT_PROSE_SEMIBOLD)
.size(theme::LABEL)
.color(theme::SLATE),
content,
]
.spacing(theme::SPACE_2)
.into()
}
#[cfg(test)]
mod tests {
use super::*;
use bombadil_core::model::DefaultVenvLocation;
const MANIFEST: &str = r#"
[project]
name = "my-api"
version = "0.1.0"
requires-python = ">=3.11"
"#;
#[test]
fn a_chosen_manifest_shows_its_name_and_python_requirement() {
let draft = draft_from(Path::new("/p/my-api/pyproject.toml"), MANIFEST);
assert_eq!(draft.name.as_deref(), Some("my-api"));
assert_eq!(draft.requires_python.as_deref(), Some(">=3.11"));
}
#[test]
fn a_manifest_without_a_name_is_still_addable() {
let draft = draft_from(
Path::new("/p/root/pyproject.toml"),
"[tool.uv.workspace]\nmembers = []\n",
);
assert_eq!(draft.name, None);
assert_eq!(draft.pyproject_path, Path::new("/p/root/pyproject.toml"));
}
#[test]
fn unparseable_toml_does_not_lose_the_chosen_path() {
let draft = draft_from(Path::new("/p/bad/pyproject.toml"), "this is not toml {{{");
assert_eq!(draft.pyproject_path, Path::new("/p/bad/pyproject.toml"));
assert_eq!(draft.name, None);
}
#[test]
fn the_default_venv_choice_is_the_settings_default() {
let draft = draft_from(Path::new("/p/my-api/pyproject.toml"), MANIFEST);
assert_eq!(draft.venv_choice, VenvChoice::SettingsDefault);
}
fn settings(default_venv_location: DefaultVenvLocation) -> Settings {
Settings {
default_venv_location,
..Settings::default()
}
}
#[test]
fn settings_default_follows_the_alongside_setting() {
let draft = draft_from(Path::new("/p/my-api/pyproject.toml"), MANIFEST);
let path = resolved_venv_path(&draft, &settings(DefaultVenvLocation::Alongside)).unwrap();
assert_eq!(path, PathBuf::from("/p/my-api/.venv"));
}
#[test]
fn settings_default_follows_the_central_setting() {
let draft = draft_from(Path::new("/p/my-api/pyproject.toml"), MANIFEST);
let path = resolved_venv_path(
&draft,
&settings(DefaultVenvLocation::Central {
path: PathBuf::from("/home/t/.venvs"),
}),
)
.unwrap();
assert!(
path.starts_with("/home/t/.venvs"),
"got {path:?}; the central setting must be honoured"
);
}
#[test]
fn alongside_ignores_the_settings_default() {
let mut draft = draft_from(Path::new("/p/my-api/pyproject.toml"), MANIFEST);
draft.venv_choice = VenvChoice::Alongside;
let path = resolved_venv_path(
&draft,
&settings(DefaultVenvLocation::Central {
path: PathBuf::from("/home/t/.venvs"),
}),
)
.unwrap();
assert_eq!(path, PathBuf::from("/p/my-api/.venv"));
}
#[test]
fn a_chosen_folder_is_used_verbatim() {
let mut draft = draft_from(Path::new("/p/my-api/pyproject.toml"), MANIFEST);
draft.venv_choice = VenvChoice::Chosen(PathBuf::from("/mnt/fast/envs/api"));
let path = resolved_venv_path(&draft, &settings(DefaultVenvLocation::Alongside)).unwrap();
assert_eq!(path, PathBuf::from("/mnt/fast/envs/api"));
}
#[test]
fn the_resolved_path_updates_as_the_choice_changes() {
let mut draft = draft_from(Path::new("/p/my-api/pyproject.toml"), MANIFEST);
let settings = settings(DefaultVenvLocation::Alongside);
let before = resolved_venv_path(&draft, &settings).unwrap();
draft.venv_choice = VenvChoice::Chosen(PathBuf::from("/mnt/fast/envs/api"));
let after = resolved_venv_path(&draft, &settings).unwrap();
assert_ne!(
before, after,
"changing venv_choice must change the resolved path"
);
assert_eq!(after, PathBuf::from("/mnt/fast/envs/api"));
}
fn interp(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(),
}
}
#[test]
fn the_interpreter_list_starts_pending_rather_than_empty() {
let draft = draft_from(Path::new("/p/my-api/pyproject.toml"), MANIFEST);
assert_eq!(draft.interpreters, Interpreters::Pending);
assert!(draft.interpreters.available().is_empty());
}
#[test]
fn before_the_fetch_lands_the_status_reads_as_checking_not_missing() {
let draft = draft_from(Path::new("/p/my-api/pyproject.toml"), MANIFEST);
assert_eq!(interpreter_line(&draft), InterpreterLine::Pending);
assert!(!needs_install_offer(&draft));
}
#[test]
fn a_failed_fetch_says_what_went_wrong_instead_of_checking_forever() {
let mut draft = draft_from(Path::new("/p/my-api/pyproject.toml"), MANIFEST);
draft.interpreters =
Interpreters::Failed("no uv binary is available at /nope/uv".to_string());
let line = interpreter_line(&draft);
assert_ne!(
line,
InterpreterLine::Pending,
"a failed fetch must not keep claiming to be in progress"
);
let InterpreterLine::Failed(status) = &line else {
panic!("a failed fetch must read as failed; got {line:?}");
};
assert!(
status.contains("/nope/uv"),
"the failure's own text must survive to the dialog; got {status}"
);
assert!(
!needs_install_offer(&draft),
"installing through the very uv that could not be run is not an offer worth making"
);
}
#[test]
fn a_satisfying_interpreter_is_named_in_the_status_line() {
let mut draft = draft_from(Path::new("/p/my-api/pyproject.toml"), MANIFEST);
draft.interpreters = Interpreters::Loaded(vec![interp("3.10.13"), interp("3.11.9")]);
assert_eq!(
interpreter_line(&draft),
InterpreterLine::Version("3.11.9".to_string())
);
assert!(!needs_install_offer(&draft));
}
#[test]
fn nothing_satisfying_offers_to_install_instead_of_naming_the_wrong_one() {
let mut draft = draft_from(Path::new("/p/my-api/pyproject.toml"), MANIFEST);
draft.interpreters = Interpreters::Loaded(vec![interp("3.9.18")]);
assert_eq!(interpreter_line(&draft), InterpreterLine::NoneCompatible);
assert!(needs_install_offer(&draft));
}
#[test]
fn a_machine_uv_finds_nothing_on_is_offered_the_install_button() {
let mut draft = draft_from(Path::new("/p/my-api/pyproject.toml"), MANIFEST);
draft.interpreters = Interpreters::Loaded(Vec::new());
assert!(needs_install_offer(&draft));
}
#[test]
fn a_folder_holding_a_venv_is_reported_as_adoptable_with_its_version() {
let outcome = venv_outcome(Path::new("/whatever/.venv"), |_| Some("3.12.4".to_string()));
assert_eq!(
outcome,
VenvOutcome::Adopt {
python_version: "3.12.4".to_string()
}
);
}
#[test]
fn an_empty_folder_is_reported_as_a_creation_not_an_adoption() {
let outcome = venv_outcome(Path::new("/whatever/.venv"), |_| None);
assert_eq!(outcome, VenvOutcome::Create);
}
#[test]
fn a_manifest_with_no_project_table_is_labelled_by_its_directory() {
let draft = draft_from(
Path::new("/p/monorepo-root/pyproject.toml"),
"[tool.uv.workspace]\nmembers = []\n",
);
let project = to_project(&draft, &Settings::default());
assert_eq!(project.label, "monorepo-root");
}
#[test]
fn a_declared_name_still_wins_over_the_directory() {
let draft = draft_from(Path::new("/p/some-folder/pyproject.toml"), MANIFEST);
assert_eq!(to_project(&draft, &Settings::default()).label, "my-api");
}
#[test]
fn a_chosen_folder_holding_a_venv_states_the_adoption_and_its_version() {
let mut draft = draft_from(Path::new("/p/my-api/pyproject.toml"), MANIFEST);
draft.venv_outcome = Some(VenvOutcome::Adopt {
python_version: "3.9.18".to_string(),
});
let status = venv_outcome_status(&draft).expect("a probed draft must say what will happen");
assert!(
status.contains("adopted"),
"the adoption must be stated outright; got {status}"
);
assert!(
status.contains("3.9.18"),
"the version of what is being adopted must be named; got {status}"
);
assert!(
status.contains("interpreter selected above will not be used"),
"the consequence of adopting is that the chosen interpreter is ignored; got {status}"
);
}
#[test]
fn an_empty_chosen_folder_does_not_claim_an_adoption() {
let mut draft = draft_from(Path::new("/p/my-api/pyproject.toml"), MANIFEST);
draft.venv_outcome = Some(VenvOutcome::Create);
let status = venv_outcome_status(&draft).expect("a probed draft must say what will happen");
assert!(
!status.contains("adopt"),
"claiming to adopt something that is not there is the opposite mistake; got {status}"
);
assert!(status.contains("created"), "got {status}");
}
#[test]
fn nothing_is_stated_until_the_probe_for_the_current_choice_lands() {
let draft = draft_from(Path::new("/p/my-api/pyproject.toml"), MANIFEST);
assert_eq!(draft.venv_outcome, None);
assert_eq!(venv_outcome_status(&draft), None);
}
#[test]
fn to_project_gives_every_draft_a_real_unique_id() {
let draft = draft_from(Path::new("/p/my-api/pyproject.toml"), MANIFEST);
let a = to_project(&draft, &Settings::default());
let b = to_project(&draft, &Settings::default());
assert_ne!(a.id, Uuid::nil());
assert_ne!(
a.id, b.id,
"two confirmations of the same draft must not collide"
);
}
}