mod common;
use common::Sandbox;
fn how_many(vivacs: &str, kind: &str) -> usize {
vivacs.lines().filter(|l| l.contains(kind)).count()
}
#[test]
fn one_stop_per_turn_does_not_leave_one_stop_per_turn() {
let c = Sandbox::new_seeded("turns");
c.ok(&["push", "A goal", "--why", "it is needed"]);
for _ in 0..5 {
c.ok(&["session", "end", "--hook"]);
}
let v = c.ok(&["vivacs"]);
assert_eq!(how_many(&v, "auto"), 1, "one stop per turn:\n{v}");
}
#[test]
fn a_new_stop_once_something_changed() {
let c = Sandbox::new_seeded("change");
c.ok(&["push", "A goal", "--why", "it is needed"]);
c.ok(&["session", "end", "--hook"]);
c.ok(&["note", "1", "something happened"]);
c.ok(&["session", "end", "--hook"]);
let v = c.ok(&["vivacs"]);
assert_eq!(how_many(&v, "auto"), 2, "it swallowed the good stop:\n{v}");
}
#[test]
fn no_stack_no_stop() {
let c = Sandbox::new_seeded("nostack");
c.ok(&["session", "end", "--hook"]);
let v = c.ok(&["vivacs"]);
assert_eq!(how_many(&v, "auto"), 0, "it invented an empty stop:\n{v}");
}
#[test]
fn the_start_hook_travels_in_its_envelope() {
let c = Sandbox::new_seeded("envelope");
c.ok(&["push", "A goal", "--why", "it is needed"]);
let s = c.ok(&["session", "start", "--hook"]);
assert_eq!(s.lines().filter(|l| !l.trim().is_empty()).count(), 1);
assert!(s.contains("hookSpecificOutput"), "{s}");
assert!(s.contains("SessionStart"), "{s}");
assert!(s.contains("additionalContext"), "{s}");
assert!(s.contains("A goal"), "the envelope went out empty:\n{s}");
}
#[test]
fn they_stay_quiet_where_there_is_no_tree() {
let c = Sandbox::new_empty("notree");
for args in [["session", "start", "--hook"], ["session", "end", "--hook"]] {
let (s, code) = c.run(&args);
assert_eq!(code, 0, "{args:?} failed outside a tree:\n{s}");
assert_eq!(s.trim(), "", "{args:?} said too much:\n{s}");
}
}
#[test]
fn an_automatic_stop_says_what_its_segment_contained() {
let c = Sandbox::new_seeded("segment");
c.ok(&["push", "A goal", "--why", "it is needed"]);
c.ok(&["session", "end", "--hook"]);
c.ok(&["add", "First finding", "--why", "it turned up"]);
c.ok(&["add", "Second finding", "--why", "it turned up too"]);
c.ok(&["session", "end", "--hook"]);
let v = c.ok(&["vivacs"]);
assert!(
v.contains("2 new"),
"the automatic stop did not say what it closed:\n{v}"
);
}
#[test]
fn an_automatic_stop_counts_what_its_segment_closed() {
let c = Sandbox::new_seeded("closed");
c.ok(&["push", "A goal", "--why", "it is needed"]);
c.ok(&["add", "First finding", "--why", "it turned up"]);
c.ok(&["session", "end", "--hook"]);
c.ok(&["done", "2", "it was settled"]);
c.ok(&["session", "end", "--hook"]);
let v = c.ok(&["vivacs"]);
assert!(
v.contains("1 closed"),
"the automatic stop counted no closes:\n{v}"
);
}
#[test]
fn an_automatic_stop_counts_the_notes_of_its_segment() {
let c = Sandbox::new_seeded("notes");
c.ok(&["push", "A goal", "--why", "it is needed"]);
c.ok(&["session", "end", "--hook"]);
c.ok(&["note", "1", "something turned up"]);
c.ok(&["note", "1", "and something else"]);
c.ok(&["session", "end", "--hook"]);
let v = c.ok(&["vivacs"]);
assert!(
v.contains("2 notes"),
"the automatic stop counted no notes:\n{v}"
);
}
#[test]
fn a_single_note_is_not_pluralised() {
let c = Sandbox::new_seeded("plural");
c.ok(&["push", "A goal", "--why", "it is needed"]);
c.ok(&["session", "end", "--hook"]);
c.ok(&["note", "1", "something turned up"]);
c.ok(&["session", "end", "--hook"]);
let v = c.ok(&["vivacs"]);
assert!(v.contains("1 note"), "it counted no notes:\n{v}");
assert!(!v.contains("1 notes"), "it said `1 notes`:\n{v}");
}
#[test]
fn a_segment_of_none_of_the_three_still_says_something() {
let c = Sandbox::new_seeded("other");
c.ok(&["push", "A goal", "--why", "it is needed"]);
c.ok(&["session", "end", "--hook"]);
c.ok(&["flag", "1", "review", "--why", "it needs a second look"]);
c.ok(&["session", "end", "--hook"]);
let v = c.ok(&["vivacs"]);
assert!(
v.contains("1 change"),
"the stop came out blank after a flag:\n{v}"
);
}
#[test]
fn the_spanish_spellings_of_the_session_hooks_are_rejected() {
let c = Sandbox::new_seeded("spanish");
c.ok(&["push", "A goal", "--why", "it is needed"]);
for args in [["session", "inicio"], ["session", "fin"]] {
let (s, code) = c.run(&args);
assert_ne!(code, 0, "`vivac {}` was accepted:\n{s}", args.join(" "));
assert!(
s.contains("usage"),
"`vivac {}` failed without saying how:\n{s}",
args.join(" ")
);
}
}
#[test]
fn opening_a_session_leaves_a_trace() {
let c = Sandbox::new_seeded("opening");
c.ok(&["push", "A goal", "--why", "it is needed"]);
let (out, code) = c.run_stdin(
&["session", "start", "--hook"],
r#"{"session_id":"abc-123","source":"startup"}"#,
);
assert_eq!(code, 0, "the start hook failed:\n{out}");
assert!(
c.log().contains("session.started"),
"the opening left no trace:\n{}",
c.log()
);
}
#[test]
fn the_source_of_the_opening_travels_into_the_log() {
let c = Sandbox::new_seeded("source");
c.ok(&["push", "A goal", "--why", "it is needed"]);
c.run_stdin(
&["session", "start", "--hook"],
r#"{"session_id":"abc-123","source":"compact"}"#,
);
assert!(
c.log().contains(r#""source":"compact""#),
"the source did not survive:\n{}",
c.log()
);
}
#[test]
fn the_command_a_person_runs_writes_nothing() {
let c = Sandbox::new_seeded("pureread");
c.ok(&["push", "A goal", "--why", "it is needed"]);
let before = c.log();
c.ok(&["session", "start"]);
assert_eq!(before, c.log(), "the read path wrote to the log");
}
#[test]
fn a_broken_payload_still_opens_the_session() {
let c = Sandbox::new_seeded("garbage");
c.ok(&["push", "A goal", "--why", "it is needed"]);
let (out, code) = c.run_stdin(&["session", "start", "--hook"], "not json at all");
assert_eq!(code, 0, "garbage on stdin took the hook down:\n{out}");
assert!(
c.log().contains(r#""source":"unknown""#),
"an unsaid source has to read as `unknown`, not as empty:\n{}",
c.log()
);
}
#[test]
fn an_opening_does_not_arm_the_automatic_stop() {
let c = Sandbox::new_seeded("noarm");
c.ok(&["push", "A goal", "--why", "it is needed"]);
c.ok(&["session", "end", "--hook"]);
let before = how_many(&c.ok(&["vivacs"]), "auto");
c.run_stdin(
&["session", "start", "--hook"],
r#"{"session_id":"abc-123","source":"startup"}"#,
);
c.ok(&["session", "end", "--hook"]);
assert_eq!(
how_many(&c.ok(&["vivacs"]), "auto"),
before,
"the opening armed a stop for a session that did nothing"
);
}
#[test]
fn the_opening_records_what_the_brief_claimed() {
let c = Sandbox::new_seeded("claimed");
c.ok(&["push", "A goal", "--why", "it is needed"]);
c.run_stdin(
&["session", "start", "--hook"],
r#"{"session_id":"abc-123","source":"startup"}"#,
);
let log = c.log();
let opening = log
.lines()
.find(|l| l.contains("session.started"))
.unwrap_or_else(|| panic!("no opening in the log:\n{log}"));
assert!(
opening.contains(r#""session":"abc-123""#),
"the session identifier did not survive:\n{opening}"
);
assert!(
!opening.contains(r#""focus":null"#),
"the opening recorded no focus, and there was one:\n{opening}"
);
assert!(
!opening.contains(r#""vivac":null"#),
"the opening recorded no last stop, and there was one:\n{opening}"
);
}
#[test]
fn the_transcript_path_never_reaches_the_log() {
let c = Sandbox::new_seeded("noleak");
c.ok(&["push", "A goal", "--why", "it is needed"]);
c.run_stdin(
&["session", "start", "--hook"],
r#"{"session_id":"abc-123","source":"startup","transcript_path":"/home/someone/.claude/projects/x/y.jsonl","cwd":"/home/someone/work"}"#,
);
let log = c.log();
assert!(
!log.contains("someone"),
"a path out of the payload reached the log:\n{log}"
);
assert!(
!log.contains("transcript"),
"the transcript path reached the log:\n{log}"
);
}
#[test]
fn an_opening_with_no_focus_records_none() {
let c = Sandbox::new_seeded("nofocus");
c.run_stdin(
&["session", "start", "--hook"],
r#"{"session_id":"abc-123","source":"startup"}"#,
);
let log = c.log();
assert!(log.contains("session.started"), "no opening:\n{log}");
assert!(
log.contains(r#""focus":null"#),
"it invented a focus out of an empty stack:\n{log}"
);
}
#[test]
fn a_refused_payload_still_opens_the_session() {
let c = Sandbox::new_seeded("refused-open");
let (out, code) = c.run_stdin(
&["session", "start", "--hook"],
r#"{"session_id":"abc-123","source":"/home/someone/.claude/projects/x"}"#,
);
assert_eq!(code, 0, "a refused field took the hook down:\n{out}");
let log = c.log();
assert!(log.contains("session.started"), "no opening:\n{log}");
assert!(
!log.contains("someone"),
"the refused text reached the log:\n{log}"
);
assert!(
log.contains(r#""source":"refused: "#),
"the refused field was not replaced:\n{log}"
);
}
#[test]
fn the_refusal_names_the_rule_not_the_text() {
let c = Sandbox::new_seeded("refused-rule");
c.run_stdin(
&["session", "start", "--hook"],
r#"{"session_id":"abc-123","source":"/home/someone/.claude/projects/x"}"#,
);
let log = c.log();
assert!(
log.contains(r#""source":"refused: path to a user home directory (personal data)""#),
"the rule did not land in the field as written:\n{log}"
);
}
#[test]
fn a_refused_session_identifier_does_not_take_the_opening_down() {
let c = Sandbox::new_seeded("refused-session");
c.run_stdin(
&["session", "start", "--hook"],
r#"{"session_id":"someone@example.com","source":"startup"}"#,
);
let log = c.log();
assert!(log.contains("session.started"), "no opening:\n{log}");
assert!(
log.contains(r#""source":"startup""#),
"the clean field was touched by its refused sibling:\n{log}"
);
assert!(
!log.contains("someone@example.com"),
"the refused session identifier reached the log:\n{log}"
);
assert!(
log.contains(r#""session":"refused: email address (personal data)""#),
"the session field was not replaced:\n{log}"
);
}
#[test]
fn a_real_session_identifier_passes_through_untouched() {
let c = Sandbox::new_seeded("real-session");
c.run_stdin(
&["session", "start", "--hook"],
r#"{"session_id":"019316f8-4c2a-7b31-9f0e-8d1a2b3c4d5e","source":"startup"}"#,
);
let log = c.log();
assert!(
log.contains("019316f8-4c2a-7b31-9f0e-8d1a2b3c4d5e"),
"a real session identifier did not survive:\n{log}"
);
assert!(
!log.contains("refused:"),
"the guard fired on a real payload:\n{log}"
);
}
fn pasted_block(out: &str) -> serde_json::Value {
let start = out
.find('{')
.unwrap_or_else(|| panic!("no `{{` in:\n{out}"));
let mut depth = 0i32;
let mut end = None;
for (i, c) in out[start..].char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = Some(start + i + 1);
break;
}
}
_ => {}
}
}
let end = end.unwrap_or_else(|| panic!("the braces in the pasted block never close:\n{out}"));
serde_json::from_str(&out[start..end])
.unwrap_or_else(|e| panic!("the pasted block is not valid JSON: {e}\n{out}"))
}
#[test]
fn the_pasted_config_declares_the_matcher_that_covers_every_session_start_source() {
let c = Sandbox::new_empty("hooks-matcher");
let out = c.ok(&["hooks"]);
let v = pasted_block(&out);
let entry = &v["hooks"]["SessionStart"][0];
assert_eq!(
entry["matcher"], "*",
"the SessionStart entry does not declare a match-all matcher:\n{out}"
);
}
#[test]
fn the_pasted_config_leaves_stop_untouched() {
let c = Sandbox::new_empty("hooks-stop-untouched");
let out = c.ok(&["hooks"]);
let v = pasted_block(&out);
let entry = &v["hooks"]["Stop"][0];
assert!(
entry.get("matcher").is_none(),
"Stop grew a matcher nobody asked it to:\n{out}"
);
assert_eq!(
entry["hooks"][0]["command"], "vivac session end --hook",
"the Stop command changed:\n{out}"
);
}
#[test]
fn the_pasted_config_prose_explains_the_compaction_case() {
let c = Sandbox::new_empty("hooks-prose");
let out = c.ok(&["hooks"]);
assert!(
out.contains("compact"),
"the prose never mentions compaction, so the matcher looks arbitrary:\n{out}"
);
}