Merge pull request #26 from bnema/feat/human-friendly-duration-parser
feat: add human-friendly duration parser for token expiry
7 files · +316 -19
M docs/cli/commands.md +23 -5
| @@ -386,23 +386,41 @@ gordon auth token generate --subject <name> [options] | |||
| 386 | 386 | |--------|---------|-------------| | |
| 387 | 387 | | `--subject` | (required) | Username/subject for the token | | |
| 388 | 388 | | `--scopes` | `push,pull` | Comma-separated scopes | | |
| 389 | - | | `--expiry` | `720h` | Duration until expiry (0 = never) | | |
| 389 | + | | `--expiry` | `30d` | Duration until expiry (0 = never) | | |
| 390 | 390 | | `-c, --config` | Auto | Path to config file | | |
| 391 | 391 | ||
| 392 | + | **Duration format:** | |
| 393 | + | ||
| 394 | + | Supports human-friendly duration units: | |
| 395 | + | ||
| 396 | + | | Unit | Description | Example | | |
| 397 | + | |------|-------------|---------| | |
| 398 | + | | `d` | Days (24 hours) | `30d` | | |
| 399 | + | | `w` | Weeks (7 days) | `2w` | | |
| 400 | + | | `M` | Months (30 days) | `6M` | | |
| 401 | + | | `y` | Years (365 days) | `1y` | | |
| 402 | + | ||
| 403 | + | Compound durations are also supported: `1y6M`, `2w3d`, `1d12h` | |
| 404 | + | ||
| 405 | + | Standard Go durations also work: `24h`, `30m`, `1h30m` | |
| 406 | + | ||
| 392 | 407 | **Examples:** | |
| 393 | 408 | ||
| 394 | 409 | ```bash | |
| 395 | 410 | # CI token that never expires | |
| 396 | 411 | gordon auth token generate --subject github-actions --expiry 0 | |
| 397 | 412 | ||
| 398 | - | # Read-only token | |
| 399 | - | gordon auth token generate --subject reader --scopes pull --expiry 720h | |
| 413 | + | # Read-only token for 30 days | |
| 414 | + | gordon auth token generate --subject reader --scopes pull --expiry 30d | |
| 400 | 415 | ||
| 401 | - | # Push-only token for build system | |
| 402 | - | gordon auth token generate --subject builder --scopes push --expiry 0 | |
| 416 | + | # Push-only token for 1 year | |
| 417 | + | gordon auth token generate --subject builder --scopes push --expiry 1y | |
| 403 | 418 | ||
| 404 | 419 | # Temporary token (24 hours) | |
| 405 | 420 | gordon auth token generate --subject temp --expiry 24h | |
| 421 | + | ||
| 422 | + | # Token for 6 months | |
| 423 | + | gordon auth token generate --subject deploy --expiry 6M | |
| 406 | 424 | ``` | |
| 407 | 425 | ||
| 408 | 426 | **Output:** | |
| @@ -386,23 +386,41 @@ gordon auth token generate --subject <name> [options] | |||||
| 386 | |--------|---------|-------------| | 386 | |--------|---------|-------------| | ||
| 387 | | `--subject` | (required) | Username/subject for the token | | 387 | | `--subject` | (required) | Username/subject for the token | | ||
| 388 | | `--scopes` | `push,pull` | Comma-separated scopes | | 388 | | `--scopes` | `push,pull` | Comma-separated scopes | | ||
| 389 | - | | `--expiry` | `720h` | Duration until expiry (0 = never) | | 389 | + | | `--expiry` | `30d` | Duration until expiry (0 = never) | |
| 390 | | `-c, --config` | Auto | Path to config file | | 390 | | `-c, --config` | Auto | Path to config file | | ||
| 391 | 391 | ||||
| 392 | + | **Duration format:** | |||
| 393 | + | ||||
| 394 | + | Supports human-friendly duration units: | |||
| 395 | + | ||||
| 396 | + | | Unit | Description | Example | | |||
| 397 | + | |------|-------------|---------| | |||
| 398 | + | | `d` | Days (24 hours) | `30d` | | |||
| 399 | + | | `w` | Weeks (7 days) | `2w` | | |||
| 400 | + | | `M` | Months (30 days) | `6M` | | |||
| 401 | + | | `y` | Years (365 days) | `1y` | | |||
| 402 | + | ||||
| 403 | + | Compound durations are also supported: `1y6M`, `2w3d`, `1d12h` | |||
| 404 | + | ||||
| 405 | + | Standard Go durations also work: `24h`, `30m`, `1h30m` | |||
| 406 | + | ||||
| 392 | **Examples:** | 407 | **Examples:** | ||
| 393 | 408 | ||||
| 394 | ```bash | 409 | ```bash | ||
| 395 | # CI token that never expires | 410 | # CI token that never expires | ||
| 396 | gordon auth token generate --subject github-actions --expiry 0 | 411 | gordon auth token generate --subject github-actions --expiry 0 | ||
| 397 | 412 | ||||
| 398 | - | # Read-only token | 413 | + | # Read-only token for 30 days |
| 399 | - | gordon auth token generate --subject reader --scopes pull --expiry 720h | 414 | + | gordon auth token generate --subject reader --scopes pull --expiry 30d |
| 400 | 415 | ||||
| 401 | - | # Push-only token for build system | 416 | + | # Push-only token for 1 year |
| 402 | - | gordon auth token generate --subject builder --scopes push --expiry 0 | 417 | + | gordon auth token generate --subject builder --scopes push --expiry 1y |
| 403 | 418 | ||||
| 404 | # Temporary token (24 hours) | 419 | # Temporary token (24 hours) | ||
| 405 | gordon auth token generate --subject temp --expiry 24h | 420 | gordon auth token generate --subject temp --expiry 24h | ||
| 421 | + | ||||
| 422 | + | # Token for 6 months | |||
| 423 | + | gordon auth token generate --subject deploy --expiry 6M | |||
| 406 | ``` | 424 | ``` | ||
| 407 | 425 | ||||
| 408 | **Output:** | 426 | **Output:** | ||
M docs/config/index.md +2 -2
| @@ -46,7 +46,7 @@ type = "token" # "password" or "token" | |||
| 46 | 46 | # password_hash = "gordon/registry/password_hash" | |
| 47 | 47 | # Token auth: | |
| 48 | 48 | token_secret = "gordon/registry/token_secret" | |
| 49 | - | token_expiry = "720h" # Duration or 0 for never | |
| 49 | + | token_expiry = "30d" # Duration (1y, 30d, 2w) or 0 for never | |
| 50 | 50 | ||
| 51 | 51 | # Deploy behavior | |
| 52 | 52 | [deploy] | |
| @@ -134,7 +134,7 @@ enabled = false # Auto-create routes from image names | |||
| 134 | 134 | | `secrets.backend` | `"unsafe"` | | |
| 135 | 135 | | `registry_auth.enabled` | `false` | | |
| 136 | 136 | | `registry_auth.type` | `"password"` | | |
| 137 | - | | `registry_auth.token_expiry` | `"720h"` | | |
| 137 | + | | `registry_auth.token_expiry` | `"30d"` | | |
| 138 | 138 | | `deploy.pull_policy` | `"if-tag-changed"` | | |
| 139 | 139 | | `logging.level` | `"info"` | | |
| 140 | 140 | | `logging.format` | `"console"` | | |
| @@ -46,7 +46,7 @@ type = "token" # "password" or "token" | |||||
| 46 | # password_hash = "gordon/registry/password_hash" | 46 | # password_hash = "gordon/registry/password_hash" | ||
| 47 | # Token auth: | 47 | # Token auth: | ||
| 48 | token_secret = "gordon/registry/token_secret" | 48 | token_secret = "gordon/registry/token_secret" | ||
| 49 | - | token_expiry = "720h" # Duration or 0 for never | 49 | + | token_expiry = "30d" # Duration (1y, 30d, 2w) or 0 for never |
| 50 | 50 | ||||
| 51 | # Deploy behavior | 51 | # Deploy behavior | ||
| 52 | [deploy] | 52 | [deploy] | ||
| @@ -134,7 +134,7 @@ enabled = false # Auto-create routes from image names | |||||
| 134 | | `secrets.backend` | `"unsafe"` | | 134 | | `secrets.backend` | `"unsafe"` | | ||
| 135 | | `registry_auth.enabled` | `false` | | 135 | | `registry_auth.enabled` | `false` | | ||
| 136 | | `registry_auth.type` | `"password"` | | 136 | | `registry_auth.type` | `"password"` | | ||
| 137 | - | | `registry_auth.token_expiry` | `"720h"` | | 137 | + | | `registry_auth.token_expiry` | `"30d"` | |
| 138 | | `deploy.pull_policy` | `"if-tag-changed"` | | 138 | | `deploy.pull_policy` | `"if-tag-changed"` | | ||
| 139 | | `logging.level` | `"info"` | | 139 | | `logging.level` | `"info"` | | ||
| 140 | | `logging.format` | `"console"` | | 140 | | `logging.format` | `"console"` | | ||
M docs/config/registry-auth.md +16 -7
| @@ -27,7 +27,7 @@ token_expiry = "720h" # Duration or 0 for never | |||
| 27 | 27 | | `username` | string | - | Username for password auth | | |
| 28 | 28 | | `password_hash` | string | - | Path to bcrypt hash in secrets backend | | |
| 29 | 29 | | `token_secret` | string | - | Path to JWT signing secret in secrets backend | | |
| 30 | - | | `token_expiry` | string | `"720h"` | Token validity duration (0 = never expires) | | |
| 30 | + | | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires). Supports: d (days), w (weeks), M (months), y (years) | | |
| 31 | 31 | ||
| 32 | 32 | ## Authentication Types | |
| 33 | 33 | ||
| @@ -43,7 +43,7 @@ backend = "pass" | |||
| 43 | 43 | enabled = true | |
| 44 | 44 | type = "token" | |
| 45 | 45 | token_secret = "gordon/registry/token_secret" | |
| 46 | - | token_expiry = "720h" # 30 days, or "0" for never | |
| 46 | + | token_expiry = "30d" # 30 days, or "0" for never. Also: 1y, 2w, 6M | |
| 47 | 47 | ``` | |
| 48 | 48 | ||
| 49 | 49 | **Setup:** | |
| @@ -113,7 +113,7 @@ Tokens can have different permission levels: | |||
| 113 | 113 | ||
| 114 | 114 | ```bash | |
| 115 | 115 | # Pull-only token (for read-only access) | |
| 116 | - | gordon auth token generate --subject reader --scopes pull --expiry 720h | |
| 116 | + | gordon auth token generate --subject reader --scopes pull --expiry 30d | |
| 117 | 117 | ||
| 118 | 118 | # Push-only token (for CI builds) | |
| 119 | 119 | gordon auth token generate --subject builder --scopes push --expiry 0 | |
| @@ -121,17 +121,26 @@ gordon auth token generate --subject builder --scopes push --expiry 0 | |||
| 121 | 121 | ||
| 122 | 122 | ## Token Expiry | |
| 123 | 123 | ||
| 124 | - | Control how long tokens remain valid: | |
| 124 | + | Control how long tokens remain valid. Supports human-friendly units: | |
| 125 | + | - `d` - days (24 hours) | |
| 126 | + | - `w` - weeks (7 days) | |
| 127 | + | - `M` - months (30 days) | |
| 128 | + | - `y` - years (365 days) | |
| 129 | + | ||
| 130 | + | Compound durations work too: `1y6M`, `2w3d` | |
| 125 | 131 | ||
| 126 | 132 | ```bash | |
| 127 | 133 | # Never expires (for CI/CD) | |
| 128 | 134 | gordon auth token generate --subject ci --expiry 0 | |
| 129 | 135 | ||
| 136 | + | # Expires in 1 year | |
| 137 | + | gordon auth token generate --subject deploy --expiry 1y | |
| 138 | + | ||
| 130 | 139 | # Expires in 30 days | |
| 131 | - | gordon auth token generate --subject temp --expiry 720h | |
| 140 | + | gordon auth token generate --subject temp --expiry 30d | |
| 132 | 141 | ||
| 133 | - | # Expires in 24 hours | |
| 134 | - | gordon auth token generate --subject short --expiry 24h | |
| 142 | + | # Expires in 2 weeks | |
| 143 | + | gordon auth token generate --subject short --expiry 2w | |
| 135 | 144 | ``` | |
| 136 | 145 | ||
| 137 | 146 | ## Instance-Specific Tokens | |
| @@ -27,7 +27,7 @@ token_expiry = "720h" # Duration or 0 for never | |||||
| 27 | | `username` | string | - | Username for password auth | | 27 | | `username` | string | - | Username for password auth | | ||
| 28 | | `password_hash` | string | - | Path to bcrypt hash in secrets backend | | 28 | | `password_hash` | string | - | Path to bcrypt hash in secrets backend | | ||
| 29 | | `token_secret` | string | - | Path to JWT signing secret in secrets backend | | 29 | | `token_secret` | string | - | Path to JWT signing secret in secrets backend | | ||
| 30 | - | | `token_expiry` | string | `"720h"` | Token validity duration (0 = never expires) | | 30 | + | | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires). Supports: d (days), w (weeks), M (months), y (years) | |
| 31 | 31 | ||||
| 32 | ## Authentication Types | 32 | ## Authentication Types | ||
| 33 | 33 | ||||
| @@ -43,7 +43,7 @@ backend = "pass" | |||||
| 43 | enabled = true | 43 | enabled = true | ||
| 44 | type = "token" | 44 | type = "token" | ||
| 45 | token_secret = "gordon/registry/token_secret" | 45 | token_secret = "gordon/registry/token_secret" | ||
| 46 | - | token_expiry = "720h" # 30 days, or "0" for never | 46 | + | token_expiry = "30d" # 30 days, or "0" for never. Also: 1y, 2w, 6M |
| 47 | ``` | 47 | ``` | ||
| 48 | 48 | ||||
| 49 | **Setup:** | 49 | **Setup:** | ||
| @@ -113,7 +113,7 @@ Tokens can have different permission levels: | |||||
| 113 | 113 | ||||
| 114 | ```bash | 114 | ```bash | ||
| 115 | # Pull-only token (for read-only access) | 115 | # Pull-only token (for read-only access) | ||
| 116 | - | gordon auth token generate --subject reader --scopes pull --expiry 720h | 116 | + | gordon auth token generate --subject reader --scopes pull --expiry 30d |
| 117 | 117 | ||||
| 118 | # Push-only token (for CI builds) | 118 | # Push-only token (for CI builds) | ||
| 119 | gordon auth token generate --subject builder --scopes push --expiry 0 | 119 | gordon auth token generate --subject builder --scopes push --expiry 0 | ||
| @@ -121,17 +121,26 @@ gordon auth token generate --subject builder --scopes push --expiry 0 | |||||
| 121 | 121 | ||||
| 122 | ## Token Expiry | 122 | ## Token Expiry | ||
| 123 | 123 | ||||
| 124 | - | Control how long tokens remain valid: | 124 | + | Control how long tokens remain valid. Supports human-friendly units: |
| 125 | + | - `d` - days (24 hours) | |||
| 126 | + | - `w` - weeks (7 days) | |||
| 127 | + | - `M` - months (30 days) | |||
| 128 | + | - `y` - years (365 days) | |||
| 129 | + | ||||
| 130 | + | Compound durations work too: `1y6M`, `2w3d` | |||
| 125 | 131 | ||||
| 126 | ```bash | 132 | ```bash | ||
| 127 | # Never expires (for CI/CD) | 133 | # Never expires (for CI/CD) | ||
| 128 | gordon auth token generate --subject ci --expiry 0 | 134 | gordon auth token generate --subject ci --expiry 0 | ||
| 129 | 135 | ||||
| 136 | + | # Expires in 1 year | |||
| 137 | + | gordon auth token generate --subject deploy --expiry 1y | |||
| 138 | + | ||||
| 130 | # Expires in 30 days | 139 | # Expires in 30 days | ||
| 131 | - | gordon auth token generate --subject temp --expiry 720h | 140 | + | gordon auth token generate --subject temp --expiry 30d |
| 132 | 141 | ||||
| 133 | - | # Expires in 24 hours | 142 | + | # Expires in 2 weeks |
| 134 | - | gordon auth token generate --subject short --expiry 24h | 143 | + | gordon auth token generate --subject short --expiry 2w |
| 135 | ``` | 144 | ``` | ||
| 136 | 145 | ||||
| 137 | ## Instance-Specific Tokens | 146 | ## Instance-Specific Tokens | ||
M internal/adapters/in/cli/auth.go +12 -4
| @@ -18,6 +18,7 @@ import ( | |||
| 18 | 18 | "gordon/internal/app" | |
| 19 | 19 | "gordon/internal/domain" | |
| 20 | 20 | "gordon/internal/usecase/auth" | |
| 21 | + | "gordon/pkg/duration" | |
| 21 | 22 | ) | |
| 22 | 23 | ||
| 23 | 24 | // newAuthCmd creates the auth command group. | |
| @@ -67,7 +68,14 @@ func newTokenGenerateCmd() *cobra.Command { | |||
| 67 | 68 | The token will be stored in the configured secrets backend and can be used | |
| 68 | 69 | for docker login as: docker login -u <subject> -p <token> | |
| 69 | 70 | ||
| 70 | - | Use --expiry=0 to generate a token that never expires (useful for CI).`, | |
| 71 | + | Expiry supports human-friendly durations: | |
| 72 | + | - d (days): 1d = 24 hours | |
| 73 | + | - w (weeks): 1w = 7 days | |
| 74 | + | - M (months): 1M = 30 days | |
| 75 | + | - y (years): 1y = 365 days | |
| 76 | + | ||
| 77 | + | Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d | |
| 78 | + | Use --expiry=0 for a token that never expires (useful for CI).`, | |
| 71 | 79 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 72 | 80 | return runTokenGenerate(subject, scopes, expiry, configPath) | |
| 73 | 81 | }, | |
| @@ -75,7 +83,7 @@ Use --expiry=0 to generate a token that never expires (useful for CI).`, | |||
| 75 | 83 | ||
| 76 | 84 | cmd.Flags().StringVar(&subject, "subject", "", "Subject/username for the token (required)") | |
| 77 | 85 | cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes") | |
| 78 | - | cmd.Flags().StringVar(&expiry, "expiry", "720h", "Token expiry duration (e.g., 720h, 24h, 0 for never)") | |
| 86 | + | cmd.Flags().StringVar(&expiry, "expiry", "30d", "Token expiry duration (e.g., 1y, 30d, 2w, 24h, 0 for never)") | |
| 79 | 87 | cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") | |
| 80 | 88 | ||
| 81 | 89 | _ = cmd.MarkFlagRequired("subject") | |
| @@ -196,10 +204,10 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error { | |||
| 196 | 204 | return err | |
| 197 | 205 | } | |
| 198 | 206 | ||
| 199 | - | // Parse expiry | |
| 207 | + | // Parse expiry (supports human-friendly units: d, w, M, y) | |
| 200 | 208 | var expiry time.Duration | |
| 201 | 209 | if expiryStr != "0" { | |
| 202 | - | expiry, err = time.ParseDuration(expiryStr) | |
| 210 | + | expiry, err = duration.Parse(expiryStr) | |
| 203 | 211 | if err != nil { | |
| 204 | 212 | return fmt.Errorf("invalid expiry duration: %w", err) | |
| 205 | 213 | } | |
| @@ -18,6 +18,7 @@ import ( | |||||
| 18 | "gordon/internal/app" | 18 | "gordon/internal/app" | ||
| 19 | "gordon/internal/domain" | 19 | "gordon/internal/domain" | ||
| 20 | "gordon/internal/usecase/auth" | 20 | "gordon/internal/usecase/auth" | ||
| 21 | + | "gordon/pkg/duration" | |||
| 21 | ) | 22 | ) | ||
| 22 | 23 | ||||
| 23 | // newAuthCmd creates the auth command group. | 24 | // newAuthCmd creates the auth command group. | ||
| @@ -67,7 +68,14 @@ func newTokenGenerateCmd() *cobra.Command { | |||||
| 67 | The token will be stored in the configured secrets backend and can be used | 68 | The token will be stored in the configured secrets backend and can be used | ||
| 68 | for docker login as: docker login -u <subject> -p <token> | 69 | for docker login as: docker login -u <subject> -p <token> | ||
| 69 | 70 | ||||
| 70 | - | Use --expiry=0 to generate a token that never expires (useful for CI).`, | 71 | + | Expiry supports human-friendly durations: |
| 72 | + | - d (days): 1d = 24 hours | |||
| 73 | + | - w (weeks): 1w = 7 days | |||
| 74 | + | - M (months): 1M = 30 days | |||
| 75 | + | - y (years): 1y = 365 days | |||
| 76 | + | ||||
| 77 | + | Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d | |||
| 78 | + | Use --expiry=0 for a token that never expires (useful for CI).`, | |||
| 71 | RunE: func(cmd *cobra.Command, args []string) error { | 79 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 72 | return runTokenGenerate(subject, scopes, expiry, configPath) | 80 | return runTokenGenerate(subject, scopes, expiry, configPath) | ||
| 73 | }, | 81 | }, | ||
| @@ -75,7 +83,7 @@ Use --expiry=0 to generate a token that never expires (useful for CI).`, | |||||
| 75 | 83 | ||||
| 76 | cmd.Flags().StringVar(&subject, "subject", "", "Subject/username for the token (required)") | 84 | cmd.Flags().StringVar(&subject, "subject", "", "Subject/username for the token (required)") | ||
| 77 | cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes") | 85 | cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes") | ||
| 78 | - | cmd.Flags().StringVar(&expiry, "expiry", "720h", "Token expiry duration (e.g., 720h, 24h, 0 for never)") | 86 | + | cmd.Flags().StringVar(&expiry, "expiry", "30d", "Token expiry duration (e.g., 1y, 30d, 2w, 24h, 0 for never)") |
| 79 | cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") | 87 | cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") | ||
| 80 | 88 | ||||
| 81 | _ = cmd.MarkFlagRequired("subject") | 89 | _ = cmd.MarkFlagRequired("subject") | ||
| @@ -196,10 +204,10 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error { | |||||
| 196 | return err | 204 | return err | ||
| 197 | } | 205 | } | ||
| 198 | 206 | ||||
| 199 | - | // Parse expiry | 207 | + | // Parse expiry (supports human-friendly units: d, w, M, y) |
| 200 | var expiry time.Duration | 208 | var expiry time.Duration | ||
| 201 | if expiryStr != "0" { | 209 | if expiryStr != "0" { | ||
| 202 | - | expiry, err = time.ParseDuration(expiryStr) | 210 | + | expiry, err = duration.Parse(expiryStr) |
| 203 | if err != nil { | 211 | if err != nil { | ||
| 204 | return fmt.Errorf("invalid expiry duration: %w", err) | 212 | return fmt.Errorf("invalid expiry duration: %w", err) | ||
| 205 | } | 213 | } | ||
M internal/app/run.go +4 -1
| @@ -52,6 +52,9 @@ import ( | |||
| 52 | 52 | "gordon/internal/usecase/proxy" | |
| 53 | 53 | registrySvc "gordon/internal/usecase/registry" | |
| 54 | 54 | secretsSvc "gordon/internal/usecase/secrets" | |
| 55 | + | ||
| 56 | + | // Pkg | |
| 57 | + | "gordon/pkg/duration" | |
| 55 | 58 | ) | |
| 56 | 59 | ||
| 57 | 60 | // Config holds the application configuration. | |
| @@ -640,7 +643,7 @@ func parseTokenExpiry(expiry string) (time.Duration, error) { | |||
| 640 | 643 | return 0, nil | |
| 641 | 644 | } | |
| 642 | 645 | ||
| 643 | - | parsed, err := time.ParseDuration(expiry) | |
| 646 | + | parsed, err := duration.Parse(expiry) | |
| 644 | 647 | if err != nil { | |
| 645 | 648 | return 0, fmt.Errorf("invalid token_expiry: %w", err) | |
| 646 | 649 | } | |
| @@ -52,6 +52,9 @@ import ( | |||||
| 52 | "gordon/internal/usecase/proxy" | 52 | "gordon/internal/usecase/proxy" | ||
| 53 | registrySvc "gordon/internal/usecase/registry" | 53 | registrySvc "gordon/internal/usecase/registry" | ||
| 54 | secretsSvc "gordon/internal/usecase/secrets" | 54 | secretsSvc "gordon/internal/usecase/secrets" | ||
| 55 | + | ||||
| 56 | + | // Pkg | |||
| 57 | + | "gordon/pkg/duration" | |||
| 55 | ) | 58 | ) | ||
| 56 | 59 | ||||
| 57 | // Config holds the application configuration. | 60 | // Config holds the application configuration. | ||
| @@ -640,7 +643,7 @@ func parseTokenExpiry(expiry string) (time.Duration, error) { | |||||
| 640 | return 0, nil | 643 | return 0, nil | ||
| 641 | } | 644 | } | ||
| 642 | 645 | ||||
| 643 | - | parsed, err := time.ParseDuration(expiry) | 646 | + | parsed, err := duration.Parse(expiry) |
| 644 | if err != nil { | 647 | if err != nil { | ||
| 645 | return 0, fmt.Errorf("invalid token_expiry: %w", err) | 648 | return 0, fmt.Errorf("invalid token_expiry: %w", err) | ||
| 646 | } | 649 | } | ||
A pkg/duration/parse.go +127 -0
| @@ -0,0 +1,127 @@ | |||
| 1 | + | // Package duration provides human-friendly duration parsing. | |
| 2 | + | package duration | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "fmt" | |
| 6 | + | "regexp" | |
| 7 | + | "strconv" | |
| 8 | + | "strings" | |
| 9 | + | "time" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // Common duration constants for human-friendly units. | |
| 13 | + | const ( | |
| 14 | + | Day = 24 * time.Hour | |
| 15 | + | Week = 7 * Day | |
| 16 | + | Month = 30 * Day // Approximate | |
| 17 | + | Year = 365 * Day // Approximate | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // unitMultipliers maps human-friendly unit suffixes to their duration values. | |
| 21 | + | var unitMultipliers = map[string]time.Duration{ | |
| 22 | + | "d": Day, | |
| 23 | + | "w": Week, | |
| 24 | + | "M": Month, // Capital M for months to distinguish from minutes | |
| 25 | + | "y": Year, | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // durationPattern matches duration components like "1y", "6M", "2w", "3d". | |
| 29 | + | var durationPattern = regexp.MustCompile(`(\d+)([ywMd])`) | |
| 30 | + | ||
| 31 | + | // Parse extends time.ParseDuration to support human-friendly units: | |
| 32 | + | // - d (days) = 24h | |
| 33 | + | // - w (weeks) = 7d | |
| 34 | + | // - M (months) = 30d (approximate) | |
| 35 | + | // - y (years) = 365d (approximate) | |
| 36 | + | // | |
| 37 | + | // Standard Go duration units (ns, us, ms, s, m, h) are also supported. | |
| 38 | + | // Compound durations like "1y6M" or "2w3d" or "1d12h" work as expected. | |
| 39 | + | // | |
| 40 | + | // Special case: "0" returns 0 duration (useful for never-expiring tokens). | |
| 41 | + | // | |
| 42 | + | // Examples: | |
| 43 | + | // | |
| 44 | + | // Parse("1d") // 24 hours | |
| 45 | + | // Parse("2w") // 14 days | |
| 46 | + | // Parse("1M") // 30 days | |
| 47 | + | // Parse("1y") // 365 days | |
| 48 | + | // Parse("1y6M") // 1 year and 6 months | |
| 49 | + | // Parse("2w3d") // 2 weeks and 3 days | |
| 50 | + | // Parse("1d12h") // 1 day and 12 hours | |
| 51 | + | // Parse("24h") // standard Go duration | |
| 52 | + | // Parse("0") // zero duration | |
| 53 | + | func Parse(s string) (time.Duration, error) { | |
| 54 | + | s = strings.TrimSpace(s) | |
| 55 | + | ||
| 56 | + | if s == "" { | |
| 57 | + | return 0, fmt.Errorf("empty duration string") | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Special case: "0" means no expiry | |
| 61 | + | if s == "0" { | |
| 62 | + | return 0, nil | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // Check if the string contains any human-friendly units | |
| 66 | + | if !containsHumanUnits(s) { | |
| 67 | + | // Fall back to standard Go parsing | |
| 68 | + | d, err := time.ParseDuration(s) | |
| 69 | + | if err != nil { | |
| 70 | + | return 0, fmt.Errorf("invalid duration %q: %w (supported units: ns, us, ms, s, m, h, d, w, M, y)", s, err) | |
| 71 | + | } | |
| 72 | + | return d, nil | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // Parse human-friendly units | |
| 76 | + | return parseHumanDuration(s) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // containsHumanUnits checks if the string contains any human-friendly unit suffixes. | |
| 80 | + | func containsHumanUnits(s string) bool { | |
| 81 | + | for unit := range unitMultipliers { | |
| 82 | + | if strings.Contains(s, unit) { | |
| 83 | + | return true | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | return false | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // parseHumanDuration parses a duration string containing human-friendly units. | |
| 90 | + | func parseHumanDuration(s string) (time.Duration, error) { | |
| 91 | + | var total time.Duration | |
| 92 | + | remaining := s | |
| 93 | + | ||
| 94 | + | // First, extract all human-friendly units (y, M, w, d) | |
| 95 | + | matches := durationPattern.FindAllStringSubmatch(remaining, -1) | |
| 96 | + | if len(matches) > 0 { | |
| 97 | + | for _, match := range matches { | |
| 98 | + | value, err := strconv.ParseInt(match[1], 10, 64) | |
| 99 | + | if err != nil { | |
| 100 | + | return 0, fmt.Errorf("invalid duration value %q in %q", match[1], s) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | unit := match[2] | |
| 104 | + | multiplier, ok := unitMultipliers[unit] | |
| 105 | + | if !ok { | |
| 106 | + | return 0, fmt.Errorf("unknown duration unit %q in %q", unit, s) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | total += time.Duration(value) * multiplier | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // Remove the matched human-friendly parts | |
| 113 | + | remaining = durationPattern.ReplaceAllString(remaining, "") | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // If there's remaining content, try to parse it as standard Go duration | |
| 117 | + | remaining = strings.TrimSpace(remaining) | |
| 118 | + | if remaining != "" { | |
| 119 | + | d, err := time.ParseDuration(remaining) | |
| 120 | + | if err != nil { | |
| 121 | + | return 0, fmt.Errorf("invalid duration %q: %w (supported units: ns, us, ms, s, m, h, d, w, M, y)", s, err) | |
| 122 | + | } | |
| 123 | + | total += d | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | return total, nil | |
| 127 | + | } | |
| @@ -0,0 +1,127 @@ | |||||
| 1 | + | // Package duration provides human-friendly duration parsing. | |||
| 2 | + | package duration | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "fmt" | |||
| 6 | + | "regexp" | |||
| 7 | + | "strconv" | |||
| 8 | + | "strings" | |||
| 9 | + | "time" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // Common duration constants for human-friendly units. | |||
| 13 | + | const ( | |||
| 14 | + | Day = 24 * time.Hour | |||
| 15 | + | Week = 7 * Day | |||
| 16 | + | Month = 30 * Day // Approximate | |||
| 17 | + | Year = 365 * Day // Approximate | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // unitMultipliers maps human-friendly unit suffixes to their duration values. | |||
| 21 | + | var unitMultipliers = map[string]time.Duration{ | |||
| 22 | + | "d": Day, | |||
| 23 | + | "w": Week, | |||
| 24 | + | "M": Month, // Capital M for months to distinguish from minutes | |||
| 25 | + | "y": Year, | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // durationPattern matches duration components like "1y", "6M", "2w", "3d". | |||
| 29 | + | var durationPattern = regexp.MustCompile(`(\d+)([ywMd])`) | |||
| 30 | + | ||||
| 31 | + | // Parse extends time.ParseDuration to support human-friendly units: | |||
| 32 | + | // - d (days) = 24h | |||
| 33 | + | // - w (weeks) = 7d | |||
| 34 | + | // - M (months) = 30d (approximate) | |||
| 35 | + | // - y (years) = 365d (approximate) | |||
| 36 | + | // | |||
| 37 | + | // Standard Go duration units (ns, us, ms, s, m, h) are also supported. | |||
| 38 | + | // Compound durations like "1y6M" or "2w3d" or "1d12h" work as expected. | |||
| 39 | + | // | |||
| 40 | + | // Special case: "0" returns 0 duration (useful for never-expiring tokens). | |||
| 41 | + | // | |||
| 42 | + | // Examples: | |||
| 43 | + | // | |||
| 44 | + | // Parse("1d") // 24 hours | |||
| 45 | + | // Parse("2w") // 14 days | |||
| 46 | + | // Parse("1M") // 30 days | |||
| 47 | + | // Parse("1y") // 365 days | |||
| 48 | + | // Parse("1y6M") // 1 year and 6 months | |||
| 49 | + | // Parse("2w3d") // 2 weeks and 3 days | |||
| 50 | + | // Parse("1d12h") // 1 day and 12 hours | |||
| 51 | + | // Parse("24h") // standard Go duration | |||
| 52 | + | // Parse("0") // zero duration | |||
| 53 | + | func Parse(s string) (time.Duration, error) { | |||
| 54 | + | s = strings.TrimSpace(s) | |||
| 55 | + | ||||
| 56 | + | if s == "" { | |||
| 57 | + | return 0, fmt.Errorf("empty duration string") | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // Special case: "0" means no expiry | |||
| 61 | + | if s == "0" { | |||
| 62 | + | return 0, nil | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // Check if the string contains any human-friendly units | |||
| 66 | + | if !containsHumanUnits(s) { | |||
| 67 | + | // Fall back to standard Go parsing | |||
| 68 | + | d, err := time.ParseDuration(s) | |||
| 69 | + | if err != nil { | |||
| 70 | + | return 0, fmt.Errorf("invalid duration %q: %w (supported units: ns, us, ms, s, m, h, d, w, M, y)", s, err) | |||
| 71 | + | } | |||
| 72 | + | return d, nil | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // Parse human-friendly units | |||
| 76 | + | return parseHumanDuration(s) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // containsHumanUnits checks if the string contains any human-friendly unit suffixes. | |||
| 80 | + | func containsHumanUnits(s string) bool { | |||
| 81 | + | for unit := range unitMultipliers { | |||
| 82 | + | if strings.Contains(s, unit) { | |||
| 83 | + | return true | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | return false | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // parseHumanDuration parses a duration string containing human-friendly units. | |||
| 90 | + | func parseHumanDuration(s string) (time.Duration, error) { | |||
| 91 | + | var total time.Duration | |||
| 92 | + | remaining := s | |||
| 93 | + | ||||
| 94 | + | // First, extract all human-friendly units (y, M, w, d) | |||
| 95 | + | matches := durationPattern.FindAllStringSubmatch(remaining, -1) | |||
| 96 | + | if len(matches) > 0 { | |||
| 97 | + | for _, match := range matches { | |||
| 98 | + | value, err := strconv.ParseInt(match[1], 10, 64) | |||
| 99 | + | if err != nil { | |||
| 100 | + | return 0, fmt.Errorf("invalid duration value %q in %q", match[1], s) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | unit := match[2] | |||
| 104 | + | multiplier, ok := unitMultipliers[unit] | |||
| 105 | + | if !ok { | |||
| 106 | + | return 0, fmt.Errorf("unknown duration unit %q in %q", unit, s) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | total += time.Duration(value) * multiplier | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // Remove the matched human-friendly parts | |||
| 113 | + | remaining = durationPattern.ReplaceAllString(remaining, "") | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // If there's remaining content, try to parse it as standard Go duration | |||
| 117 | + | remaining = strings.TrimSpace(remaining) | |||
| 118 | + | if remaining != "" { | |||
| 119 | + | d, err := time.ParseDuration(remaining) | |||
| 120 | + | if err != nil { | |||
| 121 | + | return 0, fmt.Errorf("invalid duration %q: %w (supported units: ns, us, ms, s, m, h, d, w, M, y)", s, err) | |||
| 122 | + | } | |||
| 123 | + | total += d | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | return total, nil | |||
| 127 | + | } | |||
A pkg/duration/parse_test.go +132 -0
| @@ -0,0 +1,132 @@ | |||
| 1 | + | package duration | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestParse(t *testing.T) { | |
| 9 | + | tests := []struct { | |
| 10 | + | name string | |
| 11 | + | input string | |
| 12 | + | want time.Duration | |
| 13 | + | wantErr bool | |
| 14 | + | }{ | |
| 15 | + | // Single human-friendly units | |
| 16 | + | {name: "1 day", input: "1d", want: Day}, | |
| 17 | + | {name: "2 days", input: "2d", want: 2 * Day}, | |
| 18 | + | {name: "1 week", input: "1w", want: Week}, | |
| 19 | + | {name: "2 weeks", input: "2w", want: 2 * Week}, | |
| 20 | + | {name: "1 month", input: "1M", want: Month}, | |
| 21 | + | {name: "3 months", input: "3M", want: 3 * Month}, | |
| 22 | + | {name: "1 year", input: "1y", want: Year}, | |
| 23 | + | {name: "2 years", input: "2y", want: 2 * Year}, | |
| 24 | + | ||
| 25 | + | // Compound human-friendly units | |
| 26 | + | {name: "1 year 6 months", input: "1y6M", want: Year + 6*Month}, | |
| 27 | + | {name: "2 weeks 3 days", input: "2w3d", want: 2*Week + 3*Day}, | |
| 28 | + | {name: "1 year 2 months 1 week", input: "1y2M1w", want: Year + 2*Month + Week}, | |
| 29 | + | ||
| 30 | + | // Mixed with standard Go units | |
| 31 | + | {name: "1 day 12 hours", input: "1d12h", want: Day + 12*time.Hour}, | |
| 32 | + | {name: "2 weeks 6 hours", input: "2w6h", want: 2*Week + 6*time.Hour}, | |
| 33 | + | {name: "1 month 1 day 1 hour", input: "1M1d1h", want: Month + Day + time.Hour}, | |
| 34 | + | {name: "1 year 30 minutes", input: "1y30m", want: Year + 30*time.Minute}, | |
| 35 | + | ||
| 36 | + | // Standard Go duration units (fallback) | |
| 37 | + | {name: "24 hours", input: "24h", want: 24 * time.Hour}, | |
| 38 | + | {name: "30 minutes", input: "30m", want: 30 * time.Minute}, | |
| 39 | + | {name: "1 hour 30 minutes", input: "1h30m", want: time.Hour + 30*time.Minute}, | |
| 40 | + | {name: "1 second", input: "1s", want: time.Second}, | |
| 41 | + | {name: "500 milliseconds", input: "500ms", want: 500 * time.Millisecond}, | |
| 42 | + | {name: "1000 microseconds", input: "1000us", want: 1000 * time.Microsecond}, | |
| 43 | + | {name: "1000000 nanoseconds", input: "1000000ns", want: 1000000 * time.Nanosecond}, | |
| 44 | + | ||
| 45 | + | // Special cases | |
| 46 | + | {name: "zero duration", input: "0", want: 0}, | |
| 47 | + | {name: "zero with unit", input: "0d", want: 0}, | |
| 48 | + | {name: "zero hours", input: "0h", want: 0}, | |
| 49 | + | ||
| 50 | + | // Large values | |
| 51 | + | {name: "10 years", input: "10y", want: 10 * Year}, | |
| 52 | + | {name: "52 weeks", input: "52w", want: 52 * Week}, | |
| 53 | + | {name: "365 days", input: "365d", want: 365 * Day}, | |
| 54 | + | ||
| 55 | + | // Edge cases with whitespace (should be trimmed) | |
| 56 | + | {name: "whitespace around", input: " 1d ", want: Day}, | |
| 57 | + | ||
| 58 | + | // Error cases | |
| 59 | + | {name: "empty string", input: "", wantErr: true}, | |
| 60 | + | {name: "invalid format", input: "abc", wantErr: true}, | |
| 61 | + | {name: "invalid unit", input: "1x", wantErr: true}, | |
| 62 | + | {name: "missing value", input: "d", wantErr: true}, | |
| 63 | + | {name: "negative not supported by Go", input: "-1d", wantErr: true}, | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | for _, tt := range tests { | |
| 67 | + | t.Run(tt.name, func(t *testing.T) { | |
| 68 | + | got, err := Parse(tt.input) | |
| 69 | + | if (err != nil) != tt.wantErr { | |
| 70 | + | t.Errorf("Parse(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) | |
| 71 | + | return | |
| 72 | + | } | |
| 73 | + | if !tt.wantErr && got != tt.want { | |
| 74 | + | t.Errorf("Parse(%q) = %v, want %v", tt.input, got, tt.want) | |
| 75 | + | } | |
| 76 | + | }) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestParseConstants(t *testing.T) { | |
| 81 | + | // Verify our constants are correct | |
| 82 | + | if Day != 24*time.Hour { | |
| 83 | + | t.Errorf("Day = %v, want %v", Day, 24*time.Hour) | |
| 84 | + | } | |
| 85 | + | if Week != 7*24*time.Hour { | |
| 86 | + | t.Errorf("Week = %v, want %v", Week, 7*24*time.Hour) | |
| 87 | + | } | |
| 88 | + | if Month != 30*24*time.Hour { | |
| 89 | + | t.Errorf("Month = %v, want %v", Month, 30*24*time.Hour) | |
| 90 | + | } | |
| 91 | + | if Year != 365*24*time.Hour { | |
| 92 | + | t.Errorf("Year = %v, want %v", Year, 365*24*time.Hour) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestParseEquivalences(t *testing.T) { | |
| 97 | + | // Test that our human-friendly units produce expected equivalences | |
| 98 | + | tests := []struct { | |
| 99 | + | name string | |
| 100 | + | human string | |
| 101 | + | goStd string | |
| 102 | + | hours int | |
| 103 | + | }{ | |
| 104 | + | {name: "1 day = 24 hours", human: "1d", goStd: "24h", hours: 24}, | |
| 105 | + | {name: "1 week = 168 hours", human: "1w", goStd: "168h", hours: 168}, | |
| 106 | + | {name: "1 month = 720 hours", human: "1M", goStd: "720h", hours: 720}, | |
| 107 | + | {name: "1 year = 8760 hours", human: "1y", goStd: "8760h", hours: 8760}, | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | for _, tt := range tests { | |
| 111 | + | t.Run(tt.name, func(t *testing.T) { | |
| 112 | + | humanDur, err := Parse(tt.human) | |
| 113 | + | if err != nil { | |
| 114 | + | t.Fatalf("Parse(%q) error = %v", tt.human, err) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | goDur, err := time.ParseDuration(tt.goStd) | |
| 118 | + | if err != nil { | |
| 119 | + | t.Fatalf("time.ParseDuration(%q) error = %v", tt.goStd, err) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | if humanDur != goDur { | |
| 123 | + | t.Errorf("Parse(%q) = %v, want %v (from %q)", tt.human, humanDur, goDur, tt.goStd) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | expectedHours := time.Duration(tt.hours) * time.Hour | |
| 127 | + | if humanDur != expectedHours { | |
| 128 | + | t.Errorf("Parse(%q) = %v, want %d hours", tt.human, humanDur, tt.hours) | |
| 129 | + | } | |
| 130 | + | }) | |
| 131 | + | } | |
| 132 | + | } | |
| @@ -0,0 +1,132 @@ | |||||
| 1 | + | package duration | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestParse(t *testing.T) { | |||
| 9 | + | tests := []struct { | |||
| 10 | + | name string | |||
| 11 | + | input string | |||
| 12 | + | want time.Duration | |||
| 13 | + | wantErr bool | |||
| 14 | + | }{ | |||
| 15 | + | // Single human-friendly units | |||
| 16 | + | {name: "1 day", input: "1d", want: Day}, | |||
| 17 | + | {name: "2 days", input: "2d", want: 2 * Day}, | |||
| 18 | + | {name: "1 week", input: "1w", want: Week}, | |||
| 19 | + | {name: "2 weeks", input: "2w", want: 2 * Week}, | |||
| 20 | + | {name: "1 month", input: "1M", want: Month}, | |||
| 21 | + | {name: "3 months", input: "3M", want: 3 * Month}, | |||
| 22 | + | {name: "1 year", input: "1y", want: Year}, | |||
| 23 | + | {name: "2 years", input: "2y", want: 2 * Year}, | |||
| 24 | + | ||||
| 25 | + | // Compound human-friendly units | |||
| 26 | + | {name: "1 year 6 months", input: "1y6M", want: Year + 6*Month}, | |||
| 27 | + | {name: "2 weeks 3 days", input: "2w3d", want: 2*Week + 3*Day}, | |||
| 28 | + | {name: "1 year 2 months 1 week", input: "1y2M1w", want: Year + 2*Month + Week}, | |||
| 29 | + | ||||
| 30 | + | // Mixed with standard Go units | |||
| 31 | + | {name: "1 day 12 hours", input: "1d12h", want: Day + 12*time.Hour}, | |||
| 32 | + | {name: "2 weeks 6 hours", input: "2w6h", want: 2*Week + 6*time.Hour}, | |||
| 33 | + | {name: "1 month 1 day 1 hour", input: "1M1d1h", want: Month + Day + time.Hour}, | |||
| 34 | + | {name: "1 year 30 minutes", input: "1y30m", want: Year + 30*time.Minute}, | |||
| 35 | + | ||||
| 36 | + | // Standard Go duration units (fallback) | |||
| 37 | + | {name: "24 hours", input: "24h", want: 24 * time.Hour}, | |||
| 38 | + | {name: "30 minutes", input: "30m", want: 30 * time.Minute}, | |||
| 39 | + | {name: "1 hour 30 minutes", input: "1h30m", want: time.Hour + 30*time.Minute}, | |||
| 40 | + | {name: "1 second", input: "1s", want: time.Second}, | |||
| 41 | + | {name: "500 milliseconds", input: "500ms", want: 500 * time.Millisecond}, | |||
| 42 | + | {name: "1000 microseconds", input: "1000us", want: 1000 * time.Microsecond}, | |||
| 43 | + | {name: "1000000 nanoseconds", input: "1000000ns", want: 1000000 * time.Nanosecond}, | |||
| 44 | + | ||||
| 45 | + | // Special cases | |||
| 46 | + | {name: "zero duration", input: "0", want: 0}, | |||
| 47 | + | {name: "zero with unit", input: "0d", want: 0}, | |||
| 48 | + | {name: "zero hours", input: "0h", want: 0}, | |||
| 49 | + | ||||
| 50 | + | // Large values | |||
| 51 | + | {name: "10 years", input: "10y", want: 10 * Year}, | |||
| 52 | + | {name: "52 weeks", input: "52w", want: 52 * Week}, | |||
| 53 | + | {name: "365 days", input: "365d", want: 365 * Day}, | |||
| 54 | + | ||||
| 55 | + | // Edge cases with whitespace (should be trimmed) | |||
| 56 | + | {name: "whitespace around", input: " 1d ", want: Day}, | |||
| 57 | + | ||||
| 58 | + | // Error cases | |||
| 59 | + | {name: "empty string", input: "", wantErr: true}, | |||
| 60 | + | {name: "invalid format", input: "abc", wantErr: true}, | |||
| 61 | + | {name: "invalid unit", input: "1x", wantErr: true}, | |||
| 62 | + | {name: "missing value", input: "d", wantErr: true}, | |||
| 63 | + | {name: "negative not supported by Go", input: "-1d", wantErr: true}, | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | for _, tt := range tests { | |||
| 67 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 68 | + | got, err := Parse(tt.input) | |||
| 69 | + | if (err != nil) != tt.wantErr { | |||
| 70 | + | t.Errorf("Parse(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) | |||
| 71 | + | return | |||
| 72 | + | } | |||
| 73 | + | if !tt.wantErr && got != tt.want { | |||
| 74 | + | t.Errorf("Parse(%q) = %v, want %v", tt.input, got, tt.want) | |||
| 75 | + | } | |||
| 76 | + | }) | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestParseConstants(t *testing.T) { | |||
| 81 | + | // Verify our constants are correct | |||
| 82 | + | if Day != 24*time.Hour { | |||
| 83 | + | t.Errorf("Day = %v, want %v", Day, 24*time.Hour) | |||
| 84 | + | } | |||
| 85 | + | if Week != 7*24*time.Hour { | |||
| 86 | + | t.Errorf("Week = %v, want %v", Week, 7*24*time.Hour) | |||
| 87 | + | } | |||
| 88 | + | if Month != 30*24*time.Hour { | |||
| 89 | + | t.Errorf("Month = %v, want %v", Month, 30*24*time.Hour) | |||
| 90 | + | } | |||
| 91 | + | if Year != 365*24*time.Hour { | |||
| 92 | + | t.Errorf("Year = %v, want %v", Year, 365*24*time.Hour) | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func TestParseEquivalences(t *testing.T) { | |||
| 97 | + | // Test that our human-friendly units produce expected equivalences | |||
| 98 | + | tests := []struct { | |||
| 99 | + | name string | |||
| 100 | + | human string | |||
| 101 | + | goStd string | |||
| 102 | + | hours int | |||
| 103 | + | }{ | |||
| 104 | + | {name: "1 day = 24 hours", human: "1d", goStd: "24h", hours: 24}, | |||
| 105 | + | {name: "1 week = 168 hours", human: "1w", goStd: "168h", hours: 168}, | |||
| 106 | + | {name: "1 month = 720 hours", human: "1M", goStd: "720h", hours: 720}, | |||
| 107 | + | {name: "1 year = 8760 hours", human: "1y", goStd: "8760h", hours: 8760}, | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | for _, tt := range tests { | |||
| 111 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 112 | + | humanDur, err := Parse(tt.human) | |||
| 113 | + | if err != nil { | |||
| 114 | + | t.Fatalf("Parse(%q) error = %v", tt.human, err) | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | goDur, err := time.ParseDuration(tt.goStd) | |||
| 118 | + | if err != nil { | |||
| 119 | + | t.Fatalf("time.ParseDuration(%q) error = %v", tt.goStd, err) | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | if humanDur != goDur { | |||
| 123 | + | t.Errorf("Parse(%q) = %v, want %v (from %q)", tt.human, humanDur, goDur, tt.goStd) | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | expectedHours := time.Duration(tt.hours) * time.Hour | |||
| 127 | + | if humanDur != expectedHours { | |||
| 128 | + | t.Errorf("Parse(%q) = %v, want %d hours", tt.human, humanDur, tt.hours) | |||
| 129 | + | } | |||
| 130 | + | }) | |||
| 131 | + | } | |||
| 132 | + | } | |||