use super::{alias_link, escape};
use crate::event::State;
use crate::model::{Node, Tree};
fn tile_class(n: &Node) -> &'static str {
if n.state == State::Suspended {
"tile parked"
} else if n.state.is_open() {
"tile"
} else {
"tile closed"
}
}
fn tile(project: &str, tree: &Tree, n: &Node) -> String {
format!(
"<a class=\"{cls}\" href=\"/p/{p}/why/{a}\" title=\"{a} · {w} · {t}\">{a}</a>\n",
cls = tile_class(n),
p = escape(project),
a = escape(&n.alias()),
w = escape(n.state.word(n.kind)),
t = escape(n.title(tree)),
)
}
fn comb(project: &str, tree: &Tree, children: &[&Node]) -> String {
let tiles: String = children.iter().map(|c| tile(project, tree, c)).collect();
format!("<div class=\"comb\">\n{tiles}</div>\n")
}
fn row(project: &str, tree: &Tree, n: &Node, children: &[&Node]) -> String {
let comb_html = comb(project, tree, children);
let nested_html = rows(project, tree, children);
let alias = alias_link(project, &n.alias());
let title = escape(n.title(tree));
if comb_html.is_empty() && nested_html.is_empty() {
return format!(
"<li class=\"row\"><span class=\"alias\">{alias}</span><p class=\"title\">{title}</p></li>\n"
);
}
let count = match children.len() {
1 => "1 child".to_string(),
n => format!("{n} children"),
};
format!(
"<li class=\"row\">\n<details open>\n<summary><span class=\"alias\">{alias}</span>\
<p class=\"title\">{title}</p><span class=\"count\">{count}</span></summary>\n\
{comb_html}{nested_html}</details>\n</li>\n"
)
}
fn rows(project: &str, tree: &Tree, siblings: &[&Node]) -> String {
let body: String = siblings
.iter()
.filter_map(|n| {
let children = tree.children(n.num);
if children.is_empty() {
None
} else {
Some(row(project, tree, n, &children))
}
})
.collect();
if body.is_empty() {
String::new()
} else {
format!("<ol class=\"rows\">\n{body}</ol>\n")
}
}
pub(super) fn tree_page(project: &str, name: &str, tree: &Tree) -> String {
let total = tree.total();
let body = if tree.is_empty_tree() {
"<p class=\"empty\">Empty tree.</p>\n".to_string()
} else {
rows(project, tree, &tree.roots())
};
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>Tree - {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>The whole tree</h1>\n\
<p class=\"promise\">How branched the work really is -- what the {total} \
lines of <code>vivac tree</code> hold but do not show.</p></header>\n\
<main>\n{body}</main>\n\
<footer>The same reading in a terminal: \
<code>vivac tree --all</code></footer>\n\
</div></body></html>\n",
name_t = escape(name),
p = escape(project),
css = super::WEB_CSS,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{Body, Kind};
const REAL_DEGREES: &[usize] = &[
56, 30, 12, 11, 8, 7, 6, 4, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, ];
fn fixture_node(tree: &mut Tree, seq: &mut u64, num: &mut u64, parent: Option<&str>) -> String {
*seq += 1;
*num += 1;
let id = format!("n{num}");
tree.apply(
*seq,
"2026-09-04T10:00:00Z",
&Body::NodeCreated {
node: id.clone(),
num: *num,
kind: Kind::Task,
title: format!("node {num}"),
why: "fixture".to_string(),
parent: parent.map(str::to_string),
blocks: false,
refs: vec![],
governs: vec![],
},
);
id
}
fn fixture_children(
tree: &mut Tree,
seq: &mut u64,
num: &mut u64,
parent: &str,
count: usize,
) -> Vec<String> {
(0..count)
.map(|_| fixture_node(tree, seq, num, Some(parent)))
.collect()
}
fn real_shape() -> Tree {
let mut tree = Tree::default();
let mut seq = 0u64;
let mut num = 0u64;
let root_a = fixture_node(&mut tree, &mut seq, &mut num, None);
let root_b = fixture_node(&mut tree, &mut seq, &mut num, None);
fixture_node(&mut tree, &mut seq, &mut num, None);
let root_a_children = fixture_children(&mut tree, &mut seq, &mut num, &root_a, 56);
let p11_children = fixture_children(&mut tree, &mut seq, &mut num, &root_a_children[0], 11);
let p12_children = fixture_children(&mut tree, &mut seq, &mut num, &p11_children[0], 12);
let p6_children = fixture_children(&mut tree, &mut seq, &mut num, &p12_children[0], 6);
let p30_children = fixture_children(&mut tree, &mut seq, &mut num, &p6_children[0], 30);
let root_b_children = fixture_children(&mut tree, &mut seq, &mut num, &root_b, 4);
let p7_children = fixture_children(&mut tree, &mut seq, &mut num, &root_b_children[0], 7);
let p8_children = fixture_children(&mut tree, &mut seq, &mut num, &p7_children[0], 8);
let mut slots: Vec<String> = Vec::new();
slots.extend(root_a_children[1..].iter().cloned());
slots.extend(root_b_children[1..].iter().cloned());
slots.extend(p11_children[1..].iter().cloned());
slots.extend(p7_children[1..].iter().cloned());
slots.extend(p12_children[1..].iter().cloned());
slots.extend(p8_children.iter().cloned());
slots.extend(p6_children[1..].iter().cloned());
let mut remaining: Vec<usize> = REAL_DEGREES.to_vec();
for placed in [56usize, 11, 12, 6, 30, 4, 7, 8] {
let at = remaining
.iter()
.position(|&d| d == placed)
.expect("every placed degree is in REAL_DEGREES");
remaining.remove(at);
}
let one_at = remaining
.iter()
.position(|&d| d == 1)
.expect("REAL_DEGREES carries a 1 to spend at depth 6");
remaining.remove(one_at);
fixture_node(&mut tree, &mut seq, &mut num, Some(&p30_children[0]));
for (slot, degree) in slots.iter().zip(remaining.iter()) {
for _ in 0..*degree {
fixture_node(&mut tree, &mut seq, &mut num, Some(slot));
}
}
tree.sort_nodes();
tree
}
fn even_shape() -> Tree {
let mut tree = Tree::default();
let mut seq = 0u64;
let mut num = 0u64;
let root = fixture_node(&mut tree, &mut seq, &mut num, None);
let mut levels: Vec<Vec<String>> = vec![vec![root]];
let branching = [1usize, 3, 9, 15, 27, 9];
for parents_with_children in branching {
let mut next = Vec::new();
for parent in levels.last().unwrap().iter().take(parents_with_children) {
next.extend(fixture_children(&mut tree, &mut seq, &mut num, parent, 3));
}
levels.push(next);
}
for parent in levels[5].iter().skip(9).take(2) {
fixture_node(&mut tree, &mut seq, &mut num, Some(parent));
}
tree.sort_nodes();
tree
}
fn max_depth(tree: &Tree) -> usize {
fn under(tree: &Tree, n: &Node, depth: usize) -> usize {
tree.children(n.num)
.iter()
.map(|c| under(tree, c, depth + 1))
.max()
.unwrap_or(depth)
}
tree.roots()
.iter()
.map(|r| under(tree, r, 0))
.max()
.unwrap_or(0)
}
#[test]
fn real_shape_matches_the_measured_degree_sequence() {
let tree = real_shape();
assert_eq!(tree.total(), 195, "node count");
assert_eq!(tree.roots().len(), 3, "root count");
let mut degrees: Vec<usize> = tree
.nodes_iter()
.map(|n| tree.children(n.num).len())
.filter(|&d| d > 0)
.collect();
degrees.sort_unstable_by(|a, b| b.cmp(a));
assert_eq!(
degrees, REAL_DEGREES,
"the degree sequence must match the measurement exactly"
);
assert_eq!(tree.total() - degrees.len(), 147, "leaf count");
assert_eq!(max_depth(&tree), 6, "max depth");
}
#[test]
fn wrapping_a_row_in_details_does_not_change_which_tiles_exist() {
let tree = real_shape();
let page = tree_page("vivac", "vivac", &tree);
assert_eq!(
page.matches("class=\"tile").count(),
192,
"192 non-root nodes should still be 192 tiles"
);
}
#[test]
fn even_shape_has_the_same_total_as_real_shape() {
assert_eq!(even_shape().total(), real_shape().total());
assert_eq!(max_depth(&even_shape()), 6, "max depth");
}
#[test]
fn the_real_shape_does_not_render_like_an_even_tree() {
let real = tree_page("vivac", "vivac", &real_shape());
let even = tree_page("vivac", "vivac", &even_shape());
let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target/shape");
std::fs::create_dir_all(&dir).expect("target/shape can be created");
let real_path = dir.join("real.html");
let even_path = dir.join("even.html");
std::fs::write(&real_path, &real).expect("real.html can be written");
std::fs::write(&even_path, &even).expect("even.html can be written");
assert!(std::fs::metadata(&real_path).unwrap().len() > 0);
assert!(std::fs::metadata(&even_path).unwrap().len() > 0);
eprintln!("real shape: {}", real_path.display());
eprintln!("even shape: {}", even_path.display());
}
}