Skip to content

Commit bb9e03a

Browse files
authored
feat(config): support per-project personal config override via runok.local.yml (#89)
1 parent 9882e01 commit bb9e03a

2 files changed

Lines changed: 160 additions & 6 deletions

File tree

README.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -172,12 +172,20 @@ Add runok as a PreToolUse hook in your Claude Code settings (`.claude/settings.j
172172

173173
### File locations
174174

175-
| Location | Scope |
176-
| --------------------------- | -------------------------------- |
177-
| `~/.config/runok/runok.yml` | Global (all projects) |
178-
| `./runok.yml` | Project-local (overrides global) |
175+
| Location | Scope |
176+
| --------------------------- | -------------------------------------------------- |
177+
| `~/.config/runok/runok.yml` | Global (all projects) |
178+
| `./runok.yml` | Project-local (overrides global) |
179+
| `./runok.local.yml` | Personal override (overrides project, git-ignored) |
179180

180-
Both `runok.yml` and `runok.yaml` are recognized (`.yml` takes precedence).
181+
All files recognize both `.yml` and `.yaml` extensions (`.yml` takes precedence).
182+
183+
`runok.local.yml` is intended for personal, environment-specific settings that should not be committed to version control. Add it to your `.gitignore`:
184+
185+
```gitignore
186+
runok.local.yml
187+
runok.local.yaml
188+
```
181189

182190
### Presets (`extends`)
183191

src/config/loader.rs

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,20 @@ impl DefaultConfigLoader {
5353
None
5454
}
5555

56+
/// Determine which local override config file to use.
57+
/// `runok.local.yml` is preferred; `runok.local.yaml` is a fallback.
58+
fn local_override_config_path(cwd: &Path) -> Option<PathBuf> {
59+
let yml = cwd.join("runok.local.yml");
60+
if yml.exists() {
61+
return Some(yml);
62+
}
63+
let yaml = cwd.join("runok.local.yaml");
64+
if yaml.exists() {
65+
return Some(yaml);
66+
}
67+
None
68+
}
69+
5670
fn read_and_parse(path: &Path) -> Result<Config, ConfigError> {
5771
let yaml = std::fs::read_to_string(path)?;
5872
parse_config(&yaml)
@@ -72,7 +86,14 @@ impl ConfigLoader for DefaultConfigLoader {
7286
.map(|p| Self::read_and_parse(&p))
7387
.transpose()?;
7488

75-
let mut config = global.unwrap_or_default().merge(local.unwrap_or_default());
89+
let local_override = Self::local_override_config_path(cwd)
90+
.map(|p| Self::read_and_parse(&p))
91+
.transpose()?;
92+
93+
let mut config = global
94+
.unwrap_or_default()
95+
.merge(local.unwrap_or_default())
96+
.merge(local_override.unwrap_or_default());
7697

7798
config.validate()?;
7899
Ok(config)
@@ -322,4 +343,129 @@ mod tests {
322343
assert_eq!(loader.global_config_path, Some(expected));
323344
}
324345
}
346+
347+
#[rstest]
348+
#[case::local_yml("runok.local.yml")]
349+
#[case::local_yaml_fallback("runok.local.yaml")]
350+
fn load_local_override(#[case] filename: &str) {
351+
let env = TestEnv::new();
352+
env.write_local(
353+
"runok.yml",
354+
indoc! {"
355+
defaults:
356+
action: deny
357+
"},
358+
);
359+
env.write_local(
360+
filename,
361+
indoc! {"
362+
defaults:
363+
action: allow
364+
"},
365+
);
366+
367+
let config = env.load_without_global().unwrap();
368+
// local override takes priority over project config
369+
assert_eq!(
370+
config.defaults.unwrap().action,
371+
Some(crate::config::ActionKind::Allow)
372+
);
373+
}
374+
375+
#[test]
376+
fn load_local_override_yml_takes_priority_over_yaml() {
377+
let env = TestEnv::new();
378+
env.write_local(
379+
"runok.local.yml",
380+
indoc! {"
381+
defaults:
382+
action: deny
383+
"},
384+
);
385+
env.write_local(
386+
"runok.local.yaml",
387+
indoc! {"
388+
defaults:
389+
action: allow
390+
"},
391+
);
392+
393+
let config = env.load_without_global().unwrap();
394+
assert_eq!(
395+
config.defaults.unwrap().action,
396+
Some(crate::config::ActionKind::Deny)
397+
);
398+
}
399+
400+
#[test]
401+
fn load_merges_all_three_layers() {
402+
let env = TestEnv::new();
403+
env.write_global(indoc! {"
404+
defaults:
405+
action: deny
406+
sandbox: global-sandbox
407+
rules:
408+
- deny: 'rm -rf /'
409+
"});
410+
env.write_local(
411+
"runok.yml",
412+
indoc! {"
413+
defaults:
414+
action: ask
415+
rules:
416+
- allow: 'git status'
417+
"},
418+
);
419+
env.write_local(
420+
"runok.local.yml",
421+
indoc! {"
422+
defaults:
423+
action: allow
424+
rules:
425+
- allow: 'cargo test'
426+
"},
427+
);
428+
429+
let config = env.load().unwrap();
430+
431+
let defaults = config.defaults.unwrap();
432+
// local override wins over project and global
433+
assert_eq!(defaults.action, Some(crate::config::ActionKind::Allow));
434+
// sandbox from global is preserved (not overridden by layers without it)
435+
assert_eq!(defaults.sandbox.as_deref(), Some("global-sandbox"));
436+
437+
// rules are appended: global + project + local override
438+
let rules = config.rules.unwrap();
439+
assert_eq!(rules.len(), 3);
440+
assert_eq!(rules[0].deny.as_deref(), Some("rm -rf /"));
441+
assert_eq!(rules[1].allow.as_deref(), Some("git status"));
442+
assert_eq!(rules[2].allow.as_deref(), Some("cargo test"));
443+
}
444+
445+
#[test]
446+
fn load_local_override_parse_error() {
447+
let env = TestEnv::new();
448+
env.write_local("runok.local.yml", "rules: [invalid yaml\n broken:");
449+
450+
let result = env.load_without_global();
451+
assert!(matches!(result.unwrap_err(), ConfigError::Yaml(_)));
452+
}
453+
454+
#[test]
455+
fn load_local_override_only_without_project_config() {
456+
let env = TestEnv::new();
457+
// no runok.yml, only runok.local.yml
458+
env.write_local(
459+
"runok.local.yml",
460+
indoc! {"
461+
rules:
462+
- allow: 'echo hello'
463+
"},
464+
);
465+
466+
let config = env.load_without_global().unwrap();
467+
let rules = config.rules.unwrap();
468+
assert_eq!(rules.len(), 1);
469+
assert_eq!(rules[0].allow.as_deref(), Some("echo hello"));
470+
}
325471
}

0 commit comments

Comments
 (0)