use crate::args::Args;
use crate::event::{Body, VivacKind};
use crate::failure::{Failure, R};
use crate::output::outln;
struct HookInput {
source: String,
session: Option<String>,
}
pub fn hook_stdin() -> &'static str {
static RAW: std::sync::OnceLock<String> = std::sync::OnceLock::new();
RAW.get_or_init(|| {
use std::io::IsTerminal;
let mut raw = String::new();
if !std::io::stdin().is_terminal() {
use std::io::Read;
std::io::stdin().read_to_string(&mut raw).ok();
}
raw
})
}
impl HookInput {
fn read() -> HookInput {
let v: serde_json::Value =
serde_json::from_str(hook_stdin()).unwrap_or(serde_json::Value::Null);
HookInput {
source: v
.get("source")
.and_then(|s| s.as_str())
.unwrap_or("unknown")
.to_string(),
session: v
.get("session_id")
.and_then(|s| s.as_str())
.map(str::to_string),
}
}
}
pub fn start(ctx: &mut crate::ops::Ctx, a: &Args, project: &str) -> R {
if !a.has("hook") {
return crate::brief::brief(&ctx.tree, &ctx.store.root, &ctx.lane_dir, a, project);
}
let text = crate::brief::to_text(&ctx.tree, &ctx.store.root, &ctx.lane_dir, a, project, true)?;
print!("{text}");
let hook = HookInput::read();
let shown_focus = ctx.tree.focus().map(|n| n.id.clone());
let shown_vivac = ctx.tree.last_vivac().map(|v| v.id.clone());
match ctx.lock_for_write() {
Ok(mine) => {
crate::ops::session_started(ctx, &hook.source, hook.session, shown_focus, shown_vivac)
.ok();
if mine {
ctx.unlock();
}
}
Err(Failure::Busy(_)) => {
outln!(
" Session not recorded: another vivac process held the tree for {} seconds.",
crate::store::LOCK_DEADLINE.as_secs()
);
}
Err(_) => {}
}
Ok(())
}
pub fn end(ctx: &mut crate::ops::Ctx, a: &Args, located: &crate::store::Located) -> R {
if a.has("hook") {
close_turn(located, a);
}
if nothing_to_stop(&ctx.tree, a) {
return Ok(());
}
let mine = match ctx.lock_for_write() {
Ok(mine) => mine,
Err(_) if a.has("hook") => return Ok(()),
Err(e) => return Err(e),
};
if nothing_to_stop(&ctx.tree, a) {
return Ok(());
}
let next = a.opt_or("next");
let label = segment_label(&ctx.tree);
let num = ctx.tree.next_vivac_num.max(1);
crate::ops::auto_vivac(ctx, VivacKind::Auto, &next, &label)?;
if mine {
ctx.unlock();
}
if !a.has("hook") {
outln!(" v{num} automatic stop at session close");
}
Ok(())
}
fn nothing_to_stop(t: &crate::model::Tree, a: &Args) -> bool {
if t.stack().is_empty() {
if !a.has("hook") {
outln!(" Empty stack: no stop worth saving.");
}
return true;
}
if t.state().seq_change <= t.state().seq_vivac {
if !a.has("hook") {
outln!(" Nothing changed since the last stop.");
}
return true;
}
false
}
fn segment_label(t: &crate::model::Tree) -> String {
let s = t.state();
let mut parts = Vec::new();
if s.seg_new > 0 {
parts.push(format!("{} new", s.seg_new));
}
if s.seg_closed > 0 {
parts.push(format!("{} closed", s.seg_closed));
}
if s.seg_notes == 1 {
parts.push("1 note".to_string());
} else if s.seg_notes > 1 {
parts.push(format!("{} notes", s.seg_notes));
}
if parts.is_empty() && s.seg_events > 0 {
parts.push(if s.seg_events == 1 {
"1 change".to_string()
} else {
format!("{} changes", s.seg_events)
});
}
parts.join(", ")
}
pub fn dispatch(
ctx: &mut crate::ops::Ctx,
a: &Args,
project: &str,
located: &crate::store::Located,
) -> R {
match a.positional(0) {
Some("start") => start(ctx, a, project),
Some("end") => end(ctx, a, located),
_ => Err(Failure::usage(
"usage: vivac session start|end|prompt [--hook]",
)),
}
}
const PROMPT_QUIET_MIN: i64 = 10;
const PROMPT_COOLDOWN_MIN: i64 = 10;
fn is_capture(body: &Body) -> bool {
match body {
Body::NodeCreated { .. }
| Body::StateChanged { .. }
| Body::NodeNoted { .. }
| Body::BlockChanged { .. }
| Body::Pushed { .. }
| Body::Popped { .. }
| Body::Promoted { .. }
| Body::FlagRaised { .. }
| Body::FlagCleared { .. }
| Body::ArmAdded { .. }
| Body::ArmRemoved { .. }
| Body::AgainstAdded { .. } => true,
Body::VivacCreated { kind, .. } => *kind == VivacKind::Manual,
Body::SessionStarted { .. }
| Body::LaneDeclared { .. }
| Body::LaneClaimed { .. }
| Body::WhereChanged { .. } => false,
}
}
pub(crate) fn capture_count(events: &[crate::event::Event]) -> usize {
events.iter().filter(|e| is_capture(&e.payload)).count()
}
pub(crate) fn lane_capture_count(events: &[crate::event::Event], lane: &str) -> usize {
events
.iter()
.filter(|e| e.lane == lane && is_capture(&e.payload))
.count()
}
fn last_matching_ts<'a>(
events: &'a [crate::event::Event],
lane: &str,
matches: impl Fn(&Body) -> bool,
) -> Option<&'a str> {
events
.iter()
.rev()
.find(|e| e.lane == lane && matches(&e.payload))
.map(|e| e.ts.as_str())
}
fn cooldown_path(key: &str) -> std::path::PathBuf {
let hash = crate::setup::fnv1a64(key.as_bytes());
std::env::temp_dir()
.join("vivac")
.join(format!("prompt-{hash:016x}"))
}
#[derive(Default)]
struct TurnState {
last_nudge_secs: i64,
turn_start_secs: i64,
active_secs: i64,
reference_secs: i64,
}
impl TurnState {
fn read(path: &std::path::Path) -> TurnState {
let Ok(text) = std::fs::read_to_string(path) else {
return TurnState::default();
};
let mut words = text.split_whitespace();
if words.next() != Some("v1") {
return TurnState::default();
}
let mut field = || words.next().and_then(|w| w.parse::<i64>().ok());
match (field(), field(), field(), field()) {
(
Some(last_nudge_secs),
Some(turn_start_secs),
Some(active_secs),
Some(reference_secs),
) => TurnState {
last_nudge_secs,
turn_start_secs,
active_secs,
reference_secs,
},
_ => TurnState::default(),
}
}
fn write(&self, path: &std::path::Path) {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).ok();
}
std::fs::write(
path,
format!(
"v1 {} {} {} {}",
self.last_nudge_secs, self.turn_start_secs, self.active_secs, self.reference_secs
),
)
.ok();
}
}
fn hook_lane(located: &crate::store::Located) -> String {
located
.lane
.as_ref()
.map(|l| l.id.clone())
.unwrap_or_else(|| crate::lane::MAIN.to_string())
}
fn state_key(root: &std::path::Path, lane: &str, session: &Option<String>) -> String {
let project_id = crate::store::first_event_id(root).unwrap_or_default();
match session {
Some(s) => format!("{project_id}\u{0}{lane}\u{0}{s}"),
None => format!("{project_id}\u{0}{lane}"),
}
}
fn close_turn(located: &crate::store::Located, a: &Args) {
let lane = hook_lane(located);
let session = HookInput::read().session;
let key = state_key(&located.root, &lane, &session);
let path = cooldown_path(&key);
let mut state = TurnState::read(&path);
if state.turn_start_secs <= 0 {
return;
}
let now = a
.opt("now")
.map(str::to_string)
.unwrap_or_else(crate::clock::now_rfc3339);
let Some(now_secs) = crate::clock::epoch_seconds(&now) else {
return;
};
let worked_secs = (now_secs - state.turn_start_secs).max(0);
state.active_secs += worked_secs;
state.turn_start_secs = 0;
state.write(&path);
}
fn cooled_down(last_nudge_secs: i64, now_secs: i64) -> bool {
last_nudge_secs == 0 || now_secs - last_nudge_secs >= PROMPT_COOLDOWN_MIN * 60
}
fn prompt_text(n: i64) -> String {
format!(
"vivac: nothing written to the tree in {n} min of work in this session. If a seam\n\
passed since (a new line of work, a choice, a finding you told, a \"not now\", \
work done, a change outside the repo), write it now, before you answer.\n"
)
}
pub fn prompt(cwd: &std::path::Path, a: &Args) {
let Some(text) = prompt_text_for(cwd, a) else {
return;
};
print!("{text}");
}
fn prompt_text_for(cwd: &std::path::Path, a: &Args) -> Option<String> {
let located = crate::store::locate(cwd).ok()??;
let store = crate::store::Store::open(located.root.clone()).ok()?;
let (events, _broken) = store.read_all().ok()?;
let lane = hook_lane(&located);
let session_start =
last_matching_ts(&events, &lane, |b| matches!(b, Body::SessionStarted { .. }))?;
let last_capture = last_matching_ts(&events, &lane, is_capture);
let now = a
.opt("now")
.map(str::to_string)
.unwrap_or_else(crate::clock::now_rfc3339);
let now_secs = crate::clock::epoch_seconds(&now)?;
let session_start_secs = crate::clock::epoch_seconds(session_start)?;
let reference_secs = match last_capture.and_then(crate::clock::epoch_seconds) {
Some(c_secs) if c_secs > session_start_secs => c_secs,
_ => session_start_secs,
};
let session = HookInput::read().session;
let key = state_key(&located.root, &lane, &session);
let path = cooldown_path(&key);
let mut state = TurnState::read(&path);
if state.reference_secs != reference_secs {
state.active_secs = 0;
state.reference_secs = reference_secs;
}
let active_min = state.active_secs / 60;
let speaks = active_min >= PROMPT_QUIET_MIN && cooled_down(state.last_nudge_secs, now_secs);
state.turn_start_secs = now_secs;
if speaks {
state.last_nudge_secs = now_secs;
}
state.write(&path);
speaks.then(|| prompt_text(active_min))
}