#[must_use]
pub fn truncate_excerpt(s: &str, max: usize) -> String {
let total = s.chars().count();
if total <= max {
return s.to_string();
}
let kept: String = s.chars().take(max).collect();
format!("{kept}… (+{} chars)", total - max)
}
#[must_use]
pub fn collapse_and_truncate(s: &str, max: usize) -> String {
let collapsed = s.split_whitespace().collect::<Vec<_>>().join(" ");
truncate_excerpt(&collapsed, max)
}
#[must_use]
pub fn malformed_note(n: usize) -> String {
format!("{n} malformed line(s) skipped")
}
#[must_use]
pub fn scope_span_fragment(top: usize, sub: usize) -> String {
let total = top + sub;
format!(
"{total} session{} in scope ({top} top-level + {sub} subagent)",
if total == 1 { "" } else { "s" }
)
}
pub fn emit_scope_banner(top: usize, sub: usize) {
if sub > 0 {
println!("scope {}", scope_span_fragment(top, sub));
println!();
}
}
#[must_use]
pub fn envelope_header(command: &str, extra: serde_json::Value) -> serde_json::Value {
merge_into(
serde_json::json!({"kind": "header", "command": command}),
extra,
)
}
#[must_use]
pub fn envelope_scope_header(
command: &str,
top: usize,
sub: usize,
extra: serde_json::Value,
) -> serde_json::Value {
merge_into(
serde_json::json!({
"kind": "header",
"command": command,
"sessions_in_scope": top + sub,
"top_level_sessions": top,
"subagent_sessions": sub,
}),
extra,
)
}
#[must_use]
pub fn envelope_summary(extra: serde_json::Value) -> serde_json::Value {
merge_into(serde_json::json!({"kind": "summary"}), extra)
}
fn merge_into(mut base: serde_json::Value, extra: serde_json::Value) -> serde_json::Value {
if let (Some(b), serde_json::Value::Object(e)) = (base.as_object_mut(), extra) {
for (k, v) in e {
b.entry(k).or_insert(v);
}
}
base
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Endpoint {
At(usize),
FromEnd(usize),
Open,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RangeSpec {
pub start: Endpoint,
pub end: Endpoint,
}
fn parse_endpoint(tok: &str, label: &str) -> anyhow::Result<Endpoint> {
use anyhow::bail;
if tok.is_empty() {
return Ok(Endpoint::Open);
}
if let Some(rest) = tok.strip_prefix('-') {
if !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()) {
let k: usize = rest
.parse()
.map_err(|_| anyhow::anyhow!("{label}: {tok:?} is not a valid from-end index"))?;
if k == 0 {
bail!("{label}: -0 is not a from-end index (use -1 for the last element)");
}
return Ok(Endpoint::FromEnd(k));
}
}
if !tok.is_empty() && tok.bytes().all(|b| b.is_ascii_digit()) {
return Ok(Endpoint::At(tok.parse().map_err(|_| {
anyhow::anyhow!("{label} index {tok:?} is out of range")
})?));
}
if tok.contains('-') && tok.bytes().any(|b| b.is_ascii_digit()) {
bail!(
"{label} range is START..END (e.g. 495..500); a leading -N counts from the end \
(-3.. = the last 3). got {tok:?}"
);
}
bail!("{label} endpoint must be N, -N (from the end), or empty (open); got {tok:?}")
}
pub fn parse_range_spec(s: &str, label: &str, one_based: bool) -> anyhow::Result<RangeSpec> {
let t = s.trim();
let spec = if let Some((a, b)) = t.split_once("..") {
RangeSpec {
start: parse_endpoint(a.trim(), label)?,
end: parse_endpoint(b.trim(), label)?,
}
} else {
let ep = parse_endpoint(t, label)?;
if ep == Endpoint::Open {
anyhow::bail!("{label} must be N, A..B, N.., ..N, or -N (from the end); got {s:?}");
}
RangeSpec { start: ep, end: ep }
};
if one_based {
if let Endpoint::At(0) = spec.start {
anyhow::bail!("{label} start must be ≥ 1 (this domain is 1-based)");
}
if let Endpoint::At(0) = spec.end {
anyhow::bail!("{label} end must be ≥ 1 (this domain is 1-based)");
}
}
if let (Endpoint::At(lo), Endpoint::At(hi)) = (spec.start, spec.end) {
if hi < lo {
anyhow::bail!("{label} end ({hi}) is before start ({lo})");
}
}
Ok(spec)
}
impl RangeSpec {
#[must_use]
pub fn resolve(&self, len: usize, one_based: bool) -> (usize, usize) {
let first = usize::from(one_based);
let last = if len == 0 {
first
} else if one_based {
len
} else {
len - 1
};
let from_end = |k: usize| -> usize {
if one_based {
(len + 1).saturating_sub(k).max(first)
} else {
len.saturating_sub(k).max(first)
}
};
let resolve_ep = |ep: Endpoint, open_to: usize| -> usize {
match ep {
Endpoint::At(n) => n,
Endpoint::FromEnd(k) => from_end(k),
Endpoint::Open => open_to,
}
};
let lo = resolve_ep(self.start, first);
let hi = resolve_ep(self.end, last);
(lo, hi)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_short_is_unchanged() {
assert_eq!(truncate_excerpt("hello", 200), "hello");
assert_eq!(truncate_excerpt("", 10), "");
assert_eq!(truncate_excerpt("abcde", 5), "abcde");
}
#[test]
fn truncate_long_marks_dropped_count() {
let s = "x".repeat(205);
let out = truncate_excerpt(&s, 200);
assert!(out.ends_with("… (+5 chars)"), "got: {out}");
assert!(out.starts_with(&"x".repeat(200)));
let s2 = "y".repeat(410);
assert!(truncate_excerpt(&s2, 400).ends_with("… (+10 chars)"));
}
#[test]
fn truncate_counts_chars_not_bytes() {
let s = "🛠".repeat(202);
let out = truncate_excerpt(&s, 200);
assert!(out.ends_with("… (+2 chars)"), "got: {out}");
let three_byte = "€".repeat(203);
assert!(truncate_excerpt(&three_byte, 200).ends_with("… (+3 chars)"));
}
#[test]
fn collapse_and_truncate_flattens_then_marks() {
assert_eq!(
collapse_and_truncate("a\n b\t c", 200),
"a b c",
"whitespace runs collapse to single spaces"
);
let long = "word ".repeat(60); let out = collapse_and_truncate(&long, 200);
assert!(
out.contains("… (+") && out.ends_with("chars)"),
"collapse path must mark the elision count, not drop silently: {out}"
);
}
#[test]
fn malformed_note_is_canonical() {
assert_eq!(malformed_note(3), "3 malformed line(s) skipped");
assert_eq!(malformed_note(0), "0 malformed line(s) skipped");
assert!(!malformed_note(1).starts_with('('));
assert!(!malformed_note(1).contains("jsonl"));
assert!(!malformed_note(1).contains("note"));
}
fn rng(s: &str, one_based: bool, len: usize) -> (usize, usize) {
parse_range_spec(s, "--r", one_based)
.unwrap()
.resolve(len, one_based)
}
#[test]
fn range_closed_bare_and_open() {
assert_eq!(rng("0..5", false, 100), (0, 5));
assert_eq!(rng(" 3 .. 9 ", false, 100), (3, 9));
assert_eq!(rng("7..7", false, 100), (7, 7));
assert_eq!(rng("270", false, 100), (270, 270));
assert_eq!(rng(" 42 ", true, 100), (42, 42));
assert_eq!(rng("0", false, 10), (0, 0));
assert_eq!(rng("3..", false, 10), (3, 9)); assert_eq!(rng("..4", false, 10), (0, 4)); assert_eq!(rng("..", false, 10), (0, 9)); assert_eq!(rng("5..", true, 200), (5, 200));
assert_eq!(rng("..50", true, 200), (1, 50));
}
#[test]
fn range_from_end_negative() {
assert_eq!(rng("-3..", false, 10), (7, 9));
assert_eq!(rng("-1", false, 10), (9, 9)); assert_eq!(rng("-1..", false, 10), (9, 9)); assert_eq!(rng("3..-1", false, 10), (3, 9)); assert_eq!(rng("-20..", true, 200), (181, 200));
assert_eq!(rng("-1", true, 200), (200, 200));
assert_eq!(rng("-100..", false, 5), (0, 4));
assert_eq!(rng("-100..", true, 5), (1, 5));
}
#[test]
fn range_dash_form_teaches_the_dotdot_grammar() {
let err = parse_range_spec("495-500", "--line", true).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("START..END"), "got: {msg}");
assert!(msg.contains("495-500"), "got: {msg}");
}
#[test]
fn range_one_based_rejects_zero() {
assert!(parse_range_spec("0..5", "--line", true).is_err());
assert!(parse_range_spec("0", "--line", true).is_err());
let err = parse_range_spec("0..5", "--line", true).unwrap_err();
assert!(err.to_string().contains("≥ 1"), "got: {err}");
}
#[test]
fn range_errors() {
assert!(parse_range_spec("a..5", "--turn", false).is_err());
let err = parse_range_spec("9..3", "--turn", false).unwrap_err();
assert!(err.to_string().contains("before start"), "got: {err}");
let err2 = parse_range_spec("x", "--my-range", false).unwrap_err();
assert!(err2.to_string().contains("--my-range"));
assert!(parse_range_spec("-0..", "--r", false).is_err());
}
}