gitlab_ci_parser/
lib.rs

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/// All Jobs in the same stage tend to be run at once.
18#[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    /// Even though variables could be None,
25    /// they could be defined in extends_job.variables
26    /// (or globally)
27    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    /// Returns the consolidated local variables based on all extends.
37    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    /// Based on include orderings, what's the parent of this gitlab config.
60    pub parent: Option<Box<GitlabCIConfig>>,
61
62    /// Global variables
63    pub variables: BTreeMap<VarName, VarValue>,
64
65    /// Stages group jobs that run in parallel. The ordering is important
66    pub stages: Vec<StageName>,
67
68    /// Targets that gitlab can run.
69    pub jobs: BTreeMap<JobName, Rc<Job>>,
70}
71
72impl GitlabCIConfig {
73    /// Returns the consolidated global variables based on all imports.
74    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
100//#[tracing::instrument]
101fn parse_includes(
102    context: &Path,
103    include: &Value,
104    parent: Option<GitlabCIConfig>,
105) -> Option<GitlabCIConfig> {
106    match include {
107        Value::String(include_filename) => {
108            // Remove leading '/' - join (correctly) won't concat them if filename starts from root.
109            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                // We assume that the included project is checked out in a sister directory.
134                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
158///
159/// Taking a path to a .gitlab-ci.yml file will read it and parse it where possible.
160/// Anything unknown will be silently skipped. Jobs will be linked up with their parents.
161///
162pub fn parse(gitlab_file: &Path) -> Result<GitlabCIConfig, DynErr> {
163    parse_aux(gitlab_file, None)
164}
165
166//#[tracing::instrument]
167fn 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// When a file is loaded, all includes are imported, then all jobs, then
242// only then do we load the jobs of the file that included us.
243#[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                // Parse parents first so we don't get wicked fun with Rc<>...
251
252                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)) //TODO: maybe push rc outside here
262        } 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 root = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?);
288        // let p = &PathBuf::from(Path::join(&root, "examples/simple/.gitlab-ci.yml"));
289        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        // Check jobs are linked up to their parents
298        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            // all spans/events with a level higher than TRACE (e.g, debug, info, warn, etc.)
344            // will be written to stdout.
345            .with_max_level(Level::TRACE)
346            // builds the subscriber.
347            .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}