use crate::anchor::AnchorRef;
use crate::args::Args;
use crate::brief::clip;
use crate::event::{Body, Event, Kind, State, WhereRepo};
use crate::failure::{Failure, R};
use crate::model::{Aggregates, Node, Tree, Vivac, Where};
use crate::output::outln;
use serde_json::json;
use std::collections::HashMap;
use std::path::Path;
use unicode_normalization::char::canonical_combining_class;
use unicode_normalization::UnicodeNormalization;
pub(crate) const WIDTH: usize = 62;
const ANCESTOR_CLIP: usize = WIDTH;
pub(crate) fn wrap(text: &str, width: usize, indent: &str) -> Vec<String> {
if text.trim().is_empty() {
return vec![];
}
let mut lines = Vec::new();
let mut cur = String::new();
for p in text.split_whitespace() {
if !cur.is_empty() && cur.chars().count() + 1 + p.chars().count() > width {
lines.push(format!("{indent}{cur}"));
cur = p.to_string();
} else {
if !cur.is_empty() {
cur.push(' ');
}
cur.push_str(p);
}
}
if !cur.is_empty() {
lines.push(format!("{indent}{cur}"));
}
lines
}
fn label(a: &Tree, n: &Node) -> String {
match n.state {
State::Active => n.title(a).to_string(),
e => format!("{} [{}]", n.title(a), e.word(n.kind)),
}
}
fn json_node(a: &Tree, ag: &Aggregates, n: &Node) -> serde_json::Value {
let r = ag.counts(n.num);
let mut v = json!({
"id": n.id,
"alias": n.alias(),
"num": n.num,
"kind": n.kind,
"title": n.title(a),
"why": n.why(a),
"state": n.state,
"blocks": n.blocks,
"parent": n.parent.and_then(|p| a.node_by_num(p).map(|x| x.alias())),
"note": n.note(a),
"notes": n.notes(a)
.iter()
.map(|(at, text)| json!({"at": at, "note": text}))
.collect::<Vec<_>>(),
"outcome": n.outcome(a),
"refs": n.refs(a),
"governs": n.governs(a),
"opened": n.opened(a),
"closed": n.closed(a),
"false_close": n.state == State::Done && ag.blockers(n.num) > 0,
"open_below": r.open_count,
"total_below": r.total,
});
if n.kind == Kind::Rule {
v["arms"] = arms_json(a, n);
}
if n.kind == Kind::Decision && (n.against_recorded || !n.against.is_empty()) {
v["against"] = against_json(a, n);
}
v
}
pub(crate) fn print_json(v: serde_json::Value) -> R {
outln!(
"{}",
serde_json::to_string_pretty(&v).map_err(std::io::Error::other)?
);
Ok(())
}
pub(crate) struct Full {
state: HashMap<String, Vec<(u64, State)>>,
}
impl Full {
pub(crate) fn from_log(log: &[Event]) -> Full {
let mut state: HashMap<String, Vec<(u64, State)>> = HashMap::new();
for e in log {
if let Body::StateChanged { node, state: s, .. } = &e.payload {
state.entry(node.clone()).or_default().push((e.seq, *s));
}
}
Full { state }
}
fn state_at(&self, id: &str, seq: u64) -> State {
self.state
.get(id)
.into_iter()
.flatten()
.rfind(|(s, _)| *s <= seq)
.map(|(_, state)| *state)
.unwrap_or(State::Active)
}
}
pub(crate) fn anchor_of(a: &Tree, n: &Node) -> AnchorRef {
a.vivacs
.iter()
.rfind(|v| v.seq <= n.born_seq)
.map(|v| v.anchor.clone())
.unwrap_or_default()
}
pub(crate) fn born_where<'a>(a: &'a Tree, n: &Node) -> Option<&'a Where> {
let lane = n.born_lane(a);
a.wheres
.iter()
.rfind(|w| w.lane == lane && w.seq <= n.born_seq)
}
pub(crate) fn standing_of<'a>(a: &'a Tree, n: &Node) -> Vec<&'a Node> {
a.children(n.num)
.into_iter()
.filter(|c| c.kind == Kind::Decision && c.state.is_open())
.collect()
}
pub(crate) fn blocking_of<'a>(a: &'a Tree, n: &Node) -> Vec<&'a Node> {
if n.state.is_open() {
a.open_blockers(n.num)
} else {
Vec::new()
}
}
pub(crate) fn open_then_of<'a>(a: &'a Tree, full: &Full, n: &Node) -> Vec<&'a Node> {
let Some(parent) = n.parent else {
return vec![];
};
a.children(parent)
.into_iter()
.filter(|c| c.id != n.id && c.num < n.num)
.filter(|c| full.state_at(&c.id, n.born_seq).is_open())
.collect()
}
fn handle_json(a: &Tree, n: &Node) -> serde_json::Value {
json!({
"alias": n.alias(),
"kind": n.kind,
"state": n.state,
"title": n.title(a),
})
}
fn add_born_where(a: &Tree, n: &Node, v: &mut serde_json::Value) {
if let Some(w) = born_where(a, n) {
v["lane"] = json!(w.lane);
v["where"] = json!(w.repos);
}
}
fn json_node_full(a: &Tree, ag: &Aggregates, full: &Full, n: &Node) -> serde_json::Value {
let mut v = json_node(a, ag, n);
v["anchor"] = json!(anchor_of(a, n));
v["standing"] = json!(standing_of(a, n)
.iter()
.map(|c| handle_json(a, c))
.collect::<Vec<_>>());
v["open_then"] = json!(open_then_of(a, full, n)
.iter()
.map(|c| handle_json(a, c))
.collect::<Vec<_>>());
add_born_where(a, n, &mut v);
v
}
fn path_step_json(
a: &Tree,
ag: &Aggregates,
full: &Full,
full_extra: bool,
p: &Node,
) -> serde_json::Value {
let body = |text: &str| {
if full_extra {
text.to_string()
} else {
clip(text, ANCESTOR_CLIP)
}
};
let below = ag.counts(p.num);
let mut v = json!({
"alias": p.alias(),
"kind": p.kind,
"state": p.state,
"title": p.title(a),
"why": body(p.why(a)),
"notes": p.notes(a)
.iter()
.map(|(at, text)| json!({"at": at, "note": body(text)}))
.collect::<Vec<_>>(),
"outcome": body(p.outcome(a)),
"below": {
"open": below.open_count,
"closed": below.closed_count,
"parked": below.parked_nodes,
},
});
if p.kind == Kind::Rule {
v["arms"] = arms_json(a, p);
}
if full_extra && p.kind == Kind::Decision && (p.against_recorded || !p.against.is_empty()) {
v["against"] = against_json(a, p);
}
add_born_where(a, p, &mut v);
if full_extra {
v["anchor"] = json!(anchor_of(a, p));
v["standing"] = json!(standing_of(a, p)
.iter()
.map(|c| handle_json(a, c))
.collect::<Vec<_>>());
v["open_then"] = json!(open_then_of(a, full, p)
.iter()
.map(|c| handle_json(a, c))
.collect::<Vec<_>>());
}
v
}
fn why_data_impl(
a: &Tree,
full: &Full,
full_extra: bool,
id: &str,
) -> Result<serde_json::Value, Failure> {
let ag = &a.aggregates();
let n = match a.resolve(id) {
Some(n) => n,
None => {
return a
.vivac(id)
.map(|v| vivac_json(a, v))
.ok_or_else(|| Failure::usage(format!("No such node: {id}.")));
}
};
let lineage = a.ancestors(n.num);
let mut node_json = if full_extra {
json_node_full(a, ag, full, n)
} else {
let mut v = json_node(a, ag, n);
add_born_where(a, n, &mut v);
v
};
let hidden: Vec<&str> = a
.repeated_nums
.iter()
.filter(|d| d.num == n.num)
.map(|d| d.second.as_str())
.collect();
if !hidden.is_empty() {
node_json["repeated"] = json!({"num": n.num, "hidden": hidden});
}
let siblings: Vec<_> = n
.parent
.map(|p| a.children(p))
.unwrap_or_default()
.into_iter()
.filter(|c| c.id != n.id && c.state.is_open())
.map(|c| handle_json(a, c))
.collect();
let born_here: Vec<_> = a
.children(n.num)
.iter()
.filter(|c| c.state.is_open())
.map(|c| {
let mut v = handle_json(a, c);
v["blocks"] = json!(c.blocks);
v
})
.collect();
let blockers: Vec<_> = lineage
.iter()
.filter_map(|p| {
let until = blocking_of(a, p);
(!until.is_empty()).then(|| {
json!({
"blocked": p.alias(),
"until": until.iter().map(|c| handle_json(a, c)).collect::<Vec<_>>(),
})
})
})
.collect();
Ok(json!({
"node": node_json,
"path": lineage[..lineage.len().saturating_sub(1)]
.iter()
.map(|p| path_step_json(a, ag, full, full_extra, p))
.collect::<Vec<_>>(),
"in_parallel": siblings,
"born_here": born_here,
"blockers": blockers,
}))
}
pub fn why_data(a: &Tree, log: &[Event], id: &str) -> Result<serde_json::Value, Failure> {
why_data_impl(a, &Full::from_log(log), false, id)
}
pub fn open_data(a: &Tree) -> serde_json::Value {
let ag = a.aggregates();
let mut leaves: Vec<&Node> = a
.nodes_iter()
.filter(|n| n.is_front() && !a.children(n.num).iter().any(|c| c.is_front()))
.collect();
leaves.sort_by_cached_key(|n| {
(
!n.blocks,
std::cmp::Reverse(ag.counts(n.num).total),
std::cmp::Reverse(n.num),
)
});
json!(leaves
.iter()
.map(|n| json!({
"alias": n.alias(),
"kind": n.kind,
"state": n.state,
"title": n.title(a),
"lineage": lineage_of(a, n),
}))
.collect::<Vec<_>>())
}
fn lane_display<'a>(a: &'a Tree, id: &'a str) -> &'a str {
match a.lanes.get(id) {
Some(s) if !s.name.is_empty() => s.name.as_str(),
_ => id,
}
}
fn describe_repo(r: &WhereRepo) -> String {
if r.withheld {
return format!("{} (branch name withheld: it looked like a secret)", r.path);
}
match (&r.branch, &r.sha) {
(Some(b), _) => format!("{}@{b}", r.path),
(None, Some(sha)) => format!("{}@{}", r.path, &sha[..sha.len().min(7)]),
(None, None) => r.path.clone(),
}
}
fn same_branch(repos: &[WhereRepo]) -> Option<&str> {
let first = repos.first()?.branch.as_deref()?;
repos
.iter()
.all(|r| r.branch.as_deref() == Some(first))
.then_some(first)
}
fn describe_repos(repos: &[WhereRepo]) -> String {
if let [one] = repos {
return describe_repo(one);
}
if let Some(branch) = same_branch(repos) {
return format!("{} repos on {branch}", repos.len());
}
let mut pieces: Vec<String> = repos.iter().take(3).map(describe_repo).collect();
if repos.len() > 3 {
pieces.push(format!("and {} more", repos.len() - 3));
}
pieces.join(", ")
}
fn branch_moved_since_birth(a: &Tree, born: &Where) -> bool {
if born.lane != a.lane() {
return false;
}
let Some(latest) = a.wheres.iter().rfind(|w| w.lane == born.lane) else {
return false;
};
born.repos.iter().any(|b| {
latest
.repos
.iter()
.find(|l| l.path == b.path)
.is_some_and(|l| l.branch != b.branch)
})
}
fn born_line(a: &Tree, n: &Node) -> Option<String> {
let w = born_where(a, n)?;
let mut line = format!(
"born in lane {} · {}",
lane_display(a, &w.lane),
describe_repos(&w.repos)
);
if branch_moved_since_birth(a, w) {
line.push_str(" (not the branch you are on)");
}
Some(line)
}
fn print_full_of(a: &Tree, full: &Full, n: &Node) {
let anchor = anchor_of(a, n);
if anchor.is_empty_tree() {
outln!(" anchor: none");
} else {
outln!(" anchor: {} ({})", anchor.short(), anchor.kind);
}
let standing = standing_of(a, n);
if !standing.is_empty() {
outln!(
" standing ({}): {}",
standing.len(),
standing
.iter()
.map(|d| d.alias())
.collect::<Vec<_>>()
.join(", ")
);
}
let open_then = open_then_of(a, full, n);
if !open_then.is_empty() {
outln!(
" open then ({}): {}",
open_then.len(),
open_then
.iter()
.map(|d| d.alias())
.collect::<Vec<_>>()
.join(", ")
);
}
}
fn safe_stop(a: &Tree, v: &Vivac, args: &Args) -> R {
if args.has("json") {
return print_json(vivac_json(a, v));
}
outln!();
outln!(" Safe stop -> {}", v.alias());
outln!(" {}", "-".repeat(66));
outln!();
let mut header = format!(
" {} · {} · {}",
v.alias(),
v.kind.word(),
crate::clock::date_of(&v.ts)
);
if let Some(anchor) = crate::model::anchoring(&v.anchor, &v.anchors) {
header.push_str(&format!(" · {anchor}"));
}
outln!("{header}");
if let Some(node) = v.node_ref.as_deref().and_then(|r| a.node(r)) {
outln!(" written at {:<6}{}", node.alias(), node.title(a));
}
if !v.label.is_empty() {
outln!(" \"{}\"", v.label);
}
if !v.next_intent.is_empty() {
outln!(" you were about to: {}", v.next_intent);
}
outln!();
outln!(" The stack it carried");
if v.stack.is_empty() {
outln!(" empty stack");
} else {
for (alias, title) in &v.stack {
outln!(" {:<6} {}", alias, title);
}
}
if !v.working_set.is_empty() {
outln!();
outln!(" Working set");
for w in &v.working_set {
outln!(" {w}");
}
}
outln!();
outln!(" vivac restore {} rebuilds this stack", v.alias());
outln!();
Ok(())
}
pub fn why(a: &Tree, log: &[Event], args: &Args) -> R {
let ag = &a.aggregates();
let s = args
.positional(0)
.ok_or_else(|| Failure::usage("usage: vivac why <id>"))?;
let n = match a.resolve(s) {
Some(n) => n,
None => {
return match a.vivac(s) {
Some(v) => safe_stop(a, v, args),
None => Err(Failure::usage(format!("No such node: {s}."))),
};
}
};
let lineage = a.ancestors(n.num);
let full_data = Full::from_log(log);
let full_extra = args.has("full");
if args.has("json") {
return print_json(why_data_impl(a, &full_data, full_extra, s)?);
}
outln!();
outln!(" Why we are here -> {}", n.alias());
outln!(" {}", "-".repeat(66));
let hidden: Vec<&str> = a
.repeated_nums
.iter()
.filter(|d| d.num == n.num)
.map(|d| d.second.as_str())
.collect();
if !hidden.is_empty() {
let (noun, pronoun) = if hidden.len() == 1 {
("another node", "it")
} else {
("other nodes", "them")
};
outln!(
" {} also names {noun}, {}, which this tree cannot show. vivac check lists {pronoun}.",
n.alias(),
hidden.join(", ")
);
}
outln!();
for (i, p) in lineage.iter().enumerate() {
let is_last = i == lineage.len() - 1;
let clip_body = !is_last && !full_extra;
let body = |text: &str| {
if clip_body {
clip(text, ANCESTOR_CLIP)
} else {
text.to_string()
}
};
outln!(" {:<6}{}", p.alias(), label(a, p));
if p.kind == Kind::Rule {
print_arms(a, p, " ", true);
}
if p.kind == Kind::Decision && (is_last || full_extra) {
print_against(a, p, " ");
}
for l in wrap(&body(p.why(a)), WIDTH, " ") {
outln!("{l}");
}
let notes = p.notes(a);
if notes.len() > 1 {
for (at, text) in ¬es {
let date = crate::clock::date_of(at);
for l in wrap(&format!("! [{date}] {}", body(text)), WIDTH, " ") {
outln!("{l}");
}
}
} else {
let note = p.note(a);
for l in wrap(&format!("! {}", body(note)), WIDTH, " ") {
if !note.is_empty() {
outln!("{l}");
}
}
}
let outcome = p.outcome(a);
for l in wrap(&format!("= {}", body(outcome)), WIDTH, " ") {
if !outcome.is_empty() {
outln!("{l}");
}
}
if let Some(line) = born_line(a, p) {
outln!(" {line}");
}
if full_extra {
print_full_of(a, &full_data, p);
}
if !is_last {
let f = ag.counts(p.num).phrase();
if !f.is_empty() {
outln!(" ({f} below)");
}
outln!(" |");
outln!(" v");
} else {
outln!();
outln!(" ^^^ you are here");
}
}
outln!();
if let Some(parent) = n.parent {
let siblings: Vec<_> = a
.children(parent)
.into_iter()
.filter(|c| c.id != n.id && c.state.is_open())
.collect();
if !siblings.is_empty() {
outln!(" In parallel, still open ({}):", siblings.len());
for c in siblings {
outln!(" {:<6} {}", c.alias(), c.title(a));
}
outln!();
}
}
let kids: Vec<_> = a
.children(n.num)
.into_iter()
.filter(|c| c.state.is_open())
.collect();
if !kids.is_empty() {
outln!(" Born here and still open ({}):", kids.len());
for c in kids {
outln!(
" {} {:<6} {}",
if c.blocks { '*' } else { ' ' },
c.alias(),
c.title(a)
);
}
outln!();
}
for p in &lineage {
let pending_count = blocking_of(a, p);
if !pending_count.is_empty() {
outln!(
" {} does not close until these close ({}):",
p.alias(),
pending_count.len()
);
for c in pending_count {
outln!(" {:<6} {}", c.alias(), c.title(a));
}
outln!();
}
}
Ok(())
}
fn branch(a: &Tree, ag: &Aggregates, n: &Node, prefix: &str, is_last: bool, show_all: bool) {
let f = ag.counts(n.num).phrase();
let mut tail = if f.is_empty() {
String::new()
} else {
format!(" ({f})")
};
let pending_count = ag.blockers(n.num);
if n.state == State::Done && pending_count > 0 {
tail.push_str(&format!(
" <== FALSE CLOSE: {pending_count} open condition(s)"
));
}
let mark = if n.blocks { "* " } else { "" };
outln!(
"{prefix}{}[{}] {:<6} {mark}{}{tail}",
if is_last { "`-- " } else { "|-- " },
n.state.mark(),
n.alias(),
n.title(a)
);
let sig = format!("{prefix}{}", if is_last { " " } else { "| " });
let children: Vec<_> = a
.children(n.num)
.into_iter()
.filter(|h| show_all || h.state.is_open() || ag.counts(h.num).open_count > 0)
.collect();
for (i, h) in children.iter().enumerate() {
branch(a, ag, h, &sig, i == children.len() - 1, show_all);
}
}
fn repeated_lines(a: &Tree) -> Vec<String> {
a.repeated_nums
.iter()
.map(|d| {
format!(
" {} is repeated: {} is shown and {} is not. vivac check lists every one.",
d.num, d.first, d.second
)
})
.collect()
}
fn subtree_json(a: &Tree, ag: &Aggregates, n: &Node) -> serde_json::Value {
let mut v = json_node(a, ag, n);
v["children"] = json!(a
.children(n.num)
.iter()
.map(|h| subtree_json(a, ag, h))
.collect::<Vec<_>>());
v
}
pub fn tree(a: &Tree, args: &Args) -> R {
let ag = &a.aggregates();
let roots: Vec<&Node> = match args.positional(0) {
Some(s) => vec![a
.resolve(s)
.ok_or_else(|| Failure::usage(format!("No such node: {s}.")))?],
None => a.roots(),
};
if args.has("json") {
return print_json(json!(roots
.iter()
.map(|n| subtree_json(a, ag, n))
.collect::<Vec<_>>()));
}
if a.is_empty_tree() {
outln!(" Empty tree. vivac push \"<title>\" --why \"<reason>\"");
return Ok(());
}
let show_all = args.has("all");
outln!();
for (i, n) in roots.iter().enumerate() {
branch(a, ag, n, " ", i == roots.len() - 1, show_all);
}
outln!();
let repeated = repeated_lines(a);
if !repeated.is_empty() {
for l in &repeated {
outln!("{l}");
}
outln!();
}
if !show_all {
outln!(" (closed nodes with no open descendants hidden; --all shows them)");
outln!();
}
Ok(())
}
const MAX_FRONTS_SHOWN: usize = 10;
pub fn open(a: &Tree, args: &Args) -> R {
let ag = a.aggregates();
let mut leaves: Vec<&Node> = a
.nodes_iter()
.filter(|n| n.is_front() && !a.children(n.num).iter().any(|c| c.is_front()))
.collect();
leaves.sort_by_cached_key(|n| {
(
!n.blocks,
std::cmp::Reverse(ag.counts(n.num).total),
std::cmp::Reverse(n.num),
)
});
let standing = a
.nodes_iter()
.filter(|n| n.kind == Kind::Decision && n.state.is_open())
.count();
if args.has("json") {
return print_json(open_data(a));
}
if leaves.is_empty() && standing == 0 {
outln!(" Nothing open.");
return Ok(());
}
outln!();
outln!(
" {} open front{}",
leaves.len(),
if leaves.len() == 1 { "" } else { "s" },
);
outln!();
let show_all = args.has("all");
let shown = if show_all {
leaves.len()
} else {
leaves.len().min(MAX_FRONTS_SHOWN)
};
for n in &leaves[..shown] {
outln!(" {:<6} {}", n.alias(), n.title(a));
let lineage = a.ancestors(n.num);
if lineage.len() > 1 {
let v: Vec<String> = lineage[..lineage.len() - 1]
.iter()
.map(|p| p.alias())
.collect();
outln!(" via {}", v.join(" > "));
}
}
let hidden = leaves.len() - shown;
if hidden > 0 {
let oldest = leaves[shown..].iter().map(|n| n.opened(a)).min();
let age = oldest.and_then(|d| crate::clock::days_between(d, &crate::clock::now_rfc3339()));
match age {
Some(d) if d <= 0 => {
outln!(" {hidden} more, the oldest opened today -- vivac open --all")
}
Some(1) => {
outln!(" {hidden} more, the oldest open since yesterday -- vivac open --all")
}
Some(days) => {
outln!(" {hidden} more, the oldest open for {days} days -- vivac open --all")
}
None => outln!(" {hidden} more -- vivac open --all"),
}
}
if standing > 0 {
let phrase = if standing == 1 {
"1 standing decision, which is not work".to_string()
} else {
format!("{standing} standing decisions, which are not work")
};
outln!();
outln!(" + {phrase} vivac brief");
}
outln!();
Ok(())
}
fn nearest_pillar<'a>(a: &'a Tree, n: &Node) -> Option<&'a Node> {
let mut cur = n.parent;
while let Some(p) = cur {
let node = a.node_by_num(p)?;
if node.kind == Kind::Pillar {
return Some(node);
}
cur = node.parent;
}
None
}
struct PillarSection<'a> {
pillar: &'a Node,
rules: Vec<&'a Node>,
}
struct RulesView<'a> {
pillars: Vec<PillarSection<'a>>,
orphan_rules: Vec<&'a Node>,
invariants: Vec<&'a Node>,
}
fn rules_view(a: &Tree) -> RulesView<'_> {
let mut under: HashMap<u64, Vec<&Node>> = HashMap::new();
let mut orphan_rules: Vec<&Node> = Vec::new();
for n in a.nodes_iter() {
if n.kind == Kind::Rule && n.state.is_open() {
match nearest_pillar(a, n) {
Some(p) => under.entry(p.num).or_default().push(n),
None => orphan_rules.push(n),
}
}
}
for v in under.values_mut() {
v.sort_by_key(|n| n.num);
}
orphan_rules.sort_by_key(|n| n.num);
let mut pillars: Vec<PillarSection> = a
.nodes_iter()
.filter(|n| n.kind == Kind::Pillar)
.filter(|n| n.state.is_open() || under.get(&n.num).is_some_and(|v| !v.is_empty()))
.map(|n| PillarSection {
pillar: n,
rules: under.get(&n.num).cloned().unwrap_or_default(),
})
.collect();
pillars.sort_by_key(|s| s.pillar.num);
let mut invariants: Vec<&Node> = a
.nodes_iter()
.filter(|n| n.kind == Kind::Constraint && n.state.is_open())
.collect();
invariants.sort_by_key(|n| n.num);
RulesView {
pillars,
orphan_rules,
invariants,
}
}
pub fn rules_data(a: &Tree) -> serde_json::Value {
let ag = &a.aggregates();
let view = rules_view(a);
json!({
"pillars": view.pillars.iter().map(|s| {
let mut v = json_node(a, ag, s.pillar);
v["rules"] = json!(s.rules.iter().map(|r| json_node(a, ag, r)).collect::<Vec<_>>());
v
}).collect::<Vec<_>>(),
"rules": view.orphan_rules.iter().map(|r| json_node(a, ag, r)).collect::<Vec<_>>(),
"invariants": view.invariants.iter().map(|n| json_node(a, ag, n)).collect::<Vec<_>>(),
})
}
fn print_arms(a: &Tree, r: &Node, indent: &str, show_judged: bool) {
let arms = r.arms(a);
if arms.is_empty() {
if show_judged {
outln!("{indent}judged: no command verifies it");
}
} else {
for (dir, command) in arms {
outln!("{indent}armed in {dir}/: {command}");
}
}
}
fn arms_json(a: &Tree, r: &Node) -> serde_json::Value {
json!(r
.arms(a)
.into_iter()
.map(|(dir, command)| json!({"dir": dir, "command": command}))
.collect::<Vec<_>>())
}
fn print_against(a: &Tree, n: &Node, indent: &str) {
for e in n.against(a) {
let mark = match e.target {
Some((kind, state)) if !state.is_open() => format!(" [{}]", state.word(kind)),
_ => String::new(),
};
let suffix = match e.declared {
Some(date) => format!(" (declared {date})"),
None => String::new(),
};
let line = format!("judged against {}{mark}: {}{suffix}", e.alias, e.why);
for l in wrap(&line, WIDTH, indent) {
outln!("{l}");
}
}
}
fn against_json(a: &Tree, n: &Node) -> serde_json::Value {
json!(n
.against(a)
.into_iter()
.map(|e| json!({
"node": e.alias,
"state": e.target.map(|(_, state)| state),
"why": e.why,
"declared": e.declared,
}))
.collect::<Vec<_>>())
}
fn print_second_map_hint() {
outln!(" Rules kept in CLAUDE.md, AGENTS.md or a memory file are a second map, and");
outln!(" vivac never reads them: bring them in with vivac add --type pillar|rule.");
}
pub fn rules(a: &Tree, args: &Args) -> R {
if args.has("json") {
return print_json(rules_data(a));
}
let view = rules_view(a);
let total_rules: usize =
view.pillars.iter().map(|s| s.rules.len()).sum::<usize>() + view.orphan_rules.len();
let armed_rules = view
.pillars
.iter()
.flat_map(|s| &s.rules)
.chain(&view.orphan_rules)
.filter(|r| !r.arms.is_empty())
.count();
let judged_rules = total_rules - armed_rules;
let nothing_governs = view.pillars.is_empty() && total_rules == 0;
if nothing_governs && view.invariants.is_empty() {
outln!(" Nothing governs this project yet: no pillars, rules or invariants.");
outln!();
print_second_map_hint();
return Ok(());
}
outln!();
if !view.pillars.is_empty() {
outln!(" PILLARS");
for s in &view.pillars {
outln!(" {:<6}{}", s.pillar.alias(), label(a, s.pillar));
for r in &s.rules {
outln!(" {:<6}{}", r.alias(), r.title(a));
print_arms(a, r, " ", false);
}
}
}
if !view.orphan_rules.is_empty() {
outln!();
outln!(" RULES WITHOUT A PILLAR");
for r in &view.orphan_rules {
outln!(" {:<6}{}", r.alias(), r.title(a));
print_arms(a, r, " ", false);
}
}
if !view.invariants.is_empty() {
outln!();
outln!(" INVARIANTS");
for n in &view.invariants {
outln!(" {:<6}{}", n.alias(), n.title(a));
}
}
outln!();
outln!(
" {} pillar{} \u{b7} {} rule{}: {} armed, {} judged \u{b7} {} invariant{}",
view.pillars.len(),
if view.pillars.len() == 1 { "" } else { "s" },
total_rules,
if total_rules == 1 { "" } else { "s" },
armed_rules,
judged_rules,
view.invariants.len(),
if view.invariants.len() == 1 { "" } else { "s" },
);
outln!();
if nothing_governs {
print_second_map_hint();
}
Ok(())
}
pub fn triage(a: &Tree, args: &Args) -> R {
let ag = &a.aggregates();
let mut parked_nodes: Vec<&Node> = a
.nodes_iter()
.filter(|n| n.state == State::Suspended)
.collect();
let mut deep: Vec<(&Node, usize)> = a
.nodes_iter()
.filter(|n| n.is_front())
.map(|n| (n, a.under_goal(n.num).len()))
.filter(|(_, d)| *d >= 6)
.collect();
let mut orphaned: Vec<(&Node, &Node)> = a
.nodes_iter()
.filter(|n| n.is_front())
.filter_map(|n| {
let p = a.node_by_num(n.parent?)?;
(p.state == State::Abandoned).then_some((n, p))
})
.collect();
let mut false_closes: Vec<&Node> = a
.nodes_iter()
.filter(|n| n.state == State::Done && !n.forced_close && ag.blockers(n.num) > 0)
.collect();
parked_nodes.sort_by_key(|n| n.num);
deep.sort_by_key(|(n, _)| n.num);
orphaned.sort_by_key(|(n, _)| n.num);
false_closes.sort_by_key(|n| n.num);
if args.has("json") {
return print_json(json!({
"parked": parked_nodes.iter().map(|n| json_node(a, ag, n)).collect::<Vec<_>>(),
"deep": deep.iter().map(|(n, d)| {
let mut v = json_node(a, ag, n);
v["depth_from_goal"] = json!(d);
v
}).collect::<Vec<_>>(),
"orphaned_by_discard": orphaned.iter().map(|(n, p)| {
let mut v = json_node(a, ag, n);
v["discarded"] = json!(p.alias());
v["discarded_because"] = json!(p.outcome(a));
v
}).collect::<Vec<_>>(),
"false_closes": false_closes.iter().map(|n| json_node(a, ag, n)).collect::<Vec<_>>(),
}));
}
let total = parked_nodes.len() + deep.len() + orphaned.len() + false_closes.len();
if total == 0 {
outln!(" Nothing to prune.");
return Ok(());
}
outln!();
outln!(" TRIAGE - {total} thing(s) to look at");
if !parked_nodes.is_empty() {
outln!();
outln!(
" PARKED ({}) focus <id> | abandon <id>",
parked_nodes.len()
);
for n in &parked_nodes {
outln!(" {:<6} {}", n.alias(), n.title(a));
for l in wrap(n.outcome(a), WIDTH, " ") {
outln!("{l}");
}
}
}
if !deep.is_empty() {
outln!();
outln!(
" 6 OR MORE FROM ITS GOAL ({}) promote <id>",
deep.len()
);
for (n, d) in &deep {
outln!(
" {:<6} {:<40} depth {d}",
n.alias(),
clip(n.title(a), 40)
);
let v: Vec<String> = a
.under_goal(n.num)
.iter()
.rev()
.skip(1)
.rev()
.map(|p| p.alias())
.collect();
outln!(" via {}", v.join(" > "));
}
}
if !orphaned.is_empty() {
outln!();
outln!(
" SURVIVED A DISCARD ({}) abandon <id> | promote <id>",
orphaned.len()
);
for (n, p) in &orphaned {
outln!(" {:<6} {}", n.alias(), n.title(a));
outln!(
" born from {}, discarded: {}",
p.alias(),
clip(p.outcome(a), 36)
);
}
}
if !false_closes.is_empty() {
outln!();
outln!(
" FALSE CLOSES ({}) close what is left, or --force",
false_closes.len()
);
for n in &false_closes {
outln!(
" {:<6} {:<40} {} blocker(s)",
n.alias(),
clip(n.title(a), 40),
ag.blockers(n.num)
);
}
}
outln!();
Ok(())
}
pub fn parked(a: &Tree, args: &Args) -> R {
let ag = &a.aggregates();
let mut ps: Vec<&Node> = a
.nodes_iter()
.filter(|n| n.state == State::Suspended)
.collect();
ps.sort_by_key(|n| n.num);
if args.has("json") {
return print_json(json!(ps
.iter()
.map(|n| json_node(a, ag, n))
.collect::<Vec<_>>()));
}
if ps.is_empty() {
outln!(" Nothing parked.");
return Ok(());
}
outln!();
outln!(" DO NOT TOUCH NOW ({})", ps.len());
outln!();
for n in ps {
outln!(" {:<6} {}", n.alias(), n.title(a));
for l in wrap(n.outcome(a), WIDTH, " ") {
outln!("{l}");
}
}
outln!();
Ok(())
}
pub fn stack(a: &Tree, root: &Path, args: &Args) -> R {
let ag = &a.aggregates();
if args.has("lanes") {
return stack_lanes(a, root, args, ag);
}
let stack: Vec<&Node> = a
.stack()
.iter()
.filter_map(|&num| a.node_by_num(num))
.collect();
if args.has("json") {
return print_json(json!({
"depth": stack.len(),
"stack": stack.iter().map(|n| json_node(a, ag, n)).collect::<Vec<_>>(),
}));
}
if stack.is_empty() {
outln!(" Empty stack. vivac push \"<title>\" --why \"<reason>\"");
return Ok(());
}
outln!();
for (i, n) in stack.iter().enumerate() {
let focus = if i == stack.len() - 1 {
" <- focus"
} else {
""
};
outln!(" {}{:<6} {}{focus}", " ".repeat(i), n.alias(), n.title(a));
}
outln!();
if stack.len() >= 6 {
outln!(
" Stack {} levels deep. Almost never lack of discipline: usually",
stack.len()
);
outln!(" the root goal moved and nobody re-rooted. vivac promote");
outln!();
}
Ok(())
}
fn stack_lanes(a: &Tree, root: &Path, args: &Args, ag: &Aggregates) -> R {
let mut rows = crate::brief::all_lanes(a);
rows.sort_by(|x, y| {
x.focus
.is_none()
.cmp(&y.focus.is_none())
.then_with(|| match (x.focus, y.focus) {
(Some(_), Some(_)) => y.seq.cmp(&x.seq).then_with(|| x.id.cmp(y.id)),
_ => x.name.cmp(y.name),
})
});
let gone = if rows.is_empty() {
Vec::new()
} else {
crate::brief::gone_lane_ids(root).unwrap_or_default()
};
if args.has("json") {
return print_json(json!({
"lanes": rows
.iter()
.map(|r| json!({
"id": r.id,
"name": r.name,
"focus": match r.focus {
Some(focus) => json_node(a, ag, focus),
None => serde_json::Value::Null,
},
"folder_gone": gone.iter().any(|g| g == r.id),
}))
.collect::<Vec<_>>(),
}));
}
if rows.is_empty() {
outln!(" No lanes yet. vivac setup claude-code plants one.");
return Ok(());
}
outln!();
for r in &rows {
let tail = if gone.iter().any(|g| g == r.id) {
" (folder gone)"
} else {
""
};
match r.focus {
Some(focus) => outln!(
" {:<11} {:<6} {:<45} {}{tail}",
r.name,
focus.alias(),
focus.title(a),
focus.opened(a)
),
None => outln!(" {:<11} (nothing pushed yet){tail}", r.name),
}
}
outln!();
Ok(())
}
pub fn stats(a: &Tree, args: &Args) -> R {
let ag = &a.aggregates();
let mut by_state = std::collections::BTreeMap::new();
let mut orphans = 0usize;
let mut false_closes = Vec::new();
for n in a.nodes_iter() {
*by_state.entry(n.state.word(n.kind)).or_insert(0usize) += 1;
if n.parent.is_some_and(|p| a.node_by_num(p).is_none()) {
orphans += 1;
}
if n.state == State::Done && ag.blockers(n.num) > 0 {
false_closes.push(n);
}
}
let depth_of = ag.max_depth;
false_closes.sort_by_key(|n| n.num);
if args.has("json") {
return print_json(json!({
"nodes": a.total(),
"by_state": by_state,
"depth": depth_of,
"roots": a.roots().len(),
"stack": a.stack_depth(),
"orphans": orphans,
"broken_lines": a.broken_lines,
"false_closes": false_closes.iter().map(|n| json_node(a, ag, n)).collect::<Vec<_>>(),
}));
}
outln!();
outln!(" nodes {}", a.total());
for (k, v) in &by_state {
outln!(" {k:<14} {v}");
}
outln!(" depth {depth_of}");
outln!(" roots {}", a.roots().len());
outln!(" stack {}", a.stack_depth());
if orphans > 0 {
outln!(" ORPHANS {orphans} <- broken provenance");
}
if a.broken_lines > 0 {
outln!(" broken lines {} <- in .vivac/events", a.broken_lines);
}
if !false_closes.is_empty() {
outln!();
outln!(" FALSE CLOSES ({})", false_closes.len());
for n in false_closes {
outln!(" {:<6} {}", n.alias(), n.title(a));
}
}
outln!();
Ok(())
}
fn vivac_json(a: &Tree, v: &Vivac) -> serde_json::Value {
json!({
"id": v.id,
"alias": v.alias(),
"node_ref": v.node_ref.as_ref().and_then(|r| a.node(r).map(|n| n.alias())),
"kind": v.kind.word(),
"ts": v.ts,
"label": v.label,
"next_intent": v.next_intent,
"anchor": v.anchor,
"anchors": v.anchors,
"stack": v.stack.iter().map(|(al, t)| json!({"alias": al, "title": t}))
.collect::<Vec<_>>(),
"working_set": v.working_set,
})
}
pub fn vivacs(a: &Tree, args: &Args) -> R {
if args.has("json") {
return print_json(json!(a
.vivacs
.iter()
.rev()
.map(|v| vivac_json(a, v))
.collect::<Vec<_>>()));
}
if a.vivacs.is_empty() {
outln!(" No stops yet. vivac save \"<label>\"");
return Ok(());
}
outln!();
for v in a.vivacs.iter().rev().take(20) {
let top = v
.stack
.last()
.map(|(al, t)| format!("{al} {t}"))
.unwrap_or_else(|| "empty stack".into());
outln!(
" {:<5} {:<7} {} {}",
v.alias(),
v.kind.word(),
crate::clock::date_of(&v.ts),
top
);
if !v.label.is_empty() {
outln!(" {}", v.label);
}
if !v.next_intent.is_empty() {
outln!(" you were about to: {}", v.next_intent);
}
}
if a.vivacs.len() > 20 {
outln!();
outln!(" ... and {} more", a.vivacs.len() - 20);
}
outln!();
Ok(())
}
fn searchable<'t>(a: &'t Tree, n: &Node) -> Vec<(&'static str, &'t str)> {
let mut fields = vec![("title", n.title(a)), ("why", n.why(a))];
fields.extend(n.notes(a).into_iter().map(|(_, text)| ("note", text)));
fields.push(("outcome", n.outcome(a)));
fields
}
fn is_diacritic(c: char) -> bool {
matches!(c as u32,
0x0300..=0x036F
| 0x1AB0..=0x1AFF
| 0x1DC0..=0x1DFF
| 0x20D0..=0x20FF
| 0xFE20..=0xFE2F
)
}
pub(crate) fn fold(text: &str) -> String {
let mut folded = String::with_capacity(text.len());
fold_into(text, &mut folded, None);
folded
}
pub(crate) fn fold_with_origin(text: &str) -> (String, Vec<usize>) {
let mut folded = String::with_capacity(text.len());
let mut origin = Vec::with_capacity(text.len());
fold_into(text, &mut folded, Some(&mut origin));
(folded, origin)
}
fn fold_into(text: &str, folded: &mut String, mut origin: Option<&mut Vec<usize>>) {
let mut segment = String::new();
let mut segment_at = 0;
let mut at = 0;
let mut rest = text;
while !rest.is_empty() {
let ascii = rest
.bytes()
.position(|b| !b.is_ascii())
.unwrap_or(rest.len());
if ascii > 0 {
if !segment.is_empty() {
fold_segment(&segment, segment_at, folded, origin.as_deref_mut());
segment.clear();
}
let (run, tail) = rest.split_at(ascii);
let start = folded.len();
folded.push_str(run);
folded[start..].make_ascii_lowercase();
if let Some(origin) = origin.as_deref_mut() {
origin.extend(at..at + ascii);
}
at += ascii;
rest = tail;
continue;
}
let c = rest.chars().next().expect("rest is not empty");
for lc in c.to_lowercase() {
if canonical_combining_class(lc) == 0 && !segment.is_empty() {
fold_segment(&segment, segment_at, folded, origin.as_deref_mut());
segment.clear();
}
if segment.is_empty() {
segment_at = at;
}
segment.push(lc);
}
at += 1;
rest = &rest[c.len_utf8()..];
}
if !segment.is_empty() {
fold_segment(&segment, segment_at, folded, origin);
}
}
fn fold_segment(
segment: &str,
at: usize,
folded: &mut String,
mut origin: Option<&mut Vec<usize>>,
) {
if segment.len() == 1 {
folded.push_str(segment);
if let Some(origin) = origin {
origin.push(at);
}
return;
}
for c in segment.nfd().filter(|c| !is_diacritic(*c)) {
if let Some(origin) = origin.as_deref_mut() {
origin.extend(std::iter::repeat_n(at, c.len_utf8()));
}
folded.push(c);
}
}
fn snippet(text: &str, terms: &[String], width: usize) -> String {
let chars: Vec<char> = text.chars().collect();
if chars.len() <= width {
return text.split_whitespace().collect::<Vec<_>>().join(" ");
}
let (lower, origin) = fold_with_origin(text);
let at = terms
.iter()
.filter_map(|t| lower.find(t.as_str()))
.min()
.map(|b| origin[b])
.unwrap_or(0);
let end = (at + width * 2 / 3).clamp(width, chars.len());
let start = end - width;
let mut out = String::new();
if start > 0 {
out.push_str("...");
}
out.extend(chars[start..end].iter());
if end < chars.len() {
out.push_str("...");
}
out.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn terms_of(query: &str) -> Result<Vec<String>, Failure> {
let terms: Vec<String> = query.split_whitespace().map(fold).collect();
if terms.is_empty() {
return Err(Failure::usage("usage: vivac find \"<text>\"".to_string()));
}
Ok(terms)
}
fn field_order(field: &str) -> u8 {
match field {
"title" => 0,
"why" => 1,
_ => 2,
}
}
fn hits_for<'t>(
a: &'t Tree,
ag: &Aggregates,
terms: &[String],
) -> Vec<(&'t Node, Vec<&'static str>)> {
let mut hits: Vec<(&Node, Vec<&'static str>)> = Vec::new();
for n in a.nodes_iter() {
let lowered: Vec<(&'static str, String)> = searchable(a, n)
.iter()
.filter(|(_, v)| !v.is_empty())
.map(|(k, v)| (*k, fold(v)))
.collect();
if !terms
.iter()
.all(|t| lowered.iter().any(|(_, v)| v.contains(t.as_str())))
{
continue;
}
let mut seen_fields = std::collections::HashSet::new();
let matched: Vec<&'static str> = lowered
.iter()
.filter(|(_, v)| terms.iter().any(|t| v.contains(t.as_str())))
.map(|(k, _)| *k)
.filter(|k| seen_fields.insert(*k))
.collect();
hits.push((n, matched));
}
hits.sort_by_key(|(n, matched)| {
(
field_order(matched[0]),
std::cmp::Reverse(ag.counts(n.num).total),
std::cmp::Reverse(n.num),
)
});
hits
}
fn lineage_of(a: &Tree, n: &Node) -> Vec<String> {
let line = a.ancestors(n.num);
line[..line.len().saturating_sub(1)]
.iter()
.map(|p| p.alias())
.collect()
}
fn hit_json(a: &Tree, n: &Node, matched: &[&'static str], terms: &[String]) -> serde_json::Value {
let fragments: serde_json::Map<String, serde_json::Value> = matched
.iter()
.map(|field| {
let text = searchable(a, n)
.iter()
.find(|(k, _)| k == field)
.map(|(_, v)| *v)
.unwrap_or_default();
(field.to_string(), json!(snippet(text, terms, WIDTH)))
})
.collect();
json!({
"alias": n.alias(),
"kind": n.kind,
"state": n.state,
"title": n.title(a),
"lineage": lineage_of(a, n),
"matched": fragments,
})
}
pub fn find_data(a: &Tree, query: &str) -> Result<serde_json::Value, Failure> {
let terms = terms_of(query)?;
let ag = &a.aggregates();
Ok(json!(hits_for(a, ag, &terms)
.iter()
.map(|(n, matched)| hit_json(a, n, matched, &terms))
.collect::<Vec<_>>()))
}
pub fn find(a: &Tree, args: &Args) -> R {
let query = args
.positional(0)
.ok_or_else(|| Failure::usage("usage: vivac find \"<text>\"".to_string()))?;
let terms = terms_of(query)?;
if args.has("json") {
return print_json(find_data(a, query)?);
}
let ag = &a.aggregates();
let hits = hits_for(a, ag, &terms);
if hits.is_empty() {
outln!(" Nothing matches \"{query}\".");
return Ok(());
}
outln!();
outln!(
" {} match{} for \"{}\"",
hits.len(),
if hits.len() == 1 { "" } else { "es" },
query,
);
outln!();
for (n, matched) in hits.iter().take(20) {
outln!(" {:<6} {}", n.alias(), n.title(a));
let lineage = lineage_of(a, n);
if !lineage.is_empty() {
outln!(" via {}", lineage.join(" > "));
}
for field in matched.iter().filter(|f| **f != "title") {
let text = searchable(a, n)
.iter()
.find(|(k, _)| k == field)
.map(|(_, v)| *v)
.unwrap_or_default();
outln!(" {}: {}", field, snippet(text, &terms, WIDTH));
}
}
if hits.len() > 20 {
outln!();
outln!(
" ... and {} more vivac find \"...\" --json",
hits.len() - 20
);
}
outln!();
Ok(())
}
pub(crate) fn project_name(root: &std::path::Path) -> String {
crate::store::store_dir()
.and_then(|store_dir| crate::registry::effective_name(&store_dir, root))
.unwrap_or_else(|| "-".into())
}
fn find_data_everywhere(projects: &[(String, Tree)], terms: &[String]) -> serde_json::Value {
let mut hits = Vec::new();
for (name, tree) in projects {
let ag = &tree.aggregates();
for (n, matched) in hits_for(tree, ag, terms) {
let mut hit = hit_json(tree, n, &matched, terms);
if let serde_json::Value::Object(fields) = &mut hit {
fields.insert("project".to_string(), json!(name));
}
hits.push(hit);
}
}
json!(hits)
}
pub fn find_everywhere_data(query: &str) -> Result<serde_json::Value, Failure> {
let terms = terms_of(query)?;
let known_roots = crate::store::store_dir()
.map(|d| crate::registry::roots(&d))
.unwrap_or_default();
let mut projects: Vec<(String, Tree)> = Vec::new();
for root in known_roots {
let name = project_name(&root);
if let Ok(tree) =
crate::store::Store::open(root).and_then(|s| crate::index::load(&s, false))
{
projects.push((name, tree));
}
}
projects.sort_by(|x, y| x.0.cmp(&y.0));
Ok(find_data_everywhere(&projects, &terms))
}
pub fn find_everywhere(a: &Args) -> R {
let query = a
.positional(0)
.ok_or_else(|| Failure::usage("usage: vivac find \"<text>\"".to_string()))?;
let terms = terms_of(query)?;
let known_roots = crate::store::store_dir()
.map(|d| crate::registry::roots(&d))
.unwrap_or_default();
let mut projects: Vec<(String, Tree)> = Vec::new();
let mut unreachable: Vec<String> = Vec::new();
for root in known_roots {
let name = project_name(&root);
match crate::store::Store::open(root).and_then(|s| crate::index::load(&s, false)) {
Ok(tree) => projects.push((name, tree)),
Err(_) => unreachable.push(name),
}
}
projects.sort_by(|x, y| x.0.cmp(&y.0));
unreachable.sort();
if a.has("json") {
return print_json(find_data_everywhere(&projects, &terms));
}
type ProjectHits<'t> = (&'t str, &'t Tree, Vec<(&'t Node, Vec<&'static str>)>);
let sections: Vec<ProjectHits> = projects
.iter()
.filter_map(|(name, tree)| {
let ag = &tree.aggregates();
let hits = hits_for(tree, ag, &terms);
(!hits.is_empty()).then_some((name.as_str(), tree, hits))
})
.collect();
let total: usize = sections.iter().map(|(_, _, hits)| hits.len()).sum();
if total == 0 {
outln!(" Nothing matches \"{query}\".");
} else {
outln!();
outln!(
" {} match{} for \"{}\" across {} project{}",
total,
if total == 1 { "" } else { "es" },
query,
sections.len(),
if sections.len() == 1 { "" } else { "s" },
);
for (name, tree, hits) in §ions {
outln!();
outln!(" {name}");
for (n, matched) in hits.iter().take(20) {
outln!(" {:<6} {}", n.alias(), n.title(tree));
let lineage = lineage_of(tree, n);
if !lineage.is_empty() {
outln!(" via {}", lineage.join(" > "));
}
for field in matched.iter().filter(|f| **f != "title") {
let text = searchable(tree, n)
.iter()
.find(|(k, _)| k == field)
.map(|(_, v)| *v)
.unwrap_or_default();
outln!(" {}: {}", field, snippet(text, &terms, WIDTH));
}
}
if hits.len() > 20 {
outln!(
" ... and {} more vivac find \"...\" --everywhere --json",
hits.len() - 20
);
}
}
outln!();
}
if !unreachable.is_empty() {
outln!(
" {} project{} unreachable: {}",
unreachable.len(),
if unreachable.len() == 1 { "" } else { "s" },
unreachable.join(", ")
);
outln!();
}
Ok(())
}
#[cfg(test)]
mod fold_tests {
use super::{fold, fold_with_origin};
use unicode_normalization::UnicodeNormalization;
#[test]
fn folds_spanish_diacritics_away() {
assert_eq!(fold("dueño"), fold("dueno"));
assert_eq!(fold("árbol"), fold("arbol"));
assert_eq!(fold("ÁRBOL"), fold("arbol"));
}
#[test]
fn folds_decomposed_and_precomposed_the_same_way() {
let decomposed = "e\u{0301}"; assert_eq!(fold(decomposed), fold("é"));
}
#[test]
fn a_lower_cased_combining_mark_still_drops() {
assert_eq!(fold("\u{0130}"), fold("i"));
}
fn reference(text: &str) -> String {
text.chars()
.flat_map(char::to_lowercase)
.collect::<String>()
.nfd()
.filter(|c| !super::is_diacritic(*c))
.collect()
}
#[test]
fn the_fold_agrees_with_whole_string_nfd() {
let cases = [
"dueño",
"café",
"garçon",
"\u{1ec7}", "Vi\u{1ec7}t Nam",
"\u{5e9}\u{5b8}\u{5dc}\u{5d5}\u{5b9}\u{5dd}", "\u{928}\u{940}\u{932}", "e\u{0301}\u{0323}", "\u{5e9}\u{5bc}\u{5b8}",
"\u{0301}bc", "sha\u{5bc}\u{5b8}lom",
"Ab\u{0301}\u{0323}C \u{212a}elvin", "ÁRBOL \u{0130}stanbul",
"🌳 tree",
"\u{6a39}\u{6728}", ];
for text in cases {
let expected = reference(text);
assert_eq!(fold(text), expected, "fold disagrees on {text:?}");
let (mapped, origin) = fold_with_origin(text);
assert_eq!(mapped, expected, "fold_with_origin disagrees on {text:?}");
assert_eq!(
origin.len(),
mapped.len(),
"one origin per byte of {text:?}"
);
let char_count = text.chars().count();
for (byte, idx) in origin.iter().enumerate() {
assert!(
*idx < char_count,
"byte {byte} of {text:?} maps to char index {idx}, past its {char_count} chars"
);
}
}
}
}