use clap::Parser;
use clap_complete::{generate, Shell};
use pipechecker::{
audit_file, discover_workflows, load_config, load_from, rule_codes, AuditOptions,
DiscoveryOptions,
};
use std::{fs, io, path::Path, process, time::Instant};
fn init_from_template(template: Option<String>, force: bool) {
let tmpl = template.unwrap_or_else(|| {
eprintln!("Please specify a template: --init --template <node|rust|docker|gitlab-node|python|go|java|circleci>");
process::exit(1);
});
let templates: &[(&str, &str)] = &[
("node", include_str!("../templates/node.yml")),
("rust", include_str!("../templates/rust.yml")),
("docker", include_str!("../templates/docker.yml")),
("gitlab-node", include_str!("../templates/gitlab-node.yml")),
("python", include_str!("../templates/python.yml")),
("go", include_str!("../templates/go.yml")),
("java", include_str!("../templates/java.yml")),
("circleci", include_str!("../templates/circleci.yml")),
];
let (name, content) = templates
.iter()
.find(|(n, _)| *n == tmpl)
.map(|(n, c)| (*n, *c))
.unwrap_or_else(|| {
eprintln!("Unknown template: {}", tmpl);
eprintln!("Available: node, rust, docker, gitlab-node, python, go, java, circleci");
process::exit(1);
});
let dest = if name == "gitlab-node" {
Path::new(".gitlab-ci.yml").to_path_buf()
} else if name == "circleci" {
Path::new(".circleci/config.yml").to_path_buf()
} else {
Path::new(".github/workflows").join(format!("{}.yml", name))
};
if dest.exists() && !force {
eprintln!(
"File {} already exists. Use --force to overwrite.",
dest.display()
);
process::exit(1);
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).ok();
}
fs::write(&dest, content).expect("Failed to write template file");
println!("✅ Created {} from template '{}'", dest.display(), name);
println!(" Run 'pipechecker {}' to validate", dest.display());
}
fn auto_detect_workflow() -> String {
let files = discover_workflows(Path::new("."), &DiscoveryOptions::default());
let common_patterns = [
".github/workflows/ci.yml",
".github/workflows/main.yml",
".github/workflows/build.yml",
".gitlab-ci.yml",
".circleci/config.yml",
];
for pattern in &common_patterns {
if files.iter().any(|f| f == pattern) {
eprintln!("✓ Auto-detected: {}", pattern);
return pattern.to_string();
}
}
if let Some(first) = files.first() {
eprintln!("✓ Auto-detected: {}", first);
return first.clone();
}
eprintln!("❌ No workflow files found. Please specify a file:");
eprintln!(" pipechecker <FILE>");
eprintln!("\nSearched for:");
eprintln!(" - .github/workflows/*.yml");
eprintln!(" - .gitlab-ci.yml");
eprintln!(" - .circleci/config.yml");
process::exit(1)
}
fn get_changed_workflows(base_branch: &str) -> Vec<String> {
let output = std::process::Command::new("git")
.args(["diff", "--name-only", &format!("{}...", base_branch)])
.output();
match output {
Ok(output) if output.status.success() => String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|f| {
f.contains(".github/workflows")
|| f.contains(".gitlab-ci")
|| f.contains(".circleci")
})
.filter(|f| f.ends_with(".yml") || f.ends_with(".yaml"))
.map(String::from)
.collect(),
_ => Vec::new(),
}
}
fn load_config_rules(cli: &Cli) -> pipechecker::Rules {
let mut rules = if let Some(ref config_path) = cli.config {
load_from(config_path).rules
} else {
load_config().rules
};
if cli.no_permissions {
rules.permissions_check = false;
}
if cli.no_schema {
rules.schema_validation = false;
}
rules
}
fn format_sarif(result: &pipechecker::AuditResult, file_path: &str) -> String {
let sarif_results: Vec<serde_json::Value> = result
.issues
.iter()
.map(|issue| {
let rule_id = issue.rule_code.unwrap_or("PC000");
let level = match issue.severity {
pipechecker::Severity::Error => "error",
pipechecker::Severity::Warning => "warning",
pipechecker::Severity::Info => "note",
};
let mut location_obj = serde_json::json!({
"physicalLocation": {
"artifactLocation": {
"uri": file_path,
"uriBaseId": "%SRCROOT%"
}
}
});
if let Some(loc) = &issue.location {
if loc.line > 0 {
location_obj["physicalLocation"]["region"] = serde_json::json!({
"startLine": loc.line,
"startColumn": loc.column,
});
}
}
let mut result_obj = serde_json::json!({
"ruleId": rule_id,
"level": level,
"message": {
"text": issue.message
},
"locations": [location_obj],
});
if let Some(suggestion) = &issue.suggestion {
result_obj["fixes"] = serde_json::json!([{
"description": { "text": suggestion },
}]);
}
result_obj
})
.collect();
let sarif = serde_json::json!({
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
"version": "2.1.0",
"runs": [{
"tool": {
"driver": {
"name": "pipechecker",
"version": env!("CARGO_PKG_VERSION"),
"rules": []
}
},
"results": sarif_results
}]
});
serde_json::to_string_pretty(&sarif).unwrap()
}
fn print_issue(issue: &pipechecker::Issue, quiet: bool) {
if quiet && issue.severity != pipechecker::Severity::Error {
return;
}
let prefix = match issue.severity {
pipechecker::Severity::Error => "\u{274c} ERROR",
pipechecker::Severity::Warning => "\u{26a0}\u{fe0f} WARNING",
pipechecker::Severity::Info => "\u{2139}\u{fe0f} INFO",
};
print!("{}: {}", prefix, issue.message);
if let Some(loc) = &issue.location {
if let Some(job) = &loc.job {
print!(" (job: {})", job);
}
if loc.line > 0 {
print!(" [line {}]", loc.line);
}
}
if let Some(code) = issue.rule_code {
print!(" [{}]", code);
}
println!();
if let Some(suggestion) = &issue.suggestion {
println!(" \u{1f4a1} {}", suggestion);
}
println!();
}
fn explain_rule(code: &str) {
let explanations: &[(&str, &str, &str)] = &[
(
rule_codes::EMPTY_PIPELINE,
"Pipeline has no jobs defined",
"Every CI/CD pipeline must define at least one job. An empty pipeline \
will fail immediately when triggered. Add a 'jobs:' block with at \
least one job entry.",
),
(
rule_codes::DUPLICATE_JOB_ID,
"Duplicate job ID",
"Each job in a pipeline must have a unique identifier. Duplicate IDs \
cause ambiguous dependency references and will be rejected by most CI \
providers. Rename one of the duplicate jobs.",
),
(
rule_codes::EMPTY_JOB_STEPS,
"Job has no steps",
"A job with no steps does no work. This is usually a mistake — either \
you forgot to add steps, or you should remove the job entirely.",
),
(
rule_codes::MISSING_DEPENDENCY,
"Job depends on non-existent job",
"A job listed in 'needs:' does not exist. This will cause the pipeline \
to fail at startup. Either add the missing job or remove the dependency.",
),
(
rule_codes::CIRCULAR_DEPENDENCY,
"Circular dependency detected",
"Two or more jobs form a dependency cycle (A needs B, B needs A). \
The pipeline can never start because each job is waiting for another. \
Remove one of the edges in the cycle to break it.",
),
(
rule_codes::HARDCODED_SECRET,
"Hardcoded secret in env var",
"An environment variable name or value looks like it contains a secret \
(API key, token, password). Hardcoded secrets are committed to version \
control and are a serious security risk. Use '${{ secrets.YOUR_SECRET }}' \
instead and store the value in your repository's secret settings.",
),
(
rule_codes::SECRET_REFERENCE,
"Secret reference in run block",
"Informational: a step references a secret via '${{ secrets.X }}'. \
Ensure the secret is configured in your repository or organisation settings.",
),
(
rule_codes::UNDECLARED_ENV_VAR,
"Undeclared environment variable referenced",
"A step references '${{ env.X }}' but X is not declared in any 'env:' \
block at the pipeline, job, or step level. This will expand to an empty \
string at runtime, which is almost never intentional. Declare the variable \
in an appropriate 'env:' block.",
),
(
rule_codes::UNPINNED_ACTION,
"Unpinned GitHub Action",
"An action is referenced without a version tag (e.g. 'uses: actions/checkout'). \
Without a pin, the action can change silently on the next run, breaking \
your pipeline or introducing security issues. Add '@v4' (or a specific SHA) \
to pin the version.",
),
(
rule_codes::DOCKER_LATEST_TAG,
"Docker image uses :latest tag",
"Using ':latest' makes builds non-reproducible — the image can change on \
any push. Pin to a specific version tag (e.g. 'node:20-alpine') to ensure \
consistent behaviour. Run 'pipechecker --fix' to apply known pinned versions \
automatically.",
),
(
rule_codes::MISSING_TIMEOUT,
"Job has no timeout",
"A job without a timeout can run indefinitely if it hangs, wasting CI \
minutes and blocking other runs. Add 'timeout-minutes: 30' (or an \
appropriate value) to the job definition.",
),
(
rule_codes::MISSING_TRIGGER,
"Workflow missing 'on' trigger",
"A GitHub Actions workflow without an 'on:' block will never be triggered \
automatically. Add at least one trigger such as 'on: push' or \
'on: [push, pull_request]'.",
),
(
rule_codes::MISSING_RUNS_ON,
"Job missing 'runs-on'",
"GitHub Actions requires every job to specify where it runs via 'runs-on:' \
(e.g. 'runs-on: ubuntu-latest'). Without this field the workflow is invalid.",
),
(
rule_codes::MISSING_PERMISSIONS,
"Missing permissions declaration",
"A GitHub Actions job without an explicit 'permissions:' block inherits \
the repository-level GITHUB_TOKEN permissions, which may be 'write-all' \
by default. This violates the principle of least privilege and can allow \
a compromised action to write to your repository. Add a 'permissions:' \
block to each job (or at the top level) to restrict access. \
See: https://docs.github.com/en/actions/security-guides/automatic-token-authentication",
),
(
rule_codes::INVALID_YAML,
"Invalid YAML syntax",
"The file is not valid YAML. Fix syntax errors (unclosed brackets, bad \
indentation, reserved characters) before running other checks. \
Tools like 'yamllint' can help locate the exact problem.",
),
(
rule_codes::CONCURRENCY_CANCEL_MISSING,
"Concurrency group missing 'cancel-in-progress'",
"When using 'concurrency:' to limit concurrent workflow runs, it is highly \
recommended to set 'cancel-in-progress: true'. Without it, new runs will \
queue up rather than cancelling outdated runs, wasting CI minutes.",
),
(
rule_codes::CACHE_STATIC_KEY,
"Cache action uses static key without 'hashFiles'",
"Using actions/cache with a static string key means the cache is never \
automatically invalidated when your dependencies change. Include \
'${{ hashFiles(...) }}' in the key to ensure fresh caches.",
),
(
rule_codes::ARTIFACT_NO_RETENTION,
"Upload-artifact missing 'retention-days'",
"The actions/upload-artifact action retains artifacts for 90 days by default. \
This quickly consumes repository storage quotas. Add 'retention-days: 7' \
(or another low number) to save space.",
),
(
rule_codes::DEPRECATED_ACTION,
"Deprecated GitHub Actions feature",
"This workflow uses a deprecated GitHub Actions feature. 'set-output' and \
'save-state' commands are deprecated — use '$GITHUB_OUTPUT' and \
'$GITHUB_STATE' environment files instead. Node.js 12 and 16 are \
end-of-life — upgrade to node:20 or later. Old action versions (@v1, @v2) \
may have security vulnerabilities.",
),
(
rule_codes::EXCESSIVE_TIMEOUT,
"Excessive job timeout",
"This job has a timeout exceeding 60 minutes. Excessive timeouts waste CI \
minutes and may indicate a configuration problem. Consider reducing the \
timeout or splitting the job into smaller units.",
),
(
rule_codes::MISSING_CONCURRENCY,
"Missing concurrency group",
"This workflow has no 'concurrency:' group. Without concurrency control, \
multiple runs of the same workflow can execute simultaneously, wasting CI \
minutes. Add a concurrency group with 'cancel-in-progress: true' to \
automatically cancel outdated runs.",
),
(
rule_codes::LARGE_MATRIX_NO_FAILFAST,
"Large matrix without fail-fast",
"This matrix strategy has more than 9 combinations without 'fail-fast: false'. \
A failing job in a large matrix will cancel all other jobs, potentially hiding \
useful test results. Consider adding 'fail-fast: false' for large matrices.",
),
(
rule_codes::PR_TARGET_INSECURE,
"Insecure pull_request_target usage",
"This workflow uses 'pull_request_target' with 'actions/checkout'. This is a \
known security vulnerability pattern — pull_request_target runs with write \
permissions and can leak secrets to attacker-controlled PRs. If you need to \
check out PR code, use 'pull_request' instead, or carefully restrict the \
ref: to a fixed branch.",
),
(
rule_codes::REUSABLE_WORKFLOW_MISSING_INPUTS,
"Reusable workflow missing inputs",
"A reusable workflow call is missing required inputs. Ensure all required \
inputs are passed when calling a reusable workflow via 'uses:'.",
),
];
let upper = code.to_uppercase();
match explanations.iter().find(|(c, _, _)| *c == upper.as_str()) {
Some((c, title, detail)) => {
println!("\n\u{1f4d6} Rule {} — {}\n", c, title);
println!("{}", detail);
println!();
}
None => {
eprintln!("Unknown rule code: '{}'", code);
eprintln!("Known codes: PC001-PC024");
process::exit(1);
}
}
}
fn run_audits_on_files(files: &[String], options: AuditOptions, quiet: bool, strict: bool) -> bool {
let mut has_error = false;
for file in files {
match audit_file(file, options) {
Ok(result) => {
let file_has_errors = result
.issues
.iter()
.any(|i| i.severity == pipechecker::Severity::Error);
has_error = has_error || file_has_errors;
if file_has_errors || (strict && !result.issues.is_empty()) {
for issue in &result.issues {
print_issue(issue, quiet);
}
}
}
Err(e) => {
eprintln!("Error auditing {}: {}", file, e);
has_error = true;
}
}
}
has_error
}
#[derive(Parser)]
#[command(name = "pipechecker")]
#[command(version)]
#[command(about = "CI/CD Pipeline Auditor - Catch errors before you push", long_about = None)]
struct Cli {
#[arg(value_name = "FILE")]
file: Option<String>,
#[arg(short, long)]
all: bool,
#[arg(long)]
install_hook: bool,
#[arg(short, long)]
watch: bool,
#[arg(long)]
fix: bool,
#[arg(long, requires = "fix")]
fix_dry_run: bool,
#[arg(long)]
tui: bool,
#[arg(short, long, default_value = "text")]
format: String,
#[arg(long)]
no_pinning: bool,
#[arg(short, long)]
strict: bool,
#[arg(short, long)]
quiet: bool,
#[arg(long)]
verbose: bool,
#[arg(long)]
ci: bool,
#[arg(short, long)]
diff: bool,
#[arg(long, default_value = "main")]
diff_branch: String,
#[arg(long)]
init: bool,
#[arg(long, requires = "init")]
template: Option<String>,
#[arg(long, requires = "init")]
force: bool,
#[arg(long)]
no_permissions: bool,
#[arg(long)]
no_schema: bool,
#[arg(long, value_name = "CODE")]
explain: Option<String>,
#[arg(long, value_name = "PATH")]
config: Option<String>,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(clap::Subcommand)]
enum Commands {
Completions {
#[arg(short, long)]
shell: Shell,
},
}
impl Cli {
fn effective_quiet(&self) -> bool {
self.quiet || self.ci
}
fn effective_strict(&self) -> bool {
self.strict || self.ci
}
fn effective_format(&self) -> &str {
if self.ci {
"json"
} else {
&self.format
}
}
}
fn main() {
let cli = Cli::parse();
if let Some(Commands::Completions { shell }) = cli.command {
let mut cmd = <Cli as clap::CommandFactory>::command();
generate(shell, &mut cmd, "pipechecker", &mut io::stdout());
return;
}
if let Some(ref code) = cli.explain {
explain_rule(code);
return;
}
if cli.init {
init_from_template(cli.template, cli.force);
return;
}
if cli.install_hook {
install_git_hook();
return;
}
if cli.watch {
watch_mode(&cli);
return;
}
if cli.tui {
let rules = load_config_rules(&cli);
let options = AuditOptions {
check_docker_images: !cli.no_pinning,
strict_mode: cli.effective_strict(),
rules: Some(rules),
};
if let Err(e) = pipechecker::tui::run_tui(options) {
eprintln!("TUI error: {}", e);
process::exit(1);
}
return;
}
if cli.fix {
println!("🔧 Auto-fix mode\n");
let file = cli.file.clone().unwrap_or_else(auto_detect_workflow);
match pipechecker::fix::fix_file(&file, cli.fix_dry_run) {
Ok(result) => {
if result.fixed == 0 {
println!("✅ No fixable issues found in {}", file);
println!(" All actions are already pinned or use local references");
} else {
if cli.fix_dry_run {
println!(
"📋 Dry run — {} issue(s) would be fixed in {}:\n",
result.fixed, file
);
} else {
println!("✨ Fixed {} issue(s) in {}:\n", result.fixed, file);
}
for change in &result.changes {
if change.starts_with(" ") {
println!("{}", change);
}
}
if cli.fix_dry_run {
println!("\n💡 Run without --fix-dry-run to apply these changes.");
} else {
println!("\n💡 Review the changes and commit them!");
}
}
}
Err(e) => {
eprintln!("❌ Error fixing {}: {}", file, e);
process::exit(1);
}
}
process::exit(0);
}
let rules = load_config_rules(&cli);
let options = AuditOptions {
check_docker_images: !cli.no_pinning,
strict_mode: cli.effective_strict(),
rules: Some(rules),
};
if cli.diff {
let changed_files = get_changed_workflows(&cli.diff_branch);
if changed_files.is_empty() {
println!("No workflow files changed since {}", cli.diff_branch);
return;
}
println!(
"📁 Checking {} file(s) changed since {}...\n",
changed_files.len(),
cli.diff_branch
);
let has_error = run_audits_on_files(
&changed_files,
options,
cli.effective_quiet(),
cli.effective_strict(),
);
if has_error {
process::exit(1);
}
println!("✅ All changed workflows valid!");
return;
}
if cli.all {
audit_all_workflows(
options,
cli.effective_format(),
cli.effective_strict(),
cli.effective_quiet(),
cli.verbose,
);
return;
}
let file = cli.file.clone().unwrap_or_else(auto_detect_workflow);
if cli.verbose {
eprintln!("📄 Auditing: {}", file);
}
match audit_file(&file, options) {
Ok(result) => {
if cli.verbose {
eprintln!("🔍 Auditors ran: syntax, dag, secrets, timeout, permissions, schema, concurrency, artifacts, pinning");
eprintln!(
"📊 Found: {} errors, {} warnings, {} info",
result
.issues
.iter()
.filter(|i| i.severity == pipechecker::Severity::Error)
.count(),
result
.issues
.iter()
.filter(|i| i.severity == pipechecker::Severity::Warning)
.count(),
result
.issues
.iter()
.filter(|i| i.severity == pipechecker::Severity::Info)
.count(),
);
}
if cli.effective_format() == "json" {
println!("{}", serde_json::to_string_pretty(&result).unwrap());
} else if cli.effective_format() == "sarif" {
println!("{}", format_sarif(&result, &file));
} else {
println!("Provider: {:?}", result.provider);
println!("\n{}", result.summary);
println!();
for issue in &result.issues {
print_issue(issue, cli.effective_quiet());
}
if !cli.effective_quiet() {
println!("⏱️ Checked in {:.1}ms", result.elapsed.as_millis());
}
}
let has_errors = result
.issues
.iter()
.any(|i| i.severity == pipechecker::Severity::Error);
if has_errors || (cli.effective_strict() && !result.issues.is_empty()) {
process::exit(1);
}
}
Err(e) => {
eprintln!("Error: {}", e);
process::exit(1);
}
}
}
fn install_git_hook() {
let hook_path = Path::new(".git/hooks/pre-commit");
if !Path::new(".git").exists() {
eprintln!("❌ Not a git repository");
process::exit(1);
}
let hook_content = r#"#!/bin/bash
# Pipecheck pre-commit hook
echo "🔍 Checking workflows with pipechecker..."
WORKFLOW_FILES=$(git diff --cached --name-only | grep -E '(\.github/workflows|\.gitlab-ci|\.circleci).*\.ya?ml$')
if [ -n "$WORKFLOW_FILES" ]; then
if command -v pipechecker &> /dev/null; then
pipechecker --all --strict
if [ $? -ne 0 ]; then
echo ""
echo "❌ Workflow validation failed!"
echo "Fix errors above or use 'git commit --no-verify' to skip"
exit 1
fi
echo "✅ All workflows valid!"
else
echo "⚠️ pipechecker not installed, skipping"
fi
fi
"#;
if hook_path.exists() {
eprint!("⚠️ Pre-commit hook already exists. Overwrite? (y/N): ");
use std::io::{self, BufRead};
let stdin = io::stdin();
let mut line = String::new();
stdin.lock().read_line(&mut line).unwrap();
if !line.trim().eq_ignore_ascii_case("y") {
eprintln!("Cancelled");
return;
}
}
fs::write(hook_path, hook_content).expect("Failed to write hook");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(hook_path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(hook_path, perms).unwrap();
}
eprintln!("✅ Pre-commit hook installed!");
eprintln!(" Pipecheck will run before every commit");
eprintln!(" Use 'git commit --no-verify' to skip");
}
fn watch_mode(cli: &Cli) {
eprintln!("👀 Watching for workflow changes...");
eprintln!(" Press Ctrl+C to stop\n");
let rules = load_config_rules(cli);
let options = AuditOptions {
check_docker_images: !cli.no_pinning,
strict_mode: cli.effective_strict(),
rules: Some(rules),
};
if cli.diff {
let changed_files = get_changed_workflows(&cli.diff_branch);
if changed_files.is_empty() {
println!("No workflow files changed since {}", cli.diff_branch);
return;
}
println!(
"📁 Checking {} file(s) changed since {}...\n",
changed_files.len(),
cli.diff_branch
);
run_audits_on_files(
&changed_files,
options,
cli.effective_quiet(),
cli.effective_strict(),
);
return;
}
if cli.all {
audit_all_workflows(
options,
cli.effective_format(),
cli.effective_strict(),
cli.effective_quiet(),
cli.verbose,
);
} else if let Some(file) = &cli.file {
let _ = audit_file(file, options);
}
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
use std::sync::mpsc::channel;
let (tx, rx) = channel();
let mut watcher = match RecommendedWatcher::new(tx, Config::default()) {
Ok(w) => w,
Err(e) => {
eprintln!("Failed to initialize file watcher: {}", e);
process::exit(1);
}
};
let watch_path = if let Some(file) = &cli.file {
Path::new(file)
} else {
Path::new(".")
};
if let Err(e) = watcher.watch(watch_path, RecursiveMode::Recursive) {
eprintln!("Failed to watch {}: {}", watch_path.display(), e);
process::exit(1);
}
loop {
match rx.recv() {
Ok(Ok(event)) => {
if matches!(
event.kind,
notify::EventKind::Modify(_) | notify::EventKind::Create(_)
) {
for path in event.paths {
let path_str = path.to_string_lossy();
if (cli.file.as_deref().is_some_and(|f| path_str.ends_with(f))
|| path_str.contains(".github/workflows")
|| path_str.contains(".gitlab-ci")
|| path_str.contains(".circleci"))
&& (path_str.ends_with(".yml") || path_str.ends_with(".yaml"))
{
eprintln!("\n🔄 File changed: {}", path.display());
let opts = AuditOptions {
check_docker_images: !cli.no_pinning,
strict_mode: cli.effective_strict(),
rules: Some(load_config_rules(cli)),
};
let _ = audit_file(&path_str, opts);
}
}
}
}
Ok(Err(e)) => eprintln!("Watch error: {:?}", e),
Err(_) => {
eprintln!("Watch channel closed");
break;
}
}
}
}
fn audit_all_workflows(
options: AuditOptions,
format: &str,
strict: bool,
quiet: bool,
verbose: bool,
) {
let all_files = discover_workflows(Path::new("."), &DiscoveryOptions::default());
if all_files.is_empty() {
eprintln!("❌ No workflow files found");
process::exit(1);
}
if verbose {
eprintln!("📄 Discovered {} workflow file(s)", all_files.len());
for f in &all_files {
eprintln!(" - {}", f);
}
eprintln!();
}
eprintln!("Checking {} workflow file(s)...\n", all_files.len());
let total_start = Instant::now();
let mut total_errors = 0;
let mut total_warnings = 0;
let config = load_config();
for file in &all_files {
if config.should_ignore(file) {
continue;
}
let opts = AuditOptions {
check_docker_images: options.check_docker_images,
strict_mode: options.strict_mode,
rules: options.rules,
};
match audit_file(file, opts) {
Ok(result) => {
if format == "json" {
println!("{}", serde_json::to_string_pretty(&result).unwrap());
} else {
let errors = result
.issues
.iter()
.filter(|i| i.severity == pipechecker::Severity::Error)
.count();
let warnings = result
.issues
.iter()
.filter(|i| i.severity == pipechecker::Severity::Warning)
.count();
total_errors += errors;
total_warnings += warnings;
if quiet {
for issue in &result.issues {
if issue.severity == pipechecker::Severity::Error {
println!("❌ {} (in {})", issue.message, file);
}
}
} else {
println!("📄 {}", file);
println!(" Provider: {:?}", result.provider);
if errors > 0 || warnings > 0 {
println!(" {} errors, {} warnings", errors, warnings);
for issue in &result.issues {
if issue.severity != pipechecker::Severity::Info {
let prefix = match issue.severity {
pipechecker::Severity::Error => "❌",
pipechecker::Severity::Warning => "⚠️",
_ => "ℹ️",
};
println!(" {} {}", prefix, issue.message);
}
}
} else {
println!(" ✅ No issues found");
}
println!();
}
}
}
Err(e) => {
eprintln!("❌ Error checking {}: {}", file, e);
total_errors += 1;
}
}
}
if format != "json" {
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!(
"Total: {} errors, {} warnings across {} files",
total_errors,
total_warnings,
all_files.len()
);
println!("⏱️ Checked in {:.1}ms", total_start.elapsed().as_millis());
}
if total_errors > 0 || (strict && total_warnings > 0) {
process::exit(1);
}
}
#[cfg(test)]
mod tests {
use super::*;
use pipechecker::{Issue, Severity};
fn make_test_result(issues: Vec<Issue>) -> pipechecker::AuditResult {
pipechecker::AuditResult {
provider: pipechecker::models::Provider::GitHubActions,
issues,
summary: "test".to_string(),
elapsed: std::time::Duration::from_millis(0),
}
}
#[test]
fn test_format_sarif_empty() {
let result = make_test_result(vec![]);
let sarif = format_sarif(&result, "ci.yml");
let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
assert_eq!(parsed["version"], "2.1.0");
assert_eq!(parsed["runs"][0]["results"], serde_json::json!([]));
}
#[test]
fn test_format_sarif_with_error() {
let issue = Issue::for_job_with_code(
Severity::Error,
"test error",
"build",
10,
3,
Some("fix it".to_string()),
"PC001",
);
let result = make_test_result(vec![issue]);
let sarif = format_sarif(&result, "ci.yml");
let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
let results = parsed["runs"][0]["results"].as_array().unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0]["ruleId"], "PC001");
assert_eq!(results[0]["level"], "error");
assert_eq!(results[0]["message"]["text"], "test error");
assert_eq!(
results[0]["locations"][0]["physicalLocation"]["region"]["startLine"],
10
);
assert_eq!(
results[0]["locations"][0]["physicalLocation"]["region"]["startColumn"],
3
);
assert_eq!(results[0]["fixes"][0]["description"]["text"], "fix it");
}
#[test]
fn test_format_sarif_warning() {
let issue = Issue::for_job_with_code(
Severity::Warning,
"test warning",
"build",
5,
1,
None,
"PC009",
);
let result = make_test_result(vec![issue]);
let sarif = format_sarif(&result, "test.yml");
let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
let r = &parsed["runs"][0]["results"][0];
assert_eq!(r["level"], "warning");
assert_eq!(r["ruleId"], "PC009");
}
#[test]
fn test_format_sarif_info() {
let issue = Issue::new(Severity::Info, "info message", None);
let result = make_test_result(vec![issue]);
let sarif = format_sarif(&result, "test.yml");
let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
assert_eq!(parsed["runs"][0]["results"][0]["level"], "note");
}
#[test]
fn test_format_sarif_no_location() {
let issue = Issue::with_code(Severity::Error, "no location", None, "PC001");
let result = make_test_result(vec![issue]);
let sarif = format_sarif(&result, "test.yml");
let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
let loc = &parsed["runs"][0]["results"][0]["locations"][0]["physicalLocation"];
assert_eq!(loc["artifactLocation"]["uri"], "test.yml");
assert!(loc.get("region").is_none());
}
#[test]
fn test_format_sarif_no_rule_code() {
let issue = Issue::new(Severity::Error, "no code", None);
let result = make_test_result(vec![issue]);
let sarif = format_sarif(&result, "test.yml");
let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
assert_eq!(parsed["runs"][0]["results"][0]["ruleId"], "PC000");
}
#[test]
fn test_cli_effective_quiet_default() {
let cli = Cli {
file: None,
all: false,
install_hook: false,
watch: false,
fix: false,
fix_dry_run: false,
tui: false,
format: "text".to_string(),
no_pinning: false,
strict: false,
quiet: false,
verbose: false,
ci: false,
diff: false,
diff_branch: "main".to_string(),
init: false,
template: None,
force: false,
no_permissions: false,
no_schema: false,
explain: None,
config: None,
command: None,
};
assert!(!cli.effective_quiet());
assert!(!cli.effective_strict());
assert_eq!(cli.effective_format(), "text");
}
#[test]
fn test_cli_effective_ci_mode() {
let cli = Cli {
file: None,
all: false,
install_hook: false,
watch: false,
fix: false,
fix_dry_run: false,
tui: false,
format: "text".to_string(),
no_pinning: false,
strict: false,
quiet: false,
verbose: false,
ci: true,
diff: false,
diff_branch: "main".to_string(),
init: false,
template: None,
force: false,
no_permissions: false,
no_schema: false,
explain: None,
config: None,
command: None,
};
assert!(cli.effective_quiet());
assert!(cli.effective_strict());
assert_eq!(cli.effective_format(), "json");
}
#[test]
fn test_cli_effective_quiet_flag() {
let cli = Cli {
file: None,
all: false,
install_hook: false,
watch: false,
fix: false,
fix_dry_run: false,
tui: false,
format: "text".to_string(),
no_pinning: false,
strict: false,
quiet: true,
verbose: false,
ci: false,
diff: false,
diff_branch: "main".to_string(),
init: false,
template: None,
force: false,
no_permissions: false,
no_schema: false,
explain: None,
config: None,
command: None,
};
assert!(cli.effective_quiet());
assert!(!cli.effective_strict());
assert_eq!(cli.effective_format(), "text");
}
#[test]
fn test_cli_effective_strict_flag() {
let cli = Cli {
file: None,
all: false,
install_hook: false,
watch: false,
fix: false,
fix_dry_run: false,
tui: false,
format: "sarif".to_string(),
no_pinning: false,
strict: true,
quiet: false,
verbose: false,
ci: false,
diff: false,
diff_branch: "main".to_string(),
init: false,
template: None,
force: false,
no_permissions: false,
no_schema: false,
explain: None,
config: None,
command: None,
};
assert!(!cli.effective_quiet());
assert!(cli.effective_strict());
assert_eq!(cli.effective_format(), "sarif");
}
#[test]
fn test_load_config_rules_defaults() {
let cli = Cli {
file: None,
all: false,
install_hook: false,
watch: false,
fix: false,
fix_dry_run: false,
tui: false,
format: "text".to_string(),
no_pinning: false,
strict: false,
quiet: false,
verbose: false,
ci: false,
diff: false,
diff_branch: "main".to_string(),
init: false,
template: None,
force: false,
no_permissions: false,
no_schema: false,
explain: None,
config: None,
command: None,
};
let rules = load_config_rules(&cli);
assert!(rules.permissions_check);
assert!(rules.schema_validation);
}
#[test]
fn test_load_config_rules_no_permissions() {
let cli = Cli {
file: None,
all: false,
install_hook: false,
watch: false,
fix: false,
fix_dry_run: false,
tui: false,
format: "text".to_string(),
no_pinning: false,
strict: false,
quiet: false,
verbose: false,
ci: false,
diff: false,
diff_branch: "main".to_string(),
init: false,
template: None,
force: false,
no_permissions: true,
no_schema: false,
explain: None,
config: None,
command: None,
};
let rules = load_config_rules(&cli);
assert!(!rules.permissions_check);
assert!(rules.schema_validation);
}
#[test]
fn test_load_config_rules_no_schema() {
let cli = Cli {
file: None,
all: false,
install_hook: false,
watch: false,
fix: false,
fix_dry_run: false,
tui: false,
format: "text".to_string(),
no_pinning: false,
strict: false,
quiet: false,
verbose: false,
ci: false,
diff: false,
diff_branch: "main".to_string(),
init: false,
template: None,
force: false,
no_permissions: false,
no_schema: true,
explain: None,
config: None,
command: None,
};
let rules = load_config_rules(&cli);
assert!(rules.permissions_check);
assert!(!rules.schema_validation);
}
#[test]
fn test_load_config_rules_from_file() {
let dir = std::env::temp_dir().join("pipechecker_test_cli_config");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.yml");
std::fs::write(
&path,
"rules:\n permissions_check: false\n schema_validation: false\n",
)
.unwrap();
let cli = Cli {
file: None,
all: false,
install_hook: false,
watch: false,
fix: false,
fix_dry_run: false,
tui: false,
format: "text".to_string(),
no_pinning: false,
strict: false,
quiet: false,
verbose: false,
ci: false,
diff: false,
diff_branch: "main".to_string(),
init: false,
template: None,
force: false,
no_permissions: false,
no_schema: false,
explain: None,
config: Some(path.to_str().unwrap().to_string()),
command: None,
};
let rules = load_config_rules(&cli);
assert!(!rules.permissions_check);
assert!(!rules.schema_validation);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_print_issue_error() {
let issue = Issue::for_job_with_code(
Severity::Error,
"test error",
"build",
10,
3,
Some("fix it".to_string()),
"PC001",
);
print_issue(&issue, false);
}
#[test]
fn test_print_issue_quiet_skips_non_error() {
let issue = Issue::new(Severity::Warning, "warning", None);
print_issue(&issue, true);
}
#[test]
fn test_print_issue_quiet_shows_error() {
let issue = Issue::new(Severity::Error, "error", None);
print_issue(&issue, true);
}
#[test]
fn test_print_issue_no_location() {
let issue = Issue::new(Severity::Info, "info", None);
print_issue(&issue, false);
}
#[test]
fn test_print_issue_with_location_no_line() {
let issue = Issue {
severity: Severity::Warning,
message: "test".to_string(),
location: Some(pipechecker::models::Location {
line: 0,
column: 0,
job: None,
}),
suggestion: None,
rule_code: None,
};
print_issue(&issue, false);
}
#[test]
fn test_explain_all_rule_codes() {
let codes = [
"PC001", "PC002", "PC003", "PC004", "PC005", "PC006", "PC007", "PC008", "PC009",
"PC010", "PC011", "PC012", "PC013", "PC014", "PC015", "PC016", "PC017", "PC018",
"PC019", "PC020", "PC021", "PC022", "PC023", "PC024",
];
for code in &codes {
explain_rule(code);
}
}
#[test]
fn test_explain_rule_case_insensitive() {
explain_rule("pc001");
}
#[test]
fn test_run_audits_on_files_valid() {
let files = vec!["tests/fixtures/github/valid.yml".to_string()];
let options = AuditOptions::default();
let has_error = run_audits_on_files(&files, options, false, false);
assert!(!has_error);
}
#[test]
fn test_run_audits_on_files_nonexistent() {
let files = vec!["nonexistent.yml".to_string()];
let options = AuditOptions::default();
let has_error = run_audits_on_files(&files, options, false, false);
assert!(has_error);
}
#[test]
fn test_run_audits_on_files_empty() {
let files = vec![];
let options = AuditOptions::default();
let has_error = run_audits_on_files(&files, options, false, false);
assert!(!has_error);
}
#[test]
fn test_run_audits_on_files_strict_with_warnings() {
let files = vec!["tests/fixtures/github/valid.yml".to_string()];
let options = AuditOptions::default();
let has_error = run_audits_on_files(&files, options, false, true);
let _ = has_error;
}
#[test]
fn test_run_audits_on_files_quiet() {
let files = vec!["tests/fixtures/github/valid.yml".to_string()];
let options = AuditOptions::default();
let has_error = run_audits_on_files(&files, options, true, false);
let _ = has_error;
}
#[test]
fn test_get_changed_workflows_invalid_branch() {
let files = get_changed_workflows("nonexistent_branch_xyz");
assert!(files.is_empty());
}
#[test]
fn test_get_changed_workflows_main() {
let files = get_changed_workflows("main");
let _ = files;
}
}