pub mod context;
pub mod decision;
pub use context::CommandContext;
pub use decision::{Decision, RuleMatch};
use std::collections::HashMap;
use crate::commands::CommandSpec;
use crate::config::Config;
use crate::parse;
use crate::parse::Operator;
fn is_likely_successful(segment: &str) -> bool {
if segment.contains("__SUBST__") {
return false;
}
let words = parse::tokenize(segment);
if words.is_empty() {
return false;
}
if words.len() == 1 && words[0].contains('=') {
return parse_assignment(&words[0]).is_some();
}
let base = parse::base_command(segment);
match base.as_str() {
"export" | "unset" => true,
"true" => true,
"echo" | "printf" => true,
_ => false,
}
}
fn is_var_name(s: &str) -> bool {
!s.is_empty()
&& s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& s.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
}
fn parse_assignment(token: &str) -> Option<(String, String)> {
let eq_pos = token.find('=')?;
let key = &token[..eq_pos];
let val = &token[eq_pos + 1..];
if is_var_name(key) {
Some((key.to_string(), val.to_string()))
} else {
None
}
}
fn extract_segment_env(segment: &str) -> Vec<(String, String)> {
let words = parse::tokenize(segment);
if words.is_empty() {
return Vec::new();
}
if words.len() == 1 {
return parse_assignment(&words[0]).into_iter().collect();
}
if words[0] == "export" {
return words[1..]
.iter()
.filter(|w| !w.starts_with('-')) .filter_map(|w| parse_assignment(w))
.collect();
}
Vec::new()
}
fn extract_unset_vars(segment: &str) -> Vec<String> {
let words = parse::tokenize(segment);
if words.is_empty() || words[0] != "unset" {
return Vec::new();
}
let mut result = Vec::new();
let mut unsetting_functions = false;
for word in &words[1..] {
if word == "-f" {
unsetting_functions = true;
} else if word == "-v" {
unsetting_functions = false;
} else if !word.starts_with('-') && !unsetting_functions && is_var_name(word) {
result.push(word.clone());
}
}
result
}
pub struct CommandRegistry {
specs: HashMap<String, Box<dyn CommandSpec>>,
wrappers: HashMap<String, Decision>,
escalate_deny: bool,
project_overlay_path: Option<std::path::PathBuf>,
}
impl CommandRegistry {
pub fn from_config(config: &Config) -> Self {
use crate::commands::{
simple::SimpleCommandSpec,
tools::{cargo::CargoSpec, gh::GhSpec, git::GitSpec, kubectl::KubectlSpec},
};
let mut specs: HashMap<String, Box<dyn CommandSpec>> = HashMap::new();
for name in &config.commands.deny {
specs.insert(
name.clone(),
Box::new(SimpleCommandSpec::new(Decision::Deny)),
);
}
for name in &config.commands.allow {
specs.insert(
name.clone(),
Box::new(SimpleCommandSpec::new(Decision::Allow)),
);
}
for name in &config.commands.ask {
specs.insert(
name.clone(),
Box::new(SimpleCommandSpec::new(Decision::Ask)),
);
}
specs.insert("git".into(), Box::new(GitSpec::from_config(&config.git)));
specs.insert(
"cargo".into(),
Box::new(CargoSpec::from_config(&config.cargo)),
);
specs.insert(
"kubectl".into(),
Box::new(KubectlSpec::from_config(&config.kubectl)),
);
specs.insert("gh".into(), Box::new(GhSpec::from_config(&config.gh)));
let mut wrappers = HashMap::new();
for name in &config.wrappers.allow_floor {
specs.remove(name);
wrappers.insert(name.clone(), Decision::Allow);
}
for name in &config.wrappers.ask_floor {
specs.remove(name);
wrappers.insert(name.clone(), Decision::Ask);
}
Self {
specs,
wrappers,
escalate_deny: config.settings.escalate_deny,
project_overlay_path: config.project_overlay_path.clone(),
}
}
pub fn set_escalate_deny(&mut self, escalate: bool) {
self.escalate_deny = escalate;
}
fn get(&self, name: &str) -> Option<&dyn CommandSpec> {
self.specs.get(name).map(|b| b.as_ref())
}
fn wrapper_floor(&self, name: &str) -> Option<Decision> {
self.wrappers.get(name).copied()
}
fn extract_wrapped_command(ctx: &CommandContext) -> String {
let iter = ctx.words.iter().skip(1);
if ctx.base_command == "env" {
let mut rest: Vec<&str> = Vec::new();
let mut found_cmd = false;
for word in iter {
if found_cmd {
rest.push(word);
} else if word.starts_with('-') {
continue; } else if word.contains('=') {
continue; } else {
found_cmd = true;
rest.push(word);
}
}
rest.join(" ")
} else {
let non_flags: Vec<&str> = iter
.skip_while(|w| w.starts_with('-'))
.map(|s| s.as_str())
.collect();
let cmd_start = non_flags
.iter()
.position(|w| !w.chars().all(|c| c.is_ascii_digit() || c == '.'))
.unwrap_or(non_flags.len());
non_flags[cmd_start..].join(" ")
}
}
fn maybe_escalate(&self, mut result: RuleMatch) -> RuleMatch {
if self.escalate_deny && result.decision == Decision::Deny {
result.decision = Decision::Ask;
result.reason = format!("{} (escalated from deny)", result.reason);
}
result
}
fn maybe_annotate_project_overlay(&self, mut result: RuleMatch) -> RuleMatch {
if result.decision == Decision::Ask
&& let Some(ref path) = self.project_overlay_path
{
result.reason = format!(
"{} (project config at {} contributed to this decision)",
result.reason,
path.display()
);
}
result
}
pub fn evaluate_single(&self, command: &str) -> RuleMatch {
let result = self.evaluate_single_with_env(command, &HashMap::new());
self.maybe_annotate_project_overlay(result)
}
fn evaluate_single_with_env(
&self,
command: &str,
accumulated_env: &HashMap<String, String>,
) -> RuleMatch {
let cmd = command.trim();
if cmd.is_empty() {
return RuleMatch {
decision: Decision::Allow,
reason: "empty".into(),
};
}
let words = parse::tokenize(cmd);
if words.len() == 1 && parse_assignment(&words[0]).is_some() {
return RuleMatch {
decision: Decision::Allow,
reason: format!("variable assignment: {}", words[0]),
};
}
let mut ctx = CommandContext::from_command(cmd);
ctx.accumulated_env = accumulated_env.clone();
if let Some(floor) = self.wrapper_floor(&ctx.base_command) {
let wrapped_cmd = Self::extract_wrapped_command(&ctx);
let mut strictest = floor;
let mut reason = if !wrapped_cmd.is_empty() {
let inner_env = if ctx.base_command == "env" && ctx.has_any_flag(&["-i", "-"]) {
HashMap::new()
} else {
accumulated_env.clone()
};
let inner = self.evaluate_single_with_env(&wrapped_cmd, &inner_env);
if inner.decision > strictest {
strictest = inner.decision;
}
format!("{} wraps: {}", ctx.base_command, inner.reason)
} else {
format!("{} (no wrapped command)", ctx.base_command)
};
if strictest == Decision::Allow && ctx.redirection.is_some() {
strictest = Decision::Ask;
reason = format!("{} with output redirection", reason);
}
return self.maybe_escalate(RuleMatch {
decision: strictest,
reason,
});
}
if let Some(spec) = self.get(&ctx.base_command) {
return self.maybe_escalate(spec.evaluate(&ctx));
}
if let Some(prefix) = ctx.base_command.split('.').next()
&& prefix != ctx.base_command
&& let Some(spec) = self.get(prefix)
{
return self.maybe_escalate(spec.evaluate(&ctx));
}
RuleMatch {
decision: Decision::Ask,
reason: format!("unrecognized command: {}", ctx.base_command),
}
}
pub fn evaluate(&self, command: &str) -> RuleMatch {
let (pipeline, substitutions) = parse::parse_with_substitutions(command);
if pipeline.segments.len() <= 1 && substitutions.is_empty() {
let is_passthrough = match pipeline.segments.first() {
Some(seg) => seg.command.trim() == command.trim(),
None => true,
};
if is_passthrough {
return self.evaluate_single(command);
}
}
let mut strictest = Decision::Allow;
let mut reasons = Vec::new();
for inner in &substitutions {
let result = self.evaluate(inner);
let label: String = inner.trim().chars().take(60).collect();
reasons.push(format!(
" subst[$({label})] -> {}: {}",
result.decision.label(),
result.reason
));
if result.decision > strictest {
strictest = result.decision;
}
}
let mut accumulated_env: HashMap<String, String> = HashMap::new();
let mut segment_executes = true;
for (i, segment) in pipeline.segments.iter().enumerate() {
if i > 0 {
let op = &pipeline.operators[i - 1];
match op {
Operator::Semi => segment_executes = true,
Operator::And => {
segment_executes = segment_executes
&& is_likely_successful(&pipeline.segments[i - 1].command);
}
Operator::Or | Operator::Pipe | Operator::PipeErr => {
segment_executes = false;
accumulated_env.clear();
}
}
}
let mut result = self.evaluate_single_with_env(&segment.command, &accumulated_env);
if segment_executes {
for (key, val) in extract_segment_env(&segment.command) {
accumulated_env.insert(key, val);
}
for var in extract_unset_vars(&segment.command) {
accumulated_env.remove(&var);
}
}
if result.decision == Decision::Allow
&& let Some(ref r) = segment.redirection
{
result.decision = Decision::Ask;
result.reason =
format!("{} (escalated: wrapping {})", result.reason, r.description);
}
let label: String = segment.command.trim().chars().take(60).collect();
reasons.push(format!(
" [{label}] -> {}: {}",
result.decision.label(),
result.reason
));
if result.decision > strictest {
strictest = result.decision;
}
}
let mut desc = Vec::new();
if !pipeline.operators.is_empty() {
let mut unique_ops: Vec<&str> = pipeline.operators.iter().map(|o| o.as_str()).collect();
unique_ops.sort();
unique_ops.dedup();
desc.push(unique_ops.join(", "));
}
if !substitutions.is_empty() {
desc.push(format!("{} substitution(s)", substitutions.len()));
}
let header = if desc.is_empty() {
"compound command".into()
} else {
format!("compound command ({})", desc.join("; "))
};
self.maybe_annotate_project_overlay(RuleMatch {
decision: strictest,
reason: format!("{}:\n{}", header, reasons.join("\n")),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn clear_git_env() {
assert!(
std::env::var("NEXTEST").is_ok(),
"this test mutates process env and requires nextest (cargo nextest run)"
);
unsafe { std::env::remove_var("GIT_CONFIG_GLOBAL") };
}
#[test]
fn likely_success_export() {
assert!(is_likely_successful("export FOO=bar"));
}
#[test]
fn likely_success_export_multiple() {
assert!(is_likely_successful("export A=1 B=2"));
}
#[test]
fn likely_success_bare_assignment() {
assert!(is_likely_successful("FOO=bar"));
}
#[test]
fn likely_success_true() {
assert!(is_likely_successful("true"));
}
#[test]
fn likely_success_echo() {
assert!(is_likely_successful("echo hello"));
}
#[test]
fn likely_success_printf() {
assert!(is_likely_successful("printf '%s\\n' hello"));
}
#[test]
fn likely_success_export_with_subshell_is_not_likely() {
assert!(!is_likely_successful("export FOO=__SUBST__"));
}
#[test]
fn likely_success_echo_with_subshell_is_not_likely() {
assert!(!is_likely_successful("echo __SUBST__"));
}
#[test]
fn likely_success_bare_assignment_with_subshell_is_not_likely() {
assert!(!is_likely_successful("FOO=__SUBST__"));
}
#[test]
fn likely_success_unknown_command() {
assert!(!is_likely_successful("some_command --flag"));
}
#[test]
fn likely_success_git() {
assert!(!is_likely_successful("git push"));
}
#[test]
fn likely_success_rm() {
assert!(!is_likely_successful("rm -rf /"));
}
#[test]
fn extract_env_export_single() {
let vars = extract_segment_env("export FOO=bar");
assert_eq!(vars, vec![("FOO".into(), "bar".into())]);
}
#[test]
fn extract_env_export_multiple() {
let vars = extract_segment_env("export A=1 B=2");
assert_eq!(
vars,
vec![("A".into(), "1".into()), ("B".into(), "2".into())]
);
}
#[test]
fn extract_env_export_with_path() {
let vars = extract_segment_env("export GIT_CONFIG_GLOBAL=~/.gitconfig.ai");
assert_eq!(
vars,
vec![("GIT_CONFIG_GLOBAL".into(), "~/.gitconfig.ai".into())]
);
}
#[test]
fn extract_env_bare_assignment() {
let vars = extract_segment_env("FOO=bar");
assert_eq!(vars, vec![("FOO".into(), "bar".into())]);
}
#[test]
fn extract_env_export_no_value() {
let vars = extract_segment_env("export FOO");
assert!(vars.is_empty());
}
#[test]
fn extract_env_export_flags() {
let vars = extract_segment_env("export -p");
assert!(vars.is_empty());
}
#[test]
fn extract_env_non_export() {
let vars = extract_segment_env("git push");
assert!(vars.is_empty());
}
fn registry_with_git_env_gate() -> CommandRegistry {
let mut config = crate::config::Config::default_config();
config.git.allowed_with_config = vec!["push".into(), "commit".into(), "add".into()];
config
.git
.config_env
.insert("GIT_CONFIG_GLOBAL".into(), "~/.gitconfig.ai".into());
CommandRegistry::from_config(&config)
}
#[test]
fn export_semicolon_git_push_allows() {
let reg = registry_with_git_env_gate();
let result =
reg.evaluate("export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; git push origin main");
assert_eq!(
result.decision,
Decision::Allow,
"reason: {}",
result.reason
);
}
#[test]
fn export_and_git_push_allows() {
let reg = registry_with_git_env_gate();
let result =
reg.evaluate("export GIT_CONFIG_GLOBAL=~/.gitconfig.ai && git push origin main");
assert_eq!(
result.decision,
Decision::Allow,
"reason: {}",
result.reason
);
}
#[test]
fn multiple_exports_and_git_push_allows() {
let reg = registry_with_git_env_gate();
let result = reg.evaluate(
"export PATH=/usr/bin && export GIT_CONFIG_GLOBAL=~/.gitconfig.ai && git push origin main",
);
assert_eq!(
result.decision,
Decision::Allow,
"reason: {}",
result.reason
);
}
#[test]
fn export_or_git_push_does_not_allow() {
clear_git_env();
let reg = registry_with_git_env_gate();
let result =
reg.evaluate("export GIT_CONFIG_GLOBAL=~/.gitconfig.ai || git push origin main");
assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
}
#[test]
fn export_pipe_git_push_does_not_allow() {
clear_git_env();
let reg = registry_with_git_env_gate();
let result =
reg.evaluate("export GIT_CONFIG_GLOBAL=~/.gitconfig.ai | git push origin main");
assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
}
#[test]
fn unknown_cmd_breaks_and_chain() {
let reg = registry_with_git_env_gate();
let result = reg.evaluate(
"export GIT_CONFIG_GLOBAL=~/.gitconfig.ai && unknown_cmd && git push origin main",
);
assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
}
#[test]
fn semicolon_after_unknown_cmd_resumes_accumulation() {
let reg = registry_with_git_env_gate();
let result = reg.evaluate(
"unknown_cmd ; export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; git push origin main",
);
assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
}
#[test]
fn semicolon_resumes_accumulation_all_known() {
let reg = registry_with_git_env_gate();
let result = reg.evaluate(
"echo starting ; export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; git push origin main",
);
assert_eq!(
result.decision,
Decision::Allow,
"reason: {}",
result.reason
);
}
#[test]
fn bare_assignment_semicolon_git_push_allows() {
let reg = registry_with_git_env_gate();
let result = reg.evaluate("GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; git push origin main");
assert_eq!(
result.decision,
Decision::Allow,
"reason: {}",
result.reason
);
}
#[test]
fn bare_assignment_and_git_push_allows() {
let reg = registry_with_git_env_gate();
let result = reg.evaluate("GIT_CONFIG_GLOBAL=~/.gitconfig.ai && git push origin main");
assert_eq!(
result.decision,
Decision::Allow,
"reason: {}",
result.reason
);
}
#[test]
fn wrong_export_value_still_asks() {
let reg = registry_with_git_env_gate();
let result =
reg.evaluate("export GIT_CONFIG_GLOBAL=~/.gitconfig.wrong && git push origin main");
assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
}
#[test]
fn export_overridden_by_later_export() {
let reg = registry_with_git_env_gate();
let result = reg.evaluate(
"export GIT_CONFIG_GLOBAL=wrong ; export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; git push origin main",
);
assert_eq!(
result.decision,
Decision::Allow,
"reason: {}",
result.reason
);
}
#[test]
fn or_after_export_clears_accumulated_env() {
clear_git_env();
let reg = registry_with_git_env_gate();
let result = reg.evaluate(
"export GIT_CONFIG_GLOBAL=~/.gitconfig.ai && echo ok || export OTHER=x && git push origin main",
);
assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
}
#[test]
fn echo_and_export_and_git_push_allows() {
let reg = registry_with_git_env_gate();
let result = reg.evaluate(
"echo 'Pushing...' && export GIT_CONFIG_GLOBAL=~/.gitconfig.ai && git push origin main",
);
assert_eq!(
result.decision,
Decision::Allow,
"reason: {}",
result.reason
);
}
#[test]
fn realistic_claude_pattern() {
let reg = registry_with_git_env_gate();
let result = reg.evaluate(
"export PATH=/home/user/.cargo/bin:/usr/bin && export GIT_CONFIG_GLOBAL=~/.gitconfig.ai && echo 'Pushing...' && git push -u origin feature-branch",
);
assert_eq!(
result.decision,
Decision::Allow,
"reason: {}",
result.reason
);
}
#[test]
fn force_push_still_asks_with_export() {
let reg = registry_with_git_env_gate();
let result = reg
.evaluate("export GIT_CONFIG_GLOBAL=~/.gitconfig.ai && git push --force origin main");
assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
}
#[test]
fn subshell_in_export_breaks_and_chain() {
let reg = registry_with_git_env_gate();
let result = reg.evaluate(
"export GIT_CONFIG_GLOBAL=$(cat ~/.gitconfig.ai.path) && git push origin main",
);
assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
}
#[test]
fn subshell_in_echo_breaks_and_chain() {
let reg = registry_with_git_env_gate();
let result = reg.evaluate(
"echo $(some_status_cmd) && export GIT_CONFIG_GLOBAL=~/.gitconfig.ai && git push origin main",
);
assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
}
#[test]
fn unset_removes_accumulated_var() {
clear_git_env();
let reg = registry_with_git_env_gate();
let result = reg.evaluate(
"export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; unset GIT_CONFIG_GLOBAL ; git push origin main",
);
assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
}
#[test]
fn unset_only_removes_named_var() {
let reg = registry_with_git_env_gate();
let result = reg.evaluate(
"export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; unset OTHER_VAR ; git push origin main",
);
assert_eq!(
result.decision,
Decision::Allow,
"reason: {}",
result.reason
);
}
#[test]
fn unset_f_does_not_remove_var() {
let reg = registry_with_git_env_gate();
let result = reg.evaluate(
"export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; unset -f GIT_CONFIG_GLOBAL ; git push origin main",
);
assert_eq!(
result.decision,
Decision::Allow,
"reason: {}",
result.reason
);
}
#[test]
fn extract_unset_single() {
assert_eq!(extract_unset_vars("unset FOO"), vec!["FOO"]);
}
#[test]
fn extract_unset_multiple() {
assert_eq!(extract_unset_vars("unset FOO BAR"), vec!["FOO", "BAR"]);
}
#[test]
fn extract_unset_with_v_flag() {
assert_eq!(extract_unset_vars("unset -v FOO"), vec!["FOO"]);
}
#[test]
fn extract_unset_with_f_flag() {
let result = extract_unset_vars("unset -f my_func");
assert!(result.is_empty());
}
#[test]
fn extract_unset_mixed_flags() {
assert_eq!(
extract_unset_vars("unset -f my_func -v MY_VAR"),
vec!["MY_VAR"]
);
}
#[test]
fn extract_unset_not_unset_cmd() {
assert!(extract_unset_vars("export FOO=bar").is_empty());
}
#[test]
fn env_i_clears_accumulated_env_for_wrapped_cmd() {
clear_git_env();
let reg = registry_with_git_env_gate();
let result =
reg.evaluate("export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; env -i git push origin main");
assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
}
#[test]
fn env_dash_clears_accumulated_env_for_wrapped_cmd() {
clear_git_env();
let reg = registry_with_git_env_gate();
let result =
reg.evaluate("export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; env - git push origin main");
assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
}
#[test]
fn env_without_i_passes_accumulated_env() {
let reg = registry_with_git_env_gate();
let result =
reg.evaluate("export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; env git push origin main");
assert_eq!(
result.decision,
Decision::Allow,
"reason: {}",
result.reason
);
}
fn registry_with_project_overlay() -> CommandRegistry {
let mut config = crate::config::Config::default_config();
config.project_overlay_path = Some(std::path::PathBuf::from(
"/fake/repo/.claude/cc-toolgate.toml",
));
CommandRegistry::from_config(&config)
}
#[test]
fn ask_decision_annotated_with_project_overlay_path() {
let reg = registry_with_project_overlay();
let result = reg.evaluate_single("curl https://example.com");
assert_eq!(result.decision, Decision::Ask);
assert!(
result.reason.contains("project config at"),
"ASK reason should mention project config; got: {}",
result.reason
);
assert!(
result
.reason
.contains("/fake/repo/.claude/cc-toolgate.toml"),
"ASK reason should include overlay path; got: {}",
result.reason
);
}
#[test]
fn allow_decision_not_annotated_with_project_overlay_path() {
let reg = registry_with_project_overlay();
let result = reg.evaluate_single("ls -la");
assert_eq!(result.decision, Decision::Allow);
assert!(
!result.reason.contains("project config at"),
"ALLOW reason should not mention project config; got: {}",
result.reason
);
}
#[test]
fn deny_decision_not_annotated_with_project_overlay_path() {
let reg = registry_with_project_overlay();
let result = reg.evaluate_single("shred /etc/passwd");
assert_eq!(result.decision, Decision::Deny);
assert!(
!result.reason.contains("project config at"),
"DENY reason should not mention project config; got: {}",
result.reason
);
}
#[test]
fn no_annotation_without_project_overlay() {
let config = crate::config::Config::default_config();
let reg = CommandRegistry::from_config(&config);
let result = reg.evaluate_single("curl https://example.com");
assert_eq!(result.decision, Decision::Ask);
assert!(
!result.reason.contains("project config at"),
"without project overlay, reason should not mention project config; got: {}",
result.reason
);
}
#[test]
fn compound_ask_decision_annotated_with_project_overlay() {
let reg = registry_with_project_overlay();
let result = reg.evaluate("ls -la ; curl https://example.com");
assert_eq!(result.decision, Decision::Ask);
assert!(
result.reason.contains("project config at"),
"compound ASK reason should mention project config; got: {}",
result.reason
);
}
}