gh_workflow/
generate.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
//! This module provides functionality to customize generation of the GitHub
//! Actions workflow files.

use std::io::ErrorKind;
use std::path::PathBuf;
use std::process::Command;

use derive_setters::Setters;
use indexmap::IndexMap;

use crate::error::{Error, Result};
use crate::{Job, Jobs, Workflow};

#[derive(Setters, Clone)]
#[setters(into)]
pub struct Generate {
    workflow: Workflow,
    name: String,
}

impl Generate {
    pub fn new(workflow: Workflow) -> Self {
        let workflow = organize_job_dependency(workflow);
        Self { workflow, name: "ci.yml".to_string() }
    }

    fn check_file(&self, path: &PathBuf, content: &str) -> Result<()> {
        if let Ok(prev) = std::fs::read_to_string(path) {
            if content != prev {
                Err(Error::OutdatedWorkflow)
            } else {
                Ok(())
            }
        } else {
            Err(Error::MissingWorkflowFile(path.clone()))
        }
    }

    pub fn generate(&self) -> Result<()> {
        let comment = include_str!("./comment.yml");

        let root_dir = String::from_utf8(
            Command::new("git")
                .args(["rev-parse", "--show-toplevel"])
                .output()?
                .stdout,
        )?;

        let path = PathBuf::from(root_dir.trim())
            .join(".github")
            .join("workflows")
            .join(self.name.as_str());

        let content = format!("{}\n{}", comment, self.workflow.to_string()?);

        let result = self.check_file(&path, &content);

        if std::env::var("CI").is_ok() {
            result
        } else {
            match result {
                Ok(()) => {
                    println!("Workflow file is up-to-date: {}", path.display());
                    Ok(())
                }
                Err(Error::OutdatedWorkflow) => {
                    std::fs::write(path.clone(), content)?;
                    println!("Updated workflow file: {}", path.display());
                    Ok(())
                }
                Err(Error::MissingWorkflowFile(path)) => {
                    std::fs::create_dir_all(path.parent().ok_or(Error::IO(
                        std::io::Error::new(ErrorKind::Other, "Invalid parent dir(s) path"),
                    ))?)?;
                    std::fs::write(path.clone(), content)?;
                    println!("Generated workflow file: {}", path.display());
                    Ok(())
                }
                Err(e) => Err(e),
            }
        }
    }
}

/// Organizes job dependencies within a given `Workflow`.
///
/// This function iterates over all jobs in the provided `Workflow` and ensures
/// that each job's dependencies are correctly set up. If a job has dependencies
/// specified in `tmp_needs`, it checks if those dependencies are already
/// defined in the workflow. If not, it creates new job IDs for the missing
/// dependencies and inserts them into the workflow. The function then updates
/// the `needs` field of each job with the appropriate job IDs.
fn organize_job_dependency(mut workflow: Workflow) -> Workflow {
    let mut job_id = 0;
    let mut new_jobs = IndexMap::<String, Job>::new();
    let empty_map = IndexMap::default();

    let old_jobs: &IndexMap<String, Job> = workflow
        .jobs
        .as_ref()
        .map(|jobs| &jobs.0)
        .unwrap_or(&empty_map);

    // Iterate over all jobs
    for (id, mut job) in workflow.jobs.clone().unwrap_or_default().0.into_iter() {
        // If job has dependencies
        if let Some(dep_jobs) = &job.tmp_needs {
            // Prepare the job_ids
            let mut job_ids = Vec::<String>::new();
            for job in dep_jobs.iter() {
                // If the job is already available
                if let Some(id) = find_value(job, &new_jobs).or(find_value(job, old_jobs)) {
                    job_ids.push(id.to_owned());
                } else {
                    // Create a job-id for the job
                    let id = format!("job-{}", job_id);

                    // Add job id as the dependency
                    job_ids.push(id.clone());

                    // Insert the missing job into the new_jobs
                    new_jobs.insert(format!("job-{}", job_id), job.clone());

                    job_id += 1;
                }
            }
            job.needs = Some(job_ids);
        }

        new_jobs.insert(id.clone(), job.clone());
    }

    workflow.jobs = Some(Jobs(new_jobs));

    workflow
}

/// Find a job in the new_jobs or old_jobs
fn find_value<'a, K, V: PartialEq>(job: &V, map: &'a IndexMap<K, V>) -> Option<&'a K> {
    map.iter()
        .find_map(|(k, v)| if v == job { Some(k) } else { None })
}

#[cfg(test)]
mod tests {
    use insta::assert_snapshot;

    use super::*;

    #[test]
    fn add_needs_job() {
        let base_job = Job::new("Base job");

        let job1 =
            Job::new("The first job that has dependency for base_job").add_needs(base_job.clone());
        let job2 =
            Job::new("The second job that has dependency for base_job").add_needs(base_job.clone());

        let workflow = Workflow::new("All jobs were added to workflow")
            .add_job("base_job", base_job)
            .add_job("with-dependency-1", job1)
            .add_job("with-dependency-2", job2);

        let workflow = Generate::new(workflow).workflow;

        assert_snapshot!(workflow.to_string().unwrap());
    }

    #[test]
    fn missing_add_job() {
        let base_job = Job::new("Base job");

        let job1 =
            Job::new("The first job that has dependency for base_job").add_needs(base_job.clone());
        let job2 =
            Job::new("The second job that has dependency for base_job").add_needs(base_job.clone());

        let workflow = Workflow::new("base_job was not added to workflow jobs")
            .add_job("with-dependency-1", job1)
            .add_job("with-dependency-2", job2);

        let workflow = Generate::new(workflow).workflow;

        assert_snapshot!(workflow.to_string().unwrap());
    }
}