#![allow(clippy::default_trait_access)]
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::hash::BuildHasher;
use std::path::PathBuf;
use cabin_core::DependencyKind;
use cabin_lockfile::Lockfile;
use cabin_workspace::{PackageGraph, PackageKind, WorkspacePackage};
use serde::Serialize;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case", tag = "kind")]
pub enum SourceProvenance {
WorkspaceMember,
LocalPath,
Patched {
path: PathBuf,
provenance: String,
},
Registry {
#[serde(skip_serializing_if = "Option::is_none")]
checksum: Option<String>,
},
}
#[derive(Debug, Clone, Serialize)]
pub struct TreeNode {
pub name: String,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub edge_kind: Option<&'static str>,
pub source: SourceProvenance,
#[serde(skip_serializing_if = "is_false")]
pub repeated: bool,
pub children: Vec<TreeNode>,
}
fn is_false<T>(value: &T) -> bool
where
T: PartialEq + Default,
{
*value == T::default()
}
pub struct TreeInputs<'a> {
pub graph: &'a PackageGraph,
pub roots: &'a [usize],
pub lockfile: Option<&'a Lockfile>,
pub active_patches: Option<&'a cabin_workspace::ActivePatchSet>,
pub kind_filter: Option<DependencyKind>,
}
pub fn build_tree(inputs: &TreeInputs<'_>) -> Vec<TreeNode> {
let roots: Vec<usize> = if inputs.roots.is_empty() {
inputs.graph.primary_packages.clone()
} else {
let mut owned = inputs.roots.to_vec();
owned.sort_by(|a, b| {
inputs.graph.packages[*a]
.package
.name
.as_str()
.cmp(inputs.graph.packages[*b].package.name.as_str())
});
owned.dedup();
owned
};
let mut out: Vec<TreeNode> = roots
.iter()
.map(|&idx| {
let mut visited: HashSet<usize> = HashSet::new();
build_node(idx, None, inputs, &mut visited)
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.version.cmp(&b.version)));
out
}
fn build_node(
idx: usize,
edge_kind: Option<DependencyKind>,
inputs: &TreeInputs<'_>,
visited: &mut HashSet<usize>,
) -> TreeNode {
let pkg = &inputs.graph.packages[idx];
let name = pkg.package.name.as_str().to_owned();
let version = pkg.package.version.to_string();
let source = source_provenance_for(pkg, inputs);
let edge_kind_label = edge_kind.map(dep_kind_key);
let already_visited = !visited.insert(idx);
if already_visited {
return TreeNode {
name,
version,
edge_kind: edge_kind_label,
source,
repeated: true,
children: Vec::new(),
};
}
let mut children: Vec<TreeNode> = Vec::new();
for edge in &pkg.deps {
if let Some(filter) = inputs.kind_filter
&& edge.kind != filter
{
continue;
}
children.push(build_node(edge.index, Some(edge.kind), inputs, visited));
}
children.sort_by(|a, b| {
edge_kind_sort_key(a.edge_kind)
.cmp(&edge_kind_sort_key(b.edge_kind))
.then_with(|| a.name.cmp(&b.name))
.then_with(|| a.version.cmp(&b.version))
});
TreeNode {
name,
version,
edge_kind: edge_kind_label,
source,
repeated: false,
children,
}
}
fn dep_kind_key(kind: DependencyKind) -> &'static str {
kind.as_str()
}
fn edge_kind_sort_key(label: Option<&'static str>) -> u8 {
match label {
None => 0,
Some("normal") => 1,
Some("build") => 2,
Some("dev") => 3,
Some(_) => 99,
}
}
fn source_provenance_for(pkg: &WorkspacePackage, inputs: &TreeInputs<'_>) -> SourceProvenance {
if let Some(set) = inputs.active_patches
&& let Some(active) = set.get(&pkg.package.name)
{
return SourceProvenance::Patched {
path: active.manifest_dir.clone(),
provenance: active.provenance.as_key(),
};
}
match pkg.kind {
PackageKind::Local => {
if inputs
.graph
.index_of(pkg.package.name.as_str())
.is_some_and(|idx| inputs.graph.primary_packages.contains(&idx))
{
SourceProvenance::WorkspaceMember
} else {
SourceProvenance::LocalPath
}
}
PackageKind::Registry => {
let checksum = inputs
.lockfile
.and_then(|lock| lock.find(&pkg.package.name))
.and_then(|locked| {
if locked.version == pkg.package.version {
locked.checksum.clone()
} else {
None
}
});
SourceProvenance::Registry { checksum }
}
}
}
pub fn render_tree_human(forest: &[TreeNode]) -> String {
let mut out = String::new();
for (i, node) in forest.iter().enumerate() {
if i > 0 {
out.push('\n');
}
render_human_node(&mut out, node, "", true, true);
}
out
}
fn render_human_node(
out: &mut String,
node: &TreeNode,
prefix: &str,
is_last: bool,
is_root: bool,
) {
let connector = if is_root {
""
} else if is_last {
"└── "
} else {
"├── "
};
out.push_str(prefix);
out.push_str(connector);
out.push_str(&node.name);
out.push(' ');
out.push('v');
out.push_str(&node.version);
if let Some(label) = node.edge_kind {
out.push_str(" [");
out.push_str(label);
out.push(']');
}
out.push(' ');
out.push('(');
out.push_str(&render_source_label(&node.source));
out.push(')');
if node.repeated {
out.push_str(" (*)");
}
out.push('\n');
let child_prefix = if is_root {
String::new()
} else if is_last {
format!("{prefix} ")
} else {
format!("{prefix}│ ")
};
let count = node.children.len();
for (i, child) in node.children.iter().enumerate() {
render_human_node(out, child, &child_prefix, i + 1 == count, false);
}
}
fn render_source_label(source: &SourceProvenance) -> String {
match source {
SourceProvenance::WorkspaceMember => "workspace".to_owned(),
SourceProvenance::LocalPath => "local path".to_owned(),
SourceProvenance::Patched { provenance, .. } => format!("patched via {provenance}"),
SourceProvenance::Registry { checksum: Some(c) } => format!("registry, {c}"),
SourceProvenance::Registry { checksum: None } => "registry".to_owned(),
}
}
pub fn render_tree_json(forest: &[TreeNode]) -> serde_json::Value {
serde_json::Value::Array(
forest
.iter()
.map(|n| serde_json::to_value(n).expect("TreeNode is Serialize"))
.collect(),
)
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "kebab-case", tag = "kind")]
pub enum Explanation {
Package(PackageExplanation),
Target(TargetExplanation),
Source(SourceExplanation),
Feature(FeatureExplanation),
}
#[derive(Debug, Clone, Serialize)]
pub struct PackageExplanation {
pub name: String,
pub version: String,
pub source: SourceProvenance,
pub paths: Vec<Vec<ExplainStep>>,
pub is_selected_root: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct ExplainStep {
pub name: String,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub edge_kind: Option<&'static str>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TargetExplanation {
pub package: String,
pub target: String,
#[serde(rename = "target_kind")]
pub target_kind: String,
pub languages: Vec<String>,
pub deps: Vec<String>,
pub is_buildable: bool,
pub is_test: bool,
pub is_dev_only: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct SourceExplanation {
pub name: String,
pub version: String,
pub source: SourceProvenance,
pub source_replacements: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct FeatureExplanation {
pub package: String,
pub feature: String,
pub enabled: bool,
pub implies: Vec<String>,
pub is_default: bool,
}
pub fn explain_package(
graph: &PackageGraph,
roots: &[usize],
name: &str,
active_patches: Option<&cabin_workspace::ActivePatchSet>,
lockfile: Option<&Lockfile>,
) -> Result<PackageExplanation, ExplainError> {
let target_idx = locate_package(graph, name)?;
let pkg = &graph.packages[target_idx];
let inputs = TreeInputs {
graph,
roots,
lockfile,
active_patches,
kind_filter: None,
};
let source = source_provenance_for(pkg, &inputs);
let effective_roots: Vec<usize> = if roots.is_empty() {
graph.primary_packages.clone()
} else {
roots.to_vec()
};
let is_selected_root = effective_roots.contains(&target_idx);
let mut paths: Vec<Vec<ExplainStep>> = Vec::new();
for &root in &effective_roots {
for path in shortest_paths_to(graph, root, target_idx) {
paths.push(materialize_path(graph, &path));
}
}
paths.sort_by(|a, b| {
a.len()
.cmp(&b.len())
.then_with(|| join_path_names(a).cmp(&join_path_names(b)))
});
paths.dedup_by(|a, b| {
a.len() == b.len()
&& a.iter()
.zip(b.iter())
.all(|(x, y)| x.name == y.name && x.version == y.version)
});
Ok(PackageExplanation {
name: pkg.package.name.as_str().to_owned(),
version: pkg.package.version.to_string(),
source,
paths,
is_selected_root,
})
}
fn join_path_names(steps: &[ExplainStep]) -> String {
steps
.iter()
.map(|s| s.name.as_str())
.collect::<Vec<_>>()
.join(" -> ")
}
fn locate_package(graph: &PackageGraph, name: &str) -> Result<usize, ExplainError> {
let matches: Vec<usize> = graph
.packages
.iter()
.enumerate()
.filter(|(_, p)| p.package.name.as_str() == name)
.map(|(i, _)| i)
.collect();
match matches.len() {
0 => Err(ExplainError::PackageNotFound {
name: name.to_owned(),
candidates: known_package_names(graph),
}),
1 => Ok(matches[0]),
_ => {
let mut versions: Vec<String> = matches
.iter()
.map(|&i| graph.packages[i].package.version.to_string())
.collect();
versions.sort();
Err(ExplainError::AmbiguousPackageName {
name: name.to_owned(),
versions,
})
}
}
}
fn known_package_names(graph: &PackageGraph) -> Vec<String> {
let mut names: Vec<String> = graph
.packages
.iter()
.map(|p| p.package.name.as_str().to_owned())
.collect();
names.sort();
names
.into_iter()
.filter(|n| !n.is_empty())
.take(10)
.collect()
}
fn shortest_paths_to(graph: &PackageGraph, start: usize, target: usize) -> Vec<Vec<usize>> {
if start == target {
return vec![vec![start]];
}
let mut depth: BTreeMap<usize, usize> = BTreeMap::new();
let mut parents: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
depth.insert(start, 0);
let mut frontier: Vec<usize> = vec![start];
let mut found: bool = false;
let mut level = 0usize;
while !frontier.is_empty() && !found {
let mut next: Vec<usize> = Vec::new();
for &node in &frontier {
for edge in &graph.packages[node].deps {
let child = edge.index;
let new_depth = level + 1;
if let Some(&existing) = depth.get(&child) {
if existing == new_depth {
parents.entry(child).or_default().push(node);
}
continue;
}
depth.insert(child, new_depth);
parents.entry(child).or_default().push(node);
if child == target {
found = true;
}
next.push(child);
}
}
frontier = next;
level += 1;
}
if !depth.contains_key(&target) {
return Vec::new();
}
let mut paths: Vec<Vec<usize>> = vec![vec![target]];
loop {
let mut next: Vec<Vec<usize>> = Vec::new();
let mut grew = false;
for path in &paths {
let head = *path.first().expect("path is non-empty");
if head == start {
next.push(path.clone());
continue;
}
let Some(parent_list) = parents.get(&head) else {
continue;
};
for &p in parent_list {
let mut extended = vec![p];
extended.extend(path.iter().copied());
next.push(extended);
grew = true;
}
}
paths = next;
if !grew {
break;
}
}
paths
.into_iter()
.filter(|p| p.first().copied() == Some(start))
.collect()
}
fn materialize_path(graph: &PackageGraph, path: &[usize]) -> Vec<ExplainStep> {
let mut out: Vec<ExplainStep> = Vec::with_capacity(path.len());
for (i, &idx) in path.iter().enumerate() {
let pkg = &graph.packages[idx];
let edge_kind = if i == 0 {
None
} else {
let parent = &graph.packages[path[i - 1]];
parent
.deps
.iter()
.find(|e| e.index == idx)
.map(|e| dep_kind_key(e.kind))
};
out.push(ExplainStep {
name: pkg.package.name.as_str().to_owned(),
version: pkg.package.version.to_string(),
edge_kind,
});
}
out
}
pub fn explain_target(
graph: &PackageGraph,
selected_packages: &[usize],
target_name: &str,
) -> Result<TargetExplanation, ExplainError> {
let pool: Vec<usize> = if selected_packages.is_empty() {
(0..graph.packages.len()).collect()
} else {
selected_packages.to_vec()
};
let mut hits: Vec<(usize, &cabin_core::Target)> = Vec::new();
for idx in &pool {
let pkg = &graph.packages[*idx];
for target in &pkg.package.targets {
if target.name.as_str() == target_name {
hits.push((*idx, target));
}
}
}
if hits.is_empty() {
let mut candidates: BTreeSet<String> = BTreeSet::new();
for idx in &pool {
for target in &graph.packages[*idx].package.targets {
candidates.insert(target.name.as_str().to_owned());
}
}
return Err(ExplainError::TargetNotFound {
name: target_name.to_owned(),
candidates: candidates.into_iter().collect(),
});
}
if hits.len() > 1 {
let owners: Vec<String> = hits
.iter()
.map(|(idx, _)| graph.packages[*idx].package.name.as_str().to_owned())
.collect();
return Err(ExplainError::AmbiguousTargetName {
name: target_name.to_owned(),
owners,
});
}
let (pkg_idx, target) = hits[0];
let pkg = &graph.packages[pkg_idx];
let mut languages: BTreeSet<&'static str> = BTreeSet::new();
for source in &target.sources {
if let Some(lang) = cabin_core::classify_source(source) {
languages.insert(lang.as_key());
}
}
let kind = target.kind;
Ok(TargetExplanation {
package: pkg.package.name.as_str().to_owned(),
target: target.name.as_str().to_owned(),
target_kind: kind.as_str().to_owned(),
languages: languages.into_iter().map(str::to_owned).collect(),
deps: target.deps.clone(),
is_buildable: kind.produces_archive() || kind.produces_executable(),
is_test: kind.is_test(),
is_dev_only: kind.is_dev_only(),
})
}
pub fn explain_source(
graph: &PackageGraph,
name: &str,
active_patches: Option<&cabin_workspace::ActivePatchSet>,
lockfile: Option<&Lockfile>,
source_replacements: &cabin_core::SourceReplacementSettings,
) -> Result<SourceExplanation, ExplainError> {
let idx = locate_package(graph, name)?;
let pkg = &graph.packages[idx];
let inputs = TreeInputs {
graph,
roots: &[],
lockfile,
active_patches,
kind_filter: None,
};
let source = source_provenance_for(pkg, &inputs);
let mut replacements: Vec<String> = source_replacements
.entries
.values()
.map(|entry| {
format!(
"{} -> {} ({})",
entry.original.display(),
entry.replacement.display(),
entry.provenance.as_key()
)
})
.collect();
replacements.sort();
Ok(SourceExplanation {
name: pkg.package.name.as_str().to_owned(),
version: pkg.package.version.to_string(),
source,
source_replacements: replacements,
})
}
pub fn explain_feature(
graph: &PackageGraph,
feature_resolution: Option<&cabin_feature_per_package_view::FeatureView>,
query: &str,
) -> Result<FeatureExplanation, ExplainError> {
let (pkg_name, feature_name) =
query
.split_once('/')
.ok_or_else(|| ExplainError::InvalidFeatureQuery {
query: query.to_owned(),
})?;
let idx = locate_package(graph, pkg_name)?;
let pkg = &graph.packages[idx];
let package = &pkg.package;
if !package.features.features.contains_key(feature_name)
&& feature_name != cabin_core::DEFAULT_FEATURE_KEY
{
let mut candidates: Vec<String> = package.features.features.keys().cloned().collect();
candidates.sort();
return Err(ExplainError::FeatureNotFound {
package: pkg_name.to_owned(),
feature: feature_name.to_owned(),
candidates,
});
}
let implies = if feature_name == cabin_core::DEFAULT_FEATURE_KEY {
package.features.default.clone()
} else {
package
.features
.features
.get(feature_name)
.cloned()
.unwrap_or_default()
};
let enabled = feature_resolution.is_some_and(|fv| fv.enabled.contains(feature_name));
let is_default = package.features.default.iter().any(|n| n == feature_name);
Ok(FeatureExplanation {
package: pkg_name.to_owned(),
feature: feature_name.to_owned(),
enabled,
implies,
is_default,
})
}
pub fn explain_build_config<'a, S: BuildHasher>(
configurations: &'a HashMap<usize, cabin_core::BuildConfiguration, S>,
graph: &PackageGraph,
name: &str,
) -> Result<&'a cabin_core::BuildConfiguration, ExplainError> {
let idx = locate_package(graph, name)?;
configurations
.get(&idx)
.ok_or_else(|| ExplainError::NoBuildConfiguration {
name: name.to_owned(),
})
}
pub fn render_explanation_human(exp: &Explanation) -> String {
use std::fmt::Write as _;
match exp {
Explanation::Package(p) => {
let mut out = String::new();
let _ = writeln!(
out,
"{} v{} ({})",
p.name,
p.version,
render_source_label(&p.source)
);
if p.is_selected_root {
out.push_str(" selected as a root package\n");
}
if p.paths.is_empty() {
out.push_str(" no dependency path from any selected root reaches this package\n");
} else {
out.push_str(" dependency paths from selected roots:\n");
for path in &p.paths {
out.push_str(" ");
for (i, step) in path.iter().enumerate() {
if i > 0 {
out.push_str(" -> ");
}
out.push_str(&step.name);
out.push(' ');
out.push('v');
out.push_str(&step.version);
if let Some(label) = step.edge_kind {
out.push_str(" [");
out.push_str(label);
out.push(']');
}
}
out.push('\n');
}
}
out
}
Explanation::Target(t) => {
let mut out = String::new();
let _ = writeln!(out, "{}:{} kind = {}", t.package, t.target, t.target_kind);
if !t.languages.is_empty() {
let _ = writeln!(out, " languages: {}", t.languages.join(", "));
}
if !t.deps.is_empty() {
let _ = writeln!(out, " deps: {}", t.deps.join(", "));
}
let _ = writeln!(
out,
" flags: buildable={}, test={}, dev-only={}",
t.is_buildable, t.is_test, t.is_dev_only
);
out
}
Explanation::Source(s) => {
let mut out = String::new();
let _ = writeln!(
out,
"{} v{} ({})",
s.name,
s.version,
render_source_label(&s.source)
);
if !s.source_replacements.is_empty() {
out.push_str(" active source-replacement entries:\n");
for entry in &s.source_replacements {
let _ = writeln!(out, " {entry}");
}
}
out
}
Explanation::Feature(f) => {
let mut out = String::new();
let _ = writeln!(
out,
"{}/{} enabled={}, default={}",
f.package, f.feature, f.enabled, f.is_default
);
if !f.implies.is_empty() {
let _ = writeln!(out, " implies: {}", f.implies.join(", "));
}
out
}
}
}
pub fn render_explanation_json(exp: &Explanation) -> serde_json::Value {
serde_json::to_value(exp).expect("Explanation is Serialize")
}
#[derive(Debug, Error)]
pub enum ExplainError {
#[error(
"package `{name}` was not found in the resolved graph; known packages: {}",
candidates.join(", ")
)]
PackageNotFound {
name: String,
candidates: Vec<String>,
},
#[error(
"package name `{name}` matches multiple packages with versions: {}",
versions.join(", ")
)]
AmbiguousPackageName { name: String, versions: Vec<String> },
#[error(
"target `{name}` was not found in the selected packages; available: {}",
candidates.join(", ")
)]
TargetNotFound {
name: String,
candidates: Vec<String>,
},
#[error(
"target name `{name}` is ambiguous; declared by packages: {}",
owners.join(", ")
)]
AmbiguousTargetName { name: String, owners: Vec<String> },
#[error(
"feature query `{query}` must use the `package/feature` form (use `default` to ask about the default feature group)"
)]
InvalidFeatureQuery { query: String },
#[error(
"feature `{feature}` was not declared by package `{package}`; available: {}",
candidates.join(", ")
)]
FeatureNotFound {
package: String,
feature: String,
candidates: Vec<String>,
},
#[error(
"no build configuration was resolved for package `{name}`; check the workspace selection"
)]
NoBuildConfiguration { name: String },
}
pub mod cabin_feature_per_package_view {
use std::collections::BTreeSet;
pub struct FeatureView {
pub enabled: BTreeSet<String>,
}
}
#[cfg(test)]
mod tests {
use super::*;
use cabin_core::{Dependency, DependencyKind, DependencySource, Package, PackageName};
use cabin_workspace::{DependencyEdge, PackageGraph, PackageKind, WorkspacePackage};
fn pkg_name(s: &str) -> PackageName {
PackageName::new(s.to_owned()).unwrap()
}
fn make_pkg(name: &str, version: &str, deps: &[(&str, DependencyKind)]) -> WorkspacePackage {
let package = Package::new(
pkg_name(name),
semver::Version::parse(version).unwrap(),
Vec::new(),
deps.iter()
.map(|(n, k)| Dependency {
name: pkg_name(n),
source: DependencySource::Path(PathBuf::from(format!("../{n}"))),
kind: *k,
optional: false,
features: Vec::new(),
default_features: true,
condition: None,
})
.collect(),
)
.unwrap();
WorkspacePackage {
package,
manifest_path: PathBuf::from(format!("/abs/{name}/cabin.toml")),
manifest_dir: PathBuf::from(format!("/abs/{name}")),
deps: Vec::new(),
kind: PackageKind::Local,
}
}
fn three_pkg_graph() -> PackageGraph {
let mut app = make_pkg("app", "0.1.0", &[("lib", DependencyKind::Normal)]);
let mut lib = make_pkg("lib", "0.2.0", &[("util", DependencyKind::Normal)]);
let util = make_pkg("util", "0.3.0", &[]);
app.deps = vec![DependencyEdge {
index: 1,
kind: DependencyKind::Normal,
condition: None,
}];
lib.deps = vec![DependencyEdge {
index: 2,
kind: DependencyKind::Normal,
condition: None,
}];
let packages = vec![app, lib, util];
PackageGraph {
root_manifest_path: PathBuf::from("/abs/app/cabin.toml"),
root_dir: PathBuf::from("/abs/app"),
is_workspace_root: false,
root_package: Some(0),
root_settings: Default::default(),
primary_packages: vec![0],
default_members: Vec::new(),
excluded_members: Vec::new(),
packages,
}
}
#[test]
fn build_tree_orders_children_by_kind_then_name() {
let graph = three_pkg_graph();
let forest = build_tree(&TreeInputs {
graph: &graph,
roots: &[],
lockfile: None,
active_patches: None,
kind_filter: None,
});
assert_eq!(forest.len(), 1);
let root = &forest[0];
assert_eq!(root.name, "app");
let kinds: Vec<&'static str> = root.children.iter().map(|c| c.edge_kind.unwrap()).collect();
assert_eq!(kinds, vec!["normal"]);
assert_eq!(root.children[0].children[0].name, "util");
}
#[test]
fn build_tree_filters_by_dependency_kind() {
let graph = three_pkg_graph();
let forest = build_tree(&TreeInputs {
graph: &graph,
roots: &[],
lockfile: None,
active_patches: None,
kind_filter: Some(DependencyKind::Normal),
});
let root = &forest[0];
assert_eq!(root.children.len(), 1);
assert_eq!(root.children[0].name, "lib");
}
#[test]
fn render_tree_human_is_deterministic_and_uses_box_chars() {
let graph = three_pkg_graph();
let forest = build_tree(&TreeInputs {
graph: &graph,
roots: &[],
lockfile: None,
active_patches: None,
kind_filter: None,
});
let a = render_tree_human(&forest);
let b = render_tree_human(&forest);
assert_eq!(a, b, "render must be deterministic");
assert!(a.contains("app v0.1.0"));
assert!(a.contains("lib v0.2.0 [normal]"));
assert!(a.contains("└── util"));
}
#[test]
fn explain_package_returns_dep_path_from_root() {
let graph = three_pkg_graph();
let exp = explain_package(&graph, &[0], "util", None, None).unwrap();
assert_eq!(exp.name, "util");
assert!(!exp.is_selected_root);
assert_eq!(exp.paths.len(), 1);
let path = &exp.paths[0];
assert_eq!(
path.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
vec!["app", "lib", "util"]
);
assert_eq!(path[1].edge_kind, Some("normal"));
assert_eq!(path[2].edge_kind, Some("normal"));
}
#[test]
fn explain_package_marks_selected_root() {
let graph = three_pkg_graph();
let exp = explain_package(&graph, &[0], "app", None, None).unwrap();
assert!(exp.is_selected_root);
assert_eq!(exp.paths.len(), 1);
assert_eq!(exp.paths[0].len(), 1);
}
#[test]
fn explain_package_returns_actionable_error_for_unknown_name() {
let graph = three_pkg_graph();
let err = explain_package(&graph, &[0], "missing", None, None).unwrap_err();
match err {
ExplainError::PackageNotFound { name, candidates } => {
assert_eq!(name, "missing");
assert!(candidates.contains(&"app".to_owned()));
assert!(candidates.contains(&"lib".to_owned()));
}
other => panic!("expected PackageNotFound, got {other:?}"),
}
}
#[test]
fn explain_target_returns_owning_package_and_kind_flags() {
let graph = three_pkg_graph();
let mut graph = graph;
let target = cabin_core::Target {
name: cabin_core::TargetName::new("util").unwrap(),
kind: cabin_core::TargetKind::Library,
sources: vec![PathBuf::from("src/util.c"), PathBuf::from("src/util.cc")],
include_dirs: Vec::new(),
defines: Vec::new(),
deps: Vec::new(),
};
graph.packages[2].package.targets.push(target);
let exp = explain_target(&graph, &[2], "util").unwrap();
assert_eq!(exp.package, "util");
assert_eq!(exp.target, "util");
assert_eq!(exp.target_kind, "library");
assert_eq!(exp.languages, vec!["c".to_owned(), "cxx".to_owned()]);
assert!(exp.is_buildable);
assert!(!exp.is_test);
assert!(!exp.is_dev_only);
}
#[test]
fn explain_target_unknown_lists_available_candidates() {
let mut graph = three_pkg_graph();
let lib_target = cabin_core::Target {
name: cabin_core::TargetName::new("lib_lib").unwrap(),
kind: cabin_core::TargetKind::Library,
sources: vec![PathBuf::from("src/lib.cc")],
include_dirs: Vec::new(),
defines: Vec::new(),
deps: Vec::new(),
};
graph.packages[1].package.targets.push(lib_target);
let err = explain_target(&graph, &[1], "missing").unwrap_err();
match err {
ExplainError::TargetNotFound { name, candidates } => {
assert_eq!(name, "missing");
assert_eq!(candidates, vec!["lib_lib".to_owned()]);
}
other => panic!("expected TargetNotFound, got {other:?}"),
}
}
#[test]
fn explain_feature_invalid_query_form_is_rejected() {
let graph = three_pkg_graph();
let err = explain_feature(&graph, None, "noseparator").unwrap_err();
assert!(matches!(err, ExplainError::InvalidFeatureQuery { .. }));
}
}