use std::collections::BTreeSet;
#[cfg(test)]
use std::path::PathBuf;
use crate::domain::CodeWorkspaceMember;
use super::{WorkspaceSource, join_relative_path};
const CARGO_TOML: &str = "Cargo.toml";
pub(super) fn detect_cargo_workspace(
source: &dyn WorkspaceSource,
) -> Option<Vec<CodeWorkspaceMember>> {
let content = source.read_to_string(CARGO_TOML)?;
let members = parse_cargo_workspace_content(source, &content);
if members.is_empty() {
None
} else {
Some(members)
}
}
fn parse_cargo_workspace_content(
source: &dyn WorkspaceSource,
content: &str,
) -> Vec<CodeWorkspaceMember> {
let member_dirs = parse_workspace_members(content);
let excluded_dirs = parse_workspace_excludes(content)
.into_iter()
.flat_map(|exclude| expand_member_pattern(source, &exclude))
.collect::<BTreeSet<_>>();
let mut result = Vec::new();
let root_package_name = read_cargo_import_crate_name(source, "");
if !root_package_name.is_empty() && !excluded_dirs.contains(".") {
result.push(CodeWorkspaceMember {
package_name: root_package_name,
relative_path: ".".to_owned(),
});
}
for relative_path in member_dirs
.into_iter()
.flat_map(|member| expand_member_pattern(source, &member))
{
if relative_path.is_empty() || relative_path == "." || relative_path == ".." {
continue;
}
if excluded_dirs.contains(&relative_path) {
continue;
}
let package_name = read_cargo_import_crate_name(source, &relative_path);
if package_name.is_empty() {
continue;
}
result.push(CodeWorkspaceMember {
package_name,
relative_path,
});
}
result
}
fn expand_member_pattern(source: &dyn WorkspaceSource, pattern: &str) -> Vec<String> {
let pattern = normalize_member_pattern(pattern);
if pattern.ends_with("/*") {
let parent = pattern.trim_end_matches("/*");
return source
.child_dirs(parent)
.into_iter()
.filter(|dir| !read_cargo_import_crate_name(source, dir).is_empty())
.collect();
}
vec![pattern]
}
fn normalize_member_pattern(pattern: &str) -> String {
pattern
.trim()
.strip_prefix("./")
.unwrap_or_else(|| pattern.trim())
.trim_matches('/')
.to_owned()
}
fn parse_workspace_members(content: &str) -> Vec<String> {
parse_workspace_array(content, "members")
}
fn parse_workspace_excludes(content: &str) -> Vec<String> {
parse_workspace_array(content, "exclude")
}
fn parse_workspace_array(content: &str, key: &str) -> Vec<String> {
let mut in_workspace_section = false;
let mut items = Vec::new();
let mut inline_buffer = String::new();
let mut collecting_array = false;
for line in content.lines() {
let trimmed = strip_toml_comment(line).trim().to_string();
if trimmed.is_empty() {
continue;
}
if trimmed == "[workspace]" {
in_workspace_section = true;
continue;
}
if trimmed.starts_with('[') && trimmed.ends_with(']') && in_workspace_section {
break;
}
if !in_workspace_section {
continue;
}
if let Some(rest) = trimmed.strip_prefix(key) {
if let Some(after_eq) = rest.trim().strip_prefix('=') {
let array_str = after_eq.trim();
if let Some(inner) = array_str.strip_prefix('[') {
if array_str.ends_with(']') && !inner.is_empty() {
items.extend(parse_toml_array_items(&array_str[1..array_str.len() - 1]));
break;
} else {
inline_buffer.push_str(inner);
inline_buffer.push('\n');
collecting_array = true;
}
}
}
continue;
}
if collecting_array {
let ends_array = trimmed.ends_with(']');
let item_line = trimmed.trim_end_matches(']').trim();
if !item_line.is_empty() {
inline_buffer.push_str(item_line);
inline_buffer.push('\n');
}
if ends_array {
items.extend(parse_toml_array_items(&inline_buffer));
inline_buffer.clear();
break;
}
}
}
if !inline_buffer.is_empty() {
items.extend(parse_toml_array_items(&inline_buffer));
}
items
}
fn parse_toml_array_items(content: &str) -> Vec<String> {
let mut items = Vec::new();
let mut current = String::new();
let mut in_quote = false;
let mut quote_char: Option<char> = None;
for ch in content.chars() {
match ch {
'"' | '\'' => {
if in_quote && quote_char == Some(ch) {
in_quote = false;
quote_char = None;
} else if !in_quote {
in_quote = true;
quote_char = Some(ch);
} else {
current.push(ch);
}
}
',' if !in_quote => {
let item = current.trim().to_string();
if !item.is_empty() {
items.push(item);
}
current.clear();
}
'\n' | '\r' => {
if in_quote {
current.push(' ');
}
}
_ => {
current.push(ch);
}
}
}
let item = current.trim().to_string();
if !item.is_empty() {
items.push(item);
}
items
}
#[cfg(test)]
fn read_cargo_package_name(source: &dyn WorkspaceSource, dir: &str) -> String {
let Some(content) = source.read_to_string(&join_relative_path(dir, CARGO_TOML)) else {
return String::new();
};
cargo_manifest_section_name(&content, "package").unwrap_or_default()
}
fn read_cargo_import_crate_name(source: &dyn WorkspaceSource, dir: &str) -> String {
let Some(content) = source.read_to_string(&join_relative_path(dir, CARGO_TOML)) else {
return String::new();
};
if let Some(lib_name) = cargo_manifest_section_name(&content, "lib") {
return lib_name;
}
cargo_manifest_section_name(&content, "package")
.map(|package_name| cargo_import_crate_name(&package_name))
.unwrap_or_default()
}
fn cargo_manifest_section_name(content: &str, section: &str) -> Option<String> {
let mut in_section = false;
for line in content.lines() {
let trimmed = strip_toml_comment(line).trim().to_string();
if trimmed.is_empty() {
continue;
}
if trimmed == format!("[{section}]") {
in_section = true;
continue;
}
if trimmed.starts_with('[') && trimmed.ends_with(']') && in_section {
break;
}
if !in_section {
continue;
}
if let Some(rest) = trimmed.strip_prefix("name") {
if let Some(after_eq) = rest.trim().strip_prefix('=') {
let name = after_eq.trim();
let name = name.trim_matches('"').trim_matches('\'');
if !name.is_empty() {
return Some(name.to_string());
}
}
}
}
None
}
fn cargo_import_crate_name(package_name: &str) -> String {
package_name.replace('-', "_")
}
fn strip_toml_comment(line: &str) -> &str {
let mut in_quote = false;
let mut quote_char: Option<char> = None;
for (i, ch) in line.char_indices() {
match ch {
'"' | '\'' => {
if in_quote && quote_char == Some(ch) {
in_quote = false;
quote_char = None;
} else if !in_quote {
in_quote = true;
quote_char = Some(ch);
}
}
'#' if !in_quote => return &line[..i],
_ => {}
}
}
line
}
#[cfg(test)]
#[path = "cargo_workspace_tests.rs"]
mod tests;