use bombadil_core::pyproject::{GroupEntry, PyProject};
use bombadil_core::uv::results::{DependencyGraph, InstalledPackage, TreeOutcome, TreePackage};
use std::collections::BTreeSet;
use crate::interpreter;
use crate::theme;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GroupKind {
Main,
Group(String),
Extra(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DepRow {
pub name: String,
pub constraint: Option<String>,
pub version: Option<String>,
pub declared: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DepGroup {
pub kind: GroupKind,
pub rows: Vec<DepRow>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DepsTab {
pub groups: Vec<DepGroup>,
pub graph: Option<DependencyGraph>,
pub tree_note: Option<String>,
}
fn split_requirement(requirement: &str) -> (String, Option<String>) {
let before_marker = requirement.split(';').next().unwrap_or(requirement).trim();
let name_end = before_marker
.find(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')))
.unwrap_or(before_marker.len());
let name = before_marker[..name_end].to_string();
let rest = before_marker[name_end..].trim();
let rest = match rest.strip_prefix('[') {
Some(after_bracket) => match after_bracket.find(']') {
Some(end) => after_bracket[end + 1..].trim(),
None => "",
},
None => rest,
};
let constraint = if rest.is_empty() {
None
} else {
Some(rest.to_string())
};
(name, constraint)
}
fn canonical_name(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut previous_was_separator = false;
for character in name.chars() {
if matches!(character, '-' | '_' | '.') {
if !previous_was_separator {
out.push('-');
previous_was_separator = true;
}
} else {
out.extend(character.to_lowercase());
previous_was_separator = false;
}
}
out
}
fn join_rows(requirements: &[String], installed: &[InstalledPackage]) -> Vec<DepRow> {
requirements
.iter()
.map(|requirement| {
let (name, constraint) = split_requirement(requirement);
let canonical = canonical_name(&name);
let version = installed
.iter()
.find(|pkg| canonical_name(&pkg.name) == canonical)
.map(|pkg| pkg.version.clone());
DepRow {
name,
constraint,
version,
declared: true,
}
})
.collect()
}
fn resolve_group<'a>(
pyproject: &'a PyProject,
group: &'a str,
visited: &mut BTreeSet<&'a str>,
) -> Vec<String> {
let mut out = Vec::new();
if !visited.insert(group) {
return out;
}
let Some(entries) = pyproject.dependency_groups.get(group) else {
return out;
};
for entry in entries {
match entry {
GroupEntry::Requirement(requirement) => out.push(requirement.clone()),
GroupEntry::IncludeGroup(other) => out.extend(resolve_group(pyproject, other, visited)),
}
}
out
}
pub fn build(
pyproject: &PyProject,
installed: &[InstalledPackage],
tree: Result<TreeOutcome, String>,
) -> DepsTab {
let mut declared_anywhere: BTreeSet<String> = BTreeSet::new();
let mut groups = Vec::new();
let main_rows = join_rows(&pyproject.dependencies, installed);
for row in &main_rows {
declared_anywhere.insert(canonical_name(&row.name));
}
for name in pyproject.dependency_groups.keys() {
let mut visited = BTreeSet::new();
let requirements = resolve_group(pyproject, name, &mut visited);
let rows = join_rows(&requirements, installed);
for row in &rows {
declared_anywhere.insert(canonical_name(&row.name));
}
groups.push(DepGroup {
kind: GroupKind::Group(name.clone()),
rows,
});
}
for (name, requirements) in &pyproject.optional_dependencies {
let rows = join_rows(requirements, installed);
for row in &rows {
declared_anywhere.insert(canonical_name(&row.name));
}
groups.push(DepGroup {
kind: GroupKind::Extra(name.clone()),
rows,
});
}
let mut main_rows = main_rows;
for pkg in installed {
if !declared_anywhere.contains(&canonical_name(&pkg.name)) {
main_rows.push(DepRow {
name: pkg.name.clone(),
constraint: None,
version: Some(pkg.version.clone()),
declared: false,
});
}
}
groups.insert(
0,
DepGroup {
kind: GroupKind::Main,
rows: main_rows,
},
);
let (graph, tree_note) = match tree {
Ok(TreeOutcome::Parsed(graph)) => (Some(graph), None),
Ok(TreeOutcome::UnsupportedSchema { found }) => (
None,
Some(format!(
"uv returned a dependency tree in an unrecognised format ({found}); showing direct dependencies only."
)),
),
Err(cause) => (
None,
Some(format!(
"could not read the dependency tree ({cause}); showing direct dependencies only."
)),
),
};
DepsTab {
groups,
graph,
tree_note,
}
}
pub fn transitive_dependencies<'a>(
graph: &'a DependencyGraph,
start_id: &str,
) -> Vec<&'a TreePackage> {
let mut visited: BTreeSet<&str> = BTreeSet::new();
let mut out = Vec::new();
let mut stack = vec![start_id];
visited.insert(start_id);
while let Some(id) = stack.pop() {
for dep in graph.dependencies_of(id) {
if visited.insert(dep.id.as_str()) {
out.push(dep);
stack.push(&dep.id);
}
}
}
out
}
pub fn find_id_by_name<'a>(graph: &'a DependencyGraph, name: &str) -> Option<&'a str> {
graph
.packages
.values()
.find(|pkg| pkg.name.eq_ignore_ascii_case(name))
.map(|pkg| pkg.id.as_str())
}
pub fn unavailable_message(cause: &str) -> String {
format!("could not read this project's environment: {cause}")
}
pub fn state(row: &DepRow) -> theme::State {
match &row.version {
None => theme::State::Absent,
Some(version) => {
if interpreter::version_satisfies(version, row.constraint.as_deref()) {
theme::State::Present
} else {
theme::State::Drifted
}
}
}
}
fn dep_row<'a>(row: &DepRow) -> iced::Element<'a, crate::app::Message> {
let glyph = theme::state_glyph(state(row));
let name = iced::widget::text(row.name.clone())
.font(theme::FONT_DATA)
.size(theme::DATA)
.width(theme::DEP_NAME_COLUMN_WIDTH);
let constraint: iced::Element<'_, crate::app::Message> = match &row.constraint {
Some(constraint) => iced::widget::text(constraint.clone())
.font(theme::FONT_DATA)
.size(theme::DATA)
.color(theme::SLATE)
.into(),
None => iced::widget::text("-")
.font(theme::FONT_PROSE)
.size(theme::DATA)
.color(theme::SLATE)
.into(),
};
let constraint = iced::widget::container(constraint).width(theme::DEP_CONSTRAINT_COLUMN_WIDTH);
let version: iced::Element<'_, crate::app::Message> = match &row.version {
Some(version) => iced::widget::text(version.clone())
.font(theme::FONT_DATA)
.size(theme::DATA)
.into(),
None => iced::widget::text("not installed")
.font(theme::FONT_PROSE)
.size(theme::DATA)
.color(theme::SLATE)
.into(),
};
let mut line = iced::widget::row![
iced::widget::text(glyph.glyph.to_string())
.size(theme::BODY)
.color(glyph.colour)
.width(theme::GLYPH_COLUMN_WIDTH),
name,
constraint,
version,
]
.spacing(theme::SPACE_1);
if !row.declared {
line = line.push(
iced::widget::text("(not declared)")
.font(theme::FONT_PROSE)
.size(theme::DATA)
.color(theme::SLATE),
);
}
iced::widget::container(line)
.padding(theme::SPACE_1)
.style(|_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(theme::BARK)),
..iced::widget::container::Style::default()
})
.into()
}
pub fn view<'a>(fetched: &Result<DepsTab, String>) -> iced::Element<'a, crate::app::Message> {
let tab = match fetched {
Ok(tab) => tab,
Err(cause) => {
return iced::widget::text(unavailable_message(cause))
.size(theme::BODY)
.into();
}
};
let mut column = iced::widget::column![].spacing(theme::SPACE_3);
if let Some(note) = &tab.tree_note {
column = column.push(
iced::widget::text(note.clone())
.size(theme::BODY)
.color(theme::SLATE),
);
}
for group in &tab.groups {
let title = match &group.kind {
GroupKind::Main => "main".to_string(),
GroupKind::Group(name) => format!("group: {name}"),
GroupKind::Extra(name) => format!("extra: {name}"),
};
let mut rows = iced::widget::column![
iced::widget::text(title)
.font(theme::FONT_PROSE)
.size(theme::LABEL)
.color(theme::SLATE)
]
.spacing(theme::SPACE_1);
for row in &group.rows {
rows = rows.push(dep_row(row));
if let Some(graph) = &tab.graph
&& let Some(id) = find_id_by_name(graph, &row.name)
{
for dep in transitive_dependencies(graph, id) {
rows = rows.push(
iced::widget::container(
iced::widget::row![
iced::widget::Space::new().width(theme::GLYPH_COLUMN_WIDTH),
iced::widget::text(format!("\u{2514} {}", dep.name))
.font(theme::FONT_DATA)
.size(theme::LABEL)
.color(theme::SLATE)
.width(theme::DEP_NAME_COLUMN_WIDTH),
iced::widget::text(dep.version.clone())
.font(theme::FONT_DATA)
.size(theme::LABEL)
.color(theme::SLATE),
]
.spacing(theme::SPACE_1),
)
.padding(theme::SPACE_1),
);
}
}
}
column = column.push(rows);
}
column.into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_member_declared_with_underscores_matches_the_hyphenated_install() {
let rows = join_rows(
&["calc_service>=15.0.0".to_string()],
&[InstalledPackage {
name: "calc-service".into(),
version: "15.2.28".into(),
editable_project_location: Some("/repo/lib/calc_service".into()),
}],
);
assert_eq!(rows.len(), 1);
assert_eq!(
rows[0].version.as_deref(),
Some("15.2.28"),
"an underscore and a hyphen are the same package to Python"
);
assert_eq!(state(&rows[0]), theme::State::Present);
}
#[test]
fn a_matched_member_does_not_also_appear_as_an_undeclared_leftover() {
let pyproject = PyProject {
dependencies: vec!["calc_service>=15.0.0".to_string()],
..PyProject::default()
};
let installed = vec![InstalledPackage {
name: "calc-service".into(),
version: "15.2.28".into(),
editable_project_location: Some("/repo/lib/calc_service".into()),
}];
let tab = build(&pyproject, &installed, Err("no tree".to_string()));
let named: Vec<&DepRow> = tab
.groups
.iter()
.flat_map(|group| group.rows.iter())
.filter(|row| canonical_name(&row.name) == "calc-service")
.collect();
assert_eq!(
named.len(),
1,
"the package must appear once, not once declared and once as a \
leftover; got {named:?}"
);
assert!(named[0].declared);
}
#[test]
fn canonical_names_follow_pep_503() {
for (raw, want) in [
("calc_service", "calc-service"),
("calc-service", "calc-service"),
("Calc.Service", "calc-service"),
("calc__service", "calc-service"),
("calc_-.service", "calc-service"),
("ThornOfficialConfig", "thornofficialconfig"),
("zope.interface", "zope-interface"),
] {
assert_eq!(canonical_name(raw), want, "{raw}");
}
}
#[test]
fn two_genuinely_different_packages_do_not_collapse_together() {
assert_ne!(
canonical_name("calc_service"),
canonical_name("calcservice")
);
assert_ne!(
canonical_name("calc_service"),
canonical_name("calc_services")
);
}
use std::collections::BTreeMap;
fn no_tree() -> Result<TreeOutcome, String> {
Err("this test is not about the tree".to_string())
}
fn pkg(name: &str, version: &str) -> InstalledPackage {
InstalledPackage {
name: name.to_string(),
version: version.to_string(),
editable_project_location: None,
}
}
fn pyproject() -> PyProject {
PyProject {
name: Some("my-api".into()),
requires_python: Some(">=3.12".into()),
dependencies: vec!["fastapi>=0.115".into(), "httpx~=0.27".into()],
optional_dependencies: BTreeMap::from([
("cli".to_string(), vec!["typer".to_string()]),
(
"postgres".to_string(),
vec!["asyncpg>=0.29".to_string(), "sqlalchemy>=2".to_string()],
),
]),
dependency_groups: BTreeMap::from([
(
"test".to_string(),
vec![
GroupEntry::Requirement("pytest>=8".into()),
GroupEntry::Requirement("pytest-cov".into()),
],
),
(
"dev".to_string(),
vec![
GroupEntry::IncludeGroup("test".into()),
GroupEntry::Requirement("ruff".into()),
],
),
]),
}
}
#[test]
fn a_constrained_requirement_splits_name_from_constraint() {
assert_eq!(
split_requirement("fastapi>=0.115"),
("fastapi".to_string(), Some(">=0.115".to_string()))
);
}
#[test]
fn an_unconstrained_requirement_has_no_constraint_text() {
assert_eq!(split_requirement("typer"), ("typer".to_string(), None));
}
#[test]
fn extras_and_markers_are_stripped_from_the_constraint_column() {
assert_eq!(
split_requirement("typer[all]>=1.0; python_version < \"3.13\""),
("typer".to_string(), Some(">=1.0".to_string()))
);
}
#[test]
fn a_declared_and_installed_package_shows_both() {
let installed = vec![pkg("fastapi", "0.115.2")];
let tab = build(&pyproject(), &installed, no_tree());
let main = &tab.groups[0];
let row = main.rows.iter().find(|r| r.name == "fastapi").unwrap();
assert_eq!(row.constraint.as_deref(), Some(">=0.115"));
assert_eq!(row.version.as_deref(), Some("0.115.2"));
}
#[test]
fn a_declared_but_uninstalled_package_shows_its_constraint_with_no_version() {
let installed: Vec<InstalledPackage> = vec![];
let tab = build(&pyproject(), &installed, no_tree());
let main = &tab.groups[0];
let row = main
.rows
.iter()
.find(|r| r.name == "httpx")
.expect("declared-but-uninstalled must still be a row");
assert_eq!(row.constraint.as_deref(), Some("~=0.27"));
assert_eq!(row.version, None);
assert!(row.declared);
}
#[test]
fn an_installed_but_undeclared_package_is_shown_marked_as_such() {
let installed = vec![pkg("fastapi", "0.115.2"), pkg("anyio", "4.14.2")];
let tab = build(&pyproject(), &installed, no_tree());
let main = &tab.groups[0];
let row = main
.rows
.iter()
.find(|r| r.name == "anyio")
.expect("an undeclared installed package must still be a row");
assert!(
!row.declared,
"anyio is not declared anywhere in the manifest"
);
assert_eq!(row.version.as_deref(), Some("4.14.2"));
assert_eq!(row.constraint, None);
}
#[test]
fn a_package_declared_in_an_extra_is_not_treated_as_a_leftover_in_main() {
let installed = vec![pkg("typer", "0.12.0")];
let tab = build(&pyproject(), &installed, no_tree());
let main = &tab.groups[0];
assert!(
!main.rows.iter().any(|r| r.name == "typer"),
"typer is declared under the cli extra, not main; got {:?}",
main.rows
);
}
#[test]
fn main_groups_and_extras_are_separate_and_a_shared_package_appears_in_both() {
let tab = build(&pyproject(), &[], no_tree());
let kinds: Vec<&GroupKind> = tab.groups.iter().map(|g| &g.kind).collect();
assert!(kinds.contains(&&GroupKind::Main));
assert!(kinds.contains(&&GroupKind::Group("test".to_string())));
assert!(kinds.contains(&&GroupKind::Group("dev".to_string())));
assert!(kinds.contains(&&GroupKind::Extra("cli".to_string())));
assert!(kinds.contains(&&GroupKind::Extra("postgres".to_string())));
let test_group = tab
.groups
.iter()
.find(|g| g.kind == GroupKind::Group("test".to_string()))
.unwrap();
let dev_group = tab
.groups
.iter()
.find(|g| g.kind == GroupKind::Group("dev".to_string()))
.unwrap();
assert!(test_group.rows.iter().any(|r| r.name == "pytest-cov"));
assert!(
dev_group.rows.iter().any(|r| r.name == "pytest-cov"),
"dev includes test, so pytest-cov must appear under dev too; got {:?}",
dev_group.rows
);
let main = &tab.groups[0];
assert!(!main.rows.iter().any(|r| r.name == "pytest-cov"));
}
#[test]
fn a_cyclic_include_group_terminates_instead_of_looping_forever() {
let pyproject = PyProject {
dependency_groups: BTreeMap::from([
(
"a".to_string(),
vec![GroupEntry::IncludeGroup("b".to_string())],
),
(
"b".to_string(),
vec![GroupEntry::IncludeGroup("a".to_string())],
),
]),
..Default::default()
};
let tab = build(&pyproject, &[], no_tree());
assert_eq!(tab.groups.len(), 3); }
#[test]
fn an_unsupported_schema_still_shows_every_declared_row() {
let installed = vec![pkg("fastapi", "0.115.2")];
let tab = build(
&pyproject(),
&installed,
Ok(TreeOutcome::UnsupportedSchema {
found: "v2".to_string(),
}),
);
let main = &tab.groups[0];
assert!(
main.rows.iter().any(|r| r.name == "fastapi"),
"direct dependencies must still be listed"
);
assert!(
main.rows.iter().any(|r| r.name == "httpx"),
"direct dependencies must still be listed"
);
}
#[test]
fn an_unsupported_schema_carries_no_graph_and_says_why() {
let tab = build(
&pyproject(),
&[],
Ok(TreeOutcome::UnsupportedSchema {
found: "v2".to_string(),
}),
);
assert!(
tab.graph.is_none(),
"no transitive expansion without a graph uv answered with"
);
let note = tab
.tree_note
.expect("the tab must say why the tree is unavailable");
assert!(
note.contains("v2"),
"the reason must name the schema uv actually returned; got {note:?}"
);
}
#[test]
fn an_unavailable_environment_is_reported_by_its_cause() {
let message = unavailable_message("error: No virtual environment found at /p/api/.venv");
assert!(
message.contains("No virtual environment found at /p/api/.venv"),
"the cause uv gave must survive into what the user reads; got {message:?}"
);
assert!(
!message.contains("not installed"),
"a fetch that could not ask must not claim anything about what is installed; \
got {message:?}"
);
}
#[test]
fn an_unreadable_tree_says_so_rather_than_looking_like_no_tree_at_all() {
let tab = build(
&pyproject(),
&[],
Err("could not read uv's dependency tree: expected value at line 1".to_string()),
);
assert!(tab.graph.is_none());
let note = tab
.tree_note
.expect("a tree that could not be read must be explained, not silently absent");
assert!(
note.contains("expected value at line 1"),
"the note must carry the actual reason; got {note:?}"
);
}
#[test]
fn an_unreadable_tree_is_not_confused_with_an_unrecognised_schema() {
let unreadable = build(&pyproject(), &[], Err("uv tree failed".to_string()));
let unsupported = build(
&pyproject(),
&[],
Ok(TreeOutcome::UnsupportedSchema {
found: "v2".to_string(),
}),
);
assert_ne!(unreadable.tree_note, unsupported.tree_note);
assert!(
!unreadable
.tree_note
.unwrap()
.contains("unrecognised format"),
"a tree that never parsed must not be reported as a schema this build has not seen"
);
}
#[test]
fn a_parsed_tree_carries_the_graph_and_no_note() {
let graph = DependencyGraph {
roots: vec!["root".to_string()],
members: vec![],
packages: BTreeMap::new(),
};
let tab = build(&pyproject(), &[], Ok(TreeOutcome::Parsed(graph.clone())));
assert_eq!(tab.graph, Some(graph));
assert!(tab.tree_note.is_none());
}
fn graph_with_chain() -> DependencyGraph {
let mut packages = BTreeMap::new();
packages.insert(
"root".to_string(),
TreePackage {
id: "root".into(),
name: "root".into(),
version: "0.1.0".into(),
dependencies: vec!["httpx".into()],
},
);
packages.insert(
"httpx".to_string(),
TreePackage {
id: "httpx".into(),
name: "httpx".into(),
version: "0.28.1".into(),
dependencies: vec!["httpcore".into()],
},
);
packages.insert(
"httpcore".to_string(),
TreePackage {
id: "httpcore".into(),
name: "httpcore".into(),
version: "1.0.9".into(),
dependencies: vec!["h11".into(), "certifi".into()],
},
);
packages.insert(
"h11".to_string(),
TreePackage {
id: "h11".into(),
name: "h11".into(),
version: "0.16.0".into(),
dependencies: vec![],
},
);
packages.insert(
"certifi".to_string(),
TreePackage {
id: "certifi".into(),
name: "certifi".into(),
version: "2026.7.22".into(),
dependencies: vec![],
},
);
DependencyGraph {
roots: vec!["root".to_string()],
members: vec![],
packages,
}
}
#[test]
fn transitive_dependencies_walks_the_whole_chain() {
let graph = graph_with_chain();
let names: BTreeSet<&str> = transitive_dependencies(&graph, "httpx")
.into_iter()
.map(|p| p.name.as_str())
.collect();
assert_eq!(
names,
BTreeSet::from(["httpcore", "h11", "certifi"]),
"httpx's whole transitive tree must be reachable, not just its direct dependency"
);
}
#[test]
fn transitive_dependencies_does_not_include_the_start_node_itself() {
let graph = graph_with_chain();
let ids: Vec<&str> = transitive_dependencies(&graph, "httpx")
.into_iter()
.map(|p| p.id.as_str())
.collect();
assert!(!ids.contains(&"httpx"));
}
#[test]
fn a_diamond_dependency_is_not_duplicated() {
let mut graph = graph_with_chain();
graph
.packages
.get_mut("root")
.unwrap()
.dependencies
.push("certifi".to_string());
let certifi_count = transitive_dependencies(&graph, "root")
.into_iter()
.filter(|p| p.name == "certifi")
.count();
assert_eq!(
certifi_count, 1,
"a diamond dependency must appear once, not once per path"
);
}
#[test]
fn find_id_by_name_locates_a_package_case_insensitively() {
let graph = graph_with_chain();
assert_eq!(find_id_by_name(&graph, "HTTPX"), Some("httpx"));
assert_eq!(find_id_by_name(&graph, "nonexistent"), None);
}
fn row(constraint: Option<&str>, version: Option<&str>) -> DepRow {
DepRow {
name: "httpx".to_string(),
constraint: constraint.map(String::from),
version: version.map(String::from),
declared: true,
}
}
#[test]
fn a_declared_and_installed_package_satisfying_its_constraint_is_present() {
assert_eq!(
state(&row(Some(">=0.27"), Some("0.27.2"))),
theme::State::Present
);
}
#[test]
fn a_declared_but_uninstalled_package_is_absent() {
assert_eq!(state(&row(Some(">=0.27"), None)), theme::State::Absent);
}
#[test]
fn an_installed_package_outside_its_declared_constraint_is_drifted() {
assert_eq!(
state(&row(Some(">=0.27"), Some("0.26.0"))),
theme::State::Drifted
);
}
#[test]
fn an_installed_package_with_no_declared_constraint_is_present() {
assert_eq!(state(&row(None, Some("4.14.2"))), theme::State::Present);
}
#[test]
fn an_unparseable_constraint_falls_back_to_present_not_drifted() {
assert_eq!(
state(&row(Some("<3.5"), Some("3.9.18"))),
theme::State::Present
);
assert_eq!(
state(&row(Some("not a specifier"), Some("3.9.18"))),
theme::State::Present
);
}
}