Skip to content

Commit 21a54d4

Browse files
maxim-uvarovclaudeclaude
authored
Add %J and %Q format specifiers for compact date/time formatting (nushell#16588)
## Summary Adds two new format specifiers to the `format date` and `into datetime` commands that provide compact, human-readable date and time formatting with improved modularity: - **%J**: "Joined date" format - expands to `%Y%m%d` producing compact dates like `20211022` - **%Q**: "seQuential time" format - expands to `%H%M%S` producing compact times like `200012` - **Use case**: Ideal for flexible timestamp formatting, backup files, logs, and sortable date/time components - **Rationale**: Replaces single `%K` format with modular date/time components after maintainer feedback - **Flexibility**: Can be used individually or combined (e.g., `%J_%Q`, `%J-%Q`) for custom formats - **Consistency**: Both `format date` and `into datetime` use the same format specifications ## Examples ```nushell # Individual date formatting "2021-10-22 20:00:12 +01:00" | format date "%J" # Output: 20211022 # Individual time formatting "2021-10-22 20:00:12 +01:00" | format date "%Q" # Output: 200012 # Combined for full timestamp (equivalent to previous %K) "2021-10-22 20:00:12 +01:00" | format date "%J_%Q" # Output: 20211022_200012 # Parse compact date with into datetime "20211022" | into datetime --format "%J" # Parse compact time with into datetime "200012" | into datetime --format "%Q" # Round-trip functionality "2021-10-22 20:00:12 +01:00" | format date "%J_%Q" | into datetime --format "%J_%Q" | format date "%J_%Q" # Output: 20211022_200012 # Custom separators for different use cases date now | format date "backup_%J-%Q.sql" # Output: backup_20250918-131144.sql # Date-only file naming date now | format date "report_%J.txt" # Output: report_20250918.txt ``` ## Release notes summary - What our users need to know The `format date` and `into datetime` commands now support two new format specifiers for creating compact, sortable date and time components: - **%J** produces compact dates in `YYYYMMDD` format (e.g., `20250918`) - **%Q** produces compact times in `HHMMSS` format (e.g., `131144`) These can be used individually for date-only or time-only formatting, or combined for full timestamps (`%J_%Q` produces `20250918_131144`), providing more flexibility than a single combined format. Perfect for: - Backup file naming with custom separators - Log file timestamps with flexible formatting - Date-only or time-only compact representations - Any scenario requiring sortable, human-readable date/time components Both commands use the same format specifications, ensuring consistency when parsing and formatting dates. This addition is fully backward compatible - all existing format specifiers continue to work unchanged. ## Implementation Details - **Preprocessing approach**: Replaces `%J` with `%Y%m%d` and `%Q` with `%H%M%S` before passing to chrono - **Help integration**: Both specifiers appear in `format date -l` with proper example generation - **Letter choice rationale**: - `%J` for "Joined date" - unused in major standards (lowercase %j is day-of-year) - `%Q` for "seQuential time" - unused in major time formatting standards - **Modular design**: Users can format dates and times independently or combine as needed - **Consistent behavior**: Same format handling in both `format date` and `into datetime` - **No breaking changes**: Existing functionality remains unchanged - **Performance**: Minimal overhead, only processes when specifiers are used ## Test Results - ✅ `format date -l` shows both new specifications correctly - ✅ `%J` format produces expected date format `YYYYMMDD` - ✅ `%Q` format produces expected time format `HHMMSS` - ✅ `%J_%Q` combined produces expected timestamp format - ✅ `into datetime --format "%J"` parses compact dates correctly - ✅ `into datetime --format "%Q"` parses compact times correctly - ✅ `into datetime --format "%J_%Q"` parses compact timestamps correctly - ✅ Round-trip functionality between both commands works for all formats - ✅ Individual and combined format specifiers work with current time - ✅ No regression in existing format specifiers - ✅ All existing tests pass with updated test suite - ✅ Comprehensive test coverage for new functionality --------- Co-authored-by: claude <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent 9ebb02d commit 21a54d4

File tree

5 files changed

+113
-12
lines changed

5 files changed

+113
-12
lines changed

crates/nu-command/src/conversions/into/datetime.rs

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -422,9 +422,15 @@ fn action(input: &Value, args: &Arguments, head: Span) -> Value {
422422

423423
let parse_as_string = |val: &str| {
424424
match dateformat {
425-
Some(dt_format) => match DateTime::parse_from_str(val, &dt_format.item.0) {
426-
Ok(dt) => {
427-
match timezone {
425+
Some(dt_format) => {
426+
// Handle custom format specifiers for compact formats
427+
let format_str = dt_format
428+
.item
429+
.0
430+
.replace("%J", "%Y%m%d") // %J for joined date (YYYYMMDD)
431+
.replace("%Q", "%H%M%S"); // %Q for sequential time (HHMMSS)
432+
match DateTime::parse_from_str(val, &format_str) {
433+
Ok(dt) => match timezone {
428434
None => Value::date(dt, head),
429435
Some(Spanned { item, span }) => match item {
430436
Zone::Utc => Value::date(dt, head),
@@ -464,10 +470,8 @@ fn action(input: &Value, args: &Arguments, head: Span) -> Value {
464470
*span,
465471
),
466472
},
467-
}
468-
}
469-
Err(reason) => {
470-
parse_with_format(val, &dt_format.item.0, head).unwrap_or_else(|_| {
473+
},
474+
Err(reason) => parse_with_format(val, &format_str, head).unwrap_or_else(|_| {
471475
Value::error(
472476
ShellError::CantConvert {
473477
to_type: format!(
@@ -478,15 +482,15 @@ fn action(input: &Value, args: &Arguments, head: Span) -> Value {
478482
span: head,
479483
help: Some(
480484
"you can use `into datetime` without a format string to \
481-
enable flexible parsing"
485+
enable flexible parsing"
482486
.to_string(),
483487
),
484488
},
485489
head,
486490
)
487-
})
491+
}),
488492
}
489-
},
493+
}
490494

491495
// Tries to automatically parse the date
492496
// (i.e. without a format string)

crates/nu-command/src/date/utils.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,14 @@ pub(crate) fn generate_strftime_list(head: Span, show_parse_only_formats: bool)
247247
spec: "%s",
248248
description: "UNIX timestamp, the number of seconds since 1970-01-01",
249249
},
250+
FormatSpecification {
251+
spec: "%J",
252+
description: "Joined date format. Same as %Y%m%d.",
253+
},
254+
FormatSpecification {
255+
spec: "%Q",
256+
description: "Sequential time format. Same as %H%M%S.",
257+
},
250258
FormatSpecification {
251259
spec: "%t",
252260
description: "Literal tab (\\t).",
@@ -264,10 +272,17 @@ pub(crate) fn generate_strftime_list(head: Span, show_parse_only_formats: bool)
264272
let mut records = specifications
265273
.iter()
266274
.map(|s| {
275+
// Handle custom format specifiers that aren't supported by chrono
276+
let example = match s.spec {
277+
"%J" => now.format("%Y%m%d").to_string(),
278+
"%Q" => now.format("%H%M%S").to_string(),
279+
_ => now.format(s.spec).to_string(),
280+
};
281+
267282
Value::record(
268283
record! {
269284
"Specification" => Value::string(s.spec, head),
270-
"Example" => Value::string(now.format(s.spec).to_string(), head),
285+
"Example" => Value::string(example, head),
271286
"Description" => Value::string(s.description, head),
272287
},
273288
head,

crates/nu-command/src/strings/format/date.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,11 @@ where
199199
Tz::Offset: Display,
200200
{
201201
let mut formatter_buf = String::new();
202-
let format = date_time.format_localized(formatter, locale);
202+
// Handle custom format specifiers for compact formats
203+
let processed_formatter = formatter
204+
.replace("%J", "%Y%m%d") // %J for joined date (YYYYMMDD)
205+
.replace("%Q", "%H%M%S"); // %Q for sequential time (HHMMSS)
206+
let format = date_time.format_localized(&processed_formatter, locale);
203207

204208
match formatter_buf.write_fmt(format_args!("{format}")) {
205209
Ok(_) => Value::string(formatter_buf, span),

crates/nu-command/tests/commands/date/format.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,43 @@ fn formatter_not_valid() {
99
assert!(actual.err.contains("invalid format"));
1010
}
1111

12+
#[test]
13+
fn test_j_q_format_specifiers() {
14+
let actual = nu!(r#"
15+
"2021-10-22 20:00:12 +01:00" | format date '%J_%Q'
16+
"#);
17+
18+
assert_eq!(actual.out, "20211022_200012");
19+
}
20+
21+
#[test]
22+
fn test_j_q_format_specifiers_current_time() {
23+
let actual = nu!(r#"
24+
date now | format date '%J_%Q' | str length
25+
"#);
26+
27+
// Should be exactly 15 characters: YYYYMMDD_HHMMSS
28+
assert_eq!(actual.out, "15");
29+
}
30+
31+
#[test]
32+
fn test_j_format_specifier_date_only() {
33+
let actual = nu!(r#"
34+
"2021-10-22 20:00:12 +01:00" | format date '%J'
35+
"#);
36+
37+
assert_eq!(actual.out, "20211022");
38+
}
39+
40+
#[test]
41+
fn test_q_format_specifier_time_only() {
42+
let actual = nu!(r#"
43+
"2021-10-22 20:00:12 +01:00" | format date '%Q'
44+
"#);
45+
46+
assert_eq!(actual.out, "200012");
47+
}
48+
1249
#[test]
1350
fn fails_without_input() {
1451
let actual = nu!(r#"

crates/nu-command/tests/commands/into_datetime.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,44 @@ fn into_datetime_from_record_incompatible_with_offset_flag() {
119119

120120
assert!(actual.err.contains("nu::shell::incompatible_parameters"));
121121
}
122+
123+
#[test]
124+
fn test_j_q_format_specifiers_into_datetime() {
125+
let actual = nu!(r#"
126+
"20211022_200012" | into datetime --format '%J_%Q'
127+
"#);
128+
129+
// Check for the date components - the exact output format may vary
130+
assert!(actual.out.contains("22 Oct 2021") || actual.out.contains("2021-10-22"));
131+
assert!(actual.out.contains("20:00:12"));
132+
}
133+
134+
#[test]
135+
fn test_j_q_format_specifiers_round_trip() {
136+
let actual = nu!(r#"
137+
"2021-10-22 20:00:12 +01:00" | format date '%J_%Q' | into datetime --format '%J_%Q' | format date '%J_%Q'
138+
"#);
139+
140+
assert_eq!(actual.out, "20211022_200012");
141+
}
142+
143+
#[test]
144+
fn test_j_format_specifier_date_only() {
145+
let actual = nu!(r#"
146+
"20211022" | into datetime --format '%J'
147+
"#);
148+
149+
// Check for the date components - time should default to midnight
150+
assert!(actual.out.contains("22 Oct 2021") || actual.out.contains("2021-10-22"));
151+
assert!(actual.out.contains("00:00:00"));
152+
}
153+
154+
#[test]
155+
fn test_q_format_specifier_time_only() {
156+
let actual = nu!(r#"
157+
"200012" | into datetime --format '%Q'
158+
"#);
159+
160+
// Check for the time components - should parse as time with default date
161+
assert!(actual.out.contains("20:00:12"));
162+
}

0 commit comments

Comments
 (0)