Skip to main content

cargo_compatible/
explain.rs

1use 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(&current, &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}