refactor: split the code into cmd/ and internal/
cmd/dew/main.go is just the dispatch now, everything else moved to internal/dew. loadConfig and listTasks got exported so main can reach them. also added tests/ with a couple of black-box cli tests.
diff --git a/cmd/dew/main.go b/cmd/dew/main.go
@@ -0,0 +1,97 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ "dew/internal/dew"
+)
+
+func main() {
+ var newArgs []string
+ silent := false
+ for _, arg := range os.Args {
+ if arg == "-s" || arg == "-q" || arg == "--silent" || arg == "--quiet" {
+ silent = true
+ } else {
+ newArgs = append(newArgs, arg)
+ }
+ }
+ os.Args = newArgs
+
+ if silent {
+ devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0666)
+ if err == nil {
+ os.Stdout = devNull
+ }
+ }
+
+ cfg, err := dew.LoadConfig()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error loading config: %v\n", err)
+ os.Exit(1)
+ }
+
+ if len(os.Args) < 2 {
+ err := dew.LaunchTUI(cfg)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error launching tui: %v\n", err)
+ os.Exit(1)
+ }
+ return
+ }
+
+ command := strings.ToLower(os.Args[1])
+
+ switch command {
+ case "add", "a":
+ msg, err := dew.AddTask(cfg.TodoFile, os.Args[2:])
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error: %v\n", err)
+ os.Exit(1)
+ }
+ fmt.Println(msg)
+ case "do", "done":
+ msg, err := dew.CompleteTask(cfg, os.Args[2:])
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error: %v\n", err)
+ os.Exit(1)
+ }
+ fmt.Println(msg)
+ case "rm", "del":
+ msg, err := dew.DeleteTask(cfg, os.Args[2:])
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error: %v\n", err)
+ os.Exit(1)
+ }
+ fmt.Println(msg)
+ case "pin":
+ msg, err := dew.ToggleModifier(cfg, os.Args[2:], "*")
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error: %v\n", err)
+ os.Exit(1)
+ }
+ fmt.Println(msg)
+ case "wait":
+ msg, err := dew.ToggleModifier(cfg, os.Args[2:], "~")
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error: %v\n", err)
+ os.Exit(1)
+ }
+ fmt.Println(msg)
+ case "ls", "list":
+ dew.ListTasks(cfg, os.Args[2:], "grouped")
+ case "flat", "raw":
+ dew.ListTasks(cfg, os.Args[2:], "flat")
+ case "tui", "ui":
+ err := dew.LaunchTUI(cfg)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error launching tui: %v\n", err)
+ os.Exit(1)
+ }
+ default:
+ fmt.Fprintf(os.Stderr, "unknown command: %s\n", command)
+ os.Exit(1)
+ }
+}
diff --git a/actions.go b/internal/dew/actions.go
@@ -1,4 +1,4 @@
-package main
+package dew
import (
"fmt"
diff --git a/actions_test.go b/internal/dew/actions_test.go
@@ -1,4 +1,4 @@
-package main
+package dew
import (
"os"
diff --git a/main.go b/internal/dew/cli.go
@@ -1,4 +1,4 @@
-package main
+package dew
import (
"fmt"
@@ -9,95 +9,7 @@ import (
"github.com/charmbracelet/lipgloss"
)
-func main() {
- var newArgs []string
- silent := false
- for _, arg := range os.Args {
- if arg == "-s" || arg == "-q" || arg == "--silent" || arg == "--quiet" {
- silent = true
- } else {
- newArgs = append(newArgs, arg)
- }
- }
- os.Args = newArgs
-
- if silent {
- devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0666)
- if err == nil {
- os.Stdout = devNull
- }
- }
-
- cfg, err := loadConfig()
- if err != nil {
- fmt.Fprintf(os.Stderr, "error loading config: %v\n", err)
- os.Exit(1)
- }
-
- if len(os.Args) < 2 {
- err := LaunchTUI(cfg)
- if err != nil {
- fmt.Fprintf(os.Stderr, "error launching tui: %v\n", err)
- os.Exit(1)
- }
- return
- }
-
- command := strings.ToLower(os.Args[1])
-
- switch command {
- case "add", "a":
- msg, err := AddTask(cfg.TodoFile, os.Args[2:])
- if err != nil {
- fmt.Fprintf(os.Stderr, "error: %v\n", err)
- os.Exit(1)
- }
- fmt.Println(msg)
- case "do", "done":
- msg, err := CompleteTask(cfg, os.Args[2:])
- if err != nil {
- fmt.Fprintf(os.Stderr, "error: %v\n", err)
- os.Exit(1)
- }
- fmt.Println(msg)
- case "rm", "del":
- msg, err := DeleteTask(cfg, os.Args[2:])
- if err != nil {
- fmt.Fprintf(os.Stderr, "error: %v\n", err)
- os.Exit(1)
- }
- fmt.Println(msg)
- case "pin":
- msg, err := ToggleModifier(cfg, os.Args[2:], "*")
- if err != nil {
- fmt.Fprintf(os.Stderr, "error: %v\n", err)
- os.Exit(1)
- }
- fmt.Println(msg)
- case "wait":
- msg, err := ToggleModifier(cfg, os.Args[2:], "~")
- if err != nil {
- fmt.Fprintf(os.Stderr, "error: %v\n", err)
- os.Exit(1)
- }
- fmt.Println(msg)
- case "ls", "list":
- listTasks(cfg, os.Args[2:], "grouped")
- case "flat", "raw":
- listTasks(cfg, os.Args[2:], "flat")
- case "tui", "ui":
- err := LaunchTUI(cfg)
- if err != nil {
- fmt.Fprintf(os.Stderr, "error launching tui: %v\n", err)
- os.Exit(1)
- }
- default:
- fmt.Fprintf(os.Stderr, "unknown command: %s\n", command)
- os.Exit(1)
- }
-}
-
-func listTasks(cfg *Config, filters []string, viewMode string) {
+func ListTasks(cfg *Config, filters []string, viewMode string) {
allTasks, err := ReadTasks(cfg.TodoFile)
if err != nil {
fmt.Fprintf(os.Stderr, "error reading todo file: %v\n", err)
diff --git a/config.go b/internal/dew/config.go
@@ -1,4 +1,4 @@
-package main
+package dew
import (
"encoding/json"
@@ -43,7 +43,7 @@ func getConfigPath() (string, error) {
return filepath.Join(homeDir, ".config", "dew", "config.json"), nil
}
-func loadConfig() (*Config, error) {
+func LoadConfig() (*Config, error) {
cfg := Config{
CenterTUI: true,
DefaultView: "grouped",
diff --git a/task.go b/internal/dew/task.go
@@ -1,4 +1,4 @@
-package main
+package dew
import (
"bufio"
diff --git a/task_test.go b/internal/dew/task_test.go
@@ -1,4 +1,4 @@
-package main
+package dew
import (
"slices"
diff --git a/tui.go b/internal/dew/tui.go
@@ -1,4 +1,4 @@
-package main
+package dew
import (
"fmt"
diff --git a/tests/cli_test.go b/tests/cli_test.go
@@ -0,0 +1,113 @@
+package tests
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// buildDew compiles the cli once so the tests can drive it as a real process
+// instead of reaching into the internals.
+func buildDew(t *testing.T) string {
+ t.Helper()
+
+ bin := filepath.Join(t.TempDir(), "dew")
+ cmd := exec.Command("go", "build", "-o", bin, "../cmd/dew")
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("building dew: %v\n%s", err, out)
+ }
+ return bin
+}
+
+// newHome writes a throwaway config pointing at temp files and returns the
+// HOME to run under, so the tests never touch the real config.
+func newHome(t *testing.T) (home, todo, done string) {
+ t.Helper()
+
+ dir := t.TempDir()
+ home = t.TempDir()
+ todo = filepath.Join(dir, "todo.txt")
+ done = filepath.Join(dir, "done.txt")
+
+ cfgDir := filepath.Join(home, ".config", "dew")
+ if err := os.MkdirAll(cfgDir, 0755); err != nil {
+ t.Fatalf("creating config dir: %v", err)
+ }
+
+ cfg := fmt.Sprintf("{\"todo_file\": %q, \"done_file\": %q}", todo, done)
+ if err := os.WriteFile(filepath.Join(cfgDir, "config.json"), []byte(cfg), 0644); err != nil {
+ t.Fatalf("writing config: %v", err)
+ }
+ return home, todo, done
+}
+
+func runDew(t *testing.T, bin, home string, args ...string) (string, error) {
+ t.Helper()
+
+ env := []string{}
+ for _, e := range os.Environ() {
+ if !strings.HasPrefix(e, "HOME=") {
+ env = append(env, e)
+ }
+ }
+
+ cmd := exec.Command(bin, args...)
+ cmd.Env = append(env, "HOME="+home)
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+}
+
+func TestCLIAddAndList(t *testing.T) {
+ bin := buildDew(t)
+ home, _, _ := newHome(t)
+
+ out, err := runDew(t, bin, home, "add", "buy", "catnip", "+metsa", "@chores")
+ if err != nil {
+ t.Fatalf("dew add: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, "added task: buy catnip +metsa @chores") {
+ t.Errorf("add output = %q", out)
+ }
+
+ out, err = runDew(t, bin, home, "ls")
+ if err != nil {
+ t.Fatalf("dew ls: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, "buy catnip") || !strings.Contains(out, "metsa") {
+ t.Errorf("ls output missing the task: %q", out)
+ }
+}
+
+func TestCLIDoneArchives(t *testing.T) {
+ bin := buildDew(t)
+ home, todo, done := newHome(t)
+
+ for _, desc := range []string{"first task +home", "second task +home"} {
+ if out, err := runDew(t, bin, home, "add", desc); err != nil {
+ t.Fatalf("dew add %q: %v\n%s", desc, err, out)
+ }
+ }
+
+ if out, err := runDew(t, bin, home, "do", "1"); err != nil {
+ t.Fatalf("dew do 1: %v\n%s", err, out)
+ }
+
+ todoBytes, err := os.ReadFile(todo)
+ if err != nil {
+ t.Fatalf("reading todo: %v", err)
+ }
+ if got := strings.TrimSpace(string(todoBytes)); got != "second task +home" {
+ t.Errorf("todo = %q, want just the second task", got)
+ }
+
+ doneBytes, err := os.ReadFile(done)
+ if err != nil {
+ t.Fatalf("reading done: %v", err)
+ }
+ if !strings.Contains(string(doneBytes), "first task +home") {
+ t.Errorf("done.txt = %q, want the archived first task", doneBytes)
+ }
+}