1use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12const DEFAULT_CONFIG: &str = include_str!("../config.default.toml");
14
15#[derive(Debug, Deserialize, Serialize)]
20pub struct Config {
21 #[serde(default)]
23 pub settings: Settings,
24 #[serde(default)]
26 pub commands: Commands,
27 #[serde(default)]
29 pub wrappers: WrapperConfig,
30 #[serde(default)]
32 pub git: GitConfig,
33 #[serde(default)]
35 pub cargo: CargoConfig,
36 #[serde(default)]
38 pub kubectl: KubectlConfig,
39 #[serde(default)]
41 pub gh: GhConfig,
42}
43
44#[derive(Debug, Deserialize, Serialize, Default)]
46pub struct Settings {
47 #[serde(default)]
51 pub escalate_deny: bool,
52}
53
54#[derive(Debug, Deserialize, Serialize, Default)]
59pub struct Commands {
60 #[serde(default)]
62 pub allow: Vec<String>,
63 #[serde(default)]
65 pub ask: Vec<String>,
66 #[serde(default)]
68 pub deny: Vec<String>,
69}
70
71#[derive(Debug, Deserialize, Serialize, Default)]
75pub struct WrapperConfig {
76 #[serde(default)]
79 pub allow_floor: Vec<String>,
80 #[serde(default)]
83 pub ask_floor: Vec<String>,
84}
85
86#[derive(Debug, Deserialize, Serialize, Default)]
88pub struct GitConfig {
89 #[serde(default)]
91 pub read_only: Vec<String>,
92 #[serde(default)]
95 pub allowed_with_config: Vec<String>,
96 #[serde(default)]
101 pub config_env: HashMap<String, String>,
102 #[serde(default)]
105 pub force_push_flags: Vec<String>,
106}
107
108#[derive(Debug, Deserialize, Serialize, Default)]
110pub struct CargoConfig {
111 #[serde(default)]
113 pub safe_subcommands: Vec<String>,
114 #[serde(default)]
116 pub allowed_with_config: Vec<String>,
117 #[serde(default)]
119 pub config_env: HashMap<String, String>,
120}
121
122#[derive(Debug, Deserialize, Serialize, Default)]
124pub struct KubectlConfig {
125 #[serde(default)]
127 pub read_only: Vec<String>,
128 #[serde(default)]
130 pub mutating: Vec<String>,
131 #[serde(default)]
133 pub allowed_with_config: Vec<String>,
134 #[serde(default)]
136 pub config_env: HashMap<String, String>,
137}
138
139#[derive(Debug, Deserialize, Serialize, Default)]
144pub struct GhConfig {
145 #[serde(default)]
147 pub read_only: Vec<String>,
148 #[serde(default)]
150 pub mutating: Vec<String>,
151 #[serde(default)]
153 pub allowed_with_config: Vec<String>,
154 #[serde(default)]
156 pub config_env: HashMap<String, String>,
157}
158
159#[derive(Debug, Deserialize, Default)]
166struct ConfigOverlay {
167 #[serde(default)]
168 settings: SettingsOverlay,
169 #[serde(default)]
170 commands: CommandsOverlay,
171 #[serde(default)]
172 wrappers: WrappersOverlay,
173 #[serde(default)]
174 git: GitOverlay,
175 #[serde(default)]
176 cargo: CargoOverlay,
177 #[serde(default)]
178 kubectl: KubectlOverlay,
179 #[serde(default)]
180 gh: GhOverlay,
181}
182
183#[derive(Debug, Deserialize, Default)]
184struct SettingsOverlay {
185 escalate_deny: Option<bool>,
186}
187
188#[derive(Debug, Deserialize, Default)]
189struct WrappersOverlay {
190 #[serde(default)]
191 replace: bool,
192 #[serde(default)]
193 allow_floor: Vec<String>,
194 #[serde(default)]
195 ask_floor: Vec<String>,
196 #[serde(default)]
197 remove_allow_floor: Vec<String>,
198 #[serde(default)]
199 remove_ask_floor: Vec<String>,
200}
201
202#[derive(Debug, Deserialize, Default)]
203struct CommandsOverlay {
204 #[serde(default)]
205 replace: bool,
206 #[serde(default)]
207 allow: Vec<String>,
208 #[serde(default)]
209 ask: Vec<String>,
210 #[serde(default)]
211 deny: Vec<String>,
212 #[serde(default)]
213 remove_allow: Vec<String>,
214 #[serde(default)]
215 remove_ask: Vec<String>,
216 #[serde(default)]
217 remove_deny: Vec<String>,
218}
219
220#[derive(Debug, Deserialize, Default)]
221struct GitOverlay {
222 #[serde(default)]
223 replace: bool,
224 #[serde(default)]
225 read_only: Vec<String>,
226 #[serde(default)]
227 allowed_with_config: Vec<String>,
228 config_env: Option<HashMap<String, String>>,
229 #[serde(default)]
230 force_push_flags: Vec<String>,
231 #[serde(default)]
232 remove_read_only: Vec<String>,
233 #[serde(default)]
234 remove_allowed_with_config: Vec<String>,
235 #[serde(default)]
236 remove_force_push_flags: Vec<String>,
237}
238
239#[derive(Debug, Deserialize, Default)]
240struct CargoOverlay {
241 #[serde(default)]
242 replace: bool,
243 #[serde(default)]
244 safe_subcommands: Vec<String>,
245 #[serde(default)]
246 allowed_with_config: Vec<String>,
247 config_env: Option<HashMap<String, String>>,
248 #[serde(default)]
249 remove_safe_subcommands: Vec<String>,
250 #[serde(default)]
251 remove_allowed_with_config: Vec<String>,
252}
253
254#[derive(Debug, Deserialize, Default)]
255struct KubectlOverlay {
256 #[serde(default)]
257 replace: bool,
258 #[serde(default)]
259 read_only: Vec<String>,
260 #[serde(default)]
261 mutating: Vec<String>,
262 #[serde(default)]
263 allowed_with_config: Vec<String>,
264 config_env: Option<HashMap<String, String>>,
265 #[serde(default)]
266 remove_read_only: Vec<String>,
267 #[serde(default)]
268 remove_mutating: Vec<String>,
269 #[serde(default)]
270 remove_allowed_with_config: Vec<String>,
271}
272
273#[derive(Debug, Deserialize, Default)]
274struct GhOverlay {
275 #[serde(default)]
276 replace: bool,
277 #[serde(default)]
278 read_only: Vec<String>,
279 #[serde(default)]
280 mutating: Vec<String>,
281 #[serde(default)]
282 allowed_with_config: Vec<String>,
283 config_env: Option<HashMap<String, String>>,
284 #[serde(default)]
285 remove_read_only: Vec<String>,
286 #[serde(default)]
287 remove_mutating: Vec<String>,
288 #[serde(default)]
289 remove_allowed_with_config: Vec<String>,
290}
291
292fn merge_list(base: &mut Vec<String>, add: Vec<String>, remove: &[String], replace: bool) {
298 if replace {
299 *base = add;
300 } else {
301 base.retain(|item| !remove.contains(item));
302 for item in add {
303 if !base.contains(&item) {
304 base.push(item);
305 }
306 }
307 }
308}
309
310impl Config {
311 pub fn default_config() -> Self {
313 toml::from_str(DEFAULT_CONFIG).expect("embedded default config must parse")
314 }
315
316 pub fn load() -> Self {
324 let mut config = Self::default_config();
325 if let Some(overlay) = Self::load_overlay() {
326 config.apply_overlay(overlay);
327 }
328 config
329 }
330
331 fn load_overlay() -> Option<ConfigOverlay> {
333 let home = std::env::var_os("HOME")?;
334 let path = std::path::Path::new(&home).join(".config/cc-toolgate/config.toml");
335 let content = std::fs::read_to_string(path).ok()?;
336 match toml::from_str(&content) {
337 Ok(overlay) => Some(overlay),
338 Err(e) => {
339 eprintln!("cc-toolgate: config parse error: {e}");
340 None
341 }
342 }
343 }
344
345 fn apply_overlay(&mut self, overlay: ConfigOverlay) {
347 if let Some(v) = overlay.settings.escalate_deny {
349 self.settings.escalate_deny = v;
350 }
351
352 let c = overlay.commands;
354 merge_list(
355 &mut self.commands.allow,
356 c.allow,
357 &c.remove_allow,
358 c.replace,
359 );
360 merge_list(&mut self.commands.ask, c.ask, &c.remove_ask, c.replace);
361 merge_list(&mut self.commands.deny, c.deny, &c.remove_deny, c.replace);
362
363 let w = overlay.wrappers;
365 merge_list(
366 &mut self.wrappers.allow_floor,
367 w.allow_floor,
368 &w.remove_allow_floor,
369 w.replace,
370 );
371 merge_list(
372 &mut self.wrappers.ask_floor,
373 w.ask_floor,
374 &w.remove_ask_floor,
375 w.replace,
376 );
377
378 let g = overlay.git;
380 merge_list(
381 &mut self.git.read_only,
382 g.read_only,
383 &g.remove_read_only,
384 g.replace,
385 );
386 merge_list(
387 &mut self.git.allowed_with_config,
388 g.allowed_with_config,
389 &g.remove_allowed_with_config,
390 g.replace,
391 );
392 merge_list(
393 &mut self.git.force_push_flags,
394 g.force_push_flags,
395 &g.remove_force_push_flags,
396 g.replace,
397 );
398 if let Some(v) = g.config_env {
399 self.git.config_env = v;
400 }
401
402 let ca = overlay.cargo;
404 merge_list(
405 &mut self.cargo.safe_subcommands,
406 ca.safe_subcommands,
407 &ca.remove_safe_subcommands,
408 ca.replace,
409 );
410 merge_list(
411 &mut self.cargo.allowed_with_config,
412 ca.allowed_with_config,
413 &ca.remove_allowed_with_config,
414 ca.replace,
415 );
416 if let Some(v) = ca.config_env {
417 self.cargo.config_env = v;
418 }
419
420 let k = overlay.kubectl;
422 merge_list(
423 &mut self.kubectl.read_only,
424 k.read_only,
425 &k.remove_read_only,
426 k.replace,
427 );
428 merge_list(
429 &mut self.kubectl.mutating,
430 k.mutating,
431 &k.remove_mutating,
432 k.replace,
433 );
434 merge_list(
435 &mut self.kubectl.allowed_with_config,
436 k.allowed_with_config,
437 &k.remove_allowed_with_config,
438 k.replace,
439 );
440 if let Some(v) = k.config_env {
441 self.kubectl.config_env = v;
442 }
443
444 let gh = overlay.gh;
446 merge_list(
447 &mut self.gh.read_only,
448 gh.read_only,
449 &gh.remove_read_only,
450 gh.replace,
451 );
452 merge_list(
453 &mut self.gh.mutating,
454 gh.mutating,
455 &gh.remove_mutating,
456 gh.replace,
457 );
458 merge_list(
459 &mut self.gh.allowed_with_config,
460 gh.allowed_with_config,
461 &gh.remove_allowed_with_config,
462 gh.replace,
463 );
464 if let Some(v) = gh.config_env {
465 self.gh.config_env = v;
466 }
467 }
468
469 #[cfg(test)]
471 fn apply_overlay_str(&mut self, toml_str: &str) {
472 let overlay: ConfigOverlay = toml::from_str(toml_str).unwrap();
473 self.apply_overlay(overlay);
474 }
475}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480
481 #[test]
482 fn default_config_parses() {
483 let config = Config::default_config();
484 assert!(!config.commands.allow.is_empty());
485 assert!(!config.commands.ask.is_empty());
486 assert!(!config.commands.deny.is_empty());
487 assert!(!config.git.read_only.is_empty());
488 assert!(!config.cargo.safe_subcommands.is_empty());
489 assert!(!config.kubectl.read_only.is_empty());
490 assert!(!config.gh.read_only.is_empty());
491 }
492
493 #[test]
494 fn default_config_has_expected_commands() {
495 let config = Config::default_config();
496 assert!(config.commands.allow.contains(&"ls".to_string()));
497 assert!(config.commands.ask.contains(&"rm".to_string()));
498 assert!(config.commands.deny.contains(&"shred".to_string()));
499 }
500
501 #[test]
502 fn default_escalate_deny_is_false() {
503 let config = Config::default_config();
504 assert!(!config.settings.escalate_deny);
505 }
506
507 #[test]
508 fn default_git_env_gate_disabled() {
509 let config = Config::default_config();
510 assert!(config.git.config_env.is_empty());
511 assert!(config.git.allowed_with_config.is_empty());
512 }
513
514 #[test]
517 fn overlay_extends_allow_list() {
518 let mut config = Config::default_config();
519 config.apply_overlay_str(
520 r#"
521 [commands]
522 allow = ["my-tool"]
523 "#,
524 );
525 assert!(config.commands.allow.contains(&"ls".to_string()));
527 assert!(config.commands.allow.contains(&"my-tool".to_string()));
529 }
530
531 #[test]
532 fn overlay_removes_from_allow_list() {
533 let mut config = Config::default_config();
534 config.apply_overlay_str(
535 r#"
536 [commands]
537 remove_allow = ["cat", "find"]
538 "#,
539 );
540 assert!(!config.commands.allow.contains(&"cat".to_string()));
541 assert!(!config.commands.allow.contains(&"find".to_string()));
542 assert!(config.commands.allow.contains(&"ls".to_string()));
544 }
545
546 #[test]
547 fn default_wrappers_populated() {
548 let config = Config::default_config();
549 assert!(config.wrappers.allow_floor.contains(&"xargs".to_string()));
550 assert!(config.wrappers.allow_floor.contains(&"env".to_string()));
551 assert!(config.wrappers.ask_floor.contains(&"sudo".to_string()));
552 assert!(config.wrappers.ask_floor.contains(&"doas".to_string()));
553 assert!(!config.commands.allow.contains(&"xargs".to_string()));
555 assert!(!config.commands.allow.contains(&"env".to_string()));
556 assert!(!config.commands.ask.contains(&"sudo".to_string()));
557 }
558
559 #[test]
560 fn overlay_removes_from_wrappers() {
561 let mut config = Config::default_config();
562 config.apply_overlay_str(
563 r#"
564 [wrappers]
565 remove_allow_floor = ["xargs"]
566 "#,
567 );
568 assert!(!config.wrappers.allow_floor.contains(&"xargs".to_string()));
569 assert!(config.wrappers.allow_floor.contains(&"env".to_string()));
571 }
572
573 #[test]
574 fn overlay_extends_wrappers() {
575 let mut config = Config::default_config();
576 config.apply_overlay_str(
577 r#"
578 [wrappers]
579 allow_floor = ["my-wrapper"]
580 "#,
581 );
582 assert!(
583 config
584 .wrappers
585 .allow_floor
586 .contains(&"my-wrapper".to_string())
587 );
588 assert!(config.wrappers.allow_floor.contains(&"xargs".to_string()));
589 }
590
591 #[test]
592 fn overlay_replace_commands() {
593 let mut config = Config::default_config();
594 config.apply_overlay_str(
595 r#"
596 [commands]
597 replace = true
598 allow = ["ls", "cat"]
599 ask = ["rm"]
600 deny = ["shred"]
601 "#,
602 );
603 assert_eq!(config.commands.allow, vec!["ls", "cat"]);
604 assert_eq!(config.commands.ask, vec!["rm"]);
605 assert_eq!(config.commands.deny, vec!["shred"]);
606 }
607
608 #[test]
609 fn overlay_git_env_gate() {
610 let mut config = Config::default_config();
611 config.apply_overlay_str(
612 r#"
613 [git]
614 allowed_with_config = ["commit", "add", "push"]
615 [git.config_env]
616 GIT_CONFIG_GLOBAL = "~/.gitconfig.ai"
617 "#,
618 );
619 assert_eq!(
620 config.git.config_env.get("GIT_CONFIG_GLOBAL").unwrap(),
621 "~/.gitconfig.ai"
622 );
623 assert_eq!(
624 config.git.allowed_with_config,
625 vec!["commit", "add", "push"]
626 );
627 assert!(config.git.read_only.contains(&"status".to_string()));
629 assert!(config.git.read_only.contains(&"log".to_string()));
630 }
631
632 #[test]
633 fn overlay_escalate_deny() {
634 let mut config = Config::default_config();
635 config.apply_overlay_str(
636 r#"
637 [settings]
638 escalate_deny = true
639 "#,
640 );
641 assert!(config.settings.escalate_deny);
642 }
643
644 #[test]
645 fn overlay_omitted_settings_unchanged() {
646 let mut config = Config::default_config();
647 config.apply_overlay_str(
648 r#"
649 [commands]
650 allow = ["my-tool"]
651 "#,
652 );
653 assert!(!config.settings.escalate_deny);
655 }
656
657 #[test]
658 fn overlay_no_duplicates() {
659 let mut config = Config::default_config();
660 config.apply_overlay_str(
661 r#"
662 [commands]
663 allow = ["ls"]
664 "#,
665 );
666 let count = config.commands.allow.iter().filter(|s| *s == "ls").count();
667 assert_eq!(count, 1);
668 }
669
670 #[test]
671 fn overlay_remove_and_add() {
672 let mut config = Config::default_config();
673 config.apply_overlay_str(
675 r#"
676 [commands]
677 remove_deny = ["eval"]
678 ask = ["eval"]
679 "#,
680 );
681 assert!(!config.commands.deny.contains(&"eval".to_string()));
682 assert!(config.commands.ask.contains(&"eval".to_string()));
683 }
684
685 #[test]
686 fn overlay_replace_git() {
687 let mut config = Config::default_config();
688 config.apply_overlay_str(
689 r#"
690 [git]
691 replace = true
692 read_only = ["status", "log"]
693 force_push_flags = ["--force"]
694 "#,
695 );
696 assert_eq!(config.git.read_only, vec!["status", "log"]);
697 assert_eq!(config.git.force_push_flags, vec!["--force"]);
698 assert!(config.git.allowed_with_config.is_empty());
699 }
700
701 #[test]
702 fn overlay_unrelated_sections_untouched() {
703 let mut config = Config::default_config();
704 let original_kubectl_read_only = config.kubectl.read_only.clone();
705 config.apply_overlay_str(
706 r#"
707 [git]
708 allowed_with_config = ["push"]
709 config_env_var = "GIT_CONFIG_GLOBAL"
710 "#,
711 );
712 assert_eq!(config.kubectl.read_only, original_kubectl_read_only);
713 }
714
715 #[test]
716 fn empty_overlay_changes_nothing() {
717 let original = Config::default_config();
718 let mut config = Config::default_config();
719 config.apply_overlay_str("");
720 assert_eq!(config.commands.allow.len(), original.commands.allow.len());
721 assert_eq!(config.git.read_only.len(), original.git.read_only.len());
722 }
723}