1use std::collections::BTreeMap;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10use anyhow::{anyhow, bail, Context, Result};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
16pub struct CargoOptions {
17 pub features: Vec<String>,
19 pub all_features: bool,
21 pub no_default_features: bool,
23 pub target: Option<String>,
25 pub package_target: Option<String>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct CargoTarget {
32 pub name: String,
33 pub kind: Vec<String>,
34 pub src_path: PathBuf,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct CargoContext {
40 pub package_name: String,
41 pub package_version: String,
42 pub package_id: String,
43 pub manifest_path: PathBuf,
44 pub workspace_root: PathBuf,
45 pub target_directory: PathBuf,
46 pub targets: Vec<CargoTarget>,
48 pub active_features: Vec<String>,
50 pub candle_versions: BTreeMap<String, String>,
52 pub dependency_aliases: BTreeMap<String, String>,
54 pub cfgs: Vec<String>,
56}
57
58pub fn discover(path: impl AsRef<Path>, options: &CargoOptions) -> Result<CargoContext> {
60 CargoContext::discover(path, options)
61}
62
63impl CargoContext {
64 pub fn discover(path: impl AsRef<Path>, options: &CargoOptions) -> Result<Self> {
66 let path = path.as_ref();
67 let manifest_path = find_manifest(path)
68 .with_context(|| format!("failed to locate Cargo.toml from {}", path.display()))?;
69
70 let metadata = run_cargo_metadata(&manifest_path, options)?;
71 let package = select_package(&metadata, &manifest_path)?;
72
73 let package_name = package
74 .get("name")
75 .and_then(|v| v.as_str())
76 .ok_or_else(|| anyhow!("package missing name"))?
77 .to_string();
78 let package_version = package
79 .get("version")
80 .and_then(|v| v.as_str())
81 .ok_or_else(|| anyhow!("package missing version"))?
82 .to_string();
83 let package_id = package
84 .get("id")
85 .and_then(|v| v.as_str())
86 .ok_or_else(|| anyhow!("package missing id"))?
87 .to_string();
88
89 let manifest_path = path_from_json(package, "manifest_path")?;
90 let workspace_root = path_from_root(&metadata, "workspace_root")?;
91 let target_directory = path_from_root(&metadata, "target_directory")?;
92
93 let mut targets = parse_targets(package)?;
94 targets
95 .sort_by(|a, b| (&a.name, &a.kind, &a.src_path).cmp(&(&b.name, &b.kind, &b.src_path)));
96
97 let mut active_features = resolve_active_features(&metadata, &package_id)?;
98 active_features.sort();
99 active_features.dedup();
100
101 let candle_versions = collect_candle_versions(&metadata, &package_id)?;
102 let dependency_aliases = collect_dependency_aliases(package)?;
103
104 let mut cfgs = collect_rustc_cfgs(options.target.as_deref())?;
105 for feature in &active_features {
106 cfgs.push(format!("feature=\"{feature}\""));
107 }
108 cfgs.sort();
109 cfgs.dedup();
110
111 Ok(Self {
112 package_name,
113 package_version,
114 package_id,
115 manifest_path,
116 workspace_root,
117 target_directory,
118 targets,
119 active_features,
120 candle_versions,
121 dependency_aliases,
122 cfgs,
123 })
124 }
125
126 pub fn selected_source_roots(&self, requested: Option<&str>) -> Result<Vec<PathBuf>> {
132 if let Some(name) = requested {
133 let selected = self
134 .targets
135 .iter()
136 .filter(|target| target.name == name)
137 .map(|target| target.src_path.clone())
138 .collect::<Vec<_>>();
139 if selected.is_empty() {
140 bail!(
141 "Cargo target `{name}` not found; available targets: {}",
142 self.targets
143 .iter()
144 .map(|target| target.name.as_str())
145 .collect::<Vec<_>>()
146 .join(", ")
147 );
148 }
149 return Ok(selected);
150 }
151
152 if let Some(library) = self
153 .targets
154 .iter()
155 .find(|target| target.kind.iter().any(|kind| kind == "lib"))
156 {
157 return Ok(vec![library.src_path.clone()]);
158 }
159 if let Some(binary) = self
160 .targets
161 .iter()
162 .find(|target| target.kind.iter().any(|kind| kind == "bin"))
163 {
164 return Ok(vec![binary.src_path.clone()]);
165 }
166 bail!(
167 "package `{}` has no library or binary target; select a target explicitly",
168 self.package_name
169 )
170 }
171}
172
173fn collect_dependency_aliases(package: &Value) -> Result<BTreeMap<String, String>> {
174 let dependencies = package
175 .get("dependencies")
176 .and_then(Value::as_array)
177 .ok_or_else(|| anyhow!("package missing dependencies array"))?;
178 let mut aliases = BTreeMap::new();
179 for dependency in dependencies {
180 let Some(name) = dependency.get("name").and_then(Value::as_str) else {
181 continue;
182 };
183 let alias = dependency
184 .get("rename")
185 .and_then(Value::as_str)
186 .unwrap_or(name)
187 .replace('-', "_");
188 aliases.insert(alias, name.to_string());
189 }
190 Ok(aliases)
191}
192
193pub fn cfg_predicates_active(predicates: &[String], active_cfg: &[String]) -> Option<bool> {
199 let active = active_cfg
200 .iter()
201 .map(|item| normalize_cfg(item))
202 .collect::<std::collections::HashSet<_>>();
203 let mut unknown = false;
204 for predicate in predicates {
205 match eval_cfg(&normalize_cfg(predicate), &active) {
206 Some(false) => return Some(false),
207 Some(true) => {}
208 None => unknown = true,
209 }
210 }
211 (!unknown).then_some(true)
212}
213
214fn eval_cfg(predicate: &str, active: &std::collections::HashSet<String>) -> Option<bool> {
215 if let Some(arguments) = outer_arguments(predicate, "all") {
216 let parts = split_cfg_arguments(arguments)?;
217 let mut unknown = false;
218 for part in parts {
219 match eval_cfg(part, active) {
220 Some(false) => return Some(false),
221 Some(true) => {}
222 None => unknown = true,
223 }
224 }
225 return (!unknown).then_some(true);
226 }
227 if let Some(arguments) = outer_arguments(predicate, "any") {
228 let parts = split_cfg_arguments(arguments)?;
229 let mut unknown = false;
230 for part in parts {
231 match eval_cfg(part, active) {
232 Some(true) => return Some(true),
233 Some(false) => {}
234 None => unknown = true,
235 }
236 }
237 return (!unknown).then_some(false);
238 }
239 if let Some(arguments) = outer_arguments(predicate, "not") {
240 let parts = split_cfg_arguments(arguments)?;
241 let [inner] = parts.as_slice() else {
242 return None;
243 };
244 return eval_cfg(inner, active).map(|value| !value);
245 }
246 if predicate.is_empty()
247 || predicate.contains('(')
248 || predicate.contains(')')
249 || predicate.contains(',')
250 {
251 None
252 } else {
253 Some(active.contains(predicate))
254 }
255}
256
257fn normalize_cfg(value: &str) -> String {
258 let mut normalized = String::with_capacity(value.len());
259 let mut quoted = false;
260 for character in value.chars() {
261 if character == '"' {
262 quoted = !quoted;
263 normalized.push(character);
264 } else if quoted || !character.is_whitespace() {
265 normalized.push(character);
266 }
267 }
268 normalized
269}
270
271fn outer_arguments<'a>(value: &'a str, name: &str) -> Option<&'a str> {
272 value
273 .strip_prefix(name)?
274 .strip_prefix('(')?
275 .strip_suffix(')')
276}
277
278fn split_cfg_arguments(value: &str) -> Option<Vec<&str>> {
279 if value.is_empty() {
280 return Some(Vec::new());
281 }
282 let mut parts = Vec::new();
283 let mut depth = 0usize;
284 let mut quoted = false;
285 let mut start = 0usize;
286 for (index, character) in value.char_indices() {
287 match character {
288 '"' => quoted = !quoted,
289 '(' if !quoted => depth = depth.checked_add(1)?,
290 ')' if !quoted => depth = depth.checked_sub(1)?,
291 ',' if !quoted && depth == 0 => {
292 parts.push(&value[start..index]);
293 start = index + character.len_utf8();
294 }
295 _ => {}
296 }
297 }
298 if quoted || depth != 0 {
299 return None;
300 }
301 parts.push(&value[start..]);
302 Some(parts)
303}
304
305fn find_manifest(start: &Path) -> Result<PathBuf> {
307 if !start.exists() {
308 bail!("path does not exist: {}", start.display());
309 }
310
311 let mut dir = if start.is_file() {
312 start
313 .parent()
314 .ok_or_else(|| anyhow!("path has no parent: {}", start.display()))?
315 .to_path_buf()
316 } else {
317 start.to_path_buf()
318 };
319
320 if let Ok(canon) = dir.canonicalize() {
322 dir = canon;
323 }
324
325 loop {
326 let candidate = dir.join("Cargo.toml");
327 if candidate.is_file() {
328 return Ok(candidate);
329 }
330 if !dir.pop() {
331 bail!("Cargo.toml not found starting from {}", start.display());
332 }
333 }
334}
335
336fn run_cargo_metadata(manifest_path: &Path, options: &CargoOptions) -> Result<Value> {
337 let mut cmd = Command::new("cargo");
338 cmd.arg("metadata")
339 .arg("--format-version")
340 .arg("1")
341 .arg("--manifest-path")
342 .arg(manifest_path);
343
344 if options.all_features {
345 cmd.arg("--all-features");
346 }
347 if options.no_default_features {
348 cmd.arg("--no-default-features");
349 }
350 if !options.features.is_empty() {
351 cmd.arg("--features").arg(options.features.join(","));
352 }
353 if let Some(target) = options.target.as_deref() {
354 cmd.arg("--filter-platform").arg(target);
355 }
356
357 let output = cmd.output().with_context(|| {
358 format!(
359 "failed to spawn cargo metadata for {}",
360 manifest_path.display()
361 )
362 })?;
363
364 if !output.status.success() {
365 let stderr = String::from_utf8_lossy(&output.stderr);
366 bail!(
367 "cargo metadata failed for {} (status {}): {}",
368 manifest_path.display(),
369 output.status,
370 stderr.trim()
371 );
372 }
373
374 let stdout = String::from_utf8(output.stdout).context("cargo metadata stdout was not UTF-8")?;
375 serde_json::from_str(&stdout).context("failed to parse cargo metadata JSON")
376}
377
378fn select_package<'a>(metadata: &'a Value, manifest_path: &Path) -> Result<&'a Value> {
379 let packages = metadata
380 .get("packages")
381 .and_then(|v| v.as_array())
382 .ok_or_else(|| anyhow!("cargo metadata missing packages array"))?;
383
384 let want = normalize_path(manifest_path);
385
386 for package in packages {
387 let Some(mp) = package.get("manifest_path").and_then(|v| v.as_str()) else {
388 continue;
389 };
390 if normalize_path(Path::new(mp)) == want {
391 return Ok(package);
392 }
393 }
394
395 bail!(
396 "no package in cargo metadata matched manifest {}",
397 manifest_path.display()
398 )
399}
400
401fn normalize_path(path: &Path) -> PathBuf {
402 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
403}
404
405fn path_from_json(obj: &Value, key: &str) -> Result<PathBuf> {
406 let s = obj
407 .get(key)
408 .and_then(|v| v.as_str())
409 .ok_or_else(|| anyhow!("missing string field `{key}`"))?;
410 Ok(PathBuf::from(s))
411}
412
413fn path_from_root(metadata: &Value, key: &str) -> Result<PathBuf> {
414 path_from_json(metadata, key).with_context(|| format!("cargo metadata missing `{key}`"))
415}
416
417fn parse_targets(package: &Value) -> Result<Vec<CargoTarget>> {
418 let targets = package
419 .get("targets")
420 .and_then(|v| v.as_array())
421 .ok_or_else(|| anyhow!("package missing targets array"))?;
422
423 let mut out = Vec::with_capacity(targets.len());
424 for target in targets {
425 let name = target
426 .get("name")
427 .and_then(|v| v.as_str())
428 .ok_or_else(|| anyhow!("target missing name"))?
429 .to_string();
430 let kind = target
431 .get("kind")
432 .and_then(|v| v.as_array())
433 .ok_or_else(|| anyhow!("target missing kind"))?
434 .iter()
435 .filter_map(|v| v.as_str().map(str::to_string))
436 .collect::<Vec<_>>();
437 let src_path = path_from_json(target, "src_path")?;
438 out.push(CargoTarget {
439 name,
440 kind,
441 src_path,
442 });
443 }
444 Ok(out)
445}
446
447fn resolve_active_features(metadata: &Value, package_id: &str) -> Result<Vec<String>> {
448 let resolve = metadata
449 .get("resolve")
450 .ok_or_else(|| anyhow!("cargo metadata missing resolve"))?;
451 let nodes = resolve
452 .get("nodes")
453 .and_then(|v| v.as_array())
454 .ok_or_else(|| anyhow!("cargo metadata resolve missing nodes"))?;
455
456 for node in nodes {
457 let id = node.get("id").and_then(|v| v.as_str()).unwrap_or("");
458 if id == package_id {
459 let features = node
460 .get("features")
461 .and_then(|v| v.as_array())
462 .ok_or_else(|| anyhow!("resolve node missing features for {package_id}"))?;
463 return Ok(features
464 .iter()
465 .filter_map(|v| v.as_str().map(str::to_string))
466 .collect());
467 }
468 }
469
470 bail!("resolve node not found for package id {package_id}")
471}
472
473fn collect_candle_versions(
474 metadata: &Value,
475 selected_package_id: &str,
476) -> Result<BTreeMap<String, String>> {
477 let packages = metadata
478 .get("packages")
479 .and_then(|v| v.as_array())
480 .ok_or_else(|| anyhow!("cargo metadata missing packages array"))?;
481
482 let mut versions: BTreeMap<String, std::collections::BTreeSet<String>> = BTreeMap::new();
483 for package in packages {
484 if package.get("id").and_then(Value::as_str) == Some(selected_package_id) {
485 continue;
486 }
487 let name = match package.get("name").and_then(|v| v.as_str()) {
488 Some(n) if n.starts_with("candle-") => n,
489 _ => continue,
490 };
491 let version = package
492 .get("version")
493 .and_then(|v| v.as_str())
494 .ok_or_else(|| anyhow!("package `{name}` missing version"))?;
495 versions
496 .entry(name.to_string())
497 .or_default()
498 .insert(version.to_string());
499 }
500 Ok(versions
501 .into_iter()
502 .map(|(name, versions)| (name, versions.into_iter().collect::<Vec<_>>().join(",")))
503 .collect())
504}
505
506fn collect_rustc_cfgs(target: Option<&str>) -> Result<Vec<String>> {
507 let mut cmd = Command::new("rustc");
508 cmd.arg("--print").arg("cfg");
509 if let Some(triple) = target {
510 cmd.arg("--target").arg(triple);
511 }
512
513 let output = cmd.output().context("failed to spawn rustc --print cfg")?;
514
515 if !output.status.success() {
516 let stderr = String::from_utf8_lossy(&output.stderr);
517 bail!(
518 "rustc --print cfg failed (status {}): {}",
519 output.status,
520 stderr.trim()
521 );
522 }
523
524 let stdout =
525 String::from_utf8(output.stdout).context("rustc --print cfg stdout was not UTF-8")?;
526 let mut cfgs = stdout
527 .lines()
528 .map(str::trim)
529 .filter(|l| !l.is_empty())
530 .map(str::to_string)
531 .collect::<Vec<_>>();
532 cfgs.sort();
533 cfgs.dedup();
534 Ok(cfgs)
535}