use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::models::validation::{ConfigValidator, Diagnostic, Severity, YamlType};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GHStep {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub uses: Option<String>,
#[serde(default)]
pub run: Option<String>,
#[serde(default, rename = "with")]
pub with_inputs: Option<serde_json::Value>,
#[serde(default)]
pub env: Option<serde_json::Value>,
#[serde(default, rename = "if")]
pub condition: Option<String>,
#[serde(default)]
pub id: Option<String>,
#[serde(default, rename = "working-directory")]
pub working_directory: Option<String>,
#[serde(default)]
pub shell: Option<String>,
#[serde(default, rename = "continue-on-error")]
pub continue_on_error: Option<serde_json::Value>,
#[serde(default, rename = "timeout-minutes")]
pub timeout_minutes: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GHContainer {
pub image: String,
#[serde(default)]
pub credentials: Option<serde_json::Value>,
#[serde(default)]
pub env: Option<serde_json::Value>,
#[serde(default)]
pub ports: Vec<serde_json::Value>,
#[serde(default)]
pub volumes: Vec<String>,
#[serde(default)]
pub options: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GHStrategy {
#[serde(default)]
pub matrix: Option<serde_json::Value>,
#[serde(default, rename = "fail-fast")]
pub fail_fast: Option<bool>,
#[serde(default, rename = "max-parallel")]
pub max_parallel: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GHJob {
#[serde(default, rename = "runs-on")]
pub runs_on: Option<serde_json::Value>,
#[serde(default)]
pub steps: Vec<GHStep>,
#[serde(default)]
pub needs: Option<serde_json::Value>,
#[serde(default)]
pub strategy: Option<GHStrategy>,
#[serde(default)]
pub env: Option<serde_json::Value>,
#[serde(default)]
pub name: Option<String>,
#[serde(default, rename = "if")]
pub condition: Option<String>,
#[serde(default, rename = "timeout-minutes")]
pub timeout_minutes: Option<u32>,
#[serde(default, rename = "continue-on-error")]
pub continue_on_error: Option<serde_json::Value>,
#[serde(default)]
pub container: Option<serde_json::Value>,
#[serde(default)]
pub services: Option<serde_json::Value>,
#[serde(default)]
pub permissions: Option<serde_json::Value>,
#[serde(default)]
pub outputs: Option<serde_json::Value>,
#[serde(default)]
pub concurrency: Option<serde_json::Value>,
#[serde(default)]
pub defaults: Option<serde_json::Value>,
#[serde(default)]
pub uses: Option<String>,
#[serde(default, rename = "with")]
pub with_inputs: Option<serde_json::Value>,
#[serde(default)]
pub secrets: Option<serde_json::Value>,
#[serde(default, rename = "environment")]
pub environment: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubActions {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub on: Option<serde_json::Value>,
#[serde(default)]
pub env: Option<serde_json::Value>,
#[serde(default)]
pub permissions: Option<serde_json::Value>,
#[serde(default)]
pub concurrency: Option<serde_json::Value>,
#[serde(default)]
pub defaults: Option<serde_json::Value>,
#[serde(default)]
pub jobs: HashMap<String, GHJob>,
}
impl GitHubActions {
pub fn from_value(data: &serde_json::Value) -> Result<Self, String> {
serde_json::from_value(data.clone())
.map_err(|e| format!("Failed to parse GitHub Actions workflow: {e}"))
}
}
impl ConfigValidator for GitHubActions {
fn yaml_type(&self) -> YamlType { YamlType::GitHubActions }
fn validate_structure(&self) -> Vec<Diagnostic> {
let mut diags = Vec::new();
if self.on.is_none() {
diags.push(Diagnostic {
severity: Severity::Error,
message: "'on' trigger is required".into(),
path: Some("on".into()),
});
}
if self.jobs.is_empty() {
diags.push(Diagnostic {
severity: Severity::Error,
message: "No jobs defined".into(),
path: Some("jobs".into()),
});
}
for (name, job) in &self.jobs {
if job.runs_on.is_none() && job.uses.is_none() {
diags.push(Diagnostic {
severity: Severity::Error,
message: format!("Job '{}': must specify 'runs-on' or 'uses' (reusable workflow)", name),
path: Some(format!("jobs > {}", name)),
});
}
if job.steps.is_empty() && job.uses.is_none() {
diags.push(Diagnostic {
severity: Severity::Error,
message: format!("Job '{}': no steps defined", name),
path: Some(format!("jobs > {} > steps", name)),
});
}
}
diags
}
fn validate_semantics(&self) -> Vec<Diagnostic> {
let mut diags = Vec::new();
let job_names: Vec<&String> = self.jobs.keys().collect();
for (name, job) in &self.jobs {
if let Some(needs) = &job.needs {
let needed: Vec<String> = match needs {
serde_json::Value::String(s) => vec![s.clone()],
serde_json::Value::Array(arr) => {
arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()
}
_ => vec![],
};
for dep in &needed {
if !job_names.contains(&dep) {
diags.push(Diagnostic {
severity: Severity::Warning,
message: format!("Job '{}': needs '{}' which is not defined in this workflow", name, dep),
path: Some(format!("jobs > {} > needs", name)),
});
}
}
}
if job.timeout_minutes.is_none() {
diags.push(Diagnostic {
severity: Severity::Info,
message: format!("Job '{}': no timeout-minutes — defaults to 360 (6 hours)", name),
path: Some(format!("jobs > {} > timeout-minutes", name)),
});
}
let has_checkout = job.steps.iter().any(|s| {
s.uses.as_deref().map(|u| u.starts_with("actions/checkout")).unwrap_or(false)
});
let has_run = job.steps.iter().any(|s| s.run.is_some());
if has_run && !has_checkout && job.uses.is_none() {
diags.push(Diagnostic {
severity: Severity::Info,
message: format!("Job '{}': has 'run' steps but no actions/checkout — repo code won't be available", name),
path: Some(format!("jobs > {} > steps", name)),
});
}
for (i, step) in job.steps.iter().enumerate() {
if let Some(uses) = &step.uses
&& (uses.contains("@master") || uses.contains("@main")) {
diags.push(Diagnostic {
severity: Severity::Warning,
message: format!("Job '{}' step[{}]: '{}' uses branch ref instead of tag/SHA — may break unexpectedly", name, i, uses),
path: Some(format!("jobs > {} > steps > {} > uses", name, i)),
});
}
if step.uses.is_none() && step.run.is_none() {
diags.push(Diagnostic {
severity: Severity::Warning,
message: format!("Job '{}' step[{}]: has neither 'uses' nor 'run'", name, i),
path: Some(format!("jobs > {} > steps > {}", name, i)),
});
}
}
}
if self.name.is_none() {
diags.push(Diagnostic {
severity: Severity::Info,
message: "No workflow 'name' — file name will be used as identifier".into(),
path: Some("name".into()),
});
}
diags
}
}