use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::error::{Error, Result};
use crate::extension;
#[derive(Debug, Clone, Serialize)]
pub struct FormatResult {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<String>,
pub files_in_scope: usize,
}
impl FormatResult {
fn skipped(files_in_scope: usize) -> Self {
Self {
success: true,
command: None,
output: None,
files_in_scope,
}
}
fn passed(command: String, files_in_scope: usize) -> Self {
Self {
success: true,
command: Some(command),
output: None,
files_in_scope,
}
}
fn failed(command: String, output: String, files_in_scope: usize) -> Self {
Self {
success: false,
command: Some(command),
output: Some(output),
files_in_scope,
}
}
}
pub fn format_after_write(root: &Path, changed_files: &[PathBuf]) -> Result<FormatResult> {
if changed_files.is_empty() {
return Ok(FormatResult::skipped(0));
}
let format_command = match resolve_format_command(root, changed_files) {
Some(cmd) => cmd,
None => return Ok(FormatResult::skipped(changed_files.len())),
};
crate::log_status!("format", "Running post-write formatter: {}", format_command);
let output = std::process::Command::new("sh")
.args(["-c", &format_command])
.current_dir(root)
.output()
.map_err(|e| {
Error::internal_io(
format!("Failed to run format command: {}", e),
Some("format_after_write".to_string()),
)
})?;
if output.status.success() {
crate::log_status!("format", "Formatting complete");
return Ok(FormatResult::passed(format_command, changed_files.len()));
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let error_output = if stderr.trim().is_empty() {
stdout.trim().to_string()
} else {
stderr.trim().to_string()
};
crate::log_status!(
"format",
"Warning: formatter exited non-zero (continuing anyway)"
);
Ok(FormatResult::failed(
format_command,
error_output,
changed_files.len(),
))
}
fn resolve_format_command(root: &Path, changed_files: &[PathBuf]) -> Option<String> {
let extensions: Vec<String> = changed_files
.iter()
.filter_map(|f| {
f.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_string())
})
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
for ext in &extensions {
if let Some(manifest) = find_extension_with_format(ext) {
let ext_path = manifest.extension_path.as_deref()?;
let script_rel = manifest.format_script()?;
let script_path = std::path::Path::new(ext_path).join(script_rel);
if script_path.exists() {
return Some(format!(
"sh {}",
crate::engine::shell::quote_path(&script_path.to_string_lossy())
));
}
}
}
resolve_builtin_format_command(root)
}
fn find_extension_with_format(file_ext: &str) -> Option<extension::ExtensionManifest> {
extension::load_all_extensions().ok().and_then(|manifests| {
manifests
.into_iter()
.find(|m| m.handles_file_extension(file_ext) && m.format_script().is_some())
})
}
fn resolve_builtin_format_command(root: &Path) -> Option<String> {
if root.join("Cargo.toml").exists() {
return Some("cargo fmt 2>&1".to_string());
}
if root.join("tsconfig.json").exists() || root.join("package.json").exists() {
if root.join("node_modules/.bin/prettier").exists() {
return Some("npx prettier --write . 2>&1".to_string());
}
}
if root.join("go.mod").exists() {
return Some("gofmt -w . 2>&1".to_string());
}
if root.join("composer.json").exists() && root.join("vendor/bin/phpcbf").exists() {
return Some("vendor/bin/phpcbf 2>&1".to_string());
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn resolve_builtin_for_rust_project() {
let dir = TempDir::new().expect("temp dir");
fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
let result = resolve_builtin_format_command(dir.path());
assert_eq!(result, Some("cargo fmt 2>&1".to_string()));
}
#[test]
fn resolve_builtin_for_go_project() {
let dir = TempDir::new().expect("temp dir");
fs::write(dir.path().join("go.mod"), "module test").unwrap();
let result = resolve_builtin_format_command(dir.path());
assert_eq!(result, Some("gofmt -w . 2>&1".to_string()));
}
#[test]
fn resolve_builtin_returns_none_for_unknown() {
let dir = TempDir::new().expect("temp dir");
let result = resolve_builtin_format_command(dir.path());
assert!(result.is_none());
}
#[test]
fn skipped_when_no_files() {
let dir = TempDir::new().expect("temp dir");
let result = format_after_write(dir.path(), &[]).unwrap();
assert!(result.success);
assert!(result.command.is_none());
assert_eq!(result.files_in_scope, 0);
}
#[test]
fn skipped_when_no_formatter_found() {
let dir = TempDir::new().expect("temp dir");
let files = vec![dir.path().join("unknown.xyz")];
let result = format_after_write(dir.path(), &files).unwrap();
assert!(result.success);
assert!(result.command.is_none());
}
}