use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum ActScope {
#[default]
None,
All,
Contexts(Vec<String>),
}
impl ActScope {
pub fn covers(&self, context_id: &str) -> bool {
match self {
ActScope::None => false,
ActScope::All => true,
ActScope::Contexts(cs) => cs
.iter()
.any(|c| crate::context_path::is_ancestor_or_self(c, context_id)),
}
}
pub fn is_unrestricted(&self) -> bool {
matches!(self, ActScope::All)
}
pub fn acts_nowhere(&self) -> bool {
matches!(self, ActScope::None)
}
pub fn named_contexts(&self) -> &[String] {
match self {
ActScope::Contexts(cs) => cs,
_ => &[],
}
}
}
impl std::fmt::Display for ActScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ActScope::None => f.write_str("(none — acts nowhere)"),
ActScope::All => f.write_str("(unrestricted)"),
ActScope::Contexts(cs) => f.write_str(&cs.join(", ")),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case", tag = "kind", content = "contexts")]
pub enum ApproveScope {
#[default]
None,
All,
Contexts(Vec<String>),
}
impl ApproveScope {
pub fn covers(&self, context_id: &str) -> bool {
match self {
ApproveScope::None => false,
ApproveScope::All => true,
ApproveScope::Contexts(cs) => cs
.iter()
.any(|c| crate::context_path::is_ancestor_or_self(c, context_id)),
}
}
pub fn confers_nothing(&self) -> bool {
matches!(self, ApproveScope::None)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wire_shape_is_pinned() {
let cases = [
(ApproveScope::None, r#"{"kind":"none"}"#),
(ApproveScope::All, r#"{"kind":"all"}"#),
(
ApproveScope::Contexts(vec!["a".into(), "b/c".into()]),
r#"{"kind":"contexts","contexts":["a","b/c"]}"#,
),
];
for (scope, json) in cases {
assert_eq!(serde_json::to_string(&scope).unwrap(), json);
assert_eq!(
serde_json::from_str::<ApproveScope>(json).unwrap(),
scope,
"round trip for {json}"
);
}
}
#[test]
fn absent_defaults_to_conferring_nothing() {
assert_eq!(ApproveScope::default(), ApproveScope::None);
assert!(ApproveScope::default().confers_nothing());
}
#[test]
fn act_and_approve_agree_on_coverage() {
for ctx in ["acme", "acme/eng", "acme-corp", "other"] {
assert_eq!(
ActScope::Contexts(vec!["acme".into()]).covers(ctx),
ApproveScope::Contexts(vec!["acme".into()]).covers(ctx),
"disagreement on {ctx}"
);
}
assert_eq!(ActScope::All.covers("x"), ApproveScope::All.covers("x"));
assert_eq!(ActScope::None.covers("x"), ApproveScope::None.covers("x"));
}
#[test]
fn act_scope_defaults_to_nothing() {
assert_eq!(ActScope::default(), ActScope::None);
assert!(ActScope::default().acts_nowhere());
assert!(!ActScope::default().is_unrestricted());
}
#[test]
fn display_distinguishes_nothing_from_everything() {
assert_eq!(ActScope::All.to_string(), "(unrestricted)");
assert_eq!(ActScope::None.to_string(), "(none — acts nowhere)");
assert_ne!(ActScope::None.to_string(), ActScope::All.to_string());
assert_eq!(
ActScope::Contexts(vec!["a".into(), "b".into()]).to_string(),
"a, b"
);
}
#[test]
fn named_contexts_is_empty_for_the_unlistable_variants() {
assert!(ActScope::All.named_contexts().is_empty());
assert!(ActScope::None.named_contexts().is_empty());
assert_eq!(
ActScope::Contexts(vec!["a".into()]).named_contexts(),
["a".to_string()]
);
}
#[test]
fn covers_is_subtree_aware() {
let scope = ApproveScope::Contexts(vec!["acme".into()]);
assert!(scope.covers("acme"));
assert!(scope.covers("acme/eng"));
assert!(!scope.covers("acme-corp"), "sibling must not match");
assert!(!scope.covers("other"));
assert!(ApproveScope::All.covers("anything"));
assert!(!ApproveScope::None.covers("anything"));
}
}