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(command): port sleep from just-bash

Assisted-by: Claude Opus 4.7 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

Xe Iaso 263b3de8 5360e6f8

+351
+122
command/internal/sleep/sleep.go
··· 1 + package sleep 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "regexp" 9 + "strconv" 10 + "time" 11 + 12 + "github.com/pborman/getopt/v2" 13 + "mvdan.cc/sh/v3/interp" 14 + "tangled.org/xeiaso.net/kefka/command" 15 + ) 16 + 17 + type Impl struct{} 18 + 19 + const maxSleep = time.Hour 20 + 21 + var durationRe = regexp.MustCompile(`^(\d+\.?\d*)(s|m|h|d)?$`) 22 + 23 + func (Impl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 24 + if ec == nil { 25 + return errors.New("sleep: nil ExecContext") 26 + } 27 + 28 + stderr := ec.Stderr 29 + if stderr == nil { 30 + stderr = io.Discard 31 + } 32 + 33 + set := getopt.New() 34 + set.SetProgram("sleep") 35 + set.SetParameters("NUMBER[SUFFIX]...") 36 + 37 + usage := func() { 38 + fmt.Fprint(stderr, "Usage: sleep NUMBER[SUFFIX]...\n") 39 + fmt.Fprint(stderr, "Pause for NUMBER seconds. SUFFIX may be:\n") 40 + fmt.Fprint(stderr, " s - seconds (default)\n") 41 + fmt.Fprint(stderr, " m - minutes\n") 42 + fmt.Fprint(stderr, " h - hours\n") 43 + fmt.Fprint(stderr, " d - days\n\n") 44 + fmt.Fprint(stderr, "NUMBER may be a decimal number.\n\n") 45 + fmt.Fprint(stderr, " --help display this help and exit\n") 46 + } 47 + set.SetUsage(usage) 48 + 49 + help := set.BoolLong("help", 0, "display this help and exit") 50 + 51 + if err := set.Getopt(append([]string{"sleep"}, args...), nil); err != nil { 52 + fmt.Fprintf(stderr, "sleep: %s\n", err) 53 + usage() 54 + return interp.ExitStatus(1) 55 + } 56 + 57 + if *help { 58 + usage() 59 + return nil 60 + } 61 + 62 + rest := set.Args() 63 + if len(rest) == 0 { 64 + fmt.Fprint(stderr, "sleep: missing operand\n") 65 + return interp.ExitStatus(1) 66 + } 67 + 68 + var total time.Duration 69 + for _, arg := range rest { 70 + d, ok := parseDuration(arg) 71 + if !ok { 72 + fmt.Fprintf(stderr, "sleep: invalid time interval '%s'\n", arg) 73 + return interp.ExitStatus(1) 74 + } 75 + total += d 76 + } 77 + 78 + if total > maxSleep { 79 + total = maxSleep 80 + } 81 + 82 + if ctx.Err() != nil { 83 + return nil 84 + } 85 + 86 + timer := time.NewTimer(total) 87 + defer timer.Stop() 88 + select { 89 + case <-timer.C: 90 + case <-ctx.Done(): 91 + } 92 + return nil 93 + } 94 + 95 + func parseDuration(arg string) (time.Duration, bool) { 96 + m := durationRe.FindStringSubmatch(arg) 97 + if m == nil { 98 + return 0, false 99 + } 100 + value, err := strconv.ParseFloat(m[1], 64) 101 + if err != nil { 102 + return 0, false 103 + } 104 + suffix := m[2] 105 + if suffix == "" { 106 + suffix = "s" 107 + } 108 + var unit time.Duration 109 + switch suffix { 110 + case "s": 111 + unit = time.Second 112 + case "m": 113 + unit = time.Minute 114 + case "h": 115 + unit = time.Hour 116 + case "d": 117 + unit = 24 * time.Hour 118 + default: 119 + return 0, false 120 + } 121 + return time.Duration(value * float64(unit)), true 122 + }
+229
command/internal/sleep/sleep_test.go
··· 1 + package sleep 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "strings" 7 + "testing" 8 + "time" 9 + 10 + "tangled.org/xeiaso.net/kefka/command" 11 + ) 12 + 13 + func run(t *testing.T, ctx context.Context, args []string) (string, string, error) { 14 + t.Helper() 15 + var stdout, stderr bytes.Buffer 16 + ec := &command.ExecContext{ 17 + Stdout: &stdout, 18 + Stderr: &stderr, 19 + Dir: ".", 20 + } 21 + err := Impl{}.Exec(ctx, ec, args) 22 + return stdout.String(), stderr.String(), err 23 + } 24 + 25 + func TestSleep(t *testing.T) { 26 + tests := []struct { 27 + name string 28 + args []string 29 + wantErrSub string 30 + wantErr bool 31 + }{ 32 + { 33 + name: "default seconds suffix", 34 + args: []string{"0.001"}, 35 + }, 36 + { 37 + name: "explicit seconds", 38 + args: []string{"0.001s"}, 39 + }, 40 + { 41 + name: "zero seconds", 42 + args: []string{"0"}, 43 + }, 44 + { 45 + name: "multiple args sum", 46 + args: []string{"0.001s", "0.001s"}, 47 + }, 48 + { 49 + name: "decimal number", 50 + args: []string{"0.0001"}, 51 + }, 52 + { 53 + name: "missing operand", 54 + args: []string{}, 55 + wantErrSub: "missing operand", 56 + wantErr: true, 57 + }, 58 + { 59 + name: "invalid time interval", 60 + args: []string{"5x"}, 61 + wantErrSub: "invalid time interval '5x'", 62 + wantErr: true, 63 + }, 64 + { 65 + name: "negative number", 66 + args: []string{"-5"}, 67 + wantErr: true, 68 + }, 69 + { 70 + name: "empty string", 71 + args: []string{""}, 72 + wantErrSub: "invalid time interval ''", 73 + wantErr: true, 74 + }, 75 + { 76 + name: "trailing junk", 77 + args: []string{"5sm"}, 78 + wantErrSub: "invalid time interval '5sm'", 79 + wantErr: true, 80 + }, 81 + { 82 + name: "scientific notation rejected", 83 + args: []string{"1e3"}, 84 + wantErrSub: "invalid time interval '1e3'", 85 + wantErr: true, 86 + }, 87 + { 88 + name: "second arg invalid stops sum", 89 + args: []string{"0.001", "bogus"}, 90 + wantErrSub: "invalid time interval 'bogus'", 91 + wantErr: true, 92 + }, 93 + } 94 + 95 + for _, tt := range tests { 96 + t.Run(tt.name, func(t *testing.T) { 97 + stdout, stderr, err := run(t, context.Background(), tt.args) 98 + if tt.wantErr { 99 + if err == nil { 100 + t.Fatalf("expected error, got nil; stdout=%q stderr=%q", stdout, stderr) 101 + } 102 + } else if err != nil { 103 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 104 + } 105 + if stdout != "" { 106 + t.Errorf("expected empty stdout, got %q", stdout) 107 + } 108 + if tt.wantErrSub != "" && !strings.Contains(stderr, tt.wantErrSub) { 109 + t.Errorf("stderr = %q, want substring %q", stderr, tt.wantErrSub) 110 + } 111 + }) 112 + } 113 + } 114 + 115 + func TestSuffixes(t *testing.T) { 116 + tests := []struct { 117 + name string 118 + arg string 119 + want time.Duration 120 + }{ 121 + {"seconds default", "1", time.Second}, 122 + {"seconds explicit", "1s", time.Second}, 123 + {"minutes", "1m", time.Minute}, 124 + {"hours", "1h", time.Hour}, 125 + {"days clamped to max", "1d", time.Hour}, 126 + {"decimal seconds", "0.5s", 500 * time.Millisecond}, 127 + {"trailing dot", "5.", 5 * time.Second}, 128 + } 129 + 130 + for _, tt := range tests { 131 + t.Run(tt.name, func(t *testing.T) { 132 + d, ok := parseDuration(tt.arg) 133 + if !ok { 134 + t.Fatalf("parseDuration(%q) returned !ok", tt.arg) 135 + } 136 + // Days exceeds the cap; the cap logic lives in Exec, not parseDuration, 137 + // so for parsing we just verify the raw conversion. 138 + if tt.name == "days clamped to max" { 139 + if d != 24*time.Hour { 140 + t.Errorf("parseDuration(%q) = %v, want %v", tt.arg, d, 24*time.Hour) 141 + } 142 + return 143 + } 144 + if d != tt.want { 145 + t.Errorf("parseDuration(%q) = %v, want %v", tt.arg, d, tt.want) 146 + } 147 + }) 148 + } 149 + } 150 + 151 + func TestCancelAlreadyDone(t *testing.T) { 152 + ctx, cancel := context.WithCancel(context.Background()) 153 + cancel() 154 + 155 + start := time.Now() 156 + _, _, err := run(t, ctx, []string{"1d"}) 157 + elapsed := time.Since(start) 158 + 159 + if err != nil { 160 + t.Fatalf("unexpected error: %v", err) 161 + } 162 + if elapsed > 100*time.Millisecond { 163 + t.Errorf("pre-cancelled ctx slept for %v, expected immediate return", elapsed) 164 + } 165 + } 166 + 167 + func TestCancelDuringSleep(t *testing.T) { 168 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) 169 + defer cancel() 170 + 171 + start := time.Now() 172 + _, _, err := run(t, ctx, []string{"1h"}) 173 + elapsed := time.Since(start) 174 + 175 + if err != nil { 176 + t.Fatalf("unexpected error: %v", err) 177 + } 178 + if elapsed > 500*time.Millisecond { 179 + t.Errorf("cancel during sleep took %v, expected ~10ms", elapsed) 180 + } 181 + } 182 + 183 + func TestCapAtOneHour(t *testing.T) { 184 + // Combined duration > 1h. With an immediately-cancelled ctx the call returns 185 + // fast; if the cap weren't in place the timer would still be set to the 186 + // uncapped duration, but that's not observable here. The point of this 187 + // test is that we don't reject "over cap" inputs as errors. 188 + ctx, cancel := context.WithCancel(context.Background()) 189 + cancel() 190 + 191 + _, stderr, err := run(t, ctx, []string{"2h", "30m"}) 192 + if err != nil { 193 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 194 + } 195 + if stderr != "" { 196 + t.Errorf("expected empty stderr, got %q", stderr) 197 + } 198 + } 199 + 200 + func TestHelp(t *testing.T) { 201 + stdout, stderr, err := run(t, context.Background(), []string{"--help"}) 202 + if err != nil { 203 + t.Fatalf("unexpected error: %v", err) 204 + } 205 + if stdout != "" { 206 + t.Errorf("expected empty stdout, got %q", stdout) 207 + } 208 + if !strings.Contains(stderr, "Usage: sleep NUMBER[SUFFIX]") { 209 + t.Errorf("usage line missing from stderr: %q", stderr) 210 + } 211 + if !strings.Contains(stderr, "--help") { 212 + t.Errorf("help flag missing from help: %q", stderr) 213 + } 214 + for _, suffix := range []string{"s - seconds", "m - minutes", "h - hours", "d - days"} { 215 + if !strings.Contains(stderr, suffix) { 216 + t.Errorf("suffix line %q missing from help: %q", suffix, stderr) 217 + } 218 + } 219 + } 220 + 221 + func TestUnknownFlag(t *testing.T) { 222 + _, stderr, err := run(t, context.Background(), []string{"--no-such-flag", "1s"}) 223 + if err == nil { 224 + t.Fatal("expected error, got nil") 225 + } 226 + if !strings.Contains(stderr, "sleep:") { 227 + t.Errorf("expected sleep prefix in stderr, got %q", stderr) 228 + } 229 + }