Skip to content

Commit c57eeb8

Browse files
authored
Add integer formatting for metric period enums (#6)
1 parent 3ca5b53 commit c57eeb8

File tree

4 files changed

+236
-29
lines changed

4 files changed

+236
-29
lines changed

.claude/settings.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(go test:*)",
5+
"Bash(task install)",
6+
"Bash(task lint)",
7+
"Bash(task test)",
8+
"Bash(task test:reset)"
9+
],
10+
"deny": []
11+
}
12+
}

CLAUDE.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# CLAUDE.md
2+
3+
This repository is a Go implementation of the Schematic rules engine. It is used in both the Schematic public Go SDK (github.com/schematichq/schematic-go) and the private Schematic API. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Commands
6+
7+
This project uses Taskfile for development commands:
8+
9+
```bash
10+
# Development workflow
11+
task install # Install Go and dependencies
12+
task lint # Run golangci-lint (MUST run before commits)
13+
task test # Run tests with coverage
14+
task test:coverage # View coverage in browser
15+
task test:reset # Clear test cache
16+
17+
# Direct Go commands
18+
go test ./... # Run all tests
19+
go test -v ./... -run TestName # Run specific test
20+
```
21+
22+
## Architecture
23+
24+
This is Schematic's **Rules Engine** - a feature flag evaluation system compiled to WebAssembly for client-side use. The core evaluation flow is:
25+
26+
1. **flagcheck.go** - Main `CheckFlag()` entry point
27+
2. **rulecheck.go** - Processes individual rules and conditions
28+
3. **models.go** - Core data structures (Flag, Rule, Condition, Company, User)
29+
4. **metrics.go** - Usage tracking and billing cycle management
30+
31+
### Rule Processing Priority
32+
33+
Rules are evaluated in strict priority order:
34+
35+
1. `global_override` - System-wide toggles
36+
2. `company_override` - Company-specific overrides
37+
3. `plan_entitlement` - Plan-based features
38+
4. `company_override_usage_exceeded` - Usage limit overrides
39+
5. `plan_entitlement_usage_exceeded` - Plan usage enforcement
40+
6. `standard` - Regular business rules
41+
7. `default` - Fallback values
42+
43+
### Key Data Flow
44+
45+
**Flag Evaluation:**
46+
47+
- Flags contain multiple Rules ordered by priority
48+
- Rules contain Conditions (AND logic within rule, OR logic within condition groups)
49+
- Conditions check company/user traits, metrics, plans, etc.
50+
51+
**Usage Tracking:**
52+
53+
- CompanyMetric tracks usage over time periods (daily/weekly/monthly/all-time)
54+
- Metric periods can reset on calendar boundaries or billing cycles
55+
- Usage limits trigger rule overrides when exceeded
56+
57+
### Condition Types
58+
59+
- **Company/User targeting** - ID-based matching with set operations
60+
- **Plan checks** - Current plan vs. allowed plans
61+
- **Metric conditions** - Usage comparisons with time periods
62+
- **Trait matching** - Key-value trait comparisons using typeconvert utilities
63+
64+
### WebAssembly Interface
65+
66+
The main.go provides a WASM interface that:
67+
68+
- Exposes `checkFlag` function to JavaScript
69+
- Accepts JSON with company/user/flag data
70+
- Returns evaluation results with detailed error information
71+
- Runs persistently in browser environments
72+
73+
## Key Files
74+
75+
- **flagcheck.go** - Main evaluation logic
76+
- **rulecheck.go** - Rule/condition processing
77+
- **metrics.go** - Time-based usage tracking and billing cycles
78+
- **models.go** - All data structures with JSON serialization
79+
- **typeconvert/** - Type comparison utilities for conditions
80+
- **set/** - Generic set operations for ID matching

metrics.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package rulesengine
22

33
import (
4+
"fmt"
45
"time"
56
)
67

@@ -13,6 +14,56 @@ const (
1314
MetricPeriodCurrentWeek MetricPeriod = "current_week"
1415
)
1516

17+
// ToInt converts MetricPeriod to its integer representation
18+
func (mp MetricPeriod) ToInt() int {
19+
switch mp {
20+
case MetricPeriodAllTime:
21+
return 0
22+
case MetricPeriodCurrentDay:
23+
return 1
24+
case MetricPeriodCurrentWeek:
25+
return 2
26+
case MetricPeriodCurrentMonth:
27+
return 3
28+
default:
29+
return 0
30+
}
31+
}
32+
33+
// Format implements fmt.Formatter interface
34+
func (mp MetricPeriod) Format(f fmt.State, verb rune) {
35+
switch verb {
36+
case 'd':
37+
fmt.Fprintf(f, "%d", mp.ToInt())
38+
case 's':
39+
fmt.Fprintf(f, "%s", string(mp))
40+
case 'v':
41+
if f.Flag('#') {
42+
fmt.Fprintf(f, "MetricPeriod(%s)", string(mp))
43+
} else {
44+
fmt.Fprintf(f, "%s", string(mp))
45+
}
46+
default:
47+
fmt.Fprintf(f, "%%!%c(MetricPeriod=%s)", verb, string(mp))
48+
}
49+
}
50+
51+
// MetricPeriodFromInt converts an integer to MetricPeriod
52+
func MetricPeriodFromInt(i int) MetricPeriod {
53+
switch i {
54+
case 0:
55+
return MetricPeriodAllTime
56+
case 1:
57+
return MetricPeriodCurrentDay
58+
case 2:
59+
return MetricPeriodCurrentWeek
60+
case 3:
61+
return MetricPeriodCurrentMonth
62+
default:
63+
return MetricPeriodAllTime
64+
}
65+
}
66+
1667
// For MetricPeriodMonth, there's an additional option indicating when the month should reset
1768
type MetricPeriodMonthReset string
1869

@@ -21,6 +72,48 @@ const (
2172
MetricPeriodMonthResetBilling MetricPeriodMonthReset = "billing_cycle"
2273
)
2374

75+
// ToInt converts MetricPeriodMonthReset to its integer representation
76+
func (mr MetricPeriodMonthReset) ToInt() int {
77+
switch mr {
78+
case MetricPeriodMonthResetFirst:
79+
return 0
80+
case MetricPeriodMonthResetBilling:
81+
return 1
82+
default:
83+
return 0
84+
}
85+
}
86+
87+
// Format implements fmt.Formatter interface
88+
func (mr MetricPeriodMonthReset) Format(f fmt.State, verb rune) {
89+
switch verb {
90+
case 'd':
91+
fmt.Fprintf(f, "%d", mr.ToInt())
92+
case 's':
93+
fmt.Fprintf(f, "%s", string(mr))
94+
case 'v':
95+
if f.Flag('#') {
96+
fmt.Fprintf(f, "MetricPeriodMonthReset(%s)", string(mr))
97+
} else {
98+
fmt.Fprintf(f, "%s", string(mr))
99+
}
100+
default:
101+
fmt.Fprintf(f, "%%!%c(MetricPeriodMonthReset=%s)", verb, string(mr))
102+
}
103+
}
104+
105+
// MetricPeriodMonthResetFromInt converts an integer to MetricPeriodMonthReset
106+
func MetricPeriodMonthResetFromInt(i int) MetricPeriodMonthReset {
107+
switch i {
108+
case 0:
109+
return MetricPeriodMonthResetFirst
110+
case 1:
111+
return MetricPeriodMonthResetBilling
112+
default:
113+
return MetricPeriodMonthResetFirst
114+
}
115+
}
116+
24117
// Given a calendar-based metric period, return the beginning of the current metric period
25118
// Will return nil for non-calendar-based metric periods such as all-time or billing cycle
26119
func GetCurrentMetricPeriodStartForCalendarMetricPeriod(metricPeriod MetricPeriod) *time.Time {

metrics_test.go

Lines changed: 51 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -102,39 +102,35 @@ func TestGetCurrentMetricPeriodStartForCompanyBillingSubscription(t *testing.T)
102102
now := time.Now().UTC()
103103
company := createTestCompany()
104104

105-
// Set subscription to start on a day later in the month than today
106-
futureDay := now.Day() + 5
107-
if futureDay > 28 {
108-
futureDay = 28 // Avoid month boundary issues
109-
}
105+
resetDay := 5
110106

111107
company.Subscription.PeriodStart = time.Date(
112108
now.Year()-1,
113109
now.Month(),
114-
futureDay,
110+
resetDay,
115111
12, 0, 0, 0,
116112
time.UTC,
117113
)
118114

119115
result := rulesengine.GetCurrentMetricPeriodStartForCompanyBillingSubscription(company)
120116
assert.NotNil(t, result)
121117

122-
// In this case, the result should be last month's reset date
123-
expectedMonth := now.Month() - 1
124-
expectedYear := now.Year()
125-
if now.Month() == time.January {
126-
expectedMonth = time.December
127-
expectedYear = now.Year() - 1
128-
}
129-
118+
// Calculate what the function should return:
119+
// If today's date is before the 5th, return last month's 5th
120+
// If today's date is the 5th or after, return this month's 5th
130121
expected := time.Date(
131-
expectedYear,
132-
expectedMonth,
133-
futureDay,
122+
now.Year(),
123+
now.Month(),
124+
resetDay,
134125
12, 0, 0, 0,
135126
time.UTC,
136127
)
137128

129+
// If we haven't reached the reset day this month, use last month's reset day
130+
if now.Day() < resetDay {
131+
expected = expected.AddDate(0, -1, 0)
132+
}
133+
138134
assert.Equal(t, expected.Year(), result.Year())
139135
assert.Equal(t, expected.Month(), result.Month())
140136
assert.Equal(t, expected.Day(), result.Day())
@@ -379,16 +375,18 @@ func TestGetNextMetricPeriodStartForCompanyBillingSubscription(t *testing.T) {
379375
company := createTestCompany()
380376

381377
// Set subscription to start on a day later in the month than today
382-
futureDay := now.Day() + 5
383-
if futureDay > 28 {
384-
futureDay = 28 // Avoid month boundary issues
378+
// Use a day that's guaranteed to be in the future within this month
379+
futureDay := 15 // Use middle of month to avoid edge cases
380+
if now.Day() >= 15 {
381+
// If we're past the 15th, use day 5 of next month
382+
futureDay = 5
385383
}
386384

387385
company.Subscription.PeriodStart = time.Date(
388386
now.Year()-1,
389387
now.Month(),
390388
futureDay,
391-
12, 0, 0, 0,
389+
0, 0, 0, 0, // Use midnight for cleaner comparison
392390
time.UTC,
393391
)
394392

@@ -397,14 +395,38 @@ func TestGetNextMetricPeriodStartForCompanyBillingSubscription(t *testing.T) {
397395
result := rulesengine.GetNextMetricPeriodStartForCompanyBillingSubscription(company)
398396
assert.NotNil(t, result)
399397

400-
// In this case, the result should be this month's reset date
401-
expected := time.Date(
402-
now.Year(),
403-
now.Month(),
404-
futureDay,
405-
12, 0, 0, 0,
406-
time.UTC,
407-
)
398+
// Calculate expected: if we're before the 15th, expect this month's reset
399+
// If we're after the 15th, we used day 5, so expect next month's reset
400+
var expected time.Time
401+
if now.Day() < 15 {
402+
// Reset day (15th) hasn't passed, so next reset is this month
403+
expected = time.Date(
404+
now.Year(),
405+
now.Month(),
406+
futureDay,
407+
0, 0, 0, 0,
408+
time.UTC,
409+
)
410+
} else {
411+
// We used day 5, and since we're past 15th, next reset is next month's 5th
412+
expected = time.Date(
413+
now.Year(),
414+
now.Month()+1,
415+
futureDay,
416+
0, 0, 0, 0,
417+
time.UTC,
418+
)
419+
// Handle year boundary
420+
if now.Month() == time.December {
421+
expected = time.Date(
422+
now.Year()+1,
423+
time.January,
424+
futureDay,
425+
0, 0, 0, 0,
426+
time.UTC,
427+
)
428+
}
429+
}
408430

409431
assert.Equal(t, expected.Year(), result.Year())
410432
assert.Equal(t, expected.Month(), result.Month())

0 commit comments

Comments
 (0)