Skip to content

Commit 30a44af

Browse files
aster-voidclaude
andcommitted
config: add timezone (runner) and working_dir (per-job)
- runner.timezone: interpret cron schedules in specified timezone - job.working_dir: run job in subdirectory of job snapshot 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 9ee3277 commit 30a44af

File tree

6 files changed

+178
-36
lines changed

6 files changed

+178
-36
lines changed

Cargo.lock

Lines changed: 35 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
@@ -7,6 +7,7 @@ edition = "2024"
77
tokio = { version = "1", features = ["full"] }
88
cron = "0.15"
99
chrono = "0.4"
10+
chrono-tz = "0.10"
1011
anyhow = "1"
1112
clap = { version = "4", features = ["derive"] }
1213
dirs = "6"

README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@ rollcron https://github.com/user/repo --pull-interval 300
6161

6262
```yaml
6363
runner: # Optional: global settings
64-
working_dir: ./scripts # Working directory (relative to job dir)
6564
timezone: Asia/Tokyo # Timezone for cron schedules (default: UTC)
6665

6766
jobs:
@@ -72,6 +71,7 @@ jobs:
7271
run: command # Shell command
7372
timeout: 10s # Optional (default: 10s)
7473
concurrency: skip # Optional: parallel|wait|skip|replace (default: skip)
74+
working_dir: ./subdir # Optional: working directory (relative to job snapshot dir)
7575
retry: # Optional
7676
max: 3 # Max retry attempts
7777
delay: 1s # Initial delay (default: 1s), exponential backoff
@@ -83,7 +83,6 @@ Global settings that apply to all jobs:
8383
8484
| Field | Description |
8585
|-------|-------------|
86-
| `working_dir` | Working directory for all jobs (relative to job snapshot dir) |
8786
| `timezone` | Timezone for cron schedule interpretation (e.g., `Asia/Tokyo`, `America/New_York`) |
8887

8988
### Concurrency
@@ -161,14 +160,16 @@ Options:
161160
## Example: GitHub Actions-style Workflow
162161
163162
```yaml
163+
runner:
164+
timezone: America/New_York
165+
164166
jobs:
165167
test:
166168
name: "Run Tests"
167169
schedule:
168170
cron: "0 */6 * * *"
169-
run: |
170-
npm install
171-
npm test
171+
run: npm test
172+
working_dir: ./frontend
172173
retry:
173174
max: 2
174175
delay: 30s

src/config.rs

Lines changed: 96 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
use anyhow::{anyhow, Result};
2+
use chrono_tz::Tz;
23
use cron::Schedule;
34
use serde::Deserialize;
45
use std::collections::HashMap;
56
use std::str::FromStr;
67
use std::time::Duration;
78

9+
#[derive(Debug, Clone, Default)]
10+
pub struct RunnerConfig {
11+
pub timezone: Option<Tz>,
12+
}
13+
14+
#[derive(Debug, Deserialize, Default)]
15+
struct RunnerConfigRaw {
16+
timezone: Option<String>,
17+
}
18+
819
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
920
#[serde(rename_all = "lowercase")]
1021
pub enum Concurrency {
@@ -16,8 +27,10 @@ pub enum Concurrency {
1627
}
1728

1829
#[derive(Debug, Deserialize)]
19-
pub struct Config {
20-
pub jobs: HashMap<String, JobConfig>,
30+
struct Config {
31+
#[serde(default)]
32+
runner: RunnerConfigRaw,
33+
jobs: HashMap<String, JobConfig>,
2134
}
2235

2336
#[derive(Debug, Deserialize)]
@@ -30,6 +43,7 @@ pub struct JobConfig {
3043
#[serde(default)]
3144
pub concurrency: Concurrency,
3245
pub retry: Option<RetryConfigRaw>,
46+
pub working_dir: Option<String>,
3347
}
3448

3549
#[derive(Debug, Deserialize)]
@@ -62,6 +76,7 @@ pub struct Job {
6276
pub timeout: Duration,
6377
pub concurrency: Concurrency,
6478
pub retry: Option<RetryConfig>,
79+
pub working_dir: Option<String>,
6580
}
6681

6782
#[derive(Debug, Clone)]
@@ -70,11 +85,20 @@ pub struct RetryConfig {
7085
pub delay: Duration,
7186
}
7287

73-
pub fn parse_config(content: &str) -> Result<Vec<Job>> {
88+
pub fn parse_config(content: &str) -> Result<(RunnerConfig, Vec<Job>)> {
7489
let config: Config = serde_yaml::from_str(content)
7590
.map_err(|e| anyhow!("Failed to parse YAML: {}", e))?;
7691

77-
config
92+
let timezone = config
93+
.runner
94+
.timezone
95+
.map(|s| s.parse::<Tz>())
96+
.transpose()
97+
.map_err(|e| anyhow!("Invalid timezone: {}", e))?;
98+
99+
let runner = RunnerConfig { timezone };
100+
101+
let jobs = config
78102
.jobs
79103
.into_iter()
80104
.map(|(id, job)| {
@@ -104,9 +128,12 @@ pub fn parse_config(content: &str) -> Result<Vec<Job>> {
104128
timeout,
105129
concurrency: job.concurrency,
106130
retry,
131+
working_dir: job.working_dir,
107132
})
108133
})
109-
.collect()
134+
.collect::<Result<Vec<_>>>()?;
135+
136+
Ok((runner, jobs))
110137
}
111138

112139
fn parse_duration(s: &str) -> Result<Duration> {
@@ -135,7 +162,7 @@ jobs:
135162
cron: "*/5 * * * *"
136163
run: echo hello
137164
"#;
138-
let jobs = parse_config(yaml).unwrap();
165+
let (_, jobs) = parse_config(yaml).unwrap();
139166
assert_eq!(jobs.len(), 1);
140167
assert_eq!(jobs[0].id, "hello");
141168
assert_eq!(jobs[0].name, "hello");
@@ -153,7 +180,7 @@ jobs:
153180
cron: "0 * * * *"
154181
run: cargo build
155182
"#;
156-
let jobs = parse_config(yaml).unwrap();
183+
let (_, jobs) = parse_config(yaml).unwrap();
157184
assert_eq!(jobs[0].id, "build");
158185
assert_eq!(jobs[0].name, "Build Project");
159186
}
@@ -168,7 +195,7 @@ jobs:
168195
run: sleep 30
169196
timeout: 60s
170197
"#;
171-
let jobs = parse_config(yaml).unwrap();
198+
let (_, jobs) = parse_config(yaml).unwrap();
172199
assert_eq!(jobs[0].timeout, Duration::from_secs(60));
173200
}
174201

@@ -187,7 +214,7 @@ jobs:
187214
run: echo two
188215
timeout: 30s
189216
"#;
190-
let jobs = parse_config(yaml).unwrap();
217+
let (_, jobs) = parse_config(yaml).unwrap();
191218
assert_eq!(jobs.len(), 2);
192219
}
193220

@@ -207,7 +234,7 @@ jobs:
207234
cron: "* * * * *"
208235
run: echo test
209236
"#;
210-
let jobs = parse_config(yaml).unwrap();
237+
let (_, jobs) = parse_config(yaml).unwrap();
211238
assert_eq!(jobs[0].concurrency, Concurrency::Skip);
212239
}
213240

@@ -236,7 +263,7 @@ jobs:
236263
run: echo 4
237264
concurrency: replace
238265
"#;
239-
let jobs = parse_config(yaml).unwrap();
266+
let (_, jobs) = parse_config(yaml).unwrap();
240267
let find = |id: &str| jobs.iter().find(|j| j.id == id).unwrap();
241268

242269
assert_eq!(find("parallel_job").concurrency, Concurrency::Parallel);
@@ -261,7 +288,7 @@ jobs:
261288
cron: "* * * * *"
262289
run: echo test
263290
"#;
264-
let jobs = parse_config(yaml).unwrap();
291+
let (_, jobs) = parse_config(yaml).unwrap();
265292
let find = |id: &str| jobs.iter().find(|j| j.id == id).unwrap();
266293

267294
let retry = find("with_retry").retry.as_ref().unwrap();
@@ -282,9 +309,65 @@ jobs:
282309
retry:
283310
max: 2
284311
"#;
285-
let jobs = parse_config(yaml).unwrap();
312+
let (_, jobs) = parse_config(yaml).unwrap();
286313
let retry = jobs[0].retry.as_ref().unwrap();
287314
assert_eq!(retry.max, 2);
288315
assert_eq!(retry.delay, Duration::from_secs(1)); // default delay
289316
}
317+
318+
#[test]
319+
fn parse_runner_config() {
320+
let yaml = r#"
321+
runner:
322+
timezone: Asia/Tokyo
323+
jobs:
324+
test:
325+
schedule:
326+
cron: "* * * * *"
327+
run: echo test
328+
"#;
329+
let (runner, _) = parse_config(yaml).unwrap();
330+
assert_eq!(runner.timezone, Some(chrono_tz::Asia::Tokyo));
331+
}
332+
333+
#[test]
334+
fn parse_runner_config_defaults() {
335+
let yaml = r#"
336+
jobs:
337+
test:
338+
schedule:
339+
cron: "* * * * *"
340+
run: echo test
341+
"#;
342+
let (runner, _) = parse_config(yaml).unwrap();
343+
assert!(runner.timezone.is_none());
344+
}
345+
346+
#[test]
347+
fn parse_working_dir() {
348+
let yaml = r#"
349+
jobs:
350+
test:
351+
schedule:
352+
cron: "* * * * *"
353+
run: echo test
354+
working_dir: ./scripts
355+
"#;
356+
let (_, jobs) = parse_config(yaml).unwrap();
357+
assert_eq!(jobs[0].working_dir.as_deref(), Some("./scripts"));
358+
}
359+
360+
#[test]
361+
fn parse_invalid_timezone() {
362+
let yaml = r#"
363+
runner:
364+
timezone: Invalid/Zone
365+
jobs:
366+
test:
367+
schedule:
368+
cron: "* * * * *"
369+
run: echo test
370+
"#;
371+
assert!(parse_config(yaml).is_err());
372+
}
290373
}

src/main.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,10 @@ async fn main() -> Result<()> {
4343
let sot_path = git::ensure_repo(&source)?;
4444
println!("[rollcron] Cache: {}", sot_path.display());
4545

46-
let initial_jobs = load_config(&sot_path)?;
46+
let (initial_runner, initial_jobs) = load_config(&sot_path)?;
4747
sync_job_dirs(&sot_path, &initial_jobs)?;
4848

49-
let (tx, mut rx) = tokio::sync::watch::channel(initial_jobs);
49+
let (tx, mut rx) = tokio::sync::watch::channel((initial_runner, initial_jobs));
5050

5151
// Spawn auto-sync task
5252
let source_clone = source.clone();
@@ -66,12 +66,12 @@ async fn main() -> Result<()> {
6666
println!("[rollcron] Synced from upstream");
6767

6868
match load_config(&sot) {
69-
Ok(jobs) => {
69+
Ok((runner, jobs)) => {
7070
if let Err(e) = sync_job_dirs(&sot, &jobs) {
7171
eprintln!("[rollcron] Failed to sync job dirs: {}", e);
7272
continue;
7373
}
74-
let _ = tx.send(jobs);
74+
let _ = tx.send((runner, jobs));
7575
println!("[rollcron] Synced job directories");
7676
}
7777
Err(e) => eprintln!("[rollcron] Failed to reload config: {}", e),
@@ -81,18 +81,18 @@ async fn main() -> Result<()> {
8181

8282
// Main scheduler loop
8383
loop {
84-
let jobs = rx.borrow_and_update().clone();
84+
let (runner, jobs) = rx.borrow_and_update().clone();
8585
let sot = sot_path.clone();
8686
tokio::select! {
87-
_ = scheduler::run_scheduler(jobs, sot) => {}
87+
_ = scheduler::run_scheduler(jobs, sot, runner) => {}
8888
_ = rx.changed() => {
8989
println!("[rollcron] Config updated, restarting scheduler");
9090
}
9191
}
9292
}
9393
}
9494

95-
fn load_config(sot_path: &PathBuf) -> Result<Vec<config::Job>> {
95+
fn load_config(sot_path: &PathBuf) -> Result<(config::RunnerConfig, Vec<config::Job>)> {
9696
let config_path = sot_path.join(CONFIG_FILE);
9797
let content = std::fs::read_to_string(&config_path)
9898
.map_err(|e| anyhow::anyhow!("Failed to read {}: {}", config_path.display(), e))?;

0 commit comments

Comments
 (0)