use std::collections::HashMap;
use super::rules::{Decision, RuleSet};
use crate::config::glob_match;
#[derive(Debug, Clone)]
pub struct SourceRule {
pub pattern: String,
pub decision: Decision,
}
#[derive(Debug, Clone)]
pub struct Translated {
pub rules: RuleSet,
pub warnings: Vec<String>,
}
pub fn translate_last_match_to_first_match(source: &[SourceRule]) -> Translated {
let mut order: Vec<String> = Vec::new();
let mut last: HashMap<String, Decision> = HashMap::new();
for r in source {
if !last.contains_key(&r.pattern) {
order.push(r.pattern.clone());
}
last.insert(r.pattern.clone(), r.decision);
}
let mut rules = RuleSet::default();
for pattern in &order {
match last[pattern] {
Decision::Deny => rules.deny.push(pattern.clone()),
Decision::Ask => rules.ask.push(pattern.clone()),
Decision::Allow => rules.allow.push(pattern.clone()),
}
}
let mut warnings = Vec::new();
for pattern in &order {
let probe = pattern.as_str();
let source_decision = simulate_last_match(source, probe);
let target_decision = evaluate_self(&rules, pattern);
let (Some(sd), Some(td)) = (source_decision, target_decision) else {
continue;
};
if sd != td {
let safe = sd.stricter(td);
if safe != td {
reassign(&mut rules, pattern, safe);
}
warnings.push(format!(
"C5 translation: pattern `{pattern}` — source (last-match) resolves to \
{sd:?}, first-match-translated resolves to {td:?}; kept {safe:?} \
({} divergence)",
if safe == td {
"safe-direction"
} else {
"unsafe, corrected"
}
));
}
}
Translated { rules, warnings }
}
fn simulate_last_match(source: &[SourceRule], probe: &str) -> Option<Decision> {
let mut result = None;
for r in source {
if glob_match(&r.pattern, probe) || r.pattern == probe {
result = Some(r.decision);
}
}
result
}
fn evaluate_self(rules: &RuleSet, pattern: &str) -> Option<Decision> {
if let Some(open) = pattern.find('(') {
if let Some(subject) = pattern.strip_suffix(')').and_then(|p| p.get(open + 1..)) {
let tool = &pattern[..open];
return rules.evaluate(tool, Some(subject));
}
}
rules.evaluate(pattern, None)
}
fn reassign(rules: &mut RuleSet, pattern: &str, target_tier: Decision) {
rules.deny.retain(|p| p != pattern);
rules.ask.retain(|p| p != pattern);
rules.allow.retain(|p| p != pattern);
match target_tier {
Decision::Deny => rules.deny.push(pattern.to_string()),
Decision::Ask => rules.ask.push(pattern.to_string()),
Decision::Allow => rules.allow.push(pattern.to_string()),
}
}
pub fn opencode_default_policy() -> Translated {
let source = vec![
SourceRule {
pattern: "*".to_string(),
decision: Decision::Allow,
},
SourceRule {
pattern: "tools_question".to_string(),
decision: Decision::Deny,
},
SourceRule {
pattern: "plan_enter".to_string(),
decision: Decision::Deny,
},
SourceRule {
pattern: "plan_exit".to_string(),
decision: Decision::Deny,
},
SourceRule {
pattern: "read(*.env)".to_string(),
decision: Decision::Ask,
},
SourceRule {
pattern: "read(*.env.*)".to_string(),
decision: Decision::Ask,
},
SourceRule {
pattern: "read(*.env.example)".to_string(),
decision: Decision::Allow,
},
];
let mut translated = translate_last_match_to_first_match(&source);
translated.warnings.push(
"C5/S4 deviation 2 (doom_loop): opencode's `doom_loop: ask` carve-out is a repetition \
TRIGGER, not a rule-language pattern — routed to `Config::doom_loop_threshold` (the P4 \
doom-loop breaker) instead of a rule entry; not silently dropped."
.to_string(),
);
translated.warnings.push(
"C5/S4 deviation 3 (external_directory): opencode's `external_directory: ask` carve-out \
is a PERMISSION CATEGORY (any tool touching paths outside the worktree), not a tool \
name — routed to `Config::additional_dirs` instead of a rule entry (paths outside cwd \
and outside additional_dirs are simply unreachable, a stricter reading); not silently \
dropped."
.to_string(),
);
translated
}