1use serde_derive::*;
2use serde_yaml::{Mapping, Value};
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5use std::rc::Rc;
6use tracing::{debug, info};
7use yaml_merge_keys::merge_keys_serde;
8
9pub type DynErr = Box<dyn std::error::Error + 'static>;
10
11pub type StageName = String;
12pub type JobName = String;
13pub type VarName = String;
14pub type VarValue = String;
15pub type Script = String;
16
17#[derive(Debug, PartialEq, Serialize, Deserialize)]
19pub struct Job {
20 pub stage: Option<StageName>,
21 pub before_script: Option<Vec<Script>>,
22 pub script: Option<Vec<Script>>,
23
24 pub variables: Option<BTreeMap<VarName, VarValue>>,
28
29 pub extends: Option<JobName>,
30
31 #[serde(skip)]
32 pub extends_job: Option<Rc<Job>>,
33}
34
35impl Job {
36 pub fn get_merged_variables(&self) -> BTreeMap<String, String> {
38 let mut results = BTreeMap::new();
39 self.calculate_variables(&mut results);
40 results
41 }
42
43 fn calculate_variables(&self, mut variables: &mut BTreeMap<String, String>) {
44 if let Some(ref parent) = self.extends_job {
45 parent.calculate_variables(&mut variables);
46 }
47 if let Some(ref var) = self.variables {
48 for (k, v) in var.iter() {
49 variables.insert(k.clone(), v.clone());
50 }
51 }
52 }
53}
54
55#[derive(Debug)]
56pub struct GitlabCIConfig {
57 pub file: PathBuf,
58
59 pub parent: Option<Box<GitlabCIConfig>>,
61
62 pub variables: BTreeMap<VarName, VarValue>,
64
65 pub stages: Vec<StageName>,
67
68 pub jobs: BTreeMap<JobName, Rc<Job>>,
70}
71
72impl GitlabCIConfig {
73 pub fn get_merged_variables(&self) -> BTreeMap<String, String> {
75 let mut results = BTreeMap::new();
76 self.calculate_variables(&mut results);
77 results
78 }
79
80 pub fn lookup_job(&self, job_name: &str) -> Option<Rc<Job>> {
81 if let Some(job) = self.jobs.get(job_name) {
82 Some(job.clone())
83 } else {
84 if let Some(parent) = &self.parent {
85 parent.lookup_job(job_name)
86 } else {
87 None
88 }
89 }
90 }
91
92 fn calculate_variables(&self, mut variables: &mut BTreeMap<String, String>) {
93 if let Some(ref parent) = self.parent {
94 parent.calculate_variables(&mut variables);
95 }
96 variables.extend(self.variables.clone());
97 }
98}
99
100fn parse_includes(
102 context: &Path,
103 include: &Value,
104 parent: Option<GitlabCIConfig>,
105) -> Option<GitlabCIConfig> {
106 match include {
107 Value::String(include_filename) => {
108 let ch = include_filename.chars().next().unwrap();
110 let include_filename = if ch == '/' || ch == '\\' {
111 include_filename[1..].to_owned()
112 } else {
113 include_filename.to_owned()
114 };
115 let include_filename = context.join(&include_filename);
116 parse_aux(&context.join(&Path::new(&include_filename)), parent).ok()
117 }
118 Value::Sequence(includes) => {
119 let mut parent = parent;
120 for include in includes {
121 parent = parse_includes(context, include, parent);
122 debug!("parent returned {:?}", parent.as_ref().unwrap().file);
123 }
124 parent
125 }
126 Value::Mapping(map) => {
127 if let Some(Value::String(local)) = map.get(&Value::String("local".to_owned())) {
128 let local = context.join(local);
129 parse_aux(&local, parent).ok()
130 } else if let Some(Value::String(project)) =
131 map.get(&Value::String("project".to_owned()))
132 {
133 let parts = project.split('/');
135 let project_name = parts.last().expect("project name should contain '/'");
136
137 if let Value::String(file) = map
138 .get(&Value::String("file".to_owned()))
139 .unwrap_or(&Value::String(".gitlab-ci.yml".to_owned()))
140 {
141 let path = context.join(
142 Path::new("..")
143 .join(Path::new(project_name))
144 .join(Path::new(file)),
145 );
146 parse_aux(&path, parent).ok()
147 } else {
148 parent
149 }
150 } else {
151 parent
152 }
153 }
154 _ => parent,
155 }
156}
157
158pub fn parse(gitlab_file: &Path) -> Result<GitlabCIConfig, DynErr> {
163 parse_aux(gitlab_file, None)
164}
165
166fn parse_aux(gitlab_file: &Path, parent: Option<GitlabCIConfig>) -> Result<GitlabCIConfig, DynErr> {
168 debug!(
169 "Parsing file {:?}, parent: {:?}",
170 gitlab_file,
171 parent.as_ref().map(|c| c.file.clone())
172 );
173 let f = std::fs::File::open(&gitlab_file)?;
174 let raw_yaml = serde_yaml::from_reader(f)?;
175
176 let val: serde_yaml::Value = merge_keys_serde(raw_yaml).expect("Couldn't merge yaml :<<");
177 let mut config = GitlabCIConfig {
178 file: gitlab_file.to_path_buf(),
179 parent: None,
180 stages: Vec::new(),
181 variables: BTreeMap::new(),
182 jobs: BTreeMap::new(),
183 };
184
185 if let serde_yaml::Value::Mapping(map) = val {
186 info!("Parsing {:?} succesful.", gitlab_file);
187
188 if let Some(includes) = map.get(&Value::String("include".to_owned())) {
189 config.parent = parse_includes(
190 gitlab_file
191 .parent()
192 .expect("gitlab-ci file wasn't in a dir??"),
193 includes,
194 parent,
195 )
196 .map(Box::new);
197 } else {
198 config.parent = parent.map(Box::new)
199 }
200
201 debug!(
202 "All includes loaded for {:?}. {:?}",
203 gitlab_file,
204 config.parent.as_ref().map(|p| p.file.clone())
205 );
206
207 for (k, v) in map.iter() {
208 if let Value::String(key) = k {
209 if !config.jobs.contains_key(key) {
210 match (key.as_ref(), v) {
211 ("variables", _) => {
212 let global_var_map: Mapping = serde_yaml::from_value(v.clone())?;
213 for (key, value) in global_var_map {
214 if let (Value::String(key), Value::String(value)) = (key, value) {
215 config.variables.insert(key, value);
216 }
217 }
218 }
219 ("stages", Value::Sequence(seq)) => {
220 for stage in seq {
221 if let Value::String(stage_name) = stage {
222 config.stages.push(stage_name.to_owned());
223 }
224 }
225 }
226 (k, _) => {
227 let job_def = parse_job(&config, k, &map);
228 if let Ok(job) = job_def {
229 config.jobs.insert(k.to_owned(), job);
230 }
231 }
232 };
233 }
234 }
235 }
236 }
237
238 Ok(config)
239}
240
241#[tracing::instrument]
244fn parse_job(config: &GitlabCIConfig, job_name: &str, top: &Mapping) -> Result<Rc<Job>, DynErr> {
245 let job_nm = Value::String(job_name.to_owned());
246 if let Some(job) = top.get(&job_nm) {
247 let j: Result<Job, _> = serde_yaml::from_value(job.clone());
248 if let Ok(mut j) = j {
249 if let Some(ref parent_job_name) = j.extends {
250 let job: Option<Rc<Job>> = if job_name != parent_job_name
253 && top.contains_key(&Value::String(parent_job_name.clone()))
254 {
255 parse_job(config, parent_job_name, top).ok()
256 } else {
257 config.lookup_job(parent_job_name)
258 };
259 j.extends_job = job;
260 }
261 Ok(Rc::new(j)) } else {
263 Err(Box::new(j.unwrap_err()))
264 }
265 } else {
266 Err(Box::new(std::io::Error::new(
267 std::io::ErrorKind::NotFound,
268 "Job not found",
269 )))
270 }
271}
272
273#[cfg(test)]
274pub mod tests {
275 use super::*;
276 use std::path::PathBuf;
277 use tracing::Level;
278 use tracing_subscriber;
279
280 #[test]
281 pub fn parse_example() -> Result<(), DynErr> {
282 let example_file: PathBuf = PathBuf::from(file!())
283 .parent()
284 .unwrap()
285 .join("../examples/simple/.gitlab-ci.yml");
286
287 let config = parse(&example_file)?;
290 assert_eq!(
291 config.variables["GLOBAL_VAR"],
292 "this GLOBAL_VAR should mostly always be set.",
293 );
294
295 assert_eq!(config.stages.len(), 1);
296
297 let parent = config
299 .jobs
300 .get("tired_starlings")
301 .unwrap()
302 .extends_job
303 .as_ref()
304 .unwrap();
305 assert!(parent
306 .variables
307 .as_ref()
308 .unwrap()
309 .contains_key("AN_INHERITED_VARIABLE"));
310 Ok(())
311 }
312
313 #[test]
314 pub fn parse_include() -> Result<(), DynErr> {
315 let example_file: PathBuf = PathBuf::from(file!())
316 .parent()
317 .unwrap()
318 .join("../.gitlab-ci.yml");
319
320 let config = parse(&example_file)?;
321 assert!(config.parent.is_some());
322
323 let globals = config.get_merged_variables();
324 assert!(globals.contains_key("GLOBAL_VAR"));
325 Ok(())
326 }
327
328 #[test]
329 pub fn consolidated_global_vars() -> Result<(), DynErr> {
330 let example_file: PathBuf = PathBuf::from(file!())
331 .parent()
332 .unwrap()
333 .join("../examples/simple/.gitlab-ci.yml");
334 let config = parse(&example_file)?;
335 let vars = config.get_merged_variables();
336 assert!(vars.contains_key("GLOBAL_VAR"));
337 Ok(())
338 }
339
340 #[test]
341 pub fn imports() -> Result<(), DynErr> {
342 let subscriber = tracing_subscriber::fmt()
343 .with_max_level(Level::TRACE)
346 .finish();
348
349 tracing::subscriber::with_default(subscriber, || {
350 let example_file: PathBuf = PathBuf::from(file!())
351 .parent()
352 .unwrap()
353 .join("../examples/imports/a.yml");
354 let config = parse(&example_file).unwrap();
355 let vars = config.get_merged_variables();
356
357 let mut parent = config.parent;
358 println!("file {:?}", config.file);
359 while let Some(par) = parent {
360 println!("parent {:?}", par.file);
361 parent = par.parent;
362 }
363
364 assert!(vars.contains_key("A"));
365 assert!(vars.contains_key("B"));
366 assert!(vars.contains_key("C"));
367 assert!(vars.contains_key("D"));
368 assert!(vars.contains_key("E"));
369 });
370 Ok(())
371 }
372}