use clap::Args;
use serde::Serialize;
use homeboy::component::{self, Component};
use homeboy::error::Error;
use homeboy::git;
use homeboy::module::{self, ModuleRunner};
use homeboy::utils::command::CapturedOutput;
use super::CmdResult;
#[derive(Args)]
pub struct LintArgs {
component: String,
#[arg(long)]
fix: bool,
#[arg(long)]
summary: bool,
#[arg(long)]
file: Option<String>,
#[arg(long)]
glob: Option<String>,
#[arg(long)]
changed_only: bool,
#[arg(long)]
errors_only: bool,
#[arg(long)]
sniffs: Option<String>,
#[arg(long)]
exclude_sniffs: Option<String>,
#[arg(long)]
category: Option<String>,
#[arg(long, value_parser = parse_key_val)]
setting: Vec<(String, String)>,
#[arg(long, hide = true)]
json: bool,
}
#[derive(Serialize)]
pub struct LintOutput {
status: String,
component: String,
#[serde(flatten)]
output: CapturedOutput,
exit_code: i32,
#[serde(skip_serializing_if = "Option::is_none")]
hints: Option<Vec<String>>,
}
fn parse_key_val(s: &str) -> Result<(String, String), String> {
let pos = s
.find('=')
.ok_or_else(|| format!("invalid KEY=value: no `=` found in `{s}`"))?;
Ok((s[..pos].to_string(), s[pos + 1..].to_string()))
}
fn resolve_lint_script(component: &Component) -> homeboy::error::Result<String> {
let modules = component.modules.as_ref().ok_or_else(|| {
Error::validation_invalid_argument(
"component",
format!("Component '{}' has no modules configured", component.id),
None,
None,
)
})?;
let module_id = if modules.contains_key("wordpress") {
"wordpress"
} else {
modules.keys().next().ok_or_else(|| {
Error::validation_invalid_argument(
"component",
format!("Component '{}' has no modules configured", component.id),
None,
None,
)
})?
};
let manifest = module::load_module(module_id)?;
manifest
.lint_script()
.map(|s| s.to_string())
.ok_or_else(|| {
Error::validation_invalid_argument(
"module",
format!(
"Module '{}' does not have lint infrastructure configured (missing lint.module_script)",
module_id
),
None,
None,
)
})
}
pub fn run_json(args: LintArgs) -> CmdResult<LintOutput> {
let component = component::load(&args.component)?;
let script_path = resolve_lint_script(&component)?;
let effective_glob = 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() {
return Ok((
LintOutput {
status: "passed".to_string(),
component: args.component,
output: CapturedOutput::new(
"No files in working tree changes".to_string(),
String::new(),
),
exit_code: 0,
hints: None,
},
0,
));
}
if changed_files.len() == 1 {
Some(changed_files[0].clone())
} else {
Some(format!("{{{}}}", changed_files.join(",")))
}
} else {
args.glob.clone()
};
let output = ModuleRunner::new(&args.component, &script_path)
.settings(&args.setting)
.env_if(args.fix, "HOMEBOY_AUTO_FIX", "1")
.env_if(args.summary, "HOMEBOY_SUMMARY_MODE", "1")
.env_opt("HOMEBOY_LINT_FILE", &args.file)
.env_opt("HOMEBOY_LINT_GLOB", &effective_glob)
.env_if(args.errors_only, "HOMEBOY_ERRORS_ONLY", "1")
.env_opt("HOMEBOY_SNIFFS", &args.sniffs)
.env_opt("HOMEBOY_EXCLUDE_SNIFFS", &args.exclude_sniffs)
.env_opt("HOMEBOY_CATEGORY", &args.category)
.run()?;
let status = if output.success { "passed" } else { "failed" };
let mut hints = Vec::new();
if !output.success && !args.fix {
hints.push(format!(
"Run 'homeboy lint {} --fix' to auto-fix formatting issues",
args.component
));
hints.push("Some issues may require manual fixes".to_string());
}
if args.file.is_none() && args.glob.is_none() && !args.changed_only {
hints.push(
"For targeted linting: --file <path>, --glob <pattern>, or --changed-only".to_string(),
);
}
hints.push("Full options: homeboy docs commands/lint".to_string());
let hints = if hints.is_empty() { None } else { Some(hints) };
Ok((
LintOutput {
status: status.to_string(),
component: args.component,
output: output.output,
exit_code: output.exit_code,
hints,
},
output.exit_code,
))
}