use super::super::CommandSpec;
use crate::config::GhConfig;
use crate::eval::{CommandContext, Decision, RuleMatch};
use std::collections::HashMap;
pub struct GhSpec {
read_only: Vec<String>,
mutating: Vec<String>,
allowed_with_config: Vec<String>,
config_env: HashMap<String, String>,
}
impl GhSpec {
pub fn from_config(config: &GhConfig) -> Self {
Self {
read_only: config.read_only.clone(),
mutating: config.mutating.clone(),
allowed_with_config: config.allowed_with_config.clone(),
config_env: config.config_env.clone(),
}
}
fn subcommands(ctx: &CommandContext) -> (String, &str) {
let gh_pos = ctx.words.iter().position(|w| w == "gh");
let after_gh = gh_pos.map(|p| p + 1).unwrap_or(1);
let sub_two = if ctx.words.len() > after_gh + 1 {
format!("{} {}", ctx.words[after_gh], ctx.words[after_gh + 1])
} else {
String::new()
};
let sub_one: &str = ctx.words.get(after_gh).map(|w| w.as_str()).unwrap_or("?");
(sub_two, sub_one)
}
fn env_keys_display(&self) -> String {
let mut keys: Vec<&str> = self.config_env.keys().map(|k| k.as_str()).collect();
keys.sort();
keys.join(", ")
}
}
impl CommandSpec for GhSpec {
fn evaluate(&self, ctx: &CommandContext) -> RuleMatch {
let (sub_two, sub_one) = Self::subcommands(ctx);
let in_read_only = self.read_only.iter().any(|s| s == &sub_two)
|| self.read_only.iter().any(|s| s == sub_one);
if in_read_only {
if let Some(ref r) = ctx.redirection {
return RuleMatch {
decision: Decision::Ask,
reason: format!("gh {sub_one} with {}", r),
};
}
return RuleMatch {
decision: Decision::Allow,
reason: format!("read-only gh {sub_two}"),
};
}
let in_env_gated = self.allowed_with_config.iter().any(|s| s == &sub_two)
|| self.allowed_with_config.iter().any(|s| s == sub_one);
if in_env_gated {
if !self.config_env.is_empty() && ctx.env_satisfies(&self.config_env) {
if let Some(ref r) = ctx.redirection {
return RuleMatch {
decision: Decision::Ask,
reason: format!("gh {sub_one} with {}", r),
};
}
return RuleMatch {
decision: Decision::Allow,
reason: format!("gh {sub_two} with {}", self.env_keys_display()),
};
}
return RuleMatch {
decision: Decision::Ask,
reason: format!("gh {sub_two} requires confirmation"),
};
}
let in_mutating = self.mutating.iter().any(|s| s == &sub_two)
|| self.mutating.iter().any(|s| s == sub_one);
if in_mutating {
return RuleMatch {
decision: Decision::Ask,
reason: format!("gh {sub_two} requires confirmation"),
};
}
RuleMatch {
decision: Decision::Ask,
reason: format!("gh {sub_one} requires confirmation"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
fn spec() -> GhSpec {
GhSpec::from_config(&Config::default_config().gh)
}
fn eval(cmd: &str) -> Decision {
let s = spec();
let ctx = CommandContext::from_command(cmd);
s.evaluate(&ctx).decision
}
#[test]
fn allow_pr_list() {
assert_eq!(eval("gh pr list"), Decision::Allow);
}
#[test]
fn allow_pr_view() {
assert_eq!(eval("gh pr view 123"), Decision::Allow);
}
#[test]
fn allow_status() {
assert_eq!(eval("gh status"), Decision::Allow);
}
#[test]
fn allow_api() {
assert_eq!(eval("gh api repos/owner/repo/pulls"), Decision::Allow);
}
#[test]
fn ask_pr_create() {
assert_eq!(eval("gh pr create --title 'Fix'"), Decision::Ask);
}
#[test]
fn ask_pr_merge() {
assert_eq!(eval("gh pr merge 123"), Decision::Ask);
}
#[test]
fn ask_repo_delete() {
assert_eq!(eval("gh repo delete my-repo --yes"), Decision::Ask);
}
#[test]
fn redir_pr_list() {
assert_eq!(eval("gh pr list > /tmp/prs.txt"), Decision::Ask);
}
fn spec_with_env_gate() -> GhSpec {
GhSpec::from_config(&GhConfig {
read_only: vec!["pr list".into(), "pr view".into(), "status".into()],
mutating: vec!["repo delete".into()],
allowed_with_config: vec!["pr create".into(), "pr merge".into()],
config_env: HashMap::from([("GH_CONFIG_DIR".into(), "~/.config/gh-ai".into())]),
})
}
fn eval_with_env_gate(cmd: &str) -> Decision {
let s = spec_with_env_gate();
let ctx = CommandContext::from_command(cmd);
s.evaluate(&ctx).decision
}
#[test]
fn env_gate_pr_create_with_matching_value() {
assert_eq!(
eval_with_env_gate("GH_CONFIG_DIR=~/.config/gh-ai gh pr create --title 'Fix'"),
Decision::Allow
);
}
#[test]
fn env_gate_pr_create_with_wrong_value() {
assert_eq!(
eval_with_env_gate("GH_CONFIG_DIR=~/.config/gh gh pr create --title 'Fix'"),
Decision::Ask
);
}
#[test]
fn env_gate_pr_create_no_config() {
assert_eq!(
eval_with_env_gate("gh pr create --title 'Fix'"),
Decision::Ask
);
}
#[test]
fn env_gate_pr_merge_with_config() {
assert_eq!(
eval_with_env_gate("GH_CONFIG_DIR=~/.config/gh-ai gh pr merge 123"),
Decision::Allow
);
}
#[test]
fn env_gate_pr_list_still_readonly() {
assert_eq!(eval_with_env_gate("gh pr list"), Decision::Allow);
}
#[test]
fn env_gate_repo_delete_still_asks() {
assert_eq!(
eval_with_env_gate("GH_CONFIG_DIR=~/.config/gh-ai gh repo delete my-repo"),
Decision::Ask
);
}
}