use crate::linter::autofix::{apply_fixes_to_file, FixOptions};
use crate::linter::{lint_shell, Diagnostic};
use crate::models::{Error, Result};
use std::path::Path;
pub(crate) fn fix_command(
inputs: &[std::path::PathBuf],
dry_run: bool,
assumptions: bool,
output: Option<&Path>,
chat_model: Option<&Path>,
) -> Result<()> {
if inputs.is_empty() {
return Err(Error::Validation("No input files specified".to_string()));
}
if let Some(model_dir) = chat_model {
return fix_with_chat_model(inputs, model_dir);
}
let mut total_fixed = 0usize;
let mut total_files = 0usize;
for input in inputs {
let result = fix_single_file(input, dry_run, assumptions, output)?;
if result > 0 {
total_files += 1;
}
total_fixed += result;
}
print_summary(total_fixed, total_files, inputs.len(), dry_run);
Ok(())
}
fn fix_with_chat_model(inputs: &[std::path::PathBuf], model_dir: &Path) -> Result<()> {
use super::chat_inference::{chat_generate, format_fix_prompt, SYSTEM_PROMPT};
for input in inputs {
let source = std::fs::read_to_string(input)
.map_err(|e| Error::Validation(format!("Cannot read {}: {e}", input.display())))?;
let lint_result = lint_shell(&source);
let findings_summary: String = lint_result
.diagnostics
.iter()
.map(|d| format!("{} (line {}): {}", d.code, d.span.start_line, d.message))
.collect::<Vec<_>>()
.join("\n");
if findings_summary.is_empty() {
println!(" {}: no issues found", input.display());
continue;
}
let user_message = format_fix_prompt(&source, &findings_summary);
let response = chat_generate(model_dir, SYSTEM_PROMPT, &user_message, 1024)?;
println!("--- {} ---", input.display());
println!("{response}");
}
Ok(())
}
fn fix_single_file(
input: &Path,
dry_run: bool,
assumptions: bool,
output: Option<&Path>,
) -> Result<usize> {
let source = std::fs::read_to_string(input)
.map_err(|e| Error::Validation(format!("Cannot read {}: {e}", input.display())))?;
let lint_result = lint_shell(&source);
let fixable = count_fixable(&lint_result.diagnostics, assumptions);
if fixable == 0 {
if !dry_run {
println!(" {}: no fixable issues", input.display());
}
return Ok(0);
}
let options = FixOptions {
create_backup: !dry_run,
dry_run,
backup_suffix: ".bak".to_string(),
apply_assumptions: assumptions,
output_path: output.map(|p| p.to_path_buf()),
};
let fix_result = apply_fixes_to_file(input, &lint_result, &options).map_err(Error::Io)?;
print_file_result(input, &fix_result, dry_run);
Ok(fix_result.fixes_applied)
}
fn count_fixable(diagnostics: &[Diagnostic], assumptions: bool) -> usize {
diagnostics
.iter()
.filter(|d| {
d.fix.as_ref().is_some_and(|f| {
use crate::linter::FixSafetyLevel;
matches!(f.safety_level, FixSafetyLevel::Safe)
|| (assumptions
&& matches!(f.safety_level, FixSafetyLevel::SafeWithAssumptions))
})
})
.count()
}
fn print_file_result(input: &Path, result: &crate::linter::autofix::FixResult, dry_run: bool) {
let action = if dry_run { "would fix" } else { "fixed" };
println!(
" {}: {action} {} issue{}",
input.display(),
result.fixes_applied,
if result.fixes_applied == 1 { "" } else { "s" }
);
if let Some(ref backup) = result.backup_path {
println!(" backup: {backup}");
}
}
fn print_summary(total_fixed: usize, files_changed: usize, total_files: usize, dry_run: bool) {
if dry_run {
println!(
"\nDry run: {total_fixed} fix{} would be applied across {files_changed}/{total_files} file{}.",
if total_fixed == 1 { "" } else { "es" },
if total_files == 1 { "" } else { "s" }
);
} else if total_fixed > 0 {
println!(
"\nApplied {total_fixed} fix{} across {files_changed} file{}.",
if total_fixed == 1 { "" } else { "es" },
if files_changed == 1 { "" } else { "s" }
);
} else {
println!("\nNo fixable issues found.");
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_temp_script(content: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().expect("create temp file");
f.write_all(content.as_bytes()).expect("write temp file");
f.flush().expect("flush temp file");
f
}
#[test]
fn test_fix_no_issues() {
let f = write_temp_script("#!/bin/sh\necho \"hello\"\n");
let result = fix_single_file(f.path(), true, false, None);
assert!(result.is_ok());
assert_eq!(result.expect("should succeed"), 0);
}
#[test]
fn test_fix_dry_run_does_not_modify() {
let f = write_temp_script("#!/bin/sh\necho $VAR\n");
let original = std::fs::read_to_string(f.path()).expect("read");
let _ = fix_single_file(f.path(), true, false, None);
let after = std::fs::read_to_string(f.path()).expect("read after");
assert_eq!(original, after, "dry run should not modify file");
}
#[test]
fn test_fix_applies_safe_fixes() {
let f = write_temp_script("#!/bin/sh\nmkdir /tmp/testdir\n");
let result = fix_single_file(f.path(), false, true, None);
assert!(result.is_ok());
let fixed = std::fs::read_to_string(f.path()).expect("read fixed");
if result.expect("should succeed") > 0 {
assert!(fixed.contains("-p"), "should contain -p flag after fix");
}
}
#[test]
fn test_fix_command_empty_inputs() {
let result = fix_command(&[], false, false, None, None);
assert!(result.is_err());
}
#[test]
fn test_count_fixable_no_fixes() {
let diagnostics = vec![crate::linter::Diagnostic::new(
"SEC001",
crate::linter::Severity::Warning,
"test",
crate::linter::Span::new(1, 1, 1, 5),
)];
assert_eq!(count_fixable(&diagnostics, false), 0);
}
#[test]
fn test_count_fixable_with_safe_fix() {
let diag = crate::linter::Diagnostic::new(
"IDEM001",
crate::linter::Severity::Warning,
"test",
crate::linter::Span::new(1, 1, 1, 5),
)
.with_fix(crate::linter::Fix::new_with_assumptions(
"mkdir -p /tmp/test",
vec!["Directory does not require special permissions".to_string()],
));
let diagnostics = vec![diag];
assert_eq!(count_fixable(&diagnostics, false), 0);
assert_eq!(count_fixable(&diagnostics, true), 1);
}
}