1#![allow(clippy::default_trait_access)]
60
61use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
62use std::hash::BuildHasher;
63use std::path::PathBuf;
64
65use cabin_core::DependencyKind;
66use cabin_lockfile::Lockfile;
67use cabin_workspace::{PackageGraph, PackageKind, WorkspacePackage};
68use serde::Serialize;
69use thiserror::Error;
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
80#[serde(rename_all = "kebab-case", tag = "kind")]
81pub enum SourceProvenance {
82 WorkspaceMember,
85 LocalPath,
88 Patched {
92 path: PathBuf,
94 provenance: String,
97 },
98 Registry {
102 #[serde(skip_serializing_if = "Option::is_none")]
106 checksum: Option<String>,
107 },
108}
109
110#[derive(Debug, Clone, Serialize)]
115pub struct TreeNode {
116 pub name: String,
118 pub version: String,
120 #[serde(skip_serializing_if = "Option::is_none")]
123 pub edge_kind: Option<&'static str>,
124 pub source: SourceProvenance,
126 #[serde(skip_serializing_if = "is_false")]
130 pub repeated: bool,
131 pub children: Vec<TreeNode>,
133}
134
135fn is_false<T>(value: &T) -> bool
136where
137 T: PartialEq + Default,
138{
139 *value == T::default()
140}
141
142pub struct TreeInputs<'a> {
147 pub graph: &'a PackageGraph,
149 pub roots: &'a [usize],
154 pub lockfile: Option<&'a Lockfile>,
157 pub active_patches: Option<&'a cabin_workspace::ActivePatchSet>,
161 pub kind_filter: Option<DependencyKind>,
164}
165
166pub fn build_tree(inputs: &TreeInputs<'_>) -> Vec<TreeNode> {
171 let roots: Vec<usize> = if inputs.roots.is_empty() {
172 inputs.graph.primary_packages.clone()
173 } else {
174 let mut owned = inputs.roots.to_vec();
175 owned.sort_by(|a, b| {
176 inputs.graph.packages[*a]
177 .package
178 .name
179 .as_str()
180 .cmp(inputs.graph.packages[*b].package.name.as_str())
181 });
182 owned.dedup();
183 owned
184 };
185 let mut out: Vec<TreeNode> = roots
186 .iter()
187 .map(|&idx| {
188 let mut visited: HashSet<usize> = HashSet::new();
189 build_node(idx, None, inputs, &mut visited)
190 })
191 .collect();
192 out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.version.cmp(&b.version)));
193 out
194}
195
196fn build_node(
197 idx: usize,
198 edge_kind: Option<DependencyKind>,
199 inputs: &TreeInputs<'_>,
200 visited: &mut HashSet<usize>,
201) -> TreeNode {
202 let pkg = &inputs.graph.packages[idx];
203 let name = pkg.package.name.as_str().to_owned();
204 let version = pkg.package.version.to_string();
205 let source = source_provenance_for(pkg, inputs);
206 let edge_kind_label = edge_kind.map(dep_kind_key);
207
208 let already_visited = !visited.insert(idx);
209 if already_visited {
210 return TreeNode {
211 name,
212 version,
213 edge_kind: edge_kind_label,
214 source,
215 repeated: true,
216 children: Vec::new(),
217 };
218 }
219
220 let mut children: Vec<TreeNode> = Vec::new();
221 for edge in &pkg.deps {
222 if let Some(filter) = inputs.kind_filter
223 && edge.kind != filter
224 {
225 continue;
226 }
227 children.push(build_node(edge.index, Some(edge.kind), inputs, visited));
228 }
229 children.sort_by(|a, b| {
230 edge_kind_sort_key(a.edge_kind)
231 .cmp(&edge_kind_sort_key(b.edge_kind))
232 .then_with(|| a.name.cmp(&b.name))
233 .then_with(|| a.version.cmp(&b.version))
234 });
235
236 TreeNode {
237 name,
238 version,
239 edge_kind: edge_kind_label,
240 source,
241 repeated: false,
242 children,
243 }
244}
245
246fn dep_kind_key(kind: DependencyKind) -> &'static str {
247 kind.as_str()
248}
249
250fn edge_kind_sort_key(label: Option<&'static str>) -> u8 {
251 match label {
255 None => 0,
256 Some("normal") => 1,
257 Some("build") => 2,
258 Some("dev") => 3,
259 Some(_) => 99,
260 }
261}
262
263fn source_provenance_for(pkg: &WorkspacePackage, inputs: &TreeInputs<'_>) -> SourceProvenance {
264 if let Some(set) = inputs.active_patches
265 && let Some(active) = set.get(&pkg.package.name)
266 {
267 return SourceProvenance::Patched {
268 path: active.manifest_dir.clone(),
269 provenance: active.provenance.as_key(),
270 };
271 }
272 match pkg.kind {
273 PackageKind::Local => {
274 if inputs
278 .graph
279 .index_of(pkg.package.name.as_str())
280 .is_some_and(|idx| inputs.graph.primary_packages.contains(&idx))
281 {
282 SourceProvenance::WorkspaceMember
283 } else {
284 SourceProvenance::LocalPath
285 }
286 }
287 PackageKind::Registry => {
288 let checksum = inputs
289 .lockfile
290 .and_then(|lock| lock.find(&pkg.package.name))
291 .and_then(|locked| {
292 if locked.version == pkg.package.version {
293 locked.checksum.clone()
294 } else {
295 None
296 }
297 });
298 SourceProvenance::Registry { checksum }
299 }
300 }
301}
302
303pub fn render_tree_human(forest: &[TreeNode]) -> String {
312 let mut out = String::new();
313 for (i, node) in forest.iter().enumerate() {
314 if i > 0 {
315 out.push('\n');
316 }
317 render_human_node(&mut out, node, "", true, true);
318 }
319 out
320}
321
322fn render_human_node(
323 out: &mut String,
324 node: &TreeNode,
325 prefix: &str,
326 is_last: bool,
327 is_root: bool,
328) {
329 let connector = if is_root {
330 ""
331 } else if is_last {
332 "└── "
333 } else {
334 "├── "
335 };
336 out.push_str(prefix);
337 out.push_str(connector);
338 out.push_str(&node.name);
339 out.push(' ');
340 out.push('v');
341 out.push_str(&node.version);
342 if let Some(label) = node.edge_kind {
343 out.push_str(" [");
344 out.push_str(label);
345 out.push(']');
346 }
347 out.push(' ');
348 out.push('(');
349 out.push_str(&render_source_label(&node.source));
350 out.push(')');
351 if node.repeated {
352 out.push_str(" (*)");
353 }
354 out.push('\n');
355 let child_prefix = if is_root {
356 String::new()
357 } else if is_last {
358 format!("{prefix} ")
359 } else {
360 format!("{prefix}│ ")
361 };
362 let count = node.children.len();
363 for (i, child) in node.children.iter().enumerate() {
364 render_human_node(out, child, &child_prefix, i + 1 == count, false);
365 }
366}
367
368fn render_source_label(source: &SourceProvenance) -> String {
369 match source {
370 SourceProvenance::WorkspaceMember => "workspace".to_owned(),
371 SourceProvenance::LocalPath => "local path".to_owned(),
372 SourceProvenance::Patched { provenance, .. } => format!("patched via {provenance}"),
373 SourceProvenance::Registry { checksum: Some(c) } => format!("registry, {c}"),
374 SourceProvenance::Registry { checksum: None } => "registry".to_owned(),
375 }
376}
377
378pub fn render_tree_json(forest: &[TreeNode]) -> serde_json::Value {
385 serde_json::Value::Array(
386 forest
387 .iter()
388 .map(|n| serde_json::to_value(n).expect("TreeNode is Serialize"))
389 .collect(),
390 )
391}
392
393#[derive(Debug, Clone, Serialize)]
398#[serde(rename_all = "kebab-case", tag = "kind")]
399pub enum Explanation {
400 Package(PackageExplanation),
402 Target(TargetExplanation),
404 Source(SourceExplanation),
406 Feature(FeatureExplanation),
408}
409
410#[derive(Debug, Clone, Serialize)]
413pub struct PackageExplanation {
414 pub name: String,
415 pub version: String,
416 pub source: SourceProvenance,
417 pub paths: Vec<Vec<ExplainStep>>,
423 pub is_selected_root: bool,
425}
426
427#[derive(Debug, Clone, Serialize)]
429pub struct ExplainStep {
430 pub name: String,
431 pub version: String,
432 #[serde(skip_serializing_if = "Option::is_none")]
435 pub edge_kind: Option<&'static str>,
436}
437
438#[derive(Debug, Clone, Serialize)]
442pub struct TargetExplanation {
443 pub package: String,
444 pub target: String,
445 #[serde(rename = "target_kind")]
450 pub target_kind: String,
451 pub languages: Vec<String>,
454 pub deps: Vec<String>,
458 pub is_buildable: bool,
464 pub is_test: bool,
466 pub is_dev_only: bool,
468}
469
470#[derive(Debug, Clone, Serialize)]
472pub struct SourceExplanation {
473 pub name: String,
474 pub version: String,
475 pub source: SourceProvenance,
476 pub source_replacements: Vec<String>,
482}
483
484#[derive(Debug, Clone, Serialize)]
487pub struct FeatureExplanation {
488 pub package: String,
489 pub feature: String,
490 pub enabled: bool,
491 pub implies: Vec<String>,
493 pub is_default: bool,
496}
497
498pub fn explain_package(
511 graph: &PackageGraph,
512 roots: &[usize],
513 name: &str,
514 active_patches: Option<&cabin_workspace::ActivePatchSet>,
515 lockfile: Option<&Lockfile>,
516) -> Result<PackageExplanation, ExplainError> {
517 let target_idx = locate_package(graph, name)?;
518 let pkg = &graph.packages[target_idx];
519 let inputs = TreeInputs {
520 graph,
521 roots,
522 lockfile,
523 active_patches,
524 kind_filter: None,
525 };
526 let source = source_provenance_for(pkg, &inputs);
527
528 let effective_roots: Vec<usize> = if roots.is_empty() {
529 graph.primary_packages.clone()
530 } else {
531 roots.to_vec()
532 };
533 let is_selected_root = effective_roots.contains(&target_idx);
534
535 let mut paths: Vec<Vec<ExplainStep>> = Vec::new();
536 for &root in &effective_roots {
537 for path in shortest_paths_to(graph, root, target_idx) {
538 paths.push(materialize_path(graph, &path));
539 }
540 }
541 paths.sort_by(|a, b| {
542 a.len()
543 .cmp(&b.len())
544 .then_with(|| join_path_names(a).cmp(&join_path_names(b)))
545 });
546 paths.dedup_by(|a, b| {
547 a.len() == b.len()
548 && a.iter()
549 .zip(b.iter())
550 .all(|(x, y)| x.name == y.name && x.version == y.version)
551 });
552
553 Ok(PackageExplanation {
554 name: pkg.package.name.as_str().to_owned(),
555 version: pkg.package.version.to_string(),
556 source,
557 paths,
558 is_selected_root,
559 })
560}
561
562fn join_path_names(steps: &[ExplainStep]) -> String {
563 steps
564 .iter()
565 .map(|s| s.name.as_str())
566 .collect::<Vec<_>>()
567 .join(" -> ")
568}
569
570fn locate_package(graph: &PackageGraph, name: &str) -> Result<usize, ExplainError> {
571 let matches: Vec<usize> = graph
572 .packages
573 .iter()
574 .enumerate()
575 .filter(|(_, p)| p.package.name.as_str() == name)
576 .map(|(i, _)| i)
577 .collect();
578 match matches.len() {
579 0 => Err(ExplainError::PackageNotFound {
580 name: name.to_owned(),
581 candidates: known_package_names(graph),
582 }),
583 1 => Ok(matches[0]),
584 _ => {
585 let mut versions: Vec<String> = matches
586 .iter()
587 .map(|&i| graph.packages[i].package.version.to_string())
588 .collect();
589 versions.sort();
590 Err(ExplainError::AmbiguousPackageName {
591 name: name.to_owned(),
592 versions,
593 })
594 }
595 }
596}
597
598fn known_package_names(graph: &PackageGraph) -> Vec<String> {
604 let mut names: Vec<String> = graph
605 .packages
606 .iter()
607 .map(|p| p.package.name.as_str().to_owned())
608 .collect();
609 names.sort();
610 names
611 .into_iter()
612 .filter(|n| !n.is_empty())
613 .take(10)
614 .collect()
615}
616
617fn shortest_paths_to(graph: &PackageGraph, start: usize, target: usize) -> Vec<Vec<usize>> {
622 if start == target {
623 return vec![vec![start]];
624 }
625 let mut depth: BTreeMap<usize, usize> = BTreeMap::new();
628 let mut parents: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
629 depth.insert(start, 0);
630 let mut frontier: Vec<usize> = vec![start];
631 let mut found: bool = false;
632 let mut level = 0usize;
633 while !frontier.is_empty() && !found {
634 let mut next: Vec<usize> = Vec::new();
635 for &node in &frontier {
636 for edge in &graph.packages[node].deps {
637 let child = edge.index;
638 let new_depth = level + 1;
639 if let Some(&existing) = depth.get(&child) {
640 if existing == new_depth {
641 parents.entry(child).or_default().push(node);
642 }
643 continue;
644 }
645 depth.insert(child, new_depth);
646 parents.entry(child).or_default().push(node);
647 if child == target {
648 found = true;
649 }
650 next.push(child);
651 }
652 }
653 frontier = next;
654 level += 1;
655 }
656 if !depth.contains_key(&target) {
657 return Vec::new();
658 }
659 let mut paths: Vec<Vec<usize>> = vec![vec![target]];
662 loop {
663 let mut next: Vec<Vec<usize>> = Vec::new();
664 let mut grew = false;
665 for path in &paths {
666 let head = *path.first().expect("path is non-empty");
667 if head == start {
668 next.push(path.clone());
669 continue;
670 }
671 let Some(parent_list) = parents.get(&head) else {
672 continue;
673 };
674 for &p in parent_list {
675 let mut extended = vec![p];
676 extended.extend(path.iter().copied());
677 next.push(extended);
678 grew = true;
679 }
680 }
681 paths = next;
682 if !grew {
683 break;
684 }
685 }
686 paths
687 .into_iter()
688 .filter(|p| p.first().copied() == Some(start))
689 .collect()
690}
691
692fn materialize_path(graph: &PackageGraph, path: &[usize]) -> Vec<ExplainStep> {
693 let mut out: Vec<ExplainStep> = Vec::with_capacity(path.len());
694 for (i, &idx) in path.iter().enumerate() {
695 let pkg = &graph.packages[idx];
696 let edge_kind = if i == 0 {
697 None
698 } else {
699 let parent = &graph.packages[path[i - 1]];
700 parent
701 .deps
702 .iter()
703 .find(|e| e.index == idx)
704 .map(|e| dep_kind_key(e.kind))
705 };
706 out.push(ExplainStep {
707 name: pkg.package.name.as_str().to_owned(),
708 version: pkg.package.version.to_string(),
709 edge_kind,
710 });
711 }
712 out
713}
714
715pub fn explain_target(
727 graph: &PackageGraph,
728 selected_packages: &[usize],
729 target_name: &str,
730) -> Result<TargetExplanation, ExplainError> {
731 let pool: Vec<usize> = if selected_packages.is_empty() {
732 (0..graph.packages.len()).collect()
733 } else {
734 selected_packages.to_vec()
735 };
736 let mut hits: Vec<(usize, &cabin_core::Target)> = Vec::new();
737 for idx in &pool {
738 let pkg = &graph.packages[*idx];
739 for target in &pkg.package.targets {
740 if target.name.as_str() == target_name {
741 hits.push((*idx, target));
742 }
743 }
744 }
745 if hits.is_empty() {
746 let mut candidates: BTreeSet<String> = BTreeSet::new();
747 for idx in &pool {
748 for target in &graph.packages[*idx].package.targets {
749 candidates.insert(target.name.as_str().to_owned());
750 }
751 }
752 return Err(ExplainError::TargetNotFound {
753 name: target_name.to_owned(),
754 candidates: candidates.into_iter().collect(),
755 });
756 }
757 if hits.len() > 1 {
758 let owners: Vec<String> = hits
759 .iter()
760 .map(|(idx, _)| graph.packages[*idx].package.name.as_str().to_owned())
761 .collect();
762 return Err(ExplainError::AmbiguousTargetName {
763 name: target_name.to_owned(),
764 owners,
765 });
766 }
767 let (pkg_idx, target) = hits[0];
768 let pkg = &graph.packages[pkg_idx];
769 let mut languages: BTreeSet<&'static str> = BTreeSet::new();
770 for source in &target.sources {
771 if let Some(lang) = cabin_core::classify_source(source) {
772 languages.insert(lang.as_key());
773 }
774 }
775 let kind = target.kind;
776 Ok(TargetExplanation {
777 package: pkg.package.name.as_str().to_owned(),
778 target: target.name.as_str().to_owned(),
779 target_kind: kind.as_str().to_owned(),
780 languages: languages.into_iter().map(str::to_owned).collect(),
781 deps: target.deps.clone(),
782 is_buildable: kind.produces_archive() || kind.produces_executable(),
786 is_test: kind.is_test(),
787 is_dev_only: kind.is_dev_only(),
788 })
789}
790
791pub fn explain_source(
798 graph: &PackageGraph,
799 name: &str,
800 active_patches: Option<&cabin_workspace::ActivePatchSet>,
801 lockfile: Option<&Lockfile>,
802 source_replacements: &cabin_core::SourceReplacementSettings,
803) -> Result<SourceExplanation, ExplainError> {
804 let idx = locate_package(graph, name)?;
805 let pkg = &graph.packages[idx];
806 let inputs = TreeInputs {
807 graph,
808 roots: &[],
809 lockfile,
810 active_patches,
811 kind_filter: None,
812 };
813 let source = source_provenance_for(pkg, &inputs);
814 let mut replacements: Vec<String> = source_replacements
815 .entries
816 .values()
817 .map(|entry| {
818 format!(
819 "{} -> {} ({})",
820 entry.original.display(),
821 entry.replacement.display(),
822 entry.provenance.as_key()
823 )
824 })
825 .collect();
826 replacements.sort();
827 Ok(SourceExplanation {
828 name: pkg.package.name.as_str().to_owned(),
829 version: pkg.package.version.to_string(),
830 source,
831 source_replacements: replacements,
832 })
833}
834
835pub fn explain_feature(
847 graph: &PackageGraph,
848 feature_resolution: Option<&cabin_feature_per_package_view::FeatureView>,
849 query: &str,
850) -> Result<FeatureExplanation, ExplainError> {
851 let (pkg_name, feature_name) =
852 query
853 .split_once('/')
854 .ok_or_else(|| ExplainError::InvalidFeatureQuery {
855 query: query.to_owned(),
856 })?;
857 let idx = locate_package(graph, pkg_name)?;
858 let pkg = &graph.packages[idx];
859 let package = &pkg.package;
860 if !package.features.features.contains_key(feature_name)
861 && feature_name != cabin_core::DEFAULT_FEATURE_KEY
862 {
863 let mut candidates: Vec<String> = package.features.features.keys().cloned().collect();
864 candidates.sort();
865 return Err(ExplainError::FeatureNotFound {
866 package: pkg_name.to_owned(),
867 feature: feature_name.to_owned(),
868 candidates,
869 });
870 }
871 let implies = if feature_name == cabin_core::DEFAULT_FEATURE_KEY {
872 package.features.default.clone()
873 } else {
874 package
875 .features
876 .features
877 .get(feature_name)
878 .cloned()
879 .unwrap_or_default()
880 };
881 let enabled = feature_resolution.is_some_and(|fv| fv.enabled.contains(feature_name));
882 let is_default = package.features.default.iter().any(|n| n == feature_name);
883 Ok(FeatureExplanation {
884 package: pkg_name.to_owned(),
885 feature: feature_name.to_owned(),
886 enabled,
887 implies,
888 is_default,
889 })
890}
891
892pub fn explain_build_config<'a, S: BuildHasher>(
904 configurations: &'a HashMap<usize, cabin_core::BuildConfiguration, S>,
905 graph: &PackageGraph,
906 name: &str,
907) -> Result<&'a cabin_core::BuildConfiguration, ExplainError> {
908 let idx = locate_package(graph, name)?;
909 configurations
910 .get(&idx)
911 .ok_or_else(|| ExplainError::NoBuildConfiguration {
912 name: name.to_owned(),
913 })
914}
915
916pub fn render_explanation_human(exp: &Explanation) -> String {
919 use std::fmt::Write as _;
920 match exp {
921 Explanation::Package(p) => {
922 let mut out = String::new();
923 let _ = writeln!(
924 out,
925 "{} v{} ({})",
926 p.name,
927 p.version,
928 render_source_label(&p.source)
929 );
930 if p.is_selected_root {
931 out.push_str(" selected as a root package\n");
932 }
933 if p.paths.is_empty() {
934 out.push_str(" no dependency path from any selected root reaches this package\n");
935 } else {
936 out.push_str(" dependency paths from selected roots:\n");
937 for path in &p.paths {
938 out.push_str(" ");
939 for (i, step) in path.iter().enumerate() {
940 if i > 0 {
941 out.push_str(" -> ");
942 }
943 out.push_str(&step.name);
944 out.push(' ');
945 out.push('v');
946 out.push_str(&step.version);
947 if let Some(label) = step.edge_kind {
948 out.push_str(" [");
949 out.push_str(label);
950 out.push(']');
951 }
952 }
953 out.push('\n');
954 }
955 }
956 out
957 }
958 Explanation::Target(t) => {
959 let mut out = String::new();
960 let _ = writeln!(out, "{}:{} kind = {}", t.package, t.target, t.target_kind);
961 if !t.languages.is_empty() {
962 let _ = writeln!(out, " languages: {}", t.languages.join(", "));
963 }
964 if !t.deps.is_empty() {
965 let _ = writeln!(out, " deps: {}", t.deps.join(", "));
966 }
967 let _ = writeln!(
968 out,
969 " flags: buildable={}, test={}, dev-only={}",
970 t.is_buildable, t.is_test, t.is_dev_only
971 );
972 out
973 }
974 Explanation::Source(s) => {
975 let mut out = String::new();
976 let _ = writeln!(
977 out,
978 "{} v{} ({})",
979 s.name,
980 s.version,
981 render_source_label(&s.source)
982 );
983 if !s.source_replacements.is_empty() {
984 out.push_str(" active source-replacement entries:\n");
985 for entry in &s.source_replacements {
986 let _ = writeln!(out, " {entry}");
987 }
988 }
989 out
990 }
991 Explanation::Feature(f) => {
992 let mut out = String::new();
993 let _ = writeln!(
994 out,
995 "{}/{} enabled={}, default={}",
996 f.package, f.feature, f.enabled, f.is_default
997 );
998 if !f.implies.is_empty() {
999 let _ = writeln!(out, " implies: {}", f.implies.join(", "));
1000 }
1001 out
1002 }
1003 }
1004}
1005
1006pub fn render_explanation_json(exp: &Explanation) -> serde_json::Value {
1013 serde_json::to_value(exp).expect("Explanation is Serialize")
1014}
1015
1016#[derive(Debug, Error)]
1019pub enum ExplainError {
1020 #[error(
1022 "package `{name}` was not found in the resolved graph; known packages: {}",
1023 candidates.join(", ")
1024 )]
1025 PackageNotFound {
1026 name: String,
1027 candidates: Vec<String>,
1028 },
1029 #[error(
1034 "package name `{name}` matches multiple packages with versions: {}",
1035 versions.join(", ")
1036 )]
1037 AmbiguousPackageName { name: String, versions: Vec<String> },
1038 #[error(
1041 "target `{name}` was not found in the selected packages; available: {}",
1042 candidates.join(", ")
1043 )]
1044 TargetNotFound {
1045 name: String,
1046 candidates: Vec<String>,
1047 },
1048 #[error(
1051 "target name `{name}` is ambiguous; declared by packages: {}",
1052 owners.join(", ")
1053 )]
1054 AmbiguousTargetName { name: String, owners: Vec<String> },
1055 #[error(
1058 "feature query `{query}` must use the `package/feature` form (use `default` to ask about the default feature group)"
1059 )]
1060 InvalidFeatureQuery { query: String },
1061 #[error(
1063 "feature `{feature}` was not declared by package `{package}`; available: {}",
1064 candidates.join(", ")
1065 )]
1066 FeatureNotFound {
1067 package: String,
1068 feature: String,
1069 candidates: Vec<String>,
1070 },
1071 #[error(
1076 "no build configuration was resolved for package `{name}`; check the workspace selection"
1077 )]
1078 NoBuildConfiguration { name: String },
1079}
1080
1081pub mod cabin_feature_per_package_view {
1090 use std::collections::BTreeSet;
1091
1092 pub struct FeatureView {
1094 pub enabled: BTreeSet<String>,
1098 }
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103 use super::*;
1104 use cabin_core::{Dependency, DependencyKind, DependencySource, Package, PackageName};
1105 use cabin_workspace::{DependencyEdge, PackageGraph, PackageKind, WorkspacePackage};
1106
1107 fn pkg_name(s: &str) -> PackageName {
1108 PackageName::new(s.to_owned()).unwrap()
1109 }
1110
1111 fn make_pkg(name: &str, version: &str, deps: &[(&str, DependencyKind)]) -> WorkspacePackage {
1112 let package = Package::new(
1113 pkg_name(name),
1114 semver::Version::parse(version).unwrap(),
1115 Vec::new(),
1116 deps.iter()
1117 .map(|(n, k)| Dependency {
1118 name: pkg_name(n),
1119 source: DependencySource::Path(PathBuf::from(format!("../{n}"))),
1120 kind: *k,
1121 optional: false,
1122 features: Vec::new(),
1123 default_features: true,
1124 condition: None,
1125 })
1126 .collect(),
1127 )
1128 .unwrap();
1129 WorkspacePackage {
1130 package,
1131 manifest_path: PathBuf::from(format!("/abs/{name}/cabin.toml")),
1132 manifest_dir: PathBuf::from(format!("/abs/{name}")),
1133 deps: Vec::new(),
1134 kind: PackageKind::Local,
1135 }
1136 }
1137
1138 fn three_pkg_graph() -> PackageGraph {
1139 let mut app = make_pkg("app", "0.1.0", &[("lib", DependencyKind::Normal)]);
1142 let mut lib = make_pkg("lib", "0.2.0", &[("util", DependencyKind::Normal)]);
1143 let util = make_pkg("util", "0.3.0", &[]);
1144 app.deps = vec![DependencyEdge {
1145 index: 1,
1146 kind: DependencyKind::Normal,
1147 condition: None,
1148 }];
1149 lib.deps = vec![DependencyEdge {
1150 index: 2,
1151 kind: DependencyKind::Normal,
1152 condition: None,
1153 }];
1154 let packages = vec![app, lib, util];
1155 PackageGraph {
1156 root_manifest_path: PathBuf::from("/abs/app/cabin.toml"),
1157 root_dir: PathBuf::from("/abs/app"),
1158 is_workspace_root: false,
1159 root_package: Some(0),
1160 root_settings: Default::default(),
1161 primary_packages: vec![0],
1162 default_members: Vec::new(),
1163 excluded_members: Vec::new(),
1164 packages,
1165 }
1166 }
1167
1168 #[test]
1169 fn build_tree_orders_children_by_kind_then_name() {
1170 let graph = three_pkg_graph();
1171 let forest = build_tree(&TreeInputs {
1172 graph: &graph,
1173 roots: &[],
1174 lockfile: None,
1175 active_patches: None,
1176 kind_filter: None,
1177 });
1178 assert_eq!(forest.len(), 1);
1179 let root = &forest[0];
1180 assert_eq!(root.name, "app");
1181 let kinds: Vec<&'static str> = root.children.iter().map(|c| c.edge_kind.unwrap()).collect();
1182 assert_eq!(kinds, vec!["normal"]);
1183 assert_eq!(root.children[0].children[0].name, "util");
1185 }
1186
1187 #[test]
1188 fn build_tree_filters_by_dependency_kind() {
1189 let graph = three_pkg_graph();
1190 let forest = build_tree(&TreeInputs {
1191 graph: &graph,
1192 roots: &[],
1193 lockfile: None,
1194 active_patches: None,
1195 kind_filter: Some(DependencyKind::Normal),
1196 });
1197 let root = &forest[0];
1198 assert_eq!(root.children.len(), 1);
1199 assert_eq!(root.children[0].name, "lib");
1200 }
1201
1202 #[test]
1203 fn render_tree_human_is_deterministic_and_uses_box_chars() {
1204 let graph = three_pkg_graph();
1205 let forest = build_tree(&TreeInputs {
1206 graph: &graph,
1207 roots: &[],
1208 lockfile: None,
1209 active_patches: None,
1210 kind_filter: None,
1211 });
1212 let a = render_tree_human(&forest);
1213 let b = render_tree_human(&forest);
1214 assert_eq!(a, b, "render must be deterministic");
1215 assert!(a.contains("app v0.1.0"));
1216 assert!(a.contains("lib v0.2.0 [normal]"));
1217 assert!(a.contains("└── util"));
1220 }
1221
1222 #[test]
1223 fn explain_package_returns_dep_path_from_root() {
1224 let graph = three_pkg_graph();
1225 let exp = explain_package(&graph, &[0], "util", None, None).unwrap();
1226 assert_eq!(exp.name, "util");
1227 assert!(!exp.is_selected_root);
1228 assert_eq!(exp.paths.len(), 1);
1229 let path = &exp.paths[0];
1230 assert_eq!(
1231 path.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
1232 vec!["app", "lib", "util"]
1233 );
1234 assert_eq!(path[1].edge_kind, Some("normal"));
1235 assert_eq!(path[2].edge_kind, Some("normal"));
1236 }
1237
1238 #[test]
1239 fn explain_package_marks_selected_root() {
1240 let graph = three_pkg_graph();
1241 let exp = explain_package(&graph, &[0], "app", None, None).unwrap();
1242 assert!(exp.is_selected_root);
1243 assert_eq!(exp.paths.len(), 1);
1245 assert_eq!(exp.paths[0].len(), 1);
1246 }
1247
1248 #[test]
1249 fn explain_package_returns_actionable_error_for_unknown_name() {
1250 let graph = three_pkg_graph();
1251 let err = explain_package(&graph, &[0], "missing", None, None).unwrap_err();
1252 match err {
1253 ExplainError::PackageNotFound { name, candidates } => {
1254 assert_eq!(name, "missing");
1255 assert!(candidates.contains(&"app".to_owned()));
1256 assert!(candidates.contains(&"lib".to_owned()));
1257 }
1258 other => panic!("expected PackageNotFound, got {other:?}"),
1259 }
1260 }
1261
1262 #[test]
1263 fn explain_target_returns_owning_package_and_kind_flags() {
1264 let graph = three_pkg_graph();
1265 let mut graph = graph;
1269 let target = cabin_core::Target {
1270 name: cabin_core::TargetName::new("util").unwrap(),
1271 kind: cabin_core::TargetKind::Library,
1272 sources: vec![PathBuf::from("src/util.c"), PathBuf::from("src/util.cc")],
1273 include_dirs: Vec::new(),
1274 defines: Vec::new(),
1275 deps: Vec::new(),
1276 };
1277 graph.packages[2].package.targets.push(target);
1278 let exp = explain_target(&graph, &[2], "util").unwrap();
1279 assert_eq!(exp.package, "util");
1280 assert_eq!(exp.target, "util");
1281 assert_eq!(exp.target_kind, "library");
1282 assert_eq!(exp.languages, vec!["c".to_owned(), "cxx".to_owned()]);
1283 assert!(exp.is_buildable);
1284 assert!(!exp.is_test);
1285 assert!(!exp.is_dev_only);
1286 }
1287
1288 #[test]
1289 fn explain_target_unknown_lists_available_candidates() {
1290 let mut graph = three_pkg_graph();
1291 let lib_target = cabin_core::Target {
1292 name: cabin_core::TargetName::new("lib_lib").unwrap(),
1293 kind: cabin_core::TargetKind::Library,
1294 sources: vec![PathBuf::from("src/lib.cc")],
1295 include_dirs: Vec::new(),
1296 defines: Vec::new(),
1297 deps: Vec::new(),
1298 };
1299 graph.packages[1].package.targets.push(lib_target);
1300 let err = explain_target(&graph, &[1], "missing").unwrap_err();
1301 match err {
1302 ExplainError::TargetNotFound { name, candidates } => {
1303 assert_eq!(name, "missing");
1304 assert_eq!(candidates, vec!["lib_lib".to_owned()]);
1305 }
1306 other => panic!("expected TargetNotFound, got {other:?}"),
1307 }
1308 }
1309
1310 #[test]
1311 fn explain_feature_invalid_query_form_is_rejected() {
1312 let graph = three_pkg_graph();
1313 let err = explain_feature(&graph, None, "noseparator").unwrap_err();
1314 assert!(matches!(err, ExplainError::InvalidFeatureQuery { .. }));
1315 }
1316}