A virtual jailed shell environment for Go apps backed by an io/fs#FS.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(touch): wire -a/-m/-r/-t/-d and fix default mode

Implements GNU coreutils compatibility for touch:

- -a only sets atime (preserves mtime)
- -m only sets mtime (preserves atime)
- -r ref stats reference and applies times to target
- -t [[CC]YY]MMDDhhmm[.SS] full parser; POSIX YY window
00-68 -> 2000s, 69-99 -> 1900s
- -d accepts RFC3339Nano/RFC3339, POSIX-strict
YYYY-MM-DDThh:mm:SS, and looser space-separated forms
- Default file creation mode is 0o666 & ~0o022 = 0o644
(was hardcoded 0o644)

billy.FileInfo lacks separate atime exposure; preserved
side falls back to ModTime, documented inline.

Refs: docs/posix2018/CONFORMANCE.md
Assisted-by: Claude Opus 4.7 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

Xe Iaso 5bc0d634 ee2a39db

+512 -38
+192 -20
command/internal/touch/touch.go
··· 7 7 "io" 8 8 "os" 9 9 "path" 10 + "strconv" 10 11 "strings" 11 12 "time" 12 13 ··· 39 40 fmt.Fprint(stderr, "Usage: touch [OPTION]... FILE...\n") 40 41 fmt.Fprint(stderr, "Update the access and modification times of each FILE to the current time.\n\n") 41 42 fmt.Fprint(stderr, "A FILE argument that does not exist is created empty, unless -c is supplied.\n\n") 42 - fmt.Fprint(stderr, " -a (ignored) change only the access time\n") 43 + fmt.Fprint(stderr, " -a change only the access time\n") 43 44 fmt.Fprint(stderr, " -c, --no-create do not create any files\n") 44 45 fmt.Fprint(stderr, " -d, --date=STRING parse STRING and use it instead of current time\n") 45 - fmt.Fprint(stderr, " -m (ignored) change only the modification time\n") 46 - fmt.Fprint(stderr, " -r, --reference=FILE (ignored) use this file's times instead of current time\n") 47 - fmt.Fprint(stderr, " -t STAMP (ignored) use [[CC]YY]MMDDhhmm[.ss] instead of current time\n") 46 + fmt.Fprint(stderr, " -h, --no-dereference affect each symbolic link rather than its referent\n") 47 + fmt.Fprint(stderr, " (only effective when the backend supports Lstat;\n") 48 + fmt.Fprint(stderr, " time updates still follow the link)\n") 49 + fmt.Fprint(stderr, " -m change only the modification time\n") 50 + fmt.Fprint(stderr, " -r, --reference=FILE use this file's times instead of current time\n") 51 + fmt.Fprint(stderr, " -t STAMP use [[CC]YY]MMDDhhmm[.ss] instead of current time\n") 48 52 fmt.Fprint(stderr, " --help display this help and exit\n") 49 53 } 50 54 set.SetUsage(usage) 51 55 52 56 noCreate := set.BoolLong("no-create", 'c', "do not create any files") 53 57 dateStr := set.StringLong("date", 'd', "", "parse STRING and use it instead of current time") 54 - _ = set.Bool('a', "(ignored) change only the access time") 55 - _ = set.Bool('m', "(ignored) change only the modification time") 56 - _ = set.StringLong("reference", 'r', "", "(ignored) use this file's times instead of current time") 57 - _ = set.String('t', "", "(ignored) use [[CC]YY]MMDDhhmm[.ss] instead of current time") 58 + aFlag := set.Bool('a', "change only the access time") 59 + mFlag := set.Bool('m', "change only the modification time") 60 + hFlag := set.BoolLong("no-dereference", 'h', "affect each symbolic link rather than any referenced file") 61 + refFile := set.StringLong("reference", 'r', "", "use this file's times instead of current time") 62 + tStamp := set.String('t', "", "use [[CC]YY]MMDDhhmm[.ss] instead of current time") 58 63 help := set.BoolLong("help", 0, "display this help and exit") 59 64 60 65 if err := set.Getopt(append([]string{"touch"}, args...), nil); err != nil { ··· 73 78 return interp.ExitStatus(1) 74 79 } 75 80 76 - var targetTime *time.Time 81 + sources := 0 82 + if *refFile != "" { 83 + sources++ 84 + } 85 + if *tStamp != "" { 86 + sources++ 87 + } 77 88 if *dateStr != "" { 89 + sources++ 90 + } 91 + if sources > 1 { 92 + fmt.Fprint(stderr, "touch: cannot specify times from more than one source\n") 93 + return interp.ExitStatus(1) 94 + } 95 + 96 + var ( 97 + atime, mtime time.Time 98 + haveTimes bool 99 + ) 100 + switch { 101 + case *refFile != "": 102 + ref := resolvePath(ec, *refFile) 103 + info, err := statMaybeLink(ec, ref, *hFlag) 104 + if err != nil { 105 + fmt.Fprintf(stderr, "touch: failed to get attributes of '%s': %s\n", *refFile, err) 106 + return interp.ExitStatus(1) 107 + } 108 + // billy.FileInfo only exposes ModTime; atime is not separately 109 + // tracked, so we use ModTime for both halves. Real GNU touch -r 110 + // copies atime and mtime independently. 111 + mtime = info.ModTime() 112 + atime = info.ModTime() 113 + haveTimes = true 114 + case *tStamp != "": 115 + parsed, ok := parseTStamp(*tStamp) 116 + if !ok { 117 + fmt.Fprintf(stderr, "touch: invalid date format '%s'\n", *tStamp) 118 + return interp.ExitStatus(1) 119 + } 120 + atime = parsed 121 + mtime = parsed 122 + haveTimes = true 123 + case *dateStr != "": 78 124 parsed, ok := parseDateString(*dateStr) 79 125 if !ok { 80 126 fmt.Fprintf(stderr, "touch: invalid date format '%s'\n", *dateStr) 81 127 return interp.ExitStatus(1) 82 128 } 83 - targetTime = &parsed 129 + atime = parsed 130 + mtime = parsed 131 + haveTimes = true 84 132 } 85 133 134 + setAtime := *aFlag || (!*aFlag && !*mFlag) 135 + setMtime := *mFlag || (!*aFlag && !*mFlag) 136 + 137 + // Spec creation mode is 0o666 modified by umask. Billy/Kefka does not 138 + // expose process umask, so we assume the conventional 0o022 — the same 139 + // fallback used by the mkdir port. With umask 022 this yields 0o644, 140 + // matching the historical GNU coreutils default. 141 + const assumedUmask os.FileMode = 0o022 142 + createMode := os.FileMode(0o666) &^ assumedUmask 143 + 86 144 exitCode := 0 87 145 for _, file := range files { 88 146 full := resolvePath(ec, file) 89 147 90 - _, err := ec.FS.Stat(full) 148 + info, err := statMaybeLink(ec, full, *hFlag) 91 149 exists := err == nil 92 150 if !exists { 93 151 if *noCreate { 94 152 continue 95 153 } 96 - f, createErr := ec.FS.OpenFile(full, os.O_CREATE|os.O_WRONLY, 0o644) 154 + f, createErr := ec.FS.OpenFile(full, os.O_CREATE|os.O_WRONLY, createMode) 97 155 if createErr != nil { 98 156 fmt.Fprintf(stderr, "touch: cannot touch '%s': %s\n", file, createErr) 99 157 exitCode = 1 100 158 continue 101 159 } 102 160 f.Close() 103 - } 104 - 105 - if changer, ok := ec.FS.(billy.Change); ok { 106 - mtime := time.Now() 107 - if targetTime != nil { 108 - mtime = *targetTime 109 - } 110 - if err := changer.Chtimes(full, mtime, mtime); err != nil { 161 + info, err = statMaybeLink(ec, full, *hFlag) 162 + if err != nil { 111 163 fmt.Fprintf(stderr, "touch: cannot touch '%s': %s\n", file, err) 112 164 exitCode = 1 113 165 continue 114 166 } 115 167 } 168 + 169 + changer, ok := ec.FS.(billy.Change) 170 + if !ok { 171 + continue 172 + } 173 + 174 + now := time.Now() 175 + newAtime := now 176 + newMtime := now 177 + if haveTimes { 178 + newAtime = atime 179 + newMtime = mtime 180 + } 181 + 182 + finalAtime := newAtime 183 + finalMtime := newMtime 184 + if !setAtime { 185 + finalAtime = info.ModTime() 186 + } 187 + if !setMtime { 188 + finalMtime = info.ModTime() 189 + } 190 + 191 + if err := changer.Chtimes(full, finalAtime, finalMtime); err != nil { 192 + fmt.Fprintf(stderr, "touch: cannot touch '%s': %s\n", file, err) 193 + exitCode = 1 194 + continue 195 + } 116 196 } 117 197 118 198 if exitCode != 0 { ··· 144 224 } 145 225 146 226 return time.Time{}, false 227 + } 228 + 229 + // parseTStamp parses a -t argument of the form [[CC]YY]MMDDhhmm[.SS]. 230 + // All times are interpreted as UTC since the sandbox runs in UTC. 231 + func parseTStamp(s string) (time.Time, bool) { 232 + main, secStr, hasSec := strings.Cut(s, ".") 233 + if hasSec { 234 + if len(secStr) != 2 { 235 + return time.Time{}, false 236 + } 237 + for _, c := range secStr { 238 + if c < '0' || c > '9' { 239 + return time.Time{}, false 240 + } 241 + } 242 + } 243 + for _, c := range main { 244 + if c < '0' || c > '9' { 245 + return time.Time{}, false 246 + } 247 + } 248 + 249 + year := time.Now().UTC().Year() 250 + var datePart string 251 + switch len(main) { 252 + case 8: 253 + datePart = main 254 + case 10: 255 + yy, err := strconv.Atoi(main[:2]) 256 + if err != nil { 257 + return time.Time{}, false 258 + } 259 + if yy < 69 { 260 + year = 2000 + yy 261 + } else { 262 + year = 1900 + yy 263 + } 264 + datePart = main[2:] 265 + case 12: 266 + cc, err := strconv.Atoi(main[:2]) 267 + if err != nil { 268 + return time.Time{}, false 269 + } 270 + yy, err := strconv.Atoi(main[2:4]) 271 + if err != nil { 272 + return time.Time{}, false 273 + } 274 + year = cc*100 + yy 275 + datePart = main[4:] 276 + default: 277 + return time.Time{}, false 278 + } 279 + 280 + month, err := strconv.Atoi(datePart[0:2]) 281 + if err != nil || month < 1 || month > 12 { 282 + return time.Time{}, false 283 + } 284 + day, err := strconv.Atoi(datePart[2:4]) 285 + if err != nil || day < 1 || day > 31 { 286 + return time.Time{}, false 287 + } 288 + hour, err := strconv.Atoi(datePart[4:6]) 289 + if err != nil || hour < 0 || hour > 23 { 290 + return time.Time{}, false 291 + } 292 + minute, err := strconv.Atoi(datePart[6:8]) 293 + if err != nil || minute < 0 || minute > 59 { 294 + return time.Time{}, false 295 + } 296 + second := 0 297 + if secStr != "" { 298 + second, err = strconv.Atoi(secStr) 299 + if err != nil || second < 0 || second > 60 { 300 + return time.Time{}, false 301 + } 302 + } 303 + 304 + return time.Date(year, time.Month(month), day, hour, minute, second, 0, time.UTC), true 305 + } 306 + 307 + // statMaybeLink returns Lstat(name) when noDeref is set and the backend 308 + // implements billy.Symlink, otherwise falls back to Stat. Note that even 309 + // when noDeref is true, the subsequent Chtimes call follows the symlink 310 + // because billy has no Lchtimes equivalent — this matches the limitation 311 + // documented in the help text. 312 + func statMaybeLink(ec *command.ExecContext, name string, noDeref bool) (os.FileInfo, error) { 313 + if noDeref { 314 + if sym, ok := ec.FS.(billy.Symlink); ok { 315 + return sym.Lstat(name) 316 + } 317 + } 318 + return ec.FS.Stat(name) 147 319 } 148 320 149 321 func resolvePath(ec *command.ExecContext, p string) string {
+320 -18
command/internal/touch/touch_test.go
··· 5 5 "context" 6 6 "os" 7 7 "strings" 8 + "sync" 8 9 "testing" 10 + "time" 9 11 10 12 "github.com/go-git/go-billy/v5" 11 13 "github.com/go-git/go-billy/v5/memfs" 12 14 "tangled.org/xeiaso.net/kefka/command" 13 15 ) 14 16 17 + // timedFS wraps a billy.Filesystem with persistent atime/mtime storage and 18 + // implements billy.Change so touch can round-trip times. memfs by itself 19 + // neither stores ModTime nor implements billy.Change, so tests need this 20 + // shim to verify behavior. 21 + type timedFS struct { 22 + billy.Filesystem 23 + mu sync.Mutex 24 + atimes map[string]time.Time 25 + mtimes map[string]time.Time 26 + } 27 + 28 + func newTimedFS(inner billy.Filesystem) *timedFS { 29 + return &timedFS{ 30 + Filesystem: inner, 31 + atimes: map[string]time.Time{}, 32 + mtimes: map[string]time.Time{}, 33 + } 34 + } 35 + 36 + func (t *timedFS) Stat(name string) (os.FileInfo, error) { 37 + info, err := t.Filesystem.Stat(name) 38 + if err != nil { 39 + return nil, err 40 + } 41 + t.mu.Lock() 42 + mt, ok := t.mtimes[name] 43 + t.mu.Unlock() 44 + if !ok { 45 + return info, nil 46 + } 47 + return &timedInfo{FileInfo: info, mtime: mt}, nil 48 + } 49 + 50 + func (t *timedFS) Chmod(name string, mode os.FileMode) error { 51 + return billy.ErrNotSupported 52 + } 53 + 54 + func (t *timedFS) Lchown(name string, uid, gid int) error { 55 + return billy.ErrNotSupported 56 + } 57 + 58 + func (t *timedFS) Chown(name string, uid, gid int) error { 59 + return billy.ErrNotSupported 60 + } 61 + 62 + func (t *timedFS) Chtimes(name string, atime, mtime time.Time) error { 63 + if _, err := t.Filesystem.Stat(name); err != nil { 64 + return err 65 + } 66 + t.mu.Lock() 67 + t.atimes[name] = atime 68 + t.mtimes[name] = mtime 69 + t.mu.Unlock() 70 + return nil 71 + } 72 + 73 + func (t *timedFS) atime(name string) (time.Time, bool) { 74 + t.mu.Lock() 75 + defer t.mu.Unlock() 76 + v, ok := t.atimes[name] 77 + return v, ok 78 + } 79 + 80 + type timedInfo struct { 81 + os.FileInfo 82 + mtime time.Time 83 + } 84 + 85 + func (t *timedInfo) ModTime() time.Time { return t.mtime } 86 + 15 87 func newFS(t *testing.T) billy.Filesystem { 16 88 t.Helper() 17 89 fs := memfs.New() ··· 22 94 f.Write([]byte("hello\n")) 23 95 f.Close() 24 96 return fs 97 + } 98 + 99 + func newTimed(t *testing.T) *timedFS { 100 + t.Helper() 101 + return newTimedFS(newFS(t)) 25 102 } 26 103 27 104 func run(t *testing.T, args []string, fs billy.Filesystem) (string, string, error) { ··· 129 206 }, 130 207 }, 131 208 { 132 - name: "ignored -r consumes its argument", 133 - args: []string{"-r", "hello.txt", "new.txt"}, 134 - check: func(t *testing.T, fs billy.Filesystem) { 135 - if !exists(t, fs, "new.txt") { 136 - t.Errorf("new.txt was not created") 137 - } 138 - }, 139 - }, 140 - { 141 - name: "ignored -t consumes its argument", 142 - args: []string{"-t", "202504300000", "new.txt"}, 143 - check: func(t *testing.T, fs billy.Filesystem) { 144 - if !exists(t, fs, "new.txt") { 145 - t.Errorf("new.txt was not created") 146 - } 147 - }, 148 - }, 149 - { 150 209 name: "date short flag with valid date creates file", 151 210 args: []string{"-d", "2024-01-15", "new.txt"}, 152 211 check: func(t *testing.T, fs billy.Filesystem) { ··· 189 248 wantErr: true, 190 249 }, 191 250 { 251 + name: "invalid -t timestamp", 252 + args: []string{"-t", "notavalidtime", "new.txt"}, 253 + wantErrSub: "invalid date format", 254 + wantErr: true, 255 + }, 256 + { 257 + name: "multiple time sources rejected", 258 + args: []string{"-t", "200001010000", "-d", "2024-01-15", "new.txt"}, 259 + wantErrSub: "more than one source", 260 + wantErr: true, 261 + }, 262 + { 192 263 name: "missing file operand", 193 264 args: []string{}, 194 265 wantErrSub: "missing file operand", ··· 239 310 } 240 311 if tt.check != nil { 241 312 tt.check(t, fs) 313 + } 314 + }) 315 + } 316 + } 317 + 318 + func TestTouchAOnlySetsAtime(t *testing.T) { 319 + fs := newTimed(t) 320 + original := time.Date(2000, 6, 15, 12, 0, 0, 0, time.UTC) 321 + if err := fs.Chtimes("hello.txt", original, original); err != nil { 322 + t.Fatal(err) 323 + } 324 + _, _, err := run(t, []string{"-a", "-d", "2024-01-15T00:00:00Z", "hello.txt"}, fs) 325 + if err != nil { 326 + t.Fatalf("touch failed: %v", err) 327 + } 328 + at, ok := fs.atime("hello.txt") 329 + if !ok { 330 + t.Fatal("atime was not recorded") 331 + } 332 + want := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) 333 + if !at.Equal(want) { 334 + t.Errorf("atime = %v, want %v", at, want) 335 + } 336 + info, err := fs.Stat("hello.txt") 337 + if err != nil { 338 + t.Fatal(err) 339 + } 340 + if !info.ModTime().Equal(original) { 341 + t.Errorf("mtime = %v, want %v (unchanged)", info.ModTime(), original) 342 + } 343 + } 344 + 345 + func TestTouchMOnlySetsMtime(t *testing.T) { 346 + fs := newTimed(t) 347 + original := time.Date(2000, 6, 15, 12, 0, 0, 0, time.UTC) 348 + if err := fs.Chtimes("hello.txt", original, original); err != nil { 349 + t.Fatal(err) 350 + } 351 + _, _, err := run(t, []string{"-m", "-d", "2024-01-15T00:00:00Z", "hello.txt"}, fs) 352 + if err != nil { 353 + t.Fatalf("touch failed: %v", err) 354 + } 355 + at, _ := fs.atime("hello.txt") 356 + if !at.Equal(original) { 357 + t.Errorf("atime = %v, want %v (unchanged)", at, original) 358 + } 359 + info, err := fs.Stat("hello.txt") 360 + if err != nil { 361 + t.Fatal(err) 362 + } 363 + want := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) 364 + if !info.ModTime().Equal(want) { 365 + t.Errorf("mtime = %v, want %v", info.ModTime(), want) 366 + } 367 + } 368 + 369 + func TestTouchAMTogetherSetsBoth(t *testing.T) { 370 + fs := newTimed(t) 371 + original := time.Date(2000, 6, 15, 12, 0, 0, 0, time.UTC) 372 + if err := fs.Chtimes("hello.txt", original, original); err != nil { 373 + t.Fatal(err) 374 + } 375 + _, _, err := run(t, []string{"-a", "-m", "-d", "2024-01-15T00:00:00Z", "hello.txt"}, fs) 376 + if err != nil { 377 + t.Fatalf("touch failed: %v", err) 378 + } 379 + want := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) 380 + at, _ := fs.atime("hello.txt") 381 + if !at.Equal(want) { 382 + t.Errorf("atime = %v, want %v", at, want) 383 + } 384 + info, err := fs.Stat("hello.txt") 385 + if err != nil { 386 + t.Fatal(err) 387 + } 388 + if !info.ModTime().Equal(want) { 389 + t.Errorf("mtime = %v, want %v", info.ModTime(), want) 390 + } 391 + } 392 + 393 + func TestTouchReference(t *testing.T) { 394 + fs := newTimed(t) 395 + refTime := time.Date(2010, 3, 5, 8, 30, 0, 0, time.UTC) 396 + if err := fs.Chtimes("hello.txt", refTime, refTime); err != nil { 397 + t.Fatal(err) 398 + } 399 + _, _, err := run(t, []string{"-r", "hello.txt", "new.txt"}, fs) 400 + if err != nil { 401 + t.Fatalf("touch failed: %v", err) 402 + } 403 + if !exists(t, fs, "new.txt") { 404 + t.Fatal("new.txt was not created") 405 + } 406 + at, ok := fs.atime("new.txt") 407 + if !ok { 408 + t.Fatal("new.txt atime not recorded") 409 + } 410 + if !at.Equal(refTime) { 411 + t.Errorf("new.txt atime = %v, want %v", at, refTime) 412 + } 413 + info, err := fs.Stat("new.txt") 414 + if err != nil { 415 + t.Fatal(err) 416 + } 417 + if !info.ModTime().Equal(refTime) { 418 + t.Errorf("new.txt mtime = %v, want %v", info.ModTime(), refTime) 419 + } 420 + } 421 + 422 + func TestTouchTStamp(t *testing.T) { 423 + fs := newTimed(t) 424 + _, _, err := run(t, []string{"-t", "199501010100.30", "new.txt"}, fs) 425 + if err != nil { 426 + t.Fatalf("touch failed: %v", err) 427 + } 428 + want := time.Date(1995, 1, 1, 1, 0, 30, 0, time.UTC) 429 + info, err := fs.Stat("new.txt") 430 + if err != nil { 431 + t.Fatal(err) 432 + } 433 + if !info.ModTime().Equal(want) { 434 + t.Errorf("new.txt mtime = %v, want %v", info.ModTime(), want) 435 + } 436 + } 437 + 438 + func TestTouchTStampAOnly(t *testing.T) { 439 + fs := newTimed(t) 440 + original := time.Date(2000, 6, 15, 12, 0, 0, 0, time.UTC) 441 + if err := fs.Chtimes("hello.txt", original, original); err != nil { 442 + t.Fatal(err) 443 + } 444 + _, _, err := run(t, []string{"-a", "-t", "200001010000", "hello.txt"}, fs) 445 + if err != nil { 446 + t.Fatalf("touch failed: %v", err) 447 + } 448 + want := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) 449 + at, _ := fs.atime("hello.txt") 450 + if !at.Equal(want) { 451 + t.Errorf("atime = %v, want %v", at, want) 452 + } 453 + info, err := fs.Stat("hello.txt") 454 + if err != nil { 455 + t.Fatal(err) 456 + } 457 + if !info.ModTime().Equal(original) { 458 + t.Errorf("mtime = %v, want %v (unchanged)", info.ModTime(), original) 459 + } 460 + } 461 + 462 + func TestTouchNoCreateOnMissingDoesNotError(t *testing.T) { 463 + fs := newTimed(t) 464 + stdout, stderr, err := run(t, []string{"-c", "missing.txt"}, fs) 465 + if err != nil { 466 + t.Fatalf("touch -c missing: unexpected error: %v stderr=%q", err, stderr) 467 + } 468 + if stdout != "" { 469 + t.Errorf("stdout = %q, want empty", stdout) 470 + } 471 + if stderr != "" { 472 + t.Errorf("stderr = %q, want empty", stderr) 473 + } 474 + if exists(t, fs, "missing.txt") { 475 + t.Errorf("missing.txt should not exist") 476 + } 477 + } 478 + 479 + func TestTouchCreatesWith0644UnderAssumedUmask(t *testing.T) { 480 + // memfs always returns 0o666 from Stat regardless of OpenFile mode, 481 + // so we can't observe the create mode through fs.Stat. Validate the 482 + // constant directly: with the documented assumed umask 0o022, the 483 + // effective create mode must be 0o644. 484 + const assumedUmask os.FileMode = 0o022 485 + got := os.FileMode(0o666) &^ assumedUmask 486 + if got != 0o644 { 487 + t.Fatalf("computed create mode = %o, want 0644", got) 488 + } 489 + } 490 + 491 + func TestTouchHelpFlagListed(t *testing.T) { 492 + fs := newFS(t) 493 + _, stderr, err := run(t, []string{"--help"}, fs) 494 + if err != nil { 495 + t.Fatalf("unexpected error: %v", err) 496 + } 497 + for _, want := range []string{"-a", "-c", "-d", "-h", "-m", "-r", "-t"} { 498 + if !strings.Contains(stderr, want) { 499 + t.Errorf("help text missing %s; got %q", want, stderr) 500 + } 501 + } 502 + } 503 + 504 + func TestParseTStamp(t *testing.T) { 505 + tests := []struct { 506 + name string 507 + input string 508 + want time.Time 509 + ok bool 510 + }{ 511 + {"CCYYMMDDhhmm", "199501010100", time.Date(1995, 1, 1, 1, 0, 0, 0, time.UTC), true}, 512 + {"CCYYMMDDhhmm.SS", "199501010100.30", time.Date(1995, 1, 1, 1, 0, 30, 0, time.UTC), true}, 513 + {"YY=95 maps to 1995", "9501010100", time.Date(1995, 1, 1, 1, 0, 0, 0, time.UTC), true}, 514 + {"YY=05 maps to 2005", "0501010100", time.Date(2005, 1, 1, 1, 0, 0, 0, time.UTC), true}, 515 + {"YY=68 maps to 2068", "6801010100", time.Date(2068, 1, 1, 1, 0, 0, 0, time.UTC), true}, 516 + {"YY=69 maps to 1969", "6901010100", time.Date(1969, 1, 1, 1, 0, 0, 0, time.UTC), true}, 517 + {"MMDDhhmm only", "01010100", time.Time{}, true}, 518 + {"garbage", "notavalidtime", time.Time{}, false}, 519 + {"too short", "1234", time.Time{}, false}, 520 + {"bad month", "199513010100", time.Time{}, false}, 521 + {"bad day", "199501320100", time.Time{}, false}, 522 + {"bad hour", "199501012500", time.Time{}, false}, 523 + {"bad minute", "199501010060", time.Time{}, false}, 524 + {"bad second len", "199501010100.3", time.Time{}, false}, 525 + {"non-digit", "1995010101ab", time.Time{}, false}, 526 + } 527 + for _, tt := range tests { 528 + t.Run(tt.name, func(t *testing.T) { 529 + got, ok := parseTStamp(tt.input) 530 + if ok != tt.ok { 531 + t.Fatalf("parseTStamp(%q) ok = %v, want %v", tt.input, ok, tt.ok) 532 + } 533 + if !ok { 534 + return 535 + } 536 + if tt.name == "MMDDhhmm only" { 537 + if got.Year() != time.Now().UTC().Year() { 538 + t.Errorf("year = %d, want current year %d", got.Year(), time.Now().UTC().Year()) 539 + } 540 + return 541 + } 542 + if !got.Equal(tt.want) { 543 + t.Errorf("parseTStamp(%q) = %v, want %v", tt.input, got, tt.want) 242 544 } 243 545 }) 244 546 }