diff --git a/internal/config/config.go b/internal/config/config.go index c9b3d93..b93c192 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -83,6 +83,7 @@ type TagsConfig struct { type StateConfig struct { Name string `toml:"name"` Color string `toml:"color"` + Done bool `toml:"done,omitempty"` } // StatesConfig holds TODO state configurations @@ -163,7 +164,7 @@ func DefaultConfig() *Config { {Name: "TODO", Color: "202"}, {Name: "PROG", Color: "220"}, {Name: "BLOCK", Color: "196"}, - {Name: "DONE", Color: "34"}, + {Name: "DONE", Color: "34", Done: true}, }, DefaultNewTaskState: "TODO", }, @@ -393,6 +394,21 @@ func (c *Config) fillDefaults() { // Note: We don't fill DefaultNewTaskState if States.States is non-empty because // an empty string is a valid intentional value meaning "no default state". + // Back-compat: if states exist but none is flagged done (config predates the + // `done` field), treat the last state as done to preserve prior behavior. + if len(c.States.States) > 0 { + anyDone := false + for _, s := range c.States.States { + if s.Done { + anyDone = true + break + } + } + if !anyDone { + c.States.States[len(c.States.States)-1].Done = true + } + } + // Fill UI if zero values if c.UI.HelpTextWidth == 0 { c.UI.HelpTextWidth = defaults.UI.HelpTextWidth @@ -511,6 +527,19 @@ func (c *Config) GetStateNames() []string { return names } +// IsDoneState reports whether the named state is configured as a done state. +func (c *Config) IsDoneState(name string) bool { + if name == "" { + return false + } + for _, state := range c.States.States { + if state.Name == name { + return state.Done + } + } + return false +} + // UpdateKeybinding updates a keybinding in the configuration func (c *Config) UpdateKeybinding(action string, keys []string) error { // Use reflection would be complex, so we handle specific cases diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..3e22f80 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,51 @@ +package config + +import "testing" + +func TestIsDoneState(t *testing.T) { + c := &Config{States: StatesConfig{States: []StateConfig{ + {Name: "TODO", Color: "202"}, + {Name: "DONE", Color: "34", Done: true}, + {Name: "CANCELLED", Color: "240", Done: true}, + }}} + + cases := map[string]bool{"TODO": false, "DONE": true, "CANCELLED": true, "": false, "NOPE": false} + for name, want := range cases { + if got := c.IsDoneState(name); got != want { + t.Errorf("IsDoneState(%q) = %v, want %v", name, got, want) + } + } +} + +func TestFillDefaultsMarksLastStateDoneWhenNoneFlagged(t *testing.T) { + c := &Config{States: StatesConfig{States: []StateConfig{ + {Name: "TODO", Color: "202"}, + {Name: "DONE", Color: "34"}, + }}} + c.fillDefaults() + + if c.States.States[len(c.States.States)-1].Name != "DONE" { + t.Fatalf("states reordered unexpectedly") + } + if !c.States.States[len(c.States.States)-1].Done { + t.Error("expected last state (DONE) to be flagged Done after migration") + } + if c.States.States[0].Done { + t.Error("expected first state (TODO) to remain not-done") + } +} + +func TestFillDefaultsLeavesExplicitDoneFlagsAlone(t *testing.T) { + c := &Config{States: StatesConfig{States: []StateConfig{ + {Name: "DONE", Color: "34", Done: true}, + {Name: "TODO", Color: "202"}, + }}} + c.fillDefaults() + + if c.States.States[0].Name != "DONE" || !c.States.States[0].Done { + t.Error("explicit done flag on DONE should be preserved") + } + if c.States.States[1].Done { + t.Error("last state (TODO) must NOT be auto-flagged when a done state already exists") + } +}