mod string_distance;
use cargo_metadata::MetadataCommand;
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::Shell;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::cmp::min;
use std::collections::{HashMap, HashSet};
use std::ffi::OsStr;
use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use std::{fmt, fs};
use string_distance::levenshtein;
#[derive(Parser)]
#[command(name = "focus")]
#[command(bin_name = "cargo")]
#[command(version, about, long_about = None)]
#[command(
after_help = "Examples:\n cargo focus status\n cargo focus list\n cargo focus apply preset:dev\n cargo focus apply crate:api regex:^worker-\n cargo focus unapply --dry-run regex:^experimental-\n cargo focus save fast-edit --description \"Small focused set\"\n cargo focus completions zsh > ~/.zsh/completions/_cargo-focus\n"
)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
#[arg(long)]
output_json: bool,
#[arg(long)]
manifest_path: Option<PathBuf>,
}
#[derive(Subcommand)]
enum Commands {
Status,
List,
#[command(
about = "Generate shell completion scripts",
after_help = "Examples:\n cargo focus completions bash\n cargo focus completions zsh > ~/.zsh/completions/_cargo-focus\n cargo focus completions fish > ~/.config/fish/completions/cargo-focus.fish\n"
)]
Completions {
shell: Shell,
},
#[command(
about = "Save the current focus as a named preset",
after_help = "Examples:\n cargo focus save dev\n cargo focus save dev --description \"Backend crates\"\n cargo focus save dev --force\n"
)]
Save {
#[arg(required = true)]
preset: String,
#[arg(short, long)]
description: Option<String>,
#[arg(short, long)]
force: bool,
},
#[command(
about = "Add crates to the current focus",
long_about = "Add crates to workspace.default-members by resolving selectors.\n\nSelector forms:\n preset:<name> Apply a saved preset\n crate:<name> Apply a specific workspace crate\n regex:<expr> Apply crates whose names match the regex\n dir:<path> Apply crates under a workspace directory path\n <token> Unprefixed resolution order: preset -> crate -> regex",
after_help = "Examples:\n cargo focus apply preset:dev\n cargo focus apply crate:api regex:^worker-\n cargo focus apply dir:crates/api\n cargo focus apply dev # unprefixed preset name\n cargo focus apply api # unprefixed crate name\n cargo focus apply --dry-run regex:^experiments-\n"
)]
Apply {
#[arg(required = true)]
items: Vec<String>,
#[arg(long)]
dry_run: bool,
},
#[command(
about = "Remove crates from the current focus",
long_about = "Remove crates from workspace.default-members by resolving selectors.\n\nSelector forms:\n preset:<name> Remove all crates in a saved preset\n crate:<name> Remove a specific workspace crate\n regex:<expr> Remove crates whose names match the regex\n dir:<path> Remove crates under a workspace directory path\n <token> Unprefixed resolution order: preset -> crate -> regex",
after_help = "Examples:\n cargo focus unapply preset:dev\n cargo focus unapply crate:api regex:^worker-\n cargo focus unapply dir:crates/api\n cargo focus unapply dev # unprefixed preset name\n cargo focus unapply api # unprefixed crate name\n cargo focus unapply --dry-run regex:^experiments-\n"
)]
Unapply {
#[arg(required = true)]
items: Vec<String>,
#[arg(long)]
dry_run: bool,
},
}
fn main() -> anyhow::Result<()> {
let mut args: Vec<_> = std::env::args_os().collect();
if args.get(1).is_some_and(|arg| arg == OsStr::new("focus")) {
args.remove(1);
}
let cli = Cli::parse_from(args);
let output_json = cli.output_json;
let use_color = should_use_color(output_json);
if let Err(err) = run(cli) {
render_error(&err, output_json, use_color);
std::process::exit(1);
}
Ok(())
}
fn run(cli: Cli) -> anyhow::Result<()> {
let use_color = should_use_color(cli.output_json);
match cli.command {
Some(Commands::Status) => {
let mut cargo_toml = CargoToml::read(cli.manifest_path.as_deref())?;
let (workspace_members, default_members) =
cargo_toml.workspace_and_default_members()?;
let output = StatusSubcmdOutput {
workspace_members,
default_members,
presets: cargo_toml.presets()?,
use_color,
};
if cli.output_json {
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
println!("{}", output);
}
Ok(())
}
Some(Commands::List) => {
let cargo_toml = CargoToml::read(cli.manifest_path.as_deref())?;
let current_focus: HashSet<String> = cargo_toml
.configured_default_members()
.into_iter()
.collect();
let mut presets: Vec<_> = cargo_toml.presets()?.into_iter().collect();
presets.sort_by(|a, b| a.0.cmp(&b.0));
let output = ListSubcmdOutput {
presets: presets
.into_iter()
.map(|(name, metadata)| ListSubcmdPreset {
is_current_focus: metadata.members.iter().cloned().collect::<HashSet<_>>()
== current_focus,
name,
members: metadata.members,
description: metadata.description,
})
.collect(),
use_color,
};
if cli.output_json {
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
println!("{}", output);
}
Ok(())
}
Some(Commands::Completions { shell }) => {
clap_complete::generate(shell, &mut Cli::command(), "cargo", &mut std::io::stdout());
Ok(())
}
Some(Commands::Apply { items, dry_run }) => {
let mut cargo_toml = CargoToml::read(cli.manifest_path.as_deref())?;
let mutation =
run_mutation_subcommand(&mut cargo_toml, &items, dry_run, MutationKind::Apply)?;
let changed_set: HashSet<&String> = mutation.changed.iter().collect();
let crates_already_focused = mutation
.resolved
.iter()
.filter(|crate_name| !changed_set.contains(crate_name))
.cloned()
.collect();
let output = ApplySubcmdOutput {
crates_selected: mutation.resolved,
crates_added: mutation.changed,
crates_already_focused,
dry_run,
use_color,
};
if cli.output_json {
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
println!("{}", output);
}
Ok(())
}
Some(Commands::Unapply { items, dry_run }) => {
let mut cargo_toml = CargoToml::read(cli.manifest_path.as_deref())?;
let mutation =
run_mutation_subcommand(&mut cargo_toml, &items, dry_run, MutationKind::Unapply)?;
let changed_set: HashSet<&String> = mutation.changed.iter().collect();
let crates_already_unfocused = mutation
.resolved
.iter()
.filter(|crate_name| !changed_set.contains(crate_name))
.cloned()
.collect();
let output = UnapplySubcmdOutput {
crates_selected: mutation.resolved,
crates_removed: mutation.changed,
crates_already_unfocused,
dry_run,
use_color,
};
if cli.output_json {
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
println!("{}", output);
}
Ok(())
}
Some(Commands::Save {
preset,
description,
force,
}) => {
if !preset
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(anyhow::anyhow!("Preset name must be alphanumeric or -_"));
}
let mut cargo_toml = CargoToml::read(cli.manifest_path.as_deref())?;
let existing_presets = cargo_toml.presets()?;
if existing_presets.contains_key(&preset) && !force {
return Err(anyhow::anyhow!(
"Preset '{}' already exists. Use --force to overwrite",
preset
));
}
let members = cargo_toml.default_members()?;
cargo_toml.add_or_replace_preset(&preset, description.as_deref(), &members)?;
cargo_toml.save()?;
let replaced_existing = existing_presets.contains_key(&preset);
let output = SaveSubcmdOutput {
preset,
crates_saved: members.len(),
description,
replaced_existing,
use_color,
};
if cli.output_json {
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
println!("{}", output);
}
Ok(())
}
None => {
Cli::command().print_help()?;
Ok(())
}
}
}
#[derive(Serialize)]
struct JsonErrorOutput {
error: String,
}
fn render_error(err: &anyhow::Error, output_json: bool, use_color: bool) {
if output_json {
let payload = JsonErrorOutput {
error: format!("{:#}", err),
};
if let Ok(json) = serde_json::to_string_pretty(&payload) {
eprintln!("{}", json);
} else {
eprintln!("error: {}", err);
}
return;
}
eprintln!("{}: {}", style("error", ColorKind::Red, use_color), err);
for cause in err.chain().skip(1) {
eprintln!(
"{}: {}",
style("caused by", ColorKind::Yellow, use_color),
cause
);
}
}
#[derive(Clone, Copy)]
enum ColorKind {
Green,
Yellow,
Cyan,
Red,
}
fn should_use_color(output_json: bool) -> bool {
if output_json {
return false;
}
if std::env::var_os("NO_COLOR").is_some() {
return false;
}
if std::env::var("CLICOLOR_FORCE").ok().as_deref() == Some("1") {
return true;
}
std::io::stdout().is_terminal()
}
fn style(text: &str, color: ColorKind, use_color: bool) -> String {
if !use_color {
return text.to_string();
}
let code = match color {
ColorKind::Green => "1;32",
ColorKind::Yellow => "1;33",
ColorKind::Cyan => "1;36",
ColorKind::Red => "1;31",
};
format!("\x1b[{}m{}\x1b[0m", code, text)
}
#[derive(Debug, Deserialize, Serialize)]
struct ApplySubcmdOutput {
crates_selected: Vec<String>,
crates_added: Vec<String>,
crates_already_focused: Vec<String>,
dry_run: bool,
#[serde(skip)]
use_color: bool,
}
impl fmt::Display for ApplySubcmdOutput {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(
f,
"Resolved {} crate(s) from selectors",
self.crates_selected.len()
)?;
writeln!(f, "Added {} crates", self.crates_added.len())?;
for crate_name in &self.crates_added {
writeln!(f, " {}", crate_name)?;
}
if !self.crates_already_focused.is_empty() {
writeln!(
f,
"Already focused ({}):",
self.crates_already_focused.len()
)?;
for crate_name in &self.crates_already_focused {
writeln!(f, " {}", crate_name)?;
}
}
if self.dry_run {
writeln!(
f,
"{}: no changes were written to Cargo.toml",
style("Dry run", ColorKind::Yellow, self.use_color)
)?;
}
Ok(())
}
}
#[derive(Debug, Deserialize, Serialize)]
struct UnapplySubcmdOutput {
crates_selected: Vec<String>,
crates_removed: Vec<String>,
crates_already_unfocused: Vec<String>,
dry_run: bool,
#[serde(skip)]
use_color: bool,
}
impl fmt::Display for UnapplySubcmdOutput {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(
f,
"Resolved {} crate(s) from selectors",
self.crates_selected.len()
)?;
writeln!(f, "Removed {} crates", self.crates_removed.len())?;
for crate_name in &self.crates_removed {
writeln!(f, " {}", crate_name)?;
}
if !self.crates_already_unfocused.is_empty() {
writeln!(
f,
"Already unfocused ({}):",
self.crates_already_unfocused.len()
)?;
for crate_name in &self.crates_already_unfocused {
writeln!(f, " {}", crate_name)?;
}
}
if self.dry_run {
writeln!(
f,
"{}: no changes were written to Cargo.toml",
style("Dry run", ColorKind::Yellow, self.use_color)
)?;
}
Ok(())
}
}
#[derive(Debug, Deserialize, Serialize)]
struct SaveSubcmdOutput {
preset: String,
crates_saved: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
replaced_existing: bool,
#[serde(skip)]
use_color: bool,
}
impl fmt::Display for SaveSubcmdOutput {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.replaced_existing {
writeln!(
f,
"{} preset '{}'",
style("Updated", ColorKind::Cyan, self.use_color),
self.preset
)?;
} else {
writeln!(
f,
"{} preset '{}'",
style("Saved", ColorKind::Green, self.use_color),
self.preset
)?;
}
writeln!(f, "Members: {}", self.crates_saved)?;
if let Some(description) = &self.description
&& !description.trim().is_empty()
{
writeln!(f, "Description: {}", description)?;
}
Ok(())
}
}
#[derive(Debug, Deserialize, Serialize)]
struct ListSubcmdOutput {
presets: Vec<ListSubcmdPreset>,
#[serde(skip)]
use_color: bool,
}
impl fmt::Display for ListSubcmdOutput {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.presets.is_empty() {
writeln!(
f,
"{} No presets saved yet.",
style("Empty:", ColorKind::Yellow, self.use_color)
)?;
writeln!(f, "Next step: `cargo focus save <name>`")?;
writeln!(
f,
"Example: `cargo focus save dev --description \"Daily editing set\"`"
)?;
return Ok(());
}
let current_count = self.presets.iter().filter(|p| p.is_current_focus).count();
writeln!(
f,
"{}: {} saved{}",
style("Presets", ColorKind::Cyan, self.use_color),
self.presets.len(),
if current_count > 0 {
format!(" · {} matching current focus", current_count)
} else {
String::new()
}
)?;
for preset in &self.presets {
let current_suffix = if preset.is_current_focus {
format!(" [{}]", style("current", ColorKind::Green, self.use_color))
} else {
String::new()
};
writeln!(f, "\n{}{}", preset.name, current_suffix)?;
if let Some(description) = &preset.description
&& !description.trim().is_empty()
{
writeln!(f, " {}", description)?;
}
writeln!(
f,
" {} {}",
preset.members.len(),
if preset.members.len() == 1 {
"crate"
} else {
"crates"
}
)?;
writeln!(f, " {}", preset.members.join(", "))?;
}
Ok(())
}
}
#[derive(Debug, Deserialize, Serialize)]
struct ListSubcmdPreset {
is_current_focus: bool,
name: String,
members: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
struct StatusSubcmdOutput {
workspace_members: Vec<String>,
default_members: Vec<String>,
presets: HashMap<String, PresetMetadata>,
#[serde(skip)]
use_color: bool,
}
impl fmt::Display for StatusSubcmdOutput {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(
f,
"{}:",
style("Current focus", ColorKind::Cyan, self.use_color)
)?;
if self.default_members.is_empty() {
writeln!(f, " (none)")?;
} else {
for member in &self.default_members {
writeln!(f, " {}", member)?;
}
}
writeln!(
f,
"\nFocused on {} out of {} workspace members",
self.default_members.len(),
self.workspace_members.len()
)?;
if self.presets.is_empty() {
writeln!(f, "\nNo presets configured")?;
writeln!(
f,
"Save current focus as a preset with: cargo focus save <name>"
)?;
return Ok(());
}
writeln!(
f,
"\n{} ({}):",
style("Presets", ColorKind::Cyan, self.use_color),
self.presets.len()
)?;
let mut preset_names: Vec<&String> = self.presets.keys().collect();
preset_names.sort();
for name in preset_names {
let metadata = &self.presets[name];
writeln!(f, "{}:", name)?;
if let Some(description) = &metadata.description {
writeln!(f, " {}", description)?;
}
writeln!(f, " Members: {}", metadata.members.len())?;
if !metadata.members.is_empty() {
writeln!(f, " Crates: {}", metadata.members.join(", "))?;
}
}
Ok(())
}
}
#[derive(Debug, Deserialize, Serialize)]
struct PresetMetadata {
members: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
}
struct CargoToml {
doc_mut: toml_edit::DocumentMut,
path: PathBuf,
metadata_cache: Option<cargo_metadata::Metadata>,
metadata_frozen: bool,
}
struct WorkspaceMemberDir {
name: String,
dir: PathBuf,
}
struct ResolvedMember {
name: String,
entry: String,
}
impl CargoToml {
fn read(manifest_path: Option<&Path>) -> anyhow::Result<Self> {
let manifest_path = manifest_path.unwrap_or_else(|| Path::new("Cargo.toml"));
if !manifest_path.exists() {
return Err(anyhow::anyhow!(
"Could not find Cargo manifest at '{}'",
manifest_path.display()
));
}
let file_contents = fs::read_to_string(manifest_path).map_err(|e| {
anyhow::anyhow!(
"Failed to read Cargo manifest '{}': {}",
manifest_path.display(),
e
)
})?;
let doc = file_contents
.parse::<toml_edit::DocumentMut>()
.map_err(|e| {
anyhow::anyhow!(
"Failed to parse Cargo manifest '{}': {}",
manifest_path.display(),
e
)
})?;
Ok(Self {
doc_mut: doc,
path: manifest_path.to_path_buf(),
metadata_cache: None,
metadata_frozen: false,
})
}
fn save(&self) -> anyhow::Result<()> {
let content = self.doc_mut.to_string();
fs::write(&self.path, content).map_err(|e| {
anyhow::anyhow!(
"Failed to write Cargo manifest '{}': {}",
self.path.display(),
e
)
})?;
Ok(())
}
fn configured_default_members(&self) -> Vec<String> {
self.doc_mut
.get("workspace")
.and_then(|w| w.as_table())
.and_then(|w| w.get("default-members"))
.and_then(|d| d.as_array())
.map(|arr| {
arr.iter()
.filter_map(|member| member.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
}
fn set_metadata_frozen(&mut self, frozen: bool) {
if self.metadata_frozen != frozen {
self.metadata_frozen = frozen;
self.metadata_cache = None;
}
}
fn metadata(&mut self) -> anyhow::Result<&cargo_metadata::Metadata> {
if self.metadata_cache.is_none() {
let metadata = run_metadata_with_feedback(&self.path, self.metadata_frozen)?;
self.metadata_cache = Some(metadata);
}
self.metadata_cache
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Failed to cache workspace metadata"))
}
fn workspace_root_dir(&self) -> anyhow::Result<PathBuf> {
let root = self.path.parent().ok_or_else(|| {
anyhow::anyhow!(
"Cannot determine workspace root from manifest path '{}'",
self.path.display()
)
})?;
Ok(root.to_path_buf())
}
fn workspace_member_dirs(&mut self) -> anyhow::Result<Vec<WorkspaceMemberDir>> {
let metadata = self.metadata()?;
let workspace_members_pkgids = &metadata.workspace_members;
let mut result = Vec::new();
for pkg in metadata
.packages
.iter()
.filter(|pkg| workspace_members_pkgids.contains(&pkg.id))
{
let manifest_path = pkg.manifest_path.as_std_path();
let Some(parent_dir) = manifest_path.parent() else {
return Err(anyhow::anyhow!(
"Invalid package manifest path for crate '{}': '{}'",
pkg.name,
manifest_path.display()
));
};
let canonical_dir = parent_dir
.canonicalize()
.unwrap_or_else(|_| parent_dir.to_path_buf());
result.push(WorkspaceMemberDir {
name: pkg.name.to_string(),
dir: canonical_dir,
});
}
Ok(result)
}
fn workspace_and_default_members(&mut self) -> anyhow::Result<(Vec<String>, Vec<String>)> {
let metadata = self.metadata()?;
let workspace_members_pkgids = &metadata.workspace_members;
let workspace_default_members_pkgids = &metadata.workspace_default_members;
let workspace_members = metadata
.packages
.iter()
.filter(|pkg| workspace_members_pkgids.contains(&pkg.id))
.map(|pkg| pkg.name.to_string())
.collect();
let default_members = metadata
.packages
.iter()
.filter(|pkg| workspace_default_members_pkgids.contains(&pkg.id))
.map(|pkg| pkg.name.to_string())
.collect();
Ok((workspace_members, default_members))
}
fn workspace_members(&mut self) -> anyhow::Result<Vec<String>> {
let metadata = self.metadata()?;
let workspace_members_pkgids = &metadata.workspace_members;
Ok(metadata
.packages
.iter()
.filter(|pkg| workspace_members_pkgids.contains(&pkg.id))
.map(|pkg| pkg.name.to_string())
.collect())
}
fn workspace_member_entries_by_name(&mut self) -> anyhow::Result<HashMap<String, String>> {
let metadata = self.metadata()?;
let workspace_root = metadata.workspace_root.as_std_path();
let workspace_members_pkgids = &metadata.workspace_members;
let mut entries = HashMap::new();
for pkg in metadata
.packages
.iter()
.filter(|pkg| workspace_members_pkgids.contains(&pkg.id))
{
let manifest_path = pkg.manifest_path.as_std_path();
let Some(parent_dir) = manifest_path.parent() else {
return Err(anyhow::anyhow!(
"Invalid package manifest path for crate '{}': '{}'",
pkg.name,
manifest_path.display()
));
};
let rel_path = parent_dir.strip_prefix(workspace_root).map_err(|e| {
anyhow::anyhow!(
"Failed to derive workspace member path for crate '{}': {}",
pkg.name,
e
)
})?;
let entry = if rel_path.as_os_str().is_empty() {
".".to_string()
} else {
rel_path.to_string_lossy().replace('\\', "/")
};
entries.insert(pkg.name.to_string(), entry);
}
Ok(entries)
}
fn default_members(&mut self) -> anyhow::Result<Vec<String>> {
let metadata = self.metadata()?;
let workspace_members_pkgids = &metadata.workspace_default_members;
Ok(metadata
.packages
.iter()
.filter(|pkg| workspace_members_pkgids.contains(&pkg.id))
.map(|pkg| pkg.name.to_string())
.collect())
}
fn add_or_replace_preset(
&mut self,
name: &str,
description: Option<&str>,
members: &[String],
) -> anyhow::Result<()> {
let mut toml_members = toml_edit::Array::new();
for member in members {
toml_members.push(member);
}
let mut preset_toml_value = toml_edit::Table::new();
if let Some(description) = description {
preset_toml_value["description"] = description.into();
}
preset_toml_value["members"] = toml_members.into();
let presets = self.get_or_create_toml_presets_mut()?;
presets[name] = toml_edit::Item::Table(preset_toml_value);
Ok(())
}
fn get_or_create_toml_presets_mut(&mut self) -> anyhow::Result<&mut toml_edit::Table> {
let workspace = self
.doc_mut
.get_mut("workspace")
.and_then(|w| w.as_table_mut())
.ok_or_else(|| anyhow::anyhow!("No [workspace] in Cargo.toml"))?;
let metadata = workspace
.entry("metadata")
.or_insert(toml_edit::Item::Table(toml_edit::Table::new()))
.as_table_mut()
.ok_or_else(|| {
anyhow::anyhow!("Metadata not a table in Cargo.toml under [workspace]")
})?;
metadata.set_implicit(true);
let cargo_focus = metadata
.entry("cargo-focus")
.or_insert_with(|| {
let mut t = toml_edit::Item::Table(toml_edit::Table::new());
t["schema"] = "1".into();
t
})
.as_table_mut()
.ok_or_else(|| {
anyhow::anyhow!("Metadata.cargo-focus not a table in Cargo.toml under [workspace]")
})?;
cargo_focus.set_implicit(true);
let presets = cargo_focus
.entry("presets")
.or_insert(toml_edit::Item::Table(toml_edit::Table::new()))
.as_table_mut()
.ok_or_else(|| {
anyhow::anyhow!(
"Malformed metadata.cargo-focus.presets in Cargo.toml under [workspace.metadata.cargo-focus]"
)
})?;
presets.set_implicit(true);
Ok(presets)
}
fn presets(&self) -> anyhow::Result<HashMap<String, PresetMetadata>> {
let mut presets = HashMap::new();
let Some(presets_table) = self
.doc_mut
.get("workspace")
.and_then(|w| w.as_table())
.and_then(|w| w.get("metadata"))
.and_then(|m| m.as_table())
.and_then(|m| m.get("cargo-focus"))
.and_then(|c| c.as_table())
.and_then(|c| c.get("presets"))
.and_then(|p| p.as_table())
else {
return Ok(presets);
};
for (name, value) in presets_table.iter() {
let members = value
.as_table()
.and_then(|t| t.get("members"))
.and_then(|m| m.as_array())
.ok_or_else(|| {
anyhow::anyhow!("Preset '{}' is missing a valid members array", name)
})?
.iter()
.map(|m| {
m.as_str().map(|s| s.to_string()).ok_or_else(|| {
anyhow::anyhow!("Preset '{}' contains a non-string member", name)
})
})
.collect::<anyhow::Result<Vec<String>>>()?;
let description = value
.as_table()
.and_then(|t| t.get("description"))
.and_then(|d| d.as_str())
.map(|s| s.to_string())
.filter(|s| !s.trim().is_empty());
presets.insert(
name.to_string(),
PresetMetadata {
members,
description,
},
);
}
Ok(presets)
}
fn toml_default_members_mut(&mut self) -> anyhow::Result<&mut toml_edit::Array> {
let members = self
.doc_mut
.get_mut("workspace")
.and_then(|w| w.as_table_mut())
.ok_or_else(|| anyhow::anyhow!("No [workspace] in Cargo.toml"))?
.entry("default-members")
.or_insert(toml_edit::Item::Value(toml_edit::Value::Array(
toml_edit::Array::new(),
)))
.as_array_mut()
.ok_or_else(|| {
anyhow::anyhow!("Default-members not a table in Cargo.toml under [workspace]")
})?;
Ok(members)
}
fn add_workspace_members(&mut self, members: &[ResolvedMember]) -> anyhow::Result<Vec<String>> {
let default_members_toml = self.toml_default_members_mut()?;
let mut existing = HashSet::new();
for member in default_members_toml.iter() {
if let Some(member_name) = member.as_str() {
existing.insert(member_name.to_string());
}
}
let mut added = Vec::new();
for member in members {
if existing.insert(member.entry.clone()) {
default_members_toml.push(member.entry.clone());
added.push(member.name.clone());
}
}
Ok(added)
}
fn remove_workspace_members(
&mut self,
members: &[ResolvedMember],
) -> anyhow::Result<Vec<String>> {
let default_members_toml = self.toml_default_members_mut()?;
let existing: HashSet<String> = default_members_toml
.iter()
.filter_map(|member| member.as_str().map(|s| s.to_string()))
.collect();
let mut removed = Vec::new();
let mut removed_entries = HashSet::new();
for member in members {
let mut matched = false;
if existing.contains(&member.entry) {
removed_entries.insert(member.entry.clone());
matched = true;
}
if existing.contains(&member.name) {
removed_entries.insert(member.name.clone());
matched = true;
}
if matched {
removed.push(member.name.clone());
}
}
if !removed.is_empty() {
default_members_toml.retain(|member| {
member
.as_str()
.is_none_or(|member_name| !removed_entries.contains(member_name))
});
}
Ok(removed)
}
}
fn metadata_error(
manifest_path: &Path,
metadata_frozen: bool,
err: cargo_metadata::Error,
) -> anyhow::Error {
anyhow::anyhow!(
"Failed to run cargo metadata{} for '{}': {}",
if metadata_frozen {
" with --frozen"
} else {
""
},
manifest_path.display(),
err
)
}
fn run_metadata_with_feedback(
manifest_path: &Path,
metadata_frozen: bool,
) -> anyhow::Result<cargo_metadata::Metadata> {
let manifest_path = manifest_path.to_path_buf();
let manifest_path_for_worker = manifest_path.clone();
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let mut cmd = MetadataCommand::new();
cmd.manifest_path(&manifest_path_for_worker);
if metadata_frozen {
cmd.other_options(vec!["--frozen".to_string()]);
}
let _ = tx.send(cmd.exec());
});
match rx.recv_timeout(Duration::from_millis(500)) {
Ok(result) => {
return result.map_err(|e| metadata_error(&manifest_path, metadata_frozen, e));
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
return Err(anyhow::anyhow!(
"Failed to run cargo metadata{} for '{}': worker disconnected",
if metadata_frozen {
" with --frozen"
} else {
""
},
manifest_path.display()
));
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
let spinner = if std::io::stderr().is_terminal() {
let message = format!(
"Resolving workspace via cargo metadata{}... this can take a while on large repos",
if metadata_frozen { " --frozen" } else { "" }
);
let (stop_tx, stop_rx) = mpsc::channel();
let handle = thread::spawn(move || {
let mut stderr = std::io::stderr();
let frames = ['-', '\\', '|', '/'];
let mut idx = 0usize;
loop {
if stop_rx.recv_timeout(Duration::from_millis(80)).is_ok() {
break;
}
let _ = write!(stderr, "\r{} {}", frames[idx], message);
let _ = stderr.flush();
idx = (idx + 1) % frames.len();
}
let _ = write!(stderr, "\r\x1b[2K\r");
let _ = stderr.flush();
});
Some((stop_tx, handle))
} else {
None
};
let result = rx.recv().map_err(|_| {
anyhow::anyhow!(
"Failed to run cargo metadata{} for '{}': worker disconnected",
if metadata_frozen {
" with --frozen"
} else {
""
},
manifest_path.display()
)
})?;
if let Some((spinner_stop, spinner_handle)) = spinner {
let _ = spinner_stop.send(());
let _ = spinner_handle.join();
}
result.map_err(|e| metadata_error(&manifest_path, metadata_frozen, e))
}
#[derive(Clone, Copy)]
enum MutationKind {
Apply,
Unapply,
}
struct MutationOutput {
resolved: Vec<String>,
changed: Vec<String>,
}
fn run_mutation_subcommand(
cargo_toml: &mut CargoToml,
items: &[String],
dry_run: bool,
kind: MutationKind,
) -> anyhow::Result<MutationOutput> {
cargo_toml.set_metadata_frozen(dry_run);
let resolved = resolve_selectors(cargo_toml, items)?;
let member_entries = cargo_toml.workspace_member_entries_by_name()?;
let resolved_members = resolved
.iter()
.map(|name| {
member_entries
.get(name)
.cloned()
.map(|entry| ResolvedMember {
name: name.clone(),
entry,
})
.ok_or_else(|| {
anyhow::anyhow!(
"Resolved crate '{}' is not a workspace member entry. \
This usually means the preset is stale; run 'cargo focus status' to inspect current members.",
name
)
})
})
.collect::<anyhow::Result<Vec<_>>>()?;
let changed = match kind {
MutationKind::Apply => cargo_toml.add_workspace_members(&resolved_members)?,
MutationKind::Unapply => cargo_toml.remove_workspace_members(&resolved_members)?,
};
if !dry_run {
cargo_toml.save()?;
}
Ok(MutationOutput { resolved, changed })
}
fn resolve_selectors(cargo_toml: &mut CargoToml, items: &[String]) -> anyhow::Result<Vec<String>> {
let presets = cargo_toml.presets()?;
let mut resolved_crates = Vec::new();
let mut errors = Vec::new();
let preset_only = items.iter().all(|item| {
if item.starts_with("preset:") {
return true;
}
!item.starts_with("crate:")
&& !item.starts_with("regex:")
&& !item.starts_with("dir:")
&& presets.contains_key(item)
});
if preset_only {
for item in items {
let selected = if let Some(name) = item.strip_prefix("preset:") {
match presets.get(name) {
Some(selected) => selected,
None => {
errors.push(unknown_preset_message(name, item, &presets));
continue;
}
}
} else {
match presets.get(item) {
Some(selected) => selected,
None => {
errors.push(unknown_preset_message(item, item, &presets));
continue;
}
}
};
resolved_crates.extend(selected.members.clone());
}
if !errors.is_empty() {
return Err(anyhow::anyhow!(format_selector_errors(errors)));
}
return Ok(dedup_preserve_order(resolved_crates));
}
let workspace_members = cargo_toml.workspace_members()?;
let workspace_member_set: HashSet<_> = workspace_members.iter().cloned().collect();
let workspace_member_dirs = cargo_toml.workspace_member_dirs()?;
let workspace_root = cargo_toml.workspace_root_dir()?;
for item in items {
let resolution = if let Some(dir) = item.strip_prefix("dir:") {
resolve_dir_selector(dir, item, &workspace_root, &workspace_member_dirs)
} else {
resolve_selector(item, &presets, &workspace_members, &workspace_member_set)
};
match resolution {
Ok(crates) => resolved_crates.extend(crates),
Err(err) => errors.push(err.to_string()),
}
}
if !errors.is_empty() {
return Err(anyhow::anyhow!(format_selector_errors(errors)));
}
Ok(dedup_preserve_order(resolved_crates))
}
fn resolve_dir_selector(
dir: &str,
selector: &str,
workspace_root: &Path,
workspace_member_dirs: &[WorkspaceMemberDir],
) -> anyhow::Result<Vec<String>> {
if dir.is_empty() {
return Err(anyhow::anyhow!(
"Invalid selector '{}': directory path cannot be empty. Use 'dir:<path>'.",
selector
));
}
let requested = PathBuf::from(dir);
let requested_abs = if requested.is_absolute() {
requested
} else {
workspace_root.join(requested)
};
let requested_canonical = requested_abs.canonicalize().map_err(|e| {
anyhow::anyhow!(
"Directory selector '{}' does not resolve to an existing path: {}",
selector,
e
)
})?;
if !requested_canonical.is_dir() {
return Err(anyhow::anyhow!(
"Directory selector '{}' must point to a directory",
selector
));
}
let matches: Vec<String> = workspace_member_dirs
.iter()
.filter(|member| member.dir.starts_with(&requested_canonical))
.map(|member| member.name.clone())
.collect();
if matches.is_empty() {
return Err(anyhow::anyhow!(
"Directory selector '{}' matched no workspace crates",
selector
));
}
Ok(matches)
}
fn dedup_preserve_order(items: Vec<String>) -> Vec<String> {
let mut seen = HashSet::new();
let mut deduped = Vec::new();
for item in items {
if seen.insert(item.clone()) {
deduped.push(item);
}
}
deduped
}
fn resolve_selector(
selector: &str,
presets: &HashMap<String, PresetMetadata>,
workspace_members: &[String],
workspace_member_set: &HashSet<String>,
) -> anyhow::Result<Vec<String>> {
if let Some(name) = selector.strip_prefix("preset:") {
return resolve_preset_selector(name, presets, selector);
}
if let Some(name) = selector.strip_prefix("crate:") {
return resolve_crate_selector(name, workspace_member_set, selector);
}
if let Some(pattern) = selector.strip_prefix("regex:") {
return resolve_regex_selector(pattern, workspace_members, selector);
}
if let Some(preset) = presets.get(selector) {
return Ok(preset.members.clone());
}
if workspace_member_set.contains(selector) {
return Ok(vec![selector.to_string()]);
}
let normalized = selector.trim_end_matches(['/', '\\']);
if !normalized.is_empty() && normalized != selector {
if let Some(preset) = presets.get(normalized) {
return Ok(preset.members.clone());
}
if workspace_member_set.contains(normalized) {
return Ok(vec![normalized.to_string()]);
}
}
resolve_regex_selector(selector, workspace_members, selector)
}
fn resolve_preset_selector(
name: &str,
presets: &HashMap<String, PresetMetadata>,
selector: &str,
) -> anyhow::Result<Vec<String>> {
if name.is_empty() {
return Err(anyhow::anyhow!(
"Invalid selector '{}': preset name cannot be empty. Use 'preset:<name>'.",
selector
));
}
let preset = presets
.get(name)
.ok_or_else(|| anyhow::anyhow!(unknown_preset_message(name, selector, presets)))?;
Ok(preset.members.clone())
}
fn resolve_crate_selector(
name: &str,
workspace_member_set: &HashSet<String>,
selector: &str,
) -> anyhow::Result<Vec<String>> {
if name.is_empty() {
return Err(anyhow::anyhow!(
"Invalid selector '{}': crate name cannot be empty. Use 'crate:<name>'.",
selector
));
}
if !workspace_member_set.contains(name) {
return Err(anyhow::anyhow!(unknown_crate_message(
name,
selector,
workspace_member_set
)));
}
Ok(vec![name.to_string()])
}
fn resolve_regex_selector(
pattern: &str,
workspace_members: &[String],
selector: &str,
) -> anyhow::Result<Vec<String>> {
if pattern.is_empty() {
return Err(anyhow::anyhow!(
"Invalid selector '{}': regex pattern cannot be empty. Use 'regex:<pattern>'.",
selector
));
}
let regex = Regex::new(pattern).map_err(|e| {
anyhow::anyhow!(
"Invalid regex '{}' in selector '{}': {}. Hint: test it with 'cargo focus status' crate names.",
pattern,
selector,
e
)
})?;
let matches: Vec<String> = workspace_members
.iter()
.filter(|member| regex.is_match(member))
.cloned()
.collect();
if matches.is_empty() {
return Err(anyhow::anyhow!(
"Selector '{}' did not match any preset, crate, or workspace crate via regex. Hint: use 'cargo focus status' or 'cargo focus list' to inspect valid names.",
selector
));
}
Ok(matches)
}
fn format_selector_errors(errors: Vec<String>) -> String {
let mut message = format!("Failed to resolve {} selector(s):", errors.len());
for error in errors {
message.push_str("\n - ");
message.push_str(&error);
}
message
}
fn unknown_preset_message(
name: &str,
selector: &str,
presets: &HashMap<String, PresetMetadata>,
) -> String {
let mut message = format!("Unknown preset '{}' in selector '{}'", name, selector);
if let Some(suggestion) = best_suggestion(name, presets.keys().map(|s| s.as_str())) {
message.push_str(&format!(". Did you mean '{}'?", suggestion));
} else {
message.push_str(". Use 'cargo focus list' to inspect available presets.");
}
message
}
fn unknown_crate_message(name: &str, selector: &str, crates: &HashSet<String>) -> String {
let mut message = format!("Unknown crate '{}' in selector '{}'", name, selector);
if let Some(suggestion) = best_suggestion(name, crates.iter().map(|s| s.as_str())) {
message.push_str(&format!(". Did you mean '{}'?", suggestion));
} else {
message.push_str(". Use 'cargo focus status' to inspect workspace crate names.");
}
message
}
fn best_suggestion<'a>(input: &str, candidates: impl Iterator<Item = &'a str>) -> Option<String> {
let mut best: Option<(&str, usize)> = None;
for candidate in candidates {
let distance = levenshtein(input, candidate);
let threshold = min(3, (candidate.len() / 3) + 1);
if distance > threshold {
continue;
}
match best {
Some((_, best_distance)) if distance >= best_distance => {}
_ => best = Some((candidate, distance)),
}
}
best.map(|(candidate, _)| candidate.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
struct SelectorTestCtx {
presets: HashMap<String, PresetMetadata>,
members: Vec<String>,
member_set: HashSet<String>,
}
impl SelectorTestCtx {
fn new() -> Self {
let presets = test_presets();
let members = workspace_members();
let member_set = members.iter().cloned().collect();
Self {
presets,
members,
member_set,
}
}
fn resolve(&self, selector: &str) -> anyhow::Result<Vec<String>> {
resolve_selector(selector, &self.presets, &self.members, &self.member_set)
}
}
fn test_presets() -> HashMap<String, PresetMetadata> {
HashMap::from([
(
"dev".to_string(),
PresetMetadata {
members: vec!["api".to_string(), "worker".to_string()],
description: Some("dev preset".to_string()),
},
),
(
"shared".to_string(),
PresetMetadata {
members: vec!["shared".to_string()],
description: None,
},
),
(
"ambiguous".to_string(),
PresetMetadata {
members: vec!["api".to_string()],
description: None,
},
),
])
}
fn workspace_members() -> Vec<String> {
vec![
"api".to_string(),
"worker".to_string(),
"shared".to_string(),
"ambiguous".to_string(),
]
}
#[test]
fn dedup_preserves_order() {
let input = vec![
"a".to_string(),
"b".to_string(),
"a".to_string(),
"c".to_string(),
"b".to_string(),
];
let deduped = dedup_preserve_order(input);
assert_eq!(
deduped,
vec!["a".to_string(), "b".to_string(), "c".to_string()]
);
}
#[test]
fn resolve_explicit_preset_selector() {
let ctx = SelectorTestCtx::new();
let resolved = ctx.resolve("preset:dev").expect("resolve");
assert_eq!(resolved, vec!["api".to_string(), "worker".to_string()]);
}
#[test]
fn resolve_explicit_crate_selector() {
let ctx = SelectorTestCtx::new();
let resolved = ctx.resolve("crate:shared").expect("resolve");
assert_eq!(resolved, vec!["shared".to_string()]);
}
#[test]
fn resolve_explicit_regex_selector() {
let ctx = SelectorTestCtx::new();
let resolved = ctx.resolve("regex:^w").expect("resolve");
assert_eq!(resolved, vec!["worker".to_string()]);
}
#[test]
fn empty_prefixed_selectors_error() {
let ctx = SelectorTestCtx::new();
let preset_err = ctx.resolve("preset:").expect_err("error");
assert!(
preset_err
.to_string()
.contains("preset name cannot be empty")
);
let crate_err = ctx.resolve("crate:").expect_err("error");
assert!(crate_err.to_string().contains("crate name cannot be empty"));
let regex_err = ctx.resolve("regex:").expect_err("error");
assert!(
regex_err
.to_string()
.contains("regex pattern cannot be empty")
);
}
#[test]
fn unprefixed_resolution_matrix_covers_precedence_and_path_separator_tolerance() {
let ctx = SelectorTestCtx::new();
let cases = vec![
("ambiguous", vec!["api".to_string()]), ("shared", vec!["shared".to_string()]), ("^w", vec!["worker".to_string()]), ("ambiguous/", vec!["api".to_string()]), ("shared\\", vec!["shared".to_string()]), ];
for (selector, expected) in cases {
let resolved = ctx.resolve(selector).expect("resolve");
assert_eq!(resolved, expected, "selector: {selector}");
}
}
#[test]
fn explicit_selectors_remain_strict_about_names() {
let ctx = SelectorTestCtx::new();
let crate_err = ctx.resolve("crate:shared/").expect_err("error");
assert!(crate_err.to_string().contains("Unknown crate 'shared/'"));
let preset_err = ctx.resolve("preset:shared/").expect_err("error");
assert!(preset_err.to_string().contains("Unknown preset 'shared/'"));
}
#[test]
fn invalid_regex_errors() {
let ctx = SelectorTestCtx::new();
let err = ctx.resolve("regex:[").expect_err("error");
let msg = err.to_string();
assert!(msg.contains("Invalid regex"));
assert!(msg.contains("selector 'regex:['"));
}
#[test]
fn valid_regex_with_no_matches_errors() {
let ctx = SelectorTestCtx::new();
let err = ctx.resolve("regex:^zzz").expect_err("error");
let msg = err.to_string();
assert!(msg.contains("did not match any preset, crate, or workspace crate via regex"));
assert!(msg.contains("regex:^zzz"));
}
#[test]
fn unknown_crate_and_preset_errors_include_selector() {
let ctx = SelectorTestCtx::new();
let crate_err = ctx.resolve("crate:missing").expect_err("error");
assert!(crate_err.to_string().contains("crate:missing"));
let preset_err = ctx.resolve("preset:missing").expect_err("error");
assert!(preset_err.to_string().contains("preset:missing"));
}
}