use super::{Context, Diagnostic, Rule, RuleName};
use crate::config::Level;
use crate::domain::workflow_actions::{JobId, StepIndex};
use crate::domain::workflow_parsed::{Defaults, Parsed, effective_shell};
use crate::infra::shellcheck::{
Availability, Finding, Severity, Sh, ShellChecker, sanitize_expressions,
};
pub struct RunShellcheckRule {
availability: Availability,
}
impl RunShellcheckRule {
#[must_use]
pub fn new() -> Self {
Self {
availability: Availability::probe(),
}
}
}
impl Default for RunShellcheckRule {
fn default() -> Self {
Self::new()
}
}
impl Rule for RunShellcheckRule {
fn name(&self) -> RuleName {
RuleName::RunShellcheck
}
fn default_level(&self) -> Level {
Level::Warn
}
fn check(&self, ctx: &Context) -> Vec<Diagnostic> {
match &self.availability {
Availability::Absent => vec![skip_diagnostic()],
Availability::Present(cli) => check_workflows(cli, ctx.workflows_full),
}
}
}
fn skip_diagnostic() -> Diagnostic {
Diagnostic::new(
RuleName::RunShellcheck,
Level::Warn,
"run-shellcheck skipped: `shellcheck` binary not found on PATH",
)
}
fn check_workflows(checker: &dyn ShellChecker, workflows: &[Parsed]) -> Vec<Diagnostic> {
let mut out = Vec::new();
for wf in workflows {
for job in &wf.jobs {
for (index, step) in job.steps.iter().enumerate() {
let Some(body) = &step.run else { continue };
let Some(shell) = resolve_shell(
step.shell.as_deref(),
job.defaults.as_ref(),
wf.defaults.as_ref(),
) else {
continue; };
let sanitized = sanitize_expressions(body);
for finding in checker.check(&sanitized, shell) {
out.push(finding_to_diagnostic(&finding, wf, &job.id, index));
}
}
}
}
out
}
fn resolve_shell(
step_shell: Option<&str>,
job_defaults: Option<&Defaults>,
workflow_defaults: Option<&Defaults>,
) -> Option<Sh> {
let token = effective_shell(step_shell, job_defaults, workflow_defaults);
Sh::from_token(&token)
}
fn finding_to_diagnostic(
finding: &Finding,
workflow: &Parsed,
job_id: &str,
step_index: usize,
) -> Diagnostic {
let message = format!(
"SC{code} ({severity}) at script line {line}: {msg}",
code = finding.code,
severity = severity_label(finding.severity),
line = finding.line,
msg = finding.message,
);
Diagnostic::new(RuleName::RunShellcheck, Level::Warn, message)
.with_workflow(workflow.path.clone())
.with_job(JobId::from(job_id))
.with_step(StepIndex::from(clamp_step_index(step_index)))
}
fn severity_label(severity: Severity) -> &'static str {
match severity {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Info => "info",
Severity::Style => "style",
}
}
fn clamp_step_index(index: usize) -> u16 {
u16::try_from(index).unwrap_or(u16::MAX)
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
clippy::indexing_slicing,
reason = "tests use unwrap and indexing freely"
)]
mod tests;