gh_workflow/
generate.rs

1//! This module provides functionality to customize generation of the GitHub
2//! Actions workflow files.
3
4use std::path::PathBuf;
5use std::process::Command;
6
7use derive_setters::Setters;
8use indexmap::IndexMap;
9
10use crate::error::{Error, Result};
11use crate::{Job, Jobs, Workflow};
12
13#[derive(Setters, Clone)]
14#[setters(into)]
15pub struct Generate {
16    workflow: Workflow,
17    name: String,
18}
19
20impl Generate {
21    pub fn new(workflow: Workflow) -> Self {
22        let workflow = organize_job_dependency(workflow);
23        Self { workflow, name: "ci.yml".to_string() }
24    }
25
26    fn check_file(&self, path: &PathBuf, content: &str) -> Result<()> {
27        if let Ok(prev) = std::fs::read_to_string(path) {
28            if content != prev {
29                Err(Error::OutdatedWorkflow)
30            } else {
31                Ok(())
32            }
33        } else {
34            Err(Error::MissingWorkflowFile(path.clone()))
35        }
36    }
37
38    pub fn generate(&self) -> Result<()> {
39        let comment = include_str!("./comment.yml");
40
41        let root_dir = String::from_utf8(
42            Command::new("git")
43                .args(["rev-parse", "--show-toplevel"])
44                .output()?
45                .stdout,
46        )?;
47
48        let path = PathBuf::from(root_dir.trim())
49            .join(".github")
50            .join("workflows")
51            .join(self.name.as_str());
52
53        let content = format!("{}\n{}", comment, self.workflow.to_string()?);
54
55        let result = self.check_file(&path, &content);
56
57        if std::env::var("CI").is_ok() {
58            result
59        } else {
60            match result {
61                Ok(()) => {
62                    println!("Workflow file is up-to-date: {}", path.display());
63                    Ok(())
64                }
65                Err(Error::OutdatedWorkflow) => {
66                    std::fs::write(path.clone(), content)?;
67                    println!("Updated workflow file: {}", path.display());
68                    Ok(())
69                }
70                Err(Error::MissingWorkflowFile(path)) => {
71                    std::fs::create_dir_all(path.parent().ok_or(Error::IO(
72                        std::io::Error::other("Invalid parent dir(s) path"),
73                    ))?)?;
74                    std::fs::write(path.clone(), content)?;
75                    println!("Generated workflow file: {}", path.display());
76                    Ok(())
77                }
78                Err(e) => Err(e),
79            }
80        }
81    }
82}
83
84/// Organizes job dependencies within a given `Workflow`.
85///
86/// This function iterates over all jobs in the provided `Workflow` and ensures
87/// that each job's dependencies are correctly set up. If a job has dependencies
88/// specified in `tmp_needs`, it checks if those dependencies are already
89/// defined in the workflow. If not, it creates new job IDs for the missing
90/// dependencies and inserts them into the workflow. The function then updates
91/// the `needs` field of each job with the appropriate job IDs.
92fn organize_job_dependency(mut workflow: Workflow) -> Workflow {
93    let mut job_id = 0;
94    let mut new_jobs = IndexMap::<String, Job>::new();
95    let empty_map = IndexMap::default();
96
97    let old_jobs: &IndexMap<String, Job> = workflow
98        .jobs
99        .as_ref()
100        .map(|jobs| &jobs.0)
101        .unwrap_or(&empty_map);
102
103    // Iterate over all jobs
104    for (id, mut job) in workflow.jobs.clone().unwrap_or_default().0.into_iter() {
105        // If job has dependencies
106        if let Some(dep_jobs) = &job.tmp_needs {
107            // Prepare the job_ids
108            let mut job_ids = Vec::<String>::new();
109            for job in dep_jobs.iter() {
110                // If the job is already available
111                if let Some(id) = find_value(job, &new_jobs).or(find_value(job, old_jobs)) {
112                    job_ids.push(id.to_owned());
113                } else {
114                    // Create a job-id for the job
115                    let id = format!("job-{job_id}");
116
117                    // Add job id as the dependency
118                    job_ids.push(id.clone());
119
120                    // Insert the missing job into the new_jobs
121                    new_jobs.insert(format!("job-{job_id}"), job.clone());
122
123                    job_id += 1;
124                }
125            }
126            job.needs = Some(job_ids);
127        }
128
129        new_jobs.insert(id.clone(), job.clone());
130    }
131
132    workflow.jobs = Some(Jobs(new_jobs));
133
134    workflow
135}
136
137/// Find a job in the new_jobs or old_jobs
138fn find_value<'a, K, V: PartialEq>(job: &V, map: &'a IndexMap<K, V>) -> Option<&'a K> {
139    map.iter()
140        .find_map(|(k, v)| if v == job { Some(k) } else { None })
141}
142
143#[cfg(test)]
144mod tests {
145    use insta::assert_snapshot;
146
147    use super::*;
148
149    #[test]
150    fn add_needs_job() {
151        let base_job = Job::new("Base job");
152
153        let job1 =
154            Job::new("The first job that has dependency for base_job").add_needs(base_job.clone());
155        let job2 =
156            Job::new("The second job that has dependency for base_job").add_needs(base_job.clone());
157
158        let workflow = Workflow::new("All jobs were added to workflow")
159            .add_job("base_job", base_job)
160            .add_job("with-dependency-1", job1)
161            .add_job("with-dependency-2", job2);
162
163        let workflow = Generate::new(workflow).workflow;
164
165        assert_snapshot!(workflow.to_string().unwrap());
166    }
167
168    #[test]
169    fn missing_add_job() {
170        let base_job = Job::new("Base job");
171
172        let job1 =
173            Job::new("The first job that has dependency for base_job").add_needs(base_job.clone());
174        let job2 =
175            Job::new("The second job that has dependency for base_job").add_needs(base_job.clone());
176
177        let workflow = Workflow::new("base_job was not added to workflow jobs")
178            .add_job("with-dependency-1", job1)
179            .add_job("with-dependency-2", job2);
180
181        let workflow = Generate::new(workflow).workflow;
182
183        assert_snapshot!(workflow.to_string().unwrap());
184    }
185}