use std::collections::BTreeMap;
use serde::Deserialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Actor {
Agent,
#[serde(rename = "self")]
Author,
Peer,
Team,
}
impl Actor {
const fn as_str(self) -> &'static str {
match self {
Actor::Agent => "agent",
Actor::Author => "self",
Actor::Peer => "peer",
Actor::Team => "team",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Autonomy {
Auto,
Draft,
Gate,
}
impl Autonomy {
const fn as_str(self) -> &'static str {
match self {
Autonomy::Auto => "auto",
Autonomy::Draft => "draft",
Autonomy::Gate => "gate",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Conduct {
pub(crate) actor: Actor,
pub(crate) autonomy: Autonomy,
}
impl Conduct {
pub(crate) fn label(self) -> String {
[self.actor.as_str(), "/", self.autonomy.as_str()].concat()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
struct StateOverride {
actor: Option<Actor>,
autonomy: Option<Autonomy>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case", default)]
pub(crate) struct ConductConfig {
default_actor: Option<Actor>,
default_autonomy: Option<Autonomy>,
#[serde(flatten)]
states: BTreeMap<String, StateOverride>,
}
const DEFAULT_ACTOR: Actor = Actor::Author;
fn baked_autonomy(state: &str) -> Autonomy {
match state {
"plan" | "reconcile" => Autonomy::Gate,
_ => Autonomy::Auto,
}
}
pub(crate) fn resolve(cfg: &ConductConfig, state: &str) -> Conduct {
let over = cfg.states.get(state);
let actor = over
.and_then(|o| o.actor)
.or(cfg.default_actor)
.unwrap_or(DEFAULT_ACTOR);
let autonomy = over
.and_then(|o| o.autonomy)
.or(cfg.default_autonomy)
.unwrap_or_else(|| baked_autonomy(state));
Conduct { actor, autonomy }
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(text: &str) -> anyhow::Result<ConductConfig> {
Ok(crate::dtoml::parse(text)?.conduct)
}
#[derive(Deserialize)]
struct ActorWrap {
a: Actor,
}
#[derive(Deserialize)]
struct AutonomyWrap {
a: Autonomy,
}
fn parse_actor(s: &str) -> Actor {
let w: ActorWrap = toml::from_str(&format!("a = \"{s}\"")).expect("actor parse");
w.a
}
fn parse_autonomy(s: &str) -> Autonomy {
let w: AutonomyWrap = toml::from_str(&format!("a = \"{s}\"")).expect("autonomy parse");
w.a
}
#[test]
fn actor_author_renames_to_self() {
assert_eq!(parse_actor("self"), Actor::Author);
assert_eq!(Actor::Author.as_str(), "self");
}
#[test]
fn actor_other_variants_are_kebab() {
assert_eq!(parse_actor("agent"), Actor::Agent);
assert_eq!(parse_actor("peer"), Actor::Peer);
assert_eq!(parse_actor("team"), Actor::Team);
}
#[test]
fn autonomy_variants_are_kebab() {
assert_eq!(parse_autonomy("auto"), Autonomy::Auto);
assert_eq!(parse_autonomy("draft"), Autonomy::Draft);
assert_eq!(parse_autonomy("gate"), Autonomy::Gate);
}
#[test]
fn absent_conduct_key_parses_to_defaults() {
let cfg = parse("title = \"some other doctrine.toml content\"\n").expect("parse");
assert_eq!(cfg, ConductConfig::default());
}
#[test]
fn empty_text_parses_to_defaults() {
assert_eq!(parse("").expect("parse"), ConductConfig::default());
}
#[test]
fn full_conduct_table_round_trips() {
let cfg = parse(
"[conduct]\n\
default-actor = \"agent\"\n\
default-autonomy = \"draft\"\n\
[conduct.plan]\n\
autonomy = \"gate\"\n\
[conduct.reconcile]\n\
actor = \"team\"\n\
autonomy = \"gate\"\n",
)
.expect("parse");
assert_eq!(cfg.default_actor, Some(Actor::Agent));
assert_eq!(cfg.default_autonomy, Some(Autonomy::Draft));
assert_eq!(
cfg.states.get("plan").and_then(|o| o.autonomy),
Some(Autonomy::Gate)
);
assert_eq!(
cfg.states.get("reconcile").and_then(|o| o.actor),
Some(Actor::Team)
);
}
#[test]
fn unknown_state_subtable_is_tolerated_not_errored() {
let cfg = parse("[conduct.foo]\nautonomy = \"gate\"\n").expect("parse");
assert!(cfg.states.contains_key("foo"));
}
#[test]
fn canary_documented_shape_parses() {
let cfg = parse(
"[conduct]\n\
default-actor = \"self\"\n\
default-autonomy = \"auto\"\n\
[conduct.plan]\n\
autonomy = \"gate\"\n\
[conduct.reconcile]\n\
autonomy = \"gate\"\n",
)
.expect("parse");
assert_eq!(
resolve(&cfg, "plan"),
Conduct {
actor: Actor::Author,
autonomy: Autonomy::Gate
}
);
}
#[test]
fn default_state_falls_back_to_self_auto() {
let c = resolve(&ConductConfig::default(), "started");
assert_eq!(
c,
Conduct {
actor: Actor::Author,
autonomy: Autonomy::Auto
}
);
}
#[test]
fn plan_and_reconcile_gate_by_default() {
let cfg = ConductConfig::default();
assert_eq!(resolve(&cfg, "plan").autonomy, Autonomy::Gate);
assert_eq!(resolve(&cfg, "reconcile").autonomy, Autonomy::Gate);
assert_eq!(resolve(&cfg, "plan").actor, Actor::Author);
}
#[test]
fn override_beats_default_per_field() {
let cfg = parse(
"[conduct]\n\
default-actor = \"agent\"\n\
[conduct.ready]\n\
autonomy = \"gate\"\n",
)
.expect("parse");
assert_eq!(
resolve(&cfg, "ready"),
Conduct {
actor: Actor::Agent,
autonomy: Autonomy::Gate
}
);
}
#[test]
fn override_beats_baked_gate_default() {
let cfg = parse("[conduct.plan]\nautonomy = \"auto\"\n").expect("parse");
assert_eq!(resolve(&cfg, "plan").autonomy, Autonomy::Auto);
}
#[test]
fn resolve_is_total_over_drifted_state() {
let c = resolve(&ConductConfig::default(), "totally-made-up-state");
assert_eq!(
c,
Conduct {
actor: Actor::Author,
autonomy: Autonomy::Auto
}
);
}
#[test]
fn shipped_template_is_valid_and_its_defaults_round_trip() {
let raw = crate::install::asset_text("doctrine.toml.example").expect("embedded template");
let cfg = parse(&raw).expect("template parses");
assert_eq!(cfg, ConductConfig::default());
let live = raw
.replace("# [conduct", "[conduct")
.replace("\n# autonomy", "\nautonomy");
let live = live
.replace("# default-actor", "default-actor")
.replace("# default-autonomy", "default-autonomy");
let cfg = parse(&live).expect("uncommented template parses");
assert_eq!(resolve(&cfg, "plan").label(), "self/gate");
assert_eq!(resolve(&cfg, "reconcile").label(), "self/gate");
assert_eq!(resolve(&cfg, "started").label(), "self/auto");
}
#[test]
fn label_renders_actor_slash_autonomy() {
assert_eq!(
Conduct {
actor: Actor::Author,
autonomy: Autonomy::Gate
}
.label(),
"self/gate"
);
assert_eq!(
Conduct {
actor: Actor::Agent,
autonomy: Autonomy::Auto
}
.label(),
"agent/auto"
);
assert_eq!(
Conduct {
actor: Actor::Peer,
autonomy: Autonomy::Draft
}
.label(),
"peer/draft"
);
}
}