use crate::args::Args;
use crate::event::{Body, Event, Flag, State};
use crate::failure::Failure;
use crate::model::{Node, Tree, Vivac};
use crate::output::outln;
use crate::render::{print_json, wrap, WIDTH};
use serde_json::json;
pub enum Boundary<'a> {
Stop {
vivac: &'a Vivac,
manual: bool,
},
Beginning { asked_for_manual: bool },
}
pub struct Changed<'a> {
pub since: Boundary<'a>,
pub opened: Vec<Opened<'a>>,
pub closed: Vec<Closed<'a>>,
pub flagged: Vec<Flagged<'a>>,
pub moved: Vec<Moved<'a>>,
pub tail: Tail,
}
pub struct Opened<'a> {
pub node: &'a Node,
pub lane: String,
}
pub struct Closed<'a> {
pub node: &'a Node,
pub outcome: String,
pub forced: bool,
pub lane: String,
}
pub struct Flagged<'a> {
pub node: &'a Node,
pub flag: Flag,
pub reason: String,
pub lane: String,
}
pub struct Moved<'a> {
pub node: &'a Node,
pub state: State,
pub lane: String,
}
#[derive(Default)]
pub struct Tail {
pub focus_moves: usize,
pub notes: usize,
pub flags_cleared: usize,
pub edges: usize,
pub arms: usize,
pub declarations: usize,
pub stops: usize,
pub unreadable: usize,
}
pub fn collect<'a>(tree: &'a Tree, log: &[Event], since_seq: u64) -> Changed<'a> {
let mut result = Changed {
since: Boundary::Beginning {
asked_for_manual: false,
},
opened: Vec::new(),
closed: Vec::new(),
flagged: Vec::new(),
moved: Vec::new(),
tail: Tail::default(),
};
for e in log {
if e.seq <= since_seq {
continue;
}
match &e.payload {
Body::SessionStarted { .. } => {}
Body::NodeCreated { node, .. } => match tree.node(node) {
Some(n) => result.opened.push(Opened {
node: n,
lane: e.lane.clone(),
}),
None => result.tail.unreadable += 1,
},
Body::StateChanged {
node,
state,
outcome,
forced,
} => match tree.node(node) {
Some(n) if *state == State::Done => result.closed.push(Closed {
node: n,
outcome: outcome.clone(),
forced: *forced,
lane: e.lane.clone(),
}),
Some(n) => result.moved.push(Moved {
node: n,
state: *state,
lane: e.lane.clone(),
}),
None => result.tail.unreadable += 1,
},
Body::FlagRaised { node, flag, reason } => match tree.node(node) {
Some(n) => result.flagged.push(Flagged {
node: n,
flag: *flag,
reason: reason.clone(),
lane: e.lane.clone(),
}),
None => result.tail.unreadable += 1,
},
Body::FlagCleared { node, .. } => match tree.node(node) {
Some(_) => result.tail.flags_cleared += 1,
None => result.tail.unreadable += 1,
},
Body::NodeNoted { node, .. } => match tree.node(node) {
Some(_) => result.tail.notes += 1,
None => result.tail.unreadable += 1,
},
Body::BlockChanged { node, .. } => match tree.node(node) {
Some(_) => result.tail.edges += 1,
None => result.tail.unreadable += 1,
},
Body::Pushed { node } | Body::Popped { node } | Body::Promoted { node } => {
match tree.node(node) {
Some(_) => result.tail.focus_moves += 1,
None => result.tail.unreadable += 1,
}
}
Body::ArmAdded { node, .. } | Body::ArmRemoved { node, .. } => match tree.node(node) {
Some(_) => result.tail.arms += 1,
None => result.tail.unreadable += 1,
},
Body::AgainstAdded { node, .. } => match tree.node(node) {
Some(_) => result.tail.declarations += 1,
None => result.tail.unreadable += 1,
},
Body::VivacCreated { .. } => result.tail.stops += 1,
Body::LaneDeclared { .. } | Body::LaneClaimed { .. } | Body::WhereChanged { .. } => {}
}
}
result
}
impl Boundary<'_> {
pub(crate) fn seq(&self) -> u64 {
match self {
Boundary::Stop { vivac, .. } => vivac.seq,
Boundary::Beginning { .. } => 0,
}
}
}
pub(crate) fn manual_boundary(tree: &Tree) -> Boundary<'_> {
match tree.last_manual_vivac() {
Some(v) => Boundary::Stop {
vivac: v,
manual: true,
},
None => Boundary::Beginning {
asked_for_manual: true,
},
}
}
pub fn changes(tree: &Tree, log: &[Event], args: &Args) -> Result<i32, Failure> {
let boundary = match args.opt("since") {
Some("manual") => manual_boundary(tree),
Some(s) => Boundary::Stop {
vivac: tree.vivac(s).ok_or_else(|| {
Failure::usage(format!(
"No such vivac: {s}. Give a stop's alias, or `manual` for the last stop you made."
))
})?,
manual: false,
},
None => match tree.last_vivac() {
Some(v) => Boundary::Stop {
vivac: v,
manual: false,
},
None => Boundary::Beginning {
asked_for_manual: false,
},
},
};
let mut result = collect(tree, log, boundary.seq());
result.since = boundary;
if args.has("json") {
return print_json(as_json(tree, &result)).map(|_| 0);
}
print_text(tree, &result);
Ok(0)
}
fn plural(n: usize) -> &'static str {
if n == 1 {
""
} else {
"s"
}
}
fn header(since: &Boundary, stops_since: usize) -> String {
match since {
Boundary::Beginning {
asked_for_manual: false,
} => " CHANGES SINCE THE BEGINNING - no stops yet".to_string(),
Boundary::Beginning {
asked_for_manual: true,
} => " CHANGES SINCE THE BEGINNING - no stop here was made by hand".to_string(),
Boundary::Stop { vivac, manual } => {
let date = crate::clock::date_of(&vivac.ts);
match (manual, stops_since) {
(false, 0) => format!(" CHANGES SINCE {}, the last stop - {date}", vivac.alias()),
(false, n) => format!(
" CHANGES SINCE {} - {date}, {n} stop{} ago",
vivac.alias(),
plural(n)
),
(true, 0) => format!(
" CHANGES SINCE {}, the last stop you made - {date}",
vivac.alias()
),
(true, n) => format!(
" CHANGES SINCE {}, the last stop you made - {date}, {n} stop{} since",
vivac.alias(),
plural(n)
),
}
}
}
}
pub(crate) fn tail_phrase(tail: &Tail) -> Option<String> {
let mut parts = Vec::new();
if tail.focus_moves > 0 {
parts.push(format!(
"{} focus move{}",
tail.focus_moves,
plural(tail.focus_moves)
));
}
if tail.notes > 0 {
parts.push(format!("{} note{}", tail.notes, plural(tail.notes)));
}
if tail.flags_cleared > 0 {
parts.push(format!(
"{} flag{} cleared",
tail.flags_cleared,
plural(tail.flags_cleared)
));
}
if tail.edges > 0 {
parts.push(format!("{} edge change{}", tail.edges, plural(tail.edges)));
}
if tail.arms > 0 {
parts.push(format!("{} arm change{}", tail.arms, plural(tail.arms)));
}
if tail.declarations > 0 {
parts.push(format!(
"{} late declaration{}",
tail.declarations,
plural(tail.declarations)
));
}
if tail.unreadable > 0 {
parts.push(format!(
"{} unreadable event{}",
tail.unreadable,
plural(tail.unreadable)
));
}
(!parts.is_empty()).then(|| parts.join(", "))
}
fn lane_label(tree: &Tree, lane: &str) -> String {
match tree.lanes.get(lane) {
Some(s) if !s.name.is_empty() => s.name.clone(),
_ => lane.to_string(),
}
}
fn is_foreign(tree: &Tree, lane: &str) -> bool {
lane != tree.lane()
}
fn foreign_mark(tree: &Tree, lane: &str) -> String {
if is_foreign(tree, lane) {
format!("[{}] ", lane_label(tree, lane))
} else {
String::new()
}
}
fn print_text(tree: &Tree, result: &Changed) {
outln!();
outln!("{}", header(&result.since, result.tail.stops));
let mut said_something = false;
if !result.opened.is_empty() {
said_something = true;
outln!();
outln!(" OPENED ({})", result.opened.len());
for o in &result.opened {
outln!(
" {:<6} {}{}",
o.node.alias(),
foreign_mark(tree, &o.lane),
o.node.title(tree)
);
}
}
if !result.closed.is_empty() {
said_something = true;
outln!();
outln!(" CLOSED ({})", result.closed.len());
for c in &result.closed {
outln!(
" {:<6} {}{}",
c.node.alias(),
foreign_mark(tree, &c.lane),
c.node.title(tree)
);
let line = if c.forced {
if c.outcome.is_empty() {
"forced".to_string()
} else {
format!("forced: {}", c.outcome)
}
} else {
c.outcome.clone()
};
for l in wrap(&line, WIDTH, " ") {
outln!("{l}");
}
}
}
if !result.flagged.is_empty() {
said_something = true;
outln!();
outln!(" FLAGGED ({})", result.flagged.len());
for f in &result.flagged {
outln!(
" {:<6} {}{}",
f.node.alias(),
foreign_mark(tree, &f.lane),
f.node.title(tree)
);
for l in wrap(
&format!("{}: {}", f.flag.word(), f.reason),
WIDTH,
" ",
) {
outln!("{l}");
}
}
}
if !result.moved.is_empty() {
said_something = true;
outln!();
outln!(" MOVED ({})", result.moved.len());
for m in &result.moved {
outln!(
" {:<6} {}{}",
m.node.alias(),
foreign_mark(tree, &m.lane),
m.node.title(tree)
);
let word = m.state.word(m.node.kind);
for l in wrap(
&format!("{word}: {}", m.node.outcome(tree)),
WIDTH,
" ",
) {
outln!("{l}");
}
}
}
if let Some(t) = tail_phrase(&result.tail) {
said_something = true;
outln!();
outln!(" + {t}");
}
if !said_something {
outln!();
match &result.since {
Boundary::Stop { vivac, .. } => {
outln!(" Nothing has moved since {}.", vivac.alias())
}
Boundary::Beginning { .. } => outln!(" Nothing has moved."),
}
}
outln!();
}
fn with_lane_if_foreign(tree: &Tree, lane: &str, mut obj: serde_json::Value) -> serde_json::Value {
if is_foreign(tree, lane) {
if let serde_json::Value::Object(map) = &mut obj {
map.insert("lane".to_string(), json!(lane));
}
}
obj
}
fn as_json(tree: &Tree, result: &Changed) -> serde_json::Value {
json!({
"since": match &result.since {
Boundary::Stop { vivac, manual } => json!({
"kind": if *manual { "manual" } else { "stop" },
"alias": vivac.alias(),
"ts": vivac.ts,
"stops_since": result.tail.stops,
}),
Boundary::Beginning { .. } => serde_json::Value::Null,
},
"opened": result.opened.iter().map(|o| with_lane_if_foreign(tree, &o.lane, json!({
"alias": o.node.alias(),
"title": o.node.title(tree),
"kind": o.node.kind,
}))).collect::<Vec<_>>(),
"closed": result.closed.iter().map(|c| with_lane_if_foreign(tree, &c.lane, json!({
"alias": c.node.alias(),
"title": c.node.title(tree),
"outcome": c.outcome,
"forced": c.forced,
}))).collect::<Vec<_>>(),
"flagged": result.flagged.iter().map(|f| with_lane_if_foreign(tree, &f.lane, json!({
"alias": f.node.alias(),
"title": f.node.title(tree),
"flag": f.flag.word(),
"reason": f.reason,
}))).collect::<Vec<_>>(),
"moved": result.moved.iter().map(|m| with_lane_if_foreign(tree, &m.lane, json!({
"alias": m.node.alias(),
"title": m.node.title(tree),
"state": m.state.word(m.node.kind),
}))).collect::<Vec<_>>(),
"tail": {
"focus_moves": result.tail.focus_moves,
"notes": result.tail.notes,
"flags_cleared": result.tail.flags_cleared,
"edge_changes": result.tail.edges,
"arm_changes": result.tail.arms,
"late_declarations": result.tail.declarations,
"unreadable": result.tail.unreadable,
},
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::Kind;
use crate::model::fold;
fn ev(seq: u64, payload: Body) -> Event {
Event {
seq,
id: format!("e{seq}"),
ts: "2026-09-03T10:00:00Z".to_string(),
actor: "a".to_string(),
lane: "main".to_string(),
payload,
}
}
fn node_created(seq: u64, node: &str, num: u64, title: &str) -> Event {
ev(
seq,
Body::NodeCreated {
node: node.to_string(),
num,
kind: Kind::Task,
title: title.to_string(),
why: "it is needed".to_string(),
parent: None,
blocks: false,
refs: vec![],
governs: vec![],
arms: vec![],
against: None,
},
)
}
fn state_changed(seq: u64, node: &str, state: State, outcome: &str, forced: bool) -> Event {
ev(
seq,
Body::StateChanged {
node: node.to_string(),
state,
outcome: outcome.to_string(),
forced,
},
)
}
fn session_started(seq: u64) -> Event {
ev(
seq,
Body::SessionStarted {
source: "test".to_string(),
focus: None,
vivac: None,
session: None,
},
)
}
fn flag_cleared(seq: u64, node: &str, flag: Flag) -> Event {
ev(
seq,
Body::FlagCleared {
node: node.to_string(),
flag,
},
)
}
fn flag_raised(seq: u64, node: &str, flag: Flag, reason: &str) -> Event {
ev(
seq,
Body::FlagRaised {
node: node.to_string(),
flag,
reason: reason.to_string(),
},
)
}
fn pushed(seq: u64, node: &str) -> Event {
ev(
seq,
Body::Pushed {
node: node.to_string(),
},
)
}
#[test]
fn the_limit_is_exclusive() {
let events = vec![
node_created(1, "n1", 1, "Old"),
node_created(2, "n2", 2, "New"),
];
let tree = fold(&events, 0);
let result = collect(&tree, &events, 1);
assert_eq!(result.opened.len(), 1);
assert_eq!(result.opened[0].node.id, "n2");
}
#[test]
fn a_session_start_counts_for_nothing() {
let events = vec![node_created(1, "n1", 1, "Node"), session_started(2)];
let tree = fold(&events, 0);
let result = collect(&tree, &events, 0);
assert_eq!(result.opened.len(), 1);
assert_eq!(result.tail.focus_moves, 0);
assert_eq!(result.tail.notes, 0);
assert_eq!(result.tail.flags_cleared, 0);
assert_eq!(result.tail.edges, 0);
assert_eq!(result.tail.stops, 0);
assert_eq!(result.tail.unreadable, 0);
}
#[test]
fn a_node_born_and_closed_in_the_same_stretch_appears_in_both_groups() {
let events = vec![
node_created(1, "n1", 1, "Fixed fast"),
state_changed(2, "n1", State::Done, "shipped", false),
];
let tree = fold(&events, 0);
let result = collect(&tree, &events, 0);
assert_eq!(result.opened.len(), 1);
assert_eq!(result.closed.len(), 1);
assert_eq!(result.closed[0].node.id, "n1");
assert_eq!(result.closed[0].outcome, "shipped");
assert!(!result.closed[0].forced);
}
#[test]
fn a_cleared_flag_goes_to_the_tail_and_not_to_a_group() {
let events = vec![
node_created(1, "n1", 1, "Node"),
flag_cleared(2, "n1", Flag::Stale),
];
let tree = fold(&events, 0);
let result = collect(&tree, &events, 0);
assert!(result.flagged.is_empty());
assert_eq!(result.tail.flags_cleared, 1);
}
#[test]
fn a_stretch_that_only_moved_the_focus_has_no_group_entries() {
let events = vec![node_created(1, "n1", 1, "Node"), pushed(2, "n1")];
let tree = fold(&events, 0);
let result = collect(&tree, &events, 1);
assert_eq!(result.tail.focus_moves, 1);
assert!(result.opened.is_empty());
assert!(result.closed.is_empty());
assert!(result.flagged.is_empty());
assert!(result.moved.is_empty());
}
#[test]
fn an_event_naming_an_unknown_node_counts_as_unreadable() {
let events = vec![
node_created(1, "n1", 1, "Node"),
flag_raised(2, "ghost", Flag::Suspect, "reason"),
];
let tree = fold(&events, 0);
let result = collect(&tree, &events, 0);
assert!(result.flagged.is_empty());
assert_eq!(result.tail.unreadable, 1);
}
fn ev_lane(seq: u64, lane: &str, payload: Body) -> Event {
Event {
seq,
id: format!("e{seq}"),
ts: "2026-09-03T10:00:00Z".to_string(),
actor: "a".to_string(),
lane: lane.to_string(),
payload,
}
}
#[test]
fn each_group_carries_the_lane_its_event_was_signed_with() {
let events = vec![
node_created(1, "n1", 1, "Mine"),
ev_lane(
2,
"b",
Body::NodeCreated {
node: "n2".to_string(),
num: 2,
kind: Kind::Task,
title: "B's own".to_string(),
why: "it is needed".to_string(),
parent: None,
blocks: false,
refs: vec![],
governs: vec![],
arms: vec![],
against: None,
},
),
ev_lane(
3,
"b",
Body::StateChanged {
node: "n2".to_string(),
state: State::Done,
outcome: "shipped".to_string(),
forced: false,
},
),
ev_lane(
5,
"b",
Body::FlagRaised {
node: "n1".to_string(),
flag: Flag::Suspect,
reason: "from over there".to_string(),
},
),
];
let tree = fold(&events, 0);
let result = collect(&tree, &events, 0);
assert_eq!(result.opened[0].lane, "main");
assert_eq!(result.opened[1].lane, "b");
assert_eq!(result.closed[0].lane, "b");
assert_eq!(result.flagged[0].lane, "b");
}
#[test]
fn a_single_lane_carries_no_foreign_mark() {
let events = vec![node_created(1, "n1", 1, "Node")];
let tree = fold(&events, 0);
assert_eq!(foreign_mark(&tree, "main"), "");
}
#[test]
fn a_foreign_lane_is_marked_with_its_name() {
let events = vec![node_created(1, "n1", 1, "Node")];
let tree = fold(&events, 0);
assert_eq!(foreign_mark(&tree, "b"), "[b] ");
}
#[test]
fn json_carries_lane_only_when_it_is_foreign() {
let events = vec![
node_created(1, "n1", 1, "Mine"),
ev_lane(
2,
"b",
Body::NodeCreated {
node: "n2".to_string(),
num: 2,
kind: Kind::Task,
title: "B's own".to_string(),
why: "it is needed".to_string(),
parent: None,
blocks: false,
refs: vec![],
governs: vec![],
arms: vec![],
against: None,
},
),
];
let tree = fold(&events, 0);
let result = collect(&tree, &events, 0);
let v = as_json(&tree, &result);
assert!(
v["opened"][0].get("lane").is_none(),
"the context's own lane gained a lane field: {v}"
);
assert_eq!(v["opened"][1]["lane"], "b");
}
#[test]
fn a_single_lane_json_never_gains_the_lane_key() {
let events = vec![node_created(1, "n1", 1, "Node")];
let tree = fold(&events, 0);
let result = collect(&tree, &events, 0);
let v = as_json(&tree, &result);
assert!(v["opened"][0].get("lane").is_none(), "{v}");
}
}