use anyhow::{bail, Context, Result};
use cv_core::ir::{Harness, SessionRef};
use cv_core::Adapter;
use std::path::Path;
#[derive(Debug)]
pub(crate) struct UsageError(pub String);
impl std::fmt::Display for UsageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for UsageError {}
pub(crate) fn usage<T>(msg: impl Into<String>) -> Result<T> {
Err(UsageError(msg.into()).into())
}
pub(crate) fn user_file_claim(path: &Path, force: bool) -> Result<cv_core::publish::Claim> {
if force {
return Ok(cv_core::publish::Claim::Replace);
}
cv_core::publish::refuse_existing(path, " (pass --force to replace it)")?;
Ok(cv_core::publish::Claim::New)
}
pub(crate) fn write_user_file(path: &Path, bytes: &[u8], force: bool) -> Result<()> {
let claim = user_file_claim(path, force)?;
cv_core::publish::publish_reader(path, "out", bytes, claim)
}
pub(crate) enum Wait {
Once,
Until(Option<(std::time::Instant, std::time::Duration)>),
}
impl Wait {
pub(crate) fn resolve(follow: bool, for_: Option<&str>) -> Result<Wait> {
let bound = for_.map(parse_for).transpose()?;
if follow || bound.is_some() || std::io::IsTerminal::is_terminal(&std::io::stdout()) {
Ok(Wait::Until(bound.map(|d| (std::time::Instant::now() + d, d))))
} else {
Ok(Wait::Once)
}
}
pub(crate) fn announce(&self, cmd: &str, what: &str) {
match self {
Wait::Once => eprintln!(
"cv {cmd}: stdout is not a terminal, so this is the current state once; \
--follow keeps following {what}, --for <duration> bounds it"
),
Wait::Until(None) => eprintln!("cv {cmd}: following {what}; stop with Ctrl-C or --for <duration>"),
Wait::Until(Some((_, length))) => eprintln!(
"cv {cmd}: following {what} for {}; Ctrl-C stops early",
fmt_duration(length.as_millis() as u64)
),
}
}
pub(crate) fn pause(&self, interval: std::time::Duration) -> bool {
let left = match self {
Wait::Once => return false,
Wait::Until(None) => interval,
Wait::Until(Some((deadline, _))) => {
let left = deadline.saturating_duration_since(std::time::Instant::now());
if left.is_zero() {
return false;
}
interval.min(left)
}
};
std::thread::sleep(left);
true
}
}
fn parse_for(s: &str) -> Result<std::time::Duration> {
match cv_core::task::project::parse_duration(s).and_then(|d| d.to_std().ok()) {
Some(d) => Ok(d),
None => usage(format!("--for wants a duration such as 30s, 5m or 2h, not {s:?}")),
}
}
pub(crate) fn parse_harness(s: &Option<String>) -> Result<Option<Harness>> {
match s {
None => Ok(None),
Some(s) => match Harness::parse(s) {
Some(h) => Ok(Some(h)),
None => match Harness::retired(s) {
Some(where_now) => usage(format!("{s} is no longer a harness: {where_now}")),
None => usage(format!("unknown harness: {s}")),
},
},
}
}
pub(crate) fn split_harness_id(spec: &str, fallback: Option<Harness>) -> (Option<Harness>, &str) {
if let Some((head, rest)) = spec.split_once(':') {
if let Some(h) = Harness::parse(head) {
return (Some(h), rest);
}
}
(fallback, spec)
}
pub(crate) fn resolve(spec: &str, harness: Option<Harness>) -> Result<(SessionRef, Box<dyn Adapter>)> {
let (want, id) = split_harness_id(spec, harness);
resolve_found(cv_core::find(id, want), id, want)
}
pub(crate) fn resolve_found(
found: Result<Option<(SessionRef, Box<dyn Adapter>)>>,
id: &str,
want: Option<Harness>,
) -> Result<(SessionRef, Box<dyn Adapter>)> {
match found {
Ok(Some(hit)) => Ok(hit),
Ok(None) => usage(format!("no session matching {id:?}")),
Err(e) if is_ambiguity(&e) => usage(ambiguous_message(id, want, &e)),
Err(e) => Err(e),
}
}
fn is_ambiguity(e: &anyhow::Error) -> bool {
let s = e.to_string();
s.starts_with("ambiguous session id") || s.contains("sessions have a sub-agent matching")
}
fn ambiguous_message(id: &str, want: Option<Harness>, core_err: &anyhow::Error) -> String {
let mut rows: Vec<String> = cv_core::sessions()
.into_iter()
.filter(|r| want.is_none_or(|h| r.harness == h))
.filter(|r| r.id.starts_with(id))
.map(|r| format!("{}:{}", r.harness.as_str(), r.id))
.collect();
rows.sort();
rows.dedup();
if rows.len() < 2 {
return core_err.to_string();
}
format!(
"ambiguous id {id:?} โ {} candidates (pass a longer prefix or harness:id):\n{}",
rows.len(),
rows.join("\n")
)
}
#[derive(clap::Args, Debug, Default, Clone)]
pub(crate) struct WindowArgs {
#[arg(long, value_name = "N", conflicts_with_all = ["last", "range", "around"])]
pub first: Option<usize>,
#[arg(long, value_name = "N", conflicts_with_all = ["range", "around"])]
pub last: Option<usize>,
#[arg(long, value_name = "A..B", allow_hyphen_values = true, conflicts_with = "around")]
pub range: Option<String>,
#[arg(long, value_name = "N|ID", value_parser = cv_core::api::parse_around)]
pub around: Option<cv_core::api::Around>,
#[arg(long, value_name = "K", default_value_t = 5, requires = "around")]
pub context: usize,
#[arg(long, value_name = "N")]
pub max_bytes: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Window {
First(usize),
Last(usize),
Range(usize, Option<usize>),
Around { center: usize, context: usize },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum KindSelector {
Rows(Window),
AroundMessage { msg_index: usize, context: usize },
}
impl WindowArgs {
#[cfg(test)]
pub(crate) fn window(&self) -> Result<Option<Window>> {
self.window_with(|id| usage(format!("`--around {id}`: a message id needs the session it is in")))
}
pub(crate) fn window_in(&self, adapter: &dyn Adapter, r: &SessionRef) -> Result<Option<Window>> {
self.window_with(|id| resolve_message_id(adapter, r, id))
}
pub(crate) fn kind_selector_in(&self, adapter: &dyn Adapter, r: &SessionRef) -> Result<Option<KindSelector>> {
if let Some(cv_core::api::Around::Id(id)) = &self.around {
let msg_index = resolve_message_id(adapter, r, id)?;
return Ok(Some(KindSelector::AroundMessage {
msg_index,
context: self.context,
}));
}
Ok(self.window_with(|_| unreachable!())?.map(KindSelector::Rows))
}
pub(crate) fn selects(&self) -> Result<bool> {
Ok(self.window_with(|_| Ok(0))?.is_some())
}
fn window_with(&self, at: impl FnOnce(&str) -> Result<usize>) -> Result<Option<Window>> {
if let Some(n) = self.first {
return Ok(Some(Window::First(n)));
}
if let Some(n) = self.last {
return Ok(Some(Window::Last(n)));
}
if let Some(spec) = &self.range {
let (a, b) = parse_range(spec)?;
return Ok(Some(Window::Range(a, b)));
}
if let Some(around) = &self.around {
let center = match around {
cv_core::api::Around::Index(n) => *n,
cv_core::api::Around::Id(id) => at(id)?,
};
return Ok(Some(Window::Around {
center,
context: self.context,
}));
}
Ok(None)
}
}
fn resolve_message_id(adapter: &dyn Adapter, r: &SessionRef, id: &str) -> Result<usize> {
match cv_core::api::message_index(adapter, r, id)? {
Some(at) => Ok(at),
None => usage(format!("no message {id:?} in session {}", r.id)),
}
}
pub(crate) enum StreamWindow {
Range(usize, Option<usize>),
Tail(usize),
}
impl Window {
pub(crate) fn streaming(self) -> StreamWindow {
match self {
Window::First(n) => StreamWindow::Range(0, Some(n)),
Window::Last(n) => StreamWindow::Tail(n),
Window::Range(a, b) => StreamWindow::Range(a, b),
Window::Around { center, context } => StreamWindow::Range(
center.saturating_sub(context),
Some(center.saturating_add(context).saturating_add(1)),
),
}
}
pub(crate) fn bounds(self, total: impl FnOnce() -> Result<usize>) -> Result<(usize, Option<usize>)> {
match self.streaming() {
StreamWindow::Range(start, end) => Ok((start, end)),
StreamWindow::Tail(n) => Ok((total()?.saturating_sub(n), None)),
}
}
}
pub(crate) fn parse_range(spec: &str) -> Result<(usize, Option<usize>)> {
parse_range_inner(spec).map_err(|error| UsageError(error.to_string()).into())
}
fn parse_range_inner(spec: &str) -> Result<(usize, Option<usize>)> {
let spec = spec.trim();
let Some((a, b)) = spec.split_once("..") else {
if spec.parse::<usize>().is_ok() {
bail!(
"bad range {spec:?}: a single message is `--around {spec} --context 0` (or `{spec}..{}`)",
next_index(spec)
);
}
if spec.starts_with('-') || spec.ends_with('-') || spec.contains('-') {
bail!(
"bad range {spec:?}: the `A-B`/`-N`/`N-` grammar is gone โ use `--range A..B` (0-based, \
end-exclusive), `A..`, `..B`; `--first N` for the first N, `--last N` for the last N"
);
}
bail!("bad range {spec:?}: expected A..B, A.., or ..B (0-based, end-exclusive)");
};
let start = if a.trim().is_empty() {
0
} else {
a.trim()
.parse()
.with_context(|| format!("bad range {spec:?}: start must be a number"))?
};
let end = if b.trim().is_empty() {
None
} else {
Some(
b.trim()
.parse()
.with_context(|| format!("bad range {spec:?}: end must be a number"))?,
)
};
if let Some(end) = end {
if end < start {
bail!("bad range {spec:?}: end ({end}) is before start ({start})");
}
}
Ok((start, end))
}
fn next_index(n: &str) -> String {
n.parse::<usize>()
.ok()
.and_then(|n| n.checked_add(1))
.map(|n| n.to_string())
.unwrap_or_default()
}
pub(crate) fn continue_hint(next: usize) -> String {
format!("โฆ continue with --range {next}..")
}
pub(crate) use cv_core::rows::session_row;
pub(crate) fn short_id(id: &str) -> String {
short_id_len(id, 8)
}
pub(crate) fn short_id_len(id: &str, min_bare: usize) -> String {
let take = min_bare.max(8);
match id.strip_prefix("agent-") {
Some(bare) => format!("agent-{}", bare.chars().take(take).collect::<String>()),
None => id.chars().take(take).collect(),
}
}
pub(crate) fn fmt_local(d: chrono::DateTime<chrono::Utc>, fmt: &str) -> String {
d.with_timezone(&chrono::Local).format(fmt).to_string()
}
pub(crate) fn fmt_local_ts(secs: i64, fmt: &str) -> Option<String> {
chrono::DateTime::from_timestamp(secs, 0).map(|d| fmt_local(d, fmt))
}
pub(crate) const SPAN_WIDTH: usize = 31;
pub(crate) fn fmt_span(
created: Option<chrono::DateTime<chrono::Utc>>,
updated: Option<chrono::DateTime<chrono::Utc>>,
) -> String {
const FMT: &str = "%y-%m-%d %H:%M";
let s = match (created, updated) {
(Some(c), Some(u)) => {
let (lc, lu) = (c.with_timezone(&chrono::Local), u.with_timezone(&chrono::Local));
if lc.date_naive() == lu.date_naive() {
format!("{} โ {}", lc.format(FMT), lu.format("%H:%M"))
} else {
format!("{} โ {}", lc.format(FMT), lu.format(FMT))
}
}
(Some(t), None) | (None, Some(t)) => fmt_local(t, FMT),
(None, None) => "?".into(),
};
format!("{s:<SPAN_WIDTH$}")
}
pub(crate) use cv_core::ir::home_rel;
pub(crate) fn dim_cwd(p: Option<&Path>) -> String {
p.map(home_rel).unwrap_or_else(|| "(no cwd)".into())
}
pub(crate) use cv_core::harness::shell_quote;
pub(crate) fn dirs_home() -> Option<std::path::PathBuf> {
cv_core::env::var_os("HOME").map(std::path::PathBuf::from)
}
pub(crate) fn report(json: bool, human: &str, value: &serde_json::Value) -> Result<()> {
eprint!("{human}");
if json {
println!("{}", serde_json::to_string_pretty(value)?);
}
Ok(())
}
pub(crate) fn dry_run_nulls(v: &mut serde_json::Value, pinned: bool, paths: &[&str], minted: &[&str]) {
v["note"] = serde_json::Value::Null;
if v["dry_run"] != true {
return;
}
for k in paths {
v[*k] = serde_json::Value::Null;
}
if !pinned {
for k in minted {
v[*k] = serde_json::Value::Null;
}
}
v["note"] = if pinned {
"dry run โ nothing written"
} else {
"dry run โ nothing written; a real run mints a fresh session id (pass --to to pin one)"
}
.into();
}
pub(crate) fn poll_interval(seconds: f64) -> Result<std::time::Duration> {
if !seconds.is_finite() {
return usage("--interval must be finite");
}
if seconds < 0.0 {
return usage(format!("--interval must not be negative (got {seconds})"));
}
std::time::Duration::try_from_secs_f64(seconds.max(0.25))
.map_err(|_| UsageError("--interval exceeds the supported duration".into()).into())
}
pub(crate) fn parse_thinking(s: &str) -> Result<cv_core::emit::ThinkingMode> {
cv_core::emit::ThinkingMode::parse(s).ok_or_else(|| {
let all = cv_core::emit::ThinkingMode::ALL
.iter()
.map(|m| m.as_str())
.collect::<Vec<_>>()
.join(", ");
anyhow::anyhow!("unknown --thinking mode {s:?} (expected one of: {all})")
})
}
pub(crate) fn fmt_int(n: u64) -> String {
let s = n.to_string();
let bytes = s.as_bytes();
let mut out = String::with_capacity(s.len() + s.len() / 3);
for (i, b) in bytes.iter().enumerate() {
if i > 0 && (bytes.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(*b as char);
}
out
}
pub(crate) fn fmt_duration(ms: u64) -> String {
let secs = ms / 1000;
let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60);
if h > 0 {
format!("{h}h {m}m")
} else if m > 0 {
format!("{m}m {s}s")
} else {
format!("{s}s")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn int_and_duration_formatting() {
assert_eq!(fmt_int(589698), "589,698");
assert_eq!(fmt_int(0), "0");
assert_eq!(fmt_int(42), "42");
assert_eq!(fmt_int(1000), "1,000");
assert_eq!(fmt_duration(3144884), "52m 24s");
assert_eq!(fmt_duration(5_000), "5s");
assert_eq!(fmt_duration(7_200_000), "2h 0m");
}
#[test]
fn invalid_read_selectors_and_harness_names_are_usage_errors() {
for spec in ["10-13", "-5", "5-", "5", "9..3", "1..x", "abc"] {
assert!(
parse_range(spec).unwrap_err().downcast_ref::<UsageError>().is_some(),
"{spec}"
);
}
assert!(parse_harness(&Some("unknown-harness".into()))
.unwrap_err()
.downcast_ref::<UsageError>()
.is_some());
}
#[test]
fn range_grammar_forms() {
assert_eq!(parse_range("10..13").unwrap(), (10, Some(13)));
assert_eq!(parse_range("10..").unwrap(), (10, None));
assert_eq!(parse_range("..13").unwrap(), (0, Some(13)));
assert_eq!(parse_range(" 2 .. 4 ").unwrap(), (2, Some(4))); assert_eq!(parse_range("0..0").unwrap(), (0, Some(0))); }
#[test]
fn range_grammar_rejects_old_forms_with_a_pointer() {
let err = parse_range("10-13").unwrap_err().to_string();
assert!(err.contains("A..B") && err.contains("--first N"), "{err}");
let err = parse_range("-5").unwrap_err().to_string();
assert!(err.contains("--last N"), "{err}");
let err = parse_range("5-").unwrap_err().to_string();
assert!(err.contains("A.."), "{err}");
let err = parse_range("5").unwrap_err().to_string();
assert!(err.contains("--around 5 --context 0") && err.contains("5..6"), "{err}");
assert!(parse_range("9..3").unwrap_err().to_string().contains("before start"));
assert!(parse_range("abc").is_err());
assert!(parse_range("1..x").is_err());
assert!(parse_range("").is_err());
}
#[test]
fn window_bounds() {
let total = || Ok(100);
assert_eq!(Window::First(7).bounds(total).unwrap(), (0, Some(7)));
assert_eq!(Window::Last(7).bounds(total).unwrap(), (93, None));
assert_eq!(Window::Last(500).bounds(total).unwrap(), (0, None)); assert_eq!(Window::Range(3, Some(9)).bounds(total).unwrap(), (3, Some(9)));
assert_eq!(Window::Range(3, None).bounds(total).unwrap(), (3, None));
assert_eq!(
Window::Around { center: 10, context: 5 }.bounds(total).unwrap(),
(5, Some(16))
);
assert_eq!(
Window::Around { center: 2, context: 5 }.bounds(total).unwrap(),
(0, Some(8))
);
assert_eq!(
Window::Around { center: 4, context: 0 }.bounds(total).unwrap(),
(4, Some(5))
);
let never = || -> Result<usize> { panic!("count must not be taken") };
assert_eq!(Window::First(1).bounds(never).unwrap(), (0, Some(1)));
}
#[test]
fn window_args_selectors_and_clamp() {
let none = WindowArgs::default();
assert!(none.window().unwrap().is_none());
let first = WindowArgs {
first: Some(3),
..Default::default()
};
assert_eq!(first.window().unwrap(), Some(Window::First(3)));
let around = WindowArgs {
around: Some(cv_core::api::Around::Index(9)),
context: 2,
..Default::default()
};
assert_eq!(around.window().unwrap(), Some(Window::Around { center: 9, context: 2 }));
assert_eq!(cv_core::api::parse_around(" 9 "), Ok(cv_core::api::Around::Index(9)));
assert_eq!(
cv_core::api::parse_around("4d1dd21f-7a00"),
Ok(cv_core::api::Around::Id("4d1dd21f-7a00".into()))
);
assert_eq!(
cv_core::api::parse_around("id:42"),
Ok(cv_core::api::Around::Id("42".into()))
);
assert!(cv_core::api::parse_around("").is_err());
assert!(cv_core::api::parse_around("id:").is_err());
let by_id = WindowArgs {
around: Some(cv_core::api::Around::Id("u1".into())),
..Default::default()
};
assert!(by_id.window().is_err(), "an id needs a session to resolve against");
assert!(by_id.selects().unwrap());
let bad = WindowArgs {
range: Some("1-2".into()),
..Default::default()
};
assert!(bad.window().is_err());
}
#[test]
fn harness_prefix_splits_only_on_known_names() {
assert_eq!(split_harness_id("codex:abc", None), (Some(Harness::Codex), "abc"));
assert_eq!(
split_harness_id("codex:abc", Some(Harness::Claude)),
(Some(Harness::Codex), "abc")
);
assert_eq!(
split_harness_id("session:abc", Some(Harness::Kimi)),
(Some(Harness::Kimi), "session:abc")
);
assert_eq!(split_harness_id("abc", None), (None, "abc"));
}
#[test]
fn session_row_has_the_contract_keys() {
let r = SessionRef {
id: "abc".into(),
harness: Harness::Claude,
path: "/x/abc.jsonl".into(),
cwd: Some("/w".into()),
title: None,
created_at: None,
updated_at: None,
message_count: Some(3),
};
let v = session_row(&r, Some(10));
let mut keys: Vec<&str> = v.as_object().unwrap().keys().map(|k| k.as_str()).collect();
keys.sort();
assert_eq!(
keys,
[
"created_at",
"cwd",
"harness",
"id",
"message_count",
"path",
"size_bytes",
"title",
"updated_at"
]
);
assert!(v["title"].is_null(), "missing title is an explicit null");
assert_eq!(v["size_bytes"], 10);
}
}
#[cfg(test)]
mod interval_tests {
use super::*;
use std::time::Duration;
#[test]
fn interval_rejects_negative_nonfinite_and_overflow_without_panicking() {
for seconds in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MAX, -1.0, -0.001] {
assert!(
poll_interval(seconds)
.unwrap_err()
.downcast_ref::<UsageError>()
.is_some(),
"{seconds}"
);
}
assert_eq!(poll_interval(0.0).unwrap(), Duration::from_millis(250));
assert_eq!(poll_interval(1.5).unwrap(), Duration::from_millis(1500));
}
#[test]
fn short_id_keeps_eight_bare_chars_past_agent_prefix() {
assert_eq!(short_id("agent-ae84249a11223344"), "agent-ae84249a");
assert_eq!(short_id("agent-aaa1"), "agent-aaa1");
assert_eq!(short_id("019c1234-5678-7abc-8000-000000000001"), "019c1234");
assert_eq!(
short_id_len("019c1234-5678-7abc-8000-000000000001", 16),
"019c1234-5678-7a"
);
}
}