seq date: generalize to allow any duration for --increment argument (#14903)

<!--
if this PR closes one or more issues, you can automatically link the PR
with
them by using one of the [*linking
keywords*](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword),
e.g.
- this PR should close #xxxx
- fixes #xxxx

you can also mention related issues, PRs or discussions!
-->

# Description
<!--
Thank you for improving Nushell. Please, check our [contributing
guide](../CONTRIBUTING.md) and talk to the core team before making major
changes.

Description of your pull request goes here. **Provide examples and/or
screenshots** if your changes affect the user experience.
-->
This PR seeks to generalize the `seq date` command so that it can
receive any duration as an `--increment`. Whereas the current command
can only output a list of dates spaced at least 1 day apart, the new
command can output a list of datetimes that are spaced apart by any
duration.

For example:
```
> seq date --begin-date 2025-01-01 --end-date 2025-01-02 --increment 6hr --output-format "%Y-%m-%d %H:%M:%S"
╭───┬─────────────────────╮
│ 0 │ 2025-01-01 00:00:00 │
│ 1 │ 2025-01-01 06:00:00 │
│ 2 │ 2025-01-01 12:00:00 │
│ 3 │ 2025-01-01 18:00:00 │
│ 4 │ 2025-01-02 00:00:00 │
╰───┴─────────────────────╯
```

Note that the default behavior remains unchanged:
```
> seq date --begin-date 2025-01-01 --end-date 2025-01-02
╭───┬────────────╮
│ 0 │ 2025-01-01 │
│ 1 │ 2025-01-02 │
╰───┴────────────╯
```

The default output format also remains unchanged:
```
> seq date --begin-date 2025-01-01 --end-date 2025-01-02 --increment 6hr
╭───┬────────────╮
│ 0 │ 2025-01-01 │
│ 1 │ 2025-01-01 │
│ 2 │ 2025-01-01 │
│ 3 │ 2025-01-01 │
│ 4 │ 2025-01-02 │
╰───┴────────────╯
```

# User-Facing Changes
<!-- List of all changes that impact the user experience here. This
helps us keep track of breaking changes. -->

## Breaking Changes
* The `--increment` argument no longer accepts just an integer and
requires a duration

```
# NEW BEHAVIOR
> seq date --begin-date 2025-01-01 --end-date 2025-01-02 --increment 1

Error: nu::parser::parse_mismatch

  × Parse mismatch during operation.
   ╭─[entry #13:1:68]
 1 │ seq date --begin-date 2025-01-01 --end-date 2025-01-02 --increment 1
   ·                                                                    ┬
   ·                                                                    ╰── expected duration with valid units
   ╰────
```

EDIT: Break Change is mitigated. `--increment` accepts either an integer
or duration.

## Bug Fix
* The `--days` argument had an off-by-one error and would print 1 too
many elements in the output. For example,

```
# OLD BEHAVIOR
> seq date -b 2025-01-01 --days 5 --increment 1
╭───┬────────────╮
│ 0 │ 2025-01-01 │
│ 1 │ 2025-01-02 │
│ 2 │ 2025-01-03 │
│ 3 │ 2025-01-04 │
│ 4 │ 2025-01-05 │
│ 5 │ 2025-01-06 │ <-- Extra element
╰───┴────────────╯

# NEW BEHAVIOR
> seq date -b 2025-01-01 --days 5 --increment 1day
╭───┬────────────╮
│ 0 │ 2025-01-01 │
│ 1 │ 2025-01-02 │
│ 2 │ 2025-01-03 │
│ 3 │ 2025-01-04 │
│ 4 │ 2025-01-05 │
╰───┴────────────╯
```

## New Argument
* A `--periods` argument is introduced to indicate the number of output
elements, regardless of the `--increment` value. Importantly, the
`--days` argument is ignored when `--periods` is set.
```
# NEW BEHAVIOR
> seq date -b 2025-01-01 --days 5 --periods 10 --increment 1day
╭───┬────────────╮
│ 0 │ 2025-01-01 │
│ 1 │ 2025-01-02 │
│ 2 │ 2025-01-03 │
│ 3 │ 2025-01-04 │
│ 4 │ 2025-01-05 │
│ 5 │ 2025-01-06 │
│ 6 │ 2025-01-07 │
│ 7 │ 2025-01-08 │
│ 8 │ 2025-01-09 │
│ 9 │ 2025-01-10 │
╰───┴────────────╯
```

Note that the `--days` and `--periods` arguments differ in their
functions. The `--periods` value determines the number of elements in
the output that are always spaced `--increment` apart. The `--days`
value determines the bookends `--begin-date` and `--end-date` when only
one is set, though the number of elements may differ based on the
`--increment` value.

```
# NEW BEHAVIOR
> seq date -e 2025-01-01 --days 2 --increment 5hr --output-format "%Y-%m-%d %H:%M:%S"

╭───┬─────────────────────╮
│ 0 │ 2025-01-23 22:25:05 │
│ 1 │ 2025-01-24 03:25:05 │
│ 2 │ 2025-01-24 08:25:05 │
│ 3 │ 2025-01-24 13:25:05 │
│ 4 │ 2025-01-24 18:25:05 │
╰───┴─────────────────────╯
```

# Tests + Formatting
<!--
Don't forget to add tests that cover your changes.

Make sure you've run and fixed any issues with these commands:

- `cargo fmt --all -- --check` to check standard code formatting (`cargo
fmt --all` applies these changes)
- `cargo clippy --workspace -- -D warnings -D clippy::unwrap_used` to
check that you're using the standard code style
- `cargo test --workspace` to check that all tests pass (on Windows make
sure to [enable developer
mode](https://learn.microsoft.com/en-us/windows/apps/get-started/developer-mode-features-and-debugging))
- `cargo run -- -c "use toolkit.nu; toolkit test stdlib"` to run the
tests for the standard library

> **Note**
> from `nushell` you can also use the `toolkit` as follows
> ```bash
> use toolkit.nu # or use an `env_change` hook to activate it
automatically
> toolkit check pr
> ```
-->

I added several examples for each user-facing change in
`generators/seq_date.rs` and some tests in `tests/commands/seq_date.rs`.

# After Submitting
<!-- If your PR had any user-facing changes, update [the
documentation](https://github.com/nushell/nushell.github.io) after the
PR is merged, if necessary. This will help us keep the docs up to date.
-->
This commit is contained in:
pyz4 2025-01-25 14:24:39 -05:00 committed by GitHub
parent 22a01d7e76
commit 926b0407c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 175 additions and 42 deletions

View File

@ -1,9 +1,11 @@
use chrono::{Duration, Local, NaiveDate};
use chrono::{Duration, Local, NaiveDate, NaiveDateTime};
use nu_engine::command_prelude::*;
use nu_protocol::FromValue;
use std::fmt::Write;
const NANOSECONDS_IN_DAY: i64 = 1_000_000_000i64 * 60i64 * 60i64 * 24i64;
#[derive(Clone)]
pub struct SeqDate;
@ -40,16 +42,22 @@ impl Command for SeqDate {
.named("end-date", SyntaxShape::String, "ending date", Some('e'))
.named(
"increment",
SyntaxShape::Int,
"increment dates by this number",
SyntaxShape::OneOf(vec![SyntaxShape::Duration, SyntaxShape::Int]),
"increment dates by this duration (defaults to days if integer)",
Some('n'),
)
.named(
"days",
SyntaxShape::Int,
"number of days to print",
"number of days to print (ignored if periods is used)",
Some('d'),
)
.named(
"periods",
SyntaxShape::Int,
"number of periods to print",
Some('p'),
)
.switch("reverse", "print dates in reverse", Some('r'))
.category(Category::Generators)
}
@ -74,7 +82,8 @@ impl Command for SeqDate {
},
Example {
description: "Return the first 10 days in January, 2020",
example: "seq date --begin-date '2020-01-01' --end-date '2020-01-10'",
example:
"seq date --begin-date '2020-01-01' --end-date '2020-01-10' --increment 1day",
result: Some(Value::list(
vec![
Value::test_string("2020-01-01"),
@ -91,9 +100,45 @@ impl Command for SeqDate {
Span::test_data(),
)),
},
Example {
description: "Return the first 10 days in January, 2020 using --days flag",
example:
"seq date --begin-date '2020-01-01' --days 10 --increment 1day",
result: Some(Value::list(
vec![
Value::test_string("2020-01-01"),
Value::test_string("2020-01-02"),
Value::test_string("2020-01-03"),
Value::test_string("2020-01-04"),
Value::test_string("2020-01-05"),
Value::test_string("2020-01-06"),
Value::test_string("2020-01-07"),
Value::test_string("2020-01-08"),
Value::test_string("2020-01-09"),
Value::test_string("2020-01-10"),
],
Span::test_data(),
)),
},
Example {
description: "Return the first five 5-minute periods starting January 1, 2020",
example:
"seq date --begin-date '2020-01-01' --periods 5 --increment 5min --output-format '%Y-%m-%d %H:%M:%S'",
result: Some(Value::list(
vec![
Value::test_string("2020-01-01 00:00:00"),
Value::test_string("2020-01-01 00:05:00"),
Value::test_string("2020-01-01 00:10:00"),
Value::test_string("2020-01-01 00:15:00"),
Value::test_string("2020-01-01 00:20:00"),
],
Span::test_data(),
)),
},
Example {
description: "print every fifth day between January 1st 2020 and January 31st 2020",
example: "seq date --begin-date '2020-01-01' --end-date '2020-01-31' --increment 5",
example:
"seq date --begin-date '2020-01-01' --end-date '2020-01-31' --increment 5day",
result: Some(Value::list(
vec![
Value::test_string("2020-01-01"),
@ -107,6 +152,42 @@ impl Command for SeqDate {
Span::test_data(),
)),
},
Example {
description: "increment defaults to days if no duration is supplied",
example:
"seq date --begin-date '2020-01-01' --end-date '2020-01-31' --increment 5",
result: Some(Value::list(
vec![
Value::test_string("2020-01-01"),
Value::test_string("2020-01-06"),
Value::test_string("2020-01-11"),
Value::test_string("2020-01-16"),
Value::test_string("2020-01-21"),
Value::test_string("2020-01-26"),
Value::test_string("2020-01-31"),
],
Span::test_data(),
)),
},
Example {
description: "print every six hours starting January 1st, 2020 until January 3rd, 2020",
example:
"seq date --begin-date '2020-01-01' --end-date '2020-01-03' --increment 6hr --output-format '%Y-%m-%d %H:%M:%S'",
result: Some(Value::list(
vec![
Value::test_string("2020-01-01 00:00:00"),
Value::test_string("2020-01-01 06:00:00"),
Value::test_string("2020-01-01 12:00:00"),
Value::test_string("2020-01-01 18:00:00"),
Value::test_string("2020-01-02 00:00:00"),
Value::test_string("2020-01-02 06:00:00"),
Value::test_string("2020-01-02 12:00:00"),
Value::test_string("2020-01-02 18:00:00"),
Value::test_string("2020-01-03 00:00:00"),
],
Span::test_data(),
)),
},
]
}
@ -124,8 +205,28 @@ impl Command for SeqDate {
let begin_date: Option<Spanned<String>> =
call.get_flag(engine_state, stack, "begin-date")?;
let end_date: Option<Spanned<String>> = call.get_flag(engine_state, stack, "end-date")?;
let increment: Option<Spanned<i64>> = call.get_flag(engine_state, stack, "increment")?;
let increment = match call.get_flag::<Value>(engine_state, stack, "increment")? {
Some(increment) => match increment {
Value::Int { val, internal_span } => Some(
val.checked_mul(NANOSECONDS_IN_DAY)
.ok_or_else(|| ShellError::GenericError {
error: "increment is too large".into(),
msg: "increment is too large".into(),
span: Some(internal_span),
help: None,
inner: vec![],
})?
.into_spanned(internal_span),
),
Value::Duration { val, internal_span } => Some(val.into_spanned(internal_span)),
_ => None,
},
None => None,
};
let days: Option<Spanned<i64>> = call.get_flag(engine_state, stack, "days")?;
let periods: Option<Spanned<i64>> = call.get_flag(engine_state, stack, "periods")?;
let reverse = call.has_flag(engine_state, stack, "reverse")?;
let out_format = match output_format {
@ -150,29 +251,41 @@ impl Command for SeqDate {
let inc = match increment {
Some(i) => Value::int(i.item, i.span),
_ => Value::int(1_i64, call.head),
_ => Value::int(NANOSECONDS_IN_DAY, call.head),
};
let day_count = days.map(|i| Value::int(i.item, i.span));
let period_count = periods.map(|i| Value::int(i.item, i.span));
let mut rev = false;
if reverse {
rev = reverse;
}
Ok(run_seq_dates(
out_format, in_format, begin, end, inc, day_count, rev, call.head,
out_format,
in_format,
begin,
end,
inc,
day_count,
period_count,
rev,
call.head,
)?
.into_pipeline_data())
}
}
pub fn parse_date_string(s: &str, format: &str) -> Result<NaiveDate, &'static str> {
let d = match NaiveDate::parse_from_str(s, format) {
Ok(d) => d,
Err(_) => return Err("Failed to parse date."),
};
Ok(d)
#[allow(clippy::unnecessary_lazy_evaluations)]
pub fn parse_date_string(s: &str, format: &str) -> Result<NaiveDateTime, &'static str> {
NaiveDateTime::parse_from_str(s, format).or_else(|_| {
// If parsing as DateTime fails, try parsing as Date before throwing error
let date = NaiveDate::parse_from_str(s, format).map_err(|_| "Failed to parse date.")?;
date.and_hms_opt(0, 0, 0)
.ok_or_else(|| "Failed to convert NaiveDate to NaiveDateTime.")
})
}
#[allow(clippy::too_many_arguments)]
@ -183,10 +296,11 @@ pub fn run_seq_dates(
ending_date: Option<String>,
increment: Value,
day_count: Option<Value>,
period_count: Option<Value>,
reverse: bool,
call_span: Span,
) -> Result<Value, ShellError> {
let today = Local::now().date_naive();
let today = Local::now().naive_local();
// if cannot convert , it will return error
let increment_span = increment.span();
let mut step_size: i64 = i64::from_value(increment)?;
@ -270,27 +384,44 @@ pub fn run_seq_dates(
None => 0i64,
};
let mut periods_to_output = match period_count {
Some(d) => i64::from_value(d)?,
None => 0i64,
};
// Make the signs opposite if we're created dates in reverse direction
if reverse {
step_size *= -1;
days_to_output *= -1;
periods_to_output *= -1;
}
if days_to_output != 0 {
end_date = match Duration::try_days(days_to_output)
// --days is ignored when --periods is set
if periods_to_output != 0 {
end_date = periods_to_output
.checked_sub(1)
.and_then(|val| val.checked_mul(step_size.abs()))
.map(Duration::nanoseconds)
.and_then(|inc| start_date.checked_add_signed(inc))
.ok_or_else(|| ShellError::GenericError {
error: "incrementing by the number of periods is too large".into(),
msg: "incrementing by the number of periods is too large".into(),
span: Some(call_span),
help: None,
inner: vec![],
})?;
} else if days_to_output != 0 {
end_date = days_to_output
.checked_sub(1)
.and_then(Duration::try_days)
.and_then(|days| start_date.checked_add_signed(days))
{
Some(date) => date,
None => {
return Err(ShellError::GenericError {
error: "int value too large".into(),
msg: "int value too large".into(),
span: Some(call_span),
help: None,
inner: vec![],
});
}
}
.ok_or_else(|| ShellError::GenericError {
error: "int value too large".into(),
msg: "int value too large".into(),
span: Some(call_span),
help: None,
inner: vec![],
})?;
}
// conceptually counting down with a positive step or counting up with a negative step
@ -302,15 +433,8 @@ pub fn run_seq_dates(
let is_out_of_range =
|next| (step_size > 0 && next > end_date) || (step_size < 0 && next < end_date);
let Some(step_size) = Duration::try_days(step_size) else {
return Err(ShellError::GenericError {
error: "increment magnitude is too large".into(),
msg: "increment magnitude is too large".into(),
span: Some(call_span),
help: None,
inner: vec![],
});
};
// Bounds are enforced by i64 conversion above
let step_size = Duration::nanoseconds(step_size);
let mut next = start_date;
if is_out_of_range(next) {

View File

@ -1,8 +1,17 @@
use nu_test_support::nu;
#[test]
fn fails_when_output_format_contains_time() {
let actual = nu!("seq date --output-format '%H-%M-%S'");
fn fails_on_datetime_input() {
let actual = nu!("seq date --begin-date (date now)");
assert!(actual.err.contains("Invalid output format"));
assert!(actual.err.contains("Type mismatch"))
}
#[test]
fn fails_when_increment_not_integer_or_duration() {
let actual = nu!("seq date --begin-date 2020-01-01 --increment 1.1");
assert!(actual
.err
.contains("expected one of a list of accepted shapes: [Duration, Int]"))
}