#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
#![allow(clippy::multiple_crate_versions)]
pub mod package_filter;
#[cfg(feature = "publish")]
pub mod publish;
#[cfg(feature = "versioning")]
pub mod versioning;
#[cfg(feature = "_tools")]
pub mod tools;
#[cfg(feature = "_transforms")]
pub mod transforms;
#[cfg(feature = "_workspace")]
pub mod workspace;
use std::{
cell::RefCell,
collections::{BTreeMap, BTreeSet, VecDeque},
path::Path,
sync::{LazyLock, RwLock},
};
use clap::ValueEnum;
static GLOB_CACHE: LazyLock<RwLock<BTreeMap<String, globset::GlobMatcher>>> =
LazyLock::new(|| RwLock::new(BTreeMap::new()));
fn get_or_compile_glob(pattern: &str) -> Option<globset::GlobMatcher> {
if let Ok(cache) = GLOB_CACHE.read()
&& let Some(matcher) = cache.get(pattern)
{
return Some(matcher.clone());
}
let matcher = globset::Glob::new(pattern).ok()?.compile_matcher();
if let Ok(mut cache) = GLOB_CACHE.write() {
cache.insert(pattern.to_string(), matcher.clone());
}
Some(matcher)
}
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use toml::Value;
type BoxError = Box<dyn std::error::Error + Send + Sync>;
#[cfg(feature = "git-diff")]
pub mod git_diff;
pub mod feature_validator;
#[cfg(any(test, feature = "test-utils"))]
pub mod test_utils;
#[cfg(any(test, feature = "test-utils"))]
pub use test_utils::*;
pub use feature_validator::{
FeatureValidator, ValidationResult, ValidatorConfig, print_github_output, print_human_output,
};
#[cfg(feature = "publish")]
pub use publish::{PublishConfig, handle_publish_command};
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
#[clap(rename_all = "kebab_case")]
pub enum OutputType {
Json,
Raw,
}
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
#[clap(rename_all = "kebab_case")]
pub enum ColorMode {
Auto,
Always,
Never,
}
#[cfg(any(feature = "check", feature = "format"))]
fn can_use_interactive_tui() -> bool {
use std::io::IsTerminal;
std::io::stdout().is_terminal() && std::io::stderr().is_terminal()
}
#[derive(Debug, Serialize, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct AffectedPackageInfo {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CargoLock {
pub version: u32,
pub package: Vec<CargoLockPackage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CargoLockPackage {
pub name: String,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dependencies: Option<Vec<String>>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Step {
command: Option<String>,
toolchain: Option<String>,
features: Option<Vec<String>>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum ClippierEnv {
Value(String),
FilteredValue {
value: String,
features: Option<Vec<String>>,
},
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum VecOrItem<T> {
Value(T),
Values(Vec<T>),
}
impl<T> From<VecOrItem<T>> for Vec<T> {
fn from(value: VecOrItem<T>) -> Self {
match value {
VecOrItem::Value(x) => vec![x],
VecOrItem::Values(x) => x,
}
}
}
impl<T> Default for VecOrItem<T> {
fn default() -> Self {
Self::Values(vec![])
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct RustConfig {
pub cargo: Option<VecOrItem<String>>,
pub nightly: Option<bool>,
pub skip_features: Option<Vec<String>>,
pub required_features: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct NodeConfig {
pub package_manager: Option<String>,
pub node_version: Option<String>,
pub skip_packages: Option<Vec<String>>,
pub args: Option<VecOrItem<String>>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct ClippierConfiguration {
pub ci_steps: Option<VecOrItem<Step>>,
pub env: Option<BTreeMap<String, ClippierEnv>>,
pub dependencies: Option<Vec<Step>>,
pub os: String,
pub name: Option<String>,
pub git_submodules: Option<bool>,
pub rust: Option<RustConfig>,
pub node: Option<NodeConfig>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct ParallelizationConfig {
pub chunked: u16,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct ClippierConf {
pub ci_steps: Option<VecOrItem<Step>>,
pub config: Option<Vec<ClippierConfiguration>>,
pub env: Option<BTreeMap<String, ClippierEnv>>,
pub parallelization: Option<ParallelizationConfig>,
pub git_submodules: Option<bool>,
pub rust: Option<RustConfig>,
pub node: Option<NodeConfig>,
#[cfg(feature = "_tools")]
pub tools: Option<tools::ToolsConfig>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct WorkspaceClippierConf {
pub config: Option<Vec<ClippierConfiguration>>,
pub env: Option<BTreeMap<String, ClippierEnv>>,
pub ci_steps: Option<VecOrItem<Step>>,
pub git_submodules: Option<bool>,
pub dependencies: Option<Vec<Step>>,
pub rust: Option<RustConfig>,
pub node: Option<NodeConfig>,
}
#[derive(Debug, Clone)]
pub enum FeaturesList {
Chunked(Vec<Vec<String>>),
NotChunked(Vec<String>),
}
#[derive(Debug, Clone, Default)]
pub struct PropagatedConfig {
pub git_submodules: Option<bool>,
pub dependencies: Vec<Step>,
pub ci_steps: Vec<Step>,
pub env: BTreeMap<String, ClippierEnv>,
}
pub fn split<T>(slice: &[T], n: usize) -> impl Iterator<Item = &[T]> {
if slice.is_empty() || n == 0 {
return SplitIter::empty();
}
let chunk_size = slice.len().div_ceil(n);
SplitIter::new(slice, chunk_size)
}
pub struct SplitIter<'a, T> {
slice: &'a [T],
chunk_size: usize,
}
impl<'a, T> SplitIter<'a, T> {
const fn new(slice: &'a [T], chunk_size: usize) -> Self {
Self { slice, chunk_size }
}
const fn empty() -> Self {
Self {
slice: &[],
chunk_size: 0,
}
}
}
impl<'a, T> Iterator for SplitIter<'a, T> {
type Item = &'a [T];
fn next(&mut self) -> Option<Self::Item> {
if self.slice.is_empty() {
return None;
}
let chunk_size = self.chunk_size.min(self.slice.len());
let (chunk, rest) = self.slice.split_at(chunk_size);
self.slice = rest;
Some(chunk)
}
}
#[must_use]
pub fn process_features(
features: Vec<String>,
chunked: Option<u16>,
spread: bool,
randomize: bool,
seed: Option<u64>,
) -> FeaturesList {
let mut features = features;
if randomize {
use switchy_random::rand::rand::seq::SliceRandom;
let actual_seed = seed.unwrap_or_else(|| {
let generated_seed = switchy_random::rng().next_u64();
eprintln!("Generated seed: {generated_seed}");
generated_seed
});
let mut rng = switchy_random::Rng::from_seed(actual_seed);
features.shuffle(&mut rng);
}
if let Some(max_features_per_chunk) = chunked {
let chunk_size = max_features_per_chunk as usize;
if spread && features.len() > chunk_size {
let num_chunks = features.len().div_ceil(chunk_size);
let mut result = vec![Vec::new(); num_chunks];
for (i, feature) in features.into_iter().enumerate() {
let chunk_index = i % num_chunks;
if result[chunk_index].len() < chunk_size {
result[chunk_index].push(feature);
} else {
if let Some(available_chunk) =
result.iter_mut().find(|chunk| chunk.len() < chunk_size)
{
available_chunk.push(feature);
} else {
result.push(vec![feature]);
}
}
}
FeaturesList::Chunked(result.into_iter().filter(|v| !v.is_empty()).collect())
} else {
let chunked_features: Vec<Vec<String>> = features
.chunks(chunk_size)
.map(<[std::string::String]>::to_vec)
.collect();
FeaturesList::Chunked(chunked_features)
}
} else {
FeaturesList::NotChunked(features)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DependencyKind {
WorkspaceReference,
WorkspaceMember,
External,
}
#[derive(Debug, Clone)]
struct DependencyInfo<'a> {
name: &'a str,
kind: DependencyKind,
is_optional: bool,
}
pub struct WorkspaceContext {
root: std::path::PathBuf,
member_patterns: Vec<String>,
member_cache: RefCell<BTreeMap<String, std::path::PathBuf>>,
path_cache: RefCell<BTreeSet<std::path::PathBuf>>,
fully_loaded: RefCell<bool>,
#[allow(clippy::option_option)]
workspace_config: RefCell<Option<Option<WorkspaceClippierConf>>>,
}
impl WorkspaceContext {
fn new(workspace_root: &Path) -> Result<Self, BoxError> {
let workspace_cargo = workspace_root.join("Cargo.toml");
let content = switchy_fs::sync::read_to_string(&workspace_cargo)?;
let root_toml: Value = toml::from_str(&content)?;
let mut raw_patterns = Vec::new();
if let Some(Value::Table(workspace)) = root_toml.get("workspace")
&& let Some(Value::Array(member_list)) = workspace.get("members")
{
for member in member_list {
if let Value::String(member_pattern) = member {
raw_patterns.push(member_pattern.clone());
}
}
}
let member_patterns = Self::expand_member_globs(workspace_root, &raw_patterns);
log::debug!(
"WorkspaceContext: expanded {} patterns to {} member paths",
raw_patterns.len(),
member_patterns.len()
);
Ok(Self {
root: workspace_root.to_path_buf(),
member_patterns,
member_cache: RefCell::new(BTreeMap::new()),
path_cache: RefCell::new(BTreeSet::new()),
fully_loaded: RefCell::new(false),
workspace_config: RefCell::new(None),
})
}
fn expand_member_globs(workspace_root: &Path, patterns: &[String]) -> Vec<String> {
let patterns_ref: Vec<&str> = patterns.iter().map(String::as_str).collect();
expand_workspace_member_globs(workspace_root, &patterns_ref)
}
fn is_member_by_path(&self, path: &Path) -> bool {
let Ok(canonical) = switchy_fs::sync::canonicalize(path) else {
return false;
};
if self.path_cache.borrow().contains(&canonical) {
return true;
}
for pattern in &self.member_patterns {
let member_path = self.root.join(pattern);
if let Ok(member_canonical) = switchy_fs::sync::canonicalize(&member_path)
&& member_canonical == canonical
{
self.path_cache.borrow_mut().insert(canonical.clone());
if let Some(name) = Self::read_package_name(&canonical) {
self.member_cache.borrow_mut().insert(name, canonical);
}
return true;
}
}
false
}
fn ensure_fully_loaded(&self) {
if *self.fully_loaded.borrow() {
return;
}
log::trace!(
"🔄 Loading all {} workspace members",
self.member_patterns.len()
);
let start = std::time::Instant::now();
for pattern in &self.member_patterns {
let member_path = self.root.join(pattern);
if switchy_fs::exists(&member_path)
&& let Ok(canonical) = switchy_fs::sync::canonicalize(&member_path)
&& !self.path_cache.borrow().contains(&canonical)
&& let Some(actual_name) = Self::read_package_name(&canonical)
{
self.member_cache
.borrow_mut()
.insert(actual_name, canonical.clone());
self.path_cache.borrow_mut().insert(canonical);
}
}
*self.fully_loaded.borrow_mut() = true;
log::trace!(
"✅ Loaded {} members in {:?}",
self.member_cache.borrow().len(),
start.elapsed()
);
}
fn is_member_by_name(&self, name: &str) -> bool {
self.ensure_fully_loaded();
self.member_cache.borrow().contains_key(name)
}
fn find_member(&self, name: &str) -> Option<std::path::PathBuf> {
if let Some(path) = self.member_cache.borrow().get(name) {
return Some(path.clone());
}
if self.is_member_by_name(name) {
self.member_cache.borrow().get(name).cloned()
} else {
None
}
}
fn read_package_name(package_path: &Path) -> Option<String> {
let cargo_toml_path = package_path.join("Cargo.toml");
let content = switchy_fs::sync::read_to_string(cargo_toml_path).ok()?;
let toml: Value = toml::from_str(&content).ok()?;
toml.get("package")?.get("name")?.as_str().map(String::from)
}
fn workspace_config(&self) -> Result<Option<WorkspaceClippierConf>, BoxError> {
if let Some(cached) = self.workspace_config.borrow().as_ref() {
return Ok(cached.clone());
}
let workspace_conf_path = self.root.join("clippier.toml");
let result = if switchy_fs::exists(&workspace_conf_path) {
log::trace!(
"Loading workspace config from: {}",
workspace_conf_path.display()
);
let content = switchy_fs::sync::read_to_string(&workspace_conf_path)?;
let conf: WorkspaceClippierConf = toml::from_str(&content)?;
Some(conf)
} else {
log::trace!(
"No workspace-level clippier.toml found at: {}",
workspace_conf_path.display()
);
None
};
*self.workspace_config.borrow_mut() = Some(result.clone());
Ok(result)
}
}
fn classify_dependency(
dep_value: &Value,
context: &WorkspaceContext,
package_path: &Path,
) -> DependencyKind {
if let Value::Table(table) = dep_value {
if table.get("workspace") == Some(&Value::Boolean(true)) {
return DependencyKind::WorkspaceReference;
}
if let Some(Value::String(path_str)) = table.get("path") {
let dep_path = package_path.join(path_str);
if context.is_member_by_path(&dep_path) {
return DependencyKind::WorkspaceMember;
}
}
}
DependencyKind::External
}
fn iterate_dependencies<'a>(
cargo_toml: &'a Value,
context: &'a WorkspaceContext,
package_path: &'a Path,
) -> impl Iterator<Item = DependencyInfo<'a>> + 'a {
const SECTIONS: [&str; 3] = ["dependencies", "dev-dependencies", "build-dependencies"];
SECTIONS.into_iter().flat_map(move |section_name| {
cargo_toml
.get(section_name)
.and_then(Value::as_table)
.into_iter()
.flat_map(move |deps_table| {
deps_table.iter().map(move |(dep_name, dep_value)| {
let kind = classify_dependency(dep_value, context, package_path);
let is_optional = if let Value::Table(table) = dep_value {
table.get("optional") == Some(&Value::Boolean(true))
} else {
false
};
DependencyInfo {
name: dep_name,
kind,
is_optional,
}
})
})
})
}
fn extract_dependencies<F>(
cargo_toml: &Value,
context: &WorkspaceContext,
package_path: &Path,
filter: F,
) -> Vec<String>
where
F: Fn(&DependencyInfo) -> bool,
{
let mut deps: Vec<String> = iterate_dependencies(cargo_toml, context, package_path)
.filter(filter)
.map(|dep| dep.name.to_string())
.collect();
deps.sort();
deps.dedup();
deps
}
fn extract_workspace_deps_simple(
cargo_toml: &Value,
context: &WorkspaceContext,
package_path: &Path,
) -> Vec<String> {
extract_dependencies(cargo_toml, context, package_path, |dep| match dep.kind {
DependencyKind::WorkspaceMember => true,
DependencyKind::WorkspaceReference => context.is_member_by_name(dep.name),
DependencyKind::External => false,
})
}
fn merge_steps(mut base: Vec<Step>, overlay: Vec<Step>) -> Vec<Step> {
for step in overlay {
if !base.iter().any(|s| {
s.command == step.command
&& s.toolchain == step.toolchain
&& s.features == step.features
}) {
base.push(step);
}
}
base
}
fn merge_env_maps(
mut base: BTreeMap<String, ClippierEnv>,
overlay: BTreeMap<String, ClippierEnv>,
) -> BTreeMap<String, ClippierEnv> {
for (key, value) in overlay {
base.insert(key, value);
}
base
}
fn merge_propagated_configs(base: PropagatedConfig, overlay: PropagatedConfig) -> PropagatedConfig {
PropagatedConfig {
git_submodules: match (base.git_submodules, overlay.git_submodules) {
(Some(true), _) | (_, Some(true)) => Some(true),
(Some(false), None | Some(false)) | (None, Some(false)) => Some(false),
(None, None) => None,
},
dependencies: merge_steps(base.dependencies, overlay.dependencies),
ci_steps: merge_steps(base.ci_steps, overlay.ci_steps),
env: merge_env_maps(base.env, overlay.env),
}
}
#[allow(clippy::too_many_lines, clippy::similar_names)]
fn collect_propagated_config(
context: &WorkspaceContext,
package_name: &str,
os_filter: Option<&str>,
visited: &mut BTreeSet<String>,
cache: &mut BTreeMap<String, PropagatedConfig>,
) -> Result<PropagatedConfig, BoxError> {
if let Some(cached) = cache.get(package_name) {
return Ok(cached.clone());
}
if visited.contains(package_name) {
log::trace!("🔁 Circular dependency detected for {package_name}, returning empty config");
return Ok(PropagatedConfig::default());
}
visited.insert(package_name.to_string());
log::trace!("📦 Collecting propagated config for package: {package_name}");
let package_path = context
.find_member(package_name)
.ok_or_else(|| format!("Package {package_name} not found in workspace"))?;
let cargo_toml_path = package_path.join("Cargo.toml");
if !switchy_fs::exists(&cargo_toml_path) {
log::trace!("⚠️ No Cargo.toml found for {package_name}, skipping");
visited.remove(package_name);
return Ok(PropagatedConfig::default());
}
let cargo_toml_content = switchy_fs::sync::read_to_string(&cargo_toml_path)?;
let cargo_toml: Value = toml::from_str(&cargo_toml_content)?;
let clippier_toml_path = package_path.join("clippier.toml");
let own_config = if switchy_fs::exists(&clippier_toml_path) {
let content = switchy_fs::sync::read_to_string(&clippier_toml_path)?;
let conf: ClippierConf = toml::from_str(&content)?;
let mut prop = PropagatedConfig {
git_submodules: conf.git_submodules,
dependencies: Vec::new(),
ci_steps: Vec::new(),
env: conf.env.unwrap_or_default(),
};
if let Some(configs) = &conf.config {
for config in configs {
let os_matches = os_filter.is_none() || os_filter == Some(&config.os);
if os_matches {
if let Some(deps) = &config.dependencies {
prop.dependencies.extend(deps.clone());
}
if let Some(steps) = &config.ci_steps {
match steps {
VecOrItem::Value(step) => prop.ci_steps.push(step.clone()),
VecOrItem::Values(steps) => prop.ci_steps.extend(steps.clone()),
}
}
}
if config.git_submodules.is_some() {
prop.git_submodules = prop.git_submodules.or(config.git_submodules);
}
}
}
if let Some(steps) = &conf.ci_steps {
match steps {
VecOrItem::Value(step) => prop.ci_steps.push(step.clone()),
VecOrItem::Values(steps) => prop.ci_steps.extend(steps.clone()),
}
}
prop
} else {
PropagatedConfig::default()
};
let workspace_deps = extract_workspace_deps_simple(&cargo_toml, context, &package_path);
let mut merged = PropagatedConfig::default();
for dep in workspace_deps {
log::trace!(" ↳ Processing dependency: {dep}");
match collect_propagated_config(context, &dep, os_filter, visited, cache) {
Ok(dep_config) => {
merged = merge_propagated_configs(merged, dep_config);
}
Err(e) => {
log::warn!("Failed to collect config for dependency {dep}: {e}");
}
}
}
merged = merge_propagated_configs(merged, own_config);
cache.insert(package_name.to_string(), merged.clone());
visited.remove(package_name);
log::trace!(
"✅ Collected config for {package_name}: git_submodules={:?}, deps={}, ci_steps={}, env={}",
merged.git_submodules,
merged.dependencies.len(),
merged.ci_steps.len(),
merged.env.len()
);
Ok(merged)
}
fn find_workspace_root_from_package(package_path: &Path) -> Result<std::path::PathBuf, BoxError> {
let mut current = package_path.to_path_buf();
while let Some(parent) = current.parent() {
let cargo_toml = parent.join("Cargo.toml");
if switchy_fs::exists(&cargo_toml) {
let content = switchy_fs::sync::read_to_string(&cargo_toml)?;
let toml_value: Value = toml::from_str(&content)?;
if toml_value.get("workspace").is_some() {
return Ok(parent.to_path_buf());
}
}
current = parent.to_path_buf();
}
Err("Workspace root not found".into())
}
#[must_use]
pub fn should_skip_feature(feature: &str, patterns: &[String]) -> bool {
let mut should_skip = false;
for pattern in patterns {
let (is_negation, pattern_str) = pattern
.strip_prefix('!')
.map_or((false, pattern.as_str()), |p| (true, p));
let matches = if pattern_str.contains('*') || pattern_str.contains('?') {
globset::Glob::new(pattern_str)
.ok()
.is_some_and(|g| g.compile_matcher().is_match(feature))
} else {
feature == pattern_str
};
if matches {
should_skip = !is_negation;
}
}
should_skip
}
#[must_use]
pub fn matches_pattern(item: &str, pattern: &str) -> bool {
if pattern.contains('*') || pattern.contains('?') {
globset::Glob::new(pattern)
.ok()
.is_some_and(|g| g.compile_matcher().is_match(item))
} else {
item == pattern
}
}
#[must_use]
pub fn expand_pattern_list(patterns: &[String], available_items: &[String]) -> Vec<String> {
let mut result = Vec::new();
let mut to_remove = Vec::new();
for pattern in patterns {
let (is_negation, pattern_str) = pattern
.strip_prefix('!')
.map_or((false, pattern.as_str()), |p| (true, p));
if is_negation {
if pattern_str.contains('*') || pattern_str.contains('?') {
for item in available_items {
if matches_pattern(item, pattern_str) && !to_remove.contains(item) {
to_remove.push(item.clone());
}
}
} else {
let pattern_string = pattern_str.to_string();
if !to_remove.contains(&pattern_string) {
to_remove.push(pattern_string);
}
}
} else {
if pattern_str.contains('*') || pattern_str.contains('?') {
for item in available_items {
if matches_pattern(item, pattern_str) && !result.contains(item) {
result.push(item.clone());
}
}
} else {
let pattern_string = pattern_str.to_string();
if !result.contains(&pattern_string) {
result.push(pattern_string);
}
}
}
}
result.retain(|item| !to_remove.contains(item));
result
}
#[must_use]
pub fn expand_features_from_cargo_toml(cargo_toml: &Value, patterns: &[String]) -> Vec<String> {
let Some(Value::Table(features_table)) = cargo_toml.get("features") else {
return vec![];
};
let all_features: Vec<String> = features_table
.keys()
.filter(|k| !k.starts_with('_'))
.cloned()
.collect();
expand_pattern_list(patterns, &all_features)
}
#[must_use]
pub fn expand_active_package_features(
cargo_toml: &Value,
requested_features: &[String],
) -> BTreeSet<String> {
let mut visited = BTreeSet::new();
let Some(Value::Table(features_table)) = cargo_toml.get("features") else {
visited.extend(requested_features.iter().cloned());
return visited;
};
let feature_names = features_table.keys().cloned().collect::<BTreeSet<_>>();
let mut adjacency: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (feature_name, feature_value) in features_table {
let mut next_features = Vec::new();
if let Value::Array(items) = feature_value {
for item in items {
let Some(item_str) = item.as_str() else {
continue;
};
if feature_names.contains(item_str) {
next_features.push(item_str.to_string());
continue;
}
if let Some((left, _)) = item_str.split_once('/')
&& feature_names.contains(left)
{
next_features.push(left.to_string());
}
}
}
adjacency.insert(feature_name.clone(), next_features);
}
let mut stack = requested_features.to_vec();
while let Some(feature) = stack.pop() {
if !visited.insert(feature.clone()) {
continue;
}
if let Some(next) = adjacency.get(&feature) {
stack.extend(next.iter().cloned());
}
}
visited
}
#[must_use]
pub fn fetch_features(
cargo_toml: &Value,
offset: Option<u16>,
max: Option<u16>,
specific_features: Option<&[String]>,
skip_features: Option<&[String]>,
_required_features: Option<&[String]>,
) -> Vec<String> {
let Some(Value::Table(features_table)) = cargo_toml.get("features") else {
return vec![];
};
let all_features: Vec<String> = features_table
.keys()
.filter(|k| !k.starts_with('_'))
.cloned()
.collect();
let mut features: Vec<String> = specific_features.map_or_else(
|| all_features.clone(),
|patterns| {
expand_pattern_list(patterns, &all_features)
},
);
if let Some(skip) = skip_features {
features.retain(|f| !should_skip_feature(f, skip));
}
let offset = offset.unwrap_or(0) as usize;
if offset < features.len() {
features = features[offset..].to_vec();
} else {
features = vec![];
}
if let Some(max_count) = max {
features.truncate(max_count as usize);
}
features
}
#[must_use]
pub fn is_workspace_dependency(dep_value: &Value) -> bool {
match dep_value {
Value::Table(table) => table.get("workspace") == Some(&Value::Boolean(true)),
_ => false,
}
}
#[must_use]
pub fn is_workspace_dependency_with_features(dep_value: &Value) -> bool {
if !is_workspace_dependency(dep_value) {
return false;
}
match dep_value {
Value::Table(table) => {
if table.get("optional") == Some(&Value::Boolean(true)) {
return false;
}
true
}
_ => false,
}
}
#[must_use]
pub fn get_dependency_default_features(dep_value: &Value) -> Option<bool> {
match dep_value {
Value::Table(table) => {
table.get("default-features").map_or_else(
|| {
table.get("default_features").and_then(
|default_features| match default_features {
Value::Boolean(b) => Some(*b),
_ => None,
},
)
},
|default_features| match default_features {
Value::Boolean(b) => Some(*b),
_ => None,
},
)
}
_ => None,
}
}
#[must_use]
pub fn is_git_url(path: &str) -> bool {
path.starts_with("http://")
|| path.starts_with("https://")
|| path.starts_with("git@")
|| path.starts_with("ssh://git@")
|| path.starts_with("git://")
}
#[must_use]
pub fn get_binary_name(
workspace_root: &Path,
target_package: &str,
target_package_path: &str,
bin_override: Option<&str>,
) -> String {
if let Some(bin_name) = bin_override {
return bin_name.to_string();
}
let cargo_path = workspace_root.join(target_package_path).join("Cargo.toml");
if let Ok(source) = switchy_fs::sync::read_to_string(&cargo_path)
&& let Ok(value) = toml::from_str::<Value>(&source)
{
if let Some(bins) = value.get("bin").and_then(|b| b.as_array()) {
if let Some(bin) = bins.first()
&& let Some(bin_name) = bin.get("name").and_then(|n| n.as_str())
{
return bin_name.to_string();
}
}
if let Some(bin) = value.get("bin").and_then(|b| b.as_table())
&& let Some(bin_name) = bin.get("name").and_then(|n| n.as_str())
{
return bin_name.to_string();
}
}
target_package.replace('-', "_")
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
pub async fn process_configs(
path: &Path,
offset: Option<u16>,
max: Option<u16>,
chunked: Option<u16>,
spread: bool,
randomize: bool,
seed: Option<u64>,
specific_features: Option<&[String]>,
skip_features_override: Option<&[String]>,
required_features_override: Option<&[String]>,
) -> Result<Vec<serde_json::Map<String, serde_json::Value>>, BoxError> {
log::debug!("Loading file '{}'", path.display());
let cargo_path = path.join("Cargo.toml");
let source = switchy_fs::unsync::read_to_string(cargo_path).await?;
let value: Value = toml::from_str(&source)?;
let conf_path = path.join("clippier.toml");
let conf = if switchy_fs::unsync::is_file(&conf_path).await {
let source = switchy_fs::unsync::read_to_string(conf_path).await?;
let value: ClippierConf = toml::from_str(&source)?;
Some(value)
} else {
None
};
log::debug!("{} conf={conf:?}", path.display());
let default_config = vec![ClippierConfiguration {
os: "ubuntu".to_string(),
dependencies: None,
env: None,
name: None,
ci_steps: None,
git_submodules: None,
rust: None,
node: None,
}];
let workspace_root =
find_workspace_root_from_package(path).unwrap_or_else(|_| path.to_path_buf());
let workspace_context = WorkspaceContext::new(&workspace_root)?;
let workspace_conf = workspace_context.workspace_config().ok().flatten();
let configs = conf
.as_ref()
.and_then(|x| x.config.clone())
.or_else(|| workspace_conf.as_ref().and_then(|x| x.config.clone()))
.unwrap_or(default_config);
let mut packages = vec![];
if let Some(name) = value
.get("package")
.and_then(|x| x.get("name"))
.and_then(|x| x.as_str())
.map(str::to_string)
{
for config in configs {
let config_rust = config.rust.as_ref();
let config_skip_features = config_rust.and_then(|r| r.skip_features.as_deref());
let config_required_features = config_rust.and_then(|r| r.required_features.as_deref());
let combined_skip_features = match (skip_features_override, config_skip_features) {
(Some(override_features), Some(config_features)) => {
let mut combined = override_features.to_vec();
for feature in config_features {
if !combined.contains(feature) {
combined.push(feature.clone());
}
}
Some(combined)
}
(Some(override_features), None) => Some(override_features.to_vec()),
(None, Some(config_features)) => Some(config_features.to_vec()),
(None, None) => None,
};
let features = fetch_features(
&value,
offset,
max,
specific_features,
combined_skip_features.as_deref(),
required_features_override.or(config_required_features),
);
let features = process_features(
features,
conf.as_ref()
.and_then(|x| x.parallelization.as_ref().map(|x| x.chunked))
.or(chunked),
spread,
randomize,
seed,
);
let expanded_required_features = required_features_override
.or(config_required_features)
.map(|patterns| expand_features_from_cargo_toml(&value, patterns));
match &features {
FeaturesList::Chunked(x) => {
if x.is_empty() {
packages.push(create_map(
&workspace_context,
&value,
&name,
conf.as_ref(),
&config,
path.to_str().unwrap(),
&name,
expanded_required_features.as_deref(),
&[],
)?);
} else {
for features in x {
packages.push(create_map(
&workspace_context,
&value,
&name,
conf.as_ref(),
&config,
path.to_str().unwrap(),
&name,
expanded_required_features.as_deref(),
features,
)?);
}
}
}
FeaturesList::NotChunked(x) => {
packages.push(create_map(
&workspace_context,
&value,
&name,
conf.as_ref(),
&config,
path.to_str().unwrap(),
&name,
expanded_required_features.as_deref(),
x,
)?);
}
}
}
}
Ok(packages)
}
pub fn apply_max_parallel_rechunking(
packages: Vec<serde_json::Map<String, serde_json::Value>>,
max_parallel: usize,
chunked: Option<u16>,
) -> Result<Vec<serde_json::Map<String, serde_json::Value>>, BoxError> {
if packages.len() <= max_parallel {
return Ok(packages);
}
let mut result = Vec::new();
let total_packages = packages.len();
let base_packages_per_slot = total_packages / max_parallel;
let extra_packages = total_packages % max_parallel;
let mut package_index = 0;
for slot_index in 0..max_parallel {
if package_index >= total_packages {
break;
}
let packages_for_this_slot = if slot_index < extra_packages {
base_packages_per_slot + 1
} else {
base_packages_per_slot
};
if packages_for_this_slot == 0 {
break;
}
let end_index = (package_index + packages_for_this_slot).min(total_packages);
let chunk_packages = &packages[package_index..end_index];
if chunk_packages.len() == 1 {
result.push(chunk_packages[0].clone());
} else {
let mut combined_features = Vec::new();
let template_package = chunk_packages[0].clone();
for package in chunk_packages {
if let Some(features) = package.get("features").and_then(|f| f.as_array()) {
for feature in features {
if let Some(feature_str) = feature.as_str() {
combined_features.push(feature_str.to_string());
}
}
}
}
combined_features.sort();
combined_features.dedup();
if let Some(chunk_limit) = chunked {
let chunk_limit = chunk_limit as usize;
if combined_features.len() > chunk_limit {
combined_features.truncate(chunk_limit);
}
}
let mut combined_package = template_package;
combined_package.insert(
"features".to_string(),
serde_json::to_value(combined_features)?,
);
result.push(combined_package);
}
package_index = end_index;
}
result.sort_by(|a, b| {
let name_a = a.get("name").and_then(|v| v.as_str()).unwrap_or("");
let name_b = b.get("name").and_then(|v| v.as_str()).unwrap_or("");
name_a.cmp(name_b)
});
Ok(result)
}
#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
pub fn create_map(
context: &WorkspaceContext,
cargo_toml: &Value,
package_name: &str,
conf: Option<&ClippierConf>,
config: &ClippierConfiguration,
file: &str,
name: &str,
required_features: Option<&[String]>,
features: &[String],
) -> Result<serde_json::Map<String, serde_json::Value>, BoxError> {
let mut visited = BTreeSet::new();
let mut cache = BTreeMap::new();
let propagated = collect_propagated_config(
context,
package_name,
Some(&config.os),
&mut visited,
&mut cache,
)
.unwrap_or_default();
let workspace_conf = context.workspace_config().ok().flatten();
let mut map = serde_json::Map::new();
let expanded_features = expand_active_package_features(cargo_toml, features);
map.insert("os".to_string(), serde_json::to_value(&config.os)?);
map.insert("path".to_string(), serde_json::to_value(file)?);
map.insert(
"name".to_string(),
serde_json::to_value(config.name.as_deref().unwrap_or(name))?,
);
map.insert("features".to_string(), features.into());
map.insert("requiredFeatures".to_string(), required_features.into());
map.insert(
"nightly".to_string(),
config
.rust
.as_ref()
.and_then(|r| r.nightly)
.or_else(|| {
conf.as_ref()
.and_then(|x| x.rust.as_ref())
.and_then(|r| r.nightly)
})
.or_else(|| {
workspace_conf
.as_ref()
.and_then(|x| x.rust.as_ref())
.and_then(|r| r.nightly)
})
.unwrap_or_default()
.into(),
);
let mut all_dependencies = propagated.dependencies.clone();
if let Some(workspace_deps) = workspace_conf
.as_ref()
.and_then(|x| x.dependencies.as_ref())
{
all_dependencies = merge_steps(all_dependencies, workspace_deps.clone());
}
if let Some(dependencies) = &config.dependencies {
all_dependencies = merge_steps(all_dependencies, dependencies.clone());
}
if !all_dependencies.is_empty() {
let dependencies = &all_dependencies;
let matches = dependencies
.iter()
.filter(|x| {
x.features.as_ref().is_none_or(|f| {
f.iter()
.any(|required| expanded_features.contains(required))
})
})
.collect::<Vec<_>>();
if !matches.is_empty() {
let dependencies = matches
.iter()
.filter_map(|x| x.command.as_ref())
.map(String::as_str)
.collect::<Vec<_>>();
if !dependencies.is_empty() {
map.insert(
"dependencies".to_string(),
serde_json::to_value(dependencies.join("\n"))?,
);
}
let toolchains = matches
.iter()
.filter_map(|x| x.toolchain.as_ref())
.map(String::as_str)
.collect::<Vec<_>>();
if !toolchains.is_empty() {
map.insert(
"toolchains".to_string(),
serde_json::to_value(toolchains.join("\n"))?,
);
}
}
}
let mut env = propagated.env.clone();
if let Some(workspace_env) = workspace_conf.as_ref().and_then(|x| x.env.as_ref()) {
env.extend(workspace_env.clone());
}
if let Some(conf_env) = conf.and_then(|x| x.env.as_ref()) {
env.extend(conf_env.clone());
}
env.extend(config.env.clone().unwrap_or_default());
let matches = env
.iter()
.filter(|(_k, v)| match v {
ClippierEnv::Value(..) => true,
ClippierEnv::FilteredValue { features: f, .. } => f.as_ref().is_none_or(|f| {
f.iter()
.any(|required| expanded_features.contains(required))
}),
})
.map(|(k, v)| {
(
k,
match v {
ClippierEnv::Value(value) | ClippierEnv::FilteredValue { value, .. } => value,
},
)
})
.collect::<Vec<_>>();
if !matches.is_empty() {
map.insert(
"env".to_string(),
serde_json::to_value(
matches
.iter()
.map(|(k, v)| serde_json::to_value(v).map(|v| format!("{k}={v}")))
.collect::<Result<Vec<_>, _>>()?
.join("\n"),
)?,
);
}
let mut cargo: Vec<_> = workspace_conf
.as_ref()
.and_then(|x| x.rust.as_ref())
.and_then(|r| r.cargo.as_ref())
.cloned()
.unwrap_or_default()
.into();
let conf_cargo: Vec<_> = conf
.and_then(|x| x.rust.as_ref())
.and_then(|r| r.cargo.as_ref())
.cloned()
.unwrap_or_default()
.into();
cargo.extend(conf_cargo);
let config_cargo: Vec<_> = config
.rust
.as_ref()
.and_then(|r| r.cargo.clone())
.unwrap_or_default()
.into();
cargo.extend(config_cargo);
if !cargo.is_empty() {
map.insert("cargo".to_string(), serde_json::to_value(cargo.join(" "))?);
}
let mut ci_steps: Vec<_> = propagated.ci_steps.clone();
if let Some(workspace_ci_steps) = workspace_conf.as_ref().and_then(|x| x.ci_steps.as_ref()) {
let workspace_ci_steps_vec: Vec<_> = workspace_ci_steps.clone().into();
ci_steps = merge_steps(ci_steps, workspace_ci_steps_vec);
}
if let Some(conf_ci_steps) = conf.and_then(|x| x.ci_steps.as_ref()) {
let conf_ci_steps_vec: Vec<_> = conf_ci_steps.clone().into();
ci_steps = merge_steps(ci_steps, conf_ci_steps_vec);
}
let config_ci_steps: Vec<_> = config.ci_steps.clone().unwrap_or_default().into();
ci_steps = merge_steps(ci_steps, config_ci_steps);
let matches = ci_steps
.iter()
.filter(|x| {
x.features.as_ref().is_none_or(|f| {
f.iter()
.any(|required| expanded_features.contains(required))
})
})
.collect::<Vec<_>>();
if !matches.is_empty() {
let commands = matches
.iter()
.filter_map(|x| x.command.as_ref())
.map(String::as_str)
.collect::<Vec<_>>();
if !commands.is_empty() {
map.insert(
"ciSteps".to_string(),
serde_json::to_value(commands.join("\n"))?,
);
}
let toolchains = matches
.iter()
.filter_map(|x| x.toolchain.as_ref())
.map(String::as_str)
.collect::<Vec<_>>();
if !toolchains.is_empty() {
map.insert(
"ciToolchains".to_string(),
serde_json::to_value(toolchains.join("\n"))?,
);
}
}
if let Some(git_submodules) = propagated
.git_submodules
.or(config.git_submodules)
.or_else(|| conf.and_then(|x| x.git_submodules))
.or_else(|| workspace_conf.as_ref().and_then(|x| x.git_submodules))
{
map.insert(
"gitSubmodules".to_string(),
serde_json::to_value(git_submodules)?,
);
}
Ok(map)
}
#[allow(clippy::too_many_lines, clippy::cognitive_complexity)]
pub fn find_workspace_dependencies(
workspace_root: &Path,
target_package: &str,
enabled_features: Option<&[String]>,
all_potential_deps: bool,
) -> Result<Vec<(String, String)>, BoxError> {
log::trace!("🔍 Finding workspace dependencies for package: {target_package}");
if let Some(features) = enabled_features {
log::trace!("📋 Enabled features: {features:?}");
} else {
log::trace!("📋 Using default features");
}
let workspace_context = WorkspaceContext::new(workspace_root)?;
let workspace_cargo_path = workspace_root.join("Cargo.toml");
log::trace!(
"📂 Loading workspace from: {}",
workspace_cargo_path.display()
);
let workspace_source = switchy_fs::sync::read_to_string(&workspace_cargo_path)?;
let workspace_value: Value = toml::from_str(&workspace_source)?;
let workspace_members_raw = workspace_value
.get("workspace")
.and_then(|x| x.get("members"))
.and_then(|x| x.as_array())
.and_then(|x| x.iter().map(|x| x.as_str()).collect::<Option<Vec<_>>>())
.ok_or("No workspace members found")?;
let workspace_members = expand_workspace_member_globs(workspace_root, &workspace_members_raw);
log::trace!("🏢 Found {} workspace members", workspace_members.len());
let mut package_paths = BTreeMap::new();
let mut package_dependencies: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut package_cargo_values: BTreeMap<String, Value> = BTreeMap::new();
for member_path in &workspace_members {
let full_path = workspace_root.join(member_path);
let cargo_path = full_path.join("Cargo.toml");
if !switchy_fs::exists(&cargo_path) {
log::trace!("⚠️ Skipping {member_path}: Cargo.toml not found");
continue;
}
log::trace!("📄 Processing package: {member_path}");
let source = switchy_fs::sync::read_to_string(&cargo_path)?;
let value: Value = toml::from_str(&source)?;
if let Some(package_name) = value
.get("package")
.and_then(|x| x.get("name"))
.and_then(|x| x.as_str())
{
log::trace!("📦 Package name: {package_name} -> {member_path}");
package_paths.insert(package_name.to_string(), member_path.clone());
package_cargo_values.insert(package_name.to_string(), value.clone());
let deps =
extract_workspace_dependencies(&value, &workspace_context, all_potential_deps);
log::trace!("📊 Direct dependencies for {package_name}: {deps:?}");
package_dependencies.insert(package_name.to_string(), deps);
}
}
if !package_paths.contains_key(target_package) {
return Err(format!("Package '{target_package}' not found in workspace").into());
}
log::trace!(
"🚀 Starting recursive dependency resolution from target package: {target_package}"
);
let mut resolved_dependencies = BTreeSet::new();
let mut processing_queue = VecDeque::new();
let mut visited = BTreeSet::new();
processing_queue.push_back((
target_package.to_string(),
enabled_features.map(<[String]>::to_vec),
));
while let Some((current_package, current_features)) = processing_queue.pop_front() {
if visited.contains(¤t_package) {
continue;
}
visited.insert(current_package.clone());
if current_package != target_package
&& let Some(package_path) = package_paths.get(¤t_package)
{
resolved_dependencies.insert((current_package.clone(), package_path.clone()));
}
if let Some(direct_deps) = package_dependencies.get(¤t_package) {
for dep_name in direct_deps {
if !visited.contains(dep_name) && package_paths.contains_key(dep_name) {
let is_activated = if all_potential_deps {
true } else {
is_dependency_activated(
&package_cargo_values,
¤t_package,
dep_name,
current_features.as_deref(),
)
};
if is_activated {
log::trace!(" ✅ Adding activated dependency: {dep_name}");
processing_queue.push_back((dep_name.clone(), None));
} else {
log::trace!(" ⏸️ Skipping dependency (not activated): {dep_name}");
}
}
}
}
}
let mut result_paths: Vec<(String, String)> = resolved_dependencies.into_iter().collect();
result_paths.sort_by(|a, b| a.0.cmp(&b.0));
log::trace!("🏁 Final workspace dependencies: {result_paths:?}");
Ok(result_paths)
}
fn extract_workspace_dependencies(
cargo_value: &Value,
context: &WorkspaceContext,
all_potential_deps: bool,
) -> Vec<String> {
let filter = |dep: &DependencyInfo| -> bool {
if dep.kind != DependencyKind::WorkspaceReference {
return false;
}
if all_potential_deps {
return true;
}
if dep.is_optional {
is_optional_dependency_activated(cargo_value, dep.name, None)
} else {
true
}
};
extract_dependencies(cargo_value, context, &context.root, filter)
}
fn is_dependency_activated(
package_cargo_values: &BTreeMap<String, Value>,
package_name: &str,
dep_name: &str,
enabled_features: Option<&[String]>,
) -> bool {
let Some(cargo_value) = package_cargo_values.get(package_name) else {
return false;
};
if let Some(dependencies) = cargo_value.get("dependencies").and_then(|d| d.as_table())
&& let Some(dep_value) = dependencies.get(dep_name)
&& is_workspace_dependency(dep_value)
{
if let Value::Table(table) = dep_value
&& table.get("optional") == Some(&Value::Boolean(true))
{
return is_optional_dependency_activated(cargo_value, dep_name, enabled_features);
}
return true;
}
for section_name in ["dev-dependencies", "build-dependencies"] {
if let Some(section) = cargo_value.get(section_name).and_then(|d| d.as_table())
&& let Some(dep_value) = section.get(dep_name)
&& is_workspace_dependency(dep_value)
{
return true; }
}
false
}
fn is_optional_dependency_activated(
cargo_value: &Value,
dep_name: &str,
enabled_features: Option<&[String]>,
) -> bool {
let Some(features_table) = cargo_value.get("features").and_then(|f| f.as_table()) else {
return false;
};
let Some(features_to_check) = enabled_features else {
if let Some(default_features) = features_table.get("default").and_then(|f| f.as_array()) {
let default_feature_names: Vec<String> = default_features
.iter()
.filter_map(|v| v.as_str())
.map(ToString::to_string)
.collect();
return check_features_activate_dependency(
features_table,
&default_feature_names,
dep_name,
);
}
return false;
};
check_features_activate_dependency(features_table, features_to_check, dep_name)
}
fn check_features_activate_dependency(
features_table: &toml::map::Map<String, Value>,
features_to_check: &[String],
dep_name: &str,
) -> bool {
for feature_name in features_to_check {
if let Some(feature_list) = features_table.get(feature_name).and_then(|f| f.as_array()) {
for feature_item in feature_list {
if let Some(feature_str) = feature_item.as_str() {
if feature_str == format!("dep:{dep_name}") {
return true;
}
if feature_str.starts_with(&format!("{dep_name}/")) {
return true;
}
}
}
}
}
false
}
#[must_use]
pub fn get_feature_dependencies(
cargo_toml: &Value,
enabled_features: &BTreeSet<String>,
) -> Vec<String> {
let mut feature_deps = Vec::new();
if let Some(features_table) = cargo_toml.get("features").and_then(|f| f.as_table()) {
for feature_name in enabled_features {
if let Some(feature_list) = features_table.get(feature_name).and_then(|f| f.as_array())
{
for feature_item in feature_list {
if let Some(feature_str) = feature_item.as_str() {
if feature_str.contains('/') {
let parts: Vec<&str> = feature_str.split('/').collect();
if parts.len() == 2 {
let dep_name = parts[0];
if !feature_deps.contains(&dep_name.to_string()) {
feature_deps.push(dep_name.to_string());
}
}
} else if feature_str.starts_with("dep:") {
let dep_name = feature_str.strip_prefix("dep:").unwrap_or(feature_str);
if !feature_deps.contains(&dep_name.to_string()) {
feature_deps.push(dep_name.to_string());
}
}
}
}
}
}
}
feature_deps
}
#[allow(clippy::too_many_arguments)]
pub async fn generate_dockerfile(
workspace_root: &Path,
target_package: &str,
enabled_features: Option<&[String]>,
no_default_features: bool,
dockerfile_path: &Path,
base_image: &str,
final_image: &str,
args: &[String],
build_args: Option<&str>,
generate_dockerignore: bool,
custom_env_vars: &[String],
build_env_vars: &[String],
bin: Option<&str>,
) -> Result<(), BoxError> {
let mut dependencies =
find_workspace_dependencies(workspace_root, target_package, enabled_features, true)?;
let default_target_path = format!(
"packages/{}",
target_package
.strip_prefix("moosicbox_")
.unwrap_or(target_package)
);
if !dependencies.iter().any(|(name, _)| name == target_package) {
dependencies.push((target_package.to_string(), default_target_path.clone()));
}
let target_package_path = dependencies
.iter()
.find(|(name, _)| name == target_package)
.map_or_else(|| default_target_path.as_str(), |(_, path)| path.as_str());
let dockerfile_content = generate_dockerfile_content(
&dependencies,
target_package,
enabled_features,
no_default_features,
base_image,
final_image,
args,
build_args,
workspace_root,
target_package_path,
custom_env_vars,
build_env_vars,
bin,
)
.await?;
switchy_fs::sync::write(dockerfile_path, dockerfile_content)?;
if generate_dockerignore {
let dockerignore_content =
generate_dockerignore_content(&dependencies, target_package, enabled_features)?;
let dockerignore_path = dockerfile_path.with_extension("dockerignore");
switchy_fs::sync::write(dockerignore_path, dockerignore_content)?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn generate_dockerfile_from_git(
git_url: &str,
git_ref: &str,
target_package: &str,
enabled_features: Option<&[String]>,
no_default_features: bool,
dockerfile_path: &Path,
base_image: &str,
final_image: &str,
args: &[String],
build_args: Option<&str>,
generate_dockerignore: bool,
custom_env_vars: &[String],
build_env_vars: &[String],
bin: Option<&str>,
) -> Result<(), BoxError> {
let dockerfile_content = generate_dockerfile_content_from_git(
git_url,
git_ref,
target_package,
enabled_features,
no_default_features,
base_image,
final_image,
args,
build_args,
custom_env_vars,
build_env_vars,
bin,
)?;
switchy_fs::sync::write(dockerfile_path, dockerfile_content)?;
if generate_dockerignore {
let dockerignore_content = generate_dockerignore_content_for_git()?;
let dockerignore_path = dockerfile_path.with_extension("dockerignore");
switchy_fs::sync::write(dockerignore_path, dockerignore_content)?;
}
Ok(())
}
#[allow(
clippy::too_many_lines,
clippy::too_many_arguments,
clippy::cognitive_complexity
)]
pub fn generate_dockerfile_content_from_git(
git_url: &str,
git_ref: &str,
target_package: &str,
enabled_features: Option<&[String]>,
no_default_features: bool,
base_image: &str,
final_image: &str,
args: &[String],
_build_args: Option<&str>,
custom_env_vars: &[String],
build_env_vars: &[String],
bin: Option<&str>,
) -> Result<String, BoxError> {
use std::fmt::Write as _;
let mut content = String::new();
writeln!(content, "# Builder")?;
writeln!(content, "FROM {base_image} AS builder")?;
writeln!(content, "WORKDIR /app\n")?;
writeln!(content, "# Install system dependencies")?;
writeln!(content, "RUN apt-get update && \\")?;
writeln!(
content,
" apt-get install -y git build-essential cmake pkg-config && \\"
)?;
writeln!(content, " rm -rf /var/lib/apt/lists/*\n")?;
if !build_env_vars.is_empty() {
writeln!(content, "# Set build-time environment variables")?;
for env_var in build_env_vars {
if let Some((key, value)) = env_var.split_once('=') {
writeln!(content, "ENV {key}={value}")?;
}
}
content.push('\n');
}
writeln!(content, "# Clone specific repository and ref")?;
writeln!(
content,
"RUN git clone --depth 1 --branch {git_ref} {git_url} . || \\"
)?;
writeln!(
content,
" (git clone --filter=blob:none --no-checkout {git_url} . && \\"
)?;
writeln!(content, " git checkout {git_ref})\n")?;
writeln!(content, "# Remove .git directory to save space")?;
writeln!(content, "RUN rm -rf .git\n")?;
writeln!(content, "# Build dependencies first (better caching)")?;
writeln!(content, "RUN cargo fetch\n")?;
writeln!(content, "# Build the specific package")?;
let mut build_cmd = format!("RUN cargo build --release --package {target_package}");
if no_default_features {
build_cmd.push_str(" --no-default-features");
}
if let Some(features) = enabled_features
&& !features.is_empty()
{
use std::fmt::Write as _;
write!(build_cmd, " --features=\"{}\"", features.join(","))?;
}
writeln!(content, "{build_cmd}\n")?;
writeln!(content, "# Runtime")?;
writeln!(content, "FROM {final_image}")?;
writeln!(content, "WORKDIR /")?;
writeln!(
content,
"RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*\n"
)?;
let binary_name = bin.map_or_else(|| target_package.replace('-', "_"), ToString::to_string);
writeln!(content, "# Copy the built binary")?;
writeln!(
content,
"COPY --from=builder /app/target/release/{binary_name} /\n"
)?;
writeln!(content, "# Set runtime environment")?;
writeln!(
content,
"ENV RUST_LOG=info,moosicbox=debug,moosicbox_middleware::api_logger=trace"
)?;
for env_var in custom_env_vars {
if let Some((key, value)) = env_var.split_once('=') {
writeln!(content, "ENV {key}={value}")?;
}
}
if args.is_empty() {
writeln!(content, "\n# Run the binary")?;
writeln!(content, "CMD [\"./{binary_name}\"]")?;
} else {
let args_json = args
.iter()
.map(|arg| format!("\"{arg}\""))
.collect::<Vec<_>>()
.join(", ");
writeln!(content, "\n# Run the binary with args")?;
writeln!(content, "CMD [\"./{binary_name}\", {args_json}]")?;
}
Ok(content)
}
pub fn generate_dockerignore_content_for_git() -> Result<String, BoxError> {
use std::fmt::Write as _;
let mut content = String::new();
writeln!(content, "# Git mode dockerignore - minimal exclusions")?;
writeln!(content, ".git")?;
writeln!(content, "target/")?;
writeln!(content, "*.dockerfile")?;
writeln!(content, "*.dockerignore")?;
Ok(content)
}
#[allow(
clippy::too_many_lines,
clippy::too_many_arguments,
clippy::cognitive_complexity
)]
pub async fn generate_dockerfile_content(
dependencies: &[(String, String)],
target_package: &str,
enabled_features: Option<&[String]>,
no_default_features: bool,
base_image: &str,
final_image: &str,
args: &[String],
build_args: Option<&str>,
workspace_root: &Path,
target_package_path: &str,
custom_env_vars: &[String],
build_env_vars: &[String],
bin: Option<&str>,
) -> Result<String, BoxError> {
use std::fmt::Write as _;
let mut content = String::new();
let env_vars = collect_environment_variables(
workspace_root,
target_package,
target_package_path,
enabled_features,
"ubuntu",
)
.await?;
writeln!(
content,
"# Builder\nFROM {base_image} AS builder\nWORKDIR /app\n"
)?;
writeln!(content, "# APT configuration for faster downloads")?;
content.push_str(
"RUN echo 'Acquire::http::Timeout \"10\";' >>/etc/apt/apt.conf.d/httpproxy && \\\n",
);
writeln!(
content,
" echo 'Acquire::ftp::Timeout \"10\";' >>/etc/apt/apt.conf.d/httpproxy\n"
)?;
let system_deps =
collect_system_dependencies(workspace_root, dependencies, enabled_features, "ubuntu")
.await?;
writeln!(
content,
"# Install system dependencies (early for better Docker layer caching)"
)?;
writeln!(content, "RUN apt-get update && \\")?;
let mut install_packages = BTreeSet::new();
let mut custom_commands = Vec::new();
install_packages.insert("build-essential".to_string());
install_packages.insert("cmake".to_string());
install_packages.insert("pkg-config".to_string());
for dep in &system_deps {
if dep.contains("apt-get install") {
if let Some(packages_part) = dep.split("apt-get install").nth(1) {
for package in packages_part.split_whitespace() {
if !package.is_empty() && !package.starts_with('-') {
install_packages.insert(package.to_string());
}
}
}
} else if !dep.contains("apt-get update") {
custom_commands.push(dep);
}
}
if !install_packages.is_empty() {
use std::fmt::Write as _;
let mut packages: Vec<String> = install_packages.into_iter().collect();
packages.sort();
write!(content, " apt-get -y install {}", packages.join(" "))?;
if custom_commands.is_empty() {
content.push_str("\n\n");
} else {
content.push_str(" && \\\n");
}
}
for (i, cmd) in custom_commands.iter().enumerate() {
if cmd.starts_with("sudo ") {
let cmd_without_sudo = cmd.strip_prefix("sudo ").unwrap_or(cmd);
write!(content, " {cmd_without_sudo}")?;
} else {
write!(content, " {cmd}")?;
}
if i < custom_commands.len() - 1 {
content.push_str(" && \\\n");
} else {
content.push_str("\n\n");
}
}
if !build_env_vars.is_empty() {
writeln!(content, "# Set build-time environment variables")?;
for env_var in build_env_vars {
if let Some((key, value)) = env_var.split_once('=') {
writeln!(content, "ENV {key}={value}")?;
}
}
content.push('\n');
}
writeln!(
content,
"COPY Cargo.toml Cargo.toml\nCOPY Cargo.lock Cargo.lock\n"
)?;
let members_list = dependencies
.iter()
.map(|(_, path)| format!("\"{path}\""))
.collect::<Vec<_>>()
.join(", ");
writeln!(
content,
"RUN sed -e '/^members = \\[/,/^\\]/c\\members = [{members_list}]' Cargo.toml > Cargo2.toml && mv Cargo2.toml Cargo.toml\n"
)?;
writeln!(content, "# Copy packages folder for Cargo.toml files")?;
writeln!(content, "COPY packages/ packages/")?;
writeln!(
content,
"RUN find packages/ -name '*.rs' ! -name 'build.rs' -delete"
)?;
content.push('\n');
writeln!(content, "# Copy real source code for building dependencies")?;
writeln!(content, "COPY packages/ packages/")?;
writeln!(
content,
"# Create stub for target package to prevent premature build"
)?;
let target_cargo_path = workspace_root.join(target_package_path).join("Cargo.toml");
if switchy_fs::exists(&target_cargo_path) {
let target_source = switchy_fs::sync::read_to_string(&target_cargo_path)?;
let target_value: Value = toml::from_str(&target_source)?;
let has_binary = target_value.get("bin").is_some()
|| switchy_fs::exists(workspace_root.join(target_package_path).join("src/main.rs"));
if has_binary {
writeln!(
content,
"RUN echo 'fn main() {{}}' > {target_package_path}/src/main.rs"
)?;
} else {
writeln!(content, "RUN echo '' > {target_package_path}/src/lib.rs")?;
}
}
content.push('\n');
let mut feature_flags = Vec::new();
if no_default_features {
feature_flags.push("--no-default-features".to_string());
}
if let Some(features) = enabled_features
&& !features.is_empty()
{
feature_flags.push(format!("--features={}", features.join(",")));
}
let features_flag = feature_flags.join(" ");
writeln!(content, "# Build dependencies first (not target package)")?;
writeln!(
content,
"RUN cargo build --release --workspace --exclude {target_package}"
)?;
writeln!(content, "\n# Copy target package source code")?;
writeln!(
content,
"COPY {target_package_path}/ {target_package_path}/"
)?;
if !env_vars.is_empty() {
writeln!(
content,
"\n# Accept build args and set as env vars for build process"
)?;
for (key, _) in &env_vars {
writeln!(content, "ARG {key}")?;
writeln!(content, "ENV {key}=${{{key}}}")?;
}
}
writeln!(content, "\n# Final build with actual source")?;
if features_flag.is_empty() {
writeln!(
content,
"RUN cargo build --release --package {target_package}"
)?;
} else {
writeln!(
content,
"RUN cargo build --release --package {target_package} {features_flag}"
)?;
}
writeln!(content, "\n# Runtime")?;
writeln!(content, "FROM {final_image}")?;
writeln!(content, "WORKDIR /")?;
let runtime_packages = vec!["ca-certificates"];
let runtime_packages_vec: Vec<&str> = runtime_packages.into_iter().collect();
writeln!(
content,
"RUN apt-get update && apt-get install -y {}",
runtime_packages_vec.join(" ")
)?;
let binary_name = get_binary_name(workspace_root, target_package, target_package_path, bin);
writeln!(
content,
"COPY --from=builder /app/target/release/{binary_name} /"
)?;
if let Some(args) = build_args {
for arg in args.split(',') {
let arg = arg.trim();
writeln!(content, "ARG {arg}\nENV {arg}=${{{arg}}}")?;
}
}
if !env_vars.is_empty() {
for (key, _) in &env_vars {
writeln!(content, "ARG {key}")?;
writeln!(content, "ENV {key}=${{{key}}}")?;
}
}
writeln!(
content,
"ENV RUST_LOG=info,moosicbox=debug,moosicbox_middleware::api_logger=trace"
)?;
for env_var in custom_env_vars {
if let Some((key, value)) = env_var.split_once('=') {
writeln!(content, "ENV {key}={value}")?;
}
}
if args.is_empty() {
writeln!(content, "CMD [\"./{binary_name}\"]")?;
} else {
let args_json = args
.iter()
.map(|arg| format!("\"{arg}\""))
.collect::<Vec<_>>()
.join(", ");
writeln!(content, "CMD [\"./{binary_name}\", {args_json}]")?;
}
Ok(content)
}
pub fn generate_dockerignore_content(
dependencies: &[(String, String)],
_target_package: &str,
_enabled_features: Option<&[String]>,
) -> Result<String, BoxError> {
use std::fmt::Write as _;
let mut content = String::new();
writeln!(content, "/packages/*\n")?;
for (_, path) in dependencies {
writeln!(content, "!/{path}")?;
}
content.push('\n');
Ok(content)
}
fn should_ignore_file(file_path: &str, ignore_patterns: &[String]) -> Result<bool, BoxError> {
if ignore_patterns.is_empty() {
return Ok(false);
}
let mut ignored = false;
for pattern in ignore_patterns {
let (is_negation, pattern_str) = pattern
.strip_prefix('!')
.map_or((false, pattern.as_str()), |p| (true, p));
let glob = globset::Glob::new(pattern_str)?;
if glob.compile_matcher().is_match(file_path) {
ignored = !is_negation;
}
}
Ok(ignored)
}
fn expand_simple_glob_pattern(workspace_root: &Path, pattern: &str) -> Option<Vec<String>> {
let matcher = get_or_compile_glob(pattern)?;
let max_depth = pattern.matches('/').count() + 1;
let mut results = Vec::new();
walk_and_match_glob(
workspace_root,
workspace_root,
&matcher,
0,
max_depth,
&mut results,
);
Some(results)
}
fn walk_and_match_glob(
workspace_root: &Path,
current_dir: &Path,
matcher: &globset::GlobMatcher,
current_depth: usize,
max_depth: usize,
results: &mut Vec<String>,
) {
if current_depth > max_depth {
return;
}
let Ok(entries) = switchy_fs::sync::read_dir_sorted(current_dir) else {
return;
};
for entry in entries {
let Ok(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_dir() {
continue;
}
let full_path = current_dir.join(entry.file_name());
let Ok(relative_path) = full_path.strip_prefix(workspace_root) else {
continue;
};
let relative_str = relative_path.to_string_lossy();
if matcher.is_match(relative_path) && switchy_fs::exists(full_path.join("Cargo.toml")) {
results.push(relative_str.into_owned());
}
walk_and_match_glob(
workspace_root,
&full_path,
matcher,
current_depth + 1,
max_depth,
results,
);
}
}
fn expand_workspace_member_globs(workspace_root: &Path, members: &[&str]) -> Vec<String> {
let mut expanded = Vec::new();
for member in members {
if member.contains('*') || member.contains('?') || member.contains('[') {
if let Some(expanded_paths) = expand_simple_glob_pattern(workspace_root, member) {
for path in expanded_paths {
log::trace!("Expanded glob '{member}' -> '{path}'");
expanded.push(path);
}
} else {
log::warn!("Failed to expand glob pattern '{member}'");
}
} else {
expanded.push((*member).to_string());
}
}
expanded.sort();
log::debug!(
"Expanded {} workspace member patterns to {} paths",
members.len(),
expanded.len()
);
expanded
}
#[allow(clippy::too_many_lines, clippy::cognitive_complexity)]
pub fn find_affected_packages(
workspace_root: &Path,
changed_files: &[String],
ignore_patterns: &[String],
) -> Result<Vec<String>, BoxError> {
log::trace!("🔍 Finding affected packages for changed files: {changed_files:?}");
let workspace_cargo_path = workspace_root.join("Cargo.toml");
let workspace_source = switchy_fs::sync::read_to_string(&workspace_cargo_path)?;
let workspace_value: Value = toml::from_str(&workspace_source)?;
let workspace_members_raw = workspace_value
.get("workspace")
.and_then(|x| x.get("members"))
.and_then(|x| x.as_array())
.and_then(|x| x.iter().map(|x| x.as_str()).collect::<Option<Vec<_>>>())
.ok_or("No workspace members found")?;
let workspace_members = expand_workspace_member_globs(workspace_root, &workspace_members_raw);
log::trace!("🏢 Found {} workspace members", workspace_members.len());
let mut package_path_to_name = BTreeMap::new();
let mut package_dependencies: BTreeMap<String, Vec<String>> = BTreeMap::new();
for member_path in &workspace_members {
let full_path = workspace_root.join(member_path);
let cargo_path = full_path.join("Cargo.toml");
if !switchy_fs::exists(&cargo_path) {
log::trace!("⚠️ Skipping {member_path}: Cargo.toml not found");
continue;
}
log::trace!("📄 Processing package: {member_path}");
let source = switchy_fs::sync::read_to_string(&cargo_path)?;
let value: Value = toml::from_str(&source)?;
if let Some(package_name) = value
.get("package")
.and_then(|x| x.get("name"))
.and_then(|x| x.as_str())
{
log::trace!("📦 Package name: {package_name} -> {member_path}");
package_path_to_name.insert(member_path.clone(), package_name.to_string());
let mut deps = Vec::new();
if let Some(dependencies) = value.get("dependencies").and_then(|x| x.as_table()) {
for (dep_name, dep_value) in dependencies {
if is_workspace_dependency(dep_value) {
deps.push(dep_name.clone());
}
}
}
if let Some(dev_dependencies) = value.get("dev-dependencies").and_then(|x| x.as_table())
{
for (dep_name, dep_value) in dev_dependencies {
if is_workspace_dependency(dep_value) && !deps.contains(dep_name) {
deps.push(dep_name.clone());
}
}
}
if let Some(build_dependencies) =
value.get("build-dependencies").and_then(|x| x.as_table())
{
for (dep_name, dep_value) in build_dependencies {
if is_workspace_dependency(dep_value) && !deps.contains(dep_name) {
deps.push(dep_name.clone());
}
}
}
log::trace!("📊 Dependencies for {package_name}: {deps:?}");
package_dependencies.insert(package_name.to_string(), deps);
}
}
let mut directly_affected_packages = BTreeSet::new();
for changed_file in changed_files {
if should_ignore_file(changed_file, ignore_patterns)? {
log::trace!("🚫 Ignoring file (matched pattern): {changed_file}");
continue;
}
let changed_path = std::path::PathBuf::from(changed_file);
let mut best_match: Option<(&String, &String)> = None;
let mut best_match_length = 0;
for (package_path, package_name) in &package_path_to_name {
let package_path_buf = std::path::PathBuf::from(package_path);
if changed_path.starts_with(&package_path_buf) {
let path_length = package_path.len();
if path_length > best_match_length {
best_match = Some((package_path, package_name));
best_match_length = path_length;
}
}
}
if let Some((package_path, package_name)) = best_match {
log::trace!(
"📝 File {changed_file} affects package {package_name} (path: {package_path})"
);
directly_affected_packages.insert(package_name.clone());
}
}
log::trace!("🎯 Directly affected packages: {directly_affected_packages:?}");
let mut all_affected_packages = directly_affected_packages.clone();
let mut queue = VecDeque::new();
for package in &directly_affected_packages {
queue.push_back(package.clone());
}
let mut reverse_deps: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (package, deps) in &package_dependencies {
for dep in deps {
reverse_deps
.entry(dep.clone())
.or_default()
.push(package.clone());
}
}
while let Some(current_package) = queue.pop_front() {
if let Some(dependents) = reverse_deps.get(¤t_package) {
for dependent in dependents {
if !all_affected_packages.contains(dependent) {
log::trace!(
"🔄 Package {dependent} depends on affected package {current_package}"
);
all_affected_packages.insert(dependent.clone());
queue.push_back(dependent.clone());
}
}
}
}
let mut result: Vec<String> = all_affected_packages.into_iter().collect();
result.sort();
log::trace!("🏁 Final affected packages: {result:?}");
Ok(result)
}
#[allow(clippy::too_many_lines, clippy::cognitive_complexity)]
pub fn find_affected_packages_with_reasoning(
workspace_root: &Path,
changed_files: &[String],
ignore_patterns: &[String],
) -> Result<Vec<AffectedPackageInfo>, BoxError> {
log::trace!("🔍 Finding affected packages with reasoning for changed files: {changed_files:?}");
let workspace_cargo_path = workspace_root.join("Cargo.toml");
let workspace_source = switchy_fs::sync::read_to_string(&workspace_cargo_path)?;
let workspace_value: Value = toml::from_str(&workspace_source)?;
let workspace_members_raw = workspace_value
.get("workspace")
.and_then(|x| x.get("members"))
.and_then(|x| x.as_array())
.and_then(|x| x.iter().map(|x| x.as_str()).collect::<Option<Vec<_>>>())
.ok_or("No workspace members found")?;
let workspace_members = expand_workspace_member_globs(workspace_root, &workspace_members_raw);
log::trace!("🏢 Found {} workspace members", workspace_members.len());
let mut package_path_to_name = BTreeMap::new();
let mut package_dependencies: BTreeMap<String, Vec<String>> = BTreeMap::new();
for member_path in &workspace_members {
let full_path = workspace_root.join(member_path);
let cargo_path = full_path.join("Cargo.toml");
if !switchy_fs::exists(&cargo_path) {
log::trace!("⚠️ Skipping {member_path}: Cargo.toml not found");
continue;
}
log::trace!("📄 Processing package: {member_path}");
let source = switchy_fs::sync::read_to_string(&cargo_path)?;
let value: Value = toml::from_str(&source)?;
if let Some(package_name) = value
.get("package")
.and_then(|x| x.get("name"))
.and_then(|x| x.as_str())
{
log::trace!("📦 Package name: {package_name} -> {member_path}");
package_path_to_name.insert(member_path.clone(), package_name.to_string());
let mut deps = Vec::new();
if let Some(dependencies) = value.get("dependencies").and_then(|x| x.as_table()) {
for (dep_name, dep_value) in dependencies {
if is_workspace_dependency(dep_value) {
deps.push(dep_name.clone());
}
}
}
if let Some(dev_dependencies) = value.get("dev-dependencies").and_then(|x| x.as_table())
{
for (dep_name, dep_value) in dev_dependencies {
if is_workspace_dependency(dep_value) && !deps.contains(dep_name) {
deps.push(dep_name.clone());
}
}
}
if let Some(build_dependencies) =
value.get("build-dependencies").and_then(|x| x.as_table())
{
for (dep_name, dep_value) in build_dependencies {
if is_workspace_dependency(dep_value) && !deps.contains(dep_name) {
deps.push(dep_name.clone());
}
}
}
log::trace!("📊 Dependencies for {package_name}: {deps:?}");
package_dependencies.insert(package_name.to_string(), deps);
}
}
let mut directly_affected_packages = BTreeMap::new(); let mut reasoning_map: BTreeMap<String, Vec<String>> = BTreeMap::new();
for changed_file in changed_files {
if should_ignore_file(changed_file, ignore_patterns)? {
log::trace!("🚫 Ignoring file (matched pattern): {changed_file}");
continue;
}
let changed_path = std::path::PathBuf::from(changed_file);
let mut best_match: Option<(&String, &String)> = None;
let mut best_match_length = 0;
for (package_path, package_name) in &package_path_to_name {
let package_path_buf = std::path::PathBuf::from(package_path);
if changed_path.starts_with(&package_path_buf) {
let path_length = package_path.len();
if path_length > best_match_length {
best_match = Some((package_path, package_name));
best_match_length = path_length;
}
}
}
if let Some((package_path, package_name)) = best_match {
log::trace!(
"📝 File {changed_file} affects package {package_name} (path: {package_path})"
);
directly_affected_packages
.entry(package_name.clone())
.or_insert_with(Vec::new)
.push(changed_file.clone());
reasoning_map
.entry(package_name.clone())
.or_default()
.push(format!("Contains changed file: {changed_file}"));
}
}
log::trace!("🎯 Directly affected packages: {directly_affected_packages:?}");
let mut all_affected_packages = directly_affected_packages
.keys()
.cloned()
.collect::<BTreeSet<String>>();
let mut queue = VecDeque::new();
for package in directly_affected_packages.keys() {
queue.push_back(package.clone());
}
let mut reverse_deps: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (package, deps) in &package_dependencies {
for dep in deps {
reverse_deps
.entry(dep.clone())
.or_default()
.push(package.clone());
}
}
while let Some(current_package) = queue.pop_front() {
if let Some(dependents) = reverse_deps.get(¤t_package) {
for dependent in dependents {
if !all_affected_packages.contains(dependent) {
log::trace!(
"🔄 Package {dependent} depends on affected package {current_package}"
);
all_affected_packages.insert(dependent.clone());
queue.push_back(dependent.clone());
reasoning_map
.entry(dependent.clone())
.or_default()
.push(format!("Depends on affected package: {current_package}"));
}
}
}
}
let mut result: Vec<AffectedPackageInfo> = all_affected_packages
.into_iter()
.map(|name| {
let reasoning = reasoning_map.get(&name).cloned();
AffectedPackageInfo { name, reasoning }
})
.collect();
result.sort_by(|a, b| a.name.cmp(&b.name));
log::trace!("🏁 Final affected packages with reasoning: {result:?}");
Ok(result)
}
pub async fn collect_environment_variables(
workspace_root: &Path,
_target_package: &str,
target_package_path: &str,
enabled_features: Option<&[String]>,
target_os: &str,
) -> Result<Vec<(String, String)>, BoxError> {
let path = workspace_root.join(target_package_path);
let clippier_path = path.join("clippier.toml");
if !switchy_fs::exists(&clippier_path) {
return Ok(Vec::new());
}
let features_str = enabled_features.map(|f| f.join(",")).unwrap_or_default();
let specific_features = if features_str.is_empty() {
None
} else {
Some(
features_str
.split(',')
.map(str::to_string)
.collect::<Vec<_>>(),
)
};
let packages = process_configs(
&path,
None,
None,
None,
false,
false, None, specific_features.as_deref(),
None,
None,
)
.await?;
let mut env_vars = Vec::new();
for package in packages {
if let Some(os) = package.get("os").and_then(|v| v.as_str())
&& os == target_os
&& let Some(env_str) = package.get("env").and_then(|v| v.as_str())
{
for line in env_str.lines() {
if let Some((key, value)) = line.split_once('=') {
env_vars.push((key.to_string(), value.to_string()));
}
}
}
}
Ok(env_vars)
}
pub async fn collect_system_dependencies(
workspace_root: &Path,
dependencies: &[(String, String)],
enabled_features: Option<&[String]>,
target_os: &str,
) -> Result<Vec<String>, BoxError> {
let mut all_deps = BTreeSet::new();
let features_str = enabled_features.map(|f| f.join(",")).unwrap_or_default();
for (_, package_path) in dependencies {
let path = workspace_root.join(package_path);
let clippier_path = path.join("clippier.toml");
if !switchy_fs::exists(&clippier_path) {
continue;
}
let specific_features = if features_str.is_empty() {
None
} else {
Some(
features_str
.split(',')
.map(str::to_string)
.collect::<Vec<_>>(),
)
};
let packages = process_configs(
&path,
None,
None,
None,
false,
false, None, specific_features.as_deref(),
None,
None,
)
.await?;
for package in packages {
if let Some(os) = package.get("os").and_then(|v| v.as_str())
&& os == target_os
&& let Some(deps) = package.get("dependencies").and_then(|v| v.as_str())
{
for dep in deps.lines() {
if !dep.trim().is_empty() {
all_deps.insert(dep.trim().to_string());
}
}
}
}
}
let mut result: Vec<String> = all_deps.into_iter().collect();
result.sort();
Ok(result)
}
#[must_use]
pub fn parse_dependency_name(dependency_line: &str) -> String {
dependency_line
.split_whitespace()
.next()
.unwrap_or("")
.to_string()
}
#[derive(Debug, Serialize, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct PackageInfo {
pub name: String,
pub path: String,
}
#[derive(Debug, Serialize)]
pub struct WorkspaceDepsResult {
pub packages: Vec<PackageInfo>,
}
#[derive(Debug, Serialize)]
pub struct AffectedPackagesResult {
pub affected_packages: Vec<AffectedPackageInfo>,
}
#[derive(Debug, Serialize)]
pub struct SinglePackageResult {
pub package: String,
pub affected: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<Vec<String>>,
pub all_affected: Vec<AffectedPackageInfo>,
}
pub async fn handle_dependencies_command(
file: &str,
os: Option<&str>,
features: Option<&str>,
output: OutputType,
) -> Result<String, BoxError> {
use std::str::FromStr;
let path = std::path::PathBuf::from_str(file)?;
let specific_features = features.map(|f| f.split(',').map(str::to_string).collect::<Vec<_>>());
let packages = process_workspace_configs(
&path,
None,
None,
None,
false,
false, None, specific_features.as_deref(),
None,
None,
)
.await?;
let dependencies: Vec<String> = packages
.iter()
.filter(|package| {
package
.get("os")
.and_then(|v| v.as_str())
.is_some_and(|package_os| os.is_none() || os == Some(package_os))
})
.filter_map(|package| {
package
.get("dependencies")
.and_then(|v| v.as_str())
.map(ToString::to_string)
})
.unique()
.collect();
match output {
OutputType::Json => Ok(serde_json::to_string(&dependencies)?),
OutputType::Raw => Ok(dependencies.join("\n")),
}
}
pub async fn handle_environment_command(
file: &str,
os: Option<&str>,
features: Option<&str>,
output: OutputType,
) -> Result<String, BoxError> {
use std::str::FromStr;
let path = std::path::PathBuf::from_str(file)?;
let specific_features = features.map(|f| f.split(',').map(str::to_string).collect::<Vec<_>>());
let packages = process_workspace_configs(
&path,
None,
None,
None,
false,
false, None, specific_features.as_deref(),
None,
None,
)
.await?;
let environment_vars = packages
.iter()
.filter(|package| {
package
.get("os")
.and_then(|v| v.as_str())
.is_some_and(|package_os| os.is_none() || os == Some(package_os))
})
.filter_map(|package| {
package
.get("env")
.and_then(|v| v.as_str())
.map(ToString::to_string)
})
.unique()
.collect::<Vec<_>>();
match output {
OutputType::Json => Ok(serde_json::to_string(&environment_vars)?),
OutputType::Raw => Ok(environment_vars.join("\n")),
}
}
pub async fn handle_ci_steps_command(
file: &str,
os: Option<&str>,
features: Option<&str>,
output: OutputType,
) -> Result<String, BoxError> {
use std::str::FromStr;
let path = std::path::PathBuf::from_str(file)?;
let specific_features = features.map(|f| f.split(',').map(str::to_string).collect::<Vec<_>>());
let packages = process_workspace_configs(
&path,
None,
None,
None,
false,
false, None, specific_features.as_deref(),
None,
None,
)
.await?;
let ci_steps = packages
.iter()
.filter(|package| {
package
.get("os")
.and_then(|v| v.as_str())
.is_some_and(|package_os| os.is_none() || os == Some(package_os))
})
.filter_map(|package| {
package
.get("ciSteps")
.and_then(|v| v.as_str())
.map(ToString::to_string)
})
.unique()
.collect::<Vec<_>>();
match output {
OutputType::Json => Ok(serde_json::to_string(&ci_steps)?),
OutputType::Raw => Ok(ci_steps.join("\n")),
}
}
#[allow(
clippy::too_many_arguments,
clippy::too_many_lines,
clippy::cognitive_complexity
)]
#[allow(clippy::fn_params_excessive_bools, clippy::future_not_send)]
pub async fn handle_features_command(
file: &str,
os: Option<&str>,
offset: Option<u16>,
max: Option<u16>,
max_parallel: Option<u16>,
chunked: Option<u16>,
spread: bool,
randomize: bool,
seed: Option<u64>,
features: Option<&str>,
skip_features: Option<&str>,
required_features: Option<&str>,
packages: Option<&[String]>,
changed_files: Option<&[String]>,
#[cfg(feature = "git-diff")] git_base: Option<&str>,
#[cfg(feature = "git-diff")] git_head: Option<&str>,
include_reasoning: bool,
ignore_patterns: Option<&[String]>,
skip_if: &[String],
include_if: &[String],
#[cfg(feature = "_transforms")] transform_scripts: &[std::path::PathBuf],
#[cfg(feature = "_transforms")] transform_trace: bool,
#[cfg(feature = "_workspace")] workspace_type: Option<&[workspace::WorkspaceType]>,
output: OutputType,
) -> Result<String, BoxError> {
use std::str::FromStr;
#[cfg(feature = "_workspace")]
log::debug!("Using workspace type filter: {workspace_type:?}");
let path = std::path::PathBuf::from_str(file)?;
let specific_features = features.map(|f| f.split(',').map(str::to_string).collect::<Vec<_>>());
let skip_features_list =
skip_features.map(|f| f.split(',').map(str::to_string).collect::<Vec<_>>());
let required_features_list =
required_features.map(|f| f.split(',').map(str::to_string).collect::<Vec<_>>());
if let Some(selected_packages) = packages
&& !selected_packages.is_empty()
{
log::debug!("Filtering to specific packages: {selected_packages:?}");
#[cfg(feature = "_workspace")]
let workspaces = workspace::detect_workspaces(&path, workspace_type).await?;
#[cfg(feature = "_workspace")]
let detected_workspace = workspace::select_primary_workspace(workspaces);
#[cfg(feature = "_workspace")]
let package_name_to_path = if let Some(ws) = detected_workspace.as_ref() {
ws.package_name_to_path().await?
} else {
std::collections::BTreeMap::new()
};
#[cfg(not(feature = "_workspace"))]
let package_name_to_path = {
let workspace_cargo_path = path.join("Cargo.toml");
let workspace_source =
switchy_fs::unsync::read_to_string(&workspace_cargo_path).await?;
let workspace_value: Value = toml::from_str(&workspace_source)?;
let workspace_members_raw = workspace_value
.get("workspace")
.and_then(|x| x.get("members"))
.and_then(|x| x.as_array())
.and_then(|x| x.iter().map(|x| x.as_str()).collect::<Option<Vec<_>>>())
.unwrap_or_default();
let workspace_members = expand_workspace_member_globs(&path, &workspace_members_raw);
let mut package_name_to_path = BTreeMap::new();
for member_path in &workspace_members {
let full_path = path.join(member_path);
let cargo_path = full_path.join("Cargo.toml");
if switchy_fs::unsync::exists(&cargo_path).await {
let source = switchy_fs::unsync::read_to_string(&cargo_path).await?;
let value: Value = toml::from_str(&source)?;
if let Some(package_name) = value
.get("package")
.and_then(|x| x.get("name"))
.and_then(|x| x.as_str())
{
package_name_to_path.insert(package_name.to_string(), member_path.clone());
}
}
}
package_name_to_path
};
let all_package_names: Vec<String> = package_name_to_path.keys().cloned().collect();
let expanded_packages = expand_pattern_list(selected_packages, &all_package_names);
log::debug!("Expanded packages: {expanded_packages:?}");
let filtered_packages = if !skip_if.is_empty() || !include_if.is_empty() {
package_filter::apply_filters(
&expanded_packages,
&package_name_to_path,
&path,
skip_if,
include_if,
)?
} else {
expanded_packages
};
let mut all_filtered_packages = Vec::new();
for selected_pkg in &filtered_packages {
if let Some(package_path) = package_name_to_path.get(selected_pkg) {
let package_dir = path.join(package_path);
let packages = process_configs(
&package_dir,
offset,
max,
chunked,
spread,
randomize,
seed,
specific_features.as_deref(),
skip_features_list.as_deref(),
required_features_list.as_deref(),
)
.await?;
all_filtered_packages.extend(packages);
} else {
log::warn!("Package '{selected_pkg}' not found in workspace");
}
}
if let Some(target_os) = os {
all_filtered_packages.retain(|package| {
package
.get("os")
.and_then(|v| v.as_str())
.is_some_and(|pkg_os| pkg_os == target_os)
});
}
if let Some(max_parallel_limit) = max_parallel {
all_filtered_packages = apply_max_parallel_rechunking(
all_filtered_packages,
max_parallel_limit as usize,
chunked,
)?;
}
let result = match output {
OutputType::Json => serde_json::to_string(&all_filtered_packages)?,
OutputType::Raw => {
let mut results = Vec::new();
for package in all_filtered_packages {
if let Some(features) = package.get("features") {
results.push(features.to_string());
}
}
results.join("\n")
}
};
return Ok(result);
}
let use_filtering = changed_files.is_some() || {
#[cfg(feature = "git-diff")]
{
git_base.is_some() && git_head.is_some()
}
#[cfg(not(feature = "git-diff"))]
{
false
}
};
if use_filtering {
#[cfg(feature = "git-diff")]
use crate::git_diff::{
build_external_dependency_map, extract_changed_dependencies_from_git,
find_packages_affected_by_external_deps_with_mapping, get_changed_files_from_git,
};
let mut all_changed_files = Vec::new();
if let Some(changed_files) = changed_files {
all_changed_files.extend(changed_files.to_vec());
}
#[cfg(feature = "git-diff")]
if let (Some(base), Some(head)) = (git_base, git_head) {
let git_changed_files = get_changed_files_from_git(&path, base, head)?;
log::debug!("Git changed files: {git_changed_files:?}");
all_changed_files.extend(git_changed_files);
}
all_changed_files.sort();
all_changed_files.dedup();
#[allow(unused_mut)]
let mut external_affected_packages = Vec::<String>::new();
#[allow(unused_mut)]
let mut external_dependency_mapping = BTreeMap::<String, Vec<String>>::new();
#[cfg(feature = "git-diff")]
if let (Some(base), Some(head)) = (git_base, git_head) {
log::debug!("Analyzing external dependency changes from Cargo.lock");
if let Ok(changed_external_deps) =
extract_changed_dependencies_from_git(&path, base, head, &all_changed_files)
{
log::debug!("Changed external dependencies: {changed_external_deps:?}");
if !changed_external_deps.is_empty() {
let workspace_cargo_path = path.join("Cargo.toml");
let workspace_source =
switchy_fs::unsync::read_to_string(&workspace_cargo_path).await?;
let workspace_value: Value = toml::from_str(&workspace_source)?;
if let Some(workspace_members_raw) = workspace_value
.get("workspace")
.and_then(|x| x.get("members"))
.and_then(|x| x.as_array())
.and_then(|x| x.iter().map(|x| x.as_str()).collect::<Option<Vec<_>>>())
{
let workspace_members_owned =
expand_workspace_member_globs(&path, &workspace_members_raw);
if let Ok(external_dep_map) =
build_external_dependency_map(&path, &workspace_members_owned)
{
let external_affected_mapping =
find_packages_affected_by_external_deps_with_mapping(
&external_dep_map,
&changed_external_deps,
);
external_affected_packages =
external_affected_mapping.keys().cloned().collect();
external_dependency_mapping = external_affected_mapping;
log::debug!(
"Packages affected by external dependency changes: {external_affected_packages:?}"
);
log::debug!(
"External dependency mapping: {external_dependency_mapping:?}"
);
}
}
}
}
}
log::debug!("All changed files: {all_changed_files:?}");
if all_changed_files.is_empty() {
return match output {
OutputType::Json => Ok("[]".to_string()),
OutputType::Raw => Ok(String::new()),
};
}
let ignore_patterns_vec = ignore_patterns.unwrap_or(&[]).to_vec();
let (mut affected_packages, affected_with_reasoning) = if include_reasoning {
let with_reasoning = find_affected_packages_with_reasoning(
&path,
&all_changed_files,
&ignore_patterns_vec,
)?;
let packages: Vec<String> = with_reasoning.iter().map(|pkg| pkg.name.clone()).collect();
(packages, Some(with_reasoning))
} else {
(
find_affected_packages(&path, &all_changed_files, &ignore_patterns_vec)?,
None,
)
};
let mut updated_reasoning = affected_with_reasoning;
for external_pkg in external_affected_packages {
if !affected_packages.contains(&external_pkg) {
affected_packages.push(external_pkg.clone());
log::debug!("Added package affected by external dependencies: {external_pkg}");
if include_reasoning && let Some(ref mut reasoning_data) = updated_reasoning {
let specific_deps = external_dependency_mapping.get(&external_pkg).map_or_else(
|| vec!["Affected by external dependency changes".to_string()],
|deps| {
deps.iter()
.map(|dep| format!("Affected by external dependency: {dep}"))
.collect()
},
);
reasoning_data.push(AffectedPackageInfo {
name: external_pkg,
reasoning: Some(specific_deps),
});
}
}
}
affected_packages.sort();
let affected_with_reasoning = updated_reasoning;
let workspace_cargo_path = path.join("Cargo.toml");
let workspace_source = switchy_fs::unsync::read_to_string(&workspace_cargo_path).await?;
let workspace_value: Value = toml::from_str(&workspace_source)?;
let workspace_members_raw = workspace_value
.get("workspace")
.and_then(|x| x.get("members"))
.and_then(|x| x.as_array())
.and_then(|x| x.iter().map(|x| x.as_str()).collect::<Option<Vec<_>>>())
.unwrap_or_default();
let workspace_members = expand_workspace_member_globs(&path, &workspace_members_raw);
let mut package_name_to_path = BTreeMap::new();
for member_path in &workspace_members {
let full_path = path.join(member_path);
let cargo_path = full_path.join("Cargo.toml");
if switchy_fs::unsync::exists(&cargo_path).await {
let source = switchy_fs::unsync::read_to_string(&cargo_path).await?;
let value: Value = toml::from_str(&source)?;
if let Some(package_name) = value
.get("package")
.and_then(|x| x.get("name"))
.and_then(|x| x.as_str())
{
package_name_to_path.insert(package_name.to_string(), member_path.clone());
}
}
}
let mut all_filtered_packages = Vec::new();
for affected_package in affected_packages {
if let Some(package_path) = package_name_to_path.get(&affected_package) {
let package_dir = path.join(package_path);
let mut packages = process_configs(
&package_dir,
offset,
max,
chunked, spread, randomize, seed, specific_features.as_deref(),
skip_features_list.as_deref(),
required_features_list.as_deref(),
)
.await?;
if let Some(ref reasoning_data) = affected_with_reasoning
&& let Some(pkg_reasoning) = reasoning_data
.iter()
.find(|pkg| pkg.name == affected_package)
&& let Some(reasoning) = &pkg_reasoning.reasoning
{
for package in &mut packages {
package.insert("reasoning".to_string(), serde_json::to_value(reasoning)?);
}
}
all_filtered_packages.extend(packages);
}
}
if let Some(target_os) = os {
all_filtered_packages.retain(|package| {
package
.get("os")
.and_then(|v| v.as_str())
.is_some_and(|pkg_os| pkg_os == target_os)
});
}
if let Some(max_parallel_limit) = max_parallel {
all_filtered_packages = apply_max_parallel_rechunking(
all_filtered_packages,
max_parallel_limit as usize,
chunked,
)?;
}
let result = match output {
OutputType::Json => serde_json::to_string(&all_filtered_packages)?,
OutputType::Raw => {
let mut results = Vec::new();
for package in all_filtered_packages {
if let Some(features) = package.get("features") {
results.push(features.to_string());
}
}
results.join("\n")
}
};
return Ok(result);
}
let effective_chunked = chunked.or(max_parallel);
let mut packages = process_workspace_configs(
&path,
offset,
max,
effective_chunked,
spread,
randomize,
seed,
specific_features.as_deref(),
skip_features_list.as_deref(),
required_features_list.as_deref(),
)
.await?;
if let Some(target_os) = os {
packages.retain(|package| {
package
.get("os")
.and_then(|v| v.as_str())
.is_some_and(|pkg_os| pkg_os == target_os)
});
}
if let Some(max_parallel_limit) = max_parallel {
packages = apply_max_parallel_rechunking(packages, max_parallel_limit as usize, chunked)?;
}
#[cfg(feature = "_transforms")]
if !transform_scripts.is_empty() {
log::info!("Applying {} transform script(s)", transform_scripts.len());
let engine = if transform_trace {
crate::transforms::TransformEngine::with_trace(&path, true)?
} else {
crate::transforms::TransformEngine::new(&path)?
};
for script_path in transform_scripts {
log::info!("Applying transform: {}", script_path.display());
let script = switchy_fs::unsync::read_to_string(script_path).await?;
engine.apply_transform(&mut packages, &script)?;
}
log::info!(
"Transforms applied successfully. Matrix size: {}",
packages.len()
);
}
let result = match output {
OutputType::Json => serde_json::to_string(&packages)?,
OutputType::Raw => {
let mut results = Vec::new();
for package in packages {
if let Some(features) = package.get("features") {
results.push(features.to_string());
}
}
results.join("\n")
}
};
Ok(result)
}
pub fn handle_workspace_deps_command(
workspace_root: &Path,
package: &str,
features: Option<&[String]>,
format: &str,
all_potential_deps: bool,
) -> Result<String, BoxError> {
let deps = find_workspace_dependencies(workspace_root, package, features, all_potential_deps)?;
let result = if format == "json" {
let result = WorkspaceDepsResult {
packages: deps
.into_iter()
.map(|(name, path)| PackageInfo { name, path })
.collect(),
};
serde_json::to_string(&result)?
} else {
let mut results = Vec::new();
for (name, path) in deps {
results.push(format!("{name}: {path}"));
}
results.join("\n")
};
Ok(result)
}
#[allow(clippy::too_many_arguments)]
pub async fn handle_generate_dockerfile_command(
workspace_root: &Path,
package: &str,
git_ref: &str,
features: Option<&[String]>,
no_default_features: bool,
output: &Path,
base_image: &str,
final_image: &str,
args: &[String],
build_args: Option<&str>,
generate_dockerignore: bool,
env: &[String],
build_env: &[String],
bin: Option<&str>,
) -> Result<String, BoxError> {
let workspace_root_str = workspace_root.to_string_lossy();
if is_git_url(&workspace_root_str) {
generate_dockerfile_from_git(
&workspace_root_str,
git_ref,
package,
features,
no_default_features,
output,
base_image,
final_image,
args,
build_args,
generate_dockerignore,
env,
build_env,
bin,
)?;
} else {
generate_dockerfile(
workspace_root,
package,
features,
no_default_features,
output,
base_image,
final_image,
args,
build_args,
generate_dockerignore,
env,
build_env,
bin,
)
.await?;
}
Ok(format!("Generated Dockerfile at: {}", output.display()))
}
#[allow(
clippy::too_many_arguments,
clippy::too_many_lines,
clippy::unused_async
)]
pub async fn handle_affected_packages_command(
workspace_root: &Path,
changed_files: &[String],
target_package: Option<&str>,
#[cfg(feature = "git-diff")] git_base: Option<&str>,
#[cfg(feature = "git-diff")] git_head: Option<&str>,
include_reasoning: bool,
ignore_patterns: Option<&[String]>,
#[cfg(feature = "_workspace")] workspace_type: Option<&[workspace::WorkspaceType]>,
output: OutputType,
) -> Result<String, BoxError> {
#[cfg(feature = "git-diff")]
use crate::git_diff::{
build_external_dependency_map, extract_changed_dependencies_from_git,
find_packages_affected_by_external_deps_with_mapping, get_changed_files_from_git,
};
#[cfg(feature = "_workspace")]
log::debug!("Using workspace type filter: {workspace_type:?}");
let mut all_changed_files = changed_files.to_vec();
#[allow(unused_mut)]
let mut external_dependency_mapping = BTreeMap::<String, Vec<String>>::new();
#[cfg(feature = "git-diff")]
if let (Some(base), Some(head)) = (git_base, git_head) {
let git_changed_files = get_changed_files_from_git(workspace_root, base, head)?;
log::debug!("Git changed files: {git_changed_files:?}");
all_changed_files.extend(git_changed_files);
log::debug!("Analyzing external dependency changes from lockfile");
if let Ok(changed_external_deps) =
extract_changed_dependencies_from_git(workspace_root, base, head, &all_changed_files)
{
log::debug!("Changed external dependencies: {changed_external_deps:?}");
if !changed_external_deps.is_empty() {
#[cfg(feature = "_workspace")]
let workspace_members_owned = {
let workspaces =
workspace::detect_workspaces(workspace_root, workspace_type).await;
workspaces
.ok()
.and_then(workspace::select_primary_workspace)
.map_or_else(Vec::new, |ws| ws.member_patterns().to_vec())
};
#[cfg(not(feature = "_workspace"))]
let workspace_members_owned = {
let workspace_cargo_path = workspace_root.join("Cargo.toml");
switchy_fs::unsync::read_to_string(&workspace_cargo_path)
.await
.ok()
.and_then(|workspace_source| {
toml::from_str::<Value>(&workspace_source).ok()
})
.and_then(|workspace_value| {
workspace_value
.get("workspace")
.and_then(|x| x.get("members"))
.and_then(|x| x.as_array())
.and_then(|x| {
x.iter().map(|x| x.as_str()).collect::<Option<Vec<_>>>()
})
.map(|raw| expand_workspace_member_globs(workspace_root, &raw))
})
.unwrap_or_default()
};
if !workspace_members_owned.is_empty() {
if let Ok(external_dep_map) =
build_external_dependency_map(workspace_root, &workspace_members_owned)
{
external_dependency_mapping =
find_packages_affected_by_external_deps_with_mapping(
&external_dep_map,
&changed_external_deps,
);
log::debug!("External dependency mapping: {external_dependency_mapping:?}");
}
}
}
}
}
all_changed_files.sort();
all_changed_files.dedup();
let ignore_patterns_vec = ignore_patterns.unwrap_or(&[]).to_vec();
let mut affected = if include_reasoning {
find_affected_packages_with_reasoning(
workspace_root,
&all_changed_files,
&ignore_patterns_vec,
)?
} else {
find_affected_packages(workspace_root, &all_changed_files, &ignore_patterns_vec)?
.into_iter()
.map(|name| AffectedPackageInfo {
name,
reasoning: None,
})
.collect()
};
for (external_pkg, external_deps) in &external_dependency_mapping {
if !affected.iter().any(|pkg| &pkg.name == external_pkg) {
let reasoning = if include_reasoning {
Some(
external_deps
.iter()
.map(|dep| format!("Affected by external dependency: {dep}"))
.collect(),
)
} else {
None
};
affected.push(AffectedPackageInfo {
name: external_pkg.clone(),
reasoning,
});
log::debug!("Added package affected by external dependencies: {external_pkg}");
}
}
affected.sort_by(|a, b| a.name.cmp(&b.name));
let result = if let Some(target) = target_package {
let is_affected = affected.iter().any(|p| p.name == target);
let reasoning = affected
.iter()
.find(|p| p.name == target)
.and_then(|p| p.reasoning.clone());
let result = SinglePackageResult {
package: target.to_string(),
affected: is_affected,
reasoning,
all_affected: affected,
};
match output {
OutputType::Json => serde_json::to_string(&result)?,
OutputType::Raw => if is_affected { "true" } else { "false" }.to_string(),
}
} else {
let result = AffectedPackagesResult {
affected_packages: affected,
};
match output {
OutputType::Json => serde_json::to_string(&result)?,
OutputType::Raw => {
let mut results = Vec::new();
for package in result.affected_packages {
results.push(package.name);
}
results.join("\n")
}
}
};
Ok(result)
}
#[allow(clippy::too_many_arguments)]
pub async fn process_workspace_configs(
workspace_path: &Path,
offset: Option<u16>,
max: Option<u16>,
chunked: Option<u16>,
spread: bool,
randomize: bool,
seed: Option<u64>,
specific_features: Option<&[String]>,
skip_features_override: Option<&[String]>,
required_features_override: Option<&[String]>,
) -> Result<Vec<serde_json::Map<String, serde_json::Value>>, BoxError> {
log::debug!(
"Processing workspace configs from '{}'",
workspace_path.display()
);
let workspace_cargo_path = workspace_path.join("Cargo.toml");
let workspace_source = switchy_fs::unsync::read_to_string(&workspace_cargo_path).await?;
let workspace_value: Value = toml::from_str(&workspace_source)?;
let workspace_members_raw = workspace_value
.get("workspace")
.and_then(|x| x.get("members"))
.and_then(|x| x.as_array())
.and_then(|x| x.iter().map(|x| x.as_str()).collect::<Option<Vec<_>>>());
match workspace_members_raw {
None => {
process_configs(
workspace_path,
offset,
max,
chunked,
spread,
randomize,
seed,
specific_features,
skip_features_override,
required_features_override,
)
.await
}
Some(members_raw) => {
let members = expand_workspace_member_globs(workspace_path, &members_raw);
let mut all_packages = Vec::new();
for member_path in &members {
let full_path = workspace_path.join(member_path);
let cargo_path = full_path.join("Cargo.toml");
if !switchy_fs::unsync::exists(&cargo_path).await {
log::trace!("Skipping workspace member {member_path} (no Cargo.toml)");
continue;
}
log::debug!("Processing workspace member: {member_path}");
match process_configs(
&full_path,
offset,
max,
chunked,
spread,
randomize,
seed,
specific_features,
skip_features_override,
required_features_override,
)
.await
{
Ok(mut packages) => {
all_packages.append(&mut packages);
}
Err(e) => {
log::warn!("Failed to process workspace member {member_path}: {e}");
}
}
}
Ok(all_packages)
}
}
}
#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
pub fn handle_validate_feature_propagation_command(
features: Option<Vec<String>>,
skip_features: Option<Vec<String>>,
path: Option<std::path::PathBuf>,
workspace_only: bool,
output: OutputType,
strict_optional_propagation: bool,
allow_missing: &[String],
allow_incorrect: &[String],
ignore_packages: &[String],
ignore_features: &[String],
use_config_overrides: bool,
use_cargo_metadata_overrides: bool,
warn_expired: bool,
fail_on_expired: bool,
verbose_overrides: bool,
parent_packages: Option<Vec<String>>,
parent_depth: Option<u8>,
parent_skip_features: Option<Vec<String>>,
parent_prefix: &[String],
no_parent_config: bool,
) -> Result<ValidationResult, BoxError> {
use crate::feature_validator::{
OverrideOptions, OverrideSource, OverrideType, ParentValidationConfig, PrefixOverride,
ValidationOverride,
};
let mut cli_overrides = Vec::new();
for entry in allow_missing {
let parts: Vec<&str> = entry.split(':').collect();
let (package, feature, dependency) = match parts.len() {
2 => (None, parts[0], parts[1]),
3 => (Some(parts[0].to_string()), parts[1], parts[2]),
_ => {
return Err(format!("Invalid --allow-missing format: {entry}. Expected '[package:]feature:dependency'").into());
}
};
cli_overrides.push(ValidationOverride {
feature: feature.to_string(),
dependency: dependency.to_string(),
package,
override_type: OverrideType::AllowMissing,
reason: Some("CLI override".to_string()),
expires: None,
source: OverrideSource::Cli,
});
}
for entry in allow_incorrect {
let parts: Vec<&str> = entry.split(':').collect();
let (package, feature, dependency) = match parts.len() {
2 => (None, parts[0], parts[1]),
3 => (Some(parts[0].to_string()), parts[1], parts[2]),
_ => {
return Err(format!(
"Invalid --allow-incorrect format: {entry}. Expected '[package:]feature:entry'"
)
.into());
}
};
cli_overrides.push(ValidationOverride {
feature: feature.to_string(),
dependency: dependency.to_string(),
package,
override_type: OverrideType::AllowIncorrect,
reason: Some("CLI override".to_string()),
expires: None,
source: OverrideSource::Cli,
});
}
let mut cli_prefix_overrides = Vec::new();
for entry in parent_prefix {
let parts: Vec<&str> = entry.split(':').collect();
if parts.len() != 2 {
return Err(format!(
"Invalid --parent-prefix format: {entry}. Expected 'dependency:prefix'"
)
.into());
}
cli_prefix_overrides.push(PrefixOverride {
dependency: parts[0].to_string(),
prefix: parts[1].to_string(),
});
}
let config = ValidatorConfig {
features,
skip_features,
workspace_only,
output_format: output,
strict_optional_propagation,
cli_overrides,
override_options: OverrideOptions {
use_config_overrides,
use_cargo_metadata_overrides,
warn_expired,
fail_on_expired,
verbose_overrides,
},
ignore_packages: ignore_packages.to_vec(),
ignore_features: ignore_features.to_vec(),
parent_config: ParentValidationConfig {
cli_packages: parent_packages.unwrap_or_default(),
cli_depth: parent_depth,
cli_skip_features: parent_skip_features.unwrap_or_default(),
cli_prefix_overrides,
use_config: !no_parent_config,
},
};
let validator = FeatureValidator::new(path, config)?;
Ok(validator.validate()?)
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
pub async fn handle_packages_command(
file: &str,
os: Option<&str>,
packages: Option<&[String]>,
changed_files: Option<&[String]>,
#[cfg(feature = "git-diff")] git_base: Option<&str>,
#[cfg(feature = "git-diff")] git_head: Option<&str>,
#[cfg(feature = "git-diff")] include_reasoning: bool,
max_parallel: Option<u16>,
#[cfg(feature = "git-diff")] ignore_patterns: Option<&[String]>,
skip_if: &[String],
include_if: &[String],
#[cfg(feature = "_workspace")] workspace_type: Option<&[workspace::WorkspaceType]>,
output: OutputType,
) -> Result<String, BoxError> {
use std::str::FromStr;
#[cfg(feature = "_workspace")]
log::debug!("Using workspace type filter: {workspace_type:?}");
let path = std::path::PathBuf::from_str(file)?;
#[cfg(feature = "_workspace")]
let workspaces = workspace::detect_workspaces(&path, workspace_type).await?;
#[cfg(feature = "_workspace")]
let detected_workspace = workspace::select_primary_workspace(workspaces);
#[cfg(feature = "_workspace")]
let workspace_members: Vec<String> = detected_workspace
.as_ref()
.map_or_else(Vec::new, |ws| ws.member_patterns().to_vec());
#[cfg(feature = "_workspace")]
let package_name_to_path = if let Some(ws) = detected_workspace.as_ref() {
ws.package_name_to_path().await?
} else {
BTreeMap::new()
};
#[cfg(not(feature = "_workspace"))]
let (workspace_members, package_name_to_path) = {
let workspace_cargo_path = path.join("Cargo.toml");
let workspace_source = switchy_fs::unsync::read_to_string(&workspace_cargo_path).await?;
let workspace_value: Value = toml::from_str(&workspace_source)?;
let workspace_members_raw = workspace_value
.get("workspace")
.and_then(|x| x.get("members"))
.and_then(|x| x.as_array())
.and_then(|x| x.iter().map(|x| x.as_str()).collect::<Option<Vec<_>>>())
.unwrap_or_default();
let workspace_members = expand_workspace_member_globs(&path, &workspace_members_raw);
let mut package_name_to_path = BTreeMap::new();
for member_path in &workspace_members {
let full_path = path.join(member_path);
let cargo_path = full_path.join("Cargo.toml");
if switchy_fs::unsync::exists(&cargo_path).await {
let source = switchy_fs::unsync::read_to_string(&cargo_path).await?;
let value: Value = toml::from_str(&source)?;
if let Some(package_name) = value
.get("package")
.and_then(|x| x.get("name"))
.and_then(|x| x.as_str())
{
package_name_to_path.insert(package_name.to_string(), member_path.clone());
}
}
}
(workspace_members, package_name_to_path)
};
#[cfg(not(feature = "git-diff"))]
let _ = &workspace_members;
let all_package_names: Vec<String> = package_name_to_path.keys().cloned().collect();
let filtered_packages = if !skip_if.is_empty() || !include_if.is_empty() {
package_filter::apply_filters(
&all_package_names,
&package_name_to_path,
&path,
skip_if,
include_if,
)?
} else {
all_package_names.clone()
};
let selected_packages: Vec<String> = if let Some(pkg_list) = packages
&& !pkg_list.is_empty()
{
let expanded_packages = expand_pattern_list(pkg_list, &all_package_names);
log::debug!("Expanded packages: {expanded_packages:?}");
expanded_packages
.iter()
.filter(|p| filtered_packages.contains(p))
.cloned()
.collect()
} else {
filtered_packages
};
let use_filtering = changed_files.is_some() || {
#[cfg(feature = "git-diff")]
{
git_base.is_some() && git_head.is_some()
}
#[cfg(not(feature = "git-diff"))]
{
false
}
};
let affected_packages: Vec<String> = if use_filtering {
#[cfg(feature = "git-diff")]
use crate::git_diff::{
build_external_dependency_map, extract_changed_dependencies_from_git,
find_packages_affected_by_external_deps_with_mapping, get_changed_files_from_git,
};
let mut all_changed_files = Vec::new();
if let Some(files) = changed_files {
all_changed_files.extend(files.to_vec());
}
#[cfg(feature = "git-diff")]
if let (Some(base), Some(head)) = (git_base, git_head) {
let git_changed_files = get_changed_files_from_git(&path, base, head)?;
log::debug!("Git changed files: {git_changed_files:?}");
all_changed_files.extend(git_changed_files);
}
all_changed_files.sort();
all_changed_files.dedup();
#[allow(unused_mut)]
let mut external_affected_packages = Vec::<String>::new();
#[cfg(feature = "git-diff")]
if let (Some(base), Some(head)) = (git_base, git_head) {
log::debug!("Analyzing external dependency changes from Cargo.lock");
if let Ok(changed_external_deps) =
extract_changed_dependencies_from_git(&path, base, head, &all_changed_files)
{
log::debug!("Changed external dependencies: {changed_external_deps:?}");
if !changed_external_deps.is_empty() {
if let Ok(external_dep_map) =
build_external_dependency_map(&path, &workspace_members)
{
let external_affected_mapping =
find_packages_affected_by_external_deps_with_mapping(
&external_dep_map,
&changed_external_deps,
);
external_affected_packages =
external_affected_mapping.keys().cloned().collect();
log::debug!(
"Packages affected by external dependency changes: {external_affected_packages:?}"
);
}
}
}
}
let mut file_affected_packages = if all_changed_files.is_empty() {
Vec::new()
} else {
#[cfg(feature = "git-diff")]
{
let ignore_patterns_vec = ignore_patterns.unwrap_or(&[]).to_vec();
if include_reasoning {
let with_reasoning = find_affected_packages_with_reasoning(
&path,
&all_changed_files,
&ignore_patterns_vec,
)?;
with_reasoning.iter().map(|pkg| pkg.name.clone()).collect()
} else {
find_affected_packages(&path, &all_changed_files, &ignore_patterns_vec)?
}
}
#[cfg(not(feature = "git-diff"))]
{
return Err("Git diff analysis requires the git-diff feature".into());
}
};
file_affected_packages.extend(external_affected_packages);
file_affected_packages.sort();
file_affected_packages.dedup();
file_affected_packages
} else {
selected_packages.clone()
};
let mut package_list = Vec::new();
for package_name in selected_packages {
if affected_packages.contains(&package_name)
&& let Some(package_path) = package_name_to_path.get(&package_name)
{
let mut entry = serde_json::Map::new();
entry.insert(
"name".to_string(),
serde_json::Value::String(package_name.clone()),
);
entry.insert(
"path".to_string(),
serde_json::Value::String(package_path.clone()),
);
let os_value = format!("{}{}", os.unwrap_or("ubuntu"), "-latest");
entry.insert("os".to_string(), serde_json::Value::String(os_value));
package_list.push(entry);
}
}
if let Some(limit) = max_parallel {
package_list.truncate(limit as usize);
}
let result = match output {
OutputType::Json => serde_json::to_string(&package_list)?,
OutputType::Raw => package_list
.iter()
.filter_map(|p| p.get("name").and_then(|n| n.as_str()))
.collect::<Vec<_>>()
.join("\n"),
};
Ok(result)
}
#[derive(Debug, Clone, Serialize)]
pub struct WorkspaceToolchains {
pub dependencies: Vec<String>,
pub toolchains: Vec<String>,
pub ci_steps: Vec<String>,
pub env: BTreeMap<String, String>,
pub nightly_packages: Vec<String>,
pub git_submodules: bool,
}
#[allow(clippy::too_many_lines)]
pub fn handle_workspace_toolchains_command(
workspace_root: &Path,
os: &str,
output: OutputType,
) -> Result<String, BoxError> {
let mut all_dependencies: BTreeSet<String> = BTreeSet::new();
let mut all_toolchains: BTreeSet<String> = BTreeSet::new();
let mut all_ci_steps: BTreeSet<String> = BTreeSet::new();
let mut all_env: BTreeMap<String, String> = BTreeMap::new();
let mut nightly_packages: BTreeSet<String> = BTreeSet::new();
let mut needs_git_submodules = false;
let workspace_clippier_path = workspace_root.join("clippier.toml");
if switchy_fs::exists(&workspace_clippier_path) {
let content = switchy_fs::sync::read_to_string(&workspace_clippier_path)?;
if let Ok(conf) = toml::from_str::<WorkspaceClippierConf>(&content) {
log::debug!("Found workspace-level clippier.toml");
if let Some(deps) = &conf.dependencies {
for dep in deps {
if let Some(cmd) = &dep.command {
all_dependencies.insert(cmd.clone());
}
if let Some(toolchain) = &dep.toolchain {
all_toolchains.insert(toolchain.clone());
}
}
}
if let Some(ci_steps) = &conf.ci_steps {
let steps: Vec<Step> = ci_steps.clone().into();
for step in steps {
if let Some(cmd) = &step.command {
all_ci_steps.insert(cmd.clone());
}
if let Some(toolchain) = &step.toolchain {
all_toolchains.insert(toolchain.clone());
}
}
}
if let Some(env) = &conf.env {
for (key, value) in env {
let resolved_value = match value {
ClippierEnv::Value(v) => v.clone(),
ClippierEnv::FilteredValue { value, .. } => value.clone(),
};
all_env.insert(key.clone(), resolved_value);
}
}
if let Some(configs) = &conf.config {
for config in configs {
if config.os != os {
continue;
}
if let Some(deps) = &config.dependencies {
for dep in deps {
if let Some(cmd) = &dep.command {
all_dependencies.insert(cmd.clone());
}
if let Some(toolchain) = &dep.toolchain {
all_toolchains.insert(toolchain.clone());
}
}
}
if let Some(ci_steps) = &config.ci_steps {
let steps: Vec<Step> = ci_steps.clone().into();
for step in steps {
if let Some(cmd) = &step.command {
all_ci_steps.insert(cmd.clone());
}
if let Some(toolchain) = &step.toolchain {
all_toolchains.insert(toolchain.clone());
}
}
}
if let Some(env) = &config.env {
for (key, value) in env {
let resolved_value = match value {
ClippierEnv::Value(v) => v.clone(),
ClippierEnv::FilteredValue { value, .. } => value.clone(),
};
all_env.insert(key.clone(), resolved_value);
}
}
if config.git_submodules == Some(true) {
needs_git_submodules = true;
}
}
}
if conf.git_submodules == Some(true) {
needs_git_submodules = true;
}
}
}
let packages_dir = workspace_root.join("packages");
if switchy_fs::exists(&packages_dir) {
for entry in switchy_fs::sync::walk_dir_sorted(&packages_dir)? {
let entry_path = entry.path();
let clippier_path = entry_path.join("clippier.toml");
if !switchy_fs::exists(&clippier_path) {
continue;
}
log::debug!("Processing clippier.toml at: {}", clippier_path.display());
let cargo_toml_path = entry_path.join("Cargo.toml");
let package_name = if switchy_fs::exists(&cargo_toml_path) {
switchy_fs::sync::read_to_string(&cargo_toml_path)
.ok()
.and_then(|content| toml::from_str::<Value>(&content).ok())
.and_then(|value| {
value
.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.map(ToString::to_string)
})
} else {
None
};
let package_name = package_name.unwrap_or_else(|| {
entry_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string()
});
let content = switchy_fs::sync::read_to_string(&clippier_path)?;
let conf: ClippierConf = match toml::from_str(&content) {
Ok(c) => c,
Err(e) => {
log::warn!("Failed to parse {}: {}", clippier_path.display(), e);
continue;
}
};
if conf.rust.as_ref().and_then(|r| r.nightly) == Some(true) {
nightly_packages.insert(package_name.clone());
}
if conf.git_submodules == Some(true) {
needs_git_submodules = true;
}
if let Some(ci_steps) = &conf.ci_steps {
let steps: Vec<Step> = ci_steps.clone().into();
for step in steps {
if let Some(cmd) = &step.command {
all_ci_steps.insert(cmd.clone());
}
if let Some(toolchain) = &step.toolchain {
all_toolchains.insert(toolchain.clone());
}
}
}
if let Some(env) = &conf.env {
for (key, value) in env {
let resolved_value = match value {
ClippierEnv::Value(v) => v.clone(),
ClippierEnv::FilteredValue { value, .. } => value.clone(),
};
all_env.insert(key.clone(), resolved_value);
}
}
if let Some(configs) = &conf.config {
for config in configs {
if config.os != os {
continue;
}
if config.rust.as_ref().and_then(|r| r.nightly) == Some(true) {
nightly_packages.insert(package_name.clone());
}
if config.git_submodules == Some(true) {
needs_git_submodules = true;
}
if let Some(deps) = &config.dependencies {
for dep in deps {
if let Some(cmd) = &dep.command {
all_dependencies.insert(cmd.clone());
}
if let Some(toolchain) = &dep.toolchain {
all_toolchains.insert(toolchain.clone());
}
}
}
if let Some(ci_steps) = &config.ci_steps {
let steps: Vec<Step> = ci_steps.clone().into();
for step in steps {
if let Some(cmd) = &step.command {
all_ci_steps.insert(cmd.clone());
}
if let Some(toolchain) = &step.toolchain {
all_toolchains.insert(toolchain.clone());
}
}
}
if let Some(env) = &config.env {
for (key, value) in env {
let resolved_value = match value {
ClippierEnv::Value(v) => v.clone(),
ClippierEnv::FilteredValue { value, .. } => value.clone(),
};
all_env.insert(key.clone(), resolved_value);
}
}
}
}
}
}
let result = WorkspaceToolchains {
dependencies: all_dependencies.into_iter().collect(),
toolchains: all_toolchains.into_iter().collect(),
ci_steps: all_ci_steps.into_iter().collect(),
env: all_env,
nightly_packages: nightly_packages.into_iter().collect(),
git_submodules: needs_git_submodules,
};
match output {
OutputType::Json => Ok(serde_json::to_string(&result)?),
OutputType::Raw => {
use std::fmt::Write as _;
let mut output = String::new();
output.push_str("Dependencies:\n");
for dep in &result.dependencies {
writeln!(output, " {dep}")?;
}
output.push_str("\nToolchains:\n");
for toolchain in &result.toolchains {
writeln!(output, " {toolchain}")?;
}
output.push_str("\nCI Steps:\n");
for step in &result.ci_steps {
writeln!(output, " {step}")?;
}
if !result.env.is_empty() {
output.push_str("\nEnvironment:\n");
for (key, value) in &result.env {
writeln!(output, " {key}={value}")?;
}
}
if !result.nightly_packages.is_empty() {
output.push_str("\nNightly Packages:\n");
for pkg in &result.nightly_packages {
writeln!(output, " {pkg}")?;
}
}
writeln!(output, "\nGit Submodules: {}", result.git_submodules)?;
Ok(output)
}
}
}
#[cfg(feature = "check")]
pub fn handle_check_command(
working_dir: Option<&Path>,
tool_names: Option<&[String]>,
list_tools: bool,
config: tools::ToolsConfig,
output: OutputType,
color: ColorMode,
enable_tui: bool,
) -> Result<String, BoxError> {
use tools::{ToolRegistry, ToolRunner};
let required_tools = config.required.clone();
let overlap_warning_suppress = config.overlap_warning_suppress.clone();
let registry = ToolRegistry::new(config, working_dir)?;
if list_tools {
let tool_info = registry.list_tools();
return match output {
OutputType::Json => Ok(serde_json::to_string_pretty(
&tool_info
.iter()
.map(|t| {
serde_json::json!({
"name": t.name,
"display_name": t.display_name,
"available": t.available,
"required": t.required,
"skipped": t.skipped,
"path": t.path,
"execution_mode": t.execution_mode,
"runner": t.runner,
})
})
.collect::<Vec<_>>(),
)?),
OutputType::Raw => {
use std::fmt::Write;
let mut output = String::new();
for tool in tool_info {
let status = if tool.skipped {
"SKIPPED"
} else if tool.available {
"AVAILABLE"
} else if tool.required {
"REQUIRED (missing)"
} else {
"not found"
};
let mode = if let Some(runner) = &tool.runner {
format!("{} ({runner})", tool.execution_mode)
} else {
tool.execution_mode.clone()
};
let _ = writeln!(output, "{}: {} [{}]", tool.display_name, status, mode);
}
Ok(output)
}
};
}
let runner = working_dir.map_or_else(
|| ToolRunner::new(®istry),
|dir| ToolRunner::new(®istry).with_working_dir(dir),
);
let runner = runner.with_color_mode(match (output, color) {
(OutputType::Json, ColorMode::Auto) => ColorMode::Never,
(_, value) => value,
});
let names = if let Some(names) = tool_names {
names.to_vec()
} else {
let auto_detected = tools::auto_detect_check_tools(working_dir)?;
tools::merge_tool_names(&auto_detected, &required_tools)
};
if output == OutputType::Raw {
let warnings = tools::overlap_warnings_for_selected_tools(
®istry,
&names,
&[tools::ToolCapability::Format, tools::ToolCapability::Lint],
&overlap_warning_suppress,
working_dir,
);
for warning in warnings {
eprintln!("{warning}");
}
}
let name_refs: Vec<&str> = names.iter().map(String::as_str).collect();
let should_use_tui =
enable_tui && !list_tools && output == OutputType::Raw && can_use_interactive_tui();
let results = if should_use_tui {
runner.run_specific_with_tui(&name_refs, &[], true)?
} else {
runner.run_specific(&name_refs, &[], true)?
};
match output {
OutputType::Json => Ok(tools::results_to_json(&results)?),
OutputType::Raw => {
tools::print_summary(&results);
Ok(String::new())
}
}
}
#[cfg(feature = "format")]
#[allow(clippy::too_many_arguments)]
pub fn handle_fmt_command(
working_dir: Option<&Path>,
tool_names: Option<&[String]>,
check_only: bool,
list_tools: bool,
config: tools::ToolsConfig,
output: OutputType,
color: ColorMode,
enable_tui: bool,
) -> Result<String, BoxError> {
use tools::{ToolRegistry, ToolRunner};
let required_tools = config.required.clone();
let overlap_warning_suppress = config.overlap_warning_suppress.clone();
let registry = ToolRegistry::new(config, working_dir)?;
if list_tools {
let tool_info: Vec<_> = registry
.list_tools()
.into_iter()
.filter(|t| t.capabilities.contains(&tools::ToolCapability::Format))
.collect();
return match output {
OutputType::Json => Ok(serde_json::to_string_pretty(
&tool_info
.iter()
.map(|t| {
serde_json::json!({
"name": t.name,
"display_name": t.display_name,
"available": t.available,
"required": t.required,
"skipped": t.skipped,
"path": t.path,
"execution_mode": t.execution_mode,
"runner": t.runner,
})
})
.collect::<Vec<_>>(),
)?),
OutputType::Raw => {
use std::fmt::Write;
let mut output = String::new();
for tool in tool_info {
let status = if tool.skipped {
"SKIPPED"
} else if tool.available {
"AVAILABLE"
} else if tool.required {
"REQUIRED (missing)"
} else {
"not found"
};
let mode = if let Some(runner) = &tool.runner {
format!("{} ({runner})", tool.execution_mode)
} else {
tool.execution_mode.clone()
};
let _ = writeln!(output, "{}: {} [{}]", tool.display_name, status, mode);
}
Ok(output)
}
};
}
let runner = working_dir.map_or_else(
|| ToolRunner::new(®istry),
|dir| ToolRunner::new(®istry).with_working_dir(dir),
);
let runner = runner.with_color_mode(match (output, color) {
(OutputType::Json, ColorMode::Auto) => ColorMode::Never,
(_, value) => value,
});
let names = if let Some(names) = tool_names {
names.to_vec()
} else {
let auto_detected = tools::auto_detect_fmt_tools(working_dir)?;
tools::merge_tool_names(&auto_detected, &required_tools)
};
if output == OutputType::Raw {
let warnings = tools::overlap_warnings_for_selected_tools(
®istry,
&names,
&[tools::ToolCapability::Format],
&overlap_warning_suppress,
working_dir,
);
for warning in warnings {
eprintln!("{warning}");
}
}
let name_refs: Vec<&str> = names.iter().map(String::as_str).collect();
let should_use_tui =
enable_tui && !list_tools && output == OutputType::Raw && can_use_interactive_tui();
let results = if should_use_tui {
runner.run_specific_with_tui(&name_refs, &[], check_only)?
} else {
runner.run_specific(&name_refs, &[], check_only)?
};
match output {
OutputType::Json => Ok(tools::results_to_json(&results)?),
OutputType::Raw => {
tools::print_summary(&results);
Ok(String::new())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_node_config_parsing() {
let toml_str = r#"
[node]
package-manager = "pnpm"
node-version = "20"
skip-packages = ["@myorg/deprecated-*", "@myorg/test-*"]
args = ["--frozen-lockfile"]
"#;
let parsed: Result<WorkspaceClippierConf, _> = toml::from_str(toml_str);
assert!(parsed.is_ok(), "Failed to parse: {:?}", parsed.err());
let conf = parsed.unwrap();
let node = conf.node.expect("node section should be present");
assert_eq!(node.package_manager, Some("pnpm".to_string()));
assert_eq!(node.node_version, Some("20".to_string()));
assert_eq!(
node.skip_packages,
Some(vec![
"@myorg/deprecated-*".to_string(),
"@myorg/test-*".to_string()
])
);
let args: Vec<String> = node.args.unwrap().into();
assert_eq!(args, vec!["--frozen-lockfile".to_string()]);
}
#[test]
fn test_workspace_config_parses_os_configs() {
let toml_str = r#"
[[config]]
os = "ubuntu"
[[config]]
os = "macos"
[[config]]
os = "windows"
"#;
let parsed: Result<WorkspaceClippierConf, _> = toml::from_str(toml_str);
assert!(parsed.is_ok(), "Failed to parse: {:?}", parsed.err());
let conf = parsed.unwrap();
let configs = conf.config.expect("config section should be present");
let oses = configs
.iter()
.map(|config| config.os.as_str())
.collect::<Vec<_>>();
assert_eq!(oses, vec!["ubuntu", "macos", "windows"]);
}
#[switchy_async::test]
async fn test_workspace_config_provides_default_os_configs() {
let temp_dir = switchy_fs::tempdir().unwrap();
let temp_path = temp_dir.path();
let package_path = temp_path.join("packages").join("package-a");
switchy_fs::sync::create_dir_all(&package_path).unwrap();
switchy_fs::sync::write(
temp_path.join("Cargo.toml"),
r#"
[workspace]
members = ["packages/*"]
"#,
)
.unwrap();
switchy_fs::sync::write(
temp_path.join("clippier.toml"),
r#"
[[config]]
os = "ubuntu"
[[config]]
os = "macos"
[[config]]
os = "windows"
"#,
)
.unwrap();
switchy_fs::sync::write(
package_path.join("Cargo.toml"),
r#"
[package]
name = "package-a"
version = "0.1.0"
[features]
default = []
feature-a = []
"#,
)
.unwrap();
let result = process_workspace_configs(
temp_path, None, None, None, false, false, None, None, None, None,
)
.await
.unwrap();
let oses = result
.iter()
.map(|package| package.get("os").unwrap().as_str().unwrap())
.collect::<Vec<_>>();
assert_eq!(oses, vec!["ubuntu", "macos", "windows"]);
}
#[switchy_async::test]
async fn test_package_config_overrides_workspace_os_configs() {
let temp_dir = switchy_fs::tempdir().unwrap();
let temp_path = temp_dir.path();
let package_path = temp_path.join("packages").join("package-a");
switchy_fs::sync::create_dir_all(&package_path).unwrap();
switchy_fs::sync::write(
temp_path.join("Cargo.toml"),
r#"
[workspace]
members = ["packages/*"]
"#,
)
.unwrap();
switchy_fs::sync::write(
temp_path.join("clippier.toml"),
r#"
[[config]]
os = "ubuntu"
[[config]]
os = "macos"
[[config]]
os = "windows"
"#,
)
.unwrap();
switchy_fs::sync::write(
package_path.join("Cargo.toml"),
r#"
[package]
name = "package-a"
version = "0.1.0"
[features]
default = []
feature-a = []
"#,
)
.unwrap();
switchy_fs::sync::write(
package_path.join("clippier.toml"),
r#"
[[config]]
os = "ubuntu"
"#,
)
.unwrap();
let result = process_workspace_configs(
temp_path, None, None, None, false, false, None, None, None, None,
)
.await
.unwrap();
let oses = result
.iter()
.map(|package| package.get("os").unwrap().as_str().unwrap())
.collect::<Vec<_>>();
assert_eq!(oses, vec!["ubuntu"]);
}
#[test]
fn test_node_config_in_os_config() {
let toml_str = r#"
[[config]]
os = "ubuntu"
node = { package-manager = "pnpm", args = ["--frozen-lockfile"] }
"#;
let parsed: Result<ClippierConf, _> = toml::from_str(toml_str);
assert!(parsed.is_ok(), "Failed to parse: {:?}", parsed.err());
let conf = parsed.unwrap();
let configs = conf.config.expect("config section should be present");
assert_eq!(configs.len(), 1);
let node = configs[0].node.as_ref().expect("node should be present");
assert_eq!(node.package_manager, Some("pnpm".to_string()));
let args: Vec<String> = node.args.clone().unwrap().into();
assert_eq!(args, vec!["--frozen-lockfile".to_string()]);
}
#[test]
fn test_rust_and_node_config_together() {
let toml_str = r#"
[rust]
nightly = true
cargo = ["--locked"]
[node]
package-manager = "pnpm"
node-version = "20"
[[config]]
os = "ubuntu"
rust = { skip-features = ["simd"] }
node = { args = ["--frozen-lockfile"] }
"#;
let parsed: Result<ClippierConf, _> = toml::from_str(toml_str);
assert!(parsed.is_ok(), "Failed to parse: {:?}", parsed.err());
let conf = parsed.unwrap();
let rust = conf.rust.as_ref().expect("rust should be present");
assert_eq!(rust.nightly, Some(true));
let node = conf.node.as_ref().expect("node should be present");
assert_eq!(node.package_manager, Some("pnpm".to_string()));
let configs = conf.config.expect("config section should be present");
let ubuntu_config = &configs[0];
let os_rust = ubuntu_config
.rust
.as_ref()
.expect("rust override should exist");
assert_eq!(os_rust.skip_features, Some(vec!["simd".to_string()]));
let os_node = ubuntu_config
.node
.as_ref()
.expect("node override should exist");
let args: Vec<String> = os_node.args.clone().unwrap().into();
assert_eq!(args, vec!["--frozen-lockfile".to_string()]);
}
#[switchy_async::test]
async fn test_skip_features_combination() {
let temp_dir = switchy_fs::tempdir().unwrap();
let temp_path = temp_dir.path();
let cargo_toml = r#"
[package]
name = "test-package"
version = "0.1.0"
[features]
default = []
feature1 = []
feature2 = []
simd = []
fail-on-warnings = []
"#;
switchy_fs::sync::write(temp_path.join("Cargo.toml"), cargo_toml).unwrap();
let clippier_toml = r#"
[[config]]
os = "ubuntu"
[config.rust]
skip-features = ["simd"]
"#;
switchy_fs::sync::write(temp_path.join("clippier.toml"), clippier_toml).unwrap();
let result = process_configs(
temp_path,
None,
None,
None,
false,
false,
None,
None,
Some(&["fail-on-warnings".to_string()]), None,
)
.await
.unwrap();
assert!(!result.is_empty());
let features = result[0].get("features").unwrap().as_array().unwrap();
let feature_names: Vec<String> = features
.iter()
.map(|f| f.as_str().unwrap().to_string())
.collect();
assert!(!feature_names.contains(&"simd".to_string()));
assert!(!feature_names.contains(&"fail-on-warnings".to_string()));
assert!(feature_names.contains(&"feature1".to_string()));
assert!(feature_names.contains(&"feature2".to_string()));
}
#[switchy_async::test]
async fn test_wildcard_skip_features_suffix() {
let temp_dir = switchy_fs::tempdir().unwrap();
let temp_path = temp_dir.path();
let cargo_toml = r#"
[package]
name = "test-package"
version = "0.1.0"
[features]
default = []
bob-default = []
sally-default = []
audio-default = []
feature1 = []
feature2 = []
enable-bob = []
"#;
switchy_fs::sync::write(temp_path.join("Cargo.toml"), cargo_toml).unwrap();
let clippier_toml = r#"
[[config]]
os = "ubuntu"
[config.rust]
skip-features = ["*-default"]
"#;
switchy_fs::sync::write(temp_path.join("clippier.toml"), clippier_toml).unwrap();
let result = process_configs(
temp_path, None, None, None, false, false, None, None, None, None,
)
.await
.unwrap();
assert!(!result.is_empty());
let features = result[0].get("features").unwrap().as_array().unwrap();
let feature_names: Vec<String> = features
.iter()
.map(|f| f.as_str().unwrap().to_string())
.collect();
assert!(!feature_names.contains(&"bob-default".to_string()));
assert!(!feature_names.contains(&"sally-default".to_string()));
assert!(!feature_names.contains(&"audio-default".to_string()));
assert!(feature_names.contains(&"default".to_string()));
assert!(feature_names.contains(&"feature1".to_string()));
assert!(feature_names.contains(&"feature2".to_string()));
assert!(feature_names.contains(&"enable-bob".to_string()));
}
#[switchy_async::test]
async fn test_wildcard_skip_features_prefix() {
let temp_dir = switchy_fs::tempdir().unwrap();
let temp_path = temp_dir.path();
let cargo_toml = r#"
[package]
name = "test-package"
version = "0.1.0"
[features]
default = []
test-utils = []
test-integration = []
test-e2e = []
feature1 = []
production = []
"#;
switchy_fs::sync::write(temp_path.join("Cargo.toml"), cargo_toml).unwrap();
let clippier_toml = r#"
[[config]]
os = "ubuntu"
[config.rust]
skip-features = ["test-*"]
"#;
switchy_fs::sync::write(temp_path.join("clippier.toml"), clippier_toml).unwrap();
let result = process_configs(
temp_path, None, None, None, false, false, None, None, None, None,
)
.await
.unwrap();
assert!(!result.is_empty());
let features = result[0].get("features").unwrap().as_array().unwrap();
let feature_names: Vec<String> = features
.iter()
.map(|f| f.as_str().unwrap().to_string())
.collect();
assert!(!feature_names.contains(&"test-utils".to_string()));
assert!(!feature_names.contains(&"test-integration".to_string()));
assert!(!feature_names.contains(&"test-e2e".to_string()));
assert!(feature_names.contains(&"default".to_string()));
assert!(feature_names.contains(&"feature1".to_string()));
assert!(feature_names.contains(&"production".to_string()));
}
#[switchy_async::test]
async fn test_wildcard_skip_features_single_char() {
let temp_dir = switchy_fs::tempdir().unwrap();
let temp_path = temp_dir.path();
let cargo_toml = r#"
[package]
name = "test-package"
version = "0.1.0"
[features]
default = []
v1 = []
v2 = []
v3 = []
v10 = []
version = []
"#;
switchy_fs::sync::write(temp_path.join("Cargo.toml"), cargo_toml).unwrap();
let clippier_toml = r#"
[[config]]
os = "ubuntu"
[config.rust]
skip-features = ["v?"]
"#;
switchy_fs::sync::write(temp_path.join("clippier.toml"), clippier_toml).unwrap();
let result = process_configs(
temp_path, None, None, None, false, false, None, None, None, None,
)
.await
.unwrap();
assert!(!result.is_empty());
let features = result[0].get("features").unwrap().as_array().unwrap();
let feature_names: Vec<String> = features
.iter()
.map(|f| f.as_str().unwrap().to_string())
.collect();
assert!(!feature_names.contains(&"v1".to_string()));
assert!(!feature_names.contains(&"v2".to_string()));
assert!(!feature_names.contains(&"v3".to_string()));
assert!(feature_names.contains(&"v10".to_string()));
assert!(feature_names.contains(&"version".to_string()));
assert!(feature_names.contains(&"default".to_string()));
}
#[switchy_async::test]
async fn test_negation_skip_all_except_one() {
let temp_dir = switchy_fs::tempdir().unwrap();
let temp_path = temp_dir.path();
let cargo_toml = r#"
[package]
name = "test-package"
version = "0.1.0"
[features]
default = []
feature1 = []
feature2 = []
enable-bob = []
enable-sally = []
"#;
switchy_fs::sync::write(temp_path.join("Cargo.toml"), cargo_toml).unwrap();
let clippier_toml = r#"
[[config]]
os = "ubuntu"
[config.rust]
skip-features = ["*", "!enable-bob"]
"#;
switchy_fs::sync::write(temp_path.join("clippier.toml"), clippier_toml).unwrap();
let result = process_configs(
temp_path, None, None, None, false, false, None, None, None, None,
)
.await
.unwrap();
assert!(!result.is_empty());
let features = result[0].get("features").unwrap().as_array().unwrap();
let feature_names: Vec<String> = features
.iter()
.map(|f| f.as_str().unwrap().to_string())
.collect();
assert!(feature_names.contains(&"enable-bob".to_string()));
assert!(!feature_names.contains(&"default".to_string()));
assert!(!feature_names.contains(&"feature1".to_string()));
assert!(!feature_names.contains(&"feature2".to_string()));
assert!(!feature_names.contains(&"enable-sally".to_string()));
}
#[switchy_async::test]
async fn test_negation_skip_all_except_pattern() {
let temp_dir = switchy_fs::tempdir().unwrap();
let temp_path = temp_dir.path();
let cargo_toml = r#"
[package]
name = "test-package"
version = "0.1.0"
[features]
default = []
feature1 = []
enable-bob = []
enable-sally = []
enable-feature = []
disable-test = []
"#;
switchy_fs::sync::write(temp_path.join("Cargo.toml"), cargo_toml).unwrap();
let clippier_toml = r#"
[[config]]
os = "ubuntu"
[config.rust]
skip-features = ["*", "!enable-*"]
"#;
switchy_fs::sync::write(temp_path.join("clippier.toml"), clippier_toml).unwrap();
let result = process_configs(
temp_path, None, None, None, false, false, None, None, None, None,
)
.await
.unwrap();
assert!(!result.is_empty());
let features = result[0].get("features").unwrap().as_array().unwrap();
let feature_names: Vec<String> = features
.iter()
.map(|f| f.as_str().unwrap().to_string())
.collect();
assert!(feature_names.contains(&"enable-bob".to_string()));
assert!(feature_names.contains(&"enable-sally".to_string()));
assert!(feature_names.contains(&"enable-feature".to_string()));
assert!(!feature_names.contains(&"default".to_string()));
assert!(!feature_names.contains(&"feature1".to_string()));
assert!(!feature_names.contains(&"disable-test".to_string()));
}
#[switchy_async::test]
async fn test_complex_combined_patterns() {
let temp_dir = switchy_fs::tempdir().unwrap();
let temp_path = temp_dir.path();
let cargo_toml = r#"
[package]
name = "test-package"
version = "0.1.0"
[features]
default = []
bob-default = []
sally-default = []
test-integration = []
test-e2e = []
test-utils = []
feature1 = []
production = []
"#;
switchy_fs::sync::write(temp_path.join("Cargo.toml"), cargo_toml).unwrap();
let clippier_toml = r#"
[[config]]
os = "ubuntu"
[config.rust]
skip-features = ["*-default", "test-*", "!test-utils"]
"#;
switchy_fs::sync::write(temp_path.join("clippier.toml"), clippier_toml).unwrap();
let result = process_configs(
temp_path, None, None, None, false, false, None, None, None, None,
)
.await
.unwrap();
assert!(!result.is_empty());
let features = result[0].get("features").unwrap().as_array().unwrap();
let feature_names: Vec<String> = features
.iter()
.map(|f| f.as_str().unwrap().to_string())
.collect();
assert!(!feature_names.contains(&"bob-default".to_string()));
assert!(!feature_names.contains(&"sally-default".to_string()));
assert!(!feature_names.contains(&"test-integration".to_string()));
assert!(!feature_names.contains(&"test-e2e".to_string()));
assert!(feature_names.contains(&"test-utils".to_string()));
assert!(feature_names.contains(&"default".to_string()));
assert!(feature_names.contains(&"feature1".to_string()));
assert!(feature_names.contains(&"production".to_string()));
}
#[switchy_async::test]
async fn test_command_line_wildcard_override() {
let temp_dir = switchy_fs::tempdir().unwrap();
let temp_path = temp_dir.path();
let cargo_toml = r#"
[package]
name = "test-package"
version = "0.1.0"
[features]
default = []
bob-default = []
test-utils = []
test-e2e = []
feature1 = []
simd = []
"#;
switchy_fs::sync::write(temp_path.join("Cargo.toml"), cargo_toml).unwrap();
let clippier_toml = r#"
[[config]]
os = "ubuntu"
[config.rust]
skip-features = ["*-default"]
"#;
switchy_fs::sync::write(temp_path.join("clippier.toml"), clippier_toml).unwrap();
let result = process_configs(
temp_path,
None,
None,
None,
false,
false,
None,
None,
Some(&["test-*".to_string()]), None,
)
.await
.unwrap();
assert!(!result.is_empty());
let features = result[0].get("features").unwrap().as_array().unwrap();
let feature_names: Vec<String> = features
.iter()
.map(|f| f.as_str().unwrap().to_string())
.collect();
assert!(!feature_names.contains(&"bob-default".to_string()));
assert!(!feature_names.contains(&"test-utils".to_string()));
assert!(!feature_names.contains(&"test-e2e".to_string()));
assert!(feature_names.contains(&"default".to_string()));
assert!(feature_names.contains(&"feature1".to_string()));
assert!(feature_names.contains(&"simd".to_string()));
}
#[switchy_async::test]
async fn test_changed_files_deduplication() {
let mut files = vec![
"packages/api/src/lib.rs".to_string(),
"packages/core/src/lib.rs".to_string(),
"packages/api/src/lib.rs".to_string(),
"packages/models/Cargo.toml".to_string(),
];
files.sort();
files.dedup();
assert_eq!(files.len(), 3);
assert_eq!(
files,
vec![
"packages/api/src/lib.rs",
"packages/core/src/lib.rs",
"packages/models/Cargo.toml",
]
);
}
#[switchy_async::test]
async fn test_package_list_merging() {
let mut file_affected = vec!["api".to_string(), "core".to_string()];
let external_affected = vec!["models".to_string(), "api".to_string()];
file_affected.extend(external_affected);
file_affected.sort();
file_affected.dedup();
assert_eq!(file_affected.len(), 3);
assert_eq!(file_affected, vec!["api", "core", "models"]);
}
#[switchy_async::test]
async fn test_package_filtering_intersection() {
use std::collections::HashSet;
let selected: HashSet<String> = ["api", "web", "cli", "core"]
.iter()
.map(|s| (*s).to_string())
.collect();
let affected: HashSet<String> = ["api", "core", "models"]
.iter()
.map(|s| (*s).to_string())
.collect();
let mut result: Vec<String> = selected
.iter()
.filter(|pkg| affected.contains(*pkg))
.cloned()
.collect();
result.sort();
assert_eq!(result.len(), 2);
assert!(result.contains(&"api".to_string()));
assert!(result.contains(&"core".to_string()));
assert!(!result.contains(&"web".to_string()));
assert!(!result.contains(&"models".to_string()));
}
#[switchy_async::test]
async fn test_empty_changed_files_deduplication() {
let mut files: Vec<String> = vec![];
files.sort();
files.dedup();
assert_eq!(files.len(), 0);
}
#[switchy_async::test]
async fn test_all_duplicate_files() {
let mut files = vec![
"packages/api/src/lib.rs".to_string(),
"packages/api/src/lib.rs".to_string(),
"packages/api/src/lib.rs".to_string(),
];
files.sort();
files.dedup();
assert_eq!(files.len(), 1);
assert_eq!(files[0], "packages/api/src/lib.rs");
}
#[switchy_async::test]
async fn test_package_filtering_no_overlap() {
use std::collections::HashSet;
let selected: HashSet<String> = ["api", "web"].iter().map(|s| (*s).to_string()).collect();
let affected: HashSet<String> = ["models", "core"]
.iter()
.map(|s| (*s).to_string())
.collect();
let count = selected
.iter()
.filter(|pkg| affected.contains(*pkg))
.count();
assert_eq!(count, 0);
}
#[switchy_async::test]
async fn test_package_filtering_all_selected_affected() {
use std::collections::HashSet;
let selected: HashSet<String> = ["api", "web"].iter().map(|s| (*s).to_string()).collect();
let affected: HashSet<String> = ["api", "web", "core", "models"]
.iter()
.map(|s| (*s).to_string())
.collect();
let mut result: Vec<String> = selected
.iter()
.filter(|pkg| affected.contains(*pkg))
.cloned()
.collect();
result.sort();
assert_eq!(result.len(), 2);
assert!(result.contains(&"api".to_string()));
assert!(result.contains(&"web".to_string()));
}
#[switchy_async::test]
async fn test_features_wildcard_expansion() {
let temp_dir = switchy_fs::tempdir().unwrap();
let temp_path = temp_dir.path();
let cargo_toml = r#"
[package]
name = "test-package"
version = "0.1.0"
[features]
default = []
enable-bob = []
enable-sally = []
enable-feature = []
disable-test = []
production = []
"#;
switchy_fs::sync::write(temp_path.join("Cargo.toml"), cargo_toml).unwrap();
let clippier_toml = r#"
[[config]]
os = "ubuntu"
"#;
switchy_fs::sync::write(temp_path.join("clippier.toml"), clippier_toml).unwrap();
let result = process_configs(
temp_path,
None,
None,
None,
false,
false,
None,
Some(&["enable-*".to_string()]), None,
None,
)
.await
.unwrap();
assert!(!result.is_empty());
let features = result[0].get("features").unwrap().as_array().unwrap();
let feature_names: Vec<String> = features
.iter()
.map(|f| f.as_str().unwrap().to_string())
.collect();
assert!(feature_names.contains(&"enable-bob".to_string()));
assert!(feature_names.contains(&"enable-sally".to_string()));
assert!(feature_names.contains(&"enable-feature".to_string()));
assert!(!feature_names.contains(&"default".to_string()));
assert!(!feature_names.contains(&"disable-test".to_string()));
assert!(!feature_names.contains(&"production".to_string()));
}
#[switchy_async::test]
async fn test_features_multiple_wildcard_patterns() {
let temp_dir = switchy_fs::tempdir().unwrap();
let temp_path = temp_dir.path();
let cargo_toml = r#"
[package]
name = "test-package"
version = "0.1.0"
[features]
default = []
enable-bob = []
enable-sally = []
test-utils = []
test-integration = []
production = []
development = []
"#;
switchy_fs::sync::write(temp_path.join("Cargo.toml"), cargo_toml).unwrap();
let clippier_toml = r#"
[[config]]
os = "ubuntu"
"#;
switchy_fs::sync::write(temp_path.join("clippier.toml"), clippier_toml).unwrap();
let result = process_configs(
temp_path,
None,
None,
None,
false,
false,
None,
Some(&["enable-*".to_string(), "test-*".to_string()]),
None,
None,
)
.await
.unwrap();
assert!(!result.is_empty());
let features = result[0].get("features").unwrap().as_array().unwrap();
let feature_names: Vec<String> = features
.iter()
.map(|f| f.as_str().unwrap().to_string())
.collect();
assert!(feature_names.contains(&"enable-bob".to_string()));
assert!(feature_names.contains(&"enable-sally".to_string()));
assert!(feature_names.contains(&"test-utils".to_string()));
assert!(feature_names.contains(&"test-integration".to_string()));
assert!(!feature_names.contains(&"default".to_string()));
assert!(!feature_names.contains(&"production".to_string()));
assert!(!feature_names.contains(&"development".to_string()));
}
#[switchy_async::test]
async fn test_features_mixed_exact_and_wildcard() {
let temp_dir = switchy_fs::tempdir().unwrap();
let temp_path = temp_dir.path();
let cargo_toml = r#"
[package]
name = "test-package"
version = "0.1.0"
[features]
default = []
enable-bob = []
enable-sally = []
production = []
development = []
"#;
switchy_fs::sync::write(temp_path.join("Cargo.toml"), cargo_toml).unwrap();
let clippier_toml = r#"
[[config]]
os = "ubuntu"
"#;
switchy_fs::sync::write(temp_path.join("clippier.toml"), clippier_toml).unwrap();
let result = process_configs(
temp_path,
None,
None,
None,
false,
false,
None,
Some(&["enable-*".to_string(), "production".to_string()]),
None,
None,
)
.await
.unwrap();
assert!(!result.is_empty());
let features = result[0].get("features").unwrap().as_array().unwrap();
let feature_names: Vec<String> = features
.iter()
.map(|f| f.as_str().unwrap().to_string())
.collect();
assert!(feature_names.contains(&"enable-bob".to_string()));
assert!(feature_names.contains(&"enable-sally".to_string()));
assert!(feature_names.contains(&"production".to_string()));
assert!(!feature_names.contains(&"default".to_string()));
assert!(!feature_names.contains(&"development".to_string()));
}
#[switchy_async::test]
async fn test_matches_pattern_helper() {
assert!(matches_pattern("bob-default", "*-default"));
assert!(matches_pattern("sally-default", "*-default"));
assert!(!matches_pattern("default", "*-default"));
assert!(matches_pattern("test-utils", "test-*"));
assert!(matches_pattern("test-integration", "test-*"));
assert!(!matches_pattern("utils", "test-*"));
assert!(matches_pattern("v1", "v?"));
assert!(matches_pattern("v2", "v?"));
assert!(!matches_pattern("v10", "v?"));
assert!(matches_pattern("exact", "exact"));
assert!(!matches_pattern("exact", "exac"));
}
#[switchy_async::test]
async fn test_expand_pattern_list_helper() {
let available = vec![
"default".to_string(),
"bob-default".to_string(),
"sally-default".to_string(),
"enable-bob".to_string(),
"production".to_string(),
];
let patterns = vec!["*-default".to_string()];
let expanded = expand_pattern_list(&patterns, &available);
assert_eq!(expanded.len(), 2);
assert!(expanded.contains(&"bob-default".to_string()));
assert!(expanded.contains(&"sally-default".to_string()));
let patterns = vec!["*-default".to_string(), "production".to_string()];
let expanded = expand_pattern_list(&patterns, &available);
assert_eq!(expanded.len(), 3);
assert!(expanded.contains(&"bob-default".to_string()));
assert!(expanded.contains(&"sally-default".to_string()));
assert!(expanded.contains(&"production".to_string()));
let patterns = vec!["nonexistent".to_string()];
let expanded = expand_pattern_list(&patterns, &available);
assert_eq!(expanded.len(), 1);
assert!(expanded.contains(&"nonexistent".to_string()));
}
#[switchy_async::test]
async fn test_required_features_wildcard_expansion() {
let temp_dir = switchy_fs::tempdir().unwrap();
let temp_path = temp_dir.path();
let cargo_toml = r#"
[package]
name = "test-package"
version = "0.1.0"
[features]
default = []
enable-bob = []
enable-sally = []
enable-feature = []
production = []
development = []
"#;
switchy_fs::sync::write(temp_path.join("Cargo.toml"), cargo_toml).unwrap();
let clippier_toml = r#"
[[config]]
os = "ubuntu"
"#;
switchy_fs::sync::write(temp_path.join("clippier.toml"), clippier_toml).unwrap();
let result = process_configs(
temp_path,
None,
None,
None,
false,
false,
None,
None, None, Some(&["enable-*".to_string(), "production".to_string()]), )
.await
.unwrap();
assert!(!result.is_empty());
let required_features = result[0]
.get("requiredFeatures")
.unwrap()
.as_array()
.unwrap();
let required_feature_names: Vec<String> = required_features
.iter()
.map(|f| f.as_str().unwrap().to_string())
.collect();
assert!(required_feature_names.contains(&"enable-bob".to_string()));
assert!(required_feature_names.contains(&"enable-sally".to_string()));
assert!(required_feature_names.contains(&"enable-feature".to_string()));
assert!(required_feature_names.contains(&"production".to_string()));
assert!(!required_feature_names.contains(&"enable-*".to_string()));
assert!(!required_feature_names.contains(&"default".to_string()));
assert!(!required_feature_names.contains(&"development".to_string()));
}
#[switchy_async::test]
async fn test_required_features_from_config_file() {
let temp_dir = switchy_fs::tempdir().unwrap();
let temp_path = temp_dir.path();
let cargo_toml = r#"
[package]
name = "test-package"
version = "0.1.0"
[features]
default = []
test-utils = []
test-integration = []
test-e2e = []
production = []
"#;
switchy_fs::sync::write(temp_path.join("Cargo.toml"), cargo_toml).unwrap();
let clippier_toml = r#"
[[config]]
os = "ubuntu"
[config.rust]
required-features = ["test-*"]
"#;
switchy_fs::sync::write(temp_path.join("clippier.toml"), clippier_toml).unwrap();
let result = process_configs(
temp_path, None, None, None, false, false, None, None, None,
None, )
.await
.unwrap();
assert!(!result.is_empty());
let required_features = result[0]
.get("requiredFeatures")
.unwrap()
.as_array()
.unwrap();
let required_feature_names: Vec<String> = required_features
.iter()
.map(|f| f.as_str().unwrap().to_string())
.collect();
assert!(required_feature_names.contains(&"test-utils".to_string()));
assert!(required_feature_names.contains(&"test-integration".to_string()));
assert!(required_feature_names.contains(&"test-e2e".to_string()));
assert!(!required_feature_names.contains(&"test-*".to_string()));
assert!(!required_feature_names.contains(&"default".to_string()));
assert!(!required_feature_names.contains(&"production".to_string()));
}
#[switchy_async::test]
async fn test_expand_features_from_cargo_toml_helper() {
let cargo_toml_str = r"
[features]
default = []
enable-bob = []
enable-sally = []
production = []
";
let cargo_toml: Value = toml::from_str(cargo_toml_str).unwrap();
let patterns = vec!["enable-*".to_string()];
let expanded = expand_features_from_cargo_toml(&cargo_toml, &patterns);
assert_eq!(expanded.len(), 2);
assert!(expanded.contains(&"enable-bob".to_string()));
assert!(expanded.contains(&"enable-sally".to_string()));
let patterns = vec!["enable-*".to_string(), "production".to_string()];
let expanded = expand_features_from_cargo_toml(&cargo_toml, &patterns);
assert_eq!(expanded.len(), 3);
assert!(expanded.contains(&"enable-bob".to_string()));
assert!(expanded.contains(&"enable-sally".to_string()));
assert!(expanded.contains(&"production".to_string()));
}
#[test]
fn test_expand_active_package_features_transitive_default() {
let cargo_toml_str = r#"
[features]
default = ["backend"]
backend = ["duckdb"]
duckdb = ["dep:duckdb"]
sqlite = []
"#;
let cargo_toml: Value = toml::from_str(cargo_toml_str).unwrap();
let expanded = expand_active_package_features(&cargo_toml, &["default".to_string()]);
assert!(expanded.contains("default"));
assert!(expanded.contains("backend"));
assert!(expanded.contains("duckdb"));
assert!(!expanded.contains("sqlite"));
}
#[test]
fn test_expand_active_package_features_cycle_safe() {
let cargo_toml_str = r#"
[features]
a = ["b"]
b = ["c"]
c = ["a"]
"#;
let cargo_toml: Value = toml::from_str(cargo_toml_str).unwrap();
let expanded = expand_active_package_features(&cargo_toml, &["a".to_string()]);
assert!(expanded.contains("a"));
assert!(expanded.contains("b"));
assert!(expanded.contains("c"));
assert_eq!(expanded.len(), 3);
}
#[switchy_async::test]
async fn test_dependency_filter_uses_transitive_feature_expansion() {
let temp_dir = switchy_fs::tempdir().unwrap();
let temp_path = temp_dir.path();
let cargo_toml = r#"
[package]
name = "test-package"
version = "0.1.0"
[features]
default = ["backend"]
backend = ["duckdb"]
duckdb = []
"#;
switchy_fs::sync::write(temp_path.join("Cargo.toml"), cargo_toml).unwrap();
let clippier_toml = r#"
[[config]]
os = "ubuntu"
[[config.dependencies]]
command = "install duckdb"
features = ["duckdb"]
"#;
switchy_fs::sync::write(temp_path.join("clippier.toml"), clippier_toml).unwrap();
let result = process_configs(
temp_path,
None,
None,
None,
false,
false,
None,
Some(&["default".to_string()]),
None,
None,
)
.await
.unwrap();
let deps = result[0].get("dependencies").and_then(|x| x.as_str());
assert_eq!(deps, Some("install duckdb"));
}
#[switchy_async::test]
async fn test_features_negation_all_except_one() {
let temp_dir = switchy_fs::tempdir().unwrap();
let temp_path = temp_dir.path();
let cargo_toml = r#"
[package]
name = "test-package"
version = "0.1.0"
[features]
default = []
enable-bob = []
enable-sally = []
enable-experimental = []
production = []
"#;
switchy_fs::sync::write(temp_path.join("Cargo.toml"), cargo_toml).unwrap();
let clippier_toml = r#"
[[config]]
os = "ubuntu"
"#;
switchy_fs::sync::write(temp_path.join("clippier.toml"), clippier_toml).unwrap();
let result = process_configs(
temp_path,
None,
None,
None,
false,
false,
None,
Some(&["*".to_string(), "!enable-experimental".to_string()]),
None,
None,
)
.await
.unwrap();
assert!(!result.is_empty());
let features = result[0].get("features").unwrap().as_array().unwrap();
let feature_names: Vec<String> = features
.iter()
.map(|f| f.as_str().unwrap().to_string())
.collect();
assert!(feature_names.contains(&"default".to_string()));
assert!(feature_names.contains(&"enable-bob".to_string()));
assert!(feature_names.contains(&"enable-sally".to_string()));
assert!(feature_names.contains(&"production".to_string()));
assert!(!feature_names.contains(&"enable-experimental".to_string()));
}
#[switchy_async::test]
async fn test_features_negation_wildcard() {
let temp_dir = switchy_fs::tempdir().unwrap();
let temp_path = temp_dir.path();
let cargo_toml = r#"
[package]
name = "test-package"
version = "0.1.0"
[features]
default = []
production = []
test-utils = []
test-integration = []
test-e2e = []
enable-bob = []
"#;
switchy_fs::sync::write(temp_path.join("Cargo.toml"), cargo_toml).unwrap();
let clippier_toml = r#"
[[config]]
os = "ubuntu"
"#;
switchy_fs::sync::write(temp_path.join("clippier.toml"), clippier_toml).unwrap();
let result = process_configs(
temp_path,
None,
None,
None,
false,
false,
None,
Some(&["*".to_string(), "!test-*".to_string()]),
None,
None,
)
.await
.unwrap();
assert!(!result.is_empty());
let features = result[0].get("features").unwrap().as_array().unwrap();
let feature_names: Vec<String> = features
.iter()
.map(|f| f.as_str().unwrap().to_string())
.collect();
assert!(feature_names.contains(&"default".to_string()));
assert!(feature_names.contains(&"production".to_string()));
assert!(feature_names.contains(&"enable-bob".to_string()));
assert!(!feature_names.contains(&"test-utils".to_string()));
assert!(!feature_names.contains(&"test-integration".to_string()));
assert!(!feature_names.contains(&"test-e2e".to_string()));
}
#[switchy_async::test]
async fn test_features_negation_complex() {
let temp_dir = switchy_fs::tempdir().unwrap();
let temp_path = temp_dir.path();
let cargo_toml = r#"
[package]
name = "test-package"
version = "0.1.0"
[features]
default = []
enable-bob = []
enable-sally = []
enable-experimental = []
production = []
test-utils = []
"#;
switchy_fs::sync::write(temp_path.join("Cargo.toml"), cargo_toml).unwrap();
let clippier_toml = r#"
[[config]]
os = "ubuntu"
"#;
switchy_fs::sync::write(temp_path.join("clippier.toml"), clippier_toml).unwrap();
let result = process_configs(
temp_path,
None,
None,
None,
false,
false,
None,
Some(&[
"enable-*".to_string(),
"!enable-experimental".to_string(),
"production".to_string(),
]),
None,
None,
)
.await
.unwrap();
assert!(!result.is_empty());
let features = result[0].get("features").unwrap().as_array().unwrap();
let feature_names: Vec<String> = features
.iter()
.map(|f| f.as_str().unwrap().to_string())
.collect();
assert!(feature_names.contains(&"enable-bob".to_string()));
assert!(feature_names.contains(&"enable-sally".to_string()));
assert!(feature_names.contains(&"production".to_string()));
assert!(!feature_names.contains(&"enable-experimental".to_string()));
assert!(!feature_names.contains(&"default".to_string()));
assert!(!feature_names.contains(&"test-utils".to_string()));
}
#[switchy_async::test]
async fn test_required_features_negation() {
let temp_dir = switchy_fs::tempdir().unwrap();
let temp_path = temp_dir.path();
let cargo_toml = r#"
[package]
name = "test-package"
version = "0.1.0"
[features]
default = []
enable-bob = []
enable-sally = []
enable-experimental = []
production = []
"#;
switchy_fs::sync::write(temp_path.join("Cargo.toml"), cargo_toml).unwrap();
let clippier_toml = r#"
[[config]]
os = "ubuntu"
"#;
switchy_fs::sync::write(temp_path.join("clippier.toml"), clippier_toml).unwrap();
let result = process_configs(
temp_path,
None,
None,
None,
false,
false,
None,
None,
None,
Some(&["enable-*".to_string(), "!enable-experimental".to_string()]),
)
.await
.unwrap();
assert!(!result.is_empty());
let required_features = result[0]
.get("requiredFeatures")
.unwrap()
.as_array()
.unwrap();
let required_feature_names: Vec<String> = required_features
.iter()
.map(|f| f.as_str().unwrap().to_string())
.collect();
assert!(required_feature_names.contains(&"enable-bob".to_string()));
assert!(required_feature_names.contains(&"enable-sally".to_string()));
assert!(!required_feature_names.contains(&"enable-experimental".to_string()));
}
#[switchy_async::test]
async fn test_expand_pattern_list_with_negation() {
let available = vec![
"default".to_string(),
"enable-bob".to_string(),
"enable-sally".to_string(),
"enable-experimental".to_string(),
"production".to_string(),
];
let patterns = vec!["*".to_string(), "!enable-experimental".to_string()];
let expanded = expand_pattern_list(&patterns, &available);
assert_eq!(expanded.len(), 4);
assert!(expanded.contains(&"default".to_string()));
assert!(expanded.contains(&"enable-bob".to_string()));
assert!(expanded.contains(&"enable-sally".to_string()));
assert!(expanded.contains(&"production".to_string()));
assert!(!expanded.contains(&"enable-experimental".to_string()));
let patterns = vec!["enable-*".to_string(), "!enable-experimental".to_string()];
let expanded = expand_pattern_list(&patterns, &available);
assert_eq!(expanded.len(), 2);
assert!(expanded.contains(&"enable-bob".to_string()));
assert!(expanded.contains(&"enable-sally".to_string()));
assert!(!expanded.contains(&"enable-experimental".to_string()));
}
#[switchy_async::test]
async fn test_should_skip_feature_exact_match() {
assert!(should_skip_feature("default", &["default".to_string()]));
assert!(!should_skip_feature("test-utils", &["default".to_string()]));
}
#[switchy_async::test]
async fn test_should_skip_feature_wildcard_suffix() {
assert!(should_skip_feature("test-utils", &["test-*".to_string()]));
assert!(should_skip_feature("test-foo", &["test-*".to_string()]));
assert!(!should_skip_feature("utils-test", &["test-*".to_string()]));
}
#[switchy_async::test]
async fn test_should_skip_feature_wildcard_prefix() {
assert!(should_skip_feature("mp3-codec", &["*-codec".to_string()]));
assert!(should_skip_feature("flac-codec", &["*-codec".to_string()]));
assert!(!should_skip_feature("codec-mp3", &["*-codec".to_string()]));
}
#[switchy_async::test]
async fn test_should_skip_feature_wildcard_anywhere() {
assert!(should_skip_feature(
"test_foo_bar",
&["*_foo_*".to_string()]
));
assert!(should_skip_feature(
"prefix_foo_suffix",
&["*_foo_*".to_string()]
));
assert!(!should_skip_feature(
"test_bar_baz",
&["*_foo_*".to_string()]
));
}
#[switchy_async::test]
async fn test_should_skip_feature_question_mark_single_char() {
assert!(should_skip_feature("test1", &["test?".to_string()]));
assert!(should_skip_feature("testX", &["test?".to_string()]));
assert!(!should_skip_feature("test12", &["test?".to_string()]));
assert!(!should_skip_feature("test", &["test?".to_string()]));
}
#[switchy_async::test]
async fn test_should_skip_feature_skip_all_with_asterisk() {
assert!(should_skip_feature("anything", &["*".to_string()]));
assert!(should_skip_feature("default", &["*".to_string()]));
assert!(should_skip_feature("fail-on-warnings", &["*".to_string()]));
}
#[switchy_async::test]
async fn test_should_skip_feature_negation_basic() {
assert!(!should_skip_feature(
"keep-this",
&["!keep-this".to_string()]
));
assert!(!should_skip_feature(
"skip-this",
&["!keep-this".to_string()]
));
}
#[switchy_async::test]
async fn test_should_skip_feature_negation_overrides_wildcard() {
let patterns = vec!["*".to_string(), "!fail-on-warnings".to_string()];
assert!(!should_skip_feature("fail-on-warnings", &patterns));
assert!(should_skip_feature("default", &patterns));
assert!(should_skip_feature("test-utils", &patterns));
}
#[switchy_async::test]
async fn test_should_skip_feature_negation_with_glob_pattern() {
let patterns = vec!["test-*".to_string(), "!test-important".to_string()];
assert!(should_skip_feature("test-utils", &patterns));
assert!(should_skip_feature("test-fixtures", &patterns));
assert!(!should_skip_feature("test-important", &patterns));
assert!(!should_skip_feature("production", &patterns));
}
#[switchy_async::test]
async fn test_should_skip_feature_multiple_patterns_no_overlap() {
let patterns = vec!["test-*".to_string(), "*-codec".to_string()];
assert!(should_skip_feature("test-utils", &patterns));
assert!(should_skip_feature("mp3-codec", &patterns));
assert!(!should_skip_feature("fail-on-warnings", &patterns));
}
#[switchy_async::test]
async fn test_should_skip_feature_order_matters_for_negation() {
let patterns1 = vec!["*".to_string(), "!keep".to_string()];
let patterns2 = vec!["!keep".to_string(), "*".to_string()];
assert!(!should_skip_feature("keep", &patterns1)); assert!(should_skip_feature("keep", &patterns2)); }
#[switchy_async::test]
async fn test_should_skip_feature_empty_pattern_list() {
assert!(!should_skip_feature("anything", &[]));
}
#[switchy_async::test]
async fn test_should_skip_feature_complex_real_world_scenario() {
let patterns = vec![
"*-codec".to_string(),
"!opus-codec".to_string(),
"test-*".to_string(),
"!test-integration".to_string(),
];
assert!(should_skip_feature("mp3-codec", &patterns));
assert!(should_skip_feature("flac-codec", &patterns));
assert!(!should_skip_feature("opus-codec", &patterns));
assert!(should_skip_feature("test-utils", &patterns));
assert!(should_skip_feature("test-fixtures", &patterns));
assert!(!should_skip_feature("test-integration", &patterns));
assert!(!should_skip_feature("fail-on-warnings", &patterns));
}
#[switchy_async::test]
async fn test_should_skip_feature_case_sensitivity() {
assert!(should_skip_feature("Test", &["Test".to_string()]));
assert!(!should_skip_feature("test", &["Test".to_string()]));
assert!(should_skip_feature("test", &["test".to_string()]));
}
#[switchy_async::test]
async fn test_should_skip_feature_multiple_negations() {
let patterns = vec!["*".to_string(), "!keep1".to_string(), "!keep2".to_string()];
assert!(!should_skip_feature("keep1", &patterns));
assert!(!should_skip_feature("keep2", &patterns));
assert!(should_skip_feature("skip-this", &patterns));
}
#[switchy_async::test]
async fn test_should_skip_feature_overlapping_patterns() {
let patterns = vec!["test-*".to_string(), "*-utils".to_string()];
assert!(should_skip_feature("test-utils", &patterns)); assert!(should_skip_feature("test-fixtures", &patterns)); assert!(should_skip_feature("string-utils", &patterns)); assert!(!should_skip_feature("production", &patterns)); }
#[switchy_async::test]
async fn test_should_skip_feature_special_characters() {
assert!(should_skip_feature(
"test-2024-feature",
&["test-*".to_string()]
));
assert!(should_skip_feature(
"feature_v1_2_3",
&["feature_v*".to_string()]
));
assert!(should_skip_feature(
"enable-foo-bar-baz",
&["enable-*".to_string()]
));
}
#[switchy_async::test]
async fn test_should_skip_feature_empty_string() {
assert!(!should_skip_feature("", &["test-*".to_string()]));
assert!(should_skip_feature("", &["*".to_string()]));
}
#[switchy_async::test]
async fn test_should_skip_feature_negation_without_match() {
let patterns = vec!["test-*".to_string(), "!nonexistent".to_string()];
assert!(should_skip_feature("test-utils", &patterns));
assert!(!should_skip_feature("production", &patterns));
}
#[switchy_async::test]
async fn test_should_skip_feature_complex_wildcards() {
assert!(should_skip_feature(
"prefix-middle-suffix",
&["prefix-*-suffix".to_string()]
));
assert!(should_skip_feature("a-b-c-d-e", &["a-*-e".to_string()]));
assert!(!should_skip_feature("a-b-c-d", &["a-*-e".to_string()]));
}
}