use std::borrow::Cow;
use std::io::Read;
use std::path::Path;
use std::path::PathBuf;
use std::process::ExitCode;
use clap::ColorChoice;
use clap::Parser;
use mago_database::Database;
use mago_database::DatabaseReader;
use mago_database::change::ChangeLog;
use mago_database::error::DatabaseError;
use mago_database::file::File;
use mago_orchestrator::service::format::FileFormatStatus;
use mago_orchestrator::service::format::FormatResult;
use crate::EXIT_CODE_ERROR;
use crate::config::Configuration;
use crate::error::Error;
use crate::utils;
use crate::utils::create_orchestrator;
use crate::utils::git;
use crate::utils::git::get_staged_file;
use crate::utils::git::update_staged_file;
#[derive(Parser, Debug)]
#[command(
name = "format",
aliases = ["fmt"],
about = "Format source files to match defined style rules",
long_about = r#"
The `format` command applies consistent formatting to source files based on the rules defined in the configuration file.
This command helps maintain a consistent codebase style, improving readability and collaboration.
"#
)]
pub struct FormatCommand {
#[arg()]
pub path: Vec<PathBuf>,
#[arg(long, short = 'd', conflicts_with_all = ["check", "stdin_input"], alias = "diff")]
pub dry_run: bool,
#[arg(long, short = 'c', conflicts_with_all = ["dry_run", "stdin_input"])]
pub check: bool,
#[arg(long, short = 'i', conflicts_with_all = ["dry_run", "check", "path", "staged"])]
pub stdin_input: bool,
#[arg(long, requires = "stdin_input", value_name = "PATH")]
pub stdin_filepath: Option<PathBuf>,
#[arg(long, short = 's', conflicts_with_all = ["dry_run", "check", "stdin_input", "path"])]
pub staged: bool,
}
impl FormatCommand {
pub fn execute(self, configuration: Configuration, color_choice: ColorChoice) -> Result<ExitCode, Error> {
if self.staged {
return self.execute_staged(configuration, color_choice);
}
let mut orchestrator = create_orchestrator(&configuration, color_choice, false, true, false);
orchestrator.add_exclude_patterns(configuration.formatter.excludes.iter());
if !self.path.is_empty() {
orchestrator.set_source_paths(self.path.iter().map(|p| p.to_string_lossy().to_string()));
}
if self.stdin_input {
return self.execute_stdin(orchestrator, &configuration);
}
let mut database = orchestrator.load_database(&configuration.source.workspace, false, None, None)?;
let service = orchestrator.get_format_service(database.read_only());
let result = service.run()?;
for (file_id, parse_error) in result.parse_errors() {
let file = database.get_ref(file_id)?;
tracing::error!("Failed to parse file '{}': {parse_error}", mago_bytes::BytesDisplay(&file.name));
}
let changed_files_count = result.changed_files_count();
if changed_files_count == 0 {
tracing::info!("All files are already formatted.");
return Ok(ExitCode::SUCCESS);
}
if self.check {
tracing::info!(
"Found {changed_files_count} file(s) need formatting. Run the command without '--check' to format them.",
);
return Ok(ExitCode::FAILURE);
}
let change_log = to_change_log(&database, &result, self.dry_run, color_choice)?;
database.commit(change_log, true)?;
let exit_code = if self.dry_run {
tracing::info!("Found {changed_files_count} file(s) that need formatting.");
ExitCode::FAILURE
} else {
tracing::info!("Formatted {changed_files_count} file(s) successfully.");
ExitCode::SUCCESS
};
Ok(exit_code)
}
fn execute_stdin(
self,
mut orchestrator: mago_orchestrator::Orchestrator<'_>,
configuration: &Configuration,
) -> Result<ExitCode, Error> {
let mut content: Vec<u8> = Vec::new();
std::io::stdin().read_to_end(&mut content).map_err(|e| Error::Database(DatabaseError::IOError(e)))?;
let Some(filepath) = self.stdin_filepath.as_deref() else {
let file = File::ephemeral(Cow::Borrowed(b"<stdin>"), Cow::Owned(content));
return Ok(emit_stdin_result(orchestrator.format_file(&file)?, &file));
};
let logical_name = stdin_logical_name(filepath, &configuration.source.workspace);
orchestrator.set_source_paths([filepath.to_string_lossy().to_string()]);
let database = orchestrator.load_database(
&configuration.source.workspace,
false,
None,
Some((logical_name.clone(), content.clone())),
)?;
let file = match database.get_by_name(logical_name.as_bytes()) {
Ok(file) => file.clone(),
Err(_) => {
use std::io::Write;
let _ = std::io::stdout().write_all(&content);
return Ok(ExitCode::SUCCESS);
}
};
let status = orchestrator.get_format_service(database.read_only()).format_file(&file)?;
Ok(emit_stdin_result(status, &file))
}
fn execute_staged(self, configuration: Configuration, color_choice: ColorChoice) -> Result<ExitCode, Error> {
let workspace = &configuration.source.workspace;
let mut orchestrator = create_orchestrator(&configuration, color_choice, false, true, false);
orchestrator.add_exclude_patterns(configuration.formatter.excludes.iter());
let database = orchestrator.load_database(workspace, false, None, None)?;
let staged_file_paths = git::get_staged_file_paths(workspace)?;
if staged_file_paths.is_empty() {
tracing::info!("No staged files to format.");
return Ok(ExitCode::SUCCESS);
}
let mut changed_files_count = 0;
for path in staged_file_paths {
let absolute_path = workspace.join(&path);
let canonical_path = absolute_path.canonicalize().unwrap_or(absolute_path);
if database.get_by_path(&canonical_path).is_err() {
continue;
}
let staged_file = get_staged_file(workspace, &path)?;
match orchestrator.format_file(&staged_file)? {
FileFormatStatus::Unchanged => continue,
FileFormatStatus::Changed(new_content) => {
update_staged_file(workspace, &path, new_content)?;
changed_files_count += 1;
}
FileFormatStatus::FailedToParse(parse_error) => {
tracing::error!("Failed to parse staged file '{}': {}", path.display(), parse_error);
}
};
}
if changed_files_count == 0 {
tracing::info!("All staged files are already formatted.");
return Ok(ExitCode::SUCCESS);
}
tracing::info!("Formatted and re-staged {changed_files_count} file(s).");
Ok(ExitCode::SUCCESS)
}
}
fn to_change_log(
database: &Database<'_>,
format_result: &FormatResult,
dry_run: bool,
color_choice: ColorChoice,
) -> Result<ChangeLog, Error> {
let change_log = ChangeLog::new();
for (file_id, new_content) in format_result.changed_files() {
let file = database.get_ref(file_id)?;
utils::apply_update(&change_log, file, new_content, dry_run, color_choice)?;
}
Ok(change_log)
}
fn emit_stdin_result(status: FileFormatStatus, file: &File) -> ExitCode {
use std::io::Write;
match status {
FileFormatStatus::Unchanged => {
let _ = std::io::stdout().write_all(&file.contents);
ExitCode::SUCCESS
}
FileFormatStatus::Changed(new_content) => {
let _ = std::io::stdout().write_all(&new_content);
ExitCode::SUCCESS
}
FileFormatStatus::FailedToParse(parse_error) => {
tracing::error!("Failed to parse {}: {parse_error}", mago_bytes::BytesDisplay(&file.name));
ExitCode::from(EXIT_CODE_ERROR)
}
}
}
fn stdin_logical_name(filepath: &Path, workspace: &Path) -> String {
let canonical_filepath = filepath.canonicalize();
let canonical_workspace = workspace.canonicalize();
let stripped: &Path = match (canonical_filepath.as_ref(), canonical_workspace.as_ref()) {
(Ok(fp), Ok(ws)) => fp.strip_prefix(ws).unwrap_or(fp),
_ => filepath.strip_prefix(workspace).unwrap_or(filepath),
};
#[cfg(windows)]
let mut name = stripped.to_string_lossy().replace('\\', "/");
#[cfg(not(windows))]
let mut name = stripped.to_string_lossy().into_owned();
while let Some(rest) = name.strip_prefix("./") {
name = rest.to_string();
}
name
}