use std::collections::{HashMap, HashSet};
use std::fmt::Write as _;
use serde_json::{json, Map, Value};
pub const FORMAT_KEY: &str = "jdwp_mcp_stop_point_set";
pub const FORMAT_VERSION: u64 = 1;
pub const ARMABLE_TOOLS: [&str; 5] = [
"debug.set_line_stop",
"debug.set_exception_stop",
"debug.set_field_stop",
"debug.set_method_exit_stop",
"debug.set_monitor_stop",
];
#[derive(Debug, Clone)]
pub struct SetEntry {
pub tool: String,
pub enabled: bool,
pub args: Value,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Dropped {
pub instance: Vec<String>,
pub thread: Vec<String>,
pub undescribable: Vec<String>,
}
#[derive(Debug)]
pub struct Export {
pub set: Value,
pub entries: usize,
pub disabled: usize,
pub suspending: usize,
pub dropped: Dropped,
}
fn id_order(id: &str) -> (String, u64) {
let cut = id.rfind('_').map_or(id.len(), |i| i + 1);
let (prefix, digits) = id.split_at(cut);
(prefix.to_string(), digits.parse().unwrap_or(u64::MAX))
}
fn sorted_by_id<T>(map: &HashMap<String, T>) -> Vec<(&String, &T)> {
let mut rows: Vec<_> = map.iter().collect();
rows.sort_by_key(|(id, _)| id_order(id));
rows
}
#[allow(clippy::too_many_arguments)]
fn write_common(
args: &mut Map<String, Value>,
hit_count: Option<i32>,
condition: Option<&str>,
trace: bool,
trace_expr: &[String],
trace_budget: Option<u32>,
trace_frames: usize,
trace_max_length: Option<usize>,
) {
args.insert("trace".to_string(), json!(trace));
args.insert("trace_frames".to_string(), json!(trace_frames));
if let Some(n) = hit_count {
args.insert("hit_count".to_string(), json!(n));
}
if let Some(c) = condition {
args.insert("condition".to_string(), json!(c));
}
if !trace_expr.is_empty() {
args.insert("trace_expr".to_string(), json!(trace_expr));
}
if let Some(n) = trace_budget {
args.insert("trace_max_hits".to_string(), json!(n));
}
if let Some(n) = trace_max_length {
args.insert("trace_max_length".to_string(), json!(n));
}
}
pub fn export(session: &crate::session::DebugSession) -> Export {
let mut b = Builder::default();
let members = b.push_families(session);
b.push_breakpoints(session, &members);
b.push_pending(session);
b.push_exceptions(session);
b.push_watchpoints(session);
b.push_method_exits(session);
b.push_monitors(session);
b.finish()
}
#[derive(Default)]
struct Builder {
entries: Vec<Value>,
dropped: Dropped,
disabled: usize,
suspending: usize,
}
impl Builder {
fn push(&mut self, tool: &str, id: &str, enabled: bool, trace: bool, args: Map<String, Value>) {
if !enabled {
self.disabled += 1;
}
if !trace {
self.suspending += 1;
}
self.entries.push(json!({"tool": tool, "from": id, "enabled": enabled, "args": Value::Object(args)}));
}
fn drop_filters(&mut self, id: &str, instance: Option<u64>, thread: Option<u64>) {
if instance.is_some() {
self.dropped.instance.push(id.to_string());
}
if thread.is_some() {
self.dropped.thread.push(id.to_string());
}
}
fn finish(self) -> Export {
let count = self.entries.len();
Export {
set: json!({FORMAT_KEY: FORMAT_VERSION, "entries": self.entries}),
entries: count,
disabled: self.disabled,
suspending: self.suspending,
dropped: self.dropped,
}
}
fn push_families<'a>(&mut self, session: &'a crate::session::DebugSession) -> HashSet<&'a String> {
let mut members: HashSet<&String> = HashSet::new();
for (id, fam) in sorted_by_id(&session.pattern_sets) {
members.extend(fam.members.iter());
let line = fam.members.iter().find_map(|m| session.breakpoints.get(m)).and_then(|b| b.arm_line);
if line.is_none() && fam.method.is_none() {
self.dropped.undescribable.push(id.clone());
continue;
}
let mut args = Map::new();
args.insert("class_pattern".to_string(), json!(fam.class_pattern));
if let Some(l) = line {
args.insert("line".to_string(), json!(l));
}
if let Some(m) = &fam.method {
args.insert("method".to_string(), json!(m));
}
args.insert("max_classes".to_string(), json!(fam.max_classes));
write_common(
&mut args,
fam.hit_count,
fam.condition.as_deref(),
fam.trace,
&fam.trace_expr,
fam.trace_budget,
fam.trace_frames,
fam.trace_max_length,
);
self.drop_filters(id, fam.instance_filter, fam.thread_filter);
self.push("debug.set_line_stop", id, fam.enabled, fam.trace, args);
}
members
}
fn push_breakpoints(
&mut self,
session: &crate::session::DebugSession,
family_members: &HashSet<&String>,
) {
for (id, bp) in sorted_by_id(&session.breakpoints) {
if family_members.contains(id) {
continue;
}
if bp.arm_line.is_none() && bp.arm_method.is_none() {
self.dropped.undescribable.push(id.clone());
continue;
}
let mut args = Map::new();
args.insert("class_pattern".to_string(), json!(bp.class_pattern));
if let Some(l) = bp.arm_line {
args.insert("line".to_string(), json!(l));
}
if let Some(m) = &bp.arm_method {
args.insert("method".to_string(), json!(m));
}
write_common(
&mut args,
bp.arm.hit_count,
bp.condition.as_deref(),
bp.trace,
&bp.trace_expr,
bp.trace_budget,
bp.trace_frames,
bp.trace_max_length,
);
self.drop_filters(id, bp.arm.instance_filter, bp.arm.thread_filter);
self.push("debug.set_line_stop", id, bp.enabled && !bp.spent, bp.trace, args);
}
}
fn push_pending(&mut self, session: &crate::session::DebugSession) {
for pb in &session.pending_breakpoints {
let mut args = Map::new();
args.insert("class_pattern".to_string(), json!(pb.class_pattern));
if let Some(l) = pb.line {
args.insert("line".to_string(), json!(l));
}
if let Some(m) = &pb.method {
args.insert("method".to_string(), json!(m));
}
write_common(
&mut args,
pb.hit_count,
pb.condition.as_deref(),
pb.trace,
&pb.trace_expr,
pb.trace_budget,
pb.trace_frames,
pb.trace_max_length,
);
self.drop_filters(&pb.bp_id, pb.instance_filter, pb.thread_filter);
self.push("debug.set_line_stop", &pb.bp_id, true, pb.trace, args);
}
}
fn push_exceptions(&mut self, session: &crate::session::DebugSession) {
for (id, er) in sorted_by_id(&session.exception_requests) {
let mut args = Map::new();
if !er.class_pattern.is_empty() {
args.insert("class_pattern".to_string(), json!(er.class_pattern));
}
args.insert("caught".to_string(), json!(er.caught));
args.insert("uncaught".to_string(), json!(er.uncaught));
write_common(
&mut args,
er.hit_count,
er.condition.as_deref(),
er.trace,
&er.trace_expr,
er.trace_budget,
er.trace_frames,
er.trace_max_length,
);
self.drop_filters(id, er.instance_filter, er.thread_filter);
self.push("debug.set_exception_stop", id, er.enabled && !er.spent, er.trace, args);
}
}
fn push_watchpoints(&mut self, session: &crate::session::DebugSession) {
for (id, wp) in sorted_by_id(&session.watchpoints) {
let mut args = Map::new();
args.insert("class_name".to_string(), json!(wp.class_name));
args.insert("field_name".to_string(), json!(wp.field_name));
args.insert("modify".to_string(), json!(wp.kind == jdwp_client::WatchKind::Modify));
args.insert("access".to_string(), json!(wp.kind == jdwp_client::WatchKind::Access));
write_common(
&mut args,
wp.hit_count,
wp.condition.as_deref(),
wp.trace,
&wp.trace_expr,
wp.trace_budget,
wp.trace_frames,
wp.trace_max_length,
);
self.drop_filters(id, wp.instance_filter, wp.thread_filter);
self.push("debug.set_field_stop", id, wp.enabled && !wp.spent, wp.trace, args);
}
}
fn push_method_exits(&mut self, session: &crate::session::DebugSession) {
for (id, me) in sorted_by_id(&session.method_exits) {
let mut args = Map::new();
args.insert("class_pattern".to_string(), json!(me.class_pattern));
if let Some(m) = &me.method {
args.insert("method".to_string(), json!(m));
}
if !me.exclude_classes.is_empty() {
args.insert("exclude_classes".to_string(), json!(me.exclude_classes));
}
write_common(
&mut args,
me.hit_count,
me.condition.as_deref(),
me.trace,
&me.trace_expr,
me.trace_budget,
me.trace_frames,
me.trace_max_length,
);
self.drop_filters(id, me.instance_filter, me.thread_filter);
self.push("debug.set_method_exit_stop", id, me.enabled && !me.spent, me.trace, args);
}
}
fn push_monitors(&mut self, session: &crate::session::DebugSession) {
let mut groups: Vec<(String, Vec<&crate::session::MonitorRequestInfo>)> = Vec::new();
for (id, mon) in sorted_by_id(&session.monitor_requests) {
let key = format!(
"{:?}|{:?}|{:?}|{}|{:?}|{}|{:?}|{:?}",
mon.thread_filter,
mon.monitor_class,
mon.min_duration_ms,
mon.trace,
mon.hit_count,
mon.trace_frames,
mon.trace_max_length,
mon.trace_expr
);
if let Some((_, found)) = groups.iter_mut().find(|(k, _)| *k == key) {
found.push(mon);
} else {
groups.push((key, vec![mon]));
}
self.drop_filters(id, None, mon.thread_filter);
}
for (_, members) in groups {
let Some(first) = members.first() else { continue };
let mut args = Map::new();
let kinds: Vec<&str> = members.iter().map(|m| m.kind.label()).collect();
args.insert("kinds".to_string(), json!(kinds));
if let Some(c) = &first.monitor_class {
args.insert("monitor_class".to_string(), json!(c));
}
if let Some(n) = first.min_duration_ms {
args.insert("min_duration_ms".to_string(), json!(n));
}
write_common(
&mut args,
first.hit_count,
None,
first.trace,
&first.trace_expr,
first.trace_budget,
first.trace_frames,
first.trace_max_length,
);
let enabled = members.iter().all(|m| m.enabled && !m.spent);
let ids = members.iter().map(|m| m.id.as_str()).collect::<Vec<_>>().join("+");
self.push("debug.set_monitor_stop", &ids, enabled, first.trace, args);
}
}
}
pub fn parse(raw: &str) -> Result<Vec<SetEntry>, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(
"The `set` argument is empty. Pass the block that debug.list_stop_points {export: true} \
returned."
.to_string(),
);
}
let unfenced = strip_code_fence(trimmed);
let root: Value = serde_json::from_str(unfenced).map_err(|e| {
format!(
"The `set` argument is not JSON ({e}). Pass the block that debug.list_stop_points \
{{export: true}} returned, not the rendered listing."
)
})?;
let Some(obj) = root.as_object() else {
return Err(format!(
"A stop-point set is a JSON object with a `{FORMAT_KEY}` key. This is a {}.",
type_name_of(&root)
));
};
let Some(version) = obj.get(FORMAT_KEY) else {
return Err(format!(
"This JSON has no `{FORMAT_KEY}` key, so it is not a stop-point set. Keys present: {}. Export \
one with debug.list_stop_points {{export: true}}.",
obj.keys().take(8).cloned().collect::<Vec<_>>().join(", ")
));
};
match version.as_u64() {
Some(v) if v == FORMAT_VERSION => {}
Some(v) if v > FORMAT_VERSION => {
return Err(format!(
"This set is format version {v} and this server reads version {FORMAT_VERSION}. It was \
exported by a NEWER build, so re-arming it here could arm something other than what it \
describes. Export a fresh set from this build."
));
}
_ => {
return Err(format!(
"`{FORMAT_KEY}` should be the format version as a number; it is {}.",
type_name_of(version)
));
}
}
let entries = obj
.get("entries")
.and_then(Value::as_array)
.ok_or_else(|| "A stop-point set needs an `entries` array.".to_string())?;
let mut out = Vec::with_capacity(entries.len());
for (i, raw) in entries.iter().enumerate() {
out.push(parse_entry(i, raw)?);
}
if out.is_empty() {
return Err("This set has no entries, so there is nothing to arm. That is what an export of a \
session with no stop points looks like."
.to_string());
}
Ok(out)
}
fn parse_entry(i: usize, raw: &Value) -> Result<SetEntry, String> {
let obj = raw.as_object().ok_or_else(|| format!("entries[{i}] is not an object."))?;
let tool = obj
.get("tool")
.and_then(Value::as_str)
.ok_or_else(|| format!("entries[{i}] has no `tool` name."))?
.to_string();
if !ARMABLE_TOOLS.contains(&tool.as_str()) {
return Err(format!(
"entries[{i}] names `{tool}`, which debug.arm_stop_points will not call. A set may only arm stop \
points, and the tools it may name are: {}. This is a whitelist rather than a check on this one \
name — without it, a set would be a way to invoke anything in this server from a blob of JSON.",
ARMABLE_TOOLS.join(", ")
));
}
let args = obj.get("args").cloned().unwrap_or_else(|| json!({}));
if !args.is_object() {
return Err(format!("entries[{i}]'s `args` is not an object."));
}
Ok(SetEntry {
tool,
enabled: obj.get("enabled").and_then(Value::as_bool).unwrap_or(true),
args,
})
}
fn strip_code_fence(s: &str) -> &str {
let Some(rest) = s.strip_prefix("```") else { return s };
let body = rest.split_once('\n').map_or("", |(_, b)| b);
body.trim().strip_suffix("```").unwrap_or(body).trim()
}
const fn type_name_of(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ArmOutcome {
Armed,
Deferred,
Refused(String),
SkippedDisabled,
}
pub fn describe_arm_outcomes(outcomes: &[(String, ArmOutcome)]) -> String {
let count = |want: &ArmOutcome| outcomes.iter().filter(|(_, o)| o == want).count();
let armed = count(&ArmOutcome::Armed);
let deferred = count(&ArmOutcome::Deferred);
let skipped = count(&ArmOutcome::SkippedDisabled);
let refused = outcomes.iter().filter(|(_, o)| matches!(o, ArmOutcome::Refused(_))).count();
let mut out = format!("{armed} armed, {deferred} deferred, {refused} refused");
if skipped > 0 {
let _ = write!(out, ", {skipped} skipped (disabled when exported)");
}
out.push('\n');
for (label, outcome) in outcomes {
match outcome {
ArmOutcome::Armed => {}
ArmOutcome::Deferred => {
let _ = writeln!(
out,
" ⏳ {label} — deferred: the class is not loaded yet, so it is armed on a class-load \
watch and fires when it is."
);
}
ArmOutcome::Refused(why) => {
let _ = writeln!(out, " 🛑 {label} — refused: {why}");
}
ArmOutcome::SkippedDisabled => {
let _ = writeln!(
out,
" ⏸ {label} — NOT armed: it was disabled or spent when the set was exported. Its \
arguments are still in the set, so arm it with the tool named there if you want it back."
);
}
}
}
out
}
pub fn describe_unverified_lines(armed: usize) -> String {
if armed == 0 {
return String::new();
}
"\n📐 Lines were NOT checked against the loaded bytecode. A set carries line numbers, which are a claim \
about the build it was exported from — against a redeployed one they resolve to whatever is now on that \
line, and arming reports success either way. Run debug.check_stale to settle it.\n"
.to_string()
}
pub fn describe_suspending(count: usize) -> String {
if count == 0 {
return String::new();
}
format!(
"\n⚠️ {count} of these are SUSPENDING stop points. On a shared JVM this one call is enough to freeze \
it on the next hit of any of them — which is more exposure than arming them one at a time, where you \
read a reply between each. The watchdog still applies. Consider trace:true in the set instead.\n"
)
}
pub fn describe_dropped(dropped: &Dropped) -> String {
let mut out = String::new();
if !dropped.instance.is_empty() {
let _ = write!(
out,
"\n⚠️ Instance filter DROPPED from {}: {}. A JDWP object id is a weak reference to one object in \
one JVM (ADR-0022), so it cannot mean anything in the next one — carrying it over would scope the \
stop point to whatever now lives at that address. These entries are therefore BROADER than what \
you exported. Take a fresh handle from debug.list_instances and re-apply it.\n",
plural(dropped.instance.len(), "stop point"),
dropped.instance.join(", ")
);
}
if !dropped.thread.is_empty() {
let _ = write!(
out,
"\n⚠️ Thread filter DROPPED from {}: {}. A JDWP thread id belongs to one JVM for the same reason, \
and a pool that retires idle workers invalidates it even within one. These entries are BROADER \
than what you exported. Read debug.list_threads for a live id and re-apply it.\n",
plural(dropped.thread.len(), "stop point"),
dropped.thread.join(", ")
);
}
if !dropped.undescribable.is_empty() {
let _ = write!(
out,
"\n⚠️ NOT exported: {}. A wildcard family whose members are all gone and which names no method \
has no line and no method left to re-arm from, so there is nothing to write down. Re-arm it with \
debug.set_line_stop.\n",
dropped.undescribable.join(", ")
);
}
out
}
fn plural(n: usize, noun: &str) -> String {
if n == 1 {
format!("{n} {noun}")
} else {
format!("{n} {noun}s")
}
}
fn entries_phrase(n: usize) -> String {
if n == 1 {
"1 entry".to_string()
} else {
format!("{n} entries")
}
}
pub fn render_export(export: &Export) -> String {
let mut out = format!("📦 Stop-point set exported — {}.\n", entries_phrase(export.entries));
if export.disabled > 0 {
let _ = writeln!(
out,
" {} of them were disabled or spent, and are recorded as such: debug.arm_stop_points will NOT \
arm those.",
export.disabled
);
}
if export.suspending > 0 {
let _ = writeln!(
out,
" {} of them are SUSPENDING. Arming this set later is one call that can freeze a shared JVM on \
the next hit of any of them — worth knowing now, while you still have the chance to re-arm them \
with trace:true and export again.",
export.suspending
);
}
out.push_str(&describe_dropped(&export.dropped));
let _ = write!(
out,
"\nNothing was written to disk — this server has no filesystem write path (BP-8). Store the block \
below and pass it back verbatim as debug.arm_stop_points {{\"set\": \"…\"}}.\n\n```json\n{}\n```\n",
serde_json::to_string_pretty(&export.set).unwrap_or_else(|_| "{}".to_string())
);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn anything_that_is_not_a_set_is_refused_with_what_it_was_instead() {
assert!(parse("").unwrap_err().contains("empty"));
assert!(parse("not json at all").unwrap_err().contains("not JSON"));
let listing = parse("📍 2 breakpoint(s), 0 deferred").unwrap_err();
assert!(
listing.contains("rendered listing"),
"the refusal should name the likely mistake: {listing}"
);
let wrong_shape = parse("[1,2,3]").unwrap_err();
assert!(wrong_shape.contains("array"), "it must say what was passed: {wrong_shape}");
let no_key = parse(r#"{"entries":[]}"#).unwrap_err();
assert!(no_key.contains(FORMAT_KEY) && no_key.contains("entries"), "{no_key}");
}
#[test]
fn a_newer_format_version_is_refused_and_an_older_one_is_not_invented() {
let newer = format!(r#"{{"{FORMAT_KEY}":{},"entries":[]}}"#, FORMAT_VERSION + 1);
let err = parse(&newer).unwrap_err();
assert!(err.contains("NEWER build"), "{err}");
assert!(err.contains(&(FORMAT_VERSION + 1).to_string()), "it must quote the version it saw: {err}");
let not_a_number = format!(r#"{{"{FORMAT_KEY}":"1","entries":[]}}"#);
assert!(parse(¬_a_number).unwrap_err().contains("string"));
}
#[test]
fn a_set_may_only_name_the_five_arming_tools() {
let sneaky = format!(
r#"{{"{FORMAT_KEY}":{FORMAT_VERSION},"entries":[{{"tool":"debug.disconnect","args":{{}}}}]}}"#
);
let err = parse(&sneaky).unwrap_err();
assert!(err.contains("debug.disconnect"), "it must quote the tool it refused: {err}");
assert!(err.contains("whitelist"), "and say it is a whitelist, not a check on this name: {err}");
for tool in ARMABLE_TOOLS {
assert!(err.contains(tool), "the refusal lists what IS allowed, so {tool} must appear: {err}");
}
for tool in ARMABLE_TOOLS {
let ok =
format!(r#"{{"{FORMAT_KEY}":{FORMAT_VERSION},"entries":[{{"tool":"{tool}","args":{{}}}}]}}"#);
assert_eq!(parse(&ok).expect("must parse").len(), 1, "{tool}");
}
}
#[test]
fn the_block_is_accepted_with_or_without_the_fence_it_was_shown_in() {
let bare = format!(
r#"{{"{FORMAT_KEY}":{FORMAT_VERSION},"entries":[{{"tool":"debug.set_line_stop","args":{{}}}}]}}"#
);
let fenced = format!("```json\n{bare}\n```");
assert_eq!(parse(&bare).expect("bare").len(), 1);
assert_eq!(parse(&fenced).expect("fenced").len(), 1, "our own rendering must round-trip");
}
#[test]
fn a_missing_enabled_flag_means_enabled() {
let set = format!(
r#"{{"{FORMAT_KEY}":{FORMAT_VERSION},"entries":[
{{"tool":"debug.set_line_stop","args":{{}}}},
{{"tool":"debug.set_line_stop","enabled":false,"args":{{}}}}]}}"#
);
let entries = parse(&set).expect("parse");
assert!(entries[0].enabled, "absent means enabled");
assert!(!entries[1].enabled, "and an explicit false is honoured");
}
#[test]
fn an_empty_entry_list_is_refused_rather_than_armed_as_nothing() {
let empty = format!(r#"{{"{FORMAT_KEY}":{FORMAT_VERSION},"entries":[]}}"#);
assert!(parse(&empty).unwrap_err().contains("nothing to arm"));
}
#[test]
fn arm_outcomes_lead_with_counts_and_then_name_everything_that_is_not_armed() {
let outcomes = vec![
("bp_1 com.x.Foo:42".to_string(), ArmOutcome::Armed),
("bp_2 com.x.Bar:7".to_string(), ArmOutcome::Deferred),
("bp_3 com.x.Gone:1".to_string(), ArmOutcome::Refused("no such line".to_string())),
("bp_4 com.x.Off:3".to_string(), ArmOutcome::SkippedDisabled),
];
let out = describe_arm_outcomes(&outcomes);
assert!(out.starts_with("1 armed, 1 deferred, 1 refused"), "counts first: {out}");
assert!(out.contains("1 skipped"), "and the skipped count when there is one: {out}");
assert!(!out.contains("bp_1"), "an armed entry needs no line of its own: {out}");
for id in ["bp_2", "bp_3", "bp_4"] {
assert!(out.contains(id), "{id} must be named: {out}");
}
assert!(out.contains("no such line"), "a refusal carries the tool's own reason: {out}");
}
#[test]
fn nothing_claims_the_lines_were_checked() {
assert_eq!(describe_unverified_lines(0), "", "nothing armed, so there is nothing to disclaim");
let note = describe_unverified_lines(3);
assert!(note.contains("check_stale"), "it must name the tool that CAN answer it: {note}");
assert!(note.contains("NOT checked"), "and be unambiguous that it did not: {note}");
}
#[test]
fn a_dropped_filter_says_the_stop_point_is_now_broader() {
assert_eq!(describe_dropped(&Dropped::default()), "", "nothing dropped, nothing said");
let both = Dropped {
instance: vec!["bp_1".to_string()],
thread: vec!["bp_2".to_string(), "mexit_1".to_string()],
undescribable: vec![],
};
let out = describe_dropped(&both);
assert!(out.contains("bp_1") && out.contains("mexit_1"), "every id is named: {out}");
assert_eq!(out.matches("BROADER").count(), 2, "each half states the consequence: {out}");
assert!(
out.contains("list_instances") && out.contains("list_threads"),
"each names its own fix: {out}"
);
assert!(out.contains("1 stop point:") || out.contains("1 stop point"), "no `1 stop point(s)`: {out}");
assert!(!out.contains("point(s)"), "the plural is resolved, not deferred to the reader: {out}");
}
#[test]
fn a_set_of_suspending_stop_points_is_warned_about_and_not_refused() {
assert_eq!(describe_suspending(0), "");
let warn = describe_suspending(4);
assert!(warn.contains('4') && warn.contains("SUSPENDING"), "{warn}");
assert!(warn.contains("one call"), "the point is the multiplicity, not the kind: {warn}");
assert!(warn.contains("trace:true"), "and it names the cheaper alternative: {warn}");
}
#[test]
fn entries_are_ordered_by_number_and_not_by_string() {
let mut ids = vec!["bp_10", "bp_2", "mexit_1", "bp_1"];
ids.sort_by_key(|i| id_order(i));
assert_eq!(ids, vec!["bp_1", "bp_2", "bp_10", "mexit_1"]);
}
}