use crate::component::Component;
use crate::engine::temp;
use crate::extension::lint::baseline::{self as lint_baseline, LintFinding};
use crate::extension::lint::build_lint_runner;
use crate::git;
use crate::refactor::{
auto::{self, AutofixMode},
run_lint_refactor, AppliedRefactor, LintSourceOptions,
};
use serde::Serialize;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct LintRunWorkflowArgs {
pub component_label: String,
pub component_id: String,
pub path_override: Option<String>,
pub settings: Vec<(String, String)>,
pub summary: bool,
pub file: Option<String>,
pub glob: Option<String>,
pub changed_only: bool,
pub changed_since: Option<String>,
pub errors_only: bool,
pub sniffs: Option<String>,
pub exclude_sniffs: Option<String>,
pub category: Option<String>,
pub fix: bool,
pub baseline: bool,
pub ignore_baseline: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct LintRunWorkflowResult {
pub status: String,
pub component: String,
pub exit_code: i32,
pub autofix: Option<AppliedRefactor>,
pub hints: Option<Vec<String>>,
pub baseline_comparison: Option<lint_baseline::BaselineComparison>,
pub lint_findings: Option<Vec<LintFinding>>,
}
pub fn run_main_lint_workflow(
component: &Component,
source_path: &PathBuf,
args: LintRunWorkflowArgs,
) -> crate::Result<LintRunWorkflowResult> {
let effective_glob = resolve_effective_glob(component, &args)?;
if let Some(ref glob_val) = effective_glob {
if glob_val.is_empty() {
return Ok(LintRunWorkflowResult {
status: "passed".to_string(),
component: args.component_label,
exit_code: 0,
autofix: None,
hints: None,
baseline_comparison: None,
lint_findings: None,
});
}
}
let planned_autofix = if args.fix {
Some(plan_autofix(
component,
source_path,
&args,
effective_glob.as_deref(),
)?)
} else {
None
};
let lint_findings_file = temp::runtime_temp_file("homeboy-lint-findings", ".json")?;
let findings_file_str = lint_findings_file.to_string_lossy().to_string();
let output = build_lint_runner(
component,
args.path_override.clone(),
&args.settings,
args.summary,
args.file.as_deref(),
effective_glob.as_deref(),
args.errors_only,
args.sniffs.as_deref(),
args.exclude_sniffs.as_deref(),
args.category.as_deref(),
&findings_file_str,
)?
.run()?;
let lint_findings = lint_baseline::parse_findings_file(&lint_findings_file)?;
let _ = std::fs::remove_file(&lint_findings_file);
let mut status = if output.success { "passed" } else { "failed" }.to_string();
let autofix = planned_autofix
.as_ref()
.map(|(plan, outcome)| AppliedRefactor::from_plan(plan, outcome.rerun_recommended));
let mut hints = Vec::new();
if let Some((plan, outcome)) = &planned_autofix {
if output.success && outcome.status == "auto_fixed" {
status = outcome.status.clone();
}
hints.extend(outcome.hints.clone());
if plan.files_modified == 0 && output.success {
status = "passed".to_string();
}
}
let (baseline_comparison, baseline_exit_override) =
process_baseline(source_path, &args, &lint_findings)?;
if !output.success && !args.fix {
hints.push(format!(
"Run 'homeboy lint {} --fix' to auto-fix formatting issues",
args.component_label
));
hints.push("Some issues may require manual fixes".to_string());
}
if args.file.is_none()
&& args.glob.is_none()
&& !args.changed_only
&& args.changed_since.is_none()
{
hints.push(
"For targeted linting: --file <path>, --glob <pattern>, --changed-only, or --changed-since <ref>".to_string(),
);
}
hints.push("Full options: homeboy docs commands/lint".to_string());
if !args.baseline && baseline_comparison.is_none() {
hints.push(format!(
"Save lint baseline: homeboy lint {} --baseline",
args.component_label
));
}
let hints = if hints.is_empty() { None } else { Some(hints) };
let exit_code = baseline_exit_override.unwrap_or(output.exit_code);
if exit_code != output.exit_code {
status = "failed".to_string();
}
Ok(LintRunWorkflowResult {
status,
component: args.component_label,
exit_code,
autofix,
hints,
baseline_comparison,
lint_findings: Some(lint_findings),
})
}
fn resolve_effective_glob(
component: &Component,
args: &LintRunWorkflowArgs,
) -> crate::Result<Option<String>> {
if args.changed_only {
let uncommitted = git::get_uncommitted_changes(&component.local_path)?;
let mut changed_files: Vec<String> = Vec::new();
changed_files.extend(uncommitted.staged);
changed_files.extend(uncommitted.unstaged);
changed_files.extend(uncommitted.untracked);
if changed_files.is_empty() {
println!("No files in working tree changes");
return Ok(Some(String::new()));
}
let abs_files: Vec<String> = changed_files
.iter()
.map(|f| format!("{}/{}", component.local_path, f))
.collect();
if abs_files.len() == 1 {
Ok(Some(abs_files[0].clone()))
} else {
Ok(Some(format!("{{{}}}", abs_files.join(","))))
}
} else if let Some(ref git_ref) = args.changed_since {
let changed_files = git::get_files_changed_since(&component.local_path, git_ref)?;
if changed_files.is_empty() {
println!("No files changed since {}", git_ref);
return Ok(Some(String::new()));
}
let abs_files: Vec<String> = changed_files
.iter()
.map(|f| format!("{}/{}", component.local_path, f))
.collect();
if abs_files.len() == 1 {
Ok(Some(abs_files[0].clone()))
} else {
Ok(Some(format!("{{{}}}", abs_files.join(","))))
}
} else {
Ok(args.glob.clone())
}
}
fn plan_autofix(
component: &Component,
source_path: &PathBuf,
args: &LintRunWorkflowArgs,
effective_glob: Option<&str>,
) -> crate::Result<(crate::refactor::RefactorPlan, auto::AutofixOutcome)> {
let changed_files = if args.changed_only {
let uncommitted = git::get_uncommitted_changes(&component.local_path)?;
let mut changed_files: Vec<String> = Vec::new();
changed_files.extend(uncommitted.staged);
changed_files.extend(uncommitted.unstaged);
changed_files.extend(uncommitted.untracked);
Some(changed_files)
} else if let Some(ref git_ref) = args.changed_since {
Some(git::get_files_changed_since(
&component.local_path,
git_ref,
)?)
} else {
None
};
let plan = run_lint_refactor(
component.clone(),
source_path.clone(),
args.settings.clone(),
LintSourceOptions {
selected_files: changed_files,
file: args.file.clone(),
glob: effective_glob.map(String::from),
errors_only: args.errors_only,
sniffs: args.sniffs.clone(),
exclude_sniffs: args.exclude_sniffs.clone(),
category: args.category.clone(),
},
true,
)?;
let outcome = auto::standard_outcome(
AutofixMode::Write,
plan.files_modified,
Some(format!("homeboy test {} --analyze", args.component_label)),
plan.hints.clone(),
);
Ok((plan, outcome))
}
fn process_baseline(
source_path: &PathBuf,
args: &LintRunWorkflowArgs,
lint_findings: &[LintFinding],
) -> crate::Result<(Option<lint_baseline::BaselineComparison>, Option<i32>)> {
let mut baseline_comparison = None;
let mut baseline_exit_override = None;
if args.baseline {
let saved = lint_baseline::save_baseline(source_path, &args.component_id, lint_findings)?;
eprintln!(
"[lint] Baseline saved to {} ({} findings)",
saved.display(),
lint_findings.len()
);
}
if !args.baseline && !args.ignore_baseline {
if let Some(existing) = lint_baseline::load_baseline(source_path) {
let comparison = lint_baseline::compare(lint_findings, &existing);
if comparison.drift_increased {
eprintln!(
"[lint] DRIFT INCREASED: {} new finding(s) since baseline",
comparison.new_items.len()
);
baseline_exit_override = Some(1);
} else if !comparison.resolved_fingerprints.is_empty() {
eprintln!(
"[lint] Drift reduced: {} finding(s) resolved since baseline",
comparison.resolved_fingerprints.len()
);
} else {
eprintln!("[lint] No change from baseline");
}
baseline_comparison = Some(comparison);
}
}
Ok((baseline_comparison, baseline_exit_override))
}