use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
Goal,
Task,
Decision,
Question,
Constraint,
Finding,
Assumption,
Pillar,
Rule,
}
impl Kind {
pub fn prefix(self) -> char {
match self {
Kind::Goal => 'g',
Kind::Task => 't',
Kind::Decision => 'd',
Kind::Question => 'q',
Kind::Constraint => 'c',
Kind::Finding => 'f',
Kind::Assumption => 'a',
Kind::Pillar => 'p',
Kind::Rule => 'r',
}
}
pub fn parse(s: &str) -> Option<Kind> {
Some(match s {
"goal" => Kind::Goal,
"task" => Kind::Task,
"decision" => Kind::Decision,
"question" => Kind::Question,
"constraint" => Kind::Constraint,
"finding" => Kind::Finding,
"assumption" => Kind::Assumption,
"pillar" => Kind::Pillar,
"rule" => Kind::Rule,
_ => return None,
})
}
pub fn word(self) -> &'static str {
match self {
Kind::Goal => "goal",
Kind::Task => "task",
Kind::Decision => "decision",
Kind::Question => "question",
Kind::Constraint => "constraint",
Kind::Finding => "finding",
Kind::Assumption => "assumption",
Kind::Pillar => "pillar",
Kind::Rule => "rule",
}
}
pub const ALL: &'static str =
"goal, task, decision, question, constraint, finding, assumption, pillar, rule";
pub fn with_article(self) -> String {
let article = if self == Kind::Assumption { "an" } else { "a" };
format!("{article} {}", self.word())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum State {
Active,
Done,
Suspended,
Abandoned,
Superseded,
}
impl State {
pub fn is_open(self) -> bool {
self == State::Active
}
pub fn word(self, kind: Kind) -> &'static str {
match (self, kind) {
(State::Active, Kind::Decision) => "standing",
(State::Active, _) => "open",
(State::Done, Kind::Goal) => "achieved",
(State::Done, Kind::Question) => "answered",
(State::Done, _) => "closed",
(State::Suspended, _) => "parked",
(State::Abandoned, _) => "abandoned",
(State::Superseded, _) => "superseded",
}
}
pub fn mark(self) -> char {
match self {
State::Active => ' ',
State::Done => 'x',
State::Suspended => '~',
State::Abandoned => '!',
State::Superseded => '-',
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Flag {
Suspect,
Review,
Stale,
}
impl Flag {
pub fn parse(s: &str) -> Option<Flag> {
Some(match s {
"suspect" => Flag::Suspect,
"review" => Flag::Review,
"stale" | "old" => Flag::Stale,
_ => return None,
})
}
pub fn word(self) -> &'static str {
match self {
Flag::Suspect => "suspect",
Flag::Review => "review",
Flag::Stale => "stale",
}
}
pub const ALL: &'static str = "suspect, review, stale";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VivacKind {
Push,
Pop,
Park,
Manual,
Auto,
}
impl VivacKind {
pub fn word(self) -> &'static str {
match self {
VivacKind::Push => "push",
VivacKind::Pop => "pop",
VivacKind::Park => "park",
VivacKind::Manual => "manual",
VivacKind::Auto => "auto",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Arm {
pub dir: String,
pub command: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
pub seq: u64,
pub id: String,
pub ts: String,
pub actor: String,
pub lane: String,
pub payload: Body,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Body {
#[serde(rename = "node.created")]
NodeCreated {
node: String,
num: u64,
kind: Kind,
title: String,
#[serde(default)]
why: String,
#[serde(default)]
parent: Option<String>,
#[serde(default)]
blocks: bool,
#[serde(default)]
refs: Vec<String>,
#[serde(default)]
governs: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
arms: Vec<Arm>,
},
#[serde(rename = "state.changed")]
StateChanged {
node: String,
state: State,
#[serde(default)]
outcome: String,
#[serde(default)]
forced: bool,
},
#[serde(rename = "node.noted")]
NodeNoted { node: String, note: String },
#[serde(rename = "edge.blocks")]
BlockChanged { node: String, blocks: bool },
#[serde(rename = "stack.pushed")]
Pushed { node: String },
#[serde(rename = "stack.popped")]
Popped { node: String },
#[serde(rename = "stack.promoted")]
Promoted { node: String },
#[serde(rename = "flag.raised")]
FlagRaised {
node: String,
flag: Flag,
reason: String,
},
#[serde(rename = "flag.cleared")]
FlagCleared { node: String, flag: Flag },
#[serde(rename = "arm.added")]
ArmAdded {
node: String,
dir: String,
command: String,
},
#[serde(rename = "arm.removed")]
ArmRemoved {
node: String,
dir: String,
command: String,
},
#[serde(rename = "vivac.created")]
VivacCreated {
vivac: String,
num: u64,
kind: VivacKind,
stack: Vec<(String, String)>,
working_set: Vec<String>,
next_intent: String,
#[serde(default)]
anchor: crate::anchor::AnchorRef,
#[serde(default)]
node_ref: Option<String>,
#[serde(default)]
label: String,
},
#[serde(rename = "session.started")]
SessionStarted {
source: String,
#[serde(default)]
focus: Option<String>,
#[serde(default)]
vivac: Option<String>,
#[serde(default)]
session: Option<String>,
},
}
impl Body {
pub(crate) const KNOWN_EVENTS: &'static [&'static str] = &[
"node.created",
"state.changed",
"node.noted",
"edge.blocks",
"stack.pushed",
"stack.popped",
"stack.promoted",
"flag.raised",
"flag.cleared",
"arm.added",
"arm.removed",
"vivac.created",
"session.started",
];
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum UnknownReason {
EventType(String),
NodeKind(String),
Shape(String),
}
pub(crate) fn unknown_reason_for(line: &str) -> Option<UnknownReason> {
let v: serde_json::Value = serde_json::from_str(line).ok()?;
v.get("seq")?.as_u64()?;
let payload = v.get("payload")?.as_object()?;
let event_type = payload.get("type")?.as_str()?;
if !Body::KNOWN_EVENTS.contains(&event_type) {
return Some(UnknownReason::EventType(event_type.to_string()));
}
if event_type == "node.created" {
if let Some(kind) = payload.get("kind").and_then(|k| k.as_str()) {
if Kind::parse(kind).is_none() {
return Some(UnknownReason::NodeKind(kind.to_string()));
}
}
}
Some(UnknownReason::Shape(event_type.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
fn tag_of(b: &Body) -> &'static str {
match b {
Body::NodeCreated { .. } => "node.created",
Body::StateChanged { .. } => "state.changed",
Body::NodeNoted { .. } => "node.noted",
Body::BlockChanged { .. } => "edge.blocks",
Body::Pushed { .. } => "stack.pushed",
Body::Popped { .. } => "stack.popped",
Body::Promoted { .. } => "stack.promoted",
Body::FlagRaised { .. } => "flag.raised",
Body::FlagCleared { .. } => "flag.cleared",
Body::ArmAdded { .. } => "arm.added",
Body::ArmRemoved { .. } => "arm.removed",
Body::VivacCreated { .. } => "vivac.created",
Body::SessionStarted { .. } => "session.started",
}
}
fn one_of_each() -> Vec<Body> {
vec![
Body::NodeCreated {
node: "n".into(),
num: 1,
kind: Kind::Task,
title: "t".into(),
why: String::new(),
parent: None,
blocks: false,
refs: vec![],
governs: vec![],
arms: vec![],
},
Body::StateChanged {
node: "n".into(),
state: State::Done,
outcome: String::new(),
forced: false,
},
Body::NodeNoted {
node: "n".into(),
note: "x".into(),
},
Body::BlockChanged {
node: "n".into(),
blocks: true,
},
Body::Pushed { node: "n".into() },
Body::Popped { node: "n".into() },
Body::Promoted { node: "n".into() },
Body::FlagRaised {
node: "n".into(),
flag: Flag::Review,
reason: "x".into(),
},
Body::FlagCleared {
node: "n".into(),
flag: Flag::Review,
},
Body::ArmAdded {
node: "n".into(),
dir: "vivac".into(),
command: "x".into(),
},
Body::ArmRemoved {
node: "n".into(),
dir: "vivac".into(),
command: "x".into(),
},
Body::VivacCreated {
vivac: "v".into(),
num: 1,
kind: VivacKind::Manual,
stack: vec![],
working_set: vec![],
next_intent: String::new(),
anchor: crate::anchor::AnchorRef::default(),
node_ref: None,
label: String::new(),
},
Body::SessionStarted {
source: "s".into(),
focus: None,
vivac: None,
session: None,
},
]
}
#[test]
fn known_events_cover_every_variant() {
let samples = one_of_each();
for s in &samples {
let tag = tag_of(s);
assert!(
Body::KNOWN_EVENTS.contains(&tag),
"missing from KNOWN_EVENTS: {tag}"
);
let v = serde_json::to_value(s).unwrap();
assert_eq!(v["type"], tag, "tag_of disagrees with serde's own tag");
}
assert_eq!(
samples.len(),
Body::KNOWN_EVENTS.len(),
"a variant or a listed tag is unmatched"
);
}
#[test]
fn an_unknown_event_type_is_flagged() {
let line = r#"{"seq":1,"id":"x","ts":"t","actor":"a","lane":"main","payload":{"type":"node.evolved","node":"n"}}"#;
assert_eq!(
unknown_reason_for(line),
Some(UnknownReason::EventType("node.evolved".to_string()))
);
}
#[test]
fn an_unknown_node_kind_is_flagged() {
let line = r#"{"seq":1,"id":"x","ts":"t","actor":"a","lane":"main","payload":{"type":"node.created","node":"n","num":1,"kind":"epic","title":"t"}}"#;
assert_eq!(
unknown_reason_for(line),
Some(UnknownReason::NodeKind("epic".to_string()))
);
}
#[test]
fn ordinary_broken_json_is_not_flagged() {
assert_eq!(unknown_reason_for("{not json"), None);
assert_eq!(
unknown_reason_for(r#"{"payload":{"type":"node.created"}}"#),
None,
"no seq at all is an ordinary broken line, not a newer vivac"
);
}
#[test]
fn a_known_event_this_version_cannot_read_is_flagged() {
let line = r#"{"seq":1,"id":"x","ts":"t","actor":"a","lane":"main","payload":{"type":"node.noted","node":"n"}}"#;
assert_eq!(
unknown_reason_for(line),
Some(UnknownReason::Shape("node.noted".to_string()))
);
}
#[test]
fn a_known_type_with_a_field_value_this_version_does_not_know_is_flagged() {
let line = r#"{"seq":1,"id":"x","ts":"t","actor":"a","lane":"main","payload":{"type":"flag.raised","node":"n","flag":"advise","reason":"x"}}"#;
assert_eq!(
unknown_reason_for(line),
Some(UnknownReason::Shape("flag.raised".to_string()))
);
}
}