feat: add human-friendly duration parser for token expiry
Extend Go's time.ParseDuration to support human-friendly units: - d (days) = 24h - w (weeks) = 7d - M (months) = 30d (approximate) - y (years) = 365d (approximate) Compound durations like "1y6M" or "2w3d" work as expected. Standard Go units (ns, us, ms, s, m, h) remain fully supported. Updates CLI default from 720h to 30d for better readability.
- commit
- a0c7c7181760766cb9bb466e7dd6300eee0628b2
- author
- brice · · 2026-01-17 08:35 UTC
- parent
- 911ca7b
- signature
- unsigned
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 | + | } | |||