11use anyhow:: { anyhow, bail, Context , Result } ;
2- use chrono:: { DateTime , NaiveDate , SecondsFormat , Utc } ;
2+ use chrono:: { DateTime , Local , NaiveDate , SecondsFormat , Utc } ;
33use clap:: { Args , Subcommand , ValueEnum } ;
44use serde:: Serialize ;
55
@@ -58,13 +58,17 @@ struct FromPrettyArgs {
5858
5959#[ derive( Debug , Clone , Args ) ]
6060struct ToTimeArgs {
61- /// Decimal transaction id
62- #[ arg( value_name = "XACT_ID " ) ]
63- xact_id : String ,
61+ /// Decimal transaction id, 16-char pretty version id, or pagination key
62+ #[ arg( value_name = "XACT_OR_PAGINATION " ) ]
63+ value : String ,
6464
6565 /// Output format for non-JSON mode
6666 #[ arg( long, value_enum, default_value_t = TimeOutputFormat :: Iso ) ]
6767 format : TimeOutputFormat ,
68+
69+ /// Display ISO timestamps in UTC instead of the local timezone
70+ #[ arg( long) ]
71+ utc : bool ,
6872}
6973
7074#[ derive( Debug , Clone , Copy , ValueEnum , Eq , PartialEq ) ]
@@ -97,25 +101,35 @@ enum TimeInputFormat {
97101
98102#[ derive( Debug , Clone , Args ) ]
99103struct InspectArgs {
100- /// Decimal transaction id or 16-char pretty version id
101- #[ arg( value_name = "XACT_OR_VERSION " ) ]
104+ /// Decimal transaction id, 16-char pretty version id, or pagination key
105+ #[ arg( value_name = "XACT_OR_PAGINATION " ) ]
102106 value : String ,
107+
108+ /// Display ISO timestamps in UTC instead of the local timezone
109+ #[ arg( long) ]
110+ utc : bool ,
103111}
104112
105113#[ derive( Debug , Clone , Copy , Serialize ) ]
106114#[ serde( rename_all = "snake_case" ) ]
107115enum InputKind {
108116 XactId ,
109117 PrettyVersionId ,
118+ PaginationKey ,
110119}
111120
112121#[ derive( Debug , Clone , Serialize ) ]
113122struct XactInfo {
114123 input_kind : InputKind ,
115124 xact_id : String ,
116125 pretty_version : String ,
126+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
127+ pagination_key : Option < String > ,
128+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
129+ pagination_row_num : Option < u16 > ,
117130 unix_seconds : u64 ,
118131 iso_utc : String ,
132+ iso_local : String ,
119133 counter : u16 ,
120134}
121135
@@ -169,22 +183,26 @@ fn run_from_pretty(json: bool, args: FromPrettyArgs) -> Result<()> {
169183}
170184
171185fn run_to_time ( json : bool , args : ToTimeArgs ) -> Result < ( ) > {
172- let xact = parse_xact_id ( & args. xact_id ) ?;
173- let unix_seconds = xact_to_unix_seconds ( xact) ;
174- let iso = unix_seconds_to_iso ( unix_seconds) ?;
186+ let info = inspect_xact_like_input ( & args. value ) ?;
175187 if json {
176- println ! (
177- "{}" ,
178- serde_json:: to_string( & serde_json:: json!( {
179- "xact_id" : xact. to_string( ) ,
180- "unix_seconds" : unix_seconds,
181- "iso_utc" : iso,
182- } ) ) ?
183- ) ;
188+ let iso = display_iso ( & info, args. utc ) . to_string ( ) ;
189+ let mut payload = serde_json:: json!( {
190+ "input_kind" : input_kind_label( info. input_kind) ,
191+ "xact_id" : & info. xact_id,
192+ "unix_seconds" : info. unix_seconds,
193+ "iso" : iso,
194+ "iso_utc" : & info. iso_utc,
195+ "iso_local" : & info. iso_local,
196+ "timezone" : timezone_label( args. utc) ,
197+ } ) ;
198+ if let Some ( pagination_key) = info. pagination_key . as_deref ( ) {
199+ payload[ "pagination_key" ] = serde_json:: json!( pagination_key) ;
200+ }
201+ println ! ( "{}" , serde_json:: to_string( & payload) ?) ;
184202 } else {
185203 match args. format {
186- TimeOutputFormat :: Iso => println ! ( "{iso}" ) ,
187- TimeOutputFormat :: Unix => println ! ( "{unix_seconds}" ) ,
204+ TimeOutputFormat :: Iso => println ! ( "{}" , display_iso ( & info , args . utc ) ) ,
205+ TimeOutputFormat :: Unix => println ! ( "{}" , info . unix_seconds ) ,
188206 }
189207 }
190208 Ok ( ( ) )
@@ -218,41 +236,72 @@ fn run_from_time(json: bool, args: FromTimeArgs) -> Result<()> {
218236fn run_inspect ( json : bool , args : InspectArgs ) -> Result < ( ) > {
219237 let info = inspect_xact_like_input ( & args. value ) ?;
220238 if json {
221- println ! ( "{}" , serde_json:: to_string( & info) ?) ;
239+ let mut payload = serde_json:: to_value ( & info) ?;
240+ payload[ "iso" ] = serde_json:: json!( display_iso( & info, args. utc) ) ;
241+ payload[ "timezone" ] = serde_json:: json!( timezone_label( args. utc) ) ;
242+ println ! ( "{}" , serde_json:: to_string( & payload) ?) ;
222243 } else {
223- println ! (
224- "Input kind: {}\n Xact ID: {}\n Pretty version: {}\n Unix seconds: {}\n ISO UTC: {}\n Counter: {}" ,
225- match info. input_kind {
226- InputKind :: XactId => "xact_id" ,
227- InputKind :: PrettyVersionId => "pretty_version_id" ,
228- } ,
229- info. xact_id,
230- info. pretty_version,
231- info. unix_seconds,
232- info. iso_utc,
233- info. counter
234- ) ;
244+ let mut lines = vec ! [
245+ format!( "Input kind: {}" , input_kind_label( info. input_kind) ) ,
246+ format!( "Xact ID: {}" , info. xact_id) ,
247+ format!( "Pretty version: {}" , info. pretty_version) ,
248+ format!( "Unix seconds: {}" , info. unix_seconds) ,
249+ format!(
250+ "{}: {}" ,
251+ iso_display_label( args. utc) ,
252+ display_iso( & info, args. utc)
253+ ) ,
254+ format!( "Counter: {}" , info. counter) ,
255+ ] ;
256+ if let Some ( pagination_key) = info. pagination_key {
257+ lines. insert ( 1 , format ! ( "Pagination key: {pagination_key}" ) ) ;
258+ }
259+ if let Some ( row_num) = info. pagination_row_num {
260+ lines. push ( format ! ( "Pagination row number: {row_num}" ) ) ;
261+ }
262+ println ! ( "{}" , lines. join( "\n " ) ) ;
235263 }
236264 Ok ( ( ) )
237265}
238266
239267fn inspect_xact_like_input ( value : & str ) -> Result < XactInfo > {
240- let ( input_kind, xact_id) = if is_pretty_version ( value) {
241- ( InputKind :: PrettyVersionId , load_pretty_xact ( value) ?)
268+ let is_pagination_key = is_pagination_key_like ( value) ;
269+ let ( input_kind, xact_id, pagination_key, pagination_row_num) = if is_pagination_key {
270+ let parsed = parse_pagination_key ( value) ?;
271+ let unix_seconds = pagination_key_to_unix_seconds ( parsed) ;
272+ let counter = pagination_key_xact_counter ( parsed) ;
273+ let xact_id = build_xact_id ( unix_seconds, counter) ;
274+ (
275+ InputKind :: PaginationKey ,
276+ xact_id. to_string ( ) ,
277+ Some ( format_pagination_key ( parsed) ) ,
278+ Some ( pagination_key_row_num ( parsed) ) ,
279+ )
280+ } else if is_pretty_version ( value) {
281+ (
282+ InputKind :: PrettyVersionId ,
283+ load_pretty_xact ( value) ?,
284+ None ,
285+ None ,
286+ )
242287 } else {
243288 let parsed = parse_xact_id ( value) ?;
244- ( InputKind :: XactId , parsed. to_string ( ) )
289+ ( InputKind :: XactId , parsed. to_string ( ) , None , None )
245290 } ;
246291
247292 let xact = parse_xact_id ( & xact_id) ?;
248293 let unix_seconds = xact_to_unix_seconds ( xact) ;
249- let iso_utc = unix_seconds_to_iso ( unix_seconds) ?;
294+ let iso_utc = unix_seconds_to_iso_utc ( unix_seconds) ?;
295+ let iso_local = unix_seconds_to_iso_local ( unix_seconds) ?;
250296 Ok ( XactInfo {
251297 input_kind,
252298 xact_id,
253299 pretty_version : prettify_xact ( xact) ,
300+ pagination_key,
301+ pagination_row_num,
254302 unix_seconds,
255303 iso_utc,
304+ iso_local,
256305 counter : xact_counter ( xact) ,
257306 } )
258307}
@@ -270,6 +319,23 @@ fn parse_xact_id(value: &str) -> Result<u64> {
270319 . with_context ( || format ! ( "invalid transaction id '{value}'" ) )
271320}
272321
322+ fn parse_pagination_key ( value : & str ) -> Result < u64 > {
323+ let numeric = value
324+ . strip_prefix ( 'p' )
325+ . or_else ( || value. strip_prefix ( 'P' ) )
326+ . ok_or_else ( || {
327+ anyhow ! ( "invalid pagination key '{value}' (expected p followed by digits)" )
328+ } ) ?;
329+
330+ if numeric. is_empty ( ) || !numeric. chars ( ) . all ( |c| c. is_ascii_digit ( ) ) {
331+ bail ! ( "invalid pagination key '{value}' (expected p followed by digits)" ) ;
332+ }
333+
334+ numeric
335+ . parse :: < u64 > ( )
336+ . with_context ( || format ! ( "invalid pagination key '{value}'" ) )
337+ }
338+
273339fn parse_timestamp ( value : & str , input : TimeInputFormat ) -> Result < u64 > {
274340 match input {
275341 TimeInputFormat :: Unix => value
@@ -335,17 +401,79 @@ fn build_xact_id(unix_seconds: u64, counter: u16) -> u64 {
335401 TOP_BITS | ( ( unix_seconds & 0xffff_ffff_ffff ) << 16 ) | u64:: from ( counter)
336402}
337403
338- fn unix_seconds_to_iso ( unix_seconds : u64 ) -> Result < String > {
404+ fn format_pagination_key ( pagination_key : u64 ) -> String {
405+ format ! ( "p{pagination_key:020}" )
406+ }
407+
408+ fn pagination_key_to_unix_seconds ( pagination_key : u64 ) -> u64 {
409+ pagination_key >> 32
410+ }
411+
412+ fn pagination_key_xact_counter ( pagination_key : u64 ) -> u16 {
413+ ( ( pagination_key >> 16 ) & 0xffff ) as u16
414+ }
415+
416+ fn pagination_key_row_num ( pagination_key : u64 ) -> u16 {
417+ ( pagination_key & 0xffff ) as u16
418+ }
419+
420+ fn unix_seconds_to_utc_datetime ( unix_seconds : u64 ) -> Result < DateTime < Utc > > {
339421 let dt = DateTime :: < Utc > :: from_timestamp ( unix_seconds as i64 , 0 ) . ok_or_else ( || {
340422 anyhow ! ( "cannot represent unix timestamp as UTC datetime: {unix_seconds}" )
341423 } ) ?;
424+ Ok ( dt)
425+ }
426+
427+ fn unix_seconds_to_iso_utc ( unix_seconds : u64 ) -> Result < String > {
428+ let dt = unix_seconds_to_utc_datetime ( unix_seconds) ?;
429+ Ok ( dt. to_rfc3339_opts ( SecondsFormat :: Secs , true ) )
430+ }
431+
432+ fn unix_seconds_to_iso_local ( unix_seconds : u64 ) -> Result < String > {
433+ let dt = unix_seconds_to_utc_datetime ( unix_seconds) ?. with_timezone ( & Local ) ;
342434 Ok ( dt. to_rfc3339_opts ( SecondsFormat :: Secs , true ) )
343435}
344436
345437fn is_pretty_version ( value : & str ) -> bool {
346438 value. len ( ) == 16 && value. chars ( ) . all ( |c| c. is_ascii_hexdigit ( ) )
347439}
348440
441+ fn is_pagination_key_like ( value : & str ) -> bool {
442+ value. starts_with ( 'p' ) || value. starts_with ( 'P' )
443+ }
444+
445+ fn input_kind_label ( input_kind : InputKind ) -> & ' static str {
446+ match input_kind {
447+ InputKind :: XactId => "xact_id" ,
448+ InputKind :: PrettyVersionId => "pretty_version_id" ,
449+ InputKind :: PaginationKey => "pagination_key" ,
450+ }
451+ }
452+
453+ fn display_iso ( info : & XactInfo , utc : bool ) -> & str {
454+ if utc {
455+ & info. iso_utc
456+ } else {
457+ & info. iso_local
458+ }
459+ }
460+
461+ fn timezone_label ( utc : bool ) -> & ' static str {
462+ if utc {
463+ "utc"
464+ } else {
465+ "local"
466+ }
467+ }
468+
469+ fn iso_display_label ( utc : bool ) -> & ' static str {
470+ if utc {
471+ "ISO UTC"
472+ } else {
473+ "ISO local"
474+ }
475+ }
476+
349477fn current_unix_seconds ( ) -> u64 {
350478 Utc :: now ( ) . timestamp ( ) . max ( 0 ) as u64
351479}
@@ -395,6 +523,32 @@ mod tests {
395523 assert_eq ! ( info. xact_id, "1000192656880881099" ) ;
396524 }
397525
526+ #[ test]
527+ fn inspect_decodes_pagination_key_time ( ) {
528+ let info = inspect_xact_like_input ( "p07639577379371417602" ) . unwrap ( ) ;
529+ assert ! ( matches!( info. input_kind, InputKind :: PaginationKey ) ) ;
530+ assert_eq ! (
531+ info. pagination_key. as_deref( ) ,
532+ Some ( "p07639577379371417602" )
533+ ) ;
534+ assert_eq ! ( info. xact_id, "1000197162952719243" ) ;
535+ assert_eq ! ( info. unix_seconds, 1_778_727_718 ) ;
536+ assert_eq ! ( info. iso_utc, "2026-05-14T03:01:58Z" ) ;
537+ assert_eq ! ( info. counter, 31_627 ) ;
538+ assert_eq ! ( info. pagination_row_num, Some ( 2 ) ) ;
539+ }
540+
541+ #[ test]
542+ fn pagination_key_parser_accepts_short_form ( ) {
543+ let info = inspect_xact_like_input ( "p0" ) . unwrap ( ) ;
544+ assert ! ( matches!( info. input_kind, InputKind :: PaginationKey ) ) ;
545+ assert_eq ! (
546+ info. pagination_key. as_deref( ) ,
547+ Some ( "p00000000000000000000" )
548+ ) ;
549+ assert_eq ! ( info. unix_seconds, 0 ) ;
550+ }
551+
398552 #[ test]
399553 fn parse_iso_date_without_time ( ) {
400554 assert_eq ! (
0 commit comments