use crate::anchor::AnchorRef;
use crate::model::Counts;
#[derive(Debug, Clone, serde::Serialize)]
pub struct Closed {
pub alias: String,
pub title: String,
pub force: bool,
pub already: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DepthAdvice {
pub depth: usize,
pub root_alias: String,
pub root_title: String,
pub root_mark: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct PoppedTo {
pub alias: String,
pub title: String,
pub counts: Counts,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct AddedUnder {
pub alias: String,
pub title: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct StillBornFrom {
pub alias: String,
pub title: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct RescuedNode {
pub alias: String,
pub title: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SupersededNode {
pub alias: String,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(tag = "change")]
pub enum FlagChange {
Off,
Raised { title: String, reason: String },
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(tag = "change")]
pub enum ArmChange {
Added,
Removed,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DeclaredPair {
pub node: String,
pub why: String,
}
fn is_false(b: &bool) -> bool {
!*b
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct LostNode {
pub alias: String,
pub title: String,
pub state: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct KeptNode {
pub alias: String,
pub title: String,
pub state: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ChangeLine {
pub file_path: String,
pub times: usize,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(tag = "anchor")]
pub enum RestoreAnchor {
Empty,
NoChanges {
anchor_short: String,
},
Changed {
anchor_short: String,
changes: Vec<ChangeLine>,
working_set: Vec<String>,
},
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(tag = "op")]
pub enum Outcome {
Pushed {
alias: String,
title: String,
blocks: bool,
advice: Option<DepthAdvice>,
#[serde(skip_serializing_if = "is_false")]
no_against: bool,
left_stack: Vec<String>,
back_to: Option<String>,
},
Popped {
closed: Closed,
parent: Option<PoppedTo>,
},
Done {
closed: Closed,
},
Added {
alias: String,
title: String,
parent: Option<AddedUnder>,
blocks: bool,
#[serde(skip_serializing_if = "is_false")]
no_against: bool,
},
Noted {
alias: String,
},
Blocked {
alias: String,
blocks: bool,
parent_alias: String,
parent_title: String,
},
Promoted {
alias: String,
title: String,
parent: Option<StillBornFrom>,
},
Abandoned {
alias: String,
title: String,
cascaded: Option<usize>,
rescued: Vec<RescuedNode>,
},
Parked {
alias: String,
title: String,
},
Focused {
alias: String,
revived: bool,
},
Flagged {
alias: String,
flag: String,
change: FlagChange,
},
Armed {
alias: String,
dir: String,
arm: String,
change: ArmChange,
},
Decided {
alias: String,
title: String,
superseded: Option<SupersededNode>,
no_alternatives: bool,
#[serde(skip_serializing_if = "is_false")]
no_against: bool,
},
Declared {
alias: String,
against: Vec<DeclaredPair>,
},
Saved {
num: u64,
label: String,
anchor: AnchorRef,
anchors: Vec<crate::event::RepoAnchor>,
next: String,
},
Restored {
alias: String,
kind: String,
ts: String,
label: String,
next_intent: String,
kept: Vec<KeptNode>,
lost: Vec<LostNode>,
anchor: RestoreAnchor,
},
AutoStopped,
SessionOpened,
}
fn no_against_lines(out: &mut Vec<String>, alias: &str) {
out.push(
" no --against: a pillar judged in silence reads the same as one skipped"
.to_string(),
);
out.push(format!(" vivac declare {alias} adds one"));
}
fn closed_lines(out: &mut Vec<String>, c: &Closed) {
if let Some(word) = &c.already {
out.push(format!(
" {} {} -> already {word}, left as it was",
c.alias, c.title
));
return;
}
out.push(format!(
" {} {} -> {}",
c.alias,
c.title,
if c.force { "closed BY FORCE" } else { "closed" }
));
if c.force {
out.push(" recorded as a false close in every render".to_string());
}
}
pub fn to_text(o: &Outcome) -> String {
let mut lines: Vec<String> = Vec::new();
match o {
Outcome::Pushed {
alias,
title,
blocks,
advice,
no_against,
left_stack,
back_to,
} => {
lines.push(format!(" {alias} {title}"));
if *blocks {
lines.push(" blocks its parent from closing".to_string());
}
if let [bottom, .., top] = left_stack.as_slice() {
lines.push(format!(
" at the root: {} left the stack, {bottom} to {top}, none closed by this",
left_stack.len()
));
} else if let [only] = left_stack.as_slice() {
lines.push(format!(
" at the root: {only} left the stack, not closed by this"
));
}
if let Some(b) = back_to {
lines.push(format!(" back there with: vivac focus {b}"));
}
if let Some(a) = advice {
lines.push(String::new());
let mark = a
.root_mark
.as_deref()
.map(|w| format!(" [{w}]"))
.unwrap_or_default();
lines.push(format!(
" You are {} levels away from {} \"{}\"{mark}.",
a.depth, a.root_alias, a.root_title
));
lines.push(" Is this still a detour, or did the real goal move?".to_string());
lines.push(" If it moved: vivac promote".to_string());
}
if *no_against {
no_against_lines(&mut lines, alias);
}
}
Outcome::Popped { closed, parent } => {
closed_lines(&mut lines, closed);
match parent {
Some(p) => {
lines.push(format!(" back to {} {}", p.alias, p.title));
let f = p.counts.phrase();
if !f.is_empty() {
lines.push(format!(" ({f} below it)"));
}
}
None => lines.push(" empty stack".to_string()),
}
}
Outcome::Done { closed } => closed_lines(&mut lines, closed),
Outcome::Added {
alias,
title,
parent,
blocks,
no_against,
} => {
let where_at = match parent {
Some(p) => format!(" under {}", p.alias),
None => " (root)".to_string(),
};
lines.push(format!(" {alias} {title}{where_at}"));
if *blocks {
lines.push(" blocks its parent from closing".to_string());
}
if *no_against {
no_against_lines(&mut lines, alias);
}
}
Outcome::Noted { alias } => lines.push(format!(" {alias} noted")),
Outcome::Blocked {
alias,
blocks,
parent_alias,
parent_title,
} => {
let verb = if *blocks {
"blocks"
} else {
"no longer blocks"
};
lines.push(format!(
" {alias} {verb} the close of {parent_alias} {parent_title}"
));
}
Outcome::Promoted {
alias,
title,
parent,
} => {
lines.push(format!(" {alias} {title} -> a goal of its own"));
if let Some(p) = parent {
lines.push(format!(" still born from {} {}", p.alias, p.title));
}
}
Outcome::Abandoned {
alias,
title,
cascaded,
rescued,
} => {
lines.push(format!(" {alias} {title} -> abandoned"));
if let Some(n) = cascaded {
lines.push(format!(" and {n} descendant(s) with it"));
}
if !rescued.is_empty() {
lines.push(String::new());
lines.push(format!(" Rescued, and still born from {alias}:"));
for r in rescued {
lines.push(format!(" {:<6} {}", r.alias, r.title));
}
lines.push(String::new());
lines.push(
" Their lineage crosses an abandoned node on purpose: where they".to_string(),
);
lines.push(" were born does not change because it got discarded.".to_string());
}
}
Outcome::Parked { alias, title } => {
lines.push(format!(" {alias} {title} -> parked"));
lines.push(" shows up in: vivac parked".to_string());
}
Outcome::Focused { alias, revived } => {
if *revived {
lines.push(format!(" {alias} is open again"));
}
}
Outcome::Flagged {
alias,
flag,
change,
} => match change {
FlagChange::Off => lines.push(format!(" {alias} is no longer {flag}")),
FlagChange::Raised { title, reason } => {
lines.push(format!(" {alias} {title} -> {flag}"));
lines.push(format!(" {reason}"));
}
},
Outcome::Armed {
alias,
dir,
arm,
change,
} => match change {
ArmChange::Added => lines.push(format!(" {alias} armed in {dir}/: {arm}")),
ArmChange::Removed => {
lines.push(format!(" {alias} no longer armed in {dir}/: {arm}"))
}
},
Outcome::Decided {
alias,
title,
superseded,
no_alternatives,
no_against,
} => {
lines.push(format!(" {alias} {title}"));
if let Some(s) = superseded {
lines.push(format!(" {} becomes superseded", s.alias));
}
if *no_alternatives {
lines.push(
" no alternatives recorded: in a month they get proposed again"
.to_string(),
);
}
if *no_against {
no_against_lines(&mut lines, alias);
}
}
Outcome::Declared { alias, against } => {
for a in against {
lines.push(format!(" {alias} judged against {}: {}", a.node, a.why));
}
}
Outcome::Saved {
num,
label,
anchor,
anchors,
next,
} => {
let shown = if label.is_empty() { "no label" } else { label };
lines.push(format!(" v{num} {shown}"));
if let Some(a) = crate::model::anchoring(anchor, anchors) {
lines.push(format!(" anchored to {a}"));
} else {
lines.push(" no anchor: there is no version control here".to_string());
}
if next.is_empty() {
lines.push(
" no --next: coming back there will be nothing to pick up".to_string(),
);
}
}
Outcome::Restored {
alias,
kind,
ts,
label,
next_intent,
kept,
lost,
anchor,
} => {
lines.push(String::new());
lines.push(format!(
" {alias} · {kind} · {}",
crate::clock::date_of(ts)
));
if !label.is_empty() {
lines.push(format!(" {label}"));
}
lines.push(String::new());
if !next_intent.is_empty() {
lines.push(format!(" you were about to: {next_intent}"));
lines.push(String::new());
}
for k in kept {
lines.push(format!(
" still on the path: {} {} [{}]",
k.alias, k.title, k.state
));
}
if !kept.is_empty() {
lines.push(String::new());
}
for p in lost {
lines.push(format!(
" no longer on the stack: {} {} [{}]",
p.alias, p.title, p.state
));
}
if !lost.is_empty() {
lines.push(String::new());
}
match anchor {
RestoreAnchor::Empty => {
lines.push(
" No anchor: there is no diff to show, only the date above.".to_string(),
);
lines.push(String::new());
}
RestoreAnchor::NoChanges { anchor_short } => {
lines.push(format!(" Nothing changed since {anchor_short}."));
lines.push(String::new());
}
RestoreAnchor::Changed {
anchor_short,
changes,
working_set,
} => {
let touching = changes
.iter()
.filter(|c| {
working_set
.iter()
.any(|g| crate::glob::covers(g, &c.file_path))
})
.count();
let suffix = if working_set.is_empty() {
String::new()
} else {
format!(", {touching} of them touch what the stack governed")
};
lines.push(format!(
" {} changes since {anchor_short}{suffix}",
changes.len()
));
for c in changes.iter().take(6) {
lines.push(format!(" {:<52} ({})", c.file_path, c.times));
}
if changes.len() > 6 {
lines.push(format!(" ... and {} more", changes.len() - 6));
}
lines.push(String::new());
}
}
}
Outcome::AutoStopped | Outcome::SessionOpened => {}
}
if lines.is_empty() {
return String::new();
}
let mut s = lines.join("\n");
s.push('\n');
s
}