use crate::anchor::{self, Anchor};
use crate::event::{Against, Arm, Body, Event, Flag, Kind, State, VivacKind};
use crate::failure::{Failure, R};
use crate::model::{fold, Node, Tree};
use crate::outcome::{self, Outcome};
use crate::params;
use crate::store::Store;
use crate::{id, redact};
use std::path::{Path, PathBuf};
pub enum Whose<'a> {
Resolved(&'a crate::store::Located),
Founding,
Declared(String, PathBuf),
}
pub struct PendingLane {
pub dir: PathBuf,
pub name: String,
pub repo: crate::event::Repo,
}
const PENDING_VIEW: &str = "";
pub struct Ctx {
pub store: Store,
pub lane: Option<String>,
pub lane_dir: PathBuf,
pub tree: Tree,
pub anchor: Box<dyn Anchor>,
pub seen: (u64, Option<std::time::SystemTime>),
pub wrote: Option<crate::store::Appended>,
pub lock: Option<crate::store::WriteLock>,
pending_lane: Option<PendingLane>,
caller_declared: bool,
lane_assumed: bool,
}
impl Ctx {
fn adopt(&mut self, tree: Tree) {
self.tree = tree;
match &self.lane {
Some(l) => self.tree.for_lane(l),
None if self.pending_lane.is_some() => self.tree.for_lane(PENDING_VIEW),
None => {}
}
}
fn finish(
store: Store,
tree: Tree,
seen: (u64, Option<std::time::SystemTime>),
whose_lane: WhoseLane,
) -> Ctx {
let anchor = anchor::detect(&store.root);
let mut ctx = Ctx {
store,
lane: whose_lane.lane,
lane_dir: whose_lane.lane_dir,
tree: Tree::default(),
anchor,
seen,
wrote: None,
lock: None,
pending_lane: whose_lane.pending_lane,
caller_declared: whose_lane.caller_declared,
lane_assumed: whose_lane.lane_assumed,
};
ctx.adopt(tree);
ctx
}
pub fn load(store: Store, whose: Whose) -> Result<Ctx, Failure> {
Ctx::load_opt(store, true, whose)
}
pub fn load_for_write(store: Store, whose: Whose) -> Result<Ctx, Failure> {
Ctx::load_opt(store, false, whose)
}
fn load_opt(store: Store, allow_index_refresh: bool, whose: Whose) -> Result<Ctx, Failure> {
let seen = crate::store::fingerprint(&store.log());
let tree = crate::index::load(&store, allow_index_refresh)?;
let tree_has_lanes = tree.has_a_declared_lane();
let whose_lane = resolve_whose(whose, &tree, tree_has_lanes, &store.root);
Ok(Ctx::finish(store, tree, seen, whose_lane))
}
pub fn load_with_log(store: Store, whose: Whose) -> Result<(Ctx, Vec<Event>), Failure> {
let seen = crate::store::fingerprint(&store.log());
let (events, broken) = store.read_all()?;
let tree = fold(&events, broken);
let tree_has_lanes = tree.has_a_declared_lane();
let whose_lane = resolve_whose(whose, &tree, tree_has_lanes, &store.root);
let ctx = Ctx::finish(store, tree, seen, whose_lane);
Ok((ctx, events))
}
pub fn from_events(
store: Store,
events: &[Event],
broken: usize,
seen: (u64, Option<std::time::SystemTime>),
whose: Whose,
) -> Ctx {
let tree = fold(events, broken);
let tree_has_lanes = tree.has_a_declared_lane();
let whose_lane = resolve_whose(whose, &tree, tree_has_lanes, &store.root);
Ctx::finish(store, tree, seen, whose_lane)
}
pub fn refold(
&mut self,
store: Store,
events: &[Event],
broken: usize,
seen: (u64, Option<std::time::SystemTime>),
) {
self.store = store;
self.adopt(fold(events, broken));
self.anchor = anchor::detect(&self.store.root);
self.seen = seen;
self.wrote = None;
}
pub fn lock_for_write(&mut self) -> Result<bool, Failure> {
if self.lock.is_some() {
return Ok(false);
}
if !self.caller_declared
&& self.lane_assumed
&& self.lane.as_deref() == Some(crate::lane::MAIN)
&& self.tree.main_claimed
{
return Err(Failure::not_a_lane());
}
let lock = self.store.lock_for_write()?;
let now = crate::store::fingerprint(&self.store.log());
if now != self.seen {
self.adopt(crate::index::load(&self.store, false)?);
self.seen = now;
}
self.lock = Some(lock);
Ok(true)
}
#[cfg(test)]
pub fn holds_write_lock(&self) -> bool {
self.lock.is_some()
}
pub fn unlock(&mut self) {
self.lock = None;
}
fn emit(&mut self, bodies: Vec<Body>) -> R {
let mut bodies = bodies;
if let Some(pending) = &self.pending_lane {
let dir = pending.dir.clone();
let folder_name = pending.name.clone();
let repo = pending.repo.clone();
let lock = self.lock.as_ref().ok_or_else(|| {
Failure::Io(std::io::Error::other("write without the tree's lock"))
})?;
if let Some(joined) = crate::lane::read(&dir.join(crate::store::DIR))? {
self.lane = Some(joined.id.clone());
self.lane_dir = dir.clone();
self.tree.for_lane(&joined.id);
self.pending_lane = None;
} else {
self.store.lock_lanes_in_config(lock)?;
let Some(project) = crate::store::first_event_id(&self.store.root) else {
return Err(Failure::Io(std::io::Error::other(
"This tree says a lane was declared, but its log has no first \
event to found a new one on. Run this from the tree's own \
folder first: an ordinary write there recovers a log that was \
deleted or emptied, the same way it always has.",
)));
};
let id = crate::lane::new_id();
let lane_file = crate::lane::Lane {
version: 1,
id: id.clone(),
project,
};
crate::lane::write(&dir.join(crate::store::DIR), &lane_file)?;
self.lane = Some(id.clone());
self.lane_dir = dir.clone();
self.tree.for_lane(&id);
self.pending_lane = None;
let name = crate::lane::declared_name(&id, &folder_name);
bodies.insert(
0,
Body::LaneDeclared {
lane: id,
name,
repos: vec![repo],
},
);
}
}
let already_governed = self.tree.has_governance;
let lock = self
.lock
.as_ref()
.ok_or_else(|| Failure::Io(std::io::Error::other("write without the tree's lock")))?;
let lane = self.lane.as_deref().unwrap_or(crate::lane::MAIN);
if let Some(w) = where_to_write(&self.tree, lane, &self.lane_dir) {
bodies.insert(0, w);
}
let appended = self
.store
.append(lock, lane, bodies, self.tree.seq, already_governed)?;
for e in &appended.events {
self.tree.apply(e.seq, &e.ts, &e.lane, &e.payload);
}
self.seen = crate::store::fingerprint(&self.store.log());
if !appended.events.is_empty() {
match self.wrote.take() {
Some(mut w) => {
w.events.extend(appended.events);
w.last_line_offset = appended.last_line_offset;
w.end_offset = appended.end_offset;
self.wrote = Some(w);
}
None => self.wrote = Some(appended),
}
}
Ok(())
}
fn resolve(&self, s: &str) -> Result<&crate::model::Node, Failure> {
self.tree
.resolve(s)
.ok_or_else(|| Failure::usage(format!("No such node: {s}.")))
}
}
struct WhoseLane {
lane: Option<String>,
pending_lane: Option<PendingLane>,
caller_declared: bool,
lane_assumed: bool,
lane_dir: PathBuf,
}
fn resolve_whose(whose: Whose, tree: &Tree, tree_has_lanes: bool, store_root: &Path) -> WhoseLane {
let located = match whose {
Whose::Founding => {
return WhoseLane {
lane: Some(crate::lane::MAIN.to_string()),
pending_lane: None,
caller_declared: false,
lane_assumed: true,
lane_dir: store_root.to_path_buf(),
};
}
Whose::Declared(id, lane_dir) => {
return WhoseLane {
lane: Some(id),
pending_lane: None,
caller_declared: true,
lane_assumed: false,
lane_dir,
};
}
Whose::Resolved(l) => l,
};
let lane_assumed = located.lane.is_none();
let lane_dir = located.lane_dir.clone();
let found_lane = located
.lane
.as_ref()
.map(|l| l.id.clone())
.unwrap_or_else(|| crate::lane::MAIN.to_string());
let Some(w) = located.worktree.as_ref() else {
return WhoseLane {
lane: Some(found_lane),
pending_lane: None,
caller_declared: false,
lane_assumed,
lane_dir,
};
};
let declared: &[crate::event::Repo] = tree
.lanes
.get(&found_lane)
.map(|s| s.repos.as_slice())
.unwrap_or(&[]);
if repo_at(declared, &located.lane_dir, w).is_some() {
return WhoseLane {
lane: Some(found_lane),
pending_lane: None,
caller_declared: false,
lane_assumed,
lane_dir,
};
}
if !tree_has_lanes {
return WhoseLane {
lane: Some(found_lane),
pending_lane: None,
caller_declared: false,
lane_assumed,
lane_dir,
};
}
let root = anchor::main_copy_of(w).and_then(|main_root| {
repo_at(declared, &located.lane_dir, &main_root).and_then(|r| r.root.clone())
});
let folder_name = w
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
WhoseLane {
lane: None,
pending_lane: Some(PendingLane {
dir: w.clone(),
name: folder_name,
repo: crate::event::Repo {
path: ".".to_string(),
root,
},
}),
caller_declared: false,
lane_assumed,
lane_dir,
}
}
fn repo_at<'a>(
declared: &'a [crate::event::Repo],
lane_dir: &Path,
target: &Path,
) -> Option<&'a crate::event::Repo> {
declared
.iter()
.find(|r| anchor::same_folder(&lane_dir.join(&r.path), target))
}
fn where_to_write(tree: &Tree, lane: &str, lane_dir: &Path) -> Option<Body> {
let declared = &tree.lanes.get(lane)?.repos;
if declared.is_empty() {
return None;
}
let now: Vec<crate::event::WhereRepo> =
declared.iter().map(|r| snapshot_of(lane_dir, r)).collect();
let last = tree.wheres.iter().rfind(|w| w.lane == lane);
match last {
Some(w) if !moved(&w.repos, &now) => None,
_ => Some(Body::WhereChanged { repos: now }),
}
}
fn moved(before: &[crate::event::WhereRepo], now: &[crate::event::WhereRepo]) -> bool {
if before.len() != now.len() {
return true;
}
before.iter().zip(now).any(|(b, n)| {
b.path != n.path
|| b.branch != n.branch
|| b.missing != n.missing
|| b.withheld != n.withheld
|| (b.branch.is_none() && !n.rebasing && b.sha != n.sha)
})
}
fn snapshot_of(lane_dir: &Path, r: &crate::event::Repo) -> crate::event::WhereRepo {
let mut out = crate::event::WhereRepo {
path: r.path.clone(),
..Default::default()
};
match anchor::where_of(&lane_dir.join(&r.path)) {
anchor::Where::Missing => out.missing = true,
anchor::Where::Head(h) => {
out.sha = h.sha;
out.rebasing = h.rebasing;
match h.branch {
Some(b) if redact::check_field("branch", &b).is_some() => out.withheld = true,
b => out.branch = b,
}
}
}
out
}
fn anchors_of(ctx: &Ctx) -> Vec<crate::event::RepoAnchor> {
let Some(lane) = ctx.lane.as_deref() else {
return vec![];
};
let Some(state) = ctx.tree.lanes.get(lane) else {
return vec![];
};
state
.repos
.iter()
.filter_map(|r| {
let anchor::Where::Head(h) = anchor::where_of(&ctx.lane_dir.join(&r.path)) else {
return None;
};
let sha = h.sha?;
let branch = match h.branch {
Some(b) if redact::check_field("branch", &b).is_some() => None,
b => b,
};
Some(crate::event::RepoAnchor {
path: r.path.clone(),
branch,
sha,
})
})
.collect()
}
fn vivac(
ctx: &Ctx,
kind: VivacKind,
next_intent: &str,
node_ref: Option<String>,
label: &str,
) -> Body {
let stack: Vec<(String, String)> = ctx
.tree
.stack()
.iter()
.filter_map(|&num| ctx.tree.node_by_num(num))
.map(|n| (n.alias(), n.title(&ctx.tree).to_string()))
.collect();
let mut working_set: Vec<String> = ctx
.tree
.stack()
.iter()
.filter_map(|&num| ctx.tree.node_by_num(num))
.flat_map(|n| n.governs(&ctx.tree).into_iter().map(str::to_string))
.collect();
working_set.sort();
working_set.dedup();
Body::VivacCreated {
vivac: id::ulid(),
num: ctx.tree.next_vivac_num.max(1),
kind,
stack,
working_set,
next_intent: next_intent.to_string(),
anchor: ctx.anchor.snapshot(),
anchors: anchors_of(ctx),
node_ref,
label: label.to_string(),
}
}
fn guard_text(fields: &[(&str, &str)]) -> R {
match redact::check_fields(fields) {
Some(h) => Err(Failure::Redaction(Box::new(h))),
None => Ok(()),
}
}
fn guarded_or_refused(field: &str, text: &str) -> String {
match redact::check_field(field, text) {
Some(f) => format!("refused: {}", f.rule),
None => text.to_string(),
}
}
fn root_and_parent_error() -> Failure {
Failure::usage(
"--root and --parent both say where it is born, and a node is born in one place.\n \
Keep the one you mean.",
)
}
fn open_parent(ctx: &Ctx, id: &str) -> Result<crate::model::Node, Failure> {
let n = ctx.resolve(id)?.clone();
if n.state == State::Suspended {
return Err(Failure::Model(format!(
" {} is parked. New work does not open under it until someone takes it back.\n\n \
To take it back: vivac focus {}",
n.alias(),
n.num
)));
}
if !n.state.is_open() {
return Err(Failure::Model(format!(
" {} is {}. New work does not open under it.\n\n \
If it really was not finished: vivac focus {} --reopen",
n.alias(),
n.state.word(n.kind),
n.num
)));
}
Ok(n)
}
fn stack_to(ctx: &Ctx, num: u64) -> (Vec<u64>, Vec<(u64, String)>) {
let lineage: Vec<(u64, String)> = ctx
.tree
.ancestors(num)
.iter()
.map(|n| (n.num, n.id.clone()))
.collect();
let to_pop: Vec<u64> = ctx
.tree
.stack()
.iter()
.copied()
.filter(|n| !lineage.iter().any(|(lineage_num, _)| lineage_num == n))
.collect();
let to_push: Vec<(u64, String)> = lineage
.into_iter()
.filter(|(n, _)| !ctx.tree.stack().contains(n))
.collect();
(to_pop, to_push)
}
fn kind_of(raw: Option<&str>, fallback: Kind) -> Result<Kind, Failure> {
match raw {
None => Ok(fallback),
Some(s) => Kind::parse(s)
.ok_or_else(|| Failure::usage(format!("Unknown type: {s}. They are: {}", Kind::ALL))),
}
}
fn validate_arm_text(s: &str) -> Result<(), Failure> {
if s.trim().is_empty() {
return Err(Failure::usage("An arm cannot be empty."));
}
if s.contains('\n') {
return Err(Failure::usage(
"An arm is one line: write it the way it would be typed.",
));
}
Ok(())
}
fn is_absolute_arm_dir(slashed: &str) -> bool {
if slashed.starts_with('/') || slashed.starts_with('~') {
return true;
}
let mut chars = slashed.chars();
matches!(
(chars.next(), chars.next()),
(Some(letter), Some(':')) if letter.is_ascii_alphabetic()
)
}
fn normalize_arm_dir(slashed: &str) -> String {
let parts: Vec<&str> = slashed
.split('/')
.filter(|c| !c.is_empty() && *c != ".")
.collect();
if parts.is_empty() {
".".to_string()
} else {
parts.join("/")
}
}
fn validate_arm_dir(raw: &str, tree_root: &Path) -> Result<String, Failure> {
let slashed = raw.replace('\\', "/");
if is_absolute_arm_dir(&slashed) {
return Err(Failure::usage(
"An arm's folder is relative to the one that holds .vivac: an \
absolute path would write this machine's layout into the log.",
));
}
if slashed.split('/').any(|c| c == "..") {
return Err(Failure::usage(
"An arm's folder has to be inside the one that holds .vivac.",
));
}
let normalized = normalize_arm_dir(&slashed);
guard_text(&[("dir", &normalized)])?;
if !tree_root.join(&normalized).is_dir() {
return Err(Failure::usage(format!(
"There is no folder {normalized} inside the one that holds .vivac."
)));
}
Ok(normalized)
}
fn needs_arm_dir_message(via_mcp: bool) -> String {
let flag = if via_mcp { "arm_dir" } else { "--arm-dir" };
format!(
"An arm needs {flag}: the folder it runs in, relative to the one \
that holds .vivac. Use . for that folder itself."
)
}
fn arm_dir_without_arm_message(via_mcp: bool) -> String {
let (dir_flag, arm_flag) = if via_mcp {
("arm_dir", "arm")
} else {
("--arm-dir", "--arm")
};
format!("{dir_flag} says where an arm runs, and no {arm_flag} was given.")
}
fn needs_dir_message(via_mcp: bool) -> String {
let flag = if via_mcp { "dir" } else { "--dir" };
format!(
"An arm needs {flag}: the folder it runs in, relative to the one \
that holds .vivac. Use . for that folder itself."
)
}
fn arms_of(
ctx: &Ctx,
raw: Vec<String>,
dir: Option<String>,
kind: Kind,
via_mcp: bool,
) -> Result<Vec<Arm>, Failure> {
if !raw.is_empty() && kind != Kind::Rule {
return Err(Failure::usage(format!(
"Only a rule has an arm; this would be {}.",
kind.with_article()
)));
}
if raw.is_empty() {
if dir.is_some() {
return Err(Failure::usage(arm_dir_without_arm_message(via_mcp)));
}
return Ok(vec![]);
}
let dir = match dir {
Some(d) if !d.trim().is_empty() => d,
_ => return Err(Failure::usage(needs_arm_dir_message(via_mcp))),
};
let normalized = validate_arm_dir(&dir, &ctx.store.root)?;
for (i, a) in raw.iter().enumerate() {
validate_arm_text(a)?;
if raw[..i].contains(a) {
return Err(Failure::usage(format!("The same arm is given twice: {a}")));
}
}
Ok(raw
.into_iter()
.map(|command| Arm {
dir: normalized.clone(),
command,
})
.collect())
}
fn split_against_entry(raw: &str) -> Result<(&str, &str), Failure> {
let form_error =
|| Failure::usage("--against needs an id and a sentence: --against \"r12: why it holds\"");
let (id, why) = raw.split_once(':').ok_or_else(form_error)?;
let (id, why) = (id.trim(), why.trim());
if id.is_empty() || why.is_empty() {
return Err(form_error());
}
Ok((id, why))
}
fn against_of(ctx: &Ctx, raw: Vec<String>, kind: Kind) -> Result<Vec<Against>, Failure> {
if !raw.is_empty() && kind != Kind::Decision {
return Err(Failure::usage(format!(
"--against goes on a decision, and this is {}",
kind.with_article()
)));
}
let mut out = Vec::with_capacity(raw.len());
let mut seen: Vec<u64> = Vec::with_capacity(raw.len());
for entry in &raw {
let (id, why) = split_against_entry(entry)?;
let n = ctx
.tree
.resolve(id)
.ok_or_else(|| Failure::usage(format!("No such node: {id}.")))?;
if !matches!(n.kind, Kind::Pillar | Kind::Rule) {
return Err(Failure::usage(format!(
"--against points at a pillar or a rule, and {} is {}",
n.alias(),
n.kind.with_article()
)));
}
if !n.state.is_open() {
return Err(Failure::usage(format!(
"--against points at what still governs, and {} is {}: vivac rules lists what does",
n.alias(),
n.state.word(n.kind)
)));
}
if seen.contains(&n.num) {
return Err(Failure::usage(format!(
"--against names {} twice",
n.alias()
)));
}
seen.push(n.num);
out.push(Against {
node: n.id.clone(),
why: why.to_string(),
});
}
Ok(out)
}
struct Born<'a> {
title: &'a str,
why: &'a str,
kind: Kind,
parent: Option<String>,
refs: Vec<String>,
governs: Vec<String>,
blocks: bool,
arms: Vec<Arm>,
against: Vec<Against>,
}
fn born(ctx: &Ctx, b: Born) -> Result<(Body, u64, String, bool), Failure> {
let mut fields: Vec<(&str, &str)> = vec![("title", b.title), ("why", b.why)];
fields.extend(b.refs.iter().map(|r| ("ref", r.as_str())));
fields.extend(b.governs.iter().map(|g| ("governs", g.as_str())));
fields.extend(b.arms.iter().map(|a| ("arm", a.command.as_str())));
fields.extend(b.against.iter().map(|a| ("against", a.why.as_str())));
guard_text(&fields)?;
let node = id::ulid();
let num = ctx.tree.next_num.max(1);
let against = (b.kind == Kind::Decision && ctx.tree.has_open_governance()).then_some(b.against);
let no_against = against.as_ref().is_some_and(Vec::is_empty);
Ok((
Body::NodeCreated {
node: node.clone(),
num,
kind: b.kind,
title: b.title.to_string(),
why: b.why.to_string(),
parent: b.parent,
blocks: b.blocks,
refs: b.refs,
governs: b.governs,
arms: b.arms,
against,
},
num,
node,
no_against,
))
}
pub fn push(ctx: &mut Ctx, p: params::Push) -> Result<Outcome, Failure> {
if p.root && p.parent.is_some() {
return Err(root_and_parent_error());
}
let target = match &p.parent {
Some(id) => Some(open_parent(ctx, id)?),
None => None,
};
let parent = if p.root {
None
} else if let Some(t) = &target {
Some(t.id.clone())
} else {
ctx.tree.focus().map(|n| n.id.clone())
};
let kind = kind_of(
p.kind.as_deref(),
if parent.is_none() {
Kind::Goal
} else {
Kind::Task
},
)?;
let arms = arms_of(ctx, p.arms, p.arm_dir, kind, p.via_mcp)?;
let against = against_of(ctx, p.against, kind)?;
let (ev, num, node, no_against) = born(
ctx,
Born {
title: &p.title,
why: &p.why,
kind,
parent: parent.clone(),
refs: p.refs,
governs: p.governs,
blocks: p.blocks,
arms,
against,
},
)?;
let (to_pop, to_push): (Vec<u64>, Vec<(u64, String)>) = if p.root {
(ctx.tree.stack().to_vec(), Vec::new())
} else if let Some(t) = &target {
stack_to(ctx, t.num)
} else {
(Vec::new(), Vec::new())
};
let v = vivac(ctx, VivacKind::Push, &p.title, parent, "");
let mut evs = vec![v, ev];
for &n in to_pop.iter().rev() {
if let Some(left) = ctx.tree.node_by_num(n) {
evs.push(Body::Popped {
node: left.id.clone(),
});
}
}
for (_, id) in &to_push {
evs.push(Body::Pushed { node: id.clone() });
}
evs.push(Body::Pushed { node });
ctx.emit(evs)?;
let left_stack: Vec<String> = to_pop
.iter()
.filter_map(|&n| ctx.tree.node_by_num(n))
.map(|n| n.alias())
.collect();
let back_to: Option<String> = to_pop
.iter()
.rev()
.filter_map(|&n| ctx.tree.node_by_num(n))
.find(|n| matches!(n.state, State::Active | State::Suspended))
.map(|n| n.alias());
let depth_of = ctx.tree.stack_depth();
let advice = if depth_of >= 4 {
ctx.tree.stack_bottom().map(|root| outcome::DepthAdvice {
depth: depth_of,
root_alias: root.alias(),
root_title: root.title(&ctx.tree).to_string(),
root_mark: (!root.state.is_open()).then(|| root.state.word(root.kind).to_string()),
})
} else {
None
};
Ok(Outcome::Pushed {
alias: format!("{}{}", kind.prefix(), num),
title: p.title,
blocks: p.blocks,
advice,
no_against,
left_stack,
back_to,
under: target.map(|t| t.alias()),
})
}
pub fn pop(ctx: &mut Ctx, p: params::Pop) -> Result<Outcome, Failure> {
let focus = ctx
.tree
.focus()
.ok_or_else(|| {
Failure::usage(
"The stack is empty. Open something: vivac push \"<title>\" --why \"<reason>\"",
)
})?
.clone();
let outcome_text = p.outcome.as_str();
let next = p.next.as_deref().unwrap_or(outcome_text);
guard_text(&[("outcome", outcome_text), ("next", next)])?;
let v = vivac(ctx, VivacKind::Pop, next, Some(focus.id.clone()), "");
let closed = if focus.state.is_open() {
close_node(ctx, &focus, outcome_text, p.force, true)?
} else {
ctx.emit(vec![Body::Popped {
node: focus.id.clone(),
}])?;
outcome::Closed {
alias: focus.alias(),
title: focus.title(&ctx.tree).to_string(),
force: p.force,
already: Some(focus.state.word(focus.kind).to_string()),
}
};
ctx.emit(vec![v])?;
let parent = match focus.parent.and_then(|p| ctx.tree.node_by_num(p)) {
Some(parent) => Some(outcome::PoppedTo {
alias: parent.alias(),
title: parent.title(&ctx.tree).to_string(),
counts: ctx.tree.counts(parent.num),
}),
None => None,
};
Ok(Outcome::Popped { closed, parent })
}
fn looks_like_an_id(s: &str) -> bool {
let s = s.trim().trim_start_matches('#');
let mut c = s.chars();
let Some(first) = c.next() else {
return false;
};
let rest = c.as_str();
if first.is_ascii_digit() {
return rest.chars().all(|c| c.is_ascii_digit());
}
!rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit())
}
fn named_or_focus(
ctx: &Ctx,
node: Option<&str>,
reason: Option<&str>,
usage: &'static str,
) -> Result<(Node, String), Failure> {
let focus = || {
ctx.tree
.focus()
.cloned()
.ok_or_else(|| Failure::usage(usage))
};
match (node, reason) {
(Some(s), Some(r)) => Ok((ctx.resolve(s)?.clone(), r.to_string())),
(Some(w), None) => match ctx.tree.resolve(w) {
Some(n) => Ok((n.clone(), String::new())),
None if looks_like_an_id(w) => Err(Failure::usage(format!("No such node: {w}."))),
None => Ok((focus()?, w.to_string())),
},
_ => Ok((focus()?, String::new())),
}
}
pub fn park(ctx: &mut Ctx, p: params::Park) -> Result<Outcome, Failure> {
let (node, reason) = named_or_focus(
ctx,
p.node.as_deref(),
p.reason.as_deref(),
"usage: vivac park [<id>] [\"<reason>\"]",
)?;
let reason = reason.as_str();
guard_text(&[("reason", reason)])?;
let mut evs = vec![vivac(
ctx,
VivacKind::Park,
reason,
Some(node.id.clone()),
"",
)];
evs.push(Body::StateChanged {
node: node.id.clone(),
state: State::Suspended,
outcome: reason.to_string(),
forced: false,
});
if ctx.tree.stack().last() == Some(&node.num) {
evs.push(Body::Popped {
node: node.id.clone(),
});
}
ctx.emit(evs)?;
Ok(Outcome::Parked {
alias: node.alias(),
title: node.title(&ctx.tree).to_string(),
})
}
fn close_node(
ctx: &mut Ctx,
n: &crate::model::Node,
outcome: &str,
force: bool,
unstack: bool,
) -> Result<crate::outcome::Closed, Failure> {
if !force {
let pending_count = ctx.tree.open_blockers(n.num);
if !pending_count.is_empty() {
let mut m = format!(
" {} CANNOT close: {} open closure condition(s)\n",
n.alias(),
pending_count.len()
);
for c in &pending_count {
m.push_str(&format!("\n {:<6} {}", c.alias(), c.title(&ctx.tree)));
}
m.push_str(&format!(
"\n\n A run closes with its findings, not with its report.\n \
Closing it anyway leaves a trace: vivac done {} --force",
n.num
));
return Err(Failure::Model(m));
}
}
let mut evs = vec![Body::StateChanged {
node: n.id.clone(),
state: State::Done,
outcome: outcome.to_string(),
forced: force,
}];
if unstack && ctx.tree.stack().last() == Some(&n.num) {
evs.push(Body::Popped { node: n.id.clone() });
}
ctx.emit(evs)?;
Ok(crate::outcome::Closed {
alias: n.alias(),
title: n.title(&ctx.tree).to_string(),
force,
already: None,
})
}
pub fn done(ctx: &mut Ctx, p: params::Done) -> Result<Outcome, Failure> {
let n = ctx.resolve(&p.id)?.clone();
guard_text(&[("outcome", &p.outcome)])?;
let closed = close_node(ctx, &n, &p.outcome, p.force, true)?;
Ok(Outcome::Done { closed })
}
pub fn add(ctx: &mut Ctx, p: params::Add) -> Result<Outcome, Failure> {
if p.root && p.parent.is_some() {
return Err(root_and_parent_error());
}
let parent = if p.root {
None
} else {
match &p.parent {
Some(s) => Some(ctx.resolve(s)?.id.clone()),
None => ctx.tree.focus().map(|n| n.id.clone()),
}
};
let kind = kind_of(
p.kind.as_deref(),
if parent.is_none() {
Kind::Goal
} else {
Kind::Task
},
)?;
let arms = arms_of(ctx, p.arms, p.arm_dir, kind, p.via_mcp)?;
let against = against_of(ctx, p.against, kind)?;
let (ev, num, _, no_against) = born(
ctx,
Born {
title: &p.title,
why: &p.why,
kind,
parent: parent.clone(),
refs: p.refs,
governs: p.governs,
blocks: p.blocks,
arms,
against,
},
)?;
ctx.emit(vec![ev])?;
let parent_info = parent
.and_then(|id| ctx.tree.node(&id))
.map(|n| outcome::AddedUnder {
alias: n.alias(),
title: n.title(&ctx.tree).to_string(),
});
Ok(Outcome::Added {
alias: format!("{}{}", kind.prefix(), num),
title: p.title,
parent: parent_info,
blocks: p.blocks,
no_against,
})
}
pub fn note(ctx: &mut Ctx, p: params::Note) -> Result<Outcome, Failure> {
let (n, note) = match (p.node.as_deref(), p.note.as_deref()) {
(Some(s), Some(t)) => (ctx.resolve(s)?.clone(), t.to_string()),
(Some(t), None) => {
let f = ctx
.tree
.focus()
.ok_or_else(|| Failure::usage("usage: vivac note [<id>] \"<note>\""))?;
(f.clone(), t.to_string())
}
_ => return Err(Failure::usage("usage: vivac note [<id>] \"<note>\"")),
};
guard_text(&[("note", ¬e)])?;
ctx.emit(vec![Body::NodeNoted {
node: n.id.clone(),
note,
}])?;
Ok(Outcome::Noted { alias: n.alias() })
}
pub fn block(ctx: &mut Ctx, p: params::Block) -> Result<Outcome, Failure> {
let n = ctx.resolve(&p.id)?.clone();
let Some(parent) = n.parent.and_then(|p| ctx.tree.node_by_num(p)) else {
return Err(Failure::usage(format!(
"{} is the root: there is no parent to block.",
n.alias()
)));
};
let blocks = !p.off;
let (pa, pt) = (parent.alias(), parent.title(&ctx.tree).to_string());
ctx.emit(vec![Body::BlockChanged {
node: n.id.clone(),
blocks,
}])?;
Ok(Outcome::Blocked {
alias: n.alias(),
blocks,
parent_alias: pa,
parent_title: pt,
})
}
pub fn promote(ctx: &mut Ctx, p: params::Promote) -> Result<Outcome, Failure> {
let n = match p.id {
Some(s) => ctx.resolve(&s)?.clone(),
None => ctx
.tree
.focus()
.ok_or_else(|| Failure::usage("usage: vivac promote [<id>]"))?
.clone(),
};
ctx.emit(vec![Body::Promoted { node: n.id.clone() }])?;
let parent = n
.parent
.and_then(|id| ctx.tree.node_by_num(id))
.map(|parent| outcome::StillBornFrom {
alias: parent.alias(),
title: parent.title(&ctx.tree).to_string(),
});
Ok(Outcome::Promoted {
alias: n.alias(),
title: n.title(&ctx.tree).to_string(),
parent,
})
}
pub fn abandon(ctx: &mut Ctx, p: params::Abandon) -> Result<Outcome, Failure> {
let (n, reason) = named_or_focus(
ctx,
p.node.as_deref(),
p.reason.as_deref(),
"usage: vivac abandon [<id>] \"<reason>\"",
)?;
let reason = reason.as_str();
guard_text(&[("reason", reason)])?;
let mut rescued: std::collections::HashSet<String> = Default::default();
for s in p.rescue {
let r = ctx
.tree
.resolve(&s)
.ok_or_else(|| Failure::usage(format!("no such node: {s}")))?;
let (rid, r_num, ralias) = (r.id.clone(), r.num, r.alias());
if rid == n.id {
return Err(Failure::usage(format!(
"{ralias} is the one being abandoned; it cannot be rescued from itself"
)));
}
if !ctx.tree.descendants(n.num).iter().any(|d| d.id == rid) {
return Err(Failure::usage(format!(
"{ralias} does not hang off {}: there is nothing to rescue it from",
n.alias()
)));
}
rescued.insert(rid.clone());
for d in ctx.tree.descendants(r_num) {
rescued.insert(d.id.clone());
}
}
let (falling, saved): (Vec<&Node>, Vec<&Node>) = ctx
.tree
.descendants(n.num)
.into_iter()
.filter(|d| d.state.is_open())
.partition(|d| !rescued.contains(&d.id));
if !falling.is_empty() && !p.cascade {
let mut m = format!(
" {} {}\n has {} open descendant(s) with no rescue:\n",
n.alias(),
n.title(&ctx.tree),
falling.len()
);
for d in &falling {
m.push_str(&format!("\n {:<6} {}", d.alias(), d.title(&ctx.tree)));
}
m.push_str("\n\n Abandon all of it: vivac abandon ");
m.push_str(&n.num.to_string());
m.push_str(" --cascade");
m.push_str("\n Save some of it: vivac abandon ");
m.push_str(&n.num.to_string());
m.push_str(" --rescue <id>");
m.push_str("\n Save it as a goal: vivac promote <id>");
return Err(Failure::Model(m));
}
let mut evs = vec![Body::StateChanged {
node: n.id.clone(),
state: State::Abandoned,
outcome: reason.to_string(),
forced: false,
}];
let falling_count = falling.len();
let saved_lines: Vec<(String, String)> = saved
.iter()
.map(|d| (d.alias(), d.title(&ctx.tree).to_string()))
.collect();
for d in falling {
evs.push(Body::StateChanged {
node: d.id.clone(),
state: State::Abandoned,
outcome: format!("cascaded from {}", n.alias()),
forced: false,
});
}
let mut out_of_scope: Vec<(u64, String)> = vec![(n.num, n.id.clone())];
out_of_scope.extend(
ctx.tree
.descendants(n.num)
.iter()
.map(|d| (d.num, d.id.clone())),
);
for (num, id) in out_of_scope {
if ctx.tree.stack().contains(&num) {
evs.push(Body::Popped { node: id });
}
}
ctx.emit(evs)?;
Ok(Outcome::Abandoned {
alias: n.alias(),
title: n.title(&ctx.tree).to_string(),
cascaded: (falling_count > 0).then_some(falling_count),
rescued: saved_lines
.into_iter()
.map(|(alias, title)| outcome::RescuedNode { alias, title })
.collect(),
})
}
pub fn focus(ctx: &mut Ctx, p: params::Focus) -> Result<Outcome, Failure> {
let n = ctx.resolve(&p.id)?.clone();
if !n.state.is_open() && !p.reopen {
if n.state != State::Suspended {
return Err(Failure::Model(format!(
" {} is {}. Going back into it undoes that claim.\n\n \
If it really was not finished: vivac focus {} --reopen",
n.alias(),
n.state.word(n.kind),
n.num
)));
}
}
let (to_pop, to_push) = stack_to(ctx, n.num);
let mut evs: Vec<Body> = to_pop
.iter()
.filter_map(|&num| ctx.tree.node_by_num(num))
.map(|n| Body::Popped { node: n.id.clone() })
.collect();
if !n.state.is_open() {
evs.push(Body::StateChanged {
node: n.id.clone(),
state: State::Active,
outcome: String::new(),
forced: false,
});
}
evs.extend(
to_push
.iter()
.map(|(_, id)| Body::Pushed { node: id.clone() }),
);
let revived = !n.state.is_open();
ctx.emit(evs)?;
Ok(Outcome::Focused {
alias: n.alias(),
revived,
})
}
pub fn flag(ctx: &mut Ctx, p: params::Flag) -> Result<Outcome, Failure> {
let n = ctx.resolve(&p.id)?.clone();
let flag = Flag::parse(&p.flag).ok_or_else(|| {
Failure::usage(format!("Unknown flag: {}. They are: {}", p.flag, Flag::ALL))
})?;
if p.off {
ctx.emit(vec![Body::FlagCleared {
node: n.id.clone(),
flag,
}])?;
return Ok(Outcome::Flagged {
alias: n.alias(),
flag: flag.word().to_string(),
change: outcome::FlagChange::Off,
});
}
let reason = p.why.ok_or_else(|| {
Failure::usage(
"Missing --why. A flag with no reason informs nobody: in two weeks\n \
nobody will know what needed looking at, and they all get ignored.",
)
})?;
guard_text(&[("reason", &reason)])?;
ctx.emit(vec![Body::FlagRaised {
node: n.id.clone(),
flag,
reason: reason.clone(),
}])?;
Ok(Outcome::Flagged {
alias: n.alias(),
flag: flag.word().to_string(),
change: outcome::FlagChange::Raised {
title: n.title(&ctx.tree).to_string(),
reason,
},
})
}
pub fn arm(ctx: &mut Ctx, p: params::Arm) -> Result<Outcome, Failure> {
let n = ctx.resolve(&p.id)?.clone();
if n.kind != Kind::Rule {
return Err(Failure::usage(format!(
"Only a rule has an arm; {} is {}.",
n.alias(),
n.kind.with_article()
)));
}
let dir = match &p.dir {
Some(d) if !d.trim().is_empty() => d.clone(),
_ => return Err(Failure::usage(needs_dir_message(p.via_mcp))),
};
let dir = validate_arm_dir(&dir, &ctx.store.root)?;
validate_arm_text(&p.command)?;
let has = n
.arms(&ctx.tree)
.contains(&(dir.as_str(), p.command.as_str()));
if p.off && !has {
return Err(Failure::usage(format!(
"{0} has no such arm; vivac why {0} lists the ones it has.",
n.alias()
)));
}
if !p.off && has {
return Err(Failure::usage(format!(
"{} already has that arm.",
n.alias()
)));
}
guard_text(&[("arm", &p.command)])?;
let body = if p.off {
Body::ArmRemoved {
node: n.id.clone(),
dir: dir.clone(),
command: p.command.clone(),
}
} else {
Body::ArmAdded {
node: n.id.clone(),
dir: dir.clone(),
command: p.command.clone(),
}
};
ctx.emit(vec![body])?;
Ok(Outcome::Armed {
alias: n.alias(),
dir,
arm: p.command,
change: if p.off {
outcome::ArmChange::Removed
} else {
outcome::ArmChange::Added
},
})
}
pub fn decide(ctx: &mut Ctx, p: params::Decide) -> Result<Outcome, Failure> {
if p.root && p.parent.is_some() {
return Err(root_and_parent_error());
}
let superseded = match &p.supersedes {
Some(s) => Some(ctx.resolve(s)?.clone()),
None => None,
};
let mut body = p.reason.clone();
if !p.alternatives.is_empty() {
body.push_str(&format!(" | discarded: {}", p.alternatives.join("; ")));
}
let parent = if p.root {
None
} else {
match &p.parent {
Some(s) => Some(ctx.resolve(s)?.id.clone()),
None => ctx.tree.focus().map(|n| n.id.clone()),
}
};
let against = against_of(ctx, p.against, Kind::Decision)?;
let (ev, num, _, no_against) = born(
ctx,
Born {
title: &p.title,
why: &body,
kind: Kind::Decision,
parent,
refs: p.refs,
governs: p.governs,
blocks: p.blocks,
arms: vec![],
against,
},
)?;
let mut evs = vec![ev];
if let Some(v) = &superseded {
evs.push(Body::StateChanged {
node: v.id.clone(),
state: State::Superseded,
outcome: format!("superseded by d{num}"),
forced: false,
});
}
ctx.emit(evs)?;
Ok(Outcome::Decided {
alias: format!("d{num}"),
title: p.title,
superseded: superseded.map(|v| outcome::SupersededNode { alias: v.alias() }),
no_alternatives: p.alternatives.is_empty(),
no_against,
})
}
pub fn declare(ctx: &mut Ctx, p: params::Declare) -> Result<Outcome, Failure> {
let (Some(id), false) = (p.id.as_deref(), p.against.is_empty()) else {
return Err(Failure::usage(
"usage: vivac declare <decision> --against \"r12: <why>\"",
));
};
let n = ctx.resolve(id)?.clone();
if n.kind != Kind::Decision {
return Err(Failure::usage(format!(
"vivac declare takes a decision, and {} is {}",
n.alias(),
n.kind.with_article()
)));
}
let entries = against_of(ctx, p.against, Kind::Decision)?;
let existing = n.against(&ctx.tree);
let before: Vec<Option<String>> = entries
.iter()
.map(|e| {
let num = ctx.tree.node(&e.node).map(|x| x.num);
n.against
.iter()
.zip(existing.iter())
.find(|(span, _)| Some(span.node) == num)
.map(|(_, resolved)| resolved.why.to_string())
})
.collect();
guard_text(
&entries
.iter()
.map(|a| ("against", a.why.as_str()))
.collect::<Vec<_>>(),
)?;
ctx.emit(vec![Body::AgainstAdded {
node: n.id.clone(),
against: entries.clone(),
}])?;
Ok(Outcome::Declared {
alias: n.alias(),
against: entries
.into_iter()
.zip(before)
.map(|(a, before)| outcome::DeclaredPair {
node: ctx.tree.node(&a.node).map(|x| x.alias()).unwrap_or(a.node),
why: a.why,
before,
})
.collect(),
})
}
pub fn save(ctx: &mut Ctx, p: params::Save) -> Result<Outcome, Failure> {
guard_text(&[("label", &p.label), ("next", &p.next)])?;
let v = vivac(ctx, VivacKind::Manual, &p.next, None, &p.label);
let num = ctx.tree.next_vivac_num.max(1);
let anchors = match &v {
Body::VivacCreated { anchors, .. } => anchors.clone(),
_ => vec![],
};
ctx.emit(vec![v])?;
let anchor = ctx.anchor.snapshot();
Ok(Outcome::Saved {
num,
label: p.label,
anchor,
anchors,
next: p.next,
})
}
type MatchedEntry = Option<(u64, bool, String)>;
fn lost_state(matched: &MatchedEntry) -> String {
matched
.as_ref()
.map(|(_, _, word)| word.clone())
.unwrap_or_else(|| "gone".to_string())
}
fn restore_path(
tree: &Tree,
saved_stack: &[(String, String)],
) -> (
Vec<(u64, String)>,
Vec<outcome::KeptNode>,
Vec<outcome::LostNode>,
) {
let matched: Vec<MatchedEntry> = saved_stack
.iter()
.map(|(alias, _)| {
tree.resolve(alias)
.map(|n| (n.num, n.state.is_open(), n.state.word(n.kind).to_string()))
})
.collect();
let deepest_open = matched
.iter()
.rposition(|m| m.as_ref().is_some_and(|(_, open, _)| *open));
let mut kept: Vec<outcome::KeptNode> = Vec::new();
let mut lost: Vec<outcome::LostNode> = Vec::new();
let mut lineage: Vec<(u64, String)> = Vec::new();
match deepest_open {
None => {
for (i, (alias, title)) in saved_stack.iter().enumerate() {
lost.push(outcome::LostNode {
alias: alias.clone(),
title: title.clone(),
state: lost_state(&matched[i]),
});
}
}
Some(deepest_open) => {
let (deepest_num, _, _) = matched[deepest_open].clone().unwrap();
let full_lineage = tree.ancestors(deepest_num);
let bottom_index = (0..=deepest_open)
.find(|&i| {
matched[i]
.as_ref()
.is_some_and(|(num, _, _)| full_lineage.iter().any(|a| a.num == *num))
})
.unwrap_or(deepest_open);
let (bottom_num, _, _) = matched[bottom_index].clone().unwrap();
let start = full_lineage
.iter()
.position(|a| a.num == bottom_num)
.unwrap_or(0);
for n in &full_lineage[start..] {
lineage.push((n.num, n.id.clone()));
if !n.state.is_open() {
kept.push(outcome::KeptNode {
alias: n.alias(),
title: n.title(tree).to_string(),
state: n.state.word(n.kind).to_string(),
});
}
}
for (i, (alias, title)) in saved_stack.iter().enumerate().take(bottom_index) {
lost.push(outcome::LostNode {
alias: alias.clone(),
title: title.clone(),
state: lost_state(&matched[i]),
});
}
for (i, (alias, title)) in saved_stack.iter().enumerate().skip(deepest_open + 1) {
lost.push(outcome::LostNode {
alias: alias.clone(),
title: title.clone(),
state: lost_state(&matched[i]),
});
}
}
}
(lineage, kept, lost)
}
pub fn restore(ctx: &mut Ctx, p: params::Restore) -> Result<Outcome, Failure> {
let v = ctx
.tree
.vivac(&p.vivac)
.ok_or_else(|| Failure::usage(format!("No such vivac: {}.", p.vivac)))?
.clone();
let changes = ctx.anchor.changed_since(&v.anchor);
let mine = ctx.lock_for_write()?;
let (lineage, kept, lost) = restore_path(&ctx.tree, &v.stack);
let mut evs: Vec<Body> = ctx
.tree
.stack()
.iter()
.filter(|num| !lineage.iter().any(|(lineage_num, _)| lineage_num == *num))
.filter_map(|&num| ctx.tree.node_by_num(num))
.map(|n| Body::Popped { node: n.id.clone() })
.collect();
for (num, id) in &lineage {
if !ctx.tree.stack().contains(num) {
evs.push(Body::Pushed { node: id.clone() });
}
}
ctx.emit(evs)?;
if mine {
ctx.unlock();
}
let anchor = if v.anchor.is_empty_tree() {
outcome::RestoreAnchor::Empty
} else if changes.is_empty() {
outcome::RestoreAnchor::NoChanges {
anchor_short: v.anchor.short().to_string(),
}
} else {
outcome::RestoreAnchor::Changed {
anchor_short: v.anchor.short().to_string(),
changes: changes
.iter()
.map(|c| outcome::ChangeLine {
file_path: c.file_path.clone(),
times: c.times,
})
.collect(),
working_set: v.working_set.clone(),
}
};
Ok(Outcome::Restored {
alias: v.alias(),
kind: v.kind.word().to_string(),
ts: v.ts,
label: v.label,
next_intent: v.next_intent,
kept,
lost,
anchor,
})
}
pub fn auto_vivac(
ctx: &mut Ctx,
kind: VivacKind,
next: &str,
label: &str,
) -> Result<Outcome, Failure> {
guard_text(&[("next", next), ("label", label)])?;
let v = vivac(ctx, kind, next, None, label);
ctx.emit(vec![v])?;
Ok(Outcome::AutoStopped)
}
pub fn session_started(
ctx: &mut Ctx,
source: &str,
session: Option<String>,
focus: Option<String>,
vivac: Option<String>,
) -> Result<Outcome, Failure> {
let source = guarded_or_refused("source", source);
let session = session.map(|s| guarded_or_refused("session", &s));
ctx.emit(vec![Body::SessionStarted {
source,
focus,
vivac,
session,
}])?;
Ok(Outcome::SessionOpened)
}
pub fn declare_lane(ctx: &mut Ctx, name: String, repos: Vec<crate::event::Repo>) -> R {
let lock = ctx
.lock
.as_ref()
.ok_or_else(|| Failure::Io(std::io::Error::other("write without the tree's lock")))?;
ctx.store.lock_lanes_in_config(lock)?;
let lane = ctx
.lane
.clone()
.unwrap_or_else(|| crate::lane::MAIN.to_string());
ctx.emit(vec![Body::LaneDeclared { lane, name, repos }])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::Event;
use crate::model::fold;
fn created(seq: u64, num: u64, kind: Kind, parent: Option<&str>) -> Event {
Event {
seq,
id: format!("e{seq}"),
ts: "2026-09-13T00:00:00Z".to_string(),
actor: "a".to_string(),
lane: "main".to_string(),
payload: Body::NodeCreated {
node: format!("n{num}"),
num,
kind,
title: format!("Node {num}"),
why: "it is needed".to_string(),
parent: parent.map(str::to_string),
blocks: false,
refs: vec![],
governs: vec![],
arms: vec![],
against: None,
},
}
}
#[test]
fn a_saved_entry_below_the_path_that_does_not_resolve_is_reported_lost() {
let events = vec![
created(1, 1, Kind::Goal, None),
created(2, 2, Kind::Task, Some("n1")),
created(3, 3, Kind::Task, Some("n2")),
created(4, 4, Kind::Task, Some("n3")),
];
let tree = fold(&events, 0);
let saved_stack = vec![
("f9".to_string(), "Nothing here resolves".to_string()),
("g1".to_string(), "Node 1".to_string()),
("t2".to_string(), "Node 2".to_string()),
("t3".to_string(), "Node 3".to_string()),
("t4".to_string(), "Node 4".to_string()),
];
let (lineage, kept, lost) = restore_path(&tree, &saved_stack);
assert_eq!(lineage.len(), 4, "the whole open lineage rebuilds");
assert!(kept.is_empty(), "nothing on this path is closed");
assert_eq!(
lost.len(),
1,
"the entry below B must not vanish from the report: {lost:?}"
);
assert_eq!(lost[0].alias, "f9");
assert_eq!(lost[0].state, "gone");
}
struct TmpDir(std::path::PathBuf);
impl Drop for TmpDir {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).ok();
}
}
fn seeded_ctx(name: &str) -> (TmpDir, Ctx) {
let tmp = std::env::temp_dir().join(format!("vivac-ops-{name}-{}", id::ulid()));
let store = Store::create(&tmp).unwrap();
(TmpDir(tmp), Ctx::load(store, Whose::Founding).unwrap())
}
#[test]
fn taking_the_write_lock_twice_does_not_deadlock() {
let (_tmp, mut ctx) = seeded_ctx("relock");
ctx.lock_for_write().unwrap();
ctx.lock_for_write()
.expect("a second take blocked on the first");
ctx.unlock();
}
#[test]
fn an_inner_release_does_not_take_the_lock_from_the_caller_above() {
let (_tmp, mut ctx) = seeded_ctx("inner-release");
assert!(
ctx.lock_for_write().unwrap(),
"the first take should be the one that locks"
);
let mine = ctx.lock_for_write().unwrap();
assert!(
!mine,
"a second take must not claim the lock it already holds"
);
if mine {
ctx.unlock();
}
assert!(
ctx.holds_write_lock(),
"an inner release dropped the caller's lock"
);
ctx.unlock();
}
#[test]
fn a_reload_under_the_lock_keeps_answering_from_its_own_lane() {
let tmp = std::env::temp_dir().join(format!("vivac-ops-lane-reload-{}", id::ulid()));
let store = Store::create(&tmp).unwrap();
let located = crate::store::Located {
root: tmp.clone(),
lane_dir: tmp.clone(),
lane: Some(crate::lane::Lane {
version: 1,
id: "b".to_string(),
project: String::new(),
}),
worktree: None,
};
let mut ctx = Ctx::load(store, Whose::Resolved(&located)).unwrap();
let mut other = Store::open(tmp.clone()).unwrap();
let lock = other.lock_for_write().unwrap();
other
.append(
&lock,
crate::lane::MAIN,
vec![Body::Pushed {
node: "ghost".to_string(),
}],
0,
false,
)
.unwrap();
drop(lock);
ctx.lock_for_write().unwrap();
assert_eq!(
ctx.tree.lane(),
"b",
"the reload under the lock forgot which lane this context is"
);
ctx.unlock();
std::fs::remove_dir_all(&tmp).ok();
}
fn where_tmp(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("vivac-ops-where-{name}-{}", id::ulid()))
}
fn where_git(dir: &Path, args: &[&str]) {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn where_git_repo_with_one_commit(at: &Path) {
std::fs::create_dir_all(at).unwrap();
where_git(at, &["init", "-q"]);
where_git(at, &["config", "user.email", "t@example.com"]);
where_git(at, &["config", "user.name", "t"]);
std::fs::write(at.join("f.txt"), "x").unwrap();
where_git(at, &["add", "."]);
where_git(at, &["commit", "-q", "-m", "first"]);
}
fn where_head_sha(dir: &Path) -> String {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
assert!(out.status.success());
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn tree_with_repo(lane: &str, path: &str) -> Tree {
let mut tree = Tree::default();
tree.lanes.insert(
lane.to_string(),
crate::model::LaneState {
repos: vec![crate::event::Repo {
path: path.to_string(),
root: None,
}],
..Default::default()
},
);
tree
}
fn push_where(tree: &mut Tree, seq: u64, lane: &str, repos: Vec<crate::event::WhereRepo>) {
tree.wheres.push(crate::model::Where {
seq,
lane: lane.to_string(),
repos,
});
}
#[test]
fn the_first_write_of_a_lane_with_repositories_always_carries_its_where() {
let t = where_tmp("first-write");
where_git_repo_with_one_commit(&t);
where_git(&t, &["checkout", "-q", "-b", "develop"]);
let tree = tree_with_repo("main", ".");
let Some(Body::WhereChanged { repos }) = where_to_write(&tree, "main", &t) else {
panic!("the first write of a lane with repositories must carry a where")
};
assert_eq!(repos[0].branch.as_deref(), Some("develop"));
std::fs::remove_dir_all(&t).ok();
}
#[test]
fn another_branch_writes_a_where() {
let t = where_tmp("another-branch");
where_git_repo_with_one_commit(&t);
where_git(&t, &["checkout", "-q", "-b", "feature"]);
let mut tree = tree_with_repo("main", ".");
push_where(
&mut tree,
1,
"main",
vec![crate::event::WhereRepo {
path: ".".into(),
branch: Some("develop".into()),
..Default::default()
}],
);
assert!(matches!(
where_to_write(&tree, "main", &t),
Some(Body::WhereChanged { .. })
));
std::fs::remove_dir_all(&t).ok();
}
#[test]
fn a_new_commit_on_the_same_branch_writes_nothing() {
let t = where_tmp("same-branch");
where_git_repo_with_one_commit(&t);
where_git(&t, &["checkout", "-q", "-b", "develop"]);
let mut tree = tree_with_repo("main", ".");
push_where(
&mut tree,
1,
"main",
vec![crate::event::WhereRepo {
path: ".".into(),
branch: Some("develop".into()),
..Default::default()
}],
);
std::fs::write(t.join("g.txt"), "y").unwrap();
where_git(&t, &["add", "."]);
where_git(&t, &["commit", "-q", "-m", "second"]);
assert!(where_to_write(&tree, "main", &t).is_none());
std::fs::remove_dir_all(&t).ok();
}
#[test]
fn a_rebase_in_progress_does_not_write_one_per_commit() {
let t = where_tmp("rebase-progress");
where_git_repo_with_one_commit(&t);
let gitdir = t.join(".git");
std::fs::create_dir_all(gitdir.join("rebase-merge")).unwrap();
std::fs::write(
gitdir.join("rebase-merge").join("head-name"),
"refs/heads/side\n",
)
.unwrap();
let mut tree = tree_with_repo("main", ".");
push_where(
&mut tree,
1,
"main",
vec![crate::event::WhereRepo {
path: ".".into(),
branch: Some("side".into()),
rebasing: true,
..Default::default()
}],
);
assert!(where_to_write(&tree, "main", &t).is_none());
std::fs::remove_dir_all(&t).ok();
}
#[test]
fn a_different_detached_sha_writes_a_where() {
let t = where_tmp("detached-sha");
where_git_repo_with_one_commit(&t);
let head = where_head_sha(&t);
where_git(&t, &["checkout", "-q", &head]);
let mut tree = tree_with_repo("main", ".");
push_where(
&mut tree,
1,
"main",
vec![crate::event::WhereRepo {
path: ".".into(),
sha: Some("a".repeat(40)),
..Default::default()
}],
);
let Some(Body::WhereChanged { repos }) = where_to_write(&tree, "main", &t) else {
panic!("a different detached sha must carry a where")
};
assert_eq!(repos[0].sha.as_deref(), Some(head.as_str()));
std::fs::remove_dir_all(&t).ok();
}
#[test]
fn a_repository_that_appeared_or_vanished_writes_a_where() {
let t = where_tmp("vanished");
std::fs::create_dir_all(&t).unwrap();
let mut tree = tree_with_repo("main", ".");
push_where(
&mut tree,
1,
"main",
vec![crate::event::WhereRepo {
path: ".".into(),
branch: Some("develop".into()),
..Default::default()
}],
);
let Some(Body::WhereChanged { repos }) = where_to_write(&tree, "main", &t) else {
panic!("a repository that vanished must carry a where")
};
assert!(repos[0].missing);
std::fs::remove_dir_all(&t).ok();
}
#[test]
fn a_lane_with_no_declared_repositories_never_writes_one() {
let t = where_tmp("no-repos");
where_git_repo_with_one_commit(&t);
let tree = Tree::default();
assert!(where_to_write(&tree, "main", &t).is_none());
std::fs::remove_dir_all(&t).ok();
}
#[test]
fn a_branch_name_the_guard_refuses_is_withheld_and_its_sha_kept() {
let t = where_tmp("withheld-branch");
where_git_repo_with_one_commit(&t);
let secret_branch = format!("ghp_{}", "a".repeat(30));
where_git(&t, &["checkout", "-q", "-b", &secret_branch]);
let tree = tree_with_repo("main", ".");
let Some(Body::WhereChanged { repos }) = where_to_write(&tree, "main", &t) else {
panic!("a branch the guard refuses still has to carry its sha")
};
assert!(
repos[0].withheld,
"a secret-looking branch name must be withheld"
);
assert!(
repos[0].branch.is_none(),
"a withheld name is not written down"
);
assert!(repos[0].sha.is_some(), "the sha survives (d600)");
std::fs::remove_dir_all(&t).ok();
}
}