diff --git a/.doc/doki/doc/config.md b/.doc/doki/doc/config.md index abb773e..9c3be2d 100644 --- a/.doc/doki/doc/config.md +++ b/.doc/doki/doc/config.md @@ -46,6 +46,12 @@ All `config.yaml` files found are merged together. A project config only needs t Search order: user config dir (base) → `.doc/config.yaml` (project) → cwd (highest priority). +### new.md (task template) + +`new.md` is searched in the same three locations but is **not merged** — the single highest-priority file found wins. If a project provides `.doc/new.md`, it completely replaces the user-level template. If no `new.md` is found anywhere, a built-in embedded template is used. + +Search order: user config dir → `.doc/new.md` (project) → cwd. Last match wins. + ### workflow.yaml merging `workflow.yaml` is searched in all three locations. Files that exist are loaded and merged sequentially. diff --git a/.doc/doki/doc/customization.md b/.doc/doki/doc/customization.md index 1fa3ea2..eca9b0b 100644 --- a/.doc/doki/doc/customization.md +++ b/.doc/doki/doc/customization.md @@ -46,7 +46,7 @@ You can customize these to match your team's workflow. All filters and actions i ## Task Template When you create a new tiki — whether in the TUI or command line — field defaults come from a template file. -Place `new.md` in your user config directory to override the built-in defaults +Place `new.md` in your config directory to override the built-in defaults The file uses YAML frontmatter for field defaults ### Built-in default diff --git a/config/paths.go b/config/paths.go index f6797e2..2ab70e5 100644 --- a/config/paths.go +++ b/config/paths.go @@ -327,6 +327,9 @@ func GetUserConfigWorkflowFile() string { // defaultWorkflowFilename is the default name for the workflow configuration file const defaultWorkflowFilename = "workflow.yaml" +// templateFilename is the default name for the task template file +const templateFilename = "new.md" + // FindWorkflowFiles returns all workflow.yaml files that exist and have non-empty views. // Ordering: user config file first (base), then project config file (overrides), then cwd. // This lets LoadPlugins load the base and merge overrides on top. @@ -397,9 +400,38 @@ func FindWorkflowFile() string { return files[0] } -// GetTemplateFile returns the path to the user's custom new.md template -func GetTemplateFile() string { - return mustGetPathManager().TemplateFile() +// FindTemplateFile returns the highest-priority new.md file that exists, +// searching user config → .doc/ (project) → cwd. Returns empty string if +// none found, in which case the caller should fall back to the embedded template. +func FindTemplateFile() string { + pm := mustGetPathManager() + + // candidate paths in discovery order: user config (base) → project → cwd (highest) + candidates := []string{ + pm.TemplateFile(), + filepath.Join(pm.ProjectConfigDir(), templateFilename), + templateFilename, + } + + var best string + seen := make(map[string]bool) + + for _, path := range candidates { + abs, err := filepath.Abs(path) + if err != nil { + abs = path + } + if seen[abs] { + continue + } + if _, err := os.Stat(path); err != nil { + continue + } + seen[abs] = true + best = path + } + + return best } // EnsureDirs creates all necessary directories with appropriate permissions diff --git a/config/paths_test.go b/config/paths_test.go index 0f60d4f..1a82e46 100644 --- a/config/paths_test.go +++ b/config/paths_test.go @@ -293,7 +293,6 @@ func TestGlobalAccessorFunctions(t *testing.T) { {"GetTaskDir", GetTaskDir}, {"GetDokiDir", GetDokiDir}, {"GetProjectConfigFile", GetProjectConfigFile}, - {"GetTemplateFile", GetTemplateFile}, } for _, tt := range tests { @@ -344,6 +343,142 @@ func TestInitPaths(t *testing.T) { } } +func TestFindTemplateFile_CwdOverridesProject(t *testing.T) { + // user config with new.md + userDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", userDir) + userTikiDir := filepath.Join(userDir, "tiki") + if err := os.MkdirAll(userTikiDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(userTikiDir, "new.md"), []byte("---\npriority: 1\n---"), 0644); err != nil { + t.Fatal(err) + } + + // project .doc/ with new.md + projectDir := t.TempDir() + docDir := filepath.Join(projectDir, ".doc") + if err := os.MkdirAll(docDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(docDir, "new.md"), []byte("---\npriority: 2\n---"), 0644); err != nil { + t.Fatal(err) + } + + // cwd with new.md (highest priority) + cwdDir := t.TempDir() + if err := os.WriteFile(filepath.Join(cwdDir, "new.md"), []byte("---\npriority: 3\n---"), 0644); err != nil { + t.Fatal(err) + } + + originalDir, _ := os.Getwd() + defer func() { _ = os.Chdir(originalDir) }() + _ = os.Chdir(cwdDir) + + ResetPathManager() + pm := mustGetPathManager() + pm.projectRoot = projectDir + + got := FindTemplateFile() + gotAbs, _ := filepath.Abs(got) + wantAbs, _ := filepath.Abs("new.md") + if gotAbs != wantAbs { + t.Errorf("FindTemplateFile() = %q, want cwd file %q", gotAbs, wantAbs) + } +} + +func TestFindTemplateFile_ProjectOverridesUser(t *testing.T) { + // user config with new.md + userDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", userDir) + userTikiDir := filepath.Join(userDir, "tiki") + if err := os.MkdirAll(userTikiDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(userTikiDir, "new.md"), []byte("---\npriority: 1\n---"), 0644); err != nil { + t.Fatal(err) + } + + // project .doc/ with new.md + projectDir := t.TempDir() + docDir := filepath.Join(projectDir, ".doc") + if err := os.MkdirAll(docDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(docDir, "new.md"), []byte("---\npriority: 2\n---"), 0644); err != nil { + t.Fatal(err) + } + + // cwd with NO new.md + cwdDir := t.TempDir() + originalDir, _ := os.Getwd() + defer func() { _ = os.Chdir(originalDir) }() + _ = os.Chdir(cwdDir) + + ResetPathManager() + pm := mustGetPathManager() + pm.projectRoot = projectDir + + got := FindTemplateFile() + want := filepath.Join(docDir, "new.md") + if got != want { + t.Errorf("FindTemplateFile() = %q, want project file %q", got, want) + } +} + +func TestFindTemplateFile_UserOnlyFallback(t *testing.T) { + // user config with new.md + userDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", userDir) + userTikiDir := filepath.Join(userDir, "tiki") + if err := os.MkdirAll(userTikiDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(userTikiDir, "new.md"), []byte("---\npriority: 1\n---"), 0644); err != nil { + t.Fatal(err) + } + + // no project .doc/, no cwd new.md + cwdDir := t.TempDir() + originalDir, _ := os.Getwd() + defer func() { _ = os.Chdir(originalDir) }() + _ = os.Chdir(cwdDir) + + ResetPathManager() + pm := mustGetPathManager() + pm.projectRoot = cwdDir + + got := FindTemplateFile() + want := filepath.Join(userTikiDir, "new.md") + if got != want { + t.Errorf("FindTemplateFile() = %q, want user config file %q", got, want) + } +} + +func TestFindTemplateFile_NoneFound(t *testing.T) { + // empty user config dir (no new.md) + userDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", userDir) + if err := os.MkdirAll(filepath.Join(userDir, "tiki"), 0750); err != nil { + t.Fatal(err) + } + + // empty cwd + cwdDir := t.TempDir() + originalDir, _ := os.Getwd() + defer func() { _ = os.Chdir(originalDir) }() + _ = os.Chdir(cwdDir) + + ResetPathManager() + pm := mustGetPathManager() + pm.projectRoot = cwdDir + + got := FindTemplateFile() + if got != "" { + t.Errorf("FindTemplateFile() = %q, want empty string when no file exists", got) + } +} + func TestResetPathManager(t *testing.T) { // Save original XDG origXDG := os.Getenv("XDG_CONFIG_HOME") diff --git a/store/tikistore/template.go b/store/tikistore/template.go index 7118fe8..47a105e 100644 --- a/store/tikistore/template.go +++ b/store/tikistore/template.go @@ -27,21 +27,23 @@ type templateFrontmatter struct { Points int `yaml:"points"` } -// loadTemplateTask reads new.md from user config directory, or falls back to embedded template. +// loadTemplateTask reads new.md from the highest-priority location +// (cwd > .doc/ > user config), or falls back to the embedded template. func loadTemplateTask() *taskpkg.Task { - // Try to load from user config directory first - templatePath := config.GetTemplateFile() + templatePath := config.FindTemplateFile() + + if templatePath == "" { + slog.Debug("no new.md found in any search path, using embedded template") + return loadEmbeddedTemplate() + } data, err := os.ReadFile(templatePath) if err != nil { - if os.IsNotExist(err) { - slog.Debug("new.md not found in user config dir, using embedded template", "path", templatePath) - return loadEmbeddedTemplate() - } slog.Warn("failed to read new.md template", "path", templatePath, "error", err) return loadEmbeddedTemplate() } + slog.Debug("loaded new.md template", "path", templatePath) return parseTaskTemplate(data) } diff --git a/store/tikistore/template_test.go b/store/tikistore/template_test.go new file mode 100644 index 0000000..6d2950b --- /dev/null +++ b/store/tikistore/template_test.go @@ -0,0 +1,75 @@ +package tikistore + +import ( + "os" + "path/filepath" + "testing" + + "github.com/boolean-maybe/tiki/config" +) + +func TestLoadTemplateTask_CwdWins(t *testing.T) { + // user config with priority 1 + userDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", userDir) + userTikiDir := filepath.Join(userDir, "tiki") + if err := os.MkdirAll(userTikiDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(userTikiDir, "new.md"), + []byte("---\ntitle: user\npriority: 1\ntype: story\nstatus: backlog\n---"), 0644); err != nil { + t.Fatal(err) + } + + // cwd with priority 5 (should win) + cwdDir := t.TempDir() + if err := os.WriteFile(filepath.Join(cwdDir, "new.md"), + []byte("---\ntitle: cwd\npriority: 5\ntype: bug\nstatus: backlog\n---"), 0644); err != nil { + t.Fatal(err) + } + + originalDir, _ := os.Getwd() + defer func() { _ = os.Chdir(originalDir) }() + _ = os.Chdir(cwdDir) + + config.ResetPathManager() + + task := loadTemplateTask() + if task == nil { + t.Fatal("loadTemplateTask() returned nil") + } + if task.Title != "cwd" { + t.Errorf("title = %q, want \"cwd\"", task.Title) + } + if task.Priority != 5 { + t.Errorf("priority = %d, want 5", task.Priority) + } +} + +func TestLoadTemplateTask_EmbeddedFallback(t *testing.T) { + // no new.md anywhere + userDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", userDir) + if err := os.MkdirAll(filepath.Join(userDir, "tiki"), 0750); err != nil { + t.Fatal(err) + } + + cwdDir := t.TempDir() + originalDir, _ := os.Getwd() + defer func() { _ = os.Chdir(originalDir) }() + _ = os.Chdir(cwdDir) + + config.ResetPathManager() + + task := loadTemplateTask() + if task == nil { + t.Fatal("loadTemplateTask() returned nil, expected embedded template") + } + // embedded template has type: story and status: backlog + if task.Type != "story" { + t.Errorf("type = %q, want \"story\" from embedded template", task.Type) + } + if task.Status != "backlog" { + t.Errorf("status = %q, want \"backlog\" from embedded template", task.Status) + } +}