use std::{fmt::Display, fs::read_to_string, io::Write, path::PathBuf};
use fmt::Config;
use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};
use toml_edit::{DocumentMut, Item};
mod fmt;
mod sort;
#[cfg(test)]
mod test_utils;
const EXTRA_HELP: &str = "\
NOTE: in check mode, only unsorted dependencies cause failure; \
formatting differences are reported as warnings unless --check-format is used";
type IoResult<T> = Result<T, Box<dyn std::error::Error>>;
#[derive(clap::Parser, Debug)]
#[command(author, version, bin_name = "cargo sort", after_help = EXTRA_HELP)]
pub struct Cli {
#[arg(value_name = "CWD")]
pub cwd: Vec<String>,
#[arg(short, long)]
pub check: bool,
#[arg(short, long, conflicts_with = "check")]
pub print: bool,
#[arg(short = 'n', long)]
pub no_format: bool,
#[arg(long, requires = "check")]
pub check_format: bool,
#[arg(short, long)]
pub workspace: bool,
#[arg(short, long)]
pub grouped: bool,
#[arg(short, long, value_delimiter = ',')]
pub order: Vec<String>,
#[arg(long, value_name = "PATH")]
pub config: Option<PathBuf>,
}
fn write_red<S: Display>(highlight: &str, msg: S) -> IoResult<()> {
let mut stderr = StandardStream::stderr(ColorChoice::Auto);
stderr.set_color(ColorSpec::new().set_fg(Some(Color::Red)))?;
write!(stderr, "{highlight}")?;
stderr.reset()?;
writeln!(stderr, "{msg}").map_err(Into::into)
}
fn write_green<S: Display>(highlight: &str, msg: S) -> IoResult<()> {
let mut stdout = StandardStream::stdout(ColorChoice::Auto);
stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)))?;
write!(stdout, "{highlight}")?;
stdout.reset()?;
writeln!(stdout, "{msg}").map_err(Into::into)
}
fn write_yellow<S: Display>(highlight: &str, msg: S) -> IoResult<()> {
let mut stderr = StandardStream::stderr(ColorChoice::Auto);
stderr.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)))?;
write!(stderr, "{highlight}")?;
stderr.reset()?;
writeln!(stderr, "{msg}").map_err(Into::into)
}
struct ProcessedToml {
is_sorted: bool,
is_formatted: bool,
final_output: String,
}
fn process_toml(
toml_raw: &str,
grouped: bool,
no_format: bool,
check_format: bool,
config: &Config,
) -> ProcessedToml {
let mut sorted =
sort::sort_toml(toml_raw, sort::MATCHER, grouped, &config.table_order);
let sorted_only = sorted.to_string();
let toml_normalized = toml_raw.replace("\r\n", "\n");
let is_sorted = toml_normalized == sorted_only;
let (final_output, is_formatted) = if !no_format || check_format {
fmt::fmt_toml(&mut sorted, config);
let formatted = sorted.to_string();
let is_fmt = sorted_only == formatted;
(formatted, is_fmt)
} else {
(sorted_only, true)
};
let final_output =
if config.crlf.unwrap_or(fmt::DEF_CRLF) && !final_output.contains("\r\n") {
final_output.replace('\n', "\r\n")
} else {
final_output
};
ProcessedToml { is_sorted, is_formatted, final_output }
}
fn check_toml(path: &str, cli: &Cli, config: &Config) -> IoResult<bool> {
let mut path = PathBuf::from(path);
if path.is_dir() {
path.push("Cargo.toml");
}
let krate = path.components().nth_back(1).ok_or("No crate folder found")?.as_os_str();
write_green("Checking ", format!("{}...", krate.to_string_lossy()))?;
let toml_raw = read_to_string(&path)
.map_err(|_| format!("No file found at: {}", path.display()))?;
let crlf = toml_raw.contains("\r\n");
let mut config = config.clone();
if config.crlf.is_none() {
config.crlf = Some(crlf);
}
let result =
process_toml(&toml_raw, cli.grouped, cli.no_format, cli.check_format, &config);
if cli.print {
print!("{}", result.final_output);
return Ok(true);
}
if cli.check {
if !result.is_sorted {
write_red(
"error: ",
format!("Dependencies for {} are not sorted", krate.to_string_lossy()),
)?;
}
if !result.is_formatted {
if cli.check_format {
write_red(
"error: ",
format!(
"Cargo.toml for {} is not formatted",
krate.to_string_lossy()
),
)?;
} else {
write_yellow(
"warning: ",
format!(
"Cargo.toml for {} is not formatted",
krate.to_string_lossy()
),
)?;
}
}
return Ok(result.is_sorted && (!cli.check_format || result.is_formatted));
}
let has_changes = toml_raw != result.final_output;
if has_changes {
std::fs::write(&path, &result.final_output)?;
write_green(
"Finished: ",
format!("Cargo.toml for {:?} has been rewritten", krate.to_string_lossy()),
)?;
} else {
write_green(
"Finished: ",
format!(
"Cargo.toml for {} is sorted already, no changes made",
krate.to_string_lossy()
),
)?;
}
Ok(true)
}
fn _main() -> IoResult<()> {
let mut args: Vec<String> = std::env::args().collect();
if args.len() > 1 && args[1] == "sort" {
args.remove(1);
}
let cli = <Cli as clap::Parser>::parse_from(args);
let cwd = std::env::current_dir()
.map_err(|e| format!("no current directory found: {e}"))?;
let dir = cwd.to_string_lossy();
let mut filtered_matches: Vec<String> = cli.cwd.clone();
let is_posible_workspace = filtered_matches.is_empty() || filtered_matches.len() == 1;
if filtered_matches.is_empty() {
filtered_matches.push(dir.to_string());
}
if cli.workspace && is_posible_workspace {
let dir = filtered_matches[0].to_string();
let mut path = PathBuf::from(&dir);
if path.is_dir() {
path.push("Cargo.toml");
}
let raw_toml = read_to_string(&path)
.map_err(|_| format!("no file found at: {}", path.display()))?;
let toml = raw_toml.parse::<DocumentMut>()?;
let workspace = toml.get("workspace");
if let Some(Item::Table(ws)) = workspace {
let excludes: Vec<&str> =
ws.get("exclude").map_or_else(Vec::new, array_string_members);
for member in ws.get("members").map_or_else(Vec::new, array_string_members) {
if member.contains('*') || member.contains('?') {
'globs: for entry in glob::glob(&format!("{dir}/{member}"))
.unwrap_or_else(|e| {
write_red("error: ", format!("Glob failed: {e}")).unwrap();
std::process::exit(1);
})
{
let path = entry?;
if path.is_file() {
continue;
}
let path_str = path.to_string_lossy();
for excl in &excludes {
if path_str.ends_with(excl) {
continue 'globs;
}
}
filtered_matches.push(path.display().to_string());
}
} else {
filtered_matches.push(format!("{dir}/{member}"));
}
}
}
}
let mut config = if let Some(config_path) = &cli.config {
read_to_string(config_path)
.map_err(|_| format!("config file not found: {}", config_path.display()))?
.parse::<Config>()?
} else {
let mut config_path = cwd.clone();
config_path.push("tomlfmt.toml");
read_to_string(&config_path)
.or_else(|_err| {
config_path.pop();
config_path.push(".tomlfmt.toml");
read_to_string(&config_path)
})
.unwrap_or_default()
.parse::<Config>()?
};
if !cli.order.is_empty() {
config.table_order = cli.order.clone();
}
let mut flag = true;
for sorted in filtered_matches.iter().map(|path| check_toml(path, &cli, &config)) {
if !(sorted?) {
flag = false;
}
}
if !flag {
return Err("Some Cargo.toml files are not sorted or formatted".into());
}
Ok(())
}
fn array_string_members(value: &Item) -> Vec<&str> {
value.as_array().into_iter().flatten().filter_map(|s| s.as_str()).collect()
}
fn main() {
_main().unwrap_or_else(|e| {
write_red("error: ", e).unwrap();
std::process::exit(1);
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cli_config_flag_parsing() {
let args = vec!["cargo-sort", "--config", "custom.toml"];
let cli = <Cli as clap::Parser>::try_parse_from(args).unwrap();
assert_eq!(cli.config, Some(PathBuf::from("custom.toml")));
}
#[test]
fn cli_config_flag_with_other_flags() {
let args = vec!["cargo-sort", "--check", "--config", "/path/to/config.toml", "."];
let cli = <Cli as clap::Parser>::try_parse_from(args).unwrap();
assert!(cli.check);
assert_eq!(cli.config, Some(PathBuf::from("/path/to/config.toml")));
assert_eq!(cli.cwd, vec!["."]);
}
#[test]
fn cli_without_config_flag() {
let args = vec!["cargo-sort", "--check", "."];
let cli = <Cli as clap::Parser>::try_parse_from(args).unwrap();
assert_eq!(cli.config, None);
}
#[test]
fn custom_config_loads_correctly() {
let config_content = read_to_string("examp/custom_config.toml").unwrap();
let config: Config = config_content.parse().unwrap();
assert!(config.always_trailing_comma);
assert!(!config.multiline_trailing_comma);
assert!(!config.space_around_eq);
assert!(config.compact_arrays);
assert_eq!(config.indent_count, 2);
}
#[test]
fn config_error_on_missing_file() {
let result = read_to_string("nonexistent_config.toml");
assert!(result.is_err());
}
#[test]
fn check_unsorted() {
let toml = "[dependencies]\nfoo = \"1\"\nbar = \"1\"\n";
let config = Config::default();
let result = process_toml(toml, false, false, false, &config);
assert!(!result.is_sorted);
assert!(result.is_formatted);
}
#[test]
fn check_sorted_unformatted() {
let toml = "[dependencies]\nbar=\"1\"\nfoo=\"1\"\n";
let config = Config::default();
let result = process_toml(toml, false, false, false, &config);
assert!(result.is_sorted);
assert!(!result.is_formatted);
}
#[test]
fn sorted_unformatted_no_check_format() {
let toml = "[dependencies]\nbar=\"1\"\nfoo=\"1\"\n";
let config = Config::default();
let result = process_toml(toml, false, false, true, &config);
assert!(result.is_sorted);
assert!(!result.is_formatted);
}
#[test]
fn sorted_with_crlf_detected_as_sorted() {
let toml = "[dependencies]\r\nbar = \"1\"\r\nfoo = \"1\"\r\n";
let config = Config::default();
let result = process_toml(toml, false, false, false, &config);
assert!(
result.is_sorted,
"CRLF file with sorted deps should be detected as sorted"
);
}
}