Skip to main content

cabin_explain/
lib.rs

1//! Typed dependency-tree and explain models for `cabin tree` and
2//! `cabin explain`.
3//!
4//! `cabin metadata` already exposes the loaded package state as
5//! a deterministic JSON document. This crate adds two
6//! complementary, lower-bandwidth views on the same loaded
7//! `PackageGraph` + lockfile + active patch / source-replacement
8//! state:
9//!
10//! - [`build_tree`] returns a [`TreeNode`] showing every package
11//!   in the loaded [`PackageGraph`] reachable from the selected
12//!   primary packages,
13//!   with edges tagged by [`cabin_core::DependencyKind`] and
14//!   provenance pulled from the lockfile / active patch set.
15//!   Renderers ([`render_tree_human`] /
16//!   [`render_tree_json`]) turn the typed tree into either a
17//!   stable text drawing or a structured JSON document; the JSON
18//!   document shares its package shape with `cabin metadata`.
19//!
20//! - [`explain_package`] / [`explain_target`] /
21//!   [`explain_source`] / [`explain_feature`] /
22//!   [`explain_build_config`] each return a typed
23//!   [`Explanation`] answering "why is X selected", "where does
24//!   X come from", "which feature lit up X", and "what does the
25//!   build configuration look like for X". Each variant carries
26//!   only structured data so callers can render either a
27//!   human-readable summary ([`render_explanation_human`]) or a
28//!   stable JSON document ([`render_explanation_json`]).
29//!
30//! Crate boundaries:
31//! - this crate must not run the resolver, parse manifests, or
32//!   plan builds; it consumes the typed values the orchestration
33//!   layer hands it;
34//! - it must not perform I/O. The orchestration layer in
35//!   `cabin` is responsible for loading the workspace, the
36//!   lockfile, and the active patch set; this crate works
37//!   purely on those typed inputs;
38//! - it must not invent new identity for packages. Provenance
39//!   comes from `cabin_workspace::PackageKind`, the lockfile, the
40//!   patch set, and the source-replacement settings.
41//!
42//! ## Determinism contract
43//!
44//! Every output produced by this crate is deterministic across
45//! runs:
46//!
47//! - tree children are sorted by `(dependency_kind, package_name,
48//!   package_version)`;
49//! - explanation paths are sorted by `(length, joined name
50//!   sequence)`;
51//! - JSON keys are emitted in struct-declaration order;
52//! - paths surfaced through the API are *not* normalized here —
53//!   that is the orchestration layer's job.
54//!
55//! Anything that mutates the inputs is the orchestration layer's
56//! responsibility; this crate only reads them.
57
58// `root_settings: Default::default()` in a test graph fixture.
59#![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/// Provenance label for one node in a [`TreeNode`] or one
72/// step in an [`Explanation::Package`] chain.
73///
74/// The variants reflect the load-bearing distinctions Cabin
75/// already makes elsewhere: a workspace member, a local path
76/// dependency, a patched local working copy, a registry
77/// package the artifact pipeline materialized, or a vendored
78/// package supplied by `cabin vendor`.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
80#[serde(rename_all = "kebab-case", tag = "kind")]
81pub enum SourceProvenance {
82    /// A workspace member declared by the root manifest's
83    /// `[workspace.members]` table.
84    WorkspaceMember,
85    /// A local `path = "..."` dependency that lives outside the
86    /// workspace.
87    LocalPath,
88    /// A registry package that an active `[patch]` entry pinned
89    /// to a local working copy. The `path` is the patched
90    /// directory's `manifest_dir`.
91    Patched {
92        /// Filesystem path of the patched working copy.
93        path: PathBuf,
94        /// Origin layer of the patch (`manifest`, `user-config`,
95        /// `workspace-config`, etc.).
96        provenance: String,
97    },
98    /// A registry package whose source bytes were materialized
99    /// by the artifact pipeline. Carries the recorded checksum
100    /// when the lockfile pinned one.
101    Registry {
102        /// `sha256:<hex>` checksum recorded for this version, if
103        /// any. `None` when the lockfile predates checksum
104        /// recording.
105        #[serde(skip_serializing_if = "Option::is_none")]
106        checksum: Option<String>,
107    },
108}
109
110/// One node in a dependency tree rooted at a selected primary
111/// package. Children are deduplicated per traversal: the first
112/// occurrence of a `(name, version)` carries the full subtree;
113/// subsequent occurrences are marked with [`TreeNode::repeated`].
114#[derive(Debug, Clone, Serialize)]
115pub struct TreeNode {
116    /// `WorkspacePackage` name.
117    pub name: String,
118    /// Resolved package version.
119    pub version: String,
120    /// How the parent reached this node. `None` for the tree's
121    /// roots.
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub edge_kind: Option<&'static str>,
124    /// Provenance of this package's source bytes.
125    pub source: SourceProvenance,
126    /// `true` when this node was already expanded earlier in
127    /// the traversal — children were pruned to keep the tree
128    /// finite.
129    #[serde(skip_serializing_if = "is_false")]
130    pub repeated: bool,
131    /// Children, sorted by `(dependency_kind, name, version)`.
132    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
142/// Per-call options for [`build_tree`]. Mirrors the same
143/// dependency-kind filter `cabin tree --kind ...` exposes, plus
144/// the optional [`Lockfile`] / patch / vendor / source-replacement
145/// inputs used to color provenance.
146pub struct TreeInputs<'a> {
147    /// Resolved package graph.
148    pub graph: &'a PackageGraph,
149    /// Indices of packages the user selected as roots.
150    /// Children are walked from these. Empty falls back to the
151    /// graph's primary set so callers do not have to special-case
152    /// "no selection" themselves.
153    pub roots: &'a [usize],
154    /// Optional lockfile contributing version-pinned checksums
155    /// to the provenance label.
156    pub lockfile: Option<&'a Lockfile>,
157    /// Active patch entries.  Patched packages are flagged
158    /// with [`SourceProvenance::Patched`] regardless of the
159    /// lockfile.
160    pub active_patches: Option<&'a cabin_workspace::ActivePatchSet>,
161    /// Restrict the walk to a single dependency-kind edge.
162    /// `None` walks every kind the graph carries.
163    pub kind_filter: Option<DependencyKind>,
164}
165
166/// Build a deterministic [`TreeNode`] forest rooted at every
167/// index in `roots`. Returned as a single root-less synthetic
168/// vector with the documented sort key applied at every level
169/// so renderers can iterate without re-sorting.
170pub 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    // Same canonical order `cabin metadata` already documents:
252    // normal → build → dev. Roots have no edge kind; sort them
253    // first.
254    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            // The graph's primary set carries every workspace
275            // member; anything else marked `Local` is a bare
276            // `path = "..."` dependency.
277            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
303/// Render a [`TreeNode`] forest as a human-readable Unicode
304/// drawing. Output is deterministic: every level sorts by
305/// `(edge_kind, name, version)` before this rendering runs, so
306/// callers can compare two renderings byte-for-byte.
307///
308/// The first column is the package identifier (`name vX.Y.Z`),
309/// followed by an annotation describing the dependency kind for
310/// non-root nodes and the source provenance for every node.
311pub 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
378/// Render the forest as a stable JSON document.
379///
380/// # Panics
381/// Panics if a [`TreeNode`] fails to serialize via `serde_json::to_value`,
382/// which cannot happen because [`TreeNode`] derives [`Serialize`] with no
383/// fallible custom serializer.
384pub 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/// Typed explanation chain returned by every `cabin explain`
394/// query. Renderers read the variant to choose the layout; the
395/// JSON output is a tagged union so downstream tooling sees the
396/// query kind without re-parsing.
397#[derive(Debug, Clone, Serialize)]
398#[serde(rename_all = "kebab-case", tag = "kind")]
399pub enum Explanation {
400    /// `cabin explain package <name>`.
401    Package(PackageExplanation),
402    /// `cabin explain target <name>`.
403    Target(TargetExplanation),
404    /// `cabin explain source <package>`.
405    Source(SourceExplanation),
406    /// `cabin explain feature <package/feature>`.
407    Feature(FeatureExplanation),
408}
409
410/// Explain why a package is in the resolved graph, who pulled
411/// it in, and which dependency edge introduced it.
412#[derive(Debug, Clone, Serialize)]
413pub struct PackageExplanation {
414    pub name: String,
415    pub version: String,
416    pub source: SourceProvenance,
417    /// Every minimal path from a selected root to this package,
418    /// sorted by `(length, joined name sequence)` for stable
419    /// output. Each element of the inner vec is one
420    /// `(name, version, edge_kind)` step; the first element is a
421    /// selected root and the last is the queried package.
422    pub paths: Vec<Vec<ExplainStep>>,
423    /// Whether this package is itself a selected root.
424    pub is_selected_root: bool,
425}
426
427/// One step in a [`PackageExplanation::paths`] chain.
428#[derive(Debug, Clone, Serialize)]
429pub struct ExplainStep {
430    pub name: String,
431    pub version: String,
432    /// Dependency kind under which this step was reached. `None`
433    /// for the root.
434    #[serde(skip_serializing_if = "Option::is_none")]
435    pub edge_kind: Option<&'static str>,
436}
437
438/// Explain a target's owning package, kind, language summary,
439/// and dependency edges. `cabin explain target <name>` only
440/// considers targets in the selected package closure.
441#[derive(Debug, Clone, Serialize)]
442pub struct TargetExplanation {
443    pub package: String,
444    pub target: String,
445    /// Target kind as the stable string the rest of Cabin uses
446    /// (`library`, `executable`, `test`, …). Named `target_kind`
447    /// rather than `kind` to avoid colliding with the
448    /// [`Explanation`] tag field in the JSON shape.
449    #[serde(rename = "target_kind")]
450    pub target_kind: String,
451    /// Names of source-language families the target carries
452    /// (`c`, `cxx`, `rust`). Sorted alphabetically.
453    pub languages: Vec<String>,
454    /// Manifest-declared deps for this target, in declaration
455    /// order. The orchestration layer normalizes each entry's
456    /// rendering.
457    pub deps: Vec<String>,
458    /// `true` for every kind that produces a Ninja action
459    /// (`library`, `executable`, `test`, `example`). `header_only`
460    /// is the only buildable=false kind. `is_test` and
461    /// `is_dev_only` further classify whether the target reaches
462    /// the default `cabin build` selection.
463    pub is_buildable: bool,
464    /// `true` for `test` only.
465    pub is_test: bool,
466    /// `true` for the dev-only kinds (`test`, `example`).
467    pub is_dev_only: bool,
468}
469
470/// Explain where a package's source bytes came from.
471#[derive(Debug, Clone, Serialize)]
472pub struct SourceExplanation {
473    pub name: String,
474    pub version: String,
475    pub source: SourceProvenance,
476    /// Active source-replacement entries the orchestration
477    /// layer surfaced as relevant to this query (typically
478    /// every entry in the merged config since one chain may
479    /// rewrite many packages). Empty when no replacements are
480    /// active.
481    pub source_replacements: Vec<String>,
482}
483
484/// Explain a feature's enablement: declared, enabled, what it
485/// implies, and which root pulled it in if any.
486#[derive(Debug, Clone, Serialize)]
487pub struct FeatureExplanation {
488    pub package: String,
489    pub feature: String,
490    pub enabled: bool,
491    /// Other features this feature implies, in declaration order.
492    pub implies: Vec<String>,
493    /// Whether this feature is a member of the package's
494    /// `default` group.
495    pub is_default: bool,
496}
497
498/// Build a [`PackageExplanation`] for `name`. Returns
499/// [`ExplainError::PackageNotFound`] when the name is not in the
500/// resolved graph; returns
501/// [`ExplainError::AmbiguousPackageName`] if a future graph
502/// gains multiple packages with the same name from distinct
503/// sources (today the resolver enforces unique names).
504///
505/// # Errors
506/// Returns [`ExplainError::PackageNotFound`] (with the known package
507/// names as candidates) when no package in `graph` matches `name`, and
508/// [`ExplainError::AmbiguousPackageName`] when more than one package
509/// shares that name.
510pub 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
598/// The known package names, sorted, capped at 10. Surfaced as the
599/// `candidates` list in `PackageNotFound`; it lists what *is* known
600/// rather than ranking by similarity to the query (the resolver
601/// package count is small enough that edit-distance would be
602/// overkill), so the rendered error reads "known packages: …".
603fn 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
617/// Walk the graph from `start` to `target` and return every
618/// shortest path of package indices. Edge kinds are recorded by
619/// the caller through [`materialize_path`] so the resulting
620/// [`ExplainStep`]s carry the right edge label.
621fn shortest_paths_to(graph: &PackageGraph, start: usize, target: usize) -> Vec<Vec<usize>> {
622    if start == target {
623        return vec![vec![start]];
624    }
625    // BFS by depth to find the *shortest* paths first; collect
626    // every parent at the depth where the target was reached.
627    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    // Reconstruct every shortest path by walking parents
660    // backwards from target.
661    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
715/// Build a [`TargetExplanation`] for `target_name`, scoped to
716/// the selected packages. Returns
717/// [`ExplainError::TargetNotFound`] if the name does not exist
718/// in any selected package, with a list of candidate names for
719/// the diagnostic.
720///
721/// # Errors
722/// Returns [`ExplainError::TargetNotFound`] (with the available target
723/// names as candidates) when no selected package declares a target named
724/// `target_name`, and [`ExplainError::AmbiguousTargetName`] when more than
725/// one selected package declares it.
726pub 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        // Buildable = anything that emits compile/archive/link
783        // actions. Excludes the header-only kinds because they
784        // contribute no translation units of their own.
785        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
791/// Build a [`SourceExplanation`] for the named package.
792///
793/// # Errors
794/// Propagates [`ExplainError::PackageNotFound`] or
795/// [`ExplainError::AmbiguousPackageName`] from `locate_package` when `name`
796/// matches no package, or more than one package, in `graph`.
797pub 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
835/// Build a [`FeatureExplanation`] for `package/feature`. The
836/// query string must contain a single `/` separating the package
837/// name from the feature name; an unrecognized shape is rejected
838/// with [`ExplainError::InvalidFeatureQuery`].
839///
840/// # Errors
841/// Returns [`ExplainError::InvalidFeatureQuery`] when `query` lacks a `/`
842/// separator; propagates [`ExplainError::PackageNotFound`] or
843/// [`ExplainError::AmbiguousPackageName`] from `locate_package`; and
844/// returns [`ExplainError::FeatureNotFound`] when the package does not
845/// declare the named feature (and it is not the `default` group).
846pub 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
892/// `cabin explain build-config <package>` returns the package's
893/// resolved [`cabin_core::BuildConfiguration`]. The orchestration
894/// layer already knows how to render it through
895/// `BuildConfiguration::as_json`, so this crate just looks it up.
896///
897/// # Errors
898/// Propagates [`ExplainError::PackageNotFound`] or
899/// [`ExplainError::AmbiguousPackageName`] from `locate_package`, and
900/// returns [`ExplainError::NoBuildConfiguration`] when `configurations`
901/// holds no entry for the located package (typically because it lies
902/// outside the selected closure).
903pub 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
916/// Render an [`Explanation`] as a concise human-readable
917/// summary suitable for terminal output.
918pub 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
1006/// Render an [`Explanation`] as a stable JSON document.
1007///
1008/// # Panics
1009/// Panics if the [`Explanation`] fails to serialize via `serde_json::to_value`,
1010/// which cannot happen because [`Explanation`] derives [`Serialize`] with no
1011/// fallible custom serializer.
1012pub fn render_explanation_json(exp: &Explanation) -> serde_json::Value {
1013    serde_json::to_value(exp).expect("Explanation is Serialize")
1014}
1015
1016/// Errors produced by the explain queries. Wording is stable
1017/// so integration tests can match on substrings.
1018#[derive(Debug, Error)]
1019pub enum ExplainError {
1020    /// The named package is not in the resolved graph.
1021    #[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    /// More than one package shares the same name. This is
1030    /// rejected by the resolver today, but the variant is
1031    /// retained so future graph changes have a clear failure
1032    /// mode.
1033    #[error(
1034        "package name `{name}` matches multiple packages with versions: {}",
1035        versions.join(", ")
1036    )]
1037    AmbiguousPackageName { name: String, versions: Vec<String> },
1038    /// The named target does not exist in any package the user
1039    /// selected.
1040    #[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    /// Multiple selected packages declare a target with the
1049    /// same name.
1050    #[error(
1051        "target name `{name}` is ambiguous; declared by packages: {}",
1052        owners.join(", ")
1053    )]
1054    AmbiguousTargetName { name: String, owners: Vec<String> },
1055    /// `cabin explain feature <package/feature>` query string
1056    /// did not contain a `/` separator.
1057    #[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    /// The named feature does not exist on the named package.
1062    #[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    /// The orchestration layer did not compute a build
1072    /// configuration for this package. Today this happens only
1073    /// when the user asks about a package outside the selected
1074    /// closure.
1075    #[error(
1076        "no build configuration was resolved for package `{name}`; check the workspace selection"
1077    )]
1078    NoBuildConfiguration { name: String },
1079}
1080
1081/// Stand-in module that names the per-package feature view this
1082/// crate consumes through a thin `&FeatureView` parameter. The
1083/// orchestration layer (`cabin`) already builds the typed
1084/// `cabin_feature::FeatureResolution`; rather than depending on
1085/// the full crate from a query-only library, we accept an
1086/// abstract view object so the orchestration layer can adapt the
1087/// existing types into our shape without leaking the resolver
1088/// crate boundary.
1089pub mod cabin_feature_per_package_view {
1090    use std::collections::BTreeSet;
1091
1092    /// Per-package feature view consumed by [`super::explain_feature`].
1093    pub struct FeatureView {
1094        /// Names of features enabled on this package by the
1095        /// resolver. Empty when the resolver did not visit the
1096        /// package.
1097        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        // app -> lib -> util
1140        // Indices: app=0, lib=1, util=2.
1141        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        // lib's child appears under lib.
1184        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        // The second-to-last child uses `└──` because there's
1218        // exactly one normal-kind dep under app.lib.
1219        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        // Path from root to root has a single step.
1244        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        // Build a small fixture with one target so we can hit
1266        // the explain_target path. Re-use the helper graph and
1267        // append a target by mutating a clone.
1268        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}