use crate::anchor::Anchor;
use crate::args::Args;
use crate::event::{Kind, State};
use crate::failure::R;
use crate::model::{Node, Tree};
use std::collections::HashSet;
const BUDGET: usize = 1500;
const RULE: &str = "------------------------------------------------------------";
struct Section {
lines: Vec<String>,
truncable: bool,
}
impl Section {
fn fixed(lines: Vec<String>) -> Section {
Section {
lines,
truncable: false,
}
}
fn loose(lines: Vec<String>) -> Section {
Section {
lines,
truncable: true,
}
}
}
fn tokens(s: &str) -> usize {
s.chars().count().div_ceil(4)
}
fn tokens_of(sections: &[Section]) -> usize {
sections
.iter()
.flat_map(|s| s.lines.iter())
.map(|l| tokens(l) + 1)
.sum()
}
fn trim_list(mut v: Vec<String>, n: usize, which: &str) -> Vec<String> {
if v.len() > n {
let left_over = v.len() - n;
v.truncate(n);
v.push(format!(" ... and {left_over} more (vivac {which})"));
}
v
}
fn heading(title: &str, body: Vec<String>) -> Vec<String> {
if body.is_empty() {
return vec![];
}
let mut v = vec![String::new(), format!(" {title}")];
v.extend(body);
v
}
pub(crate) fn constraints<'a>(a: &'a Tree, lineage: &[&Node]) -> Vec<&'a Node> {
let on_lineage: HashSet<u64> = lineage.iter().map(|n| n.num).collect();
let mut v: Vec<&Node> = a
.nodes_iter()
.filter(|n| n.kind == Kind::Constraint && n.state.is_open())
.filter(|n| {
let project_wide = n.parent.is_none()
|| n.parent
.and_then(|p| a.node_by_num(p))
.is_some_and(|p| p.parent.is_none());
project_wide
|| a.ancestors(n.num)
.iter()
.any(|p| on_lineage.contains(&p.num))
})
.collect();
v.sort_by_key(|n| (n.flags.is_empty(), n.num));
v
}
fn spine_label(a: &Tree, n: &Node) -> String {
if n.state.is_open() {
return clip(n.title(a), 44);
}
let mark = format!(" [{}]", n.state.word(n.kind));
let budget = 44usize.saturating_sub(mark.chars().count());
format!("{}{mark}", clip(n.title(a), budget))
}
fn spine(a: &Tree, lineage: &[&Node]) -> Vec<String> {
let mut v = Vec::new();
for (i, n) in lineage.iter().enumerate() {
let first = i == 0;
let is_last = i == lineage.len() - 1;
let cont = if is_last { " " } else { " | " };
let branch = if first {
" GOAL ".to_string()
} else if is_last {
" `-- ".to_string()
} else {
" |-- ".to_string()
};
let flags: Vec<&str> = n.flags.keys().map(|b| b.word()).collect();
let flag = if flags.is_empty() {
String::new()
} else {
format!(" ! {}", flags.join(" "))
};
let here_mark = if is_last { " <== HERE" } else { "" };
v.push(format!(
"{branch}{:<6} {}{flag}{here_mark}",
n.alias(),
spine_label(a, n)
));
let why = n.why(a);
if !first && !why.is_empty() {
v.push(format!("{cont}why: {}", clip(why, 52)));
}
let governs = n.governs(a);
if !governs.is_empty() {
v.push(format!("{cont}governs: {}", governs.join(" ")));
}
if !is_last {
v.push(" |".to_string());
}
}
v
}
pub(crate) fn clip(s: &str, n: usize) -> String {
if s.chars().count() <= n {
return s.to_string();
}
let t: String = s.chars().take(n.saturating_sub(3)).collect();
match t.rsplit_once(' ') {
Some((a, _)) if !a.is_empty() => format!("{a}..."),
_ => format!("{t}..."),
}
}
fn project_wide(a: &Tree, n: &Node) -> bool {
n.parent.is_none()
|| n.parent
.and_then(|p| a.node_by_num(p))
.is_some_and(|p| p.parent.is_none())
}
pub(crate) fn standing<'a>(a: &'a Tree, focus: &Node, on_lineage: &HashSet<u64>) -> Vec<&'a Node> {
let mut dec: Vec<&Node> = a
.nodes_iter()
.filter(|n| n.kind == Kind::Decision && n.state.is_open())
.filter(|n| {
project_wide(a, n)
|| on_lineage.contains(&n.num)
|| n.parent.is_some_and(|p| on_lineage.contains(&p))
|| n.governs(a)
.iter()
.any(|g| focus.governs(a).iter().any(|f| crate::glob::covers(g, f)))
})
.collect();
dec.sort_by_key(|n| n.num);
dec
}
fn is_goal_shaped(n: &Node) -> bool {
!matches!(
n.kind,
Kind::Decision | Kind::Constraint | Kind::Pillar | Kind::Rule
) && (n.kind == Kind::Goal || n.parent.is_none())
}
fn born_from_here(a: &Tree, focus: &Node) -> Vec<String> {
let mut children: Vec<String> = a
.children(focus.num)
.into_iter()
.filter(|c| c.is_front())
.filter(|c| !(c.kind == Kind::Question && c.blocks))
.map(|c| {
format!(
" {} {:<6} {}",
if c.blocks { '*' } else { ' ' },
c.alias(),
c.title(a)
)
})
.collect();
let direct: std::collections::HashSet<&str> = a
.children(focus.num)
.iter()
.map(|c| c.id.as_str())
.collect();
let deep = a
.descendants(focus.num)
.into_iter()
.filter(|n| n.is_front() && !direct.contains(n.id.as_str()))
.filter(|n| !a.children(n.num).iter().any(|c| c.is_front()))
.count();
if deep > 0 {
children.push(format!(
" + {deep} further down, outside this level vivac open"
));
}
children
}
fn no_focus_block(a: &Tree) -> Vec<String> {
let mut v = vec![" No active focus.".to_string()];
let mut goals: Vec<&Node> = a
.nodes_iter()
.filter(|n| n.state.is_open() && is_goal_shaped(n))
.collect();
goals.sort_by_key(|n| n.num);
if !goals.is_empty() {
v.push(String::new());
v.push(" OPEN GOALS".to_string());
for m in &goals {
v.push(format!(
" {:<6} {:<40} {} open below",
m.alias(),
clip(m.title(a), 40),
a.counts(m.num).open_count
));
}
}
v.push(String::new());
if a.is_empty_tree() {
v.push(" Start with: vivac push \"<title>\" --why \"<reason>\"".to_string());
} else if let Some(first) = goals.first() {
v.push(format!(" Pick up with: vivac focus {}", first.alias()));
v.push(" Or open another: vivac push \"<title>\" --why \"<reason>\"".to_string());
} else {
let mut parked: Vec<&Node> = a
.nodes_iter()
.filter(|n| n.state == State::Suspended && is_goal_shaped(n))
.collect();
parked.sort_by_key(|n| n.num);
match parked.first() {
Some(p) => {
v.push(format!(" Pick up with: vivac focus {}", p.alias()));
v.push(" Or open another: vivac push \"<title>\" --why \"<reason>\"".to_string());
}
None => {
v.push(" Open the next one: vivac push \"<title>\" --why \"<reason>\"".to_string())
}
}
}
v
}
pub fn brief(a: &Tree, anchor_of: &dyn Anchor, args: &Args, project: &str) -> R {
print!("{}", to_text(a, anchor_of, args, project)?);
Ok(())
}
pub fn to_text(
a: &Tree,
anchor_of: &dyn Anchor,
args: &Args,
project: &str,
) -> Result<String, crate::failure::Failure> {
let today = args.opt("now").unwrap_or("").to_string();
let today = if today.is_empty() {
crate::clock::now_rfc3339()
} else {
today
};
let date = crate::clock::date_of(&today).to_string();
let budget: usize = args
.opt("budget")
.and_then(|s| s.parse().ok())
.unwrap_or(BUDGET);
let lineage: Vec<&Node> = match a.stack.last() {
Some(&num) => a.ancestors(num),
None => vec![],
};
let focus: Option<&Node> = lineage.last().copied();
let mut s: Vec<Section> = Vec::new();
s.push(Section::fixed(vec![
format!("vivac · project: {project} · lane: main · {date}"),
RULE.to_string(),
String::new(),
]));
s.push(Section::fixed(match focus {
Some(_) => spine(a, &lineage),
None => no_focus_block(a),
}));
let born = focus.map(|f| born_from_here(a, f)).unwrap_or_default();
s.push(Section::fixed(heading("BORN FROM HERE", born)));
let invariants: Vec<String> = constraints(a, &lineage)
.iter()
.map(|c| {
let risk = if c.flags.is_empty() { "" } else { " AT RISK" };
format!(" {:<6} {}{risk}", c.alias(), c.title(a))
})
.collect();
s.push(Section::fixed(heading("INVARIANTS", invariants)));
let on_lineage: HashSet<u64> = lineage.iter().map(|n| n.num).collect();
let questions: Vec<String> = a
.nodes_iter()
.filter(|n| n.kind == Kind::Question && n.state.is_open() && n.blocks)
.filter(|n| {
a.ancestors(n.num)
.iter()
.any(|p| on_lineage.contains(&p.num))
})
.map(|n| format!(" {:<6} {}", n.alias(), n.title(a)))
.collect();
let mut questions = questions;
questions.sort();
s.push(Section::fixed(heading("BLOCKS", questions)));
let mut flagged: Vec<&Node> = a
.nodes_iter()
.filter(|n| !n.flags.is_empty())
.filter(|n| {
on_lineage.contains(&n.num) || n.parent.is_some_and(|p| on_lineage.contains(&p))
})
.collect();
flagged.sort_by_key(|n| n.num);
let flag_lines: Vec<String> = flagged
.iter()
.flat_map(|n| {
n.flags.iter().map(move |(b, reason)| {
format!(
" {:<6} {:<10} {}",
n.alias(),
b.word(),
clip(a.text(*reason), 44)
)
})
})
.collect();
s.push(Section::loose(heading(
"FLAGGED",
trim_list(flag_lines, 3, "stats"),
)));
let mut parked_nodes: Vec<&Node> = a
.nodes_iter()
.filter(|n| n.state == State::Suspended)
.collect();
parked_nodes.sort_by_key(|n| n.num);
let out_of_scope: Vec<String> = parked_nodes
.iter()
.flat_map(|n| {
let hangs_off = n
.parent
.and_then(|p| a.node_by_num(p))
.map(|p| format!("hangs off {}", p.alias()))
.unwrap_or_default();
let mut v = vec![format!(
" {:<6} {:<40} {hangs_off}",
n.alias(),
clip(n.title(a), 40)
)];
let outcome = n.outcome(a);
if !outcome.is_empty() {
v.push(format!(" \"{}\"", clip(outcome, 56)));
}
v
})
.collect();
s.push(Section::loose(heading(
"DO NOT TOUCH NOW",
trim_list(out_of_scope, 6, "parked"),
)));
let dec: Vec<&Node> = match focus {
Some(f) => standing(a, f, &on_lineage),
None => {
let mut d: Vec<&Node> = a
.nodes_iter()
.filter(|n| n.kind == Kind::Decision && n.state.is_open() && project_wide(a, n))
.collect();
d.sort_by_key(|n| n.num);
d
}
};
let decisions: Vec<String> = dec
.iter()
.map(|n| format!(" {:<6} {}", n.alias(), clip(n.title(a), 52)))
.collect();
s.push(Section::loose(heading(
"STANDING DECISIONS",
trim_list(decisions, 3, "tree"),
)));
let vv: Vec<String> = match a.last_vivac() {
None => vec![],
Some(v) => {
let mut l = vec![format!(
" {} · {} · {}{}",
v.alias(),
v.kind.word(),
crate::clock::date_of(&v.ts),
if v.anchor.is_empty_tree() {
String::new()
} else {
format!(" · {}", v.anchor.short())
}
)];
if !v.next_intent.is_empty() {
l.push(format!(
" you were about to: {}",
clip(&v.next_intent, 52)
));
}
if !v.anchor.is_empty_tree() {
let changes = anchor_of.changed_since(&v.anchor);
if !changes.is_empty() {
let touching = changes
.iter()
.filter(|c| {
v.working_set
.iter()
.any(|g| crate::glob::covers(g, &c.file_path))
})
.count();
l.push(format!(
" {} changes since, {touching} touching what it governs",
changes.len()
));
}
}
l
}
};
s.push(Section::loose(heading("LAST VIVAC", vv)));
let stale_ones: Vec<String> = lineage
.iter()
.filter(|n| n.flags.contains_key(&crate::event::Flag::Stale))
.map(|n| format!(" {:<6} {}", n.alias(), n.title(a)))
.collect();
s.push(Section::loose(heading("UNTOUCHED FOR A WHILE", stale_ones)));
emit(s, budget, a)
}
fn emit(mut s: Vec<Section>, budget: usize, a: &Tree) -> Result<String, crate::failure::Failure> {
let requested = tokens_of(&s);
while tokens_of(&s) > budget {
match s.iter().rposition(|x| x.truncable && !x.lines.is_empty()) {
Some(i) => s[i].lines.clear(),
None => break,
}
}
let spent = tokens_of(&s);
let mut o = String::new();
for l in s.iter().flat_map(|x| x.lines.iter()) {
o.push_str(l);
o.push('\n');
}
let parked_nodes = a
.nodes_iter()
.filter(|n| n.state == State::Suspended)
.count();
o.push_str(&format!(
"
{RULE}
{spent} tokens · depth {} · {parked_nodes} parked
",
a.stack_depth()
));
if spent > budget {
o.push_str(&format!(
"
! the brief is over budget ({spent}/{budget}).
The spine is never truncated: what is left over is tree, not render.
What can be pruned: vivac triage
"
));
} else if requested > budget {
o.push_str(&format!(
"
! {} tokens trimmed to fit in {budget}.
",
requested - spent
));
}
Ok(o)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_estimator_is_deterministic() {
assert_eq!(tokens("same"), 1);
assert_eq!(tokens("same tokens"), 3);
assert_eq!(tokens(""), 0);
assert_eq!(tokens("abcdefgh"), tokens("12345678"));
}
#[test]
fn trimming_says_what_is_missing() {
let v: Vec<String> = (0..10).map(|i| format!("l{i}")).collect();
let r = trim_list(v, 3, "parked");
assert_eq!(r.len(), 4);
assert_eq!(r[0], "l0");
assert!(r[3].contains("7 more"), "{}", r[3]);
}
#[test]
fn an_empty_section_leaves_no_heading() {
assert!(heading("DO NOT TOUCH NOW", vec![]).is_empty());
assert_eq!(heading("X", vec![" a".into()]).len(), 3);
}
#[test]
fn clipping_respects_words() {
assert_eq!(clip("hello world", 20), "hello world");
assert!(clip("a fairly long sentence that does not fit", 20).ends_with("..."));
assert!(
clip("a fairly long sentence that does not fit", 20)
.chars()
.count()
<= 20
);
}
}