use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalResolver {
#[default]
Host,
AutoReview,
AllowAll,
}
impl ApprovalResolver {
pub const ALL: [Self; 3] = [Self::Host, Self::AutoReview, Self::AllowAll];
pub fn as_str(self) -> &'static str {
match self {
Self::Host => "host",
Self::AutoReview => "auto_review",
Self::AllowAll => "allow_all",
}
}
pub fn parse(value: &str) -> Option<Self> {
match value.trim() {
"host" | "ask" | "person" => Some(Self::Host),
"auto_review" | "auto-review" | "review" => Some(Self::AutoReview),
"allow_all" | "allow-all" | "yolo" | "full_auto" | "full-auto" => Some(Self::AllowAll),
_ => None,
}
}
pub fn answers_ask_without_a_person(self) -> bool {
matches!(self, Self::AutoReview | Self::AllowAll)
}
pub fn requires_reviewer(self) -> bool {
matches!(self, Self::AutoReview)
}
}
pub const APPROVAL_RESOLVER_RECEIPT_SCHEMA: &str = "harn.approval_resolver.v1";
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ApprovalResolverReceipt {
pub schema: String,
pub resolver: ApprovalResolver,
pub requested: ApprovalResolver,
#[serde(skip_serializing_if = "Option::is_none")]
pub reviewer_model: Option<String>,
}
impl ApprovalResolverReceipt {
pub fn new(
resolved: ApprovalResolver,
requested: ApprovalResolver,
reviewer_model: Option<String>,
) -> Self {
Self {
schema: APPROVAL_RESOLVER_RECEIPT_SCHEMA.to_string(),
resolver: resolved,
requested,
reviewer_model,
}
}
pub fn matches_request(&self) -> bool {
self.resolver == self.requested
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn host_cannot_answer_without_a_person() {
assert!(!ApprovalResolver::Host.answers_ask_without_a_person());
}
#[test]
fn review_and_allow_all_answer_without_a_person() {
assert!(ApprovalResolver::AutoReview.answers_ask_without_a_person());
assert!(ApprovalResolver::AllowAll.answers_ask_without_a_person());
}
#[test]
fn default_is_host() {
assert_eq!(ApprovalResolver::default(), ApprovalResolver::Host);
}
#[test]
fn downstream_yolo_spellings_resolve_to_allow_all() {
for spelling in ["allow_all", "allow-all", "yolo", "full_auto", "full-auto"] {
assert_eq!(
ApprovalResolver::parse(spelling),
Some(ApprovalResolver::AllowAll),
"{spelling} must reach the one owner of allow-all"
);
}
assert_eq!(ApprovalResolver::parse("nonsense"), None);
}
#[test]
fn only_auto_review_needs_a_reviewer_model() {
assert!(ApprovalResolver::AutoReview.requires_reviewer());
assert!(!ApprovalResolver::AllowAll.requires_reviewer());
assert!(!ApprovalResolver::Host.requires_reviewer());
}
#[test]
fn receipt_reports_a_silent_fallback_as_a_mismatch() {
let honest = ApprovalResolverReceipt::new(
ApprovalResolver::AutoReview,
ApprovalResolver::AutoReview,
Some("claude-haiku-4-5-20251001".to_string()),
);
assert!(honest.matches_request());
let fell_back = ApprovalResolverReceipt::new(
ApprovalResolver::Host,
ApprovalResolver::AutoReview,
None,
);
assert!(!fell_back.matches_request());
}
#[test]
fn receipt_serializes_with_the_schema_consumers_match_on() {
let receipt = ApprovalResolverReceipt::new(
ApprovalResolver::AllowAll,
ApprovalResolver::AllowAll,
None,
);
let json = serde_json::to_value(&receipt).expect("receipt serializes");
assert_eq!(json["schema"], APPROVAL_RESOLVER_RECEIPT_SCHEMA);
assert_eq!(json["resolver"], "allow_all");
assert_eq!(json["requested"], "allow_all");
assert!(json.get("reviewer_model").is_none());
}
}