use crate::error::{Error, Result};
use crate::model::Severity;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TelemetryMode {
Off,
#[default]
Aggregate,
Full,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AutoApplyGrant {
pub analyzer: String,
pub targets: Vec<String>,
pub max_severity: Severity,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Policy {
#[serde(default)]
pub auto_apply_enabled: bool,
#[serde(default)]
pub auto_apply: Vec<AutoApplyGrant>,
#[serde(default)]
pub deny: Vec<String>,
#[serde(default)]
pub severity_floors: BTreeMap<String, Severity>,
#[serde(default)]
pub telemetry: TelemetryMode,
}
impl Policy {
pub fn from_json(s: &str) -> Result<Self> {
serde_json::from_str(s).map_err(|e| Error::InvalidProposal(format!("policy: {e}")))
}
pub fn denies(&self, family: &str) -> bool {
self.deny.iter().any(|d| crate::manifest::analyzer_family(d) == family)
}
pub fn severity_floor(&self, family: &str) -> Option<Severity> {
self.severity_floors
.iter()
.find(|(k, _)| crate::manifest::analyzer_family(k) == family)
.map(|(_, v)| *v)
}
pub fn grants_auto_apply(&self, family: &str, target_class: &str, severity: Severity) -> bool {
if !self.auto_apply_enabled || target_class != "memory" {
return false;
}
self.auto_apply.iter().any(|g| {
crate::manifest::analyzer_family(&g.analyzer) == family
&& g.targets.iter().any(|t| t == target_class)
&& severity <= g.max_severity
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn code_targets_never_auto_apply_even_when_granted() {
let p = Policy::from_json(
r#"{"auto_apply_enabled": true,
"auto_apply": [{"analyzer": "loop.codegen", "targets": ["code", "evalset", "memory"], "max_severity": "high"}]}"#,
)
.unwrap();
assert!(!p.grants_auto_apply("loop.codegen", "code", Severity::Info));
assert!(!p.grants_auto_apply("loop.codegen", "evalset", Severity::Info));
assert!(
p.grants_auto_apply("loop.codegen", "memory", Severity::Low),
"the same grant's memory leg still works — the exclusion is by class"
);
}
#[test]
fn default_policy_grants_nothing() {
let p = Policy::default();
assert!(!p.grants_auto_apply("loop.duplicate_sweep", "memory", Severity::Info));
assert!(!p.denies("loop.staleness"));
assert_eq!(p.telemetry, TelemetryMode::Aggregate);
}
#[test]
fn parses_and_grants() {
let p = Policy::from_json(
r#"{"auto_apply_enabled": true,
"auto_apply": [{"analyzer": "loop.duplicate_sweep", "targets": ["memory"], "max_severity": "low"}],
"deny": ["loop.staleness"],
"severity_floors": {"loop.contradiction_sweep": "high"}}"#,
)
.unwrap();
assert!(p.grants_auto_apply("loop.duplicate_sweep", "memory", Severity::Low));
assert!(!p.grants_auto_apply("loop.duplicate_sweep", "memory", Severity::High), "above max_severity");
assert!(!p.grants_auto_apply("loop.duplicate_sweep", "query", Severity::Low), "query not granted");
assert!(p.denies("loop.staleness"));
assert_eq!(p.severity_floor("loop.contradiction_sweep"), Some(Severity::High));
}
#[test]
fn prompt_and_host_targets_never_granted() {
let p = Policy::from_json(
r#"{"auto_apply_enabled": true,
"auto_apply": [{"analyzer": "x", "targets": ["prompt", "host"], "max_severity": "high"}]}"#,
)
.unwrap();
assert!(!p.grants_auto_apply("x", "prompt", Severity::Info));
assert!(!p.grants_auto_apply("x", "host", Severity::Info));
}
#[test]
fn unknown_keys_rejected() {
assert!(Policy::from_json(r#"{"analyzer_cmd": "evil"}"#).is_err());
assert!(Policy::from_json(r#"{"auto_apply_free_text": true}"#).is_err());
}
}