use crate::config::Rules;
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Provider {
GitHubActions,
GitLabCI,
CircleCI,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Severity {
Error,
Warning,
Info,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Location {
pub line: usize,
pub column: usize,
pub job: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Issue {
pub severity: Severity,
pub message: String,
pub location: Option<Location>,
pub suggestion: Option<String>,
}
impl Issue {
pub fn for_job(
severity: Severity,
message: &str,
job_id: &str,
line: usize,
column: usize,
suggestion: Option<String>,
) -> Self {
Issue {
severity,
message: message.to_string(),
location: Some(Location {
line,
column,
job: Some(job_id.to_string()),
}),
suggestion,
}
}
pub fn new(severity: Severity, message: &str, suggestion: Option<String>) -> Self {
Issue {
severity,
message: message.to_string(),
location: None,
suggestion,
}
}
}
impl Pipeline {
pub fn find_line(&self, key: &str) -> (usize, usize) {
for (idx, line) in self.source.lines().enumerate() {
let trimmed = line.trim_start();
if trimmed.starts_with(key) {
let column = line.len() - trimmed.len() + 1;
return (idx + 1, column);
}
}
(0, 0)
}
pub fn find_job_line(&self, job_id: &str, key_hint: &str) -> (usize, usize) {
let mut job_line = 0;
let mut job_indent = 0;
for (idx, line) in self.source.lines().enumerate() {
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let prefix = format!("{}:", job_id);
if trimmed.starts_with(&prefix) {
let remainder = &trimmed[prefix.len()..];
if remainder.is_empty() || remainder.starts_with(char::is_whitespace) {
job_line = idx + 1;
job_indent = line.len() - trimmed.len();
break;
}
}
}
if job_line > 0 {
for (idx, line) in self.source.lines().enumerate().skip(job_line) {
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let current_indent = line.len() - trimmed.len();
if current_indent <= job_indent {
break;
}
if trimmed.starts_with(&format!("{}:", key_hint)) {
let column = current_indent + 1;
return (idx + 1, column);
}
}
let column = job_indent + 1;
return (job_line, column);
}
(0, 0)
}
}
#[derive(Debug, Clone, Copy)]
pub struct AuditOptions {
pub check_docker_images: bool,
pub strict_mode: bool,
pub rules: Option<Rules>,
}
impl Default for AuditOptions {
fn default() -> Self {
Self {
check_docker_images: true,
strict_mode: false,
rules: None,
}
}
}
#[derive(Debug, Serialize)]
pub struct AuditResult {
pub provider: Provider,
pub issues: Vec<Issue>,
pub summary: String,
#[serde(skip)]
pub elapsed: Duration,
}
#[derive(Debug)]
pub struct Pipeline {
pub provider: Provider,
pub jobs: Vec<Job>,
pub env: Vec<EnvVar>,
pub source: String,
}
#[derive(Debug, Default)]
pub struct Job {
pub id: String,
pub name: Option<String>,
pub depends_on: Vec<String>,
pub steps: Vec<Step>,
pub env: Vec<EnvVar>,
pub container_image: Option<String>,
pub service_images: Vec<String>,
pub timeout_minutes: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct Step {
pub name: Option<String>,
pub uses: Option<String>,
pub run: Option<String>,
pub env: Vec<EnvVar>,
pub with_inputs: Option<serde_yaml::Value>,
}
#[derive(Debug, Clone)]
pub struct EnvVar {
pub key: String,
pub value: String,
pub is_secret: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_issue_new_without_location() {
let issue = Issue::new(
Severity::Error,
"something broke",
Some("fix it".to_string()),
);
assert_eq!(issue.severity, Severity::Error);
assert_eq!(issue.message, "something broke");
assert!(issue.location.is_none());
assert_eq!(issue.suggestion, Some("fix it".to_string()));
}
#[test]
fn test_issue_new_no_suggestion() {
let issue = Issue::new(Severity::Warning, "watch out", None);
assert_eq!(issue.severity, Severity::Warning);
assert!(issue.suggestion.is_none());
assert!(issue.location.is_none());
}
#[test]
fn test_issue_for_job() {
let issue = Issue::for_job(
Severity::Error,
"cycle detected",
"build",
10,
3,
Some("break the cycle".to_string()),
);
assert_eq!(issue.severity, Severity::Error);
assert_eq!(issue.message, "cycle detected");
assert!(issue.location.is_some());
let loc = issue.location.as_ref().unwrap();
assert_eq!(loc.line, 10);
assert_eq!(loc.column, 3);
assert_eq!(loc.job, Some("build".to_string()));
assert_eq!(issue.suggestion, Some("break the cycle".to_string()));
}
#[test]
fn test_issue_for_job_no_suggestion() {
let issue = Issue::for_job(Severity::Warning, "missing step", "test", 5, 1, None);
assert_eq!(issue.message, "missing step");
assert!(issue.suggestion.is_none());
assert!(issue.location.is_some());
}
#[test]
fn test_location_default() {
let loc = Location::default();
assert_eq!(loc.line, 0);
assert_eq!(loc.column, 0);
assert!(loc.job.is_none());
}
#[test]
fn test_issue_with_explicit_location() {
let issue = Issue {
severity: Severity::Info,
message: "info message".to_string(),
location: Some(Location {
line: 42,
column: 7,
job: Some("deploy".to_string()),
}),
suggestion: None,
};
assert_eq!(issue.location.as_ref().unwrap().line, 42);
assert_eq!(issue.location.as_ref().unwrap().column, 7);
}
#[test]
fn test_pipeline_find_line_basic() {
let source = "name: CI\njobs:\n build:\n runs-on: ubuntu";
let pipeline = Pipeline {
provider: Provider::GitHubActions,
jobs: vec![],
env: vec![],
source: source.to_string(),
};
let (line, col) = pipeline.find_line("jobs:");
assert_eq!(line, 2);
assert_eq!(col, 1);
}
#[test]
fn test_pipeline_find_line_indented() {
let source = "name: CI\njobs:\n build:\n runs-on: ubuntu";
let pipeline = Pipeline {
provider: Provider::GitHubActions,
jobs: vec![],
env: vec![],
source: source.to_string(),
};
let (line, col) = pipeline.find_line("runs-on:");
assert_eq!(line, 4);
assert_eq!(col, 5);
}
#[test]
fn test_pipeline_find_line_not_found() {
let source = "name: CI\non: push";
let pipeline = Pipeline {
provider: Provider::GitHubActions,
jobs: vec![],
env: vec![],
source: source.to_string(),
};
let (line, col) = pipeline.find_line("missing:");
assert_eq!(line, 0);
assert_eq!(col, 0);
}
#[test]
fn test_pipeline_find_job_line_returns_hint_line() {
let source = "jobs:\n build:\n runs-on: ubuntu\n steps:";
let pipeline = Pipeline {
provider: Provider::GitHubActions,
jobs: vec![],
env: vec![],
source: source.to_string(),
};
let (line, col) = pipeline.find_job_line("build", "runs-on");
assert_eq!(line, 3);
assert_eq!(col, 5);
}
#[test]
fn test_pipeline_find_job_line_scoped_to_correct_job() {
let source = "jobs:\n build-extra:\n runs-on: ubuntu\n build:\n runs-on: ubuntu";
let pipeline = Pipeline {
provider: Provider::GitHubActions,
jobs: vec![],
env: vec![],
source: source.to_string(),
};
let (line, _col) = pipeline.find_job_line("build", "runs-on");
assert_eq!(line, 5);
}
#[test]
fn test_pipeline_find_job_line_no_job_match_returns_zero() {
let source = "jobs:\n build:\n runs-on: ubuntu";
let pipeline = Pipeline {
provider: Provider::GitHubActions,
jobs: vec![],
env: vec![],
source: source.to_string(),
};
let (line, col) = pipeline.find_job_line("deploy", "runs-on");
assert_eq!(line, 0);
assert_eq!(col, 0);
}
#[test]
fn test_pipeline_find_job_line_not_found_at_all() {
let source = "jobs:\n build:\n runs-on: ubuntu";
let pipeline = Pipeline {
provider: Provider::GitHubActions,
jobs: vec![],
env: vec![],
source: source.to_string(),
};
let (line, col) = pipeline.find_job_line("deploy", "steps");
assert_eq!(line, 0);
assert_eq!(col, 0);
}
#[test]
fn test_severity_equality() {
assert_eq!(Severity::Error, Severity::Error);
assert_ne!(Severity::Error, Severity::Warning);
assert_ne!(Severity::Warning, Severity::Info);
}
#[test]
fn test_severity_clone() {
let s = Severity::Error;
assert_eq!(s.clone(), Severity::Error);
}
#[test]
fn test_env_var_creation() {
let env = EnvVar {
key: "API_KEY".to_string(),
value: "secret123".to_string(),
is_secret: true,
};
assert_eq!(env.key, "API_KEY");
assert_eq!(env.value, "secret123");
assert!(env.is_secret);
}
#[test]
fn test_step_with_all_fields() {
let step = Step {
name: Some("Build".to_string()),
uses: Some("actions/checkout@v4".to_string()),
run: None,
env: vec![],
with_inputs: None,
};
assert_eq!(step.name, Some("Build".to_string()));
assert_eq!(step.uses, Some("actions/checkout@v4".to_string()));
assert!(step.run.is_none());
}
#[test]
fn test_step_with_run_command() {
let step = Step {
name: Some("Test".to_string()),
uses: None,
run: Some("cargo test".to_string()),
env: vec![EnvVar {
key: "RUST_BACKTRACE".to_string(),
value: "1".to_string(),
is_secret: false,
}],
with_inputs: None,
};
assert_eq!(step.run, Some("cargo test".to_string()));
assert_eq!(step.env.len(), 1);
}
#[test]
fn test_job_with_dependencies() {
let job = Job {
id: "deploy".to_string(),
name: Some("Deploy".to_string()),
depends_on: vec!["build".to_string(), "test".to_string()],
steps: vec![],
env: vec![],
container_image: None,
service_images: vec![],
timeout_minutes: None,
};
assert_eq!(job.depends_on.len(), 2);
assert!(job.depends_on.contains(&"build".to_string()));
assert!(job.depends_on.contains(&"test".to_string()));
}
#[test]
fn test_job_with_container_and_services() {
let job = Job {
id: "ci".to_string(),
name: None,
depends_on: vec![],
steps: vec![],
env: vec![],
container_image: Some("node:18".to_string()),
service_images: vec!["postgres:15".to_string(), "redis:7".to_string()],
timeout_minutes: None,
};
assert_eq!(job.container_image, Some("node:18".to_string()));
assert_eq!(job.service_images.len(), 2);
}
#[test]
fn test_provider_clone() {
let p = Provider::GitHubActions;
assert_eq!(p.clone(), Provider::GitHubActions);
}
#[test]
fn test_provider_equality() {
assert_eq!(Provider::GitLabCI, Provider::GitLabCI);
assert_ne!(Provider::GitHubActions, Provider::CircleCI);
}
}