Skip to content

Commit 8e735a4

Browse files
aster-voidclaude
andcommitted
treewide: implement environment variable system with dotenvy
- Move timezone from job level to schedule.timezone - Replace custom .env parser with dotenvy crate - Add runner.env_file and runner.env for global environment - Add job.env_file and job.env for per-job environment - Priority: host < runner.env_file < runner.env < job.env_file < job.env - Add comprehensive tests for env merging and priority 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 70da343 commit 8e735a4

File tree

6 files changed

+324
-55
lines changed

6 files changed

+324
-55
lines changed

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ serde = { version = "1", features = ["derive"] }
1515
serde_yaml = "0.9"
1616
rand = "0.8"
1717
xtra = { version = "0.6", features = ["tokio"] }
18+
dotenvy = "0.15"
1819

1920
[dev-dependencies]
2021
tempfile = "3"

src/config.rs

Lines changed: 121 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,15 @@ pub enum TimezoneConfig {
3333
#[derive(Debug, Clone, Default)]
3434
pub struct RunnerConfig {
3535
pub timezone: TimezoneConfig,
36+
pub env_file: Option<String>,
37+
pub env: Option<HashMap<String, String>>,
3638
}
3739

3840
#[derive(Debug, Deserialize, Default)]
3941
struct RunnerConfigRaw {
4042
timezone: Option<String>,
43+
env_file: Option<String>,
44+
env: Option<HashMap<String, String>>,
4145
}
4246

4347
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
@@ -70,7 +74,8 @@ pub struct JobConfig {
7074
pub working_dir: Option<String>,
7175
pub jitter: Option<String>,
7276
pub enabled: Option<bool>,
73-
pub timezone: Option<String>,
77+
pub env_file: Option<String>,
78+
pub env: Option<HashMap<String, String>>,
7479
}
7580

7681
#[derive(Debug, Deserialize)]
@@ -89,6 +94,7 @@ fn default_retry_delay() -> String {
8994
#[derive(Debug, Deserialize)]
9095
pub struct ScheduleConfig {
9196
pub cron: String,
97+
pub timezone: Option<String>,
9298
}
9399

94100
fn default_timeout() -> String {
@@ -108,6 +114,8 @@ pub struct Job {
108114
pub jitter: Option<Duration>,
109115
pub enabled: bool,
110116
pub timezone: Option<TimezoneConfig>,
117+
pub env_file: Option<String>,
118+
pub env: Option<HashMap<String, String>>,
111119
}
112120

113121
#[derive(Debug, Clone)]
@@ -130,7 +138,11 @@ pub fn parse_config(content: &str) -> Result<(RunnerConfig, Vec<Job>)> {
130138
),
131139
};
132140

133-
let runner = RunnerConfig { timezone };
141+
let runner = RunnerConfig {
142+
timezone: timezone.clone(),
143+
env_file: config.runner.env_file,
144+
env: config.runner.env,
145+
};
134146

135147
let jobs = config
136148
.jobs
@@ -173,6 +185,7 @@ pub fn parse_config(content: &str) -> Result<(RunnerConfig, Vec<Job>)> {
173185
.transpose()?;
174186

175187
let job_timezone = job
188+
.schedule
176189
.timezone
177190
.map(|tz| {
178191
if tz == "inherit" {
@@ -183,7 +196,8 @@ pub fn parse_config(content: &str) -> Result<(RunnerConfig, Vec<Job>)> {
183196
.map_err(|e| anyhow!("Invalid timezone '{}' in job '{}': {}", tz, id, e))
184197
}
185198
})
186-
.transpose()?;
199+
.transpose()?
200+
.or(Some(timezone.clone()));
187201

188202
Ok(Job {
189203
id,
@@ -197,6 +211,8 @@ pub fn parse_config(content: &str) -> Result<(RunnerConfig, Vec<Job>)> {
197211
jitter,
198212
enabled: job.enabled.unwrap_or(true),
199213
timezone: job_timezone,
214+
env_file: job.env_file,
215+
env: job.env,
200216
})
201217
})
202218
.collect::<Result<Vec<_>>>()?;
@@ -614,8 +630,8 @@ jobs:
614630
job1:
615631
schedule:
616632
cron: "* * * * *"
633+
timezone: America/New_York
617634
run: echo test
618-
timezone: America/New_York
619635
job2:
620636
schedule:
621637
cron: "* * * * *"
@@ -628,7 +644,10 @@ jobs:
628644
find("job1").timezone,
629645
Some(TimezoneConfig::Named(chrono_tz::America::New_York))
630646
);
631-
assert!(find("job2").timezone.is_none());
647+
assert_eq!(
648+
find("job2").timezone,
649+
Some(TimezoneConfig::Named(chrono_tz::Asia::Tokyo))
650+
);
632651
}
633652

634653
#[test]
@@ -638,8 +657,8 @@ jobs:
638657
test:
639658
schedule:
640659
cron: "* * * * *"
660+
timezone: inherit
641661
run: echo test
642-
timezone: inherit
643662
"#;
644663
let (_, jobs) = parse_config(yaml).unwrap();
645664
assert_eq!(jobs[0].timezone, Some(TimezoneConfig::Inherit));
@@ -652,9 +671,104 @@ jobs:
652671
test:
653672
schedule:
654673
cron: "* * * * *"
674+
timezone: Invalid/Zone
655675
run: echo test
656-
timezone: Invalid/Zone
657676
"#;
658677
assert!(parse_config(yaml).is_err());
659678
}
679+
680+
#[test]
681+
fn parse_runner_env() {
682+
let yaml = r#"
683+
runner:
684+
env:
685+
FOO: bar
686+
BAZ: qux
687+
jobs:
688+
test:
689+
schedule:
690+
cron: "* * * * *"
691+
run: echo test
692+
"#;
693+
let (runner, _) = parse_config(yaml).unwrap();
694+
let env = runner.env.as_ref().unwrap();
695+
assert_eq!(env.get("FOO"), Some(&"bar".to_string()));
696+
assert_eq!(env.get("BAZ"), Some(&"qux".to_string()));
697+
}
698+
699+
#[test]
700+
fn parse_runner_env_file() {
701+
let yaml = r#"
702+
runner:
703+
env_file: .env.global
704+
jobs:
705+
test:
706+
schedule:
707+
cron: "* * * * *"
708+
run: echo test
709+
"#;
710+
let (runner, _) = parse_config(yaml).unwrap();
711+
assert_eq!(runner.env_file.as_deref(), Some(".env.global"));
712+
}
713+
714+
#[test]
715+
fn parse_job_env() {
716+
let yaml = r#"
717+
jobs:
718+
test:
719+
schedule:
720+
cron: "* * * * *"
721+
run: echo test
722+
env:
723+
KEY1: value1
724+
KEY2: value2
725+
"#;
726+
let (_, jobs) = parse_config(yaml).unwrap();
727+
let env = jobs[0].env.as_ref().unwrap();
728+
assert_eq!(env.get("KEY1"), Some(&"value1".to_string()));
729+
assert_eq!(env.get("KEY2"), Some(&"value2".to_string()));
730+
}
731+
732+
#[test]
733+
fn parse_job_env_file() {
734+
let yaml = r#"
735+
jobs:
736+
test:
737+
schedule:
738+
cron: "* * * * *"
739+
run: echo test
740+
env_file: .env.job
741+
"#;
742+
let (_, jobs) = parse_config(yaml).unwrap();
743+
assert_eq!(jobs[0].env_file.as_deref(), Some(".env.job"));
744+
}
745+
746+
#[test]
747+
fn parse_full_env_config() {
748+
let yaml = r#"
749+
runner:
750+
env_file: .env.global
751+
env:
752+
GLOBAL_VAR: global_value
753+
jobs:
754+
test:
755+
schedule:
756+
cron: "* * * * *"
757+
run: echo test
758+
env_file: .env.local
759+
env:
760+
LOCAL_VAR: local_value
761+
"#;
762+
let (runner, jobs) = parse_config(yaml).unwrap();
763+
assert_eq!(runner.env_file.as_deref(), Some(".env.global"));
764+
assert_eq!(
765+
runner.env.as_ref().unwrap().get("GLOBAL_VAR"),
766+
Some(&"global_value".to_string())
767+
);
768+
assert_eq!(jobs[0].env_file.as_deref(), Some(".env.local"));
769+
assert_eq!(
770+
jobs[0].env.as_ref().unwrap().get("LOCAL_VAR"),
771+
Some(&"local_value".to_string())
772+
);
773+
}
660774
}

src/env.rs

Lines changed: 33 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,30 @@
11
use anyhow::Result;
22
use std::collections::HashMap;
3-
use std::path::Path;
3+
use std::path::{Path, PathBuf};
44

55
/// Load environment variables from .env file if it exists.
66
/// Returns a HashMap of key-value pairs.
77
/// If the .env file doesn't exist, returns an empty HashMap (no error).
8+
#[allow(dead_code)]
89
pub fn load_env_file(dir: &Path) -> Result<HashMap<String, String>> {
910
let env_path = dir.join(".env");
11+
load_env_from_path(&env_path)
12+
}
1013

11-
if !env_path.exists() {
14+
/// Load environment variables from a specific .env file path.
15+
/// Returns a HashMap of key-value pairs.
16+
/// If the file doesn't exist, returns an empty HashMap (no error).
17+
pub fn load_env_from_path(path: &PathBuf) -> Result<HashMap<String, String>> {
18+
if !path.exists() {
1219
return Ok(HashMap::new());
1320
}
1421

15-
let content = std::fs::read_to_string(&env_path)?;
22+
let iter = dotenvy::from_path_iter(path)?;
1623
let mut vars = HashMap::new();
17-
18-
for line in content.lines() {
19-
let line = line.trim();
20-
21-
// Skip empty lines and comments
22-
if line.is_empty() || line.starts_with('#') {
23-
continue;
24-
}
25-
26-
// Parse KEY=VALUE
27-
if let Some((key, value)) = line.split_once('=') {
28-
let key = key.trim().to_string();
29-
let value = value.trim().to_string();
30-
31-
// Remove quotes from value if present
32-
let value = if (value.starts_with('"') && value.ends_with('"'))
33-
|| (value.starts_with('\'') && value.ends_with('\''))
34-
{
35-
value[1..value.len() - 1].to_string()
36-
} else {
37-
value
38-
};
39-
40-
vars.insert(key, value);
41-
}
24+
for item in iter {
25+
let (key, value) = item?;
26+
vars.insert(key, value);
4227
}
43-
4428
Ok(vars)
4529
}
4630

@@ -107,9 +91,28 @@ mod tests {
10791
fn test_load_env_with_spaces() {
10892
let dir = TempDir::new().unwrap();
10993
let env_path = dir.path().join(".env");
110-
fs::write(&env_path, "KEY = value with spaces").unwrap();
94+
fs::write(&env_path, "KEY=\"value with spaces\"").unwrap();
11195

11296
let vars = load_env_file(dir.path()).unwrap();
11397
assert_eq!(vars.get("KEY"), Some(&"value with spaces".to_string()));
11498
}
99+
100+
#[test]
101+
fn test_load_env_from_path_exists() {
102+
let dir = TempDir::new().unwrap();
103+
let env_path = dir.path().join("custom.env");
104+
fs::write(&env_path, "FOO=bar\nBAZ=qux").unwrap();
105+
106+
let vars = load_env_from_path(&env_path).unwrap();
107+
assert_eq!(vars.get("FOO"), Some(&"bar".to_string()));
108+
assert_eq!(vars.get("BAZ"), Some(&"qux".to_string()));
109+
}
110+
111+
#[test]
112+
fn test_load_env_from_path_missing() {
113+
let dir = TempDir::new().unwrap();
114+
let env_path = dir.path().join("missing.env");
115+
let vars = load_env_from_path(&env_path).unwrap();
116+
assert!(vars.is_empty());
117+
}
115118
}

0 commit comments

Comments
 (0)