use crate::args::Args;
use crate::event::{Kind, State, WhereRepo};
use crate::failure::R;
use crate::model::{Node, Tree};
use std::collections::HashSet;
use std::path::Path;
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(groups: Vec<Vec<String>>, max_lines: usize, which: &str) -> Vec<String> {
let mut out = Vec::new();
let mut used = 0;
let mut taken = 0;
for group in &groups {
if taken > 0 && used + group.len() > max_lines {
break;
}
used += group.len();
out.extend(group.iter().cloned());
taken += 1;
}
let left_over = groups.len() - taken;
if left_over > 0 {
out.push(format!(" ... and {left_over} more (vivac {which})"));
}
out
}
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, root: &Path, lane_dir: &Path, args: &Args, project: &str) -> R {
print!("{}", to_text(a, root, lane_dir, args, project)?);
Ok(())
}
fn copy_block(root: &Path) -> Vec<String> {
let Some(project_id) = crate::store::first_event_id(root) else {
return vec![];
};
let Some(store_dir) = crate::store::store_dir() else {
return vec![];
};
let (first, rest) = match crate::registry::copy_of(&store_dir, &project_id, root) {
crate::registry::Noted::Copy { first, rest } => (first, rest),
crate::registry::Noted::Fine => return vec![],
};
crate::store::mark_shown();
let notice = crate::registry::copy_notice(first.as_deref(), &rest);
let mut v = vec![format!(" {}", notice.heading), String::new()];
v.extend(notice.body.lines().map(|l| format!(" {l}")));
v.push(String::new());
v
}
fn head_repr(branch: Option<&str>, sha: Option<&str>, rebasing: bool) -> Option<String> {
match branch {
Some(b) if rebasing => Some(format!("@{b} (rebasing)")),
Some(b) => Some(b.to_string()),
None => sha.map(|s| format!("@{}", &s[..s.len().min(7)])),
}
}
const BRANCH_WITHHELD: &str = "branch name withheld: it looked like a secret";
fn last_known_repr(r: &WhereRepo) -> Option<String> {
if r.missing {
return None;
}
if r.withheld {
return Some(BRANCH_WITHHELD.to_string());
}
head_repr(r.branch.as_deref(), r.sha.as_deref(), r.rebasing)
}
fn now_repr(w: &crate::anchor::Where) -> Option<String> {
let crate::anchor::Where::Head(h) = w else {
return None;
};
if let Some(b) = &h.branch {
if crate::redact::check_field("branch", b).is_some() {
return Some(BRANCH_WITHHELD.to_string());
}
}
head_repr(h.branch.as_deref(), h.sha.as_deref(), h.rebasing)
}
fn candidate_branch(w: &crate::anchor::Where) -> Option<String> {
let crate::anchor::Where::Head(h) = w else {
return None;
};
if h.rebasing {
return None;
}
let b = h.branch.as_ref()?;
(crate::redact::check_field("branch", b).is_none()).then(|| b.clone())
}
struct Moved {
path: String,
before: String,
now: String,
candidate_branch: Option<String>,
root: Option<String>,
}
fn branch_moved_block(a: &Tree, lane_dir: &Path) -> Vec<String> {
let lane = a.lane();
let Some(state) = a.lanes.get(lane) else {
return vec![];
};
if state.repos.is_empty() {
return vec![];
}
let Some(last) = a.wheres.iter().rev().find(|w| w.lane == lane) else {
return vec![];
};
let mut moved: Vec<Moved> = state
.repos
.iter()
.filter_map(|r| {
let before_repo = last.repos.iter().find(|w| w.path == r.path)?;
let before = last_known_repr(before_repo)?;
let now = crate::anchor::where_of(&lane_dir.join(&r.path));
let now_line = now_repr(&now)?;
if before == now_line {
return None;
}
Some(Moved {
path: r.path.clone(),
before,
now: now_line,
candidate_branch: candidate_branch(&now),
root: r.root.clone(),
})
})
.collect();
if moved.is_empty() {
return vec![];
}
moved.sort_by(|x, y| x.path.cmp(&y.path));
let mut lines = vec![" BRANCH MOVED since this lane last wrote".to_string()];
for m in &moved {
lines.push(format!(" {} {} -> {}", m.path, m.before, m.now));
}
struct Candidate {
seq: u64,
line: String,
target: Option<String>,
}
let mut candidates: Vec<Candidate> = moved
.iter()
.filter_map(|m| {
let branch = m.candidate_branch.as_deref()?;
Some(
match a.branch_candidate(lane, &m.path, m.root.as_deref(), branch) {
Some(c) => {
let node = a.node_by_num(c.node)?;
let who = match &c.lane {
Some(other) => format!(" (lane {other})"),
None => String::new(),
};
Candidate {
seq: c.seq,
line: format!(
" last focus on {branch}{who}: {} {}",
node.alias(),
node.title(a)
),
target: Some(node.alias()),
}
}
None => Candidate {
seq: 0,
line: format!(" no earlier work on {branch}"),
target: None,
},
},
)
})
.collect();
candidates.sort_by_key(|x| std::cmp::Reverse(x.seq));
candidates.truncate(3);
for c in &candidates {
lines.push(c.line.clone());
}
let mut targets: Vec<&str> = candidates
.iter()
.filter_map(|c| c.target.as_deref())
.collect();
targets.sort_unstable();
targets.dedup();
if let [only] = targets[..] {
lines.push(format!(" to resume: vivac focus {only}"));
}
lines.push(String::new());
lines
}
pub(crate) struct LaneFocus<'t> {
pub(crate) id: &'t str,
pub(crate) name: &'t str,
pub(crate) focus: &'t Node,
pub(crate) seq: u64,
}
pub(crate) fn lanes_with_a_stack(a: &Tree) -> Vec<LaneFocus<'_>> {
a.lanes
.iter()
.filter_map(|(id, s)| {
let focus = a.node_by_num(*s.stack.last()?)?;
Some(LaneFocus {
id: id.as_str(),
name: if s.name.is_empty() {
id.as_str()
} else {
s.name.as_str()
},
focus,
seq: s.seq_wrote,
})
})
.collect()
}
pub(crate) struct LaneRow<'t> {
pub(crate) id: &'t str,
pub(crate) name: &'t str,
pub(crate) focus: Option<&'t Node>,
pub(crate) seq: u64,
}
pub(crate) fn all_lanes(a: &Tree) -> Vec<LaneRow<'_>> {
a.lanes
.iter()
.map(|(id, s)| LaneRow {
id: id.as_str(),
name: if s.name.is_empty() {
id.as_str()
} else {
s.name.as_str()
},
focus: s.stack.last().and_then(|&num| a.node_by_num(num)),
seq: s.seq_wrote,
})
.collect()
}
pub(crate) fn last_writer(a: &Tree) -> Option<LaneFocus<'_>> {
let mut rows = lanes_with_a_stack(a);
rows.sort_by(|x, y| y.seq.cmp(&x.seq).then_with(|| x.id.cmp(y.id)));
rows.into_iter().next()
}
pub(crate) fn gone_lane_ids(root: &Path) -> Option<Vec<String>> {
let project_id = crate::store::first_event_id(root)?;
let store_dir = crate::store::store_dir()?;
Some(crate::registry::lanes_with_missing_folder(
&store_dir,
&project_id,
))
}
fn other_lanes<'t>(a: &'t Tree, root: &Path) -> Vec<LaneFocus<'t>> {
let here = a.lane();
let own_seq = a.lanes.get(here).map(|s| s.seq_wrote).unwrap_or(0);
let mut rows: Vec<LaneFocus> = lanes_with_a_stack(a)
.into_iter()
.filter(|r| r.id != here && r.seq > own_seq)
.collect();
if rows.is_empty() {
return rows;
}
let Some(gone) = gone_lane_ids(root) else {
return Vec::new();
};
rows.retain(|r| !gone.iter().any(|g| g == r.id));
rows.sort_by(|x, y| y.seq.cmp(&x.seq).then_with(|| x.id.cmp(y.id)));
rows
}
const OTHER_LANES_TITLE: &str = "OTHER LANES since you last wrote here";
fn other_lanes_rows(a: &Tree, rows: &[LaneFocus]) -> Vec<String> {
rows.iter()
.map(|r| {
format!(
" {} {} {} {}",
r.name,
r.focus.alias(),
r.focus.title(a),
r.focus.opened(a)
)
})
.collect()
}
fn other_lanes_fallback(n: usize) -> Vec<String> {
let lanes = if n == 1 { "lane" } else { "lanes" };
heading(
OTHER_LANES_TITLE,
vec![format!(
" {n} {lanes} wrote here since you did (vivac stack --lanes)"
)],
)
}
pub fn to_text(
a: &Tree,
root: &Path,
lane_dir: &Path,
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();
let block = copy_block(root);
if !block.is_empty() {
s.push(Section::fixed(block));
}
s.push(Section::fixed(vec![
format!(
"vivac · project: {project} · lane: {} · {date}",
a.lane_name()
),
RULE.to_string(),
String::new(),
]));
let branch_moved = branch_moved_block(a, lane_dir);
if !branch_moved.is_empty() {
s.push(Section::fixed(branch_moved));
}
if !a.repeated_nums.is_empty() {
let mut seen = HashSet::new();
let distinct_nums: Vec<u64> = a
.repeated_nums
.iter()
.map(|d| d.num)
.filter(|num| seen.insert(*num))
.collect();
let mut nums: Vec<String> = distinct_nums.iter().take(5).map(u64::to_string).collect();
if distinct_nums.len() > 5 {
nums.push(format!("+{}", distinct_nums.len() - 5));
}
s.push(Section::fixed(vec![
format!(
" REPEATED NUMBERS {} <- each names two nodes; vivac check",
nums.join(", ")
),
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 mut question_nodes: Vec<&Node> = 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))
})
.collect();
question_nodes.sort_by_key(|n| n.num);
let questions: Vec<String> = question_nodes
.iter()
.map(|n| format!(" {:<6} {}", n.alias(), n.title(a)))
.collect();
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_groups: Vec<Vec<String>> = flagged
.iter()
.flat_map(|n| {
n.flags.iter().map(move |(b, reason)| {
vec![format!(
" {:<6} {:<10} {}",
n.alias(),
b.word(),
clip(a.text(*reason), 44)
)]
})
})
.collect();
s.push(Section::loose(heading(
"FLAGGED",
trim_list(flag_groups, 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<Vec<String>> = parked_nodes
.iter()
.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 decision_groups: Vec<Vec<String>> = dec
.iter()
.map(|n| vec![format!(" {:<6} {}", n.alias(), clip(n.title(a), 52))])
.collect();
s.push(Section::loose(heading(
"STANDING DECISIONS",
trim_list(decision_groups, 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),
match crate::model::anchoring(&v.anchor, &v.anchors) {
Some(a) => format!(" · {a}"),
None => String::new(),
}
)];
let spoken = a
.vivacs
.iter()
.rev()
.find(|s| s.lane == v.lane && !s.next_intent.is_empty());
let label = spoken.map_or(v.label.as_str(), |s| s.label.as_str());
if !label.is_empty() {
l.push(format!(" \"{}\"", clip(label, 52)));
}
if let Some(s) = spoken {
l.push(if s.num == v.num {
format!(" you were about to: {}", clip(&s.next_intent, 52))
} else {
format!(
" {} was about to: {}",
s.alias(),
clip(&s.next_intent, 52)
)
});
}
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)));
let other = other_lanes(a, root);
if !other.is_empty() {
let full = heading(OTHER_LANES_TITLE, other_lanes_rows(a, &other));
let full_tokens: usize = full.iter().map(|l| tokens(l) + 1).sum();
if tokens_of(&s) + full_tokens <= budget {
s.push(Section::loose(full));
} else {
let short = other_lanes_fallback(other.len());
let short_tokens: usize = short.iter().map(|l| tokens(l) + 1).sum();
if tokens_of(&s) + short_tokens <= budget {
s.push(Section::loose(short));
}
}
}
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_trace_says_one_lane_and_never_one_lanes() {
assert!(other_lanes_fallback(1)
.iter()
.any(|l| l.contains("1 lane wrote here since you did")));
assert!(other_lanes_fallback(2)
.iter()
.any(|l| l.contains("2 lanes wrote here since you did")));
}
#[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 groups: Vec<Vec<String>> = (0..10).map(|i| vec![format!("l{i}")]).collect();
let r = trim_list(groups, 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
);
}
}