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