cargo_compatible/
explain.rs1use crate::cli::ExplainCommand;
2use crate::compat::analyze_current_workspace;
3use crate::metadata::resolve_package_query;
4use crate::model::{
5 BlockerKind, CompatibilityStatus, ExplainReport, PackageIssue, Selection, WorkspaceData,
6};
7use crate::resolution::build_candidate_resolution;
8use anyhow::{anyhow, Result};
9
10pub fn build_explain_report(
11 workspace: &WorkspaceData,
12 selection: &Selection,
13 command: &ExplainCommand,
14) -> Result<ExplainReport> {
15 let selected_graph = selected_graph_package_ids(workspace, selection);
16 let package_id = resolve_package_query(workspace, &selected_graph, &command.query)?;
17 let current = analyze_current_workspace(workspace, selection)?;
18 let package = workspace
19 .packages_by_id
20 .get(&package_id)
21 .cloned()
22 .ok_or_else(|| anyhow!("resolved package `{package_id}` missing from package map"))?;
23 let current_issue = find_issue(¤t, &package_id);
24
25 let mut candidate_version = None;
26 let mut candidate_status = None;
27 let mut notes = workspace.recommendations.clone();
28 let blocker;
29
30 if current_issue.is_some() {
31 let resolve = build_candidate_resolution(
32 workspace,
33 selection,
34 &crate::cli::ResolveCommand {
35 selection: command.selection.clone(),
36 format: command.format,
37 write_candidate: None,
38 write_report: None,
39 },
40 )?;
41 let candidate_issue = find_issue(&resolve.candidate, &package_id);
42 candidate_version = candidate_issue
43 .map(|issue| issue.package.version.to_string())
44 .or_else(|| candidate_version_from_changes(&package, &resolve.version_changes));
45 candidate_status = candidate_issue.map(|issue| issue.status.clone());
46 notes = resolve.notes;
47 blocker = classify_blocker(Some(&package), current_issue, candidate_issue, selection);
48 } else {
49 blocker = classify_blocker(Some(&package), current_issue, None, selection);
50 }
51
52 Ok(ExplainReport {
53 query: command.query.clone(),
54 target: selection.target.clone(),
55 package: Some(package),
56 current_status: current_issue.map(|issue| issue.status.clone()),
57 current_reason: current_issue.map(|issue| issue.reason.clone()),
58 current_paths: current_issue
59 .map(|issue| issue.paths.clone())
60 .unwrap_or_default(),
61 current_rust_version: current_issue
62 .map(|issue| issue.package.rust_version.clone())
63 .unwrap_or(None),
64 candidate_version,
65 candidate_status,
66 blocker,
67 notes,
68 workspace_root: workspace.workspace_root.clone(),
69 })
70}
71
72fn find_issue<'a>(
73 report: &'a crate::model::ScanReport,
74 package_id: &str,
75) -> Option<&'a PackageIssue> {
76 report
77 .incompatible_packages
78 .iter()
79 .chain(report.unknown_packages.iter())
80 .find(|issue| issue.package.id == package_id)
81}
82
83fn selected_graph_package_ids(
84 workspace: &WorkspaceData,
85 selection: &Selection,
86) -> std::collections::BTreeSet<String> {
87 let mut reachable = std::collections::BTreeSet::new();
88 let mut queue = std::collections::VecDeque::from_iter(
89 selection
90 .members
91 .iter()
92 .map(|member| member.package_id.clone()),
93 );
94
95 while let Some(package_id) = queue.pop_front() {
96 if !reachable.insert(package_id.clone()) {
97 continue;
98 }
99 for dependency_id in workspace.graph.get(&package_id).into_iter().flatten() {
100 queue.push_back(dependency_id.clone());
101 }
102 }
103
104 reachable
105}
106
107fn candidate_version_from_changes(
108 package: &crate::model::ResolvedPackage,
109 changes: &[crate::model::CandidateVersionChange],
110) -> Option<String> {
111 let mut matching_changes = changes
112 .iter()
113 .filter(|change| change.package_name == package.name && change.source == package.source);
114 let change = matching_changes.next()?;
115 if matching_changes.next().is_some() {
116 return None;
117 }
118 change.after.clone()
119}
120
121fn classify_blocker(
122 package: Option<&crate::model::ResolvedPackage>,
123 current_issue: Option<&PackageIssue>,
124 candidate_issue: Option<&PackageIssue>,
125 selection: &Selection,
126) -> Option<BlockerKind> {
127 let package = package?;
128 if current_issue.is_none() {
129 return Some(BlockerKind::Compatible);
130 }
131 if package
132 .source
133 .as_deref()
134 .map(|source| source.starts_with("git+"))
135 .unwrap_or(false)
136 || matches!(package.source_kind, crate::model::PackageSourceKind::Path)
137 {
138 return Some(BlockerKind::PathOrGitConstraint);
139 }
140 if package.rust_version.is_none() {
141 return Some(BlockerKind::UnknownRustVersion);
142 }
143 if current_issue.is_some() && candidate_issue.is_none() {
144 return Some(BlockerKind::LockfileDrift);
145 }
146 if matches!(
147 selection.target.mode,
148 crate::model::TargetSelectionMode::WorkspaceMixed
149 ) {
150 return Some(BlockerKind::MixedWorkspaceRustVersionUnification);
151 }
152 match current_issue.map(|issue| &issue.status) {
153 Some(CompatibilityStatus::Unknown) => Some(BlockerKind::NonRegistryConstraint),
154 Some(CompatibilityStatus::Incompatible) => Some(BlockerKind::DirectDependencyTooNew),
155 _ => Some(BlockerKind::Compatible),
156 }
157}