new.md precedence

This commit is contained in:
booleanmaybe 2026-04-01 10:58:10 -04:00
parent 1737e042a7
commit c0621840e1
6 changed files with 262 additions and 12 deletions

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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")

View file

@ -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)
}

View file

@ -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)
}
}