-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Include the underlying reason in invalid --changed-before/-within date errors #2054
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
momomuchu
wants to merge
3
commits into
sharkdp:master
Choose a base branch
from
momomuchu:fix/invalid-date-error-message-2053
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+136
−33
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| use anyhow::Result; | ||
| use jiff::{Span, Timestamp, Zoned, civil::DateTime, tz::TimeZone}; | ||
|
|
||
| use std::time::{Duration, SystemTime, UNIX_EPOCH}; | ||
|
|
@@ -26,31 +27,46 @@ fn now() -> Zoned { | |
| } | ||
|
|
||
| impl TimeFilter { | ||
| fn from_str(s: &str) -> Option<SystemTime> { | ||
| /// Parses a duration/timestamp/date/`@`-prefixed unix-timestamp string. | ||
| /// | ||
| /// On failure, returns the underlying parse error from the `DateTime` | ||
| /// parser (the calendar-date format, and the most common source of | ||
| /// confusing failures, e.g. `2025-11-31` which is not a valid calendar | ||
| /// date) instead of silently discarding it, so callers can tell the | ||
| /// user *why* their input was rejected. | ||
| fn from_str(s: &str) -> Result<SystemTime> { | ||
| if let Ok(span) = s.parse::<Span>() { | ||
| let datetime = now().checked_sub(span).ok()?; | ||
| Some(datetime.into()) | ||
| } else if let Ok(timestamp) = s.parse::<Timestamp>() { | ||
| Some(timestamp.into()) | ||
| } else if let Ok(datetime) = s.parse::<DateTime>() { | ||
| Some( | ||
| TimeZone::system() | ||
| .to_ambiguous_zoned(datetime) | ||
| .later() | ||
| .ok()? | ||
| .into(), | ||
| ) | ||
| } else { | ||
| let timestamp_secs: u64 = s.strip_prefix('@')?.parse().ok()?; | ||
| Some(UNIX_EPOCH + Duration::from_secs(timestamp_secs)) | ||
| let datetime = now().checked_sub(span)?; | ||
| return Ok(datetime.into()); | ||
| } | ||
| if let Ok(timestamp) = s.parse::<Timestamp>() { | ||
| return Ok(timestamp.into()); | ||
| } | ||
| match s.parse::<DateTime>() { | ||
| Ok(datetime) => Ok(TimeZone::system() | ||
| .to_ambiguous_zoned(datetime) | ||
| .later()? | ||
| .into()), | ||
| Err(datetime_err) => { | ||
| if let Some(timestamp_secs) = s.strip_prefix('@') | ||
| && let Ok(timestamp_secs) = timestamp_secs.parse() | ||
| { | ||
| return Ok(UNIX_EPOCH + Duration::from_secs(timestamp_secs)); | ||
| } | ||
| // None of the supported formats matched. The `DateTime` | ||
| // parser gives the most useful reason for calendar-date- | ||
| // shaped input (the common case for this kind of mistake), | ||
| // so surface that instead of a generic message. | ||
| Err(datetime_err.into()) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| pub fn before(s: &str) -> Option<TimeFilter> { | ||
| pub fn before(s: &str) -> Result<TimeFilter> { | ||
| TimeFilter::from_str(s).map(TimeFilter::Before) | ||
| } | ||
|
|
||
| pub fn after(s: &str) -> Option<TimeFilter> { | ||
| pub fn after(s: &str) -> Result<TimeFilter> { | ||
| TimeFilter::from_str(s).map(TimeFilter::After) | ||
| } | ||
|
|
||
|
|
@@ -177,7 +193,7 @@ mod tests { | |
| let t1m_ago = ref_time - Duration::from_secs(60); | ||
| let t1s_later = ref_time + Duration::from_secs(1); | ||
| // Timestamp only supported via '@' prefix | ||
| assert!(TimeFilter::before(&ref_timestamp.to_string()).is_none()); | ||
| assert!(TimeFilter::before(&ref_timestamp.to_string()).is_err()); | ||
| assert!( | ||
| TimeFilter::before(&format!("@{ref_timestamp}")) | ||
| .unwrap() | ||
|
|
@@ -199,4 +215,86 @@ mod tests { | |
| .applies_to(&t1s_later) | ||
| ); | ||
| } | ||
|
|
||
| /// Length of the longest contiguous substring shared by `a` and `b`. | ||
| /// | ||
| /// Used to check that two error messages share substantial content | ||
| /// without hardcoding what that content actually says. | ||
| fn longest_common_substring_len(a: &str, b: &str) -> usize { | ||
| let a: Vec<char> = a.chars().collect(); | ||
| let b: Vec<char> = b.chars().collect(); | ||
| let mut prev = vec![0usize; b.len() + 1]; | ||
| let mut best = 0; | ||
| for i in 1..=a.len() { | ||
| let mut cur = vec![0usize; b.len() + 1]; | ||
| for j in 1..=b.len() { | ||
| if a[i - 1] == b[j - 1] { | ||
| cur[j] = prev[j - 1] + 1; | ||
| best = best.max(cur[j]); | ||
| } | ||
| } | ||
| prev = cur; | ||
| } | ||
| best | ||
| } | ||
|
|
||
| #[test] | ||
| fn invalid_calendar_date_error_includes_inner_reason() { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test seems a little brittle. If the jiff error messages change, then this test could fail on a version update. |
||
| // The error must surface *why* parsing failed, not merely echo the | ||
| // raw input back under a generic wrapper. We deliberately don't | ||
| // assert on jiff's exact wording (e.g. that it mentions "day"), | ||
| // since that would break on an unrelated jiff version bump. See | ||
| // https://github.com/sharkdp/fd/issues/2053 | ||
| // | ||
| // A naive check that two different invalid inputs produce two | ||
| // different messages is gameable: a regression that dropped the | ||
| // real parse reason but still echoed the input (e.g. | ||
| // `format!("could not parse '{s}' as a date")`) would still make | ||
| // the two messages differ, purely because the inputs differ, while | ||
| // never actually surfacing the reason. | ||
| // | ||
| // Instead, compare error text for inputs that are invalid for the | ||
| // *same* underlying reason against error text for an input that's | ||
| // invalid for a *different* reason: | ||
| // - "2025-11-31" and "2019-06-31" both fail because day 31 doesn't | ||
| // exist in a 30-day month (November / June), despite the raw | ||
| // strings barely overlapping (different year and month). | ||
| // - "2025-13-01" fails for an unrelated reason (month 13 doesn't | ||
| // exist). | ||
| // A message that actually carries the reason will make the first | ||
| // pair share a large chunk of text that the third message doesn't | ||
| // share. A message that's just the input echoed into a fixed | ||
| // template would make all three overlap by roughly the same | ||
| // (small) amount, since the only shared content would be the fixed | ||
| // wrapper text. | ||
| for filter in [TimeFilter::before, TimeFilter::after] { | ||
| let same_reason_a = filter("2025-11-31").unwrap_err().to_string(); | ||
| let same_reason_b = filter("2019-06-31").unwrap_err().to_string(); | ||
| let different_reason = filter("2025-13-01").unwrap_err().to_string(); | ||
|
|
||
| assert!(!same_reason_a.is_empty()); | ||
| assert_ne!( | ||
| same_reason_a, different_reason, | ||
| "distinct invalid inputs should surface distinct underlying reasons, not a shared generic message" | ||
| ); | ||
|
|
||
| let same_reason_overlap = longest_common_substring_len(&same_reason_a, &same_reason_b); | ||
| let different_reason_overlap = | ||
| longest_common_substring_len(&same_reason_a, &different_reason); | ||
| assert!( | ||
| same_reason_overlap >= 20, | ||
| "two dates invalid for the same reason should share substantial error text \ | ||
| (got only {same_reason_overlap} shared characters between {same_reason_a:?} \ | ||
| and {same_reason_b:?})" | ||
| ); | ||
| assert!( | ||
| same_reason_overlap > different_reason_overlap + 10, | ||
| "shared text between same-reason errors ({same_reason_overlap} chars) should \ | ||
| clearly exceed shared text between different-reason errors \ | ||
| ({different_reason_overlap} chars): {same_reason_a:?} vs {same_reason_b:?} vs \ | ||
| {different_reason:?}; a smaller gap suggests the message may just be echoing \ | ||
| the input rather than surfacing the actual reason" | ||
| ); | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This could however be more confusing if the user meant to use a span, or timestamp.