Skip to main content

cargo_compatible/
metadata.rs

1use crate::cli::SelectionArgs;
2use crate::model::{
3    MemberTarget, PackageSourceKind, ResolvedPackage, SelectedMember, Selection, TargetSelection,
4    TargetSelectionMode, WorkspaceData,
5};
6use anyhow::{anyhow, bail, Context, Result};
7use cargo_metadata::{Metadata, MetadataCommand, Package};
8use semver::Version;
9use std::collections::{BTreeMap, BTreeSet};
10use std::fs;
11use std::path::{Path, PathBuf};
12use toml_edit::DocumentMut;
13use tracing::{debug, info};
14
15pub fn load_workspace(manifest_path: Option<&Path>) -> Result<WorkspaceData> {
16    debug!(manifest_path = ?manifest_path, "loading cargo metadata");
17    let mut command = MetadataCommand::new();
18    if let Some(path) = manifest_path {
19        command.manifest_path(path);
20    }
21    let metadata = command.exec().context("failed to read cargo metadata")?;
22    let workspace_root = PathBuf::from(metadata.workspace_root.as_std_path());
23    let workspace_manifest = workspace_root.join("Cargo.toml");
24    let is_virtual_workspace = metadata.root_package().is_none();
25    let resolver = workspace_resolver(&workspace_manifest)?;
26    let mut recommendations = Vec::new();
27    if is_virtual_workspace && resolver.as_deref() != Some("3") {
28        recommendations.push(
29            "virtual workspace is missing `workspace.resolver = \"3\"`; Cargo's rust-version-aware fallback is clearer with resolver 3"
30                .to_string(),
31        );
32    }
33    let packages_by_id = metadata
34        .packages
35        .iter()
36        .map(|package| {
37            let id = package.id.repr.clone();
38            package_to_resolved(package, &metadata).map(|resolved| (id, resolved))
39        })
40        .collect::<Result<BTreeMap<_, _>>>()?;
41    let graph = resolve_graph(&metadata)?;
42
43    info!(
44        workspace_root = %workspace_root.display(),
45        packages = metadata.packages.len(),
46        workspace_members = metadata.workspace_members.len(),
47        is_virtual_workspace,
48        resolver = ?resolver,
49        "loaded workspace metadata"
50    );
51
52    Ok(WorkspaceData {
53        workspace_root,
54        workspace_manifest,
55        is_virtual_workspace,
56        resolver,
57        recommendations,
58        metadata,
59        packages_by_id,
60        graph,
61    })
62}
63
64pub fn select_packages(workspace: &WorkspaceData, args: &SelectionArgs) -> Result<Selection> {
65    debug!(
66        manifest_path = ?args.manifest_path,
67        workspace = args.workspace,
68        packages = ?args.package,
69        rust_version = ?args.rust_version,
70        "selecting workspace packages"
71    );
72    let selected_ids = if !args.package.is_empty() {
73        match_selected_packages(&workspace.metadata, &args.package)?
74    } else if args.workspace || workspace.is_virtual_workspace {
75        workspace
76            .metadata
77            .workspace_members
78            .iter()
79            .map(|id| id.repr.clone())
80            .collect()
81    } else {
82        let root = workspace.metadata.root_package().ok_or_else(|| {
83            anyhow!("this workspace has no root package; pass --workspace or --package")
84        })?;
85        vec![root.id.repr.clone()]
86    };
87
88    let members = selected_ids
89        .into_iter()
90        .map(|id| {
91            let package = workspace
92                .metadata
93                .packages
94                .iter()
95                .find(|package| package.id.repr == id)
96                .ok_or_else(|| anyhow!("selected package `{id}` not found in metadata"))?;
97            Ok(SelectedMember {
98                package_id: package.id.repr.clone(),
99                package_name: package.name.to_string(),
100                manifest_path: package.manifest_path.clone().into_std_path_buf(),
101                rust_version: package.rust_version.clone(),
102            })
103        })
104        .collect::<Result<Vec<_>>>()?;
105    let target = select_target(&members, args.rust_version.as_deref())?;
106
107    info!(
108        selected_members = members.len(),
109        target_mode = ?target.mode,
110        target_rust_version = ?target.target_rust_version,
111        "selected workspace packages"
112    );
113
114    Ok(Selection { members, target })
115}
116
117pub fn normalize_rust_version(input: &str) -> Result<Version> {
118    let parts = input.split('.').collect::<Vec<_>>();
119    let normalized = match parts.len() {
120        1 => format!("{}.0.0", parts[0]),
121        2 => format!("{}.{}.0", parts[0], parts[1]),
122        3 => input.to_string(),
123        _ => bail!("invalid Rust version `{input}`"),
124    };
125    Version::parse(&normalized).map_err(Into::into)
126}
127
128pub fn display_rust_version(version: &Version) -> String {
129    if version.patch == 0 {
130        format!("{}.{}", version.major, version.minor)
131    } else {
132        version.to_string()
133    }
134}
135
136fn select_target(members: &[SelectedMember], explicit: Option<&str>) -> Result<TargetSelection> {
137    if let Some(value) = explicit {
138        let version = normalize_rust_version(value)?;
139        return Ok(TargetSelection {
140            mode: TargetSelectionMode::Explicit,
141            target_rust_version: Some(display_rust_version(&version)),
142            members: members_to_targets(members),
143            notes: Vec::new(),
144        });
145    }
146
147    let known = members
148        .iter()
149        .filter_map(|member| {
150            member
151                .rust_version
152                .as_ref()
153                .map(|version| (member, version))
154        })
155        .collect::<Vec<_>>();
156    let unique_versions = known
157        .iter()
158        .map(|(_, version)| display_rust_version(version))
159        .collect::<BTreeSet<_>>();
160
161    let mode = if members.len() == 1 && known.len() == 1 {
162        TargetSelectionMode::SelectedPackage
163    } else if !members.is_empty() && unique_versions.len() == 1 && known.len() == members.len() {
164        TargetSelectionMode::WorkspaceUniform
165    } else if unique_versions.len() > 1 {
166        TargetSelectionMode::WorkspaceMixed
167    } else {
168        TargetSelectionMode::Missing
169    };
170
171    let target_rust_version = if unique_versions.len() == 1 && known.len() == members.len() {
172        unique_versions.into_iter().next()
173    } else {
174        None
175    };
176    let mut notes = Vec::new();
177    if matches!(mode, TargetSelectionMode::WorkspaceMixed) {
178        notes.push(
179            "selected packages use different `rust-version` values; results are grouped by affected member".to_string(),
180        );
181    }
182    if matches!(mode, TargetSelectionMode::Missing) {
183        notes.push(
184            "at least one selected package is missing `rust-version`; compatibility cannot be asserted for that member".to_string(),
185        );
186    }
187
188    Ok(TargetSelection {
189        mode,
190        target_rust_version,
191        members: members_to_targets(members),
192        notes,
193    })
194}
195
196fn members_to_targets(members: &[SelectedMember]) -> Vec<MemberTarget> {
197    let mut targets = members
198        .iter()
199        .map(|member| MemberTarget {
200            package_id: member.package_id.clone(),
201            package_name: member.package_name.clone(),
202            rust_version: member.rust_version.as_ref().map(display_rust_version),
203        })
204        .collect::<Vec<_>>();
205    targets.sort_by(|left, right| left.package_name.cmp(&right.package_name));
206    targets
207}
208
209fn match_selected_packages(metadata: &Metadata, specs: &[String]) -> Result<Vec<String>> {
210    let workspace_member_ids = metadata
211        .workspace_members
212        .iter()
213        .map(|id| id.repr.clone())
214        .collect::<BTreeSet<_>>();
215    let workspace_members = metadata
216        .packages
217        .iter()
218        .filter(|package| workspace_member_ids.contains(&package.id.repr))
219        .collect::<Vec<_>>();
220    let cwd = std::env::current_dir()
221        .context("failed to determine current directory for package selection")?;
222    let mut matched = BTreeSet::new();
223    for spec in specs {
224        matched.insert(resolve_workspace_member_spec(
225            &workspace_members,
226            spec,
227            &cwd,
228        )?);
229    }
230    Ok(matched.into_iter().collect())
231}
232
233fn resolve_workspace_member_spec(
234    workspace_members: &[&Package],
235    spec: &str,
236    cwd: &Path,
237) -> Result<String> {
238    if let Some(package) = workspace_members
239        .iter()
240        .find(|package| package.id.repr == spec)
241    {
242        return Ok(package.id.repr.clone());
243    }
244
245    let name_matches = workspace_members
246        .iter()
247        .filter(|package| package.name.as_str() == spec)
248        .collect::<Vec<_>>();
249    if let [package] = name_matches.as_slice() {
250        return Ok(package.id.repr.clone());
251    }
252    if name_matches.len() > 1 {
253        bail!(
254            "package spec `{spec}` matched multiple workspace members by name; use an exact package ID or manifest path"
255        );
256    }
257
258    if let Some(spec_path) = normalize_package_spec_path(spec, cwd) {
259        let path_matches = workspace_members
260            .iter()
261            .filter_map(|package| {
262                normalize_existing_path(package.manifest_path.as_std_path())
263                    .filter(|manifest_path| manifest_path == &spec_path)
264                    .map(|_| package.id.repr.clone())
265            })
266            .collect::<Vec<_>>();
267        if let [package_id] = path_matches.as_slice() {
268            return Ok(package_id.clone());
269        }
270        if path_matches.len() > 1 {
271            bail!(
272                "package spec `{spec}` matched multiple workspace members by manifest path; use an exact package ID"
273            );
274        }
275    }
276
277    bail!(
278        "package spec `{spec}` did not match any workspace member by exact package name, package ID, or manifest path"
279    );
280}
281
282fn normalize_package_spec_path(spec: &str, cwd: &Path) -> Option<PathBuf> {
283    let candidate = Path::new(spec);
284    if !candidate.is_absolute() && candidate.components().count() == 1 && !spec.ends_with(".toml") {
285        return None;
286    }
287    let candidate = if candidate.is_absolute() {
288        candidate.to_path_buf()
289    } else {
290        cwd.join(candidate)
291    };
292    normalize_existing_path(&candidate)
293}
294
295fn normalize_existing_path(path: &Path) -> Option<PathBuf> {
296    fs::canonicalize(path).ok()
297}
298
299fn package_to_resolved(package: &Package, metadata: &Metadata) -> Result<ResolvedPackage> {
300    let workspace_member = metadata
301        .workspace_members
302        .iter()
303        .any(|id| id == &package.id);
304    let source = package.source.as_ref().map(ToString::to_string);
305    let source_kind = match source.as_deref() {
306        None if workspace_member => PackageSourceKind::Workspace,
307        Some(value) if value.starts_with("registry+") => PackageSourceKind::Registry,
308        Some(value) if value.starts_with("git+") => PackageSourceKind::Git,
309        None => PackageSourceKind::Path,
310        _ => PackageSourceKind::Unknown,
311    };
312
313    Ok(ResolvedPackage {
314        id: package.id.repr.clone(),
315        name: package.name.to_string(),
316        version: package.version.clone(),
317        source,
318        source_kind,
319        manifest_path: package.manifest_path.clone().into_std_path_buf(),
320        rust_version: package.rust_version.as_ref().map(display_rust_version),
321        workspace_member,
322    })
323}
324
325fn resolve_graph(metadata: &Metadata) -> Result<BTreeMap<String, Vec<String>>> {
326    let resolve = metadata
327        .resolve
328        .as_ref()
329        .ok_or_else(|| anyhow!("cargo metadata returned no resolve graph"))?;
330    let mut graph = BTreeMap::new();
331    for node in &resolve.nodes {
332        let mut deps = node
333            .deps
334            .iter()
335            .map(|dep| dep.pkg.repr.clone())
336            .collect::<Vec<_>>();
337        deps.sort();
338        graph.insert(node.id.repr.clone(), deps);
339    }
340    Ok(graph)
341}
342
343fn workspace_resolver(path: &Path) -> Result<Option<String>> {
344    let contents = match fs::read_to_string(path) {
345        Ok(contents) => contents,
346        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
347        Err(error) => return Err(error.into()),
348    };
349    let document = contents.parse::<DocumentMut>()?;
350    let package = document
351        .get("package")
352        .and_then(|item| item.as_table_like())
353        .and_then(|table| table.get("resolver"))
354        .and_then(|item| item.as_value())
355        .and_then(|value| value.as_str())
356        .map(ToOwned::to_owned);
357    let workspace = document
358        .get("workspace")
359        .and_then(|item| item.as_table_like())
360        .and_then(|table| table.get("resolver"))
361        .and_then(|item| item.as_value())
362        .and_then(|value| value.as_str())
363        .map(ToOwned::to_owned);
364    Ok(workspace.or(package))
365}
366
367pub fn resolve_package_query(
368    workspace: &WorkspaceData,
369    allowed_package_ids: &BTreeSet<String>,
370    query: &str,
371) -> Result<String> {
372    if allowed_package_ids.contains(query) {
373        return Ok(query.to_string());
374    }
375
376    let candidates = workspace
377        .metadata
378        .packages
379        .iter()
380        .filter(|package| allowed_package_ids.contains(&package.id.repr))
381        .filter(|package| {
382            package.name.as_str() == query
383                || format!("{}@{}", package.name, package.version) == query
384        })
385        .map(|package| package.id.repr.clone())
386        .collect::<Vec<_>>();
387
388    match candidates.as_slice() {
389        [] => bail!(
390            "query `{query}` did not match any package in the selected dependency graph"
391        ),
392        [package_id] => Ok(package_id.clone()),
393        _ => bail!(
394            "query `{query}` matched multiple packages in the selected dependency graph; use an exact package ID or name@version"
395        ),
396    }
397}