use std::borrow::Cow;
use std::io::Read;
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, 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 {
let file = Self::create_file_from_stdin()?;
let status = orchestrator.format_file(&file)?;
let exit_code = match status {
FileFormatStatus::Unchanged => {
print!("{}", file.contents);
ExitCode::SUCCESS
}
FileFormatStatus::Changed(new_content) => {
print!("{new_content}");
ExitCode::SUCCESS
}
FileFormatStatus::FailedToParse(parse_error) => {
tracing::error!("Failed to parse input: {}", parse_error);
ExitCode::from(EXIT_CODE_ERROR)
}
};
return Ok(exit_code);
}
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}", 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 create_file_from_stdin() -> Result<File, Error> {
let mut content = String::new();
std::io::stdin().read_to_string(&mut content).map_err(|e| Error::Database(DatabaseError::IOError(e)))?;
Ok(File::ephemeral(Cow::Borrowed("<stdin>"), Cow::Owned(content)))
}
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)
}