use std::collections::HashSet;
use std::fs;
use std::io;
use std::path::Path;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RootKind {
Lib,
Bin,
Test,
Example,
Bench,
Build,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CrateRoot {
pub file: String,
pub kind: RootKind,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RustPackage {
pub dir: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lib: Option<String>,
pub roots: Vec<CrateRoot>,
}
pub type RustCrates = Vec<RustPackage>;
pub fn discover(
root: &Path,
scope: &[String],
rs_files: &[String],
) -> io::Result<Option<RustCrates>> {
let walked: HashSet<&str> = rs_files.iter().map(String::as_str).collect();
let mut packages = Vec::new();
for rel in crate::walk::list_files_with_ext(root, scope, &[".toml"])? {
if rel != "Cargo.toml" && !rel.ends_with("/Cargo.toml") {
continue;
}
let Ok(bytes) = fs::read(root.join(&rel)) else {
continue;
};
let dir = rel
.strip_suffix("Cargo.toml")
.unwrap_or("")
.trim_end_matches('/');
if let Some(package) = package_of(dir, &String::from_utf8_lossy(&bytes), &walked) {
packages.push(package);
}
}
packages.sort_by(|a, b| a.dir.cmp(&b.dir));
Ok((!packages.is_empty()).then_some(packages))
}
#[derive(Default)]
struct Manifest {
package: Option<String>,
lib_name: Option<String>,
lib_path: Option<String>,
targets: Vec<(RootKind, String)>,
}
fn scan(text: &str) -> Manifest {
let mut m = Manifest::default();
let mut section = String::new();
let mut has_package = false;
for raw in text.lines() {
let line = strip_comment(raw.trim_end_matches('\r')).trim();
if let Some(header) = line.strip_prefix('[') {
section = header
.trim_matches(|c| c == '[' || c == ']' || c == ' ')
.to_string();
has_package |= section == "package";
continue;
}
let Some((key, value)) = key_value(line) else {
continue;
};
match (section.as_str(), key) {
("package", "name") => m.package = Some(value),
("lib", "name") => m.lib_name = Some(value),
("lib", "path") => m.lib_path = Some(value),
(s, "path") => {
if let Some(kind) = array_kind(s) {
m.targets.push((kind, value));
}
}
_ => {}
}
}
if !has_package {
m.package = None;
}
m
}
fn array_kind(section: &str) -> Option<RootKind> {
Some(match section {
"bin" => RootKind::Bin,
"test" => RootKind::Test,
"example" => RootKind::Example,
"bench" => RootKind::Bench,
_ => return None,
})
}
fn strip_comment(line: &str) -> &str {
let mut quote: Option<char> = None;
for (i, c) in line.char_indices() {
match (quote, c) {
(None, '"' | '\'') => quote = Some(c),
(Some(q), c) if c == q => quote = None,
(None, '#') => return &line[..i],
_ => {}
}
}
line
}
fn key_value(line: &str) -> Option<(&str, String)> {
let (key, value) = line.split_once('=')?;
let value = value.trim();
let quote = value.chars().next().filter(|c| *c == '"' || *c == '\'')?;
let inner = value[1..].strip_suffix(quote)?;
Some((key.trim(), inner.to_string()))
}
fn package_of(dir: &str, text: &str, walked: &HashSet<&str>) -> Option<RustPackage> {
let m = scan(text);
let name = m.package?;
let mut roots: Vec<CrateRoot> = Vec::new();
let mut add = |kind: RootKind, file: Option<String>| {
if let Some(file) = file.filter(|f| walked.contains(f.as_str())) {
if !roots.iter().any(|r| r.file == file) {
roots.push(CrateRoot { file, kind });
}
}
};
let lib = m.lib_path.clone().unwrap_or_else(|| "src/lib.rs".into());
add(RootKind::Lib, join(dir, &lib));
for (kind, path) in &m.targets {
add(*kind, join(dir, path));
}
add(RootKind::Bin, join(dir, "src/main.rs"));
for (folder, kind) in [
("tests", RootKind::Test),
("examples", RootKind::Example),
("benches", RootKind::Bench),
] {
let prefix = join(dir, folder).map(|p| p + "/").unwrap_or_default();
let mut found: Vec<&&str> = walked
.iter()
.filter(|f| {
f.strip_prefix(prefix.as_str())
.is_some_and(is_default_target)
})
.collect();
found.sort();
for file in found {
add(kind, Some((*file).to_string()));
}
}
add(RootKind::Build, join(dir, "build.rs"));
roots.sort_by(|a, b| a.kind.cmp(&b.kind).then_with(|| a.file.cmp(&b.file)));
let has_lib = roots.iter().any(|r| r.kind == RootKind::Lib);
let lib = has_lib.then(|| m.lib_name.unwrap_or_else(|| name.replace('-', "_")));
Some(RustPackage {
dir: dir.to_string(),
name,
lib,
roots,
})
}
fn is_default_target(rest: &str) -> bool {
match rest.split_once('/') {
None => rest.ends_with(".rs"),
Some((name, file)) => !name.is_empty() && file == "main.rs",
}
}
pub(super) fn join(dir: &str, path: &str) -> Option<String> {
if path.starts_with('/') {
return None;
}
let mut parts: Vec<&str> = Vec::new();
for part in path.split('/') {
match part {
"" | "." => {}
".." => {
parts.pop()?;
}
p => parts.push(p),
}
}
let rel = parts.join("/");
Some(if dir.is_empty() {
rel
} else {
format!("{dir}/{rel}")
})
}
pub fn sidecar_differs(root: &Path, crates: Option<&RustCrates>) -> bool {
let existing = fs::read(crate::graph::rust_crates_path(root)).ok();
match (existing, crates) {
(None, None) => false,
(Some(bytes), Some(c)) => serde_json::to_vec(c).map_or(true, |fresh| bytes != fresh),
_ => true,
}
}