1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use crate::models::validation::{ConfigValidator, Diagnostic, Severity, YamlType};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct GHStep {
11 #[serde(default)]
12 pub name: Option<String>,
13 #[serde(default)]
14 pub uses: Option<String>,
15 #[serde(default)]
16 pub run: Option<String>,
17 #[serde(default, rename = "with")]
18 pub with_inputs: Option<serde_json::Value>,
19 #[serde(default)]
20 pub env: Option<serde_json::Value>,
21 #[serde(default, rename = "if")]
22 pub condition: Option<String>,
23 #[serde(default)]
24 pub id: Option<String>,
25 #[serde(default, rename = "working-directory")]
26 pub working_directory: Option<String>,
27 #[serde(default)]
28 pub shell: Option<String>,
29 #[serde(default, rename = "continue-on-error")]
30 pub continue_on_error: Option<serde_json::Value>,
31 #[serde(default, rename = "timeout-minutes")]
32 pub timeout_minutes: Option<u32>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct GHContainer {
37 pub image: String,
38 #[serde(default)]
39 pub credentials: Option<serde_json::Value>,
40 #[serde(default)]
41 pub env: Option<serde_json::Value>,
42 #[serde(default)]
43 pub ports: Vec<serde_json::Value>,
44 #[serde(default)]
45 pub volumes: Vec<String>,
46 #[serde(default)]
47 pub options: Option<String>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct GHStrategy {
52 #[serde(default)]
53 pub matrix: Option<serde_json::Value>,
54 #[serde(default, rename = "fail-fast")]
55 pub fail_fast: Option<bool>,
56 #[serde(default, rename = "max-parallel")]
57 pub max_parallel: Option<u32>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct GHJob {
62 #[serde(default, rename = "runs-on")]
63 pub runs_on: Option<serde_json::Value>,
64 #[serde(default)]
65 pub steps: Vec<GHStep>,
66 #[serde(default)]
67 pub needs: Option<serde_json::Value>,
68 #[serde(default)]
69 pub strategy: Option<GHStrategy>,
70 #[serde(default)]
71 pub env: Option<serde_json::Value>,
72 #[serde(default)]
73 pub name: Option<String>,
74 #[serde(default, rename = "if")]
75 pub condition: Option<String>,
76 #[serde(default, rename = "timeout-minutes")]
77 pub timeout_minutes: Option<u32>,
78 #[serde(default, rename = "continue-on-error")]
79 pub continue_on_error: Option<serde_json::Value>,
80 #[serde(default)]
81 pub container: Option<serde_json::Value>,
82 #[serde(default)]
83 pub services: Option<serde_json::Value>,
84 #[serde(default)]
85 pub permissions: Option<serde_json::Value>,
86 #[serde(default)]
87 pub outputs: Option<serde_json::Value>,
88 #[serde(default)]
89 pub concurrency: Option<serde_json::Value>,
90 #[serde(default)]
91 pub defaults: Option<serde_json::Value>,
92 #[serde(default)]
93 pub uses: Option<String>,
94 #[serde(default, rename = "with")]
95 pub with_inputs: Option<serde_json::Value>,
96 #[serde(default)]
97 pub secrets: Option<serde_json::Value>,
98 #[serde(default, rename = "environment")]
99 pub environment: Option<serde_json::Value>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct GitHubActions {
104 #[serde(default)]
105 pub name: Option<String>,
106 #[serde(default)]
107 pub on: Option<serde_json::Value>,
108 #[serde(default)]
109 pub env: Option<serde_json::Value>,
110 #[serde(default)]
111 pub permissions: Option<serde_json::Value>,
112 #[serde(default)]
113 pub concurrency: Option<serde_json::Value>,
114 #[serde(default)]
115 pub defaults: Option<serde_json::Value>,
116 #[serde(default)]
118 pub jobs: HashMap<String, GHJob>,
119}
120
121impl GitHubActions {
122 pub fn from_value(data: &serde_json::Value) -> Result<Self, String> {
123 serde_json::from_value(data.clone())
124 .map_err(|e| format!("Failed to parse GitHub Actions workflow: {e}"))
125 }
126}
127
128impl ConfigValidator for GitHubActions {
129 fn yaml_type(&self) -> YamlType { YamlType::GitHubActions }
130
131 fn validate_structure(&self) -> Vec<Diagnostic> {
132 let mut diags = Vec::new();
133 if self.on.is_none() {
134 diags.push(Diagnostic {
135 severity: Severity::Error,
136 message: "'on' trigger is required".into(),
137 path: Some("on".into()),
138 });
139 }
140 if self.jobs.is_empty() {
141 diags.push(Diagnostic {
142 severity: Severity::Error,
143 message: "No jobs defined".into(),
144 path: Some("jobs".into()),
145 });
146 }
147 for (name, job) in &self.jobs {
148 if job.runs_on.is_none() && job.uses.is_none() {
149 diags.push(Diagnostic {
150 severity: Severity::Error,
151 message: format!("Job '{}': must specify 'runs-on' or 'uses' (reusable workflow)", name),
152 path: Some(format!("jobs > {}", name)),
153 });
154 }
155 if job.steps.is_empty() && job.uses.is_none() {
156 diags.push(Diagnostic {
157 severity: Severity::Error,
158 message: format!("Job '{}': no steps defined", name),
159 path: Some(format!("jobs > {} > steps", name)),
160 });
161 }
162 }
163 diags
164 }
165
166 fn validate_semantics(&self) -> Vec<Diagnostic> {
167 let mut diags = Vec::new();
168 let job_names: Vec<&String> = self.jobs.keys().collect();
169
170 for (name, job) in &self.jobs {
171 if let Some(needs) = &job.needs {
173 let needed: Vec<String> = match needs {
174 serde_json::Value::String(s) => vec![s.clone()],
175 serde_json::Value::Array(arr) => {
176 arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()
177 }
178 _ => vec![],
179 };
180 for dep in &needed {
181 if !job_names.contains(&dep) {
182 diags.push(Diagnostic {
183 severity: Severity::Warning,
184 message: format!("Job '{}': needs '{}' which is not defined in this workflow", name, dep),
185 path: Some(format!("jobs > {} > needs", name)),
186 });
187 }
188 }
189 }
190
191 if job.timeout_minutes.is_none() {
193 diags.push(Diagnostic {
194 severity: Severity::Info,
195 message: format!("Job '{}': no timeout-minutes — defaults to 360 (6 hours)", name),
196 path: Some(format!("jobs > {} > timeout-minutes", name)),
197 });
198 }
199
200 let has_checkout = job.steps.iter().any(|s| {
202 s.uses.as_deref().map(|u| u.starts_with("actions/checkout")).unwrap_or(false)
203 });
204
205 let has_run = job.steps.iter().any(|s| s.run.is_some());
206
207 if has_run && !has_checkout && job.uses.is_none() {
208 diags.push(Diagnostic {
209 severity: Severity::Info,
210 message: format!("Job '{}': has 'run' steps but no actions/checkout — repo code won't be available", name),
211 path: Some(format!("jobs > {} > steps", name)),
212 });
213 }
214
215 for (i, step) in job.steps.iter().enumerate() {
216 if let Some(uses) = &step.uses
218 && (uses.contains("@master") || uses.contains("@main")) {
219 diags.push(Diagnostic {
220 severity: Severity::Warning,
221 message: format!("Job '{}' step[{}]: '{}' uses branch ref instead of tag/SHA — may break unexpectedly", name, i, uses),
222 path: Some(format!("jobs > {} > steps > {} > uses", name, i)),
223 });
224 }
225
226 if step.uses.is_none() && step.run.is_none() {
228 diags.push(Diagnostic {
229 severity: Severity::Warning,
230 message: format!("Job '{}' step[{}]: has neither 'uses' nor 'run'", name, i),
231 path: Some(format!("jobs > {} > steps > {}", name, i)),
232 });
233 }
234 }
235 }
236
237 if self.name.is_none() {
238 diags.push(Diagnostic {
239 severity: Severity::Info,
240 message: "No workflow 'name' — file name will be used as identifier".into(),
241 path: Some("name".into()),
242 });
243 }
244
245 diags
246 }
247}