gh_workflow/
generate.rs

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