├── .github ├── CODEOWNERS ├── FUNDING.yml └── workflows │ └── ci.yml ├── LICENSE ├── README.md ├── cmd ├── main.go └── main_test.go ├── cronrange.go ├── cronrange_test.go ├── go.mod ├── rule.go └── rule_test.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # These owners will be the default owners for everything in the repo. 2 | # Unless a later match takes precedence, @umputun will be requested for 3 | # review when someone opens a pull request. 4 | 5 | * @umputun 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [umputun] 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | tags: 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: set up go 1.23 15 | uses: actions/setup-go@v2 16 | with: 17 | go-version: "1.23" 18 | id: go 19 | 20 | - name: checkout 21 | uses: actions/checkout@v3 22 | 23 | - name: build and test 24 | run: | 25 | go get -v 26 | go test -timeout=60s -race -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov_tmp 27 | cat $GITHUB_WORKSPACE/profile.cov_tmp | grep -v "_mock.go" > $GITHUB_WORKSPACE/profile.cov 28 | go build -race 29 | env: 30 | GO111MODULE: "on" 31 | TZ: "America/Chicago" 32 | 33 | - name: golangci-lint 34 | uses: golangci/golangci-lint-action@v3 35 | with: 36 | version: latest 37 | 38 | - name: install goveralls, submit coverage 39 | run: | 40 | go install github.com/mattn/goveralls@latest 41 | goveralls -service="github" -coverprofile=$GITHUB_WORKSPACE/profile.cov 42 | env: 43 | COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Umputun 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cronrange 2 | [![Build Status](https://github.com/go-pkgz/cronrange/workflows/build/badge.svg)](https://github.com/go-pkgz/cronrange/actions) [![Coverage Status](https://coveralls.io/repos/github/go-pkgz/cronrange/badge.svg?branch=master)](https://coveralls.io/github/go-pkgz/cronrange?branch=master) [![Go Reference](https://pkg.go.dev/badge/github.com/go-pkgz/cronrange.svg)](https://pkg.go.dev/github.com/go-pkgz/cronrange) 3 | 4 | `cronrange` is a Go package that provides a crontab-like format for expressing time ranges, particularly useful for defining recurring time windows. Unlike traditional crontab that defines specific moments in time, cronrange defines time periods when something should be active or eligible. 5 | 6 | ## Format 7 | 8 | The format consists of four fields separated by whitespace: 9 | ``` 10 | time dow dom month 11 | ``` 12 | 13 | Where: 14 | - `time`: Time range in 24-hour format (HH:MM[:SS]-HH:MM[:SS]) or * for all day. Seconds are optional. 15 | - `dow`: Day of week (0-6, where 0=Sunday) 16 | - `dom`: Day of month (1-31) 17 | - `month`: Month (1-12) 18 | 19 | Multiple rules can be combined using semicolons (;). 20 | 21 | Each field (except time) supports: 22 | - Single values: "5" 23 | - Lists: "1,3,5" 24 | - Ranges: "1-5" 25 | - Asterisk: "*" for any/all values 26 | 27 | ## Examples 28 | 29 | ``` 30 | # Basic patterns 31 | 17:20-21:35 1-5 * * # Weekdays from 5:20 PM to 9:35 PM 32 | 17:20:15-21:35:16 1-5 * * # Weekdays from 5:20:15 PM to 9:35:16 PM 33 | * 0,6 * * # All day on weekends 34 | 09:00-17:00 1-5 * 4-9 # Weekdays 9 AM to 5 PM, April through September 35 | 12:00-13:00 * 1,15 * # Noon-1 PM on 1st and 15th of every month 36 | 23:00-07:00 * * * # Overnight range from 11 PM to 7 AM, every day 37 | 38 | # Multiple rules combined: 39 | 17:20-21:35 1-5 *;* * 0,6 * * # Weekday evenings and all weekend 40 | 09:00-17:00 * *1-5; 10:00-16:00 * *6-12 # Different hours for different months 41 | ``` 42 | 43 | ## Installation 44 | 45 | ```bash 46 | go get github.com/go-pkgz/cronrange 47 | ``` 48 | ## Library Usage 49 | 50 | ```go 51 | import "github.com/go-pkgz/cronrange" 52 | 53 | // Parse rules 54 | rules, err := cronrange.Parse("17:20-21:35 1-5 *;* * 0,6 * *") 55 | if err != nil { 56 | log.Fatal(err) 57 | } 58 | 59 | // Check if current time matches 60 | if cronrange.Match(rules, time.Now()) { 61 | fmt.Println("Current time matches the rules") 62 | } 63 | 64 | // Check specific time 65 | t := time.Date(2024, 1, 1, 18, 30, 0, 0, time.UTC) 66 | if cronrange.Match(rules, t) { 67 | fmt.Println("Time matches the rules") 68 | } 69 | 70 | // Rules can be converted back to string format 71 | fmt.Println(rules[0].String()) // "17:20-21:35 1-5 *" 72 | ``` 73 | 74 | Alternatively, you can use the `ParseFromReader` function to read rules from an `io.Reader` 75 | 76 | ## Error Handling 77 | 78 | The package validates input and provides specific errors: 79 | ```go 80 | // These will return errors 81 | _, err1 := cronrange.Parse("25:00-26:00 1-5 * *") // Invalid hours 82 | _, err2 := cronrange.Parse("17:20-21:35 7 * *") // Invalid day of week 83 | _, err3 := cronrange.Parse("17:20-21:35 1-5 32 *") // Invalid day of month 84 | _, err4 := cronrange.Parse("17:20-21:35 1-5 * 13") // Invalid month 85 | _, err5 := cronrange.Parse("17:20-21:35 1-5 *") // Wrong number of fields 86 | ``` 87 | 88 | ## Command Line Utility 89 | 90 | The package includes a command-line utility that can be used to execute commands within specified time ranges. 91 | If the current time matches the range, the command is executed and the exit code indicates the result. If the time is outside the range, the command is not executed and the exit code is 1. 92 | 93 | It can be run without the command just to check if the current time is within the range. in this case, the exit code will indicate the result, i.e. 0 if the time is within the range, 1 otherwise. 94 | 95 | Install it with: 96 | 97 | ```bash 98 | go install github.com/go-pkgz/cronrange/cmd@latest 99 | ``` 100 | 101 | ### Usage 102 | 103 | ```bash 104 | cronrange "TIME_RANGE" [command args...] 105 | ``` 106 | 107 | Examples: 108 | ```bash 109 | # Check if current time is within range (exit code indicates result) 110 | cronrange "17:20-21:35 1-5 * *" 111 | 112 | # Execute command only if within range 113 | cronrange "17:20-21:35 1-5 * *" echo "Running backup" 114 | ``` 115 | 116 | Exit codes: 117 | - 0: Time matches range (or command executed successfully) 118 | - 1: Time outside range (or command failed) 119 | - 2: Invalid arguments or parsing error 120 | 121 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | // cmd/main.go 2 | package main 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | "time" 10 | 11 | "github.com/go-pkgz/cronrange" 12 | ) 13 | 14 | func main() { 15 | if len(os.Args) < 2 { 16 | fmt.Fprintf(os.Stderr, "Usage: %s TIME_RANGE [command args...]\n", os.Args[0]) 17 | fmt.Fprintf(os.Stderr, "Example: %s \"17:20-21:35 1-5 * *\" echo hello\n", os.Args[0]) 18 | os.Exit(2) 19 | } 20 | 21 | // parse cronrange expression 22 | rules, err := cronrange.Parse(os.Args[1]) 23 | if err != nil { 24 | fmt.Fprintf(os.Stderr, "Error parsing cronrange: %v\n", err) 25 | os.Exit(2) 26 | } 27 | 28 | // get current time or use test time if provided 29 | now := time.Now() 30 | if testTime := os.Getenv("CRONRANGE_TEST_TIME"); testTime != "" { 31 | parsed, err := time.Parse(time.RFC3339, testTime) 32 | if err != nil { 33 | fmt.Fprintf(os.Stderr, "Error parsing test time: %v\n", err) 34 | os.Exit(2) 35 | } 36 | now = parsed 37 | } 38 | 39 | // check if current time matches the rules 40 | if !cronrange.Match(rules, now) { 41 | os.Exit(1) 42 | } 43 | 44 | // if no command provided, just exit with success 45 | if len(os.Args) == 2 { 46 | os.Exit(0) 47 | } 48 | 49 | // execute the command 50 | cmd := exec.Command(os.Args[2], os.Args[3:]...) 51 | cmd.Stdout = os.Stdout 52 | cmd.Stderr = os.Stderr 53 | 54 | if err := cmd.Run(); err != nil { 55 | var exitErr *exec.ExitError 56 | if errors.As(err, &exitErr) { 57 | os.Exit(exitErr.ExitCode()) 58 | } 59 | fmt.Fprintf(os.Stderr, "Error executing command: %v\n", err) 60 | os.Exit(1) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /cmd/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestCommand(t *testing.T) { 13 | // build the command for testing 14 | exe := filepath.Join(t.TempDir(), "cronrange") 15 | build := exec.Command("go", "build", "-o", exe) 16 | if err := build.Run(); err != nil { 17 | t.Fatalf("Failed to build: %v", err) 18 | } 19 | 20 | testTime := time.Date(2024, time.January, 2, 12, 30, 0, 0, time.UTC) // Tuesday, Jan 2, 2024 12:30 UTC 21 | tests := []struct { 22 | name string 23 | args []string 24 | wantCode int 25 | timeOffset time.Duration // offset from testTime 26 | }{ 27 | { 28 | name: "no arguments", 29 | args: []string{}, 30 | wantCode: 2, 31 | }, 32 | { 33 | name: "invalid expression format", 34 | args: []string{"invalid"}, 35 | wantCode: 2, 36 | }, 37 | { 38 | name: "matches range without command", 39 | args: []string{"* * * *"}, 40 | wantCode: 0, 41 | }, 42 | { 43 | name: "outside time range", 44 | args: []string{"00:00-00:01 * * *"}, 45 | wantCode: 1, 46 | }, 47 | { 48 | name: "outside day range", 49 | args: []string{"* 1-5 * *"}, 50 | timeOffset: time.Hour * 24 * 5, // Sunday 51 | wantCode: 1, 52 | }, 53 | { 54 | name: "valid command execution", 55 | args: []string{"* * * *", "echo", "test"}, 56 | wantCode: 0, 57 | }, 58 | { 59 | name: "command not found", 60 | args: []string{"* * * *", "nonexistentcmd"}, 61 | wantCode: 1, 62 | }, 63 | { 64 | name: "matches specific time", 65 | args: []string{"12:00-13:00 * * *"}, 66 | wantCode: 0, 67 | }, 68 | { 69 | name: "matches weekday", 70 | args: []string{"* 1-5 * *"}, 71 | wantCode: 0, 72 | }, 73 | } 74 | 75 | for _, tt := range tests { 76 | t.Run(tt.name, func(t *testing.T) { 77 | cmd := exec.Command(exe, tt.args...) 78 | 79 | // set test time 80 | testTimeWithOffset := testTime.Add(tt.timeOffset) 81 | cmd.Env = append(os.Environ(), 82 | "CRONRANGE_TEST_TIME="+testTimeWithOffset.Format(time.RFC3339)) 83 | 84 | err := cmd.Run() 85 | var code int 86 | if err != nil { 87 | var exitErr *exec.ExitError 88 | if errors.As(err, &exitErr) { 89 | code = exitErr.ExitCode() 90 | } 91 | } 92 | 93 | if code != tt.wantCode { 94 | t.Errorf("Expected exit code %d, got %d", tt.wantCode, code) 95 | } 96 | }) 97 | } 98 | } 99 | 100 | func TestCommandOutput(t *testing.T) { 101 | // Build the command for testing 102 | exe := filepath.Join(t.TempDir(), "cronrange") 103 | build := exec.Command("go", "build", "-o", exe) 104 | if err := build.Run(); err != nil { 105 | t.Fatalf("Failed to build: %v", err) 106 | } 107 | 108 | t.Run("command output", func(t *testing.T) { 109 | cmd := exec.Command(exe, "* * * *", "echo", "test output") 110 | out, err := cmd.CombinedOutput() 111 | if err != nil { 112 | t.Fatalf("Command failed: %v", err) 113 | } 114 | 115 | if got := string(out); got != "test output\n" { 116 | t.Errorf("Expected output 'test output\\n', got %q", got) 117 | } 118 | }) 119 | 120 | t.Run("command exit code", func(t *testing.T) { 121 | cmd := exec.Command(exe, "* * * *", "sh", "-c", "exit 42") 122 | err := cmd.Run() 123 | 124 | var exitErr *exec.ExitError 125 | if !errors.As(err, &exitErr) || exitErr.ExitCode() != 42 { 126 | t.Errorf("Expected exit code 42, got %v", err) 127 | } 128 | }) 129 | } 130 | -------------------------------------------------------------------------------- /cronrange.go: -------------------------------------------------------------------------------- 1 | // Package cronrange provides a crontab-like format for expressing time ranges. 2 | // Unlike traditional crontab that defines specific moments in time, cronrange 3 | // defines time periods when something should be active. 4 | // 5 | // Format: `time dow dom month` 6 | // 7 | // Where: 8 | // - time: Time range in 24h format (HH:MM[:SS]-HH:MM[:SS]) or * for all day 9 | // - dow: Day of week (0-6, where 0=Sunday) 10 | // - dom: Day of month (1-31) 11 | // - month: Month (1-12) 12 | // 13 | // Each field (except time) supports single values, lists (1,3,5), ranges (1-5) 14 | // and asterisk (*) for any/all values. Multiple rules can be combined using semicolons. 15 | // 16 | // Examples: 17 | // 18 | // 17:20-21:35 1-5 * * # Weekdays from 5:20 PM to 9:35 PM 19 | // 11:20:12-19:25:18 1-5 * * # Weekdays from 11:20:12 AM to 7:35:18 PM 20 | // * 0,6 * * # All day on weekends 21 | // 09:00-17:00 1-5 * 4-9 # Weekdays 9 AM to 5 PM, April through September 22 | // 12:00-13:00 * 1,15 * # Noon-1 PM on 1st and 15th of every month 23 | package cronrange 24 | 25 | import ( 26 | "bufio" 27 | "bytes" 28 | "fmt" 29 | "io" 30 | "strings" 31 | "time" 32 | ) 33 | 34 | // Parse parses a cronrange expression and returns a Rule slice 35 | func Parse(expr string) ([]Rule, error) { 36 | rules := strings.Split(expr, ";") 37 | result := make([]Rule, 0, len(rules)) 38 | 39 | for _, r := range rules { 40 | rule, err := parseRule(strings.TrimSpace(r)) 41 | if err != nil { 42 | return nil, fmt.Errorf("invalid rule %q: %w", r, err) 43 | } 44 | result = append(result, rule) 45 | } 46 | 47 | return result, nil 48 | } 49 | 50 | // ParseFromReader parses a cronrange expression from a reader and returns a Rule slice 51 | func ParseFromReader(rdr io.Reader) ([]Rule, error) { 52 | buf, err := io.ReadAll(rdr) 53 | if err != nil { 54 | return nil, fmt.Errorf("can't read from reader: %w", err) 55 | } 56 | if len(buf) == 0 { 57 | return []Rule{}, nil 58 | } 59 | 60 | var res []Rule 61 | scanner := bufio.NewScanner(bytes.NewReader(buf)) 62 | for scanner.Scan() { 63 | rule := strings.TrimSpace(scanner.Text()) 64 | if rule == "" { 65 | continue 66 | } 67 | r, err := Parse(rule) 68 | if err != nil { 69 | return nil, fmt.Errorf("invalid rule %q: %w", rule, err) 70 | } 71 | res = append(res, r...) 72 | } 73 | if err := scanner.Err(); err != nil { 74 | return nil, fmt.Errorf("error reading input: %w", err) 75 | } 76 | return res, nil 77 | } 78 | 79 | // Match checks if the given time matches any of the rules 80 | func Match(rules []Rule, t time.Time) bool { 81 | for _, rule := range rules { 82 | if rule.matches(t) { 83 | return true 84 | } 85 | } 86 | return false 87 | } 88 | -------------------------------------------------------------------------------- /cronrange_test.go: -------------------------------------------------------------------------------- 1 | package cronrange 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestParse(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | expr string 13 | want string // expected String() output 14 | wantErr bool 15 | }{ 16 | { 17 | name: "basic weekday evening", 18 | expr: "17:20-21:35 1-5 * *", 19 | want: "17:20-21:35 1-5 * *", 20 | }, 21 | { 22 | name: "all weekend", 23 | expr: "* 0,6 * *", 24 | want: "* 0,6 * *", 25 | }, 26 | { 27 | name: "multiple rules", 28 | expr: "17:20-21:35 1-5 * *; * 0,6 * *", 29 | want: "17:20-21:35 1-5 * *; * 0,6 * *", 30 | }, 31 | { 32 | name: "specific month days", 33 | expr: "12:00-13:00 * 1,15 *", 34 | want: "12:00-13:00 * 1,15 *", 35 | }, 36 | { 37 | name: "specific months", 38 | expr: "09:00-17:00 1-5 * 4-9", 39 | want: "09:00-17:00 1-5 * 4-9", 40 | }, 41 | { 42 | name: "invalid time format", 43 | expr: "1720-2135 1-5 * *", 44 | wantErr: true, 45 | }, 46 | { 47 | name: "invalid time range", 48 | expr: "25:00-26:00 1-5 * *", 49 | wantErr: true, 50 | }, 51 | { 52 | name: "invalid dow", 53 | expr: "17:20-21:35 7 * *", 54 | wantErr: true, 55 | }, 56 | { 57 | name: "invalid dom", 58 | expr: "17:20-21:35 1-5 32 *", 59 | wantErr: true, 60 | }, 61 | { 62 | name: "invalid month", 63 | expr: "17:20-21:35 1-5 * 13", 64 | wantErr: true, 65 | }, 66 | { 67 | name: "wrong number of fields", 68 | expr: "17:20-21:35 1-5 *", 69 | wantErr: true, 70 | }, 71 | { 72 | name: "empty expression", 73 | expr: "", 74 | wantErr: true, 75 | }, 76 | } 77 | 78 | for _, tt := range tests { 79 | t.Run(tt.name, func(t *testing.T) { 80 | got, err := Parse(tt.expr) 81 | if (err != nil) != tt.wantErr { 82 | t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) 83 | return 84 | } 85 | if err != nil { 86 | return 87 | } 88 | 89 | // Convert the rules back to string and check 90 | var gotStr string 91 | for i, rule := range got { 92 | if i > 0 { 93 | gotStr += "; " 94 | } 95 | gotStr += rule.String() 96 | } 97 | if gotStr != tt.want { 98 | t.Errorf("Parse() = %v, want %v", gotStr, tt.want) 99 | } 100 | }) 101 | } 102 | } 103 | 104 | func TestMatch(t *testing.T) { 105 | tests := []struct { 106 | name string 107 | expr string 108 | time time.Time 109 | want bool 110 | wantErr bool 111 | }{ 112 | { 113 | name: "weekday evening match", 114 | expr: "17:20-21:35 1-5 * *", 115 | time: time.Date(2024, 1, 1, 18, 30, 0, 0, time.UTC), // Monday 6:30 PM 116 | want: true, 117 | }, 118 | { 119 | name: "weekday evening non-match time", 120 | expr: "17:20-21:35 1-5 * *", 121 | time: time.Date(2024, 1, 1, 16, 30, 0, 0, time.UTC), // Monday 4:30 PM 122 | want: false, 123 | }, 124 | { 125 | name: "weekday evening non-match day", 126 | expr: "17:20-21:35 1-5 * *", 127 | time: time.Date(2024, 1, 6, 18, 30, 0, 0, time.UTC), // Saturday 6:30 PM 128 | want: false, 129 | }, 130 | { 131 | name: "weekend all time", 132 | expr: "* 0,6 * *", 133 | time: time.Date(2024, 1, 6, 12, 0, 0, 0, time.UTC), // Saturday noon 134 | want: true, 135 | }, 136 | { 137 | name: "multiple rules - weekday match", 138 | expr: "17:20-21:35 1-5 * *; * 0,6 * *", 139 | time: time.Date(2024, 1, 1, 18, 30, 0, 0, time.UTC), // Monday 6:30 PM 140 | want: true, 141 | }, 142 | { 143 | name: "multiple rules - weekend match", 144 | expr: "17:20-21:35 1-5 * *; * 0,6 * *", 145 | time: time.Date(2024, 1, 6, 12, 0, 0, 0, time.UTC), // Saturday noon 146 | want: true, 147 | }, 148 | { 149 | name: "multiple rules - weekday non-match", 150 | expr: "17:20-21:35 1-5 * *; * 0,6 * *", 151 | time: time.Date(2024, 1, 1, 16, 30, 0, 0, time.UTC), // Monday 4:30 PM 152 | want: false, 153 | }, 154 | { 155 | name: "specific month days match", 156 | expr: "12:00-13:00 * 1,15 *", 157 | time: time.Date(2024, 1, 15, 12, 30, 0, 0, time.UTC), // 15th at 12:30 158 | want: true, 159 | }, 160 | { 161 | name: "specific month days non-match", 162 | expr: "12:00-13:00 * 1,15 *", 163 | time: time.Date(2024, 1, 14, 12, 30, 0, 0, time.UTC), // 14th at 12:30 164 | want: false, 165 | }, 166 | } 167 | 168 | for _, tt := range tests { 169 | t.Run(tt.name, func(t *testing.T) { 170 | rules, err := Parse(tt.expr) 171 | if (err != nil) != tt.wantErr { 172 | t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) 173 | return 174 | } 175 | if err != nil { 176 | return 177 | } 178 | 179 | got := Match(rules, tt.time) 180 | if got != tt.want { 181 | t.Errorf("Match() = %v, want %v", got, tt.want) 182 | } 183 | }) 184 | } 185 | } 186 | 187 | func TestParseFromReader(t *testing.T) { 188 | equal := func(a, b []string) bool { 189 | if len(a) != len(b) { 190 | return false 191 | } 192 | for i := range a { 193 | if a[i] != b[i] { 194 | return false 195 | } 196 | } 197 | return true 198 | } 199 | tests := []struct { 200 | name string 201 | input string 202 | want []string // expected String() outputs 203 | wantErr bool 204 | }{ 205 | { 206 | name: "single rule", 207 | input: "17:20-21:35 1-5 * *", 208 | want: []string{"17:20-21:35 1-5 * *"}, 209 | }, 210 | { 211 | name: "multiple rules", 212 | input: "17:20-21:35 1-5 * *\n* 0,6 * *", 213 | want: []string{"17:20-21:35 1-5 * *", "* 0,6 * *"}, 214 | }, 215 | { 216 | name: "empty input", 217 | input: "", 218 | want: []string{}, 219 | }, 220 | { 221 | name: "invalid rule", 222 | input: "invalid rule", 223 | wantErr: true, 224 | }, 225 | } 226 | 227 | for _, tt := range tests { 228 | t.Run(tt.name, func(t *testing.T) { 229 | rdr := strings.NewReader(tt.input) 230 | got, err := ParseFromReader(rdr) 231 | if (err != nil) != tt.wantErr { 232 | t.Errorf("ParseFromReader() error = %v, wantErr %v", err, tt.wantErr) 233 | return 234 | } 235 | if err != nil { 236 | return 237 | } 238 | 239 | var gotStr []string 240 | for _, rule := range got { 241 | gotStr = append(gotStr, rule.String()) 242 | } 243 | if !equal(gotStr, tt.want) { 244 | t.Errorf("ParseFromReader() = %v, want %v", gotStr, tt.want) 245 | } 246 | }) 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/go-pkgz/cronrange 2 | 3 | go 1.23 4 | -------------------------------------------------------------------------------- /rule.go: -------------------------------------------------------------------------------- 1 | package cronrange 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strconv" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | // Rule represents a single cronrange rule 12 | type Rule struct { 13 | timeRange TimeRange 14 | dow Field // 0-6 (Sunday = 0) 15 | dom Field // 1-31 16 | month Field // 1-12 17 | } 18 | 19 | // TimeRange represents a time period within a day 20 | type TimeRange struct { 21 | start time.Duration // minutes since midnight 22 | end time.Duration 23 | all bool 24 | overnight bool // true if range spans across midnight 25 | hasSeconds bool // track if the original format included seconds 26 | } 27 | 28 | // Field represents a cronrange field that can contain multiple values 29 | type Field struct { 30 | values map[int]bool 31 | all bool 32 | } 33 | 34 | // parseRule parses a cronrange rule string and returns a Rule struct or an error if the input is invalid 35 | func parseRule(rule string) (Rule, error) { 36 | parts := strings.Fields(rule) 37 | if len(parts) != 4 { 38 | return Rule{}, fmt.Errorf("rule must have 4 fields: time dow dom month") 39 | } 40 | 41 | timeRange, err := parseTimeRange(parts[0]) 42 | if err != nil { 43 | return Rule{}, err 44 | } 45 | 46 | dow, err := parseField(parts[1], 0, 6) 47 | if err != nil { 48 | return Rule{}, fmt.Errorf("invalid dow: %w", err) 49 | } 50 | 51 | dom, err := parseField(parts[2], 1, 31) 52 | if err != nil { 53 | return Rule{}, fmt.Errorf("invalid dom: %w", err) 54 | } 55 | 56 | month, err := parseField(parts[3], 1, 12) 57 | if err != nil { 58 | return Rule{}, fmt.Errorf("invalid month: %w", err) 59 | } 60 | 61 | return Rule{ 62 | timeRange: timeRange, 63 | dow: dow, 64 | dom: dom, 65 | month: month, 66 | }, nil 67 | } 68 | 69 | // parseTimeRange parses a time range string in the following formats: HH:MM-HH:MM, HH:MM:SS-HH:MM:SS 70 | // or a single asterisk for all day. Handles ranges that span across midnight. 71 | func parseTimeRange(s string) (TimeRange, error) { 72 | if s == "*" { 73 | return TimeRange{all: true}, nil 74 | } 75 | 76 | parts := strings.Split(s, "-") 77 | if len(parts) != 2 { 78 | return TimeRange{}, fmt.Errorf("invalid time range format") 79 | } 80 | 81 | start, hasStartSeconds, err := parseTime(parts[0]) 82 | if err != nil { 83 | return TimeRange{}, err 84 | } 85 | 86 | end, hasEndSeconds, err := parseTime(parts[1]) 87 | if err != nil { 88 | return TimeRange{}, err 89 | } 90 | 91 | // Check if this is an overnight range 92 | overnight := false 93 | if end < start { 94 | overnight = true 95 | } 96 | 97 | return TimeRange{ 98 | start: start, 99 | end: end, 100 | overnight: overnight, 101 | hasSeconds: hasStartSeconds || hasEndSeconds, 102 | }, nil 103 | } 104 | 105 | // parseTime parses a time string in the formats HH:MM or HH:MM:SS. 106 | // It returns the duration since midnight, a boolean indicating if seconds were specified, and an error if the input is invalid. 107 | // The function splits the input string by colons, converts the parts to integers, and validates the values. 108 | func parseTime(s string) (time.Duration, bool, error) { 109 | parts := strings.Split(s, ":") 110 | if len(parts) < 2 || len(parts) > 3 { // ensure the time string has either 2 or 3 parts 111 | return 0, false, fmt.Errorf("invalid time format") 112 | } 113 | 114 | hours, err := strconv.Atoi(parts[0]) // convert the first part to hours 115 | if err != nil { 116 | return 0, false, err 117 | } 118 | 119 | minutes, err := strconv.Atoi(parts[1]) // convert the second part to minutes 120 | if err != nil { 121 | return 0, false, err 122 | } 123 | 124 | seconds := 0 125 | hasSeconds := len(parts) == 3 // check if the seconds' part is present 126 | if hasSeconds { 127 | seconds, err = strconv.Atoi(parts[2]) // convert the third part to seconds 128 | if err != nil { 129 | return 0, false, err 130 | } 131 | } 132 | 133 | if hours < 0 || hours > 23 || minutes < 0 || minutes > 59 || seconds < 0 || seconds > 59 { // validate the time values 134 | return 0, false, fmt.Errorf("invalid time values") 135 | } 136 | 137 | return time.Duration(hours)*time.Hour + time.Duration(minutes)*time.Minute + time.Duration(seconds)*time.Second, hasSeconds, nil 138 | } 139 | 140 | // parseField parses a field string in the following formats: 1,2,3, 1-3,5-6 or a single asterisk for all values. 141 | // The min and max arguments define the range of valid values for the field. The function returns a Field with 142 | // the parsed values or an error if the input is invalid. Values in the Field are stored in a map 143 | // for fast lookup of allowed values. 144 | func parseField(s string, min, max int) (Field, error) { 145 | if s == "*" { 146 | return Field{all: true}, nil 147 | } 148 | 149 | values := make(map[int]bool) 150 | ranges := strings.Split(s, ",") 151 | 152 | for _, r := range ranges { 153 | if strings.Contains(r, "-") { 154 | parts := strings.Split(r, "-") 155 | if len(parts) != 2 { 156 | return Field{}, fmt.Errorf("invalid range format") 157 | } 158 | 159 | start, err := strconv.Atoi(parts[0]) 160 | if err != nil { 161 | return Field{}, err 162 | } 163 | 164 | end, err := strconv.Atoi(parts[1]) 165 | if err != nil { 166 | return Field{}, err 167 | } 168 | 169 | if start < min || end > max || start > end { 170 | return Field{}, fmt.Errorf("values out of range") 171 | } 172 | 173 | for i := start; i <= end; i++ { 174 | values[i] = true 175 | } 176 | continue 177 | } 178 | 179 | val, err := strconv.Atoi(r) 180 | if err != nil { 181 | return Field{}, err 182 | } 183 | 184 | if val < min || val > max { 185 | return Field{}, fmt.Errorf("value out of range") 186 | } 187 | 188 | values[val] = true 189 | } 190 | 191 | return Field{values: values}, nil 192 | } 193 | 194 | // matches checks if the current time falls within the time range, 195 | // handling ranges that span across midnight 196 | func (r Rule) matches(t time.Time) bool { 197 | if !r.month.matches(int(t.Month())) { 198 | return false 199 | } 200 | 201 | if !r.dom.matches(t.Day()) { 202 | return false 203 | } 204 | 205 | if !r.dow.matches(int(t.Weekday())) { 206 | return false 207 | } 208 | 209 | if r.timeRange.all { 210 | return true 211 | } 212 | 213 | currentTime := time.Duration(t.Hour())*time.Hour + 214 | time.Duration(t.Minute())*time.Minute + 215 | time.Duration(t.Second())*time.Second 216 | 217 | if r.timeRange.overnight { 218 | // for overnight ranges (e.g. 23:00-02:00) 219 | // the time matches if it's: 220 | // - after or equal to start time (e.g. >= 23:00) OR 221 | // - before or equal to end time (e.g. <= 02:00) 222 | return currentTime >= r.timeRange.start || currentTime <= r.timeRange.end 223 | } 224 | 225 | // For same-day ranges, time must be between start and end 226 | return currentTime >= r.timeRange.start && currentTime <= r.timeRange.end 227 | } 228 | 229 | func (f Field) matches(val int) bool { 230 | return f.all || f.values[val] 231 | } 232 | 233 | // String returns the string representation of a Rule 234 | func (r Rule) String() string { 235 | return fmt.Sprintf("%s %s %s %s", 236 | r.timeRange.String(), 237 | r.dow.String(), 238 | r.dom.String(), 239 | r.month.String(), 240 | ) 241 | } 242 | 243 | // String returns the string representation of a TimeRange 244 | func (tr TimeRange) String() string { 245 | if tr.all { 246 | return "*" 247 | } 248 | 249 | startH := tr.start / time.Hour 250 | startM := (tr.start % time.Hour) / time.Minute 251 | startS := (tr.start % time.Minute) / time.Second 252 | endH := tr.end / time.Hour 253 | endM := (tr.end % time.Hour) / time.Minute 254 | endS := (tr.end % time.Minute) / time.Second 255 | 256 | if tr.hasSeconds { 257 | return fmt.Sprintf("%02d:%02d:%02d-%02d:%02d:%02d", 258 | startH, startM, startS, endH, endM, endS) 259 | } 260 | return fmt.Sprintf("%02d:%02d-%02d:%02d", startH, startM, endH, endM) 261 | } 262 | 263 | // String returns the string representation of a Field 264 | func (f Field) String() string { 265 | if f.all { 266 | return "*" 267 | } 268 | 269 | // get all values from the map 270 | var vals []int 271 | for v := range f.values { 272 | vals = append(vals, v) 273 | } 274 | if len(vals) == 0 { 275 | return "*" 276 | } 277 | 278 | // sort values 279 | sort.Ints(vals) 280 | 281 | // find ranges and individual values 282 | var ranges []string 283 | start := vals[0] 284 | prev := start 285 | 286 | for i := 1; i < len(vals); i++ { 287 | if vals[i] != prev+1 { 288 | // end of a range or single value 289 | if start == prev { 290 | ranges = append(ranges, fmt.Sprintf("%d", start)) 291 | } else { 292 | ranges = append(ranges, fmt.Sprintf("%d-%d", start, prev)) 293 | } 294 | start = vals[i] 295 | } 296 | prev = vals[i] 297 | } 298 | 299 | // handle the last range or value 300 | if start == prev { 301 | ranges = append(ranges, fmt.Sprintf("%d", start)) 302 | } else { 303 | ranges = append(ranges, fmt.Sprintf("%d-%d", start, prev)) 304 | } 305 | 306 | return strings.Join(ranges, ",") 307 | } 308 | -------------------------------------------------------------------------------- /rule_test.go: -------------------------------------------------------------------------------- 1 | package cronrange 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestParseTimeRange(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | s string 12 | want string 13 | wantErr bool 14 | }{ 15 | { 16 | name: "all time", 17 | s: "*", 18 | want: "*", 19 | }, 20 | { 21 | name: "simple range", 22 | s: "09:00-17:00", 23 | want: "09:00-17:00", 24 | }, 25 | { 26 | name: "range with seconds", 27 | s: "09:00:11-17:00:22", 28 | want: "09:00:11-17:00:22", 29 | }, 30 | { 31 | name: "evening range", 32 | s: "17:20-21:35", 33 | want: "17:20-21:35", 34 | }, 35 | { 36 | name: "invalid format", 37 | s: "9:00to17:00", 38 | wantErr: true, 39 | }, 40 | { 41 | name: "invalid hour", 42 | s: "24:00-17:00", 43 | wantErr: true, 44 | }, 45 | { 46 | name: "invalid minute", 47 | s: "09:60-17:00", 48 | wantErr: true, 49 | }, 50 | } 51 | 52 | for _, tt := range tests { 53 | t.Run(tt.name, func(t *testing.T) { 54 | got, err := parseTimeRange(tt.s) 55 | if (err != nil) != tt.wantErr { 56 | t.Errorf("parseTimeRange() error = %v, wantErr %v", err, tt.wantErr) 57 | return 58 | } 59 | if err != nil { 60 | return 61 | } 62 | if got.String() != tt.want { 63 | t.Errorf("parseTimeRange() = %v, want %v", got.String(), tt.want) 64 | } 65 | }) 66 | } 67 | } 68 | 69 | func TestParseField(t *testing.T) { 70 | tests := []struct { 71 | name string 72 | s string 73 | min int 74 | max int 75 | want string 76 | wantErr bool 77 | }{ 78 | { 79 | name: "all values", 80 | s: "*", 81 | min: 0, 82 | max: 6, 83 | want: "*", 84 | }, 85 | { 86 | name: "single value", 87 | s: "5", 88 | min: 0, 89 | max: 6, 90 | want: "5", 91 | }, 92 | { 93 | name: "value list", 94 | s: "1,3,5", 95 | min: 0, 96 | max: 6, 97 | want: "1,3,5", 98 | }, 99 | { 100 | name: "range", 101 | s: "1-5", 102 | min: 0, 103 | max: 6, 104 | want: "1-5", 105 | }, 106 | { 107 | name: "complex range", 108 | s: "1-3,5-6", 109 | min: 0, 110 | max: 6, 111 | want: "1-3,5-6", 112 | }, 113 | { 114 | name: "out of range", 115 | s: "7", 116 | min: 0, 117 | max: 6, 118 | wantErr: true, 119 | }, 120 | { 121 | name: "invalid range", 122 | s: "5-3", 123 | min: 0, 124 | max: 6, 125 | wantErr: true, 126 | }, 127 | { 128 | name: "invalid format", 129 | s: "a-b", 130 | min: 0, 131 | max: 6, 132 | wantErr: true, 133 | }, 134 | } 135 | 136 | for _, tt := range tests { 137 | t.Run(tt.name, func(t *testing.T) { 138 | got, err := parseField(tt.s, tt.min, tt.max) 139 | if (err != nil) != tt.wantErr { 140 | t.Errorf("parseField() error = %v, wantErr %v", err, tt.wantErr) 141 | return 142 | } 143 | if err != nil { 144 | return 145 | } 146 | if got.String() != tt.want { 147 | t.Errorf("parseField() = %v, want %v", got.String(), tt.want) 148 | } 149 | }) 150 | } 151 | } 152 | 153 | func TestTimeRangeString(t *testing.T) { 154 | tests := []struct { 155 | name string 156 | tr TimeRange 157 | want string 158 | }{ 159 | { 160 | name: "all time", 161 | tr: TimeRange{all: true}, 162 | want: "*", 163 | }, 164 | { 165 | name: "simple range", 166 | tr: TimeRange{ 167 | start: 9*time.Hour + 0*time.Minute, 168 | end: 17*time.Hour + 0*time.Minute, 169 | }, 170 | want: "09:00-17:00", 171 | }, 172 | { 173 | name: "complex range", 174 | tr: TimeRange{ 175 | start: 17*time.Hour + 20*time.Minute, 176 | end: 21*time.Hour + 35*time.Minute, 177 | }, 178 | want: "17:20-21:35", 179 | }, 180 | } 181 | 182 | for _, tt := range tests { 183 | t.Run(tt.name, func(t *testing.T) { 184 | if got := tt.tr.String(); got != tt.want { 185 | t.Errorf("TimeRange.String() = %v, want %v", got, tt.want) 186 | } 187 | }) 188 | } 189 | } 190 | 191 | func TestOvernightTimeRange(t *testing.T) { 192 | cases := []struct { 193 | name string 194 | input string 195 | want string 196 | }{ 197 | {"overnight range", "23:00-02:00", "23:00-02:00"}, 198 | {"overnight range with seconds", "22:30:00-04:15:00", "22:30:00-04:15:00"}, 199 | {"regular range", "09:00-17:00", "09:00-17:00"}, 200 | } 201 | 202 | for _, c := range cases { 203 | t.Run(c.name, func(t *testing.T) { 204 | timeRange, err := parseTimeRange(c.input) 205 | if err != nil { 206 | t.Errorf("unexpected error: %v", err) 207 | return 208 | } 209 | 210 | if got := timeRange.String(); got != c.want { 211 | t.Errorf("got %q, want %q", got, c.want) 212 | } 213 | 214 | if c.input == "23:00-02:00" { 215 | if !timeRange.overnight { 216 | t.Error("overnight should be true for 23:00-02:00") 217 | } 218 | 219 | rule := Rule{ 220 | timeRange: timeRange, 221 | dow: Field{all: true}, 222 | dom: Field{all: true}, 223 | month: Field{all: true}, 224 | } 225 | 226 | baseTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) 227 | times := []struct { 228 | t time.Time 229 | name string 230 | want bool 231 | }{ 232 | {baseTime.Add(-time.Hour), "23:00", true}, 233 | {baseTime, "00:00", true}, 234 | {baseTime.Add(time.Hour), "01:00", true}, 235 | {baseTime.Add(2 * time.Hour), "02:00", true}, 236 | {baseTime.Add(3 * time.Hour), "03:00", false}, 237 | {baseTime.Add(-2 * time.Hour), "22:00", false}, 238 | } 239 | 240 | for _, tc := range times { 241 | if got := rule.matches(tc.t); got != tc.want { 242 | t.Errorf("%s: got %v, want %v", tc.name, got, tc.want) 243 | } 244 | } 245 | } 246 | }) 247 | } 248 | } 249 | 250 | func TestMatches(t *testing.T) { 251 | cases := []struct { 252 | name string 253 | rule string 254 | times []time.Time 255 | wantMatch []bool 256 | }{ 257 | { 258 | name: "simple range", 259 | rule: "09:00-17:00 * * *", 260 | times: []time.Time{ 261 | time.Date(2024, 1, 1, 8, 59, 59, 0, time.UTC), // before range 262 | time.Date(2024, 1, 1, 9, 0, 0, 0, time.UTC), // start of range 263 | time.Date(2024, 1, 1, 13, 0, 0, 0, time.UTC), // middle 264 | time.Date(2024, 1, 1, 17, 0, 0, 0, time.UTC), // end of range 265 | time.Date(2024, 1, 1, 17, 0, 1, 0, time.UTC), // after range 266 | }, 267 | wantMatch: []bool{false, true, true, true, false}, 268 | }, 269 | { 270 | name: "overnight range", 271 | rule: "23:00-02:00 * * *", 272 | times: []time.Time{ 273 | time.Date(2024, 1, 1, 22, 59, 59, 0, time.UTC), // just before 274 | time.Date(2024, 1, 1, 23, 0, 0, 0, time.UTC), // start 275 | time.Date(2024, 1, 1, 23, 59, 59, 0, time.UTC), // before midnight 276 | time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), // midnight 277 | time.Date(2024, 1, 2, 1, 30, 0, 0, time.UTC), // middle 278 | time.Date(2024, 1, 2, 2, 0, 0, 0, time.UTC), // end 279 | time.Date(2024, 1, 2, 2, 0, 1, 0, time.UTC), // just after 280 | }, 281 | wantMatch: []bool{false, true, true, true, true, true, false}, 282 | }, 283 | { 284 | name: "specific days", 285 | rule: "10:00-12:00 1,3,5 * *", // Mon,Wed,Fri 286 | times: []time.Time{ 287 | time.Date(2024, 1, 1, 11, 0, 0, 0, time.UTC), // Monday 288 | time.Date(2024, 1, 2, 11, 0, 0, 0, time.UTC), // Tuesday 289 | time.Date(2024, 1, 3, 11, 0, 0, 0, time.UTC), // Wednesday 290 | }, 291 | wantMatch: []bool{true, false, true}, 292 | }, 293 | { 294 | name: "specific months", 295 | rule: "* * * 3,6,9,12", // Mar,Jun,Sep,Dec 296 | times: []time.Time{ 297 | time.Date(2024, 2, 1, 11, 0, 0, 0, time.UTC), // February 298 | time.Date(2024, 3, 1, 11, 0, 0, 0, time.UTC), // March 299 | time.Date(2024, 4, 1, 11, 0, 0, 0, time.UTC), // April 300 | }, 301 | wantMatch: []bool{false, true, false}, 302 | }, 303 | { 304 | name: "complex range", 305 | rule: "09:00-17:00 1-5 1-7 3,6,9,12", // weekdays, first week, specific months 306 | times: []time.Time{ 307 | time.Date(2024, 3, 1, 13, 0, 0, 0, time.UTC), // Fri, Mar 1 308 | time.Date(2024, 3, 2, 13, 0, 0, 0, time.UTC), // Sat, Mar 2 309 | time.Date(2024, 3, 4, 13, 0, 0, 0, time.UTC), // Mon, Mar 4 310 | time.Date(2024, 3, 8, 13, 0, 0, 0, time.UTC), // Fri, Mar 8 311 | time.Date(2024, 4, 1, 13, 0, 0, 0, time.UTC), // Mon, Apr 1 312 | }, 313 | wantMatch: []bool{true, false, true, false, false}, 314 | }, 315 | { 316 | name: "all wildcards", 317 | rule: "* * * *", 318 | times: []time.Time{ 319 | time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), 320 | time.Date(2024, 6, 15, 12, 30, 0, 0, time.UTC), 321 | time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC), 322 | }, 323 | wantMatch: []bool{true, true, true}, 324 | }, 325 | } 326 | 327 | for _, c := range cases { 328 | t.Run(c.name, func(t *testing.T) { 329 | rule, err := parseRule(c.rule) 330 | if err != nil { 331 | t.Fatalf("failed to parse rule %q: %v", c.rule, err) 332 | } 333 | 334 | if len(c.times) != len(c.wantMatch) { 335 | t.Fatal("times and wantMatch slices must have equal length") 336 | } 337 | 338 | for i, tm := range c.times { 339 | got := rule.matches(tm) 340 | if got != c.wantMatch[i] { 341 | t.Errorf("time %v: got %v, want %v", tm.Format("2006-01-02 15:04:05"), got, c.wantMatch[i]) 342 | } 343 | } 344 | }) 345 | } 346 | } 347 | --------------------------------------------------------------------------------