M5c-1 plan: task 04, bxctl chat --speaker NAME, opt-in, no default
The speaker goes on every turn of the chat: interactive, --say and --say's retry. Without the flag it is None. A name that is empty or holds whitespace or a control character (Unicode), or a second --speaker, is a usage error: the usage text on stderr and exit status 2. The usage text gains the flag and one line explaining it. Given: chat_speaker.rs (new) and new versions of chat.rs, chat_approvals.rs and cli.rs. Task 01 now copies chat.rs-task01, the older chat.rs. Checks: every before block found once at ref 03; replayed from the task text on a fresh ref-03 worktree with its own build directory, byte-identical to the reference, gate ok with 1158 tests (1148 + 10); /tmp unchanged around the reference's and the replay's gate runs; 20 of 20 mutations caught; the new tests passed 40 of 40 runs under CPU load; given files pass fmt and the 500-line check; no chat-template tag in the plan or the given files. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
//! Tests for `bxctl`'s command line. Do not edit.
|
||||
|
||||
use bxcore::SessionId;
|
||||
use bxctl::cli::{ChatOptions, Command, USAGE, UsageError, parse};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command as Process;
|
||||
|
||||
const HOME: &str = "/srv/bx";
|
||||
|
||||
fn args(words: &[&str]) -> Vec<String> {
|
||||
words.iter().map(|w| w.to_string()).collect()
|
||||
}
|
||||
|
||||
fn ok(words: &[&str]) -> Command {
|
||||
parse(&args(words), Path::new(HOME)).unwrap_or_else(|_| panic!("{words:?} must parse"))
|
||||
}
|
||||
|
||||
fn bad(words: &[&str]) {
|
||||
assert_eq!(
|
||||
parse(&args(words), Path::new(HOME)),
|
||||
Err(UsageError),
|
||||
"{words:?} must be a usage error"
|
||||
);
|
||||
}
|
||||
|
||||
fn default_admin() -> PathBuf {
|
||||
PathBuf::from("/srv/bx/run/owner-broker/admin.sock")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_defaults_come_from_home() {
|
||||
assert_eq!(
|
||||
ok(&["chat"]),
|
||||
Command::Chat(ChatOptions {
|
||||
socket: PathBuf::from("/srv/bx/run/loop/loop.sock"),
|
||||
admin_socket: default_admin(),
|
||||
session: None,
|
||||
show_thinking: true,
|
||||
say: None,
|
||||
json: false,
|
||||
speaker: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_takes_every_flag_in_any_order() {
|
||||
assert_eq!(
|
||||
ok(&[
|
||||
"chat",
|
||||
"--json",
|
||||
"--admin-socket",
|
||||
"/tmp/a.sock",
|
||||
"--say",
|
||||
"hello there",
|
||||
"--no-thinking",
|
||||
"--session",
|
||||
"s-1",
|
||||
"--speaker",
|
||||
"kyle",
|
||||
"--socket",
|
||||
"/tmp/l.sock",
|
||||
]),
|
||||
Command::Chat(ChatOptions {
|
||||
socket: PathBuf::from("/tmp/l.sock"),
|
||||
admin_socket: PathBuf::from("/tmp/a.sock"),
|
||||
session: Some(SessionId::new("s-1").unwrap()),
|
||||
show_thinking: false,
|
||||
say: Some("hello there".to_string()),
|
||||
json: true,
|
||||
speaker: Some("kyle".to_string()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_usage_errors() {
|
||||
bad(&["chat", "--session", "Not Valid!"]);
|
||||
bad(&["chat", "--session"]);
|
||||
bad(&["chat", "--socket"]);
|
||||
bad(&["chat", "--admin-socket"]);
|
||||
bad(&["chat", "--say"]);
|
||||
bad(&["chat", "--dance"]);
|
||||
bad(&["chat", "stray"]);
|
||||
}
|
||||
|
||||
/// `chat` with only `--speaker NAME`: everything else at its default.
|
||||
fn chat_as(name: &str) -> Command {
|
||||
let mut opts = ChatOptions::default(Path::new(HOME));
|
||||
opts.speaker = Some(name.to_string());
|
||||
Command::Chat(opts)
|
||||
}
|
||||
|
||||
/// The speaker is the argument after `--speaker`, kept as it is: a `.` is not mapped, letters
|
||||
/// outside ASCII are kept (`memoryd` checks Honcho's pattern, not `bxctl`), and a value that
|
||||
/// looks like a flag is a value.
|
||||
#[test]
|
||||
fn chat_speaker_is_the_value_after_the_flag() {
|
||||
assert_eq!(ok(&["chat", "--speaker", "kyle"]), chat_as("kyle"));
|
||||
assert_eq!(
|
||||
ok(&["chat", "--speaker", "first.last"]),
|
||||
chat_as("first.last")
|
||||
);
|
||||
assert_eq!(ok(&["chat", "--speaker", "zo\u{eb}"]), chat_as("zo\u{eb}"));
|
||||
assert_eq!(ok(&["chat", "--speaker", "--json"]), chat_as("--json"));
|
||||
assert_eq!(ok(&["chat", "--speaker", "k"]), chat_as("k"));
|
||||
let Command::Chat(opts) = ok(&["chat", "--speaker", "kyle", "--json"]) else {
|
||||
panic!("not chat")
|
||||
};
|
||||
assert_eq!(opts.speaker.as_deref(), Some("kyle"));
|
||||
assert!(opts.json, "the flag after the name is read as a flag");
|
||||
}
|
||||
|
||||
/// A speaker that is empty, or holds whitespace or a control character anywhere, is a usage
|
||||
/// error; so are a missing value and a second `--speaker`, even with the same name.
|
||||
#[test]
|
||||
fn a_bad_speaker_is_a_usage_error() {
|
||||
for name in [
|
||||
"",
|
||||
" ",
|
||||
"first last",
|
||||
" kyle",
|
||||
"kyle ",
|
||||
"kyle\t",
|
||||
"kyle\n",
|
||||
"\rkyle",
|
||||
"ky\u{a0}le",
|
||||
"ky\u{2003}le",
|
||||
"ky\u{3000}le",
|
||||
"ky\u{7}le",
|
||||
"ky\u{1b}le",
|
||||
"ky\u{7f}le",
|
||||
"ky\u{85}le",
|
||||
"ky\u{9b}le",
|
||||
] {
|
||||
bad(&["chat", "--speaker", name]);
|
||||
}
|
||||
bad(&["chat", "--speaker"]);
|
||||
bad(&["chat", "--json", "--speaker"]);
|
||||
bad(&["chat", "--speaker", "kyle", "--speaker", "eve"]);
|
||||
bad(&["chat", "--speaker", "kyle", "--speaker", "kyle"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approvals() {
|
||||
assert_eq!(
|
||||
ok(&["approvals"]),
|
||||
Command::Approvals {
|
||||
admin_socket: default_admin()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
ok(&["approvals", "--admin-socket", "/tmp/a.sock"]),
|
||||
Command::Approvals {
|
||||
admin_socket: PathBuf::from("/tmp/a.sock")
|
||||
}
|
||||
);
|
||||
bad(&["approvals", "41"]);
|
||||
bad(&["approvals", "--admin-socket"]);
|
||||
bad(&["approvals", "--reason", "x"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approve() {
|
||||
assert_eq!(
|
||||
ok(&["approve", "41"]),
|
||||
Command::Approve {
|
||||
admin_socket: default_admin(),
|
||||
approval: 41
|
||||
}
|
||||
);
|
||||
// The flag may come before or after the id.
|
||||
for words in [
|
||||
["approve", "--admin-socket", "/tmp/a.sock", "41"],
|
||||
["approve", "41", "--admin-socket", "/tmp/a.sock"],
|
||||
] {
|
||||
assert_eq!(
|
||||
ok(&words),
|
||||
Command::Approve {
|
||||
admin_socket: PathBuf::from("/tmp/a.sock"),
|
||||
approval: 41
|
||||
}
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
ok(&["approve", "18446744073709551615"]),
|
||||
Command::Approve {
|
||||
admin_socket: default_admin(),
|
||||
approval: u64::MAX
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// An id is decimal digits and nothing else. `str::parse::<u64>` alone would accept `+41`.
|
||||
#[test]
|
||||
fn an_approval_id_is_only_digits() {
|
||||
bad(&["approve"]);
|
||||
bad(&["approve", "41", "42"]);
|
||||
bad(&["approve", "+41"]);
|
||||
bad(&["approve", "-1"]);
|
||||
bad(&["approve", "4 1"]);
|
||||
bad(&["approve", " 41"]);
|
||||
bad(&["approve", "0x29"]);
|
||||
bad(&["approve", "forty-one"]);
|
||||
bad(&["approve", ""]);
|
||||
bad(&["approve", "18446744073709551616"]);
|
||||
bad(&["approve", "41", "--reason", "x"]);
|
||||
bad(&["refuse"]);
|
||||
bad(&["refuse", "+41"]);
|
||||
bad(&["refuse", "41", "42"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuse() {
|
||||
assert_eq!(
|
||||
ok(&["refuse", "41"]),
|
||||
Command::Refuse {
|
||||
admin_socket: default_admin(),
|
||||
approval: 41,
|
||||
reason: None
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
ok(&[
|
||||
"refuse",
|
||||
"41",
|
||||
"--reason",
|
||||
"not on a Friday",
|
||||
"--admin-socket",
|
||||
"/tmp/a.sock"
|
||||
]),
|
||||
Command::Refuse {
|
||||
admin_socket: PathBuf::from("/tmp/a.sock"),
|
||||
approval: 41,
|
||||
reason: Some("not on a Friday".to_string())
|
||||
}
|
||||
);
|
||||
// A value is a value, even when it looks like a flag.
|
||||
assert_eq!(
|
||||
ok(&["refuse", "--reason", "--admin-socket", "41"]),
|
||||
Command::Refuse {
|
||||
admin_socket: default_admin(),
|
||||
approval: 41,
|
||||
reason: Some("--admin-socket".to_string())
|
||||
}
|
||||
);
|
||||
bad(&["refuse", "41", "--reason"]);
|
||||
bad(&["refuse", "41", "--reason", "a", "--reason", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grants_check_and_audit_verify() {
|
||||
assert_eq!(
|
||||
ok(&["grants", "check"]),
|
||||
Command::GrantsCheck {
|
||||
admin_socket: default_admin()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
ok(&["grants", "check", "--admin-socket", "/tmp/a.sock"]),
|
||||
Command::GrantsCheck {
|
||||
admin_socket: PathBuf::from("/tmp/a.sock")
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
ok(&["audit", "verify"]),
|
||||
Command::AuditVerify {
|
||||
home: PathBuf::from(HOME)
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
ok(&["audit", "verify", "--home", "/tmp/h"]),
|
||||
Command::AuditVerify {
|
||||
home: PathBuf::from("/tmp/h")
|
||||
}
|
||||
);
|
||||
bad(&["grants"]);
|
||||
bad(&["grants", "list"]);
|
||||
bad(&["grants", "check", "extra"]);
|
||||
bad(&["audit"]);
|
||||
bad(&["audit", "verify", "--home"]);
|
||||
bad(&["audit", "verify", "--admin-socket", "/tmp/a.sock"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anything_else_is_a_usage_error() {
|
||||
bad(&[]);
|
||||
bad(&["dance"]);
|
||||
bad(&["--admin-socket", "/tmp/a.sock", "approvals"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_names_every_command() {
|
||||
for word in [
|
||||
"bxctl chat",
|
||||
"bxctl approvals",
|
||||
"bxctl approve <id>",
|
||||
"bxctl refuse <id>",
|
||||
"bxctl grants check",
|
||||
"bxctl audit verify",
|
||||
"--admin-socket",
|
||||
"--reason",
|
||||
"--home",
|
||||
] {
|
||||
assert!(USAGE.contains(word), "usage lacks {word:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The chat lines of the usage text, exactly, and the indentation of every line: two spaces
|
||||
/// before a command, six before its explanation.
|
||||
#[test]
|
||||
fn usage_explains_the_speaker_flag() {
|
||||
let chat = concat!(
|
||||
"\n bxctl chat [--socket PATH] [--admin-socket PATH] [--session ID] [--say TEXT] ",
|
||||
"[--no-thinking] [--json] [--speaker NAME]\n",
|
||||
" Talk to loopd.\n",
|
||||
" With --speaker NAME (no spaces), the turns are NAME's and are synced to Honcho as ",
|
||||
"that person.\n\n bxctl approvals ",
|
||||
);
|
||||
assert!(USAGE.contains(chat), "{USAGE}");
|
||||
for line in USAGE.lines().skip(1) {
|
||||
let command = line.starts_with(" bxctl ");
|
||||
let explanation = line.starts_with(" ") && !line.starts_with(" ");
|
||||
assert!(
|
||||
line.is_empty() || command || explanation,
|
||||
"badly indented: {line:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_binary_prints_usage_and_exits_2() {
|
||||
for words in [
|
||||
vec![],
|
||||
vec!["dance"],
|
||||
vec!["approve", "+41"],
|
||||
vec!["refuse"],
|
||||
] {
|
||||
let output = Process::new(env!("CARGO_BIN_EXE_bxctl"))
|
||||
.args(&words)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(2), "{words:?}");
|
||||
assert_eq!(String::from_utf8_lossy(&output.stdout), "", "{words:?}");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output.stderr).contains("bxctl approve <id>"),
|
||||
"{words:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The default sockets are under `$BOXMAKER_HOME`. Nothing listens there, so the command fails
|
||||
/// to connect, and says where it tried.
|
||||
#[test]
|
||||
fn the_binary_finds_the_admin_socket_under_boxmaker_home() {
|
||||
let home = std::env::temp_dir().join(format!("bxctl-cli-home-{}", std::process::id()));
|
||||
let output = Process::new(env!("CARGO_BIN_EXE_bxctl"))
|
||||
.arg("approvals")
|
||||
.env("BOXMAKER_HOME", &home)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let want = home.join("run/owner-broker/admin.sock");
|
||||
assert!(stderr.contains(want.to_str().unwrap()), "{stderr}");
|
||||
}
|
||||
Reference in New Issue
Block a user