#[cfg(feature = "cli")]
use clap::Args;
use std::io::BufRead;
#[derive(Debug, Clone, Copy, Default)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
pub enum ColorMode {
#[default]
Auto,
Always,
Never,
}
pub use crate::write::EolMode;
#[derive(Debug, Default)]
#[cfg_attr(feature = "cli", derive(Args))]
pub struct GlobalFlags {
#[cfg_attr(feature = "cli", arg(long, global = true))]
pub json: bool,
#[cfg_attr(feature = "cli", arg(long, global = true))]
pub jsonl: bool,
#[cfg_attr(feature = "cli", arg(long, short = 'q', global = true))]
pub quiet: bool,
#[cfg_attr(feature = "cli", arg(long, global = true))]
pub verbose: bool,
#[cfg_attr(feature = "cli", arg(long, global = true))]
pub cwd: Option<String>,
#[cfg_attr(feature = "cli", arg(long, global = true, action = clap::ArgAction::Append))]
pub glob: Vec<String>,
#[cfg_attr(feature = "cli", arg(long, global = true, action = clap::ArgAction::Append))]
pub exclude: Vec<String>,
#[cfg_attr(feature = "cli", arg(long, global = true, action = clap::ArgAction::Append, value_name = "FILENAME"))]
pub ignore_file: Vec<String>,
#[cfg_attr(feature = "cli", arg(long, global = true))]
pub files_from: Option<String>,
#[cfg_attr(
feature = "cli",
arg(long, global = true, value_enum, default_value = "auto")
)]
pub color: ColorMode,
#[cfg_attr(feature = "cli", clap(skip))]
pub diff: bool,
#[cfg_attr(feature = "cli", clap(skip))]
pub apply: bool,
#[cfg_attr(feature = "cli", clap(skip))]
pub check: bool,
#[cfg_attr(feature = "cli", clap(skip))]
pub ensure_final_newline: bool,
#[cfg_attr(feature = "cli", clap(skip))]
pub normalize_eol: Option<EolMode>,
#[cfg_attr(feature = "cli", clap(skip))]
pub trim_trailing_whitespace: bool,
#[cfg_attr(feature = "cli", clap(skip))]
pub respect_editorconfig: bool,
#[cfg_attr(feature = "cli", clap(skip))]
pub collapse_blanks: bool,
#[cfg_attr(feature = "cli", clap(skip))]
pub confirm: bool,
#[cfg_attr(feature = "cli", clap(skip))]
pub format: Option<String>,
#[cfg_attr(feature = "cli", clap(skip))]
pub format_timeout: Option<u64>,
#[cfg_attr(feature = "cli", clap(skip))]
pub no_format: bool,
#[cfg_attr(feature = "cli", clap(skip))]
pub format_config: Option<crate::config::FormatConfig>,
}
#[derive(Debug, Default)]
#[cfg_attr(feature = "cli", derive(Args))]
pub struct WriteFlags {
#[cfg_attr(feature = "cli", arg(long, global = true))]
pub diff: bool,
#[cfg_attr(feature = "cli", arg(long, global = true, conflicts_with = "check"))]
pub apply: bool,
#[cfg_attr(feature = "cli", arg(long, global = true, conflicts_with = "apply"))]
pub check: bool,
#[cfg_attr(feature = "cli", arg(long, global = true))]
pub ensure_final_newline: bool,
#[cfg_attr(feature = "cli", arg(long, global = true, value_enum))]
pub normalize_eol: Option<EolMode>,
#[cfg_attr(feature = "cli", arg(long, global = true))]
pub trim_trailing_whitespace: bool,
#[cfg_attr(feature = "cli", arg(long, global = true))]
pub respect_editorconfig: bool,
#[cfg_attr(feature = "cli", arg(long, global = true))]
pub collapse_blanks: bool,
#[cfg_attr(feature = "cli", arg(long, global = true, conflicts_with_all = ["apply", "check"]))]
pub confirm: bool,
#[cfg_attr(feature = "cli", arg(long, global = true))]
pub format: Option<String>,
#[cfg_attr(feature = "cli", arg(long, global = true, default_value = "30"))]
pub format_timeout: Option<u64>,
#[cfg_attr(feature = "cli", arg(long, global = true))]
pub no_format: bool,
}
pub(crate) fn confirm_prompt(prompt: &str) -> bool {
use std::io::Write;
let _ = std::io::stdout().flush();
let is_tty = std::io::IsTerminal::is_terminal(&std::io::stdin());
confirm_prompt_interactive(prompt, is_tty, &mut std::io::stdin().lock())
}
pub(crate) fn confirm_prompt_interactive(
prompt: &str,
is_tty: bool,
reader: &mut impl BufRead,
) -> bool {
if !is_tty {
return false;
}
eprint!("{prompt} [Y/n] ");
let mut buf = String::new();
match reader.read_line(&mut buf) {
Ok(0) | Err(_) => false,
Ok(_) => {
let answer = buf.trim().to_lowercase();
answer.is_empty() || answer == "y" || answer == "yes"
}
}
}
impl GlobalFlags {
pub fn merge_write(&mut self, w: &WriteFlags) {
let WriteFlags {
diff,
apply,
check,
confirm,
ensure_final_newline,
normalize_eol,
trim_trailing_whitespace,
respect_editorconfig,
collapse_blanks,
ref format,
format_timeout,
no_format,
} = *w;
self.diff = diff;
self.apply = apply;
self.check = check;
self.confirm = confirm;
self.ensure_final_newline = ensure_final_newline;
self.normalize_eol = normalize_eol;
self.trim_trailing_whitespace = trim_trailing_whitespace;
self.respect_editorconfig = respect_editorconfig;
self.collapse_blanks = collapse_blanks;
self.format = format.clone();
self.format_timeout = format_timeout;
self.no_format = no_format;
}
pub fn should_apply(&self) -> bool {
if self.apply {
return true;
}
self.confirm && confirm_prompt("Apply?")
}
pub fn should_color(&self) -> bool {
match self.color {
ColorMode::Always => true,
ColorMode::Never => false,
ColorMode::Auto => {
if std::env::var_os("NO_COLOR").is_some() {
return false;
}
#[cfg(feature = "cli")]
{
anstream::stdout().is_terminal()
}
#[cfg(not(feature = "cli"))]
{
false
}
}
}
}
pub fn show_status(&self) -> bool {
!self.quiet && !self.json && !self.jsonl && {
#[cfg(feature = "cli")]
{
anstream::stderr().is_terminal()
}
#[cfg(not(feature = "cli"))]
{
false
}
}
}
pub fn resolve_cwd(&self) -> anyhow::Result<std::path::PathBuf> {
if let Some(ref cwd) = self.cwd {
let path = std::path::PathBuf::from(cwd);
if !path.exists() {
anyhow::bail!("--cwd directory does not exist: {cwd}");
}
if !path.is_dir() {
anyhow::bail!("--cwd is not a directory: {cwd}");
}
Ok(path)
} else {
std::env::current_dir().map_err(Into::into)
}
}
pub fn emit_json<T: serde::Serialize>(&self, value: &T) -> anyhow::Result<bool> {
if self.json {
println!("{}", serde_json::to_string_pretty(value)?);
Ok(true)
} else if self.jsonl {
println!("{}", serde_json::to_string(value)?);
Ok(true)
} else {
Ok(false)
}
}
pub fn emit_json_items<T: serde::Serialize>(&self, items: &[T]) -> anyhow::Result<bool> {
if self.json {
println!("{}", serde_json::to_string_pretty(items)?);
Ok(true)
} else if self.jsonl {
for item in items {
println!("{}", serde_json::to_string(item)?);
}
Ok(true)
} else {
Ok(false)
}
}
pub fn read_files_from(&self) -> anyhow::Result<Option<Vec<String>>> {
let source = match self.files_from.as_deref() {
Some(s) => s,
None => return Ok(None),
};
let lines: Vec<String> = if source == "-" {
std::io::stdin()
.lock()
.lines()
.map_while(Result::ok)
.enumerate()
.map(|(i, l)| {
let l = l.trim().to_string();
if i == 0 {
l.strip_prefix('\u{FEFF}').unwrap_or(&l).to_string()
} else {
l
}
})
.filter(|l| !l.is_empty())
.collect()
} else {
let content = std::fs::read_to_string(source)
.map_err(|e| anyhow::anyhow!("failed to read --files-from '{}': {e}", source))?;
let content = content.strip_prefix('\u{FEFF}').unwrap_or(&content);
content
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.map(String::from)
.collect()
};
Ok(Some(lines))
}
}
#[cfg(test)]
impl GlobalFlags {
pub fn test_default() -> Self {
GlobalFlags {
color: ColorMode::Never,
..GlobalFlags::default()
}
}
pub fn test_with_cwd(dir: &std::path::Path) -> Self {
GlobalFlags {
color: ColorMode::Never,
..Self::with_cwd(dir)
}
}
pub fn test_apply() -> Self {
let mut g = Self::test_default();
g.apply = true;
g
}
}
impl GlobalFlags {
pub fn with_cwd(cwd: impl AsRef<std::path::Path>) -> Self {
GlobalFlags {
cwd: Some(cwd.as_ref().to_string_lossy().into_owned()),
..Default::default()
}
}
pub fn with_cwd_and_json(cwd: impl AsRef<std::path::Path>) -> Self {
GlobalFlags {
cwd: Some(cwd.as_ref().to_string_lossy().into_owned()),
json: true,
..Default::default()
}
}
pub fn with_cwd_and_jsonl(cwd: impl AsRef<std::path::Path>) -> Self {
GlobalFlags {
cwd: Some(cwd.as_ref().to_string_lossy().into_owned()),
jsonl: true,
..Default::default()
}
}
pub fn with_editorconfig(base: &GlobalFlags, ec: bool) -> Self {
GlobalFlags {
cwd: base.cwd.clone(),
json: base.json,
jsonl: base.jsonl,
quiet: base.quiet,
glob: base.glob.clone(),
exclude: base.exclude.clone(),
ignore_file: base.ignore_file.clone(),
files_from: base.files_from.clone(),
diff: base.diff,
apply: base.apply,
check: base.check,
ensure_final_newline: base.ensure_final_newline,
normalize_eol: base.normalize_eol,
trim_trailing_whitespace: base.trim_trailing_whitespace,
collapse_blanks: base.collapse_blanks,
respect_editorconfig: ec,
confirm: base.confirm,
no_format: base.no_format,
format: base.format.clone(),
format_timeout: base.format_timeout,
format_config: base.format_config.clone(),
verbose: base.verbose,
color: base.color,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_apply_returns_true_when_apply_set() {
let g = GlobalFlags::test_apply();
assert!(g.should_apply());
}
#[test]
fn should_apply_returns_false_by_default() {
let g = GlobalFlags::test_default();
assert!(!g.should_apply());
}
#[test]
fn confirm_prompt_non_tty_returns_false() {
let mut reader = std::io::Cursor::new(b"y\n");
assert!(!confirm_prompt_interactive("Apply?", false, &mut reader));
}
#[test]
fn confirm_prompt_tty_accepts_default_yes() {
let mut reader = std::io::Cursor::new(b"\n");
assert!(confirm_prompt_interactive("Apply?", true, &mut reader));
}
#[test]
fn should_apply_confirm_non_tty_returns_false() {
if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
return;
}
let g = GlobalFlags {
confirm: true,
..GlobalFlags::default()
};
assert!(!g.should_apply());
}
#[test]
fn merge_write_copies_confirm() {
let w = WriteFlags {
confirm: true,
..WriteFlags::default()
};
let mut g = GlobalFlags::default();
g.merge_write(&w);
assert!(g.confirm);
}
#[test]
fn merge_write_copies_all_fields() {
let w = WriteFlags {
diff: true,
apply: true,
check: false,
confirm: true,
ensure_final_newline: true,
normalize_eol: Some(EolMode::Lf),
trim_trailing_whitespace: true,
respect_editorconfig: true,
collapse_blanks: true,
format: Some("cargo fmt".into()),
format_timeout: Some(60),
no_format: true,
};
let mut g = GlobalFlags::default();
g.merge_write(&w);
assert!(g.diff);
assert!(g.apply);
assert!(!g.check);
assert!(g.confirm);
assert!(g.ensure_final_newline);
assert_eq!(g.normalize_eol, Some(EolMode::Lf));
assert!(g.trim_trailing_whitespace);
assert!(g.respect_editorconfig);
assert!(g.collapse_blanks);
assert_eq!(g.format.as_deref(), Some("cargo fmt"));
assert_eq!(g.format_timeout, Some(60));
assert!(g.no_format);
}
#[test]
fn verbose_flag_defaults_to_false() {
let g = GlobalFlags::default();
assert!(!g.verbose);
}
#[test]
fn resolve_cwd_nonexistent_errors() {
let g = GlobalFlags {
cwd: Some("/nonexistent/path/that/does/not/exist".into()),
..GlobalFlags::default()
};
let err = g.resolve_cwd().unwrap_err().to_string();
assert!(err.contains("does not exist"), "unexpected: {err}");
}
#[test]
fn resolve_cwd_not_a_directory_errors() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("file.txt");
std::fs::write(&file, "x").unwrap();
let g = GlobalFlags {
cwd: Some(file.to_string_lossy().into_owned()),
..GlobalFlags::default()
};
let err = g.resolve_cwd().unwrap_err().to_string();
assert!(err.contains("not a directory"), "unexpected: {err}");
}
#[test]
fn read_files_from_trims_whitespace() {
let dir = tempfile::tempdir().unwrap();
let list = dir.path().join("files.txt");
std::fs::write(&list, " src/main.rs \n lib.rs\t\n").unwrap();
let flags = GlobalFlags {
files_from: Some(list.to_str().unwrap().to_string()),
..GlobalFlags::test_default()
};
let result = flags.read_files_from().unwrap().unwrap();
assert_eq!(result, vec!["src/main.rs", "lib.rs"]);
}
#[test]
fn read_files_from_strips_bom() {
let dir = tempfile::tempdir().unwrap();
let list = dir.path().join("files.txt");
std::fs::write(&list, "\u{FEFF}src/main.rs\nlib.rs\n").unwrap();
let flags = GlobalFlags {
files_from: Some(list.to_str().unwrap().to_string()),
..GlobalFlags::test_default()
};
let result = flags.read_files_from().unwrap().unwrap();
assert_eq!(result, vec!["src/main.rs", "lib.rs"]);
}
#[test]
fn read_files_from_whitespace_only_lines_filtered() {
let dir = tempfile::tempdir().unwrap();
let list = dir.path().join("files.txt");
std::fs::write(&list, "src/main.rs\n \nlib.rs\n").unwrap();
let flags = GlobalFlags {
files_from: Some(list.to_str().unwrap().to_string()),
..GlobalFlags::test_default()
};
let result = flags.read_files_from().unwrap().unwrap();
assert_eq!(result, vec!["src/main.rs", "lib.rs"]);
}
}