diff --git a/.gitignore b/.gitignore index aaadf73..877de78 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ go.work.sum # Editor/IDE # .idea/ # .vscode/ + +# Test build artifacts +sleepycli \ No newline at end of file diff --git a/go.mod b/go.mod index 46e2ffb..c568190 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/TyostoKarry/sleepycli go 1.26.1 + +require github.com/spf13/pflag v1.0.10 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8ec1276 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= diff --git a/internal/validate/validate.go b/internal/validate/validate.go index a86bc49..0c13693 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -2,42 +2,110 @@ package validate import ( "fmt" + "strings" "time" ) type Config struct { WakeTime string SleepTime string + FromTime string + ToTime string Buffer int MinCycles int MaxCycles int } func (c *Config) Validate() error { - if c.WakeTime != "" && c.SleepTime != "" { - return fmt.Errorf("cannot specify both wake time and sleep time") + if err := validateModes(c.WakeTime, c.SleepTime, c.FromTime, c.ToTime); err != nil { + return err } - if c.WakeTime == "" && c.SleepTime == "" { - return fmt.Errorf("must specify either wake time or sleep time") + if c.FromTime != "" || c.ToTime != "" { + if err := validateWindow(c.FromTime, c.ToTime); err != nil { + return err + } + } else { + if err := validateTimeFlags(c.WakeTime, c.SleepTime); err != nil { + return err + } + } + if err := validateBuffer(c.Buffer); err != nil { + return err + } + return validateCycles(c.MinCycles, c.MaxCycles) +} + +func validateModes(wake, sleep, from, to string) error { + windowSet := from != "" || to != "" + wakeSleepSet := wake != "" || sleep != "" + if windowSet && wakeSleepSet { + return fmt.Errorf("cannot use --from/--to with --wake or --sleep") } - if c.WakeTime != "" { - if _, err := time.Parse("15:04", c.WakeTime); err != nil { - return fmt.Errorf("invalid wake time format: %v", err) + return nil +} + +func validateWindow(from, to string) error { + if from == "" { + return fmt.Errorf("--from is required when using --to") + } + if to == "" { + return fmt.Errorf("--to is required when using --from") + } + if err := validateTimeFormat(from); err != nil { + return fmt.Errorf("invalid --from value: %w", err) + } + if err := validateTimeFormat(to); err != nil { + return fmt.Errorf("invalid --to value: %w", err) + } + return nil +} + +func validateTimeFlags(wake, sleep string) error { + if wake != "" && sleep != "" { + return fmt.Errorf("cannot specify both --wake and --sleep") + } + if wake == "" && sleep == "" { + return fmt.Errorf("must specify either --wake or --sleep") + } + if wake != "" { + if err := validateTimeFormat(wake); err != nil { + return fmt.Errorf("invalid --wake value: %w", err) } } - if c.SleepTime != "" { - if _, err := time.Parse("15:04", c.SleepTime); err != nil { - return fmt.Errorf("invalid sleep time format: %v", err) + if sleep != "" { + if err := validateTimeFormat(sleep); err != nil { + return fmt.Errorf("invalid --sleep value: %w", err) } } - if c.Buffer < 0 { - return fmt.Errorf("buffer time cannot be negative") + return nil +} + +func validateTimeFormat(s string) error { + _, err := time.Parse("15:04", NormalizeHour(s)) + return err +} + +func validateBuffer(buffer int) error { + if buffer < 0 { + return fmt.Errorf("--buffer cannot be negative") } - if c.MinCycles < 0 || c.MaxCycles < 0 { - return fmt.Errorf("cycle counts cannot be negative") + return nil +} + +func validateCycles(min, max int) error { + if min < 0 || max < 0 { + return fmt.Errorf("--cycles-min and --cycles-max cannot be negative") } - if c.MinCycles > c.MaxCycles { - return fmt.Errorf("min cycles cannot be greater than max cycles") + if min > max { + return fmt.Errorf("--cycles-min cannot be greater than --cycles-max") } return nil } + +func NormalizeHour(s string) string { + parts := strings.SplitN(s, ":", 2) + if len(parts) == 2 && len(parts[0]) == 1 { + return "0" + s + } + return s +} diff --git a/internal/validate/validate_test.go b/internal/validate/validate_test.go index 6757d94..464832f 100644 --- a/internal/validate/validate_test.go +++ b/internal/validate/validate_test.go @@ -9,15 +9,16 @@ func TestValidateWakeTime(t *testing.T) { wantErr bool }{ {"valid wake time", Config{WakeTime: "07:00"}, false}, - {"valid wake time format", Config{WakeTime: "7:00"}, false}, + {"valid wake time short hour", Config{WakeTime: "7:00"}, false}, {"invalid wake time value", Config{WakeTime: "25:00"}, true}, + {"invalid wake time format", Config{WakeTime: "7am"}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := tt.config.Validate() if (err != nil) != tt.wantErr { - t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) } }) } @@ -30,6 +31,7 @@ func TestValidateSleepTime(t *testing.T) { wantErr bool }{ {"valid sleep time", Config{SleepTime: "22:00"}, false}, + {"valid sleep time short hour", Config{SleepTime: "9:00"}, false}, {"invalid sleep time format", Config{SleepTime: "10:00 PM"}, true}, {"invalid sleep time value", Config{SleepTime: "24:00"}, true}, } @@ -38,7 +40,27 @@ func TestValidateSleepTime(t *testing.T) { t.Run(tt.name, func(t *testing.T) { err := tt.config.Validate() if (err != nil) != tt.wantErr { - t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestValidateTimeFlagsMutualExclusion(t *testing.T) { + tests := []struct { + name string + config Config + wantErr bool + }{ + {"both wake and sleep set", Config{WakeTime: "07:00", SleepTime: "22:00"}, true}, + {"neither wake nor sleep set", Config{}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) } }) } @@ -51,6 +73,7 @@ func TestValidateBuffer(t *testing.T) { wantErr bool }{ {"valid buffer", Config{WakeTime: "07:00", Buffer: 15}, false}, + {"zero buffer", Config{WakeTime: "07:00", Buffer: 0}, false}, {"negative buffer", Config{WakeTime: "07:00", Buffer: -5}, true}, } @@ -58,7 +81,7 @@ func TestValidateBuffer(t *testing.T) { t.Run(tt.name, func(t *testing.T) { err := tt.config.Validate() if (err != nil) != tt.wantErr { - t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) } }) } @@ -78,7 +101,7 @@ func TestValidateMinCycles(t *testing.T) { t.Run(tt.name, func(t *testing.T) { err := tt.config.Validate() if (err != nil) != tt.wantErr { - t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) } }) } @@ -98,7 +121,7 @@ func TestValidateMaxCycles(t *testing.T) { t.Run(tt.name, func(t *testing.T) { err := tt.config.Validate() if (err != nil) != tt.wantErr { - t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) } }) } @@ -111,6 +134,7 @@ func TestValidateMinMaxCycles(t *testing.T) { wantErr bool }{ {"valid min and max cycles", Config{WakeTime: "07:00", MinCycles: 4, MaxCycles: 6}, false}, + {"equal min and max cycles", Config{WakeTime: "07:00", MinCycles: 6, MaxCycles: 6}, false}, {"min cycles greater than max cycles", Config{WakeTime: "07:00", MinCycles: 7, MaxCycles: 6}, true}, } @@ -118,7 +142,33 @@ func TestValidateMinMaxCycles(t *testing.T) { t.Run(tt.name, func(t *testing.T) { err := tt.config.Validate() if (err != nil) != tt.wantErr { - t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestValidateWindow(t *testing.T) { + tests := []struct { + name string + config Config + wantErr bool + }{ + {"valid window", Config{FromTime: "22:00", ToTime: "07:00"}, false}, + {"valid window short hour", Config{FromTime: "9:00", ToTime: "07:00"}, false}, + {"missing --to", Config{FromTime: "22:00"}, true}, + {"missing --from", Config{ToTime: "07:00"}, true}, + {"invalid --from format", Config{FromTime: "10:00 PM", ToTime: "07:00"}, true}, + {"invalid --to format", Config{FromTime: "22:00", ToTime: "7am"}, true}, + {"window mixed with --wake", Config{FromTime: "22:00", ToTime: "07:00", WakeTime: "07:00"}, true}, + {"window mixed with --sleep", Config{FromTime: "22:00", ToTime: "07:00", SleepTime: "22:00"}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) } }) } diff --git a/main.go b/main.go index 38dd16d..fbaa334 100644 --- a/main.go +++ b/main.go @@ -1,3 +1,44 @@ package main -func main() {} +import ( + "fmt" + "os" + + "github.com/spf13/pflag" +) + +const version = "0.1.0" + +func main() { + var ( + wakeFlag string + sleepFlag string + fromFlag string + toFlag string + bufferFlag int + cyclesMaxFlag int + cyclesMinFlag int + versionFlag bool + ) + + pflag.StringVarP(&wakeFlag, "wake", "w", "", "Calculate bedtimes from wake time (HH:MM)") + pflag.StringVarP(&sleepFlag, "sleep", "s", "", "Calculate wake times from sleep time (HH:MM)") + pflag.StringVarP(&fromFlag, "from", "f", "", "Window sleep time (HH:MM), use with --to") + pflag.StringVarP(&toFlag, "to", "t", "", "Window wake time (HH:MM), use with --from") + pflag.IntVarP(&bufferFlag, "buffer", "b", 15, "Fall asleep buffer in minutes") + pflag.IntVarP(&cyclesMinFlag, "cycles-min", "n", 4, "Minimum cycles to show") + pflag.IntVarP(&cyclesMaxFlag, "cycles-max", "x", 6, "Maximum cycles to show") + pflag.BoolVarP(&versionFlag, "version", "v", false, "Print version") + + pflag.Parse() + + if versionFlag { + fmt.Println("sleepycli v" + version) + os.Exit(0) + } + + if err := validateAndSelectMode(wakeFlag, sleepFlag, fromFlag, toFlag, bufferFlag, cyclesMinFlag, cyclesMaxFlag); err != nil { + fmt.Fprintln(os.Stderr, "Error:", err) + os.Exit(1) + } +} diff --git a/modes.go b/modes.go new file mode 100644 index 0000000..b62f8b2 --- /dev/null +++ b/modes.go @@ -0,0 +1,86 @@ +package main + +import ( + "fmt" + "time" + + "github.com/TyostoKarry/sleepycli/internal/cycle" + "github.com/TyostoKarry/sleepycli/internal/validate" +) + +func validateAndSelectMode( + wakeFlag, sleepFlag, fromFlag, toFlag string, + bufferFlag, cyclesMinFlag, cyclesMaxFlag int, +) error { + cfg := validate.Config{ + WakeTime: wakeFlag, + SleepTime: sleepFlag, + FromTime: fromFlag, + ToTime: toFlag, + Buffer: bufferFlag, + MinCycles: cyclesMinFlag, + MaxCycles: cyclesMaxFlag, + } + if err := cfg.Validate(); err != nil { + return err + } + + buffer := time.Duration(bufferFlag) * time.Minute + + if fromFlag != "" && toFlag != "" { + return runWindowMode(fromFlag, toFlag, buffer) + } + if wakeFlag != "" { + return runWakeMode(wakeFlag, buffer, cyclesMinFlag, cyclesMaxFlag) + } + if sleepFlag != "" { + return runSleepMode(sleepFlag, buffer, cyclesMinFlag, cyclesMaxFlag) + } + return fmt.Errorf("no valid mode selected") +} + +func runWindowMode(from, to string, buffer time.Duration) error { + fromTime, err := time.Parse("15:04", validate.NormalizeHour(from)) + if err != nil { + return err + } + toTime, err := time.Parse("15:04", validate.NormalizeHour(to)) + if err != nil { + return err + } + + cycles, remainder := cycle.CalculateCyclesInWindow(fromTime, toTime, buffer) + fmt.Printf("Between %s and %s:\n", from, to) + fmt.Printf("You can fit %d complete sleep cycles (%d minutes remaining)\n", cycles, int(remainder.Minutes())) + return nil +} + +func runWakeMode(wake string, buffer time.Duration, minCycles, maxCycles int) error { + wakeTime, err := time.Parse("15:04", validate.NormalizeHour(wake)) + if err != nil { + return err + } + bedtimes := cycle.CalculateBedtimes(wakeTime, buffer, minCycles, maxCycles) + + fmt.Printf("To wake up at %s:\n", wake) + for i, bedtime := range bedtimes { + cycleCount := minCycles + i + fmt.Printf(" - For %d cycles, go to sleep at %s\n", cycleCount, bedtime.Format("15:04")) + } + return nil +} + +func runSleepMode(sleep string, buffer time.Duration, minCycles, maxCycles int) error { + sleepTime, err := time.Parse("15:04", validate.NormalizeHour(sleep)) + if err != nil { + return err + } + wakeTimes := cycle.CalculateWakeTimes(sleepTime, buffer, minCycles, maxCycles) + + fmt.Printf("If you go to sleep at %s:\n", sleep) + for i, wakeTime := range wakeTimes { + cycleCount := minCycles + i + fmt.Printf(" - For %d cycles, wake up at %s\n", cycleCount, wakeTime.Format("15:04")) + } + return nil +}