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 acts_within(&self, context_id: &str) -> bool {
match self {
ActScope::None | ActScope::All => false,
ActScope::Contexts(cs) => cs
.iter()
.any(|c| crate::context_path::is_ancestor_or_self(context_id, c)),
}
}
pub fn matches_context(&self, context_id: &str, direction: ContextDirection) -> bool {
match direction {
ContextDirection::ActingIn => self.covers(context_id),
ContextDirection::Subtree => self.acts_within(context_id),
ContextDirection::Any => self.covers(context_id) || self.acts_within(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,
_ => &[],
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "kebab-case")]
pub enum ContextDirection {
#[default]
ActingIn,
Subtree,
Any,
}
impl ContextDirection {
pub const ALL: [ContextDirection; 3] = [
ContextDirection::ActingIn,
ContextDirection::Subtree,
ContextDirection::Any,
];
pub fn as_str(&self) -> &'static str {
match self {
ContextDirection::ActingIn => "acting-in",
ContextDirection::Subtree => "subtree",
ContextDirection::Any => "any",
}
}
}
impl std::fmt::Display for ContextDirection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for ContextDirection {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::ALL
.into_iter()
.find(|d| d.as_str() == s)
.ok_or_else(|| {
let valid = Self::ALL
.iter()
.map(|d| d.as_str())
.collect::<Vec<_>>()
.join(", ");
format!("invalid context direction '{s}', expected one of: {valid}")
})
}
}
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"));
}
#[test]
fn the_two_directions_are_mirrors() {
let ancestor = ActScope::Contexts(vec!["acme".into()]);
let exact = ActScope::Contexts(vec!["acme/eng".into()]);
let descendant = ActScope::Contexts(vec!["acme/eng/team-a".into()]);
let sibling = ActScope::Contexts(vec!["acme/ops".into()]);
let q = "acme/eng";
assert!(ancestor.covers(q));
assert!(exact.covers(q));
assert!(!descendant.covers(q), "a leaf holds no authority over q");
assert!(!sibling.covers(q));
assert!(!ancestor.acts_within(q), "a parent grant is not inside q");
assert!(exact.acts_within(q), "self is inside its own subtree");
assert!(descendant.acts_within(q), "the sweep's whole point");
assert!(!sibling.acts_within(q));
}
#[test]
fn acts_within_is_segment_aware() {
let scope = ActScope::Contexts(vec!["acme/engineering".into()]);
assert!(!scope.acts_within("acme/eng"));
assert!(scope.acts_within("acme"));
}
#[test]
fn unrestricted_is_reported_by_acting_in_and_any_but_not_subtree() {
let all = ActScope::All;
assert!(all.matches_context("acme/eng", ContextDirection::ActingIn));
assert!(!all.matches_context("acme/eng", ContextDirection::Subtree));
assert!(all.matches_context("acme/eng", ContextDirection::Any));
}
#[test]
fn acts_nowhere_matches_no_direction() {
for d in ContextDirection::ALL {
assert!(
!ActScope::None.matches_context("acme", d),
"acts-nowhere matched {d}"
);
}
}
#[test]
fn a_partially_inside_scope_is_surfaced() {
let scope = ActScope::Contexts(vec!["acme/eng/team-a".into(), "other".into()]);
assert!(scope.acts_within("acme/eng"));
}
#[test]
fn any_is_the_union_of_both_legs() {
let scopes = [
ActScope::None,
ActScope::All,
ActScope::Contexts(vec!["acme".into()]),
ActScope::Contexts(vec!["acme/eng".into()]),
ActScope::Contexts(vec!["acme/eng/team-a".into()]),
ActScope::Contexts(vec!["other".into()]),
];
for scope in scopes {
let acting_in = scope.matches_context("acme/eng", ContextDirection::ActingIn);
let subtree = scope.matches_context("acme/eng", ContextDirection::Subtree);
assert_eq!(
scope.matches_context("acme/eng", ContextDirection::Any),
acting_in || subtree,
"union broken for {scope:?}"
);
}
}
#[test]
fn default_direction_is_the_historical_one() {
assert_eq!(ContextDirection::default(), ContextDirection::ActingIn);
let scope = ActScope::Contexts(vec!["acme".into()]);
for ctx in ["acme", "acme/eng", "acme-corp", "other"] {
assert_eq!(
scope.matches_context(ctx, ContextDirection::default()),
scope.covers(ctx),
"default direction diverged from `covers` at {ctx}"
);
}
}
#[test]
fn wire_spelling_is_pinned_and_round_trips() {
let cases = [
(ContextDirection::ActingIn, "acting-in"),
(ContextDirection::Subtree, "subtree"),
(ContextDirection::Any, "any"),
];
for (direction, wire) in cases {
assert_eq!(direction.as_str(), wire);
assert_eq!(direction.to_string(), wire);
assert_eq!(
serde_json::to_string(&direction).unwrap(),
format!("\"{wire}\"")
);
assert_eq!(
serde_json::from_str::<ContextDirection>(&format!("\"{wire}\"")).unwrap(),
direction
);
assert_eq!(wire.parse::<ContextDirection>().unwrap(), direction);
}
}
#[test]
fn an_unknown_direction_is_refused_with_the_valid_set() {
let err = "descendants".parse::<ContextDirection>().unwrap_err();
assert!(err.contains("descendants"), "{err}");
for d in ContextDirection::ALL {
assert!(err.contains(d.as_str()), "{err} omits {d}");
}
assert!(serde_json::from_str::<ContextDirection>("\"ActingIn\"").is_err());
}
}