Skip to main content

wrkflw_executor/
environment.rs

1use chrono::Utc;
2use serde_json;
3use serde_yaml::Value;
4use std::{collections::HashMap, fs, io, path::Path};
5use wrkflw_matrix::MatrixCombination;
6use wrkflw_parser::workflow::WorkflowDefinition;
7
8pub fn setup_github_environment_files(workspace_dir: &Path) -> io::Result<()> {
9    // Create necessary directories
10    let github_dir = workspace_dir.join("github");
11    fs::create_dir_all(&github_dir)?;
12
13    // Create common GitHub environment files
14    let github_output = github_dir.join("output");
15    let github_env = github_dir.join("env");
16    let github_path = github_dir.join("path");
17    let github_step_summary = github_dir.join("step_summary");
18
19    // Initialize files with empty content
20    fs::write(&github_output, "")?;
21    fs::write(&github_env, "")?;
22    fs::write(&github_path, "")?;
23    fs::write(&github_step_summary, "")?;
24
25    Ok(())
26}
27
28pub fn create_github_context(
29    workflow: &WorkflowDefinition,
30    workspace_dir: &Path,
31) -> HashMap<String, String> {
32    let mut env = HashMap::new();
33
34    // Basic GitHub environment variables
35    env.insert("GITHUB_WORKFLOW".to_string(), workflow.name.clone());
36    env.insert("GITHUB_ACTION".to_string(), "run".to_string());
37    env.insert("GITHUB_REPOSITORY".to_string(), get_repo_name());
38    env.insert("GITHUB_EVENT_NAME".to_string(), get_event_name(workflow));
39    env.insert("GITHUB_WORKSPACE".to_string(), get_workspace_path());
40    env.insert("GITHUB_SHA".to_string(), get_current_sha());
41    env.insert("GITHUB_REF".to_string(), get_current_ref());
42
43    // File paths for GitHub Actions
44    env.insert(
45        "GITHUB_OUTPUT".to_string(),
46        workspace_dir
47            .join("github")
48            .join("output")
49            .to_string_lossy()
50            .to_string(),
51    );
52    env.insert(
53        "GITHUB_ENV".to_string(),
54        workspace_dir
55            .join("github")
56            .join("env")
57            .to_string_lossy()
58            .to_string(),
59    );
60    env.insert(
61        "GITHUB_PATH".to_string(),
62        workspace_dir
63            .join("github")
64            .join("path")
65            .to_string_lossy()
66            .to_string(),
67    );
68    env.insert(
69        "GITHUB_STEP_SUMMARY".to_string(),
70        workspace_dir
71            .join("github")
72            .join("step_summary")
73            .to_string_lossy()
74            .to_string(),
75    );
76
77    // Time-related variables
78    let now = Utc::now();
79    env.insert("GITHUB_RUN_ID".to_string(), format!("{}", now.timestamp()));
80    env.insert("GITHUB_RUN_NUMBER".to_string(), "1".to_string());
81    env.insert("GITHUB_RUN_ATTEMPT".to_string(), "1".to_string());
82
83    // CI detection variables
84    env.insert("GITHUB_ACTIONS".to_string(), "true".to_string());
85    env.insert("CI".to_string(), "true".to_string());
86
87    // GitHub URLs
88    env.insert(
89        "GITHUB_SERVER_URL".to_string(),
90        "https://github.com".to_string(),
91    );
92    env.insert(
93        "GITHUB_API_URL".to_string(),
94        "https://api.github.com".to_string(),
95    );
96    env.insert(
97        "GITHUB_GRAPHQL_URL".to_string(),
98        "https://api.github.com/graphql".to_string(),
99    );
100
101    // Ref-derived variables
102    let full_ref = env.get("GITHUB_REF").cloned().unwrap_or_default();
103    env.insert("GITHUB_REF_NAME".to_string(), get_ref_name(&full_ref));
104    env.insert("GITHUB_REF_TYPE".to_string(), get_ref_type(&full_ref));
105
106    // PR-related variables (empty for local runs)
107    env.insert("GITHUB_HEAD_REF".to_string(), String::new());
108    env.insert("GITHUB_BASE_REF".to_string(), String::new());
109
110    // Actor-related variables
111    let actor = get_actor();
112    env.insert("GITHUB_ACTOR".to_string(), actor.clone());
113    env.insert("GITHUB_TRIGGERING_ACTOR".to_string(), actor);
114
115    // Repository owner
116    let repo = env.get("GITHUB_REPOSITORY").cloned().unwrap_or_default();
117    env.insert(
118        "GITHUB_REPOSITORY_OWNER".to_string(),
119        get_repository_owner(&repo),
120    );
121
122    // Miscellaneous
123    env.insert("GITHUB_RETENTION_DAYS".to_string(), "90".to_string());
124
125    // Runner variables
126    env.insert("RUNNER_OS".to_string(), get_runner_os());
127    env.insert("RUNNER_ARCH".to_string(), get_runner_arch());
128    env.insert("RUNNER_NAME".to_string(), "wrkflw-local".to_string());
129    env.insert("RUNNER_ENVIRONMENT".to_string(), "local".to_string());
130    env.insert("RUNNER_TEMP".to_string(), get_temp_dir());
131    env.insert("RUNNER_TOOL_CACHE".to_string(), get_tool_cache_dir());
132
133    env
134}
135
136/// Add job-specific context variables to the environment
137pub fn add_job_context(env: &mut HashMap<String, String>, job_name: &str) {
138    env.insert("GITHUB_JOB".to_string(), job_name.to_string());
139}
140
141/// Add matrix context variables to the environment
142pub fn add_matrix_context(
143    env: &mut HashMap<String, String>,
144    matrix_combination: &MatrixCombination,
145) {
146    // Add each matrix parameter as an environment variable
147    for (key, value) in &matrix_combination.values {
148        let env_key = format!("MATRIX_{}", key.to_uppercase());
149        let env_value = value_to_string(value);
150        env.insert(env_key, env_value);
151    }
152
153    // Also serialize the whole matrix as JSON for potential use
154    if let Ok(json_value) = serde_json::to_string(&matrix_combination.values) {
155        env.insert("MATRIX_CONTEXT".to_string(), json_value);
156    }
157}
158
159/// Convert a serde_yaml::Value to a string for environment variables
160fn value_to_string(value: &Value) -> String {
161    match value {
162        Value::String(s) => s.clone(),
163        Value::Number(n) => n.to_string(),
164        Value::Bool(b) => b.to_string(),
165        Value::Sequence(seq) => {
166            let items = seq
167                .iter()
168                .map(value_to_string)
169                .collect::<Vec<_>>()
170                .join(",");
171            items
172        }
173        Value::Mapping(map) => {
174            let items = map
175                .iter()
176                .map(|(k, v)| format!("{}={}", value_to_string(k), value_to_string(v)))
177                .collect::<Vec<_>>()
178                .join(",");
179            items
180        }
181        Value::Null => "".to_string(),
182        _ => "".to_string(),
183    }
184}
185
186fn get_repo_name() -> String {
187    // Try to detect from git if available
188    if let Ok(output) = std::process::Command::new("git")
189        .args(["remote", "get-url", "origin"])
190        .output()
191    {
192        if output.status.success() {
193            let url = String::from_utf8_lossy(&output.stdout);
194            if let Some(repo) = extract_repo_from_url(&url) {
195                return repo;
196            }
197        }
198    }
199
200    // Fallback to directory name
201    let current_dir = std::env::current_dir().unwrap_or_default();
202    format!(
203        "wrkflw/{}",
204        current_dir
205            .file_name()
206            .unwrap_or_default()
207            .to_string_lossy()
208    )
209}
210
211fn extract_repo_from_url(url: &str) -> Option<String> {
212    // Extract owner/repo from common git URLs
213    let url = url.trim();
214
215    // Handle SSH URLs: git@github.com:owner/repo.git
216    if url.starts_with("git@") {
217        let parts: Vec<&str> = url.split(':').collect();
218        if parts.len() == 2 {
219            let repo_part = parts[1].trim_end_matches(".git");
220            return Some(repo_part.to_string());
221        }
222    }
223
224    // Handle HTTPS URLs: https://github.com/owner/repo.git
225    if url.starts_with("http") {
226        let without_protocol = url.split("://").nth(1)?;
227        let parts: Vec<&str> = without_protocol.split('/').collect();
228        if parts.len() >= 3 {
229            let owner = parts[1];
230            let repo = parts[2].trim_end_matches(".git");
231            return Some(format!("{}/{}", owner, repo));
232        }
233    }
234
235    None
236}
237
238fn get_event_name(workflow: &WorkflowDefinition) -> String {
239    // Try to extract from the workflow trigger
240    if let Some(first_trigger) = workflow.on.first() {
241        return first_trigger.clone();
242    }
243    "workflow_dispatch".to_string()
244}
245
246fn get_workspace_path() -> String {
247    std::env::current_dir()
248        .unwrap_or_default()
249        .to_string_lossy()
250        .to_string()
251}
252
253fn get_current_sha() -> String {
254    if let Ok(output) = std::process::Command::new("git")
255        .args(["rev-parse", "HEAD"])
256        .output()
257    {
258        if output.status.success() {
259            return String::from_utf8_lossy(&output.stdout).trim().to_string();
260        }
261    }
262
263    "0000000000000000000000000000000000000000".to_string()
264}
265
266fn get_current_ref() -> String {
267    if let Ok(output) = std::process::Command::new("git")
268        .args(["symbolic-ref", "--short", "HEAD"])
269        .output()
270    {
271        if output.status.success() {
272            return format!(
273                "refs/heads/{}",
274                String::from_utf8_lossy(&output.stdout).trim()
275            );
276        }
277    }
278
279    "refs/heads/main".to_string()
280}
281
282fn get_runner_os() -> String {
283    match std::env::consts::OS {
284        "macos" => "macOS".to_string(),
285        "linux" => "Linux".to_string(),
286        "windows" => "Windows".to_string(),
287        other => other.to_string(),
288    }
289}
290
291fn get_runner_arch() -> String {
292    match std::env::consts::ARCH {
293        "x86_64" | "x86" => "X64".to_string(),
294        "aarch64" => "ARM64".to_string(),
295        other => other.to_string(),
296    }
297}
298
299fn get_temp_dir() -> String {
300    let temp_dir = std::env::temp_dir();
301    temp_dir.join("wrkflw").to_string_lossy().to_string()
302}
303
304fn get_tool_cache_dir() -> String {
305    let home_dir = dirs::home_dir().unwrap_or_default();
306    home_dir
307        .join(".wrkflw")
308        .join("tools")
309        .to_string_lossy()
310        .to_string()
311}
312
313fn get_ref_name(full_ref: &str) -> String {
314    if let Some(name) = full_ref.strip_prefix("refs/heads/") {
315        name.to_string()
316    } else if let Some(name) = full_ref.strip_prefix("refs/tags/") {
317        name.to_string()
318    } else if let Some(name) = full_ref.strip_prefix("refs/pull/") {
319        name.to_string()
320    } else {
321        full_ref.to_string()
322    }
323}
324
325fn get_ref_type(full_ref: &str) -> String {
326    if full_ref.starts_with("refs/tags/") {
327        "tag".to_string()
328    } else {
329        "branch".to_string()
330    }
331}
332
333fn get_repository_owner(repo: &str) -> String {
334    repo.split('/').next().unwrap_or("").to_string()
335}
336
337fn get_actor() -> String {
338    // Try git config user.name first
339    if let Ok(output) = std::process::Command::new("git")
340        .args(["config", "user.name"])
341        .output()
342    {
343        if output.status.success() {
344            let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
345            if !name.is_empty() {
346                return name;
347            }
348        }
349    }
350
351    // Fall back to $USER or $USERNAME
352    if let Ok(user) = std::env::var("USER") {
353        if !user.is_empty() {
354            return user;
355        }
356    }
357    if let Ok(user) = std::env::var("USERNAME") {
358        if !user.is_empty() {
359            return user;
360        }
361    }
362
363    "wrkflw".to_string()
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn ref_name_strips_heads_prefix() {
372        assert_eq!(get_ref_name("refs/heads/main"), "main");
373        assert_eq!(get_ref_name("refs/heads/feature/foo"), "feature/foo");
374    }
375
376    #[test]
377    fn ref_name_strips_tags_prefix() {
378        assert_eq!(get_ref_name("refs/tags/v1.0.0"), "v1.0.0");
379    }
380
381    #[test]
382    fn ref_name_returns_input_for_unknown_prefix() {
383        assert_eq!(get_ref_name("some/other/ref"), "some/other/ref");
384    }
385
386    #[test]
387    fn ref_type_detects_tag() {
388        assert_eq!(get_ref_type("refs/tags/v1.0.0"), "tag");
389    }
390
391    #[test]
392    fn ref_type_defaults_to_branch() {
393        assert_eq!(get_ref_type("refs/heads/main"), "branch");
394        assert_eq!(get_ref_type("something-else"), "branch");
395    }
396
397    #[test]
398    fn repository_owner_extracts_owner() {
399        assert_eq!(get_repository_owner("octocat/hello-world"), "octocat");
400    }
401
402    #[test]
403    fn repository_owner_handles_no_slash() {
404        assert_eq!(get_repository_owner("myrepo"), "myrepo");
405    }
406
407    #[test]
408    fn repository_owner_handles_empty() {
409        assert_eq!(get_repository_owner(""), "");
410    }
411
412    #[test]
413    fn extract_repo_from_ssh_url() {
414        assert_eq!(
415            extract_repo_from_url("git@github.com:owner/repo.git"),
416            Some("owner/repo".to_string())
417        );
418    }
419
420    #[test]
421    fn extract_repo_from_https_url() {
422        assert_eq!(
423            extract_repo_from_url("https://github.com/owner/repo.git"),
424            Some("owner/repo".to_string())
425        );
426    }
427
428    #[test]
429    fn extract_repo_from_invalid_url() {
430        assert_eq!(extract_repo_from_url("not-a-url"), None);
431    }
432}