use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use warble_claude_code::ir::WarbleIr;
use warble_claude_code::{emit_claude_code, RenderFlavor};
const GENBI_DEFAULT_IR: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../genbi-default/ir.golden.json"
);
fn snapshot_root(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/snapshots")
.join(name)
}
const POSITION_DEPENDENT: &[&str] = &[".warble/interactive-launch.json"];
fn first_difference(committed: &str, emitted: &str) -> String {
let (left, right): (Vec<&str>, Vec<&str>) =
(committed.lines().collect(), emitted.lines().collect());
let at = (0..left.len().max(right.len()))
.find(|i| left.get(*i) != right.get(*i))
.unwrap_or(0);
let context = at.saturating_sub(2);
let mut out = format!("first difference at line {}:\n", at + 1);
for (label, side) in [("committed", &left), ("emitted ", &right)] {
for (offset, line) in side.iter().skip(context).take(at - context + 3).enumerate() {
let number = context + offset + 1;
let marker = if number == at + 1 { '>' } else { ' ' };
out.push_str(&format!(" {label} {marker}{number:>4} | {line}\n"));
}
}
out
}
fn read_tree(root: &Path) -> BTreeMap<String, String> {
fn walk(dir: &Path, base: &Path, into: &mut BTreeMap<String, String>) {
let mut entries = fs::read_dir(dir)
.unwrap_or_else(|e| panic!("read {}: {e}", dir.display()))
.map(|entry| entry.expect("dir entry").path())
.collect::<Vec<_>>();
entries.sort();
for path in entries {
if path.is_dir() {
walk(&path, base, into);
continue;
}
let relative = path
.strip_prefix(base)
.expect("path under base")
.to_string_lossy()
.replace('\\', "/");
if POSITION_DEPENDENT.contains(&relative.as_str()) {
continue;
}
let contents = fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
into.insert(relative, contents);
}
}
let mut tree = BTreeMap::new();
if root.is_dir() {
walk(root, root, &mut tree);
}
tree
}
fn write_snapshot(root: &Path, tree: &BTreeMap<String, String>) {
if root.exists() {
fs::remove_dir_all(root).expect("clear stale snapshot");
}
for (relative, contents) in tree {
let path = root.join(relative);
fs::create_dir_all(path.parent().expect("snapshot parent")).expect("create snapshot dir");
fs::write(&path, contents).unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
}
}
fn assert_snapshot(name: &str, target: &str, flavor: RenderFlavor) {
let raw = fs::read_to_string(GENBI_DEFAULT_IR).expect("read genbi-default golden IR");
let ir: WarbleIr = serde_json::from_str(&raw).expect("golden IR deserializes");
let out = tempfile::tempdir().expect("tempdir");
emit_claude_code(&ir, out.path(), target, flavor).expect("emit succeeds");
let emitted = read_tree(out.path());
assert!(
!emitted.is_empty(),
"dispatch emitted nothing — the snapshot would pass vacuously"
);
let root = snapshot_root(name);
if std::env::var_os("UPDATE_DISPATCH_SNAPSHOT").is_some() {
write_snapshot(&root, &emitted);
return;
}
let committed = read_tree(&root);
assert!(
!committed.is_empty(),
"no committed snapshot at {} — create it with UPDATE_DISPATCH_SNAPSHOT=1",
root.display()
);
let refresh = "UPDATE_DISPATCH_SNAPSHOT=1 cargo test -p warble-claude-code --test dispatch_snapshot_tests";
let added: Vec<&String> = emitted
.keys()
.filter(|k| !committed.contains_key(*k))
.collect();
let removed: Vec<&String> = committed
.keys()
.filter(|k| !emitted.contains_key(*k))
.collect();
assert!(
added.is_empty(),
"dispatch now emits {added:?} into every {target} agent directory, which the snapshot has \
never seen. Every session in that scope will read it — confirm that is intended, then refresh:\n \
{refresh}"
);
assert!(
removed.is_empty(),
"dispatch no longer emits {removed:?} for {target}; if that is intended, refresh:\n {refresh}"
);
for (relative, committed_contents) in &committed {
let emitted_contents = &emitted[relative];
assert!(
committed_contents == emitted_contents,
"the emitted content of {relative} changed for {target}. This is what every agent in \
that scope will read — review the change on its merits (and consider whether it warrants a manual \
accuracy run) before refreshing:\n {refresh}\n\n{}",
first_difference(committed_contents, emitted_contents)
);
}
}
#[test]
fn headless_dispatch_output_matches_the_committed_snapshot() {
assert_snapshot(
"genbi-default-headless",
"claude-code:headless",
RenderFlavor::Programmatic,
);
}
#[test]
fn interactive_dispatch_output_matches_the_committed_snapshot() {
assert_snapshot(
"genbi-default-interactive",
"claude-code:interactive",
RenderFlavor::Programmatic,
);
}
#[test]
fn every_snapshot_file_is_tracked_by_git() {
let snapshots = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots");
let listed = match std::process::Command::new("git")
.args(["ls-files", "--", "."])
.current_dir(&snapshots)
.output()
{
Ok(output) if output.status.success() => {
String::from_utf8_lossy(&output.stdout).to_string()
}
_ => {
eprintln!("skipping: git unavailable or not a repository");
return;
}
};
let tracked: std::collections::BTreeSet<String> = listed
.lines()
.map(|line| line.trim().to_string())
.filter(|line| !line.is_empty())
.collect();
let mut untracked = Vec::new();
for name in ["genbi-default-headless", "genbi-default-interactive"] {
for relative in read_tree(&snapshot_root(name)).keys() {
let from_snapshots = format!("{name}/{relative}");
if !tracked.contains(&from_snapshots) {
untracked.push(from_snapshots);
}
}
}
assert!(
untracked.is_empty(),
"these snapshot files are not tracked by git, so a fresh checkout would not have them and \
this gate would cover less than it looks like it does: {untracked:?}. Check .gitignore — dispatch \
emits into dot-directories that are ignored elsewhere in the repository on purpose."
);
}