use super::{alias_link, escape};
use crate::event::Event;
use crate::model::{Aggregates, Node, Tree};
use crate::render::{anchor_of, open_then_of, standing_of, Full};
fn link(project: &str, tree: &Tree, n: &Node) -> String {
format!(
"<li><span class=\"alias\"><a href=\"/p/{p}/why/{a}\">{a}</a></span>\
<p class=\"title\">{t}</p></li>\n",
p = escape(project),
a = escape(&n.alias()),
t = escape(n.title(tree))
)
}
fn weight(project: &str, tree: &Tree, standing: &[&Node], open_then: &[&Node]) -> String {
if standing.is_empty() && open_then.is_empty() {
return String::new();
}
let counts = [
(standing.len(), "governing here"),
(open_then.len(), "open then"),
]
.iter()
.filter(|(n, _)| *n > 0)
.map(|(n, w)| format!("<span class=\"count\">{n} {w}</span>"))
.collect::<Vec<_>>()
.join(" ");
let mut body = String::new();
if !standing.is_empty() {
body.push_str("<h3>Decided here, still standing</h3>\n<ul class=\"nodes\">\n");
for n in standing {
body.push_str(&link(project, tree, n));
}
body.push_str("</ul>\n");
}
if !open_then.is_empty() {
body.push_str("<h3>Still open at that moment</h3>\n<ul class=\"nodes\">\n");
for n in open_then {
body.push_str(&link(project, tree, n));
}
body.push_str("</ul>\n");
}
format!(
"<details><summary>{counts}</summary>\n<div class=\"detail\">\n{body}</div>\n</details>\n"
)
}
fn facts(tree: &Tree, ag: &Aggregates, full: &Full, n: &Node) -> String {
let mut parts = vec![
format!("<span class=\"word\">{}</span>", n.kind.word()),
format!("<span class=\"word\">{}</span>", n.state.word(n.kind)),
format!(
"<span class=\"when\">opened {}</span>",
escape(n.opened(tree))
),
];
let anchor = anchor_of(tree, full, n);
if !anchor.is_empty_tree() {
parts.push(format!(
"<span class=\"anchor\">{} {}</span>",
escape(&anchor.kind),
escape(anchor.short())
));
}
let open_below = ag.counts(n.num).open_count;
if open_below > 0 {
parts.push(format!(
"<span class=\"count\">{open_below} open below</span>"
));
}
format!("<p class=\"facts\">{}</p>\n", parts.join(" "))
}
fn step(project: &str, tree: &Tree, ag: &Aggregates, full: &Full, n: &Node, here: bool) -> String {
let mark = if here {
"<span class=\"here-mark\">you are here</span>"
} else {
""
};
let alias = if here {
escape(&n.alias())
} else {
alias_link(project, &n.alias())
};
format!(
"<li{cls}>\n<span class=\"alias\">{alias}</span>\n<div class=\"what\">\n\
<p class=\"title\">{title}{mark}</p>\n{facts}{weight}</div>\n</li>\n",
cls = if here { " class=\"here\"" } else { "" },
title = escape(n.title(tree)),
facts = facts(tree, ag, full, n),
weight = weight(
project,
tree,
&standing_of(tree, n),
&open_then_of(tree, full, n)
),
)
}
pub(super) fn why_page(
project: &str,
name: &str,
tree: &Tree,
log: &[Event],
id: &str,
) -> Option<String> {
let target = tree.resolve(id)?;
let full = Full::from_log(log);
let ag = tree.aggregates();
let path = tree.ancestors(target.num);
let last = path.len().saturating_sub(1);
let spine: String = path
.iter()
.enumerate()
.map(|(i, n)| step(project, tree, &ag, &full, n, i == last))
.collect();
Some(format!(
"<!doctype html>\n\
<html lang=\"en\"><head><meta charset=\"utf-8\">\n\
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
<title>Why {alias} - {name_t}</title>\n\
<style>\n{css}</style></head>\n\
<body><div class=\"page\">\n\
<header><p class=\"crumb\"><a href=\"/p/{p}/\">{name_t}</a></p>\n\
<h1>Why we are here</h1>\n\
<p class=\"promise\">The shape of the path at a glance: how deep it goes, \
where it branched, and what was still open at each step.</p></header>\n\
<main>\n<ol class=\"spine\">\n{spine}</ol>\n</main>\n\
<footer>The same reading in a terminal: \
<code>vivac why {alias} --full</code></footer>\n\
</div></body></html>\n",
alias = escape(&target.alias()),
name_t = escape(name),
p = escape(project),
css = super::WEB_CSS,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{Body, Event, Kind, State};
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 born(seq: u64, num: u64, kind: Kind, title: &str, parent: Option<&str>) -> Event {
ev(
seq,
Body::NodeCreated {
node: format!("n{num}"),
num,
kind,
title: title.to_string(),
why: "it is needed".to_string(),
parent: parent.map(str::to_string),
blocks: false,
refs: vec![],
governs: vec![],
},
)
}
fn closed(seq: u64, num: u64) -> Event {
ev(
seq,
Body::StateChanged {
node: format!("n{num}"),
state: State::Done,
outcome: String::new(),
forced: false,
},
)
}
fn lineage() -> Vec<Event> {
vec![
born(1, 1, Kind::Goal, "ship it", None),
born(2, 2, Kind::Decision, "the face is web", Some("n1")),
born(3, 3, Kind::Task, "the sibling", Some("n2")),
born(4, 4, Kind::Finding, "the anchor is empty", Some("n2")),
closed(5, 3),
]
}
#[test]
fn the_spine_has_one_step_per_node_of_the_path() {
let events = lineage();
let tree = fold(&events, 0);
let page = why_page("vivac", "vivac", &tree, &events, "f4").unwrap();
assert_eq!(
page.matches("class=\"facts\"").count(),
3,
"one step per node of g1 > d2 > f4"
);
assert!(page.contains("ship it"));
assert!(page.contains("the face is web"));
assert!(page.contains("the anchor is empty"));
}
#[test]
fn the_last_step_is_the_node_that_was_asked_for() {
let events = lineage();
let tree = fold(&events, 0);
let page = why_page("vivac", "vivac", &tree, &events, "f4").unwrap();
let here = page.find("you are here").expect("the step is marked");
let target = page
.find("the anchor is empty")
.expect("the target is drawn");
let middle = page
.find("the face is web")
.expect("the middle step is drawn");
assert!(middle < target, "the spine runs from the root down");
assert!(target < here, "the mark sits on the step it belongs to");
}
#[test]
fn the_step_you_are_on_is_marked_by_a_word_and_not_only_by_a_class() {
let events = lineage();
let tree = fold(&events, 0);
let page = why_page("vivac", "vivac", &tree, &events, "f4").unwrap();
assert!(page.contains("you are here"));
assert!(page.contains("class=\"here\""));
}
#[test]
fn a_title_that_looks_like_markup_reaches_the_lineage_escaped() {
let events = vec![born(1, 1, Kind::Goal, "<script>alert(1)</script>", None)];
let tree = fold(&events, 0);
let page = why_page("vivac", "vivac", &tree, &events, "g1").unwrap();
assert!(!page.contains("<script>alert(1)</script>"));
assert!(page.contains("<script>"));
}
#[test]
fn a_step_with_no_anchor_draws_no_anchor_row() {
let events = lineage();
let tree = fold(&events, 0);
let page = why_page("vivac", "vivac", &tree, &events, "f4").unwrap();
assert!(!page.contains("class=\"anchor\""));
}
#[test]
fn a_step_with_nothing_to_expand_has_no_disclosure() {
let events = vec![born(1, 1, Kind::Goal, "ship it", None)];
let tree = fold(&events, 0);
let page = why_page("vivac", "vivac", &tree, &events, "g1").unwrap();
assert!(!page.contains("<details>"));
}
#[test]
fn what_closed_below_a_step_is_a_count_and_never_a_list() {
let mut events = lineage();
events.push(born(6, 5, Kind::Task, "the buried one", Some("n4")));
events.push(closed(7, 5));
let tree = fold(&events, 0);
let page = why_page("vivac", "vivac", &tree, &events, "f4").unwrap();
assert!(page.contains("open below"), "the counts are drawn");
assert!(
!page.contains("the buried one"),
"what closed below is counted, not listed"
);
}
#[test]
fn every_step_but_the_one_you_are_on_links_to_its_own_lineage() {
let events = lineage();
let tree = fold(&events, 0);
let page = why_page("vivac", "vivac", &tree, &events, "f4").unwrap();
assert!(page.contains("href=\"/p/vivac/why/g1\""), "{page}");
assert!(page.contains("href=\"/p/vivac/why/d2\""), "{page}");
assert!(!page.contains("href=\"/p/vivac/why/f4\""), "{page}");
}
#[test]
fn an_alias_that_resolves_to_nothing_gives_no_page() {
let events = lineage();
let tree = fold(&events, 0);
assert!(why_page("vivac", "vivac", &tree, &events, "f999").is_none());
}
#[test]
fn the_lineage_asks_for_nothing_off_this_machine() {
let events = lineage();
let tree = fold(&events, 0);
let page = why_page("vivac", "vivac", &tree, &events, "f4").unwrap();
assert!(!page.contains("http://"));
assert!(!page.contains("https://"));
assert!(!page.contains("//cdn"));
}
}