Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,11 @@ Local transaction-id conversion helpers:
- `bt util xact to-pretty 1000192656880881099`
- Convert pretty version id to transaction id:
- `bt util xact from-pretty 81cd05ee665fdfb3`
- Convert transaction id to timestamp:
- Convert transaction id, pretty version id, or pagination key to timestamp (local timezone by default):
- `bt util xact to-time 1000192656880881099`
- `bt util xact to-time 81cd05ee665fdfb3`
- `bt util xact to-time p07639577379371417602`
- `bt util xact to-time p07639577379371417602 --utc`
- `bt util xact to-time 1000192656880881099 --format unix`
- Convert timestamp to transaction id:
- `bt util xact from-time` (defaults to current time)
Expand All @@ -241,6 +244,8 @@ Local transaction-id conversion helpers:
- Inspect any xact value:
- `bt util xact inspect 1000192656880881099`
- `bt util xact inspect 81cd05ee665fdfb3`
- `bt util xact inspect p07639577379371417602`
- `bt util xact inspect p07639577379371417602 --utc`

## `bt auth`

Expand Down
228 changes: 191 additions & 37 deletions src/util_cmd.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use anyhow::{anyhow, bail, Context, Result};
use chrono::{DateTime, NaiveDate, SecondsFormat, Utc};
use chrono::{DateTime, Local, NaiveDate, SecondsFormat, Utc};
use clap::{Args, Subcommand, ValueEnum};
use serde::Serialize;

Expand Down Expand Up @@ -58,13 +58,17 @@ struct FromPrettyArgs {

#[derive(Debug, Clone, Args)]
struct ToTimeArgs {
/// Decimal transaction id
#[arg(value_name = "XACT_ID")]
xact_id: String,
/// Decimal transaction id, 16-char pretty version id, or pagination key
#[arg(value_name = "XACT_OR_PAGINATION")]
value: String,

/// Output format for non-JSON mode
#[arg(long, value_enum, default_value_t = TimeOutputFormat::Iso)]
format: TimeOutputFormat,

/// Display ISO timestamps in UTC instead of the local timezone
#[arg(long)]
utc: bool,
}

#[derive(Debug, Clone, Copy, ValueEnum, Eq, PartialEq)]
Expand Down Expand Up @@ -97,25 +101,35 @@ enum TimeInputFormat {

#[derive(Debug, Clone, Args)]
struct InspectArgs {
/// Decimal transaction id or 16-char pretty version id
#[arg(value_name = "XACT_OR_VERSION")]
/// Decimal transaction id, 16-char pretty version id, or pagination key
#[arg(value_name = "XACT_OR_PAGINATION")]
value: String,

/// Display ISO timestamps in UTC instead of the local timezone
#[arg(long)]
utc: bool,
}

#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
enum InputKind {
XactId,
PrettyVersionId,
PaginationKey,
}

#[derive(Debug, Clone, Serialize)]
struct XactInfo {
input_kind: InputKind,
xact_id: String,
pretty_version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pagination_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pagination_row_num: Option<u16>,
unix_seconds: u64,
iso_utc: String,
iso_local: String,
counter: u16,
}

Expand Down Expand Up @@ -169,22 +183,26 @@ fn run_from_pretty(json: bool, args: FromPrettyArgs) -> Result<()> {
}

fn run_to_time(json: bool, args: ToTimeArgs) -> Result<()> {
let xact = parse_xact_id(&args.xact_id)?;
let unix_seconds = xact_to_unix_seconds(xact);
let iso = unix_seconds_to_iso(unix_seconds)?;
let info = inspect_xact_like_input(&args.value)?;
if json {
println!(
"{}",
serde_json::to_string(&serde_json::json!({
"xact_id": xact.to_string(),
"unix_seconds": unix_seconds,
"iso_utc": iso,
}))?
);
let iso = display_iso(&info, args.utc).to_string();
let mut payload = serde_json::json!({
"input_kind": input_kind_label(info.input_kind),
"xact_id": &info.xact_id,
"unix_seconds": info.unix_seconds,
"iso": iso,
"iso_utc": &info.iso_utc,
"iso_local": &info.iso_local,
"timezone": timezone_label(args.utc),
});
if let Some(pagination_key) = info.pagination_key.as_deref() {
payload["pagination_key"] = serde_json::json!(pagination_key);
}
println!("{}", serde_json::to_string(&payload)?);
} else {
match args.format {
TimeOutputFormat::Iso => println!("{iso}"),
TimeOutputFormat::Unix => println!("{unix_seconds}"),
TimeOutputFormat::Iso => println!("{}", display_iso(&info, args.utc)),
TimeOutputFormat::Unix => println!("{}", info.unix_seconds),
}
}
Ok(())
Expand Down Expand Up @@ -218,41 +236,72 @@ fn run_from_time(json: bool, args: FromTimeArgs) -> Result<()> {
fn run_inspect(json: bool, args: InspectArgs) -> Result<()> {
let info = inspect_xact_like_input(&args.value)?;
if json {
println!("{}", serde_json::to_string(&info)?);
let mut payload = serde_json::to_value(&info)?;
payload["iso"] = serde_json::json!(display_iso(&info, args.utc));
payload["timezone"] = serde_json::json!(timezone_label(args.utc));
println!("{}", serde_json::to_string(&payload)?);
} else {
println!(
"Input kind: {}\nXact ID: {}\nPretty version: {}\nUnix seconds: {}\nISO UTC: {}\nCounter: {}",
match info.input_kind {
InputKind::XactId => "xact_id",
InputKind::PrettyVersionId => "pretty_version_id",
},
info.xact_id,
info.pretty_version,
info.unix_seconds,
info.iso_utc,
info.counter
);
let mut lines = vec![
format!("Input kind: {}", input_kind_label(info.input_kind)),
format!("Xact ID: {}", info.xact_id),
format!("Pretty version: {}", info.pretty_version),
format!("Unix seconds: {}", info.unix_seconds),
format!(
"{}: {}",
iso_display_label(args.utc),
display_iso(&info, args.utc)
),
format!("Counter: {}", info.counter),
];
if let Some(pagination_key) = info.pagination_key {
lines.insert(1, format!("Pagination key: {pagination_key}"));
}
if let Some(row_num) = info.pagination_row_num {
lines.push(format!("Pagination row number: {row_num}"));
}
println!("{}", lines.join("\n"));
}
Ok(())
}

fn inspect_xact_like_input(value: &str) -> Result<XactInfo> {
let (input_kind, xact_id) = if is_pretty_version(value) {
(InputKind::PrettyVersionId, load_pretty_xact(value)?)
let is_pagination_key = is_pagination_key_like(value);
let (input_kind, xact_id, pagination_key, pagination_row_num) = if is_pagination_key {
let parsed = parse_pagination_key(value)?;
let unix_seconds = pagination_key_to_unix_seconds(parsed);
let counter = pagination_key_xact_counter(parsed);
let xact_id = build_xact_id(unix_seconds, counter);
(
InputKind::PaginationKey,
xact_id.to_string(),
Some(format_pagination_key(parsed)),
Some(pagination_key_row_num(parsed)),
)
} else if is_pretty_version(value) {
(
InputKind::PrettyVersionId,
load_pretty_xact(value)?,
None,
None,
)
} else {
let parsed = parse_xact_id(value)?;
(InputKind::XactId, parsed.to_string())
(InputKind::XactId, parsed.to_string(), None, None)
};

let xact = parse_xact_id(&xact_id)?;
let unix_seconds = xact_to_unix_seconds(xact);
let iso_utc = unix_seconds_to_iso(unix_seconds)?;
let iso_utc = unix_seconds_to_iso_utc(unix_seconds)?;
let iso_local = unix_seconds_to_iso_local(unix_seconds)?;
Ok(XactInfo {
input_kind,
xact_id,
pretty_version: prettify_xact(xact),
pagination_key,
pagination_row_num,
unix_seconds,
iso_utc,
iso_local,
counter: xact_counter(xact),
})
}
Expand All @@ -270,6 +319,23 @@ fn parse_xact_id(value: &str) -> Result<u64> {
.with_context(|| format!("invalid transaction id '{value}'"))
}

fn parse_pagination_key(value: &str) -> Result<u64> {
let numeric = value
.strip_prefix('p')
.or_else(|| value.strip_prefix('P'))
.ok_or_else(|| {
anyhow!("invalid pagination key '{value}' (expected p followed by digits)")
})?;

if numeric.is_empty() || !numeric.chars().all(|c| c.is_ascii_digit()) {
bail!("invalid pagination key '{value}' (expected p followed by digits)");
}

numeric
.parse::<u64>()
.with_context(|| format!("invalid pagination key '{value}'"))
}

fn parse_timestamp(value: &str, input: TimeInputFormat) -> Result<u64> {
match input {
TimeInputFormat::Unix => value
Expand Down Expand Up @@ -335,17 +401,79 @@ fn build_xact_id(unix_seconds: u64, counter: u16) -> u64 {
TOP_BITS | ((unix_seconds & 0xffff_ffff_ffff) << 16) | u64::from(counter)
}

fn unix_seconds_to_iso(unix_seconds: u64) -> Result<String> {
fn format_pagination_key(pagination_key: u64) -> String {
format!("p{pagination_key:020}")
}

fn pagination_key_to_unix_seconds(pagination_key: u64) -> u64 {
pagination_key >> 32
}

fn pagination_key_xact_counter(pagination_key: u64) -> u16 {
((pagination_key >> 16) & 0xffff) as u16
}

fn pagination_key_row_num(pagination_key: u64) -> u16 {
(pagination_key & 0xffff) as u16
}

fn unix_seconds_to_utc_datetime(unix_seconds: u64) -> Result<DateTime<Utc>> {
let dt = DateTime::<Utc>::from_timestamp(unix_seconds as i64, 0).ok_or_else(|| {
anyhow!("cannot represent unix timestamp as UTC datetime: {unix_seconds}")
})?;
Ok(dt)
}

fn unix_seconds_to_iso_utc(unix_seconds: u64) -> Result<String> {
let dt = unix_seconds_to_utc_datetime(unix_seconds)?;
Ok(dt.to_rfc3339_opts(SecondsFormat::Secs, true))
}

fn unix_seconds_to_iso_local(unix_seconds: u64) -> Result<String> {
let dt = unix_seconds_to_utc_datetime(unix_seconds)?.with_timezone(&Local);
Ok(dt.to_rfc3339_opts(SecondsFormat::Secs, true))
}

fn is_pretty_version(value: &str) -> bool {
value.len() == 16 && value.chars().all(|c| c.is_ascii_hexdigit())
}

fn is_pagination_key_like(value: &str) -> bool {
value.starts_with('p') || value.starts_with('P')
}

fn input_kind_label(input_kind: InputKind) -> &'static str {
match input_kind {
InputKind::XactId => "xact_id",
InputKind::PrettyVersionId => "pretty_version_id",
InputKind::PaginationKey => "pagination_key",
}
}

fn display_iso(info: &XactInfo, utc: bool) -> &str {
if utc {
&info.iso_utc
} else {
&info.iso_local
}
}

fn timezone_label(utc: bool) -> &'static str {
if utc {
"utc"
} else {
"local"
}
}

fn iso_display_label(utc: bool) -> &'static str {
if utc {
"ISO UTC"
} else {
"ISO local"
}
}

fn current_unix_seconds() -> u64 {
Utc::now().timestamp().max(0) as u64
}
Expand Down Expand Up @@ -395,6 +523,32 @@ mod tests {
assert_eq!(info.xact_id, "1000192656880881099");
}

#[test]
fn inspect_decodes_pagination_key_time() {
let info = inspect_xact_like_input("p07639577379371417602").unwrap();
assert!(matches!(info.input_kind, InputKind::PaginationKey));
assert_eq!(
info.pagination_key.as_deref(),
Some("p07639577379371417602")
);
assert_eq!(info.xact_id, "1000197162952719243");
assert_eq!(info.unix_seconds, 1_778_727_718);
assert_eq!(info.iso_utc, "2026-05-14T03:01:58Z");
assert_eq!(info.counter, 31_627);
assert_eq!(info.pagination_row_num, Some(2));
}

#[test]
fn pagination_key_parser_accepts_short_form() {
let info = inspect_xact_like_input("p0").unwrap();
assert!(matches!(info.input_kind, InputKind::PaginationKey));
assert_eq!(
info.pagination_key.as_deref(),
Some("p00000000000000000000")
);
assert_eq!(info.unix_seconds, 0);
}

#[test]
fn parse_iso_date_without_time() {
assert_eq!(
Expand Down
9 changes: 9 additions & 0 deletions tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,15 @@ fn setup_instrument_accepts_deprecated_agents_alias() {
.success();
}

#[test]
fn util_xact_to_time_accepts_pagination_key_with_utc() {
bt_command()
.args(["util", "xact", "to-time", "p07639577379371417602", "--utc"])
.assert()
.success()
.stdout(predicate::str::contains("2026-05-14T03:01:58Z"));
}

#[test]
fn setup_uses_codex_detected_on_path_without_explicit_agent() {
let repo = make_git_repo();
Expand Down
Loading