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}