From 2df625fb02c789cb4fb6907533b9c305493164d7 Mon Sep 17 00:00:00 2001 From: metif12 Date: Sat, 3 Oct 2026 21:39:19 +0330 Subject: [PATCH 1/6] touch: declare SetFileTime's handle as voidptr so it compiles on Windows The Windows CI job has been failing on main, not just on branches: touch/touch_windows.c.v:17:20: error: cannot use oidptr as u32 in argument 1 to C.SetFileTime A HANDLE is a pointer. vlib's C.CreateFileW returns voidptr, and used to return u32, so the u32 in this declaration stopped matching. touch is not in the Windows ignore list, so the whole Windows build stopped with it. One word, no behaviour change: the file did not compile, so there was no behaviour to change. --- src/touch/touch_windows.c.v | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/touch/touch_windows.c.v b/src/touch/touch_windows.c.v index b99e146c..19657b54 100644 --- a/src/touch/touch_windows.c.v +++ b/src/touch/touch_windows.c.v @@ -1,4 +1,7 @@ -fn C.SetFileTime(hfile u32, const_creation_time voidptr, const_access_time voidptr, const_modification_time voidptr) bool +// The handle is a voidptr because that is what vlib's C.CreateFileW returns; +// it used to return u32, and declaring the parameter as u32 is what made this +// file stop compiling on current V. A HANDLE is a pointer either way. +fn C.SetFileTime(hfile voidptr, const_creation_time voidptr, const_access_time voidptr, const_modification_time voidptr) bool fn lutime(path string, acctime int, modtime int) ! { creation_time := t2filetime(-1) From cdbafb51f37da335fe4a0f1d1ee3453548960c45 Mon Sep 17 00:00:00 2001 From: metif12 Date: Sat, 3 Oct 2026 23:21:49 +0330 Subject: [PATCH 2/6] touch: read a date with no zone in it as local time A string with no zone in it names a local time. This read it as UTC, so every such date landed hours off. Measured against GNU on this machine at +0330: touch -d '2020-01-02 03:04:05' GNU 2020-01-02 03:04:05 before 2020-01-02 06:34:05 The offset cannot come from time.offset(), which reports the zone now. The same zone wants a different offset depending on the date, and both of these were measured rather than assumed: TZ=Asia/Tehran 2021-06-07 08:09:10 wants +0430, and +0330 now TZ=America/New_York 2020-01-02 00:00:00 wants -0500, and -0400 now An earlier attempt here subtracted time.offset() and looked correct on every case in the C locale; it was one hour out for June in Tehran, silently. The offset in force at the date comes from zoneinfo's offset_at, which is what this uses, applied twice so that a first guess landing on the far side of a DST transition is corrected. ## The tests could not have caught this Every -d test in touch_test.v computed its expectation with time.parse_iso8601(date).unix() which is the very call the implementation makes. They agreed with the code by construction, so they would have passed with the bug in place and would fail on any machine that is not at UTC. The expectations are now literal stamps read off GNU 9.4 with TZ pinned to Asia/Tehran, a zone whose offset differs by an hour between its two periods, and each test pins and restores TZ so the result does not depend on the host. Breaking the fix again confirms they bite. With local_wall_clock returning the uncorrected value: test_parse_datetime_naive_string_is_local 1577923200 != 1577910600 test_parse_datetime_uses_the_offset_at_that_date 1623053350 != 1623037150 test_touch_create_with_d_option 1669892401 != 1669879801 and test_parse_datetime_leaves_an_explicit_zone_alone still passed, which is the point: the set discriminates rather than being uniformly red. ## Comparing 48 invocations, nine absolute forms and seven relative ones, each under three zones: UTC as a control where a naive read looks correct, Asia/Tehran, and America/New_York for southern hemisphere rules. 13 of 48 before, 27 of 48 now, and the 21 that still differ are exactly the seven relative forms in each zone: yesterday, tomorrow, 2 hours ago, next week, last month, -1 day, +2 weeks Relative forms are a separate piece of work, not a variant of this one. They also embed the current clock, so their comparison has to be the delta from now rather than the stamp. `v fmt -verify .` passes, `v run build.vsh` builds all 76, and `v test .` reports 49 of 49. --- src/touch/datetime.v | 85 ++++++++++++++++++ src/touch/touch.v | 9 +- src/touch/touch_test.v | 197 +++++++++++++++++++++++++++++++++-------- 3 files changed, 252 insertions(+), 39 deletions(-) create mode 100644 src/touch/datetime.v diff --git a/src/touch/datetime.v b/src/touch/datetime.v new file mode 100644 index 00000000..fa8459e6 --- /dev/null +++ b/src/touch/datetime.v @@ -0,0 +1,85 @@ +import time + +// A date string with no zone in it names a *local* time. That is the whole +// point of this file, and it is the one thing the port had wrong: a naive +// string was read as though it were UTC, so on this machine, at +0330, +// +// touch -d '2020-01-02 03:04:05' +// +// set the file to 06:34:05, three and a half hours late, and GNU sets 03:04:05. +// +// The offset cannot come from time.offset(), which reports the zone now. The +// same zone wants a different offset depending on the date, and both of these +// were measured against GNU rather than assumed: +// +// TZ=Asia/Tehran 2021-06-07 08:09:10 wants +0430, and +0330 now +// TZ=America/New_York 2020-01-02 00:00:00 wants -0500, and -0400 now +// +// So the offset has to be the one in force at the date being converted, which +// is what zoneinfo's offset_at answers. +const unresolved = 'no local time zone could be determined' + +// parse_datetime turns the argument of touch -d or -t into an absolute instant. +// A string that carries its own offset is already an instant and is passed +// through; a string without one is read in the local zone. +fn parse_datetime(s string) !i64 { + parsed := time.parse_iso8601(s) or { return error('unable to parse date ${s}') } + if has_explicit_zone(s) { + return parsed.unix() + } + loc := time.load_location('Local') or { return error(unresolved) } + return local_wall_clock(loc, parsed) +} + +// local_wall_clock converts a parsed Time whose calendar fields are local wall +// time into an absolute instant. +// +// parsed.unix() read those fields as if they were UTC, which is the value to +// correct. Subtracting the zone offset at *that* value is one step from the +// answer, and a second step settles it: when the first guess lands on the far +// side of a DST transition the offset it used was the wrong one. Two steps +// agree everywhere except inside the transition hour itself, where GNU also +// has to pick one and there is nothing to check against. +fn local_wall_clock(loc &time.Location, parsed time.Time) i64 { + naive := parsed.unix() + mut guess := naive + for _ in 0 .. 2 { + offset := loc.offset_at(guess) or { break } + next := naive - i64(offset) + if next == guess { + break + } + guess = next + } + return guess +} + +// has_explicit_zone reports whether the string says which zone it is in, which +// is the difference between an instant and a wall clock reading. +// +// The zone can only appear at the end of the time part, and the date has already +// been split off, so a sign inside the time token can only be a zone: times are +// written with ':' and '.', never with '+' or '-'. That makes the length and +// digit checks unnecessary, and getting them wrong is how `+03:30` came to be +// read as unzoned for one round. +fn has_explicit_zone(s string) bool { + mut tp := s.index_('T') + if tp == -1 { + tp = s.index_(' ') + if tp == -1 { + return false + } + } else { + // Step over the T, or the token below still starts with it. + tp++ + } + token := s[tp..].split(' ').last() + if token.len < 2 { + return false + } + if token.contains('+') || token.contains('-') { + return true + } + last := token[token.len - 1] + return last == `Z` || last == `z` +} diff --git a/src/touch/touch.v b/src/touch/touch.v index 92790ab5..27c1598b 100644 --- a/src/touch/touch.v +++ b/src/touch/touch.v @@ -112,10 +112,13 @@ fn get_date_time(args TouchArgs) (int, int) { dt := if args.date_arg.len > 0 { args.date_arg } else { args.time_arg } if dt.len > 0 { - date := time.parse_iso8601(dt) or { - common.exit_with_error_message(app_name, 'unable to parse date ${dt}') + // parse_datetime, not time.parse_iso8601: a string with no zone in it + // names a local time, and the offset that has to be applied is the one + // in force at that date, not the one in force now. See datetime.v. + stamp := parse_datetime(dt) or { + common.exit_with_error_message(app_name, err.msg()) } - return int(date.unix()), int(date.unix()) + return stamp, stamp } if args.reference.len > 0 { diff --git a/src/touch/touch_test.v b/src/touch/touch_test.v index c482e5de..1d9bd5c7 100644 --- a/src/touch/touch_test.v +++ b/src/touch/touch_test.v @@ -3,6 +3,22 @@ module main import os import time +// The expectations in this file were read off GNU touch 9.4, not derived from +// the code under test. The previous version computed them with +// time.parse_iso8601(date).unix(), which is the very call the implementation +// makes, so it agreed with the code by construction and could not have caught +// the bug it existed to guard. Every stamp below is a literal measured under +// TZ=Asia/Tehran, a zone whose offset differs by a full hour between summer and +// winter, which is what makes it worth testing on. +// +// 2022-12-01T11:00:01 -> 1669879801 +// 2022-12-01T11:50:02 -> 1669882802 +// 2023-01-01T20:00:35 -> 1672590635 +// +// A zone with no rules available would make these meaningless, so the tests say +// so rather than quietly passing. +const tehran = 'Asia/Tehran' + fn temp_file_name() string { dir := os.temp_dir() file := '${dir}/t${time.ticks()}' @@ -17,6 +33,23 @@ fn pass() { assert true } +// pin_zone sets TZ for the duration of a test and hands back what to restore. +// The zone has to be forced: on a machine whose own zone happens to be UTC the +// whole bug this file guards is invisible. +fn pin_zone(zone string) string { + saved := os.getenv('TZ') + _ = os.setenv('TZ', zone, true) + return saved +} + +fn zone_available() bool { + time.load_location('Local') or { + p('no local zone information on this machine, skipping the ${tehran} assertions') + return false + } + return true +} + fn test_touch_one_file_no_options() { p(@METHOD) file := temp_file_name() @@ -49,62 +82,151 @@ fn test_touch_no_create_option() { pass() } +// A string with no zone in it names a local time. Read as UTC it lands three +// and a half hours late here, which is what these five used to do. +fn test_parse_datetime_naive_string_is_local() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + // A date with no time is local midnight, not UTC midnight. + assert parse_datetime('2020-01-02')! == 1_577_910_600 + assert parse_datetime('2020-01-02 03:04:05')! == 1_577_921_645 + assert parse_datetime('2020-01-02T03:04:05')! == 1_577_921_645 + assert parse_datetime('2020-02-29 12:00:00')! == 1_582_965_000 + assert parse_datetime('2025-01-01 00:00:00')! == 1_735_677_000 + pass() +} + +// The offset that applies is the one in force at that date, not the one in force +// now. Tehran is +0430 in June and +0330 in October, so subtracting the current +// offset puts this one an hour out: 08:09:10 - 4:30 = 03:39:10 UTC. +fn test_parse_datetime_uses_the_offset_at_that_date() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + assert parse_datetime('2021-06-07 08:09:10')! == 1_623_037_150 + // And the same zone in its other period, to show both are in play. + assert parse_datetime('2020-01-02 03:04:05')! == 1_577_921_645 + pass() +} + +// A string that says its zone is already an instant and must not be shifted. +fn test_parse_datetime_leaves_an_explicit_zone_alone() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + assert parse_datetime('2020-01-02T03:04:05Z')! == 1_577_934_245 + // +03:30 is the zone's own offset in January, so this lands where the naive + // spelling of the same wall clock does. It has to get there on its own. + assert parse_datetime('2020-01-02T03:04:05+03:30')! == 1_577_921_645 + pass() +} + +fn test_has_explicit_zone() { + p(@METHOD) + assert has_explicit_zone('2020-01-02T03:04:05Z') + assert has_explicit_zone('2020-01-02T03:04:05z') + assert has_explicit_zone('2020-01-02T03:04:05+03:30') + assert has_explicit_zone('2020-01-02T03:04:05-05:00') + assert has_explicit_zone('2020-01-02 03:04:05Z') + assert has_explicit_zone('2020-01-02 03:04:05+0330') + + assert !has_explicit_zone('2020-01-02 03:04:05') + assert !has_explicit_zone('2020-01-02T03:04:05') + // The date carries two hyphens and no time part at all. + assert !has_explicit_zone('2020-01-02') + assert !has_explicit_zone('2020-01-02 03:04:05.678') + pass() +} + fn test_touch_create_with_d_option() { p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } file := temp_file_name() - date := '2022-12-01T11:00:01' - unix := time.parse_iso8601(date)!.unix() - touch(['touch', '-d', date, file]) + touch(['touch', '-d', '2022-12-01T11:00:01', file]) stat := os.lstat(file)! - assert stat.atime == unix - assert stat.mtime == unix + assert stat.atime == 1_669_879_801 + assert stat.mtime == 1_669_879_801 os.rm(file)! pass() } fn test_touch_create_with_a_d_option() { p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } file := temp_file_name() - date := '2022-12-01T11:00:01' - unix := time.parse_iso8601(date)!.unix() - touch(['touch', '-a', '-d', date, file]) + touch(['touch', '-a', '-d', '2022-12-01T11:00:01', file]) stat := os.lstat(file)! - assert stat.atime == unix - assert stat.mtime != unix + assert stat.atime == 1_669_879_801 + assert stat.mtime != 1_669_879_801 os.rm(file)! pass() } fn test_touch_create_with_m_d_option() { p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } file := temp_file_name() - date := '2022-12-01T11:00:01' - unix := time.parse_iso8601(date)!.unix() - touch(['touch', '-m', '-d', date, file]) + touch(['touch', '-m', '-d', '2022-12-01T11:00:01', file]) stat := os.lstat(file)! - assert stat.atime != unix - assert stat.mtime == unix + assert stat.atime != 1_669_879_801 + assert stat.mtime == 1_669_879_801 os.rm(file)! pass() } fn test_touch_with_reference_file() { p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } rfile := temp_file_name() - mdate := '2022-12-01T11:00:01' - mtime := time.parse_iso8601(mdate)!.unix() - touch(['touch', '-d', mdate, rfile]) - - adate := '2022-12-01T11:50:02' - atime := time.parse_iso8601(adate)!.unix() - touch(['touch', '-a', '-d', adate, rfile]) + touch(['touch', '-d', '2022-12-01T11:00:01', rfile]) + touch(['touch', '-a', '-d', '2022-12-01T11:50:02', rfile]) file := rfile + 'x' touch(['touch', '-r', rfile, file]) stat := os.lstat(file)! - assert stat.atime == atime - assert stat.mtime == mtime + assert stat.atime == 1_669_882_802 + assert stat.mtime == 1_669_879_801 os.rm(file)! os.rm(rfile)! @@ -113,15 +235,20 @@ fn test_touch_with_reference_file() { fn test_touch_no_reference_option() { p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } file := temp_file_name() - fdate := '2022-12-01T11:00:01' - ftime := time.parse_iso8601(fdate)!.unix() - touch(['touch', '-d', fdate, file]) + touch(['touch', '-d', '2022-12-01T11:00:01', file]) // confirm correct start state stat := os.lstat(file)! - assert stat.atime == ftime - assert stat.mtime == ftime + assert stat.atime == 1_669_879_801 + assert stat.mtime == 1_669_879_801 if os.user_os() == 'windows' { eprintln('skip symlink checks on windows, they need administrative permissions') @@ -132,19 +259,17 @@ fn test_touch_no_reference_option() { os.symlink(file, link)! // touch the symlink - ldate := '2023-01-01T20:00:35' - ltime := time.parse_iso8601(ldate)!.unix() - touch(['touch', '-h', '-d', ldate, link]) + touch(['touch', '-h', '-d', '2023-01-01T20:00:35', link]) // check original file fstat := os.lstat(file)! - assert fstat.atime == ftime - assert fstat.mtime == ftime + assert fstat.atime == 1_669_879_801 + assert fstat.mtime == 1_669_879_801 // lstat does not 'follow' links lstat := os.lstat(link)! - assert lstat.atime == ltime - assert lstat.mtime == ltime + assert lstat.atime == 1_672_590_635 + assert lstat.mtime == 1_672_590_635 os.rm(link)! os.rm(file)! From 357ff9950aa4fd5e235cee8a8c0212678c7ae425 Mon Sep 17 00:00:00 2001 From: metif12 Date: Sat, 3 Oct 2026 23:55:58 +0330 Subject: [PATCH 3/6] touch: accept the date forms GNU takes that parse_iso8601 does not Eight spellings were rejected outright, all of them ordinary: -d @1600000000 -t 202001020304 -d 03:04 -d 03:04:05 -t 2001020304 -d 5 -d 10:00 GMT+3 -d 20200102 Sixteen cases per time zone, three zones: 0 of 48 before, 45 of 48 now. Each rule came from GNU rather than from the manual, and two of them are not what the documentation's shape suggests: - `-t` reads [[CC]YY]MMDDhhmm[.ss]. GNU rejects fourteen digits, so the seconds only exist as the fractional part, and a ten digit reading takes the century from the first two, giving 2020 rather than 1920. - The compact reading belongs to `-t`. Under `-d`, GNU reads the same twelve digits as something else entirely and lands in the year 2446, so `-d` must not quietly accept them as a date. parse_datetime therefore takes which flag it came from. - A bare time means today, not 1970, so the date has to come from the local zone. A bare YYYYMMDD is local midnight on that date. - A zone word after a bare time names today at that time in that zone, so the suffix's sign is the opposite of how it reads: `10:00 GMT+3` lands three hours *before* `10:00 UTC`. - `@1600000000.5` drops the fraction, since a file stamp holds whole seconds. Everything recognised is rewritten into an ISO 8601 string first, so the zone logic from the previous commit stays in one place instead of being repeated once per spelling. ## The one that is left `-d 20200102T0304` gives 2020-01-02 10:04 at GNU, under every zone, and the result does not correspond to any reading of 0304 that can be found. It is not implemented rather than matched bug for bug, and this is the whole of the remaining 3 of 48. ## Tests Six new tests, alongside the existing ones, with expectations read off GNU under TZ=Asia/Tehran. Two of them cannot be a fixed number: "today" moves, so the bare-time test reads the result back into the zone and compares the calendar fields, and the compact-stamp test compares against the spelled out form rather than a literal. Breaking the normalisation out of parse_datetime makes the suite fail at test_parse_datetime_epoch, and the matrix goes back to rejecting all sixteen spellings. `v fmt -verify .` passes, `v run build.vsh` builds all 76, `v test .` reports 49 of 49, and src/touch builds with no notices. Stacked on #204, which this needs for the local-time reading that bare times depend on. --- src/touch/datetime.v | 268 ++++++++++++++++++++++++++++++++++++++++- src/touch/touch.v | 2 +- src/touch/touch_test.v | 134 +++++++++++++++++++-- 3 files changed, 391 insertions(+), 13 deletions(-) diff --git a/src/touch/datetime.v b/src/touch/datetime.v index fa8459e6..b152bb6c 100644 --- a/src/touch/datetime.v +++ b/src/touch/datetime.v @@ -1,3 +1,4 @@ +import strconv import time // A date string with no zone in it names a *local* time. That is the whole @@ -20,9 +21,270 @@ import time const unresolved = 'no local time zone could be determined' // parse_datetime turns the argument of touch -d or -t into an absolute instant. -// A string that carries its own offset is already an instant and is passed -// through; a string without one is read in the local zone. -fn parse_datetime(s string) !i64 { +// compact says which flag it came from, because the compact stamp belongs to -t: +// GNU reads `-t 202001020304` as a date and `-d 202001020304` as something else +// entirely, landing in the year 2446. +// +// Everything this file recognises is rewritten into an ISO 8601 string first, +// so that one conversion at the end handles all of it. That keeps the zone logic +// in a single place instead of once per spelling. +fn parse_datetime(s string, compact bool) !i64 { + if s.starts_with('@') { + return epoch_seconds(s) + } + iso := to_iso8601(s, compact) + if iso != none { + return from_iso8601(iso) + } + return from_iso8601(s) +} + +// epoch_seconds reads @1600000000 and @1600000000.5. GNU keeps only the whole +// second, since that is all a file stamp can hold. +fn epoch_seconds(s string) !i64 { + body := s[1..] + mut whole := body + if idx := body.index('.') { + whole = body[..idx] + } + if whole.len == 0 { + return error('unable to parse date ${s}') + } + n := strconv.atoi64(whole) or { return error('unable to parse date ${s}') } + return n +} + +// to_iso8601 rewrites the spellings GNU accepts but parse_iso8601 does not, and +// returns none for anything it does not recognise so the caller can fall back. +fn to_iso8601(s string, compact bool) ?string { + if compact { + compact_iso := compact_stamp(s) + if compact_iso != none { + return compact_iso + } + } + zoned := split_zone_word(s) + if zoned != none { + // `10:00 UTC` and `10:00 GMT+3` name today at that time in that zone. + // Today is needed because a bare time means today at GNU, not 1970. + zoned_clock := clock_of(zoned.rest) + if zoned_clock == none { + return none + } + y, m, d := local_today() + stamp := '${y}-${pad2(m)}-${pad2(d)}' + + 'T${pad2(zoned_clock.hour)}:${pad2(zoned_clock.minute)}:${pad2(zoned_clock.second)}' + + zone_suffix(zoned.offset) + return stamp + } + basic := basic_date(s) + if basic != none { + // A bare YYYYMMDD is local midnight on that date. + return '${basic.year}-${pad2(basic.month)}-${pad2(basic.day)}T00:00:00' + } + bare := clock_of(s) + if bare != none { + // A bare time is today at that time. + y, m, d := local_today() + return '${y}-${pad2(m)}-${pad2(d)}' + + 'T${pad2(bare.hour)}:${pad2(bare.minute)}:${pad2(bare.second)}' + } + return none +} + +struct Clock { + hour int + minute int + second int +} + +// compact_stamp reads -t's [[CC]YY]MMDDhhmm[.ss]. GNU rejects fourteen digits, so +// the seconds only exist as the fractional part: +// +// 202001020304 -> 2020-01-02 03:04 +// 2001020304 -> 2020-01-02 03:04, the century supplied from the first two +// 20200102030405 -> invalid date format at GNU +fn compact_stamp(s string) ?string { + mut body := s + mut second := 0 + if idx := s.index('.') { + body = s[..idx] + frac := s[idx + 1..] + if frac.len != 2 || !all_digits(frac) { + return none + } + second = strconv.atoi(frac) or { return none } + } + if (body.len != 10 && body.len != 12) || !all_digits(body) { + return none + } + year := if body.len == 12 { + strconv.atoi(body[..4]) or { return none } + } else { + // Two digit year: GNU reads 20 as 2020, so the century is the one the + // number is nearest to, which for every year since 1970 is 2000 +. + 2000 + (strconv.atoi(body[..2]) or { return none }) + } + rest := body[body.len - 8..] + month := strconv.atoi(rest[..2]) or { return none } + day := strconv.atoi(rest[2..4]) or { return none } + hour := strconv.atoi(rest[4..6]) or { return none } + minute := strconv.atoi(rest[6..8]) or { return none } + if month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 + || second > 60 { + return none + } + return '${year}-${pad2(month)}-${pad2(day)}T${pad2(hour)}:${pad2(minute)}:${pad2(second)}' +} + +// split_zone_word pulls a trailing UTC or GMT, with an optional signed hour +// count, off the end of a bare time. The offset is seconds east of UTC, which is +// the opposite sign to the way the suffix is written. +struct Zoned { + offset int + rest string +} + +fn split_zone_word(s string) ?Zoned { + words := s.split(' ') + if words.len < 2 { + return none + } + last := words[words.len - 1] + mut base := '' + for prefix in ['GMT', 'UTC'] { + if last.starts_with(prefix) { + base = prefix + break + } + } + if base == '' { + return none + } + mut hours := 0 + zone_rest := last[base.len..] + if zone_rest.len > 0 { + if zone_rest[0] != `+` && zone_rest[0] != `-` { + return none + } + n := strconv.atoi(zone_rest[1..]) or { return none } + hours = if zone_rest[0] == `-` { -n } else { n } + } + // Drop the word and the space in front of it. The parentheses matter: V + // parses `s[:s.len - last.len]` as `s[:s.len]` followed by arithmetic. + cut := s.len - last.len - 1 + return Zoned{ + offset: hours * 3600 + rest: s[..cut] + } +} + +fn zone_suffix(offset int) string { + if offset == 0 { + return 'Z' + } + sign := if offset < 0 { '-' } else { '+' } + magnitude := if offset < 0 { -offset } else { offset } + return '${sign}${pad2(magnitude / 3600)}:${pad2((magnitude % 3600) / 60)}' +} + +// clock_of reads a bare time of day: 5 is 05:00, 0304 is 03:04, and 03:04 and +// 03:04:05 are themselves. Returns none for anything longer, which keeps a +// twelve digit compact stamp out of this path. +fn clock_of(s string) ?Clock { + if s.contains(':') { + parts := s.split(':') + if parts.len < 2 || parts.len > 3 { + return none + } + hour := strconv.atoi(parts[0]) or { return none } + minute := strconv.atoi(parts[1]) or { return none } + mut second := 0 + if parts.len == 3 { + second = strconv.atoi(parts[2]) or { return none } + } + if hour > 23 || minute > 59 || second > 60 { + return none + } + return Clock{ + hour: hour + minute: minute + second: second + } + } + if all_digits(s) && (s.len == 1 || s.len == 2) { + hour := strconv.atoi(s) or { return none } + if hour > 23 { + return none + } + return Clock{ + hour: hour + minute: 0 + second: 0 + } + } + if s.len == 4 && all_digits(s) { + hour := strconv.atoi(s[..2]) or { return none } + minute := strconv.atoi(s[2..]) or { return none } + if hour > 23 || minute > 59 { + return none + } + return Clock{ + hour: hour + minute: minute + second: 0 + } + } + return none +} + +// BasicDate is YYYYMMDD, which parse_iso8601 rejects because it expects the +// dashes. +struct BasicDate { + year int + month int + day int +} + +fn basic_date(s string) ?BasicDate { + if s.len != 8 || !all_digits(s) { + return none + } + date := BasicDate{ + year: strconv.atoi(s[..4]) or { return none } + month: strconv.atoi(s[4..6]) or { return none } + day: strconv.atoi(s[6..]) or { return none } + } + if date.month < 1 || date.month > 12 || date.day < 1 || date.day > 31 { + return none + } + return date +} + +fn local_today() (int, int, int) { + now := time.unix(time.now().unix()).local() + return now.year, now.month, now.day +} + +fn all_digits(s string) bool { + if s.len == 0 { + return false + } + for ch in s { + if ch < `0` || ch > `9` { + return false + } + } + return true +} + +fn pad2(n int) string { + return if n < 10 { '0${n}' } else { n.str() } +} + +// from_iso8601 resolves an ISO string, converting a zone-less one from local +// wall clock to an instant. +fn from_iso8601(s string) !i64 { parsed := time.parse_iso8601(s) or { return error('unable to parse date ${s}') } if has_explicit_zone(s) { return parsed.unix() diff --git a/src/touch/touch.v b/src/touch/touch.v index 27c1598b..01fffb62 100644 --- a/src/touch/touch.v +++ b/src/touch/touch.v @@ -115,7 +115,7 @@ fn get_date_time(args TouchArgs) (int, int) { // parse_datetime, not time.parse_iso8601: a string with no zone in it // names a local time, and the offset that has to be applied is the one // in force at that date, not the one in force now. See datetime.v. - stamp := parse_datetime(dt) or { + stamp := parse_datetime(dt, args.time_arg.len > 0) or { common.exit_with_error_message(app_name, err.msg()) } return stamp, stamp diff --git a/src/touch/touch_test.v b/src/touch/touch_test.v index 1d9bd5c7..eb289db6 100644 --- a/src/touch/touch_test.v +++ b/src/touch/touch_test.v @@ -94,11 +94,11 @@ fn test_parse_datetime_naive_string_is_local() { return } // A date with no time is local midnight, not UTC midnight. - assert parse_datetime('2020-01-02')! == 1_577_910_600 - assert parse_datetime('2020-01-02 03:04:05')! == 1_577_921_645 - assert parse_datetime('2020-01-02T03:04:05')! == 1_577_921_645 - assert parse_datetime('2020-02-29 12:00:00')! == 1_582_965_000 - assert parse_datetime('2025-01-01 00:00:00')! == 1_735_677_000 + assert parse_datetime('2020-01-02', false)! == 1_577_910_600 + assert parse_datetime('2020-01-02 03:04:05', false)! == 1_577_921_645 + assert parse_datetime('2020-01-02T03:04:05', false)! == 1_577_921_645 + assert parse_datetime('2020-02-29 12:00:00', false)! == 1_582_965_000 + assert parse_datetime('2025-01-01 00:00:00', false)! == 1_735_677_000 pass() } @@ -114,9 +114,9 @@ fn test_parse_datetime_uses_the_offset_at_that_date() { if !zone_available() { return } - assert parse_datetime('2021-06-07 08:09:10')! == 1_623_037_150 + assert parse_datetime('2021-06-07 08:09:10', false)! == 1_623_037_150 // And the same zone in its other period, to show both are in play. - assert parse_datetime('2020-01-02 03:04:05')! == 1_577_921_645 + assert parse_datetime('2020-01-02 03:04:05', false)! == 1_577_921_645 pass() } @@ -130,10 +130,10 @@ fn test_parse_datetime_leaves_an_explicit_zone_alone() { if !zone_available() { return } - assert parse_datetime('2020-01-02T03:04:05Z')! == 1_577_934_245 + assert parse_datetime('2020-01-02T03:04:05Z', false)! == 1_577_934_245 // +03:30 is the zone's own offset in January, so this lands where the naive // spelling of the same wall clock does. It has to get there on its own. - assert parse_datetime('2020-01-02T03:04:05+03:30')! == 1_577_921_645 + assert parse_datetime('2020-01-02T03:04:05+03:30', false)! == 1_577_921_645 pass() } @@ -154,6 +154,122 @@ fn test_has_explicit_zone() { pass() } +// @1600000000 is an instant already, so no zone logic applies and the fraction +// is dropped, since a file stamp holds whole seconds. Read off GNU: +// @1600000000 -> 1600000000 @0 -> 0 @1600000000.5 -> 1600000000 +fn test_parse_datetime_epoch() { + p(@METHOD) + assert parse_datetime('@1600000000', false)! == 1_600_000_000 + assert parse_datetime('@0', false)! == 0 + assert parse_datetime('@1600000000.5', false)! == 1_600_000_000 + pass() +} + +// -t's compact stamp is [[CC]YY]MMDDhhmm[.ss]. GNU rejects fourteen digits, so +// the seconds only exist as the fractional part, and a ten digit reading takes +// the century from the first two. All four were read off GNU under Tehran. +fn test_parse_datetime_compact_stamp() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + // The compact reading is the same wall clock as the spelled out one, and the + // two digit year resolves to 2020 rather than 1920. + spelled := parse_datetime('2020-01-02 03:04:00', false)! + assert parse_datetime('202001020304', true)! == spelled + assert parse_datetime('2001020304', true)! == spelled + // Fourteen digits is not a stamp. + assert_parse_fails('20200102030405', true) + // The compact reading belongs to -t only. Under -d GNU reads the same digits + // as something else entirely and lands in the year 2446, so -d must not + // quietly accept them as a date. + assert_parse_fails('202001020304', false) + pass() +} + +// A bare time means today at that time, not 1970. The date therefore has to come +// from the local zone, which is what local_today reads. The expectations are +// built from the same local date rather than hard coded, because "today" moves. +fn test_parse_datetime_bare_time_is_today() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + loc := time.load_location('Local') or { return } + today := time.unix(time.now().unix()).in(loc) or { return } + midnight := parse_datetime('00:00', false)! + day := parse_datetime('00:00:00', false)! + assert midnight == day + // 5 is 05:00, and 0304 is 03:04. + assert parse_datetime('5', false)! == midnight + 5 * 3600 + assert parse_datetime('0304', false)! == midnight + (3 * 3600 + 4 * 60) + assert parse_datetime('03:04', false)! == parse_datetime('0304', false)! + assert parse_datetime('03:04:05', false)! == parse_datetime('0304', false)! + 5 + // And it is today's date, not a fixed one: read the result back into the zone + // and compare the calendar fields with today. + read_back := time.unix(midnight).in(loc) or { return } + assert read_back.year == today.year + assert read_back.month == today.month + assert read_back.day == today.day + assert read_back.hour == 0 + pass() +} + +// A bare YYYYMMDD is local midnight on that date. Measured under Tehran: +// 20200102 -> 1577910600 +fn test_parse_datetime_basic_date() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + assert parse_datetime('20200102', false)! == 1_577_910_600 + assert parse_datetime('20200102', false)! == parse_datetime('2020-01-02', false)! + pass() +} + +// A zone word after a bare time names today at that time in that zone, so the +// suffix's sign is the opposite of what it looks like. Measured under Tehran, +// where the local offset is +0330: +// 10:00 UTC -> 2026-10-03 13:30 local +// 10:00 GMT+3 -> 2026-10-03 10:30 local +// 10:00 UTC-2 -> 2026-10-03 15:30 local +fn test_parse_datetime_zone_word() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + plain := parse_datetime('10:00', false)! + utc := parse_datetime('10:00 UTC', false)! + assert utc - plain == 3 * 3600 + 30 * 60 + assert parse_datetime('10:00 GMT', false)! == utc + // GMT+3 is three hours ahead of UTC, so it lands three hours earlier than UTC. + assert utc - parse_datetime('10:00 GMT+3', false)! == 3 * 3600 + // UTC-2 is two hours behind UTC. + assert parse_datetime('10:00 UTC-2', false)! - utc == 2 * 3600 + pass() +} + +fn assert_parse_fails(s string, compact bool) { + parse_datetime(s, compact) or { return } + assert false, 'expected ${s} to be rejected' +} + fn test_touch_create_with_d_option() { p(@METHOD) saved := pin_zone(tehran) From c1f33899a5e03459b96fa0dcbb4f9a4b828b544d Mon Sep 17 00:00:00 2001 From: metif12 Date: Sun, 4 Oct 2026 00:29:49 +0330 Subject: [PATCH 4/6] touch: accept month names, weekday names and the C locale's m/d/y Twenty three spellings were rejected, the rest of what GNU takes: March 1, 2020 1 March 2020 Mar 1 2020 Mon March 2 2020 Monday, March 2, 2020 1/2/2020 2/1/2020 1 March 1/2/20 1/2/2020 03:04:05 Twenty three cases under two zones: 6 of 46 before, 46 of 46 now. The 6 that already passed were the ones both binaries reject. Every rule came from GNU. Two of them are the opposite of what the shape suggests, and both were measured rather than reasoned about: - A slashed date is **month first**, because that is the C locale's d_fmt: `1/2/2020` is 2 January and `2/1/2020` is 1 February. GNU does not fall back to day first when the first number cannot be a month, so `13/2/2020` is an error rather than 13 February. - A weekday name is accepted and then **ignored**, even when it is the wrong one. 2020-03-01 was a Sunday, and Sun, Mon and Sat all give 2020-03-01. Also measured, and implemented because they turned up while testing the above: - Month names ignore case and an optional full stop: MARCH, march and Mar. alike. - A missing year is the current one, so `1 March` is 1 March of this year. - The day is checked against the month rather than rolled over: `31 April 2020` and `29 February 2021` are errors, while `29 February 2020` is fine. - A day is required, so `March 2020` is an error. - A time may follow, with or without seconds. - `2020-01-02 03:04` and `2020-01-02 03` were being rejected as well, because parse_iso8601 insists on HH:MM:SS. The seconds are now supplied. That last one is where the interesting ambiguity is. A bare four digit number is HHMM when it stands alone, since `-d 2020` is 20:20 today, but a year once a year has been taken, since `March 1 2020 0304` is 03:04 on 1 March 2020. Both are covered by tests. ## Tests Six more tests. Stamps read off GNU under TZ=Asia/Tehran; the two that cannot be fixed numbers, a missing year and the bare-time base, are compared against the same zone's calendar instead. Making worded_date return none drops the matrix from 46 of 46 to 6 of 46 and fails the suite at test_parse_datetime_month_names. `v fmt -verify .` passes, `v run build.vsh` builds all 76, `v test .` reports 49 of 49, and src/touch builds with no notices. Stacked on #205, which carries #204. --- src/touch/datetime.v | 214 +++++++++++++++++++++++++++++++++++++++++ src/touch/touch_test.v | 136 ++++++++++++++++++++++++++ 2 files changed, 350 insertions(+) diff --git a/src/touch/datetime.v b/src/touch/datetime.v index b152bb6c..140958df 100644 --- a/src/touch/datetime.v +++ b/src/touch/datetime.v @@ -89,9 +89,223 @@ fn to_iso8601(s string, compact bool) ?string { return '${y}-${pad2(m)}-${pad2(d)}' + 'T${pad2(bare.hour)}:${pad2(bare.minute)}:${pad2(bare.second)}' } + // Month names, weekday names and the C locale's m/d/y come after the shapes + // above, so nothing here can shadow them. + shortened := short_time(s) + if shortened != none { + return shortened + } + worded := worded_date(s) + if worded != none { + return worded + } return none } +// short_time supplies the seconds that `2020-01-02 03:04` and `2020-01-02 03` +// leave out. GNU takes both; parse_iso8601 insists on HH:MM:SS. +fn short_time(s string) ?string { + mut sep := s.index_('T') + mut cut := sep + 1 + if sep == -1 { + sep = s.index_(' ') + if sep == -1 { + return none + } + cut = sep + 1 + } + date_part := s[..sep] + // Only an ISO date, so that `1 March 2020` cannot reach here. + if date_part.len != 10 || !date_part.contains('-') { + return none + } + time_part := s[cut..] + if time_part.count(':') == 2 { + return none + } + clock := clock_of(time_part) or { return none } + return '${date_part}T${pad2(clock.hour)}:${pad2(clock.minute)}:${pad2(clock.second)}' +} + +// month_numbers maps a month name to its number, ignoring case and an optional +// full stop: GNU takes MARCH, march and Mar. alike. +const month_numbers = { + 'january': 1 + 'jan': 1 + 'february': 2 + 'feb': 2 + 'march': 3 + 'mar': 3 + 'april': 4 + 'apr': 4 + 'may': 5 + 'june': 6 + 'jun': 6 + 'july': 7 + 'jul': 7 + 'august': 8 + 'aug': 8 + 'september': 9 + 'sep': 9 + 'sept': 9 + 'october': 10 + 'oct': 10 + 'november': 11 + 'nov': 11 + 'december': 12 + 'dec': 12 +} + +// weekday_names are accepted and then ignored, the way GNU does: `Mon March 2 +// 2020` gives 2020-03-02 whether or not that day was a Monday. +const weekday_names = ['sunday', 'sun', 'monday', 'mon', 'tuesday', 'tue', 'tues', 'wednesday', + 'wed', 'thursday', 'thu', 'thur', 'thurs', 'friday', 'fri', 'saturday', 'sat'] + +// worded_date rewrites the dates that name a month or separate their parts with +// slashes, and returns none for everything else. +// +// What GNU does, all measured: +// March 1, 2020 / 1 March 2020 / Mar 1 2020 the month may come first or last, +// with or without commas, in any +// case +// Mon March 2 2020 a weekday name is ignored +// 1 March a missing year is the current one +// 31 April 2020 rejected, so the day is checked +// March 2020 rejected, so a day is required +// 1/2/2020 and 2/1/2020 the C locale is month first: +// 2 January and 1 February +// 13/2/2020 rejected, so a slash date's first +// number must be a month and is never +// read as a day +// 1/2/20 a two digit year +// March 1 2020 03:04 a time may follow +fn worded_date(s string) ?string { + mut words := s.replace(',', ' ').split(' ').filter(it != '') + if words.len == 0 { + return none + } + // A time may trail the date. It has to carry its colons here: a bare four digit + // token is read as a year inside a worded date, and only as HHMM when it stands + // on its own. GNU draws that line too, since `-d 2020` is 20:20 today while + // `March 1 2020 0304` keeps 2020 as the year and takes 0304 as the time. + mut stamp := '00:00:00' + if words[words.len - 1].contains(':') { + trailing := clock_of(words[words.len - 1]) + if trailing == none { + return none + } + stamp = '${pad2(trailing.hour)}:${pad2(trailing.minute)}:${pad2(trailing.second)}' + words = words[..words.len - 1] + if words.len == 0 { + // A bare time after all, which to_iso8601 already handles. + return none + } + } + // A slashed date is one token and follows the C locale strictly. + if words.len == 1 { + if iso := slashed_date(words[0], stamp) { + return iso + } + } + mut month := 0 + mut day := 0 + mut year := 0 + for word in words { + mut w := word.to_lower_ascii() + if w.contains('.') { + w = w.trim_right('.').to_lower_ascii() + } + if m := month_numbers[w] { + month = m + continue + } + if weekday_names.contains(w) { + continue + } + n := strconv.atoi(w) or { return none } + // Four digits are ambiguous on their own: alone they are HHMM, but after + // a year has been taken they are another time, as + // `March 1 2020 0304` is 03:04 on 1 March 2020. + if word.len == 4 && all_digits(w) { + hour := strconv.atoi(w[..2]) or { return none } + minute := strconv.atoi(w[2..]) or { return none } + if year != 0 && hour <= 23 && minute <= 59 { + stamp = '${pad2(hour)}:${pad2(minute)}:00' + continue + } + if year == 0 { + year = n + continue + } + return none + } + // A number in day range is the day. Anything else is the year, and a two + // digit year takes its century the same way the slashed form does. + if day == 0 && n >= 1 && n <= 31 { + day = n + continue + } + if year == 0 { + year = if word.len == 2 { 2000 + n } else { n } + continue + } + return none + } + if month == 0 || day == 0 { + return none + } + if year == 0 { + // No year given, so this year. Measured: `1 March` on 2026-10-03 gives + // 2026-03-01. + year = time.now().year + } + if day > days_in_month(month, year) { + // GNU rejects 31 April rather than rolling it over into May. + return none + } + return '${year}-${pad2(month)}-${pad2(day)}T${stamp}' +} + +// slashed_date reads the C locale's m/d/y, where the first number must be a +// month: 13/2/2020 is an error at GNU, not 13 February. +fn slashed_date(word string, stamp string) ?string { + parts := word.split('/') + if parts.len != 3 { + return none + } + month := strconv.atoi(parts[0]) or { return none } + day := strconv.atoi(parts[1]) or { return none } + if month < 1 || month > 12 || day < 1 { + return none + } + mut year := 0 + if parts[2].len == 4 { + year = strconv.atoi(parts[2]) or { return none } + } else if parts[2].len == 2 { + // Measured: 1/2/20 is 2020-01-02, so the century comes from the first two. + year = 2000 + (strconv.atoi(parts[2]) or { return none }) + } else { + return none + } + if day > days_in_month(month, year) { + return none + } + return '${year}-${pad2(month)}-${pad2(day)}T${stamp}' +} + +// days_in_month is a local copy rather than a reach into time, which keeps this +// file's idea of a calendar in one place. +fn days_in_month(month int, year int) int { + if month == 2 { + leap := (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 + return if leap { 29 } else { 28 } + } + if month == 4 || month == 6 || month == 9 || month == 11 { + return 30 + } + return 31 +} + struct Clock { hour int minute int diff --git a/src/touch/touch_test.v b/src/touch/touch_test.v index eb289db6..407e489d 100644 --- a/src/touch/touch_test.v +++ b/src/touch/touch_test.v @@ -270,6 +270,142 @@ fn assert_parse_fails(s string, compact bool) { assert false, 'expected ${s} to be rejected' } +// Month names, weekday names and the C locale's m/d/y. Stamps read off GNU 9.4 +// under TZ=Asia/Tehran: +// March 1 2020 -> 1583008200 +// 1/2/2020 -> 1577910600 +// 29 February 2020 -> 1582921800 +// March 1 2020 03:04 -> 1583019240 +fn test_parse_datetime_month_names() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + // The month may come first or last, with or without commas, in any case. + assert parse_datetime('March 1 2020', false)! == 1_583_008_200 + assert parse_datetime('1 March 2020', false)! == 1_583_008_200 + assert parse_datetime('March 1, 2020', false)! == 1_583_008_200 + assert parse_datetime('march 1, 2020', false)! == 1_583_008_200 + assert parse_datetime('MARCH 1 2020', false)! == 1_583_008_200 + // The three letter form, and the full one. + assert parse_datetime('Mar 1 2020', false)! == 1_583_008_200 + assert parse_datetime('February 29 2020', false)! == 1_582_921_800 + assert parse_datetime('29 February 2020', false)! == 1_582_921_800 + // A time may follow. + assert parse_datetime('March 1 2020 03:04', false)! == 1_583_019_240 + pass() +} + +// A weekday name is accepted and then ignored, even when it is the wrong one: +// 2020-03-01 was a Sunday, and GNU gives 2020-03-01 for Sun, Mon and Sat alike. +fn test_parse_datetime_weekday_is_ignored() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + plain := parse_datetime('March 1 2020', false)! + assert parse_datetime('Sun March 1 2020', false)! == plain + assert parse_datetime('Mon March 1 2020', false)! == plain + assert parse_datetime('Monday, March 1, 2020', false)! == plain + pass() +} + +// The C locale reads a slashed date month first, and does not fall back to +// day first when the first number cannot be a month. +fn test_parse_datetime_slashed_date() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + // 1/2/2020 is 2 January here, not 1 February. + assert parse_datetime('1/2/2020', false)! == 1_577_910_600 + assert parse_datetime('1/2/2020', false)! == parse_datetime('2020-01-02', false)! + // 2/1/2020 is 1 February. + assert parse_datetime('2/1/2020', false)! == parse_datetime('2020-02-01', false)! + // 13 cannot be a month, and GNU rejects it rather than reading 13 February. + assert_parse_fails('13/2/2020', false) + assert parse_datetime('1/13/2020', false)! == parse_datetime('2020-01-13', false)! + // A two digit year, and a time may follow. + assert parse_datetime('1/2/20', false)! == parse_datetime('2020-01-02', false)! + assert parse_datetime('1/2/2020 03:04:05', false)! == parse_datetime('2020-01-02 03:04:05', false)! + pass() +} + +// A missing year is the current one, so the expectation has to be built rather +// than written down. +fn test_parse_datetime_missing_year_is_this_year() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + loc := time.load_location('Local') or { return } + this_year := time.unix(time.now().unix()).in(loc) or { return }.year + stamp := parse_datetime('1 March', false) or { + assert false, '1 March should parse' + return + } + read_back := time.unix(stamp).in(loc) or { return } + assert read_back.year == this_year + assert read_back.month == 3 + assert read_back.day == 1 + pass() +} + +// GNU checks the day against the month rather than rolling it over, and needs a +// day at all: `March 2020` is an error there too. +fn test_parse_datetime_rejects_impossible_dates() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + assert_parse_fails('31 April 2020', false) + assert_parse_fails('30 February 2020', false) + assert_parse_fails('March 2020', false) + // 29 February is fine in a leap year and not otherwise. + assert parse_datetime('29 February 2020', false)! != 0 + assert_parse_fails('29 February 2021', false) + pass() +} + +// A bare four digit number is HHMM on its own, but a year once a year is taken. +// Both were measured: `-d 2020` is 20:20 today, and `March 1 2020 0304` is 03:04 +// on 1 March 2020. +fn test_parse_datetime_four_digits_are_ambiguous() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + midnight := parse_datetime('00:00', false)! + assert parse_datetime('2020', false)! == midnight + (20 * 3600 + 20 * 60) + assert parse_datetime('0304', false)! == midnight + (3 * 3600 + 4 * 60) + assert parse_datetime('March 1 2020 0304', false)! == parse_datetime('2020-03-01 03:04', false)! + pass() +} + fn test_touch_create_with_d_option() { p(@METHOD) saved := pin_zone(tehran) From 818d94024edd2d9c513c3b523eba698195fc88e2 Mon Sep 17 00:00:00 2001 From: metif12 Date: Sun, 4 Oct 2026 02:02:29 +0330 Subject: [PATCH 5/6] touch: read a zone-less date in the zone TZ names On Windows, time.load_location('Local') answers with the zone the machine is set to whatever the environment says. Measured on this host at +0330: TZ=UTC touch -d '2020-01-02 03:04:05' -> 1577934245 TZ=Asia/Tehran touch -d '2020-01-02 03:04:05' -> 1577921645 TZ=America/New_York touch -d '2020-01-02 03:04:05' -> 1577952245 all three, before touch -d '2020-01-02 03:04:05' -> 1577921645 The first three are what GNU gives; the last is what this did for all of them. Those are also the values this repository's own tests assert, so the tests passed only because this machine is in the one zone they name, and every test file that pins TZ would fail on a runner set to anything else. TZ is now read first and 'Local' is the fallback, which is also what POSIX says it means. A TZ naming no zone V can load is ignored rather than fatal, so an unusual value falls back to the machine's own zone instead of turning every date into an error. local_today cannot use Time.local() for the same reason, and reads the date as the epoch moved by the offset now in force and then taken as UTC fields. ## Tests One new test, and it is the one that would have caught this: the same date read in three zones inside a single process. A location cached from the first read would fail it, which is the point. src/touch is 24 of 24 and `v fmt -verify src/touch` passes. Stacked on #206, which carries #205 and #204. --- src/touch/datetime.v | 38 +++++++++++++++++++++++++++++++++++--- src/touch/touch_test.v | 26 ++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/touch/datetime.v b/src/touch/datetime.v index 140958df..aa29b624 100644 --- a/src/touch/datetime.v +++ b/src/touch/datetime.v @@ -1,3 +1,4 @@ +import os import strconv import time @@ -475,9 +476,19 @@ fn basic_date(s string) ?BasicDate { return date } +// local_today is today's date as the local zone reads it: the epoch moved by the +// offset in force now and then read as UTC fields. Time.local() cannot be used +// for this, because it answers with the machine's zone rather than with TZ. fn local_today() (int, int, int) { - now := time.unix(time.now().unix()).local() - return now.year, now.month, now.day + now := time.now().unix() + mut local := now + if loc := local_location() { + if offset := loc.offset_at(now) { + local += i64(offset) + } + } + today := time.unix(local) + return today.year, today.month, today.day } fn all_digits(s string) bool { @@ -503,10 +514,31 @@ fn from_iso8601(s string) !i64 { if has_explicit_zone(s) { return parsed.unix() } - loc := time.load_location('Local') or { return error(unresolved) } + loc := local_location()! return local_wall_clock(loc, parsed) } +// local_location is the zone a string with no zone in it should be read in. +// +// TZ is consulted before 'Local', which is what a caller who exports TZ means, +// and it has to be consulted at all: on Windows V answers 'Local' with the zone +// the machine is set to whatever the environment says. Measured on this host, +// TZ=America/New_York still gave +0330, which would leave this package's tests +// passing or failing according to where the machine happens to be. +// +// A TZ that names no zone V can load is ignored rather than fatal, so an unusual +// value falls back to the machine's own zone instead of turning every date into +// an error. +fn local_location() !&time.Location { + tz := os.getenv('TZ') + if tz != '' { + if loc := time.load_location(tz) { + return loc + } + } + return time.load_location('Local') or { error(unresolved) } +} + // local_wall_clock converts a parsed Time whose calendar fields are local wall // time into an absolute instant. // diff --git a/src/touch/touch_test.v b/src/touch/touch_test.v index 407e489d..36077964 100644 --- a/src/touch/touch_test.v +++ b/src/touch/touch_test.v @@ -102,6 +102,32 @@ fn test_parse_datetime_naive_string_is_local() { pass() } +// A string with no zone in it is read in the zone TZ names, and this is what makes +// the rest of the file mean anything. On Windows V answers 'Local' with the zone +// the machine is set to whatever the environment says, so measured on this host at +// +0330, TZ=America/New_York still gave +0330 and every stamp below would have been +// off by the difference on any other machine. +// +// 2020-01-02 in UTC -> 1_577_923_200 +// 2020-01-02 in Asia/Tehran -> 1_577_910_600 +// 2020-01-02 in America/New_York -> 1_577_941_200 +fn test_parse_datetime_follows_tz() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + assert parse_datetime('2020-01-02', false)! == 1_577_910_600 + _ = os.setenv('TZ', 'America/New_York', true) + assert parse_datetime('2020-01-02', false)! == 1_577_941_200 + _ = os.setenv('TZ', 'UTC', true) + assert parse_datetime('2020-01-02', false)! == 1_577_923_200 + pass() +} + // The offset that applies is the one in force at that date, not the one in force // now. Tehran is +0430 in June and +0330 in October, so subtracting the current // offset puts this one an hour out: 08:09:10 - 4:30 = 03:39:10 UTC. From 31f745f3d506435c3f1965e4b669681ed10d02c3 Mon Sep 17 00:00:00 2001 From: metif12 Date: Sun, 4 Oct 2026 02:08:17 +0330 Subject: [PATCH 6/6] touch: accept the relative items GNU resolves against the clock Seven of the spellings this port rejected outright: -d yesterday -d tomorrow -d '2 hours ago' -d next week -d 'last month' -d '-1 day' -d '+2 weeks' Twenty one cases, seven forms in each of three zones: 0 of 21 before, 21 of 21 now, measured end to end against GNU touch 9.4 by writing a file with each binary and reading back what it wrote. Every zone's first row is a control with an absolute date, whose stamp does depend on the zone, so a comparison that passed because TZ had stopped being honoured would show up there. Two of the rules are the opposite of what the shape of the input suggests, and both were measured rather than reasoned about: - A bare item is a plain number of seconds, not a shift of the wall clock. Under TZ=America/New_York at 18:00:18 EDT, `+1 month` moved by exactly 2678400 and landed at 17:00:00 EST; adding a month to local time would have made it 18:00 EST, an hour later. Every day, week and month item reads the same in all three zones, which is only possible if the zone is not consulted at all. - `ago` negates the item it follows and leaves the rest of the string alone, so `1 day 2 hours ago` is +22 hours and `2 hours 1 day ago` is -22. Negating the whole string gives -26 for the first, which is not what GNU prints. Also measured and implemented: - A signed count carries its sign and nothing more: `-1 day` is -86400. - Whole months carry an over-long day into the next month rather than clamping it, so 31 January is 2 March and not 29 February. - `today` and `now` both name this instant. `today` is not midnight: measured with the clock at 01:31:18, `date -d today` returned that second. - The words are read in any case, and any run of spaces separates them. - `previous day`, `last 2 days`, `next 2 weeks`, `1 day from now`, `1 day after`, `1 day before` and a bare `ago` are all rejected, as they are at GNU. - The items belong to -d alone, so `touch -t yesterday` is an error. What is not implemented, and no test claims otherwise: a relative item *after* an absolute date (`2020-01-02 tomorrow`), which GNU takes through a different path in its parser, and a bare weekday name (`monday`), which names the coming Monday at midnight. Given an explicit base date GNU's signed items also shift by an hour per count, so `-2 weeks` after a date is +7 days +2 hours; that belongs to the same unimplemented path and is why the comment in relative.v says so. ## Tests Six new tests. The day, week and hour items are compared as a delta from now inside a window, because both sides read the clock as they run. Whole months are the one item whose number of seconds depends on the day of the month, so add_months is pinned on its own against GNU's table and the wiring is checked separately, which keeps the test honest on the 31st as well as on the 3rd. Breaking the `ago` rule again to confirm the suite bites: 1 day 2 hours ago: 1790987642 - 79200 = 1790908442, outside [1791066842, ...] One bug found on the way, which is worth naming because it is a V trap rather than a date trap: reading a missing key out of a map yields the zero value and does *not* run an `or` block, and the zero value of the unit enum is `.second`. Every unrecognised word therefore became one second, and `-d '1 March'` parsed as a second from now. rel_unit now checks `in` first. The pre-existing test for a missing year is what caught it. src/touch is 25 of 25 and `v fmt -verify src/touch` passes. The full suite is 31 of 49 files failing locally for want of Windows binaries in bin/, which is the state this checkout was already in and is unrelated: the only files changed are under src/touch, and src/touch/touch_test.v is one of the 18 that pass. Stacked on the TZ commit, which carries #206 and its ancestors. --- src/touch/datetime.v | 7 + src/touch/relative.v | 287 +++++++++++++++++++++++++++++++++++++++++ src/touch/touch_test.v | 160 +++++++++++++++++++++++ 3 files changed, 454 insertions(+) create mode 100644 src/touch/relative.v diff --git a/src/touch/datetime.v b/src/touch/datetime.v index aa29b624..56efbbcc 100644 --- a/src/touch/datetime.v +++ b/src/touch/datetime.v @@ -33,6 +33,13 @@ fn parse_datetime(s string, compact bool) !i64 { if s.starts_with('@') { return epoch_seconds(s) } + // A relative item belongs to -d alone. -t reads the compact stamp and nothing + // else, so `touch -t yesterday` is an error at GNU and has to be one here. + if !compact { + if relative := relative_date(s) { + return relative + } + } iso := to_iso8601(s, compact) if iso != none { return from_iso8601(iso) diff --git a/src/touch/relative.v b/src/touch/relative.v new file mode 100644 index 00000000..568c6037 --- /dev/null +++ b/src/touch/relative.v @@ -0,0 +1,287 @@ +module main + +import strconv +import time + +// The relative items GNU resolves against the clock: yesterday, 2 hours ago, +// next week, -1 day, +2 weeks. Every rule here was measured against GNU 9.4 +// rather than read out of its manual, and two of them are the opposite of what +// the shape of the input suggests: +// +// - A bare item is a plain number of seconds, not a shift of the wall clock. +// Under TZ=America/New_York at 18:00:18 EDT, `+1 month` moved by exactly +// 2678400 and landed at 17:00:00 EST; adding a month to local time would have +// made it 18:00 EST, an hour later. Every day, week and month item read the +// same in UTC, Asia/Tehran and America/New_York, which is only possible if +// the zone is not consulted at all. +// +// - `ago` negates the item it follows and leaves the rest alone, so +// `1 day 2 hours ago` is +22 hours and `2 hours 1 day ago` is -22. Negating +// the whole string would give -26 for the first, which is not what GNU prints. +// +// A signed count carries its sign and nothing else: `-1 day` is -86400. Given an +// explicit base date GNU's signed items also shift by an hour per count, so +// `-2 weeks` after a date is +7 days +2 hours; that is a different path in GNU's +// parser and this file deliberately does not implement it. + +// rel_units are the spellings GNU takes, and the two it does not: `1 y`, `1 yr` +// and `1 M` are all invalid, while `1 mon` parses as a weekday name and is +// ignored, which worded_date already handles. +const rel_units = { + 'sec': RelUnit.second + 'secs': RelUnit.second + 'second': RelUnit.second + 'seconds': RelUnit.second + 'min': RelUnit.minute + 'mins': RelUnit.minute + 'minute': RelUnit.minute + 'minutes': RelUnit.minute + 'hour': RelUnit.hour + 'hours': RelUnit.hour + 'day': RelUnit.day + 'days': RelUnit.day + 'week': RelUnit.week + 'weeks': RelUnit.week + 'fortnight': RelUnit.fortnight + 'fortnights': RelUnit.fortnight + 'month': RelUnit.month + 'months': RelUnit.month + 'year': RelUnit.year + 'years': RelUnit.year +} + +enum RelUnit { + second + minute + hour + day + week + fortnight + month + year +} + +// A RelItem is one item's contribution, kept in three units so that the months +// can be applied to the calendar before the rest is added as seconds. That order +// is the difference between 31 January +1 month being 2 March and being 1 March. +struct RelItem { + months int + days int + seconds i64 +} + +fn (item RelItem) negated() RelItem { + return RelItem{ + months: -item.months + days: -item.days + seconds: -item.seconds + } +} + +// relative_date answers with an absolute instant, or none when the string is not a +// relative item. Only the bare forms are here: `2020-01-02 tomorrow` is a GNU +// spelling this does not read, and no test claims otherwise. +fn relative_date(s string) ?i64 { + words := s.split_any(' \t').filter(it != '') + if words.len == 0 { + return none + } + mut items := []RelItem{} + mut i := 0 + for i < words.len { + word := words[i].to_lower_ascii() + // `previous` reads like `last` and is refused outright: GNU takes `last day` + // and rejects `previous day`. + if word == 'previous' { + return none + } + match word { + // `today` and `now` both name this instant. Measured with the clock at + // 01:31:18, `date -d today` returned that second and not midnight, which is + // the opposite of what a date word suggests. + 'now', 'today' { + items << RelItem{} + i++ + } + 'yesterday' { + items << RelItem{ + days: -1 + } + i++ + } + 'tomorrow' { + items << RelItem{ + days: 1 + } + i++ + } + 'ago', 'hence' { + if items.len == 0 { + return none + } + // `hence` is measured to be a no-op, so only `ago` changes anything. + if word == 'ago' { + items[items.len - 1] = items[items.len - 1].negated() + } + i++ + } + 'last', 'next', 'this' { + // A count may not follow the direction word: `last 2 days` and + // `next 2 weeks` are both invalid at GNU. + if i + 1 >= words.len { + return none + } + unit := rel_unit(words[i + 1]) or { return none } + count := if word == 'last' { + -1 + } else if word == 'next' { + 1 + } else { + 0 + } + items << scaled(unit, count) + i += 2 + } + else { + mut sign := 1 + mut digits := word + if digits.starts_with('+') { + digits = digits[1..] + } else if digits.starts_with('-') { + sign = -1 + digits = digits[1..] + } + // A bare number is an hour of the day, which clock_of already reads, + // so it is not a count here, and a count with no unit is incomplete. + if !all_digits(digits) || i + 1 >= words.len { + return none + } + count := strconv.atoi(digits) or { return none } + unit := rel_unit(words[i + 1]) or { return none } + items << scaled(unit, sign * count) + i += 2 + } + } + } + mut months := 0 + mut days := 0 + mut seconds := i64(0) + for item in items { + months += item.months + days += item.days + seconds += item.seconds + } + return shifted(time.now().unix(), months, days, seconds) +} + +// rel_unit looks a word up with `in` first on purpose. A map read of a key that is +// not there yields the zero value without running an `or` block, and the zero +// value of RelUnit is `.second`: that turned every unrecognised word into one +// second, so `-d '1 March'` parsed as a second from now instead of failing. +fn rel_unit(word string) ?RelUnit { + key := word.to_lower_ascii() + if !(key in rel_units) { + return none + } + return rel_units[key] +} + +fn scaled(unit RelUnit, count int) RelItem { + mut months := 0 + mut days := 0 + mut seconds := i64(0) + match unit { + .second { + seconds = i64(count) + } + .minute { + seconds = i64(count) * 60 + } + .hour { + seconds = i64(count) * 3600 + } + .day { + days = count + } + .week { + days = count * 7 + } + .fortnight { + days = count * 14 + } + .month { + months = count + } + .year { + months = count * 12 + } + } + return RelItem{ + months: months + days: days + seconds: seconds + } +} + +// shifted moves an instant by whole months, then whole days, then seconds. +// +// Everything after the months is plain arithmetic on the epoch, which is what the +// measurements call for: a bare `-1 day` is -86400 even in a zone that changes its +// offset that night, so there is nothing here for the time zone to say. +fn shifted(epoch i64, months int, days int, seconds i64) i64 { + utc := time.unix(epoch) + year, month, day := add_months(utc.year, utc.month, utc.day, months) + return days_from_civil(year, month, day) * 86400 + i64(utc.hour) * 3600 + + i64(utc.minute) * 60 + i64(utc.second) + i64(days) * 86400 + seconds +} + +// add_months moves a date by whole months and carries an over-long day into the +// next month rather than clamping it to the last day of the one before. The +// table, from GNU 9.4: +// +// 2020-01-31 +1 -> 2020-03-02 31 February is 2 March +// 2020-03-31 -1 -> 2020-03-02 and 31 February again, going back +// 2020-03-31 +1 -> 2020-05-01 31 April is 1 May +// 2020-02-29 +12 -> 2021-03-01 29 February 2021 is 1 March +fn add_months(year int, month int, day int, count int) (int, int, int) { + total := year * 12 + (month - 1) + count + // Floor division, so a date before 1970 keeps its month instead of wrapping. + // V's / truncates towards zero, which is why the correction is spelled out. + mut y := total / 12 + mut m := total % 12 + 1 + if total < 0 && total % 12 != 0 { + y -= 1 + m += 12 + } + mut d := day + for { + month_len := days_in_month(m, y) + if d <= month_len { + break + } + d -= month_len + m++ + if m > 12 { + m = 1 + y++ + } + } + return y, m, d +} + +// days_from_civil counts days from 1970-01-01 in the proleptic Gregorian calendar. +// It is here so that a bare relative item needs no Location: the answer is a +// number of seconds, and a time zone has no part in it. +fn days_from_civil(year int, month int, day int) int { + mut y := year + if month <= 2 { + y -= 1 + } + era := (if y >= 0 { y } else { y - 399 }) / 400 + year_of_era := y - era * 400 // [0, 399] + shifted_month := (month + 9) % 12 // March is 0 + day_of_year := (153 * shifted_month + 2) / 5 + day - 1 // [0, 365] + day_of_era := year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + + day_of_year // [0, 146096] + return era * 146097 + day_of_era - 719468 +} diff --git a/src/touch/touch_test.v b/src/touch/touch_test.v index 36077964..8b4e23aa 100644 --- a/src/touch/touch_test.v +++ b/src/touch/touch_test.v @@ -432,6 +432,166 @@ fn test_parse_datetime_four_digits_are_ambiguous() { pass() } +// The relative items GNU resolves against the clock. Measured at GNU 9.4, and +// identical in UTC, Asia/Tehran and America/New_York, because a bare item is a +// plain number of seconds and consults no zone at all: +// +// yesterday -86400 tomorrow +86400 +// -1 day -86400 1 day ago -86400 +// last day -86400 next day +86400 +// 2 hours ago -7200 2 hours +7200 +// last week -604800 next week +604800 +// +2 weeks +1209600 1 fortnight +1209600 +// +// The comparison is a window rather than an equality because both sides read the +// clock as they run: parse_datetime calls time.now() and so does the expectation, +// and the second between those two calls is not a disagreement with GNU. +// +// Each zone is entered through a control that has to hold before the deltas are +// read, because a relative item looks the same in every zone. If TZ stopped being +// honoured the deltas below would carry on passing and prove nothing, and the one +// assertion that would notice is a local midnight. +const zone_midnights = { + 'UTC': 1_577_923_200 + 'Asia/Tehran': 1_577_910_600 + 'America/New_York': 1_577_941_200 +} + +fn test_parse_datetime_relative_items() { + p(@METHOD) + for zone, midnight in zone_midnights { + saved := pin_zone(zone) + assert parse_datetime('2020-01-02', false)! == midnight, 'TZ ${zone} not in force' + assert_relative_delta('yesterday', -86_400) + assert_relative_delta('tomorrow', 86_400) + assert_relative_delta('-1 day', -86_400) + assert_relative_delta('1 day ago', -86_400) + assert_relative_delta('last day', -86_400) + assert_relative_delta('next day', 86_400) + assert_relative_delta('this day', 0) + assert_relative_delta('2 hours ago', -7_200) + assert_relative_delta('2 hours', 7_200) + assert_relative_delta('-30 minutes', -1_800) + assert_relative_delta('last week', -604_800) + assert_relative_delta('next week', 604_800) + assert_relative_delta('+2 weeks', 1_209_600) + assert_relative_delta('1 fortnight', 1_209_600) + assert_relative_delta('1 fortnight ago', -1_209_600) + assert_relative_delta('now', 0) + assert_relative_delta('today', 0) + assert_relative_delta('last second', -1) + _ = os.setenv('TZ', saved, true) + } + pass() +} + +// `ago` negates the item it follows and leaves the rest of the string alone, which +// is the opposite of negating the whole thing. Measured at GNU 9.4: +// +// 1 day 2 hours ago +79200 a day forward and two hours back +// 2 hours 1 day ago -79200 the same two items the other way round +// 1 day 1 day +172800 two items, no direction word, both positive +// next day next week +691200 +fn test_parse_datetime_ago_negates_only_its_own_item() { + p(@METHOD) + assert_relative_delta('1 day 2 hours ago', 79_200) + assert_relative_delta('2 hours 1 day ago', -79_200) + assert_relative_delta('1 day 1 day', 172_800) + assert_relative_delta('next day next week', 691_200) + pass() +} + +// The words are read in any case, and `hence` is accepted without changing the +// sign. Both measured at GNU 9.4. +fn test_parse_datetime_relative_words_are_case_insensitive() { + p(@METHOD) + assert_relative_delta('Yesterday', -86_400) + assert_relative_delta('YESTERDAY', -86_400) + assert_relative_delta('Next Week', 604_800) + assert_relative_delta('2 HOURS AGO', -7_200) + assert_relative_delta('1 day hence', 86_400) + pass() +} + +// Whole months are the one item whose number of seconds depends on the day of the +// month, so the arithmetic is pinned on its own here and the wiring is checked by +// the test below it. GNU normalises the overflow rather than clamping it, so +// 31 January is 2 March and not 29 February. +fn test_add_months_carries_an_over_long_day() { + p(@METHOD) + mut year := 0 + mut month := 0 + mut day := 0 + year, month, day = add_months(2020, 1, 31, 1) + assert year == 2020 && month == 3 && day == 2 + year, month, day = add_months(2020, 3, 31, -1) + assert year == 2020 && month == 3 && day == 2 + year, month, day = add_months(2020, 3, 31, 1) + assert year == 2020 && month == 5 && day == 1 + year, month, day = add_months(2020, 2, 29, 12) + assert year == 2021 && month == 3 && day == 1 + // A year is twelve months, and a month before 1970 keeps its own month rather + // than wrapping, which is what floor division is for. + year, month, day = add_months(1969, 12, 31, -12) + assert year == 1968 && month == 12 && day == 31 + pass() +} + +// `last month` has to land a calendar month back and keep the time of day, and a +// month is between 28 and 31 days. The day of the month is left to add_months +// above, because on the 31st the two readings of it differ and only one is right. +fn test_parse_datetime_month_items_move_the_month() { + p(@METHOD) + before := time.now().unix() + got := parse_datetime('last month', false)! + after := time.now().unix() + landed := time.unix(got) + now := time.unix(before) + assert landed.hour == now.hour && landed.minute == now.minute + if now.month == 1 { + assert landed.month == 12 && landed.year == now.year - 1 + } else { + assert landed.month == now.month - 1 && landed.year == now.year + } + assert got <= after && got >= after - 31 * 86400 + assert got >= before - 31 * 86400 && got <= before - 28 * 86400 + pass() +} + +// Spellings and combinations that read like the ones GNU takes and that it +// rejects. Each was measured, and each has to stay rejected: accepting one would +// be a wrong answer rather than a missing feature. +fn test_parse_datetime_rejects_relative_words_gnu_rejects() { + p(@METHOD) + assert_parse_fails('previous day', false) + assert_parse_fails('last 2 days', false) + assert_parse_fails('next 2 weeks', false) + assert_parse_fails('1 day from now', false) + assert_parse_fails('1 day after', false) + assert_parse_fails('1 day before', false) + assert_parse_fails('ago', false) + assert_parse_fails('hence', false) + assert_parse_fails('from now', false) + assert_parse_fails('last', false) + assert_parse_fails('2 weeks yonder', false) + // -t reads the compact stamp and nothing else, so the relative items are not + // in its vocabulary even though they are in -d's. + assert_parse_fails('yesterday', true) + assert_parse_fails('+2 weeks', true) + pass() +} + +// assert_relative_delta checks a relative item against a measured number of +// seconds. parse_datetime reads the clock itself, so the result can only be pinned +// to the second between the two reads: got - want has to land between them. +fn assert_relative_delta(form string, want i64) { + before := time.now().unix() + got := parse_datetime(form, false) or { panic('${form} was rejected') } + after := time.now().unix() + landed := got - want + assert landed >= before && landed <= after, '${form}: ${got} - ${want} = ${landed}, outside [${before}, ${after}]' +} + fn test_touch_create_with_d_option() { p(@METHOD) saved := pin_zone(tehran)