use serde::{Deserialize, Serialize};
use std::path::Path;
#[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, PartialEq)]
pub struct Arm {
pub dir: String,
pub command: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Against {
pub node: String,
pub why: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Repo {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub root: Option<String>,
}
impl Repo {
pub fn relative(base: &Path, path: &Path, root: Option<String>) -> Option<Repo> {
let rel = path.strip_prefix(base).ok()?;
let path = if rel.as_os_str().is_empty() {
".".to_string()
} else {
rel.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/")
};
Some(Repo { path, root })
}
}
fn is_false(b: &bool) -> bool {
!*b
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct WhereRepo {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub rebasing: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub missing: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub withheld: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct RepoAnchor {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
pub sha: 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, PartialEq)]
#[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(default, skip_serializing_if = "Option::is_none")]
against: Option<Vec<Against>>,
},
#[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 = "against.added")]
AgainstAdded { node: String, against: Vec<Against> },
#[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, skip_serializing_if = "Vec::is_empty")]
anchors: Vec<RepoAnchor>,
#[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>,
},
#[serde(rename = "lane.declared")]
LaneDeclared {
lane: String,
name: String,
repos: Vec<Repo>,
},
#[serde(rename = "lane.claimed")]
LaneClaimed { lane: String },
#[serde(rename = "where.changed")]
WhereChanged { repos: Vec<WhereRepo> },
}
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",
"against.added",
"vivac.created",
"session.started",
"lane.declared",
"lane.claimed",
"where.changed",
];
}
#[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::AgainstAdded { .. } => "against.added",
Body::VivacCreated { .. } => "vivac.created",
Body::SessionStarted { .. } => "session.started",
Body::LaneDeclared { .. } => "lane.declared",
Body::LaneClaimed { .. } => "lane.claimed",
Body::WhereChanged { .. } => "where.changed",
}
}
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![],
against: None,
},
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::AgainstAdded {
node: "n".into(),
against: vec![Against {
node: "r".into(),
why: "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(),
anchors: vec![],
node_ref: None,
label: String::new(),
},
Body::SessionStarted {
source: "s".into(),
focus: None,
vivac: None,
session: None,
},
Body::LaneDeclared {
lane: "01M2".into(),
name: "v2".into(),
repos: vec![Repo {
path: "webapi".into(),
root: Some("abc123".into()),
}],
},
Body::LaneClaimed {
lane: "main".into(),
},
Body::WhereChanged {
repos: vec![WhereRepo {
path: "webapi".into(),
branch: Some("develop".into()),
..Default::default()
}],
},
]
}
#[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()))
);
}
#[test]
fn the_lane_events_are_named_as_the_spec_names_them() {
let d = Body::LaneDeclared {
lane: "01M2".into(),
name: "v2".into(),
repos: vec![Repo {
path: "webapi".into(),
root: Some("abc123".into()),
}],
};
let text = serde_json::to_string(&d).unwrap();
assert!(text.contains(r#""type":"lane.declared""#), "{text}");
let c = Body::LaneClaimed {
lane: "main".into(),
};
assert!(serde_json::to_string(&c)
.unwrap()
.contains(r#""type":"lane.claimed""#));
}
#[test]
fn a_repo_without_a_root_commit_does_not_write_the_field() {
let r = Repo {
path: ".".into(),
root: None,
};
assert_eq!(serde_json::to_string(&r).unwrap(), r#"{"path":"."}"#);
}
#[test]
fn a_repo_with_a_root_commit_round_trips_both_fields_and_no_others() {
let r = Repo {
path: "webapi".into(),
root: Some("abc123".into()),
};
let text = serde_json::to_string(&r).unwrap();
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
let obj = v.as_object().unwrap();
let mut keys: Vec<&str> = obj.keys().map(|k| k.as_str()).collect();
keys.sort();
assert_eq!(keys, ["path", "root"]);
let back: Repo = serde_json::from_str(&text).unwrap();
assert_eq!(back, r);
}
#[test]
fn a_path_inside_the_base_comes_out_relative_with_forward_slashes() {
let base = Path::new("lane");
let path = base.join("nested").join("webapi");
let r = Repo::relative(base, &path, None).unwrap();
assert_eq!(r.path, "nested/webapi");
}
#[test]
fn the_base_itself_comes_out_as_dot() {
let base = Path::new("lane");
let r = Repo::relative(base, base, None).unwrap();
assert_eq!(r.path, ".");
}
#[test]
fn a_path_outside_the_base_is_none() {
let base = Path::new("lane");
let path = Path::new("elsewhere");
assert!(Repo::relative(base, path, None).is_none());
}
#[test]
fn a_windows_absolute_path_outside_the_base_is_none() {
let base = Path::new(r"C:\work\lane");
let path = Path::new(r"D:\other\place");
assert!(Repo::relative(base, path, None).is_none());
}
#[test]
fn a_where_survives_the_round_trip_and_omits_what_it_does_not_know() {
let w = Body::WhereChanged {
repos: vec![
WhereRepo {
path: "webapi".into(),
branch: Some("feature/net10".into()),
sha: Some("0123456789abcdef0123456789abcdef01234567".into()),
rebasing: false,
missing: false,
withheld: false,
},
WhereRepo {
path: "gone".into(),
missing: true,
..Default::default()
},
],
};
let s = serde_json::to_string(&w).unwrap();
assert!(s.contains(r#""type":"where.changed""#));
assert!(!s.contains("rebasing"), "a false flag is not written down");
assert!(
!s.contains(r#""branch":null"#),
"an absent branch is absent, not null"
);
assert_eq!(serde_json::from_str::<Body>(&s).unwrap(), w);
}
#[test]
fn this_version_knows_the_lane_events_and_does_not_refuse_them() {
for tag in ["lane.declared", "lane.claimed"] {
let line = format!(r#"{{"seq":1,"payload":{{"type":"{tag}"}}}}"#);
assert_ne!(
unknown_reason_for(&line),
Some(UnknownReason::EventType(tag.to_string())),
"{tag} was refused as an event type this version does not know"
);
}
}
}