mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
Merge pull request #20 from boolean-maybe/feature/standard-config
move config to standard location
This commit is contained in:
commit
57aa3ccfa4
21 changed files with 841 additions and 165 deletions
|
|
@ -45,7 +45,8 @@ func PromptForProjectInit() ([]string, bool, error) {
|
|||
// Returns (proceed, error).
|
||||
// If proceed is false, the user canceled initialization.
|
||||
func EnsureProjectInitialized(tikiSkillMdContent, dokiSkillMdContent string) (bool, error) {
|
||||
if _, err := os.Stat(TaskDir); err != nil {
|
||||
taskDir := GetTaskDir()
|
||||
if _, err := os.Stat(taskDir); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return false, fmt.Errorf("failed to stat task directory: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,21 +14,6 @@ import (
|
|||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Hardcoded task storage configuration
|
||||
var (
|
||||
TaskDir = ".doc/tiki"
|
||||
DokiDir = ".doc/doki"
|
||||
)
|
||||
|
||||
// GetDokiRoot returns the absolute path to the doki directory
|
||||
func GetDokiRoot() string {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return DokiDir // Fallback to relative path
|
||||
}
|
||||
return filepath.Join(cwd, DokiDir)
|
||||
}
|
||||
|
||||
// Config holds all application configuration loaded from config.yaml
|
||||
type Config struct {
|
||||
// Logging configuration
|
||||
|
|
@ -59,25 +44,23 @@ type Config struct {
|
|||
|
||||
var appConfig *Config
|
||||
|
||||
// LoadConfig loads configuration from config.yaml in the binary's directory
|
||||
// LoadConfig loads configuration from config.yaml
|
||||
// Priority order (first found wins): project config → user config → current directory (dev)
|
||||
// If config.yaml doesn't exist, it uses default values
|
||||
func LoadConfig() (*Config, error) {
|
||||
// Reset viper to clear any previous configuration
|
||||
viper.Reset()
|
||||
|
||||
// Get the directory where the binary is located
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
slog.Error("failed to get executable path", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
binaryDir := filepath.Dir(exePath)
|
||||
|
||||
// Configure viper to look for config.yaml in the binary's directory
|
||||
// Configure viper to look for config.yaml
|
||||
// Viper uses first-found priority, so project config takes precedence
|
||||
viper.SetConfigName("config")
|
||||
viper.SetConfigType("yaml")
|
||||
viper.AddConfigPath(binaryDir)
|
||||
viper.AddConfigPath(".") // Also check current directory for development
|
||||
|
||||
// Add search paths in priority order (first added = highest priority)
|
||||
projectConfigDir := filepath.Dir(GetProjectConfigFile())
|
||||
viper.AddConfigPath(projectConfigDir) // Project config (highest priority)
|
||||
viper.AddConfigPath(GetConfigDir()) // User config
|
||||
viper.AddConfigPath(".") // Current directory (development)
|
||||
|
||||
// Set default values
|
||||
setDefaults()
|
||||
|
|
@ -85,7 +68,7 @@ func LoadConfig() (*Config, error) {
|
|||
// Read the config file (if it exists)
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
|
||||
slog.Debug("no config.yaml found, using defaults", "directory", binaryDir)
|
||||
slog.Debug("no config.yaml found, using defaults")
|
||||
} else {
|
||||
slog.Error("error reading config file", "error", err)
|
||||
return nil, err
|
||||
|
|
@ -269,13 +252,8 @@ func GetMaxPoints() int {
|
|||
func saveConfig() error {
|
||||
configFile := viper.ConfigFileUsed()
|
||||
if configFile == "" {
|
||||
// If no config file was loaded, determine where to save it
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
binaryDir := filepath.Dir(exePath)
|
||||
configFile = filepath.Join(binaryDir, "config.yaml")
|
||||
// If no config file was loaded, save to user config directory
|
||||
configFile = GetConfigFile()
|
||||
}
|
||||
|
||||
return viper.WriteConfigAs(configFile)
|
||||
|
|
|
|||
313
config/paths.go
Normal file
313
config/paths.go
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNoHome indicates that the user's home directory could not be determined
|
||||
ErrNoHome = errors.New("unable to determine home directory")
|
||||
|
||||
// ErrPathManagerInit indicates that the PathManager failed to initialize
|
||||
ErrPathManagerInit = errors.New("failed to initialize path manager")
|
||||
)
|
||||
|
||||
// PathManager manages all file system paths for tiki
|
||||
type PathManager struct {
|
||||
configDir string // User config directory
|
||||
cacheDir string // User cache directory
|
||||
projectRoot string // Current working directory
|
||||
}
|
||||
|
||||
// newPathManager creates and initializes a new PathManager
|
||||
func newPathManager() (*PathManager, error) {
|
||||
configDir, err := getUserConfigDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get config directory: %w", err)
|
||||
}
|
||||
|
||||
cacheDir, err := getUserCacheDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get cache directory: %w", err)
|
||||
}
|
||||
|
||||
projectRoot, err := getProjectRoot()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get project root: %w", err)
|
||||
}
|
||||
|
||||
return &PathManager{
|
||||
configDir: configDir,
|
||||
cacheDir: cacheDir,
|
||||
projectRoot: projectRoot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getUserConfigDir returns the platform-appropriate user config directory
|
||||
func getUserConfigDir() (string, error) {
|
||||
// Check XDG_CONFIG_HOME first (works on all platforms)
|
||||
if xdgConfig := os.Getenv("XDG_CONFIG_HOME"); xdgConfig != "" {
|
||||
return filepath.Join(xdgConfig, "tiki"), nil
|
||||
}
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", ErrNoHome
|
||||
}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
// macOS: prefer ~/.config/tiki if it exists, else ~/Library/Application Support/tiki
|
||||
// Note: We only check for existence here; directory creation happens in EnsureDirs()
|
||||
tikiConfigDir := filepath.Join(homeDir, ".config", "tiki")
|
||||
|
||||
// If ~/.config/tiki already exists, use it
|
||||
if info, err := os.Stat(tikiConfigDir); err == nil && info.IsDir() {
|
||||
return tikiConfigDir, nil
|
||||
}
|
||||
|
||||
// If ~/.config exists (even without tiki subdir), prefer XDG-style
|
||||
dotConfigDir := filepath.Join(homeDir, ".config")
|
||||
if info, err := os.Stat(dotConfigDir); err == nil && info.IsDir() {
|
||||
return tikiConfigDir, nil
|
||||
}
|
||||
|
||||
// Fall back to macOS native location
|
||||
return filepath.Join(homeDir, "Library", "Application Support", "tiki"), nil
|
||||
|
||||
case "windows":
|
||||
// Windows: %APPDATA%\tiki
|
||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||
return filepath.Join(appData, "tiki"), nil
|
||||
}
|
||||
return filepath.Join(homeDir, "AppData", "Roaming", "tiki"), nil
|
||||
|
||||
default:
|
||||
// Linux and other Unix-like: ~/.config/tiki
|
||||
return filepath.Join(homeDir, ".config", "tiki"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// getUserCacheDir returns the platform-appropriate user cache directory
|
||||
func getUserCacheDir() (string, error) {
|
||||
// Check XDG_CACHE_HOME first (works on all platforms)
|
||||
if xdgCache := os.Getenv("XDG_CACHE_HOME"); xdgCache != "" {
|
||||
return filepath.Join(xdgCache, "tiki"), nil
|
||||
}
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", ErrNoHome
|
||||
}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
// macOS: ~/Library/Caches/tiki
|
||||
return filepath.Join(homeDir, "Library", "Caches", "tiki"), nil
|
||||
|
||||
case "windows":
|
||||
// Windows: %LOCALAPPDATA%\tiki
|
||||
if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" {
|
||||
return filepath.Join(localAppData, "tiki"), nil
|
||||
}
|
||||
return filepath.Join(homeDir, "AppData", "Local", "tiki"), nil
|
||||
|
||||
default:
|
||||
// Linux and other Unix-like: ~/.cache/tiki
|
||||
return filepath.Join(homeDir, ".cache", "tiki"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// getProjectRoot returns the current working directory
|
||||
func getProjectRoot() (string, error) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get current directory: %w", err)
|
||||
}
|
||||
return cwd, nil
|
||||
}
|
||||
|
||||
// ConfigDir returns the user config directory
|
||||
func (pm *PathManager) ConfigDir() string {
|
||||
return pm.configDir
|
||||
}
|
||||
|
||||
// CacheDir returns the user cache directory
|
||||
func (pm *PathManager) CacheDir() string {
|
||||
return pm.cacheDir
|
||||
}
|
||||
|
||||
// ConfigFile returns the path to the user config file
|
||||
func (pm *PathManager) ConfigFile() string {
|
||||
return filepath.Join(pm.configDir, "config.yaml")
|
||||
}
|
||||
|
||||
// TaskDir returns the project-local task directory
|
||||
func (pm *PathManager) TaskDir() string {
|
||||
return filepath.Join(pm.projectRoot, ".doc", "tiki")
|
||||
}
|
||||
|
||||
// DokiDir returns the project-local documentation directory
|
||||
func (pm *PathManager) DokiDir() string {
|
||||
return filepath.Join(pm.projectRoot, ".doc", "doki")
|
||||
}
|
||||
|
||||
// ProjectConfigFile returns the path to the project-local config file
|
||||
func (pm *PathManager) ProjectConfigFile() string {
|
||||
return filepath.Join(pm.TaskDir(), "config.yaml")
|
||||
}
|
||||
|
||||
// PluginSearchPaths returns directories to search for plugin files
|
||||
// Search order: project config dir → user config dir
|
||||
func (pm *PathManager) PluginSearchPaths() []string {
|
||||
return []string{
|
||||
pm.TaskDir(), // Project config directory (for project-specific plugins)
|
||||
pm.configDir, // User config directory
|
||||
}
|
||||
}
|
||||
|
||||
// TemplateFile returns the path to the user's custom new.md template
|
||||
func (pm *PathManager) TemplateFile() string {
|
||||
return filepath.Join(pm.configDir, "new.md")
|
||||
}
|
||||
|
||||
// EnsureDirs creates all necessary directories with appropriate permissions
|
||||
func (pm *PathManager) EnsureDirs() error {
|
||||
// Create user config directory
|
||||
//nolint:gosec // G301: 0755 is appropriate for config directory
|
||||
if err := os.MkdirAll(pm.configDir, 0755); err != nil {
|
||||
return fmt.Errorf("create config directory %s: %w", pm.configDir, err)
|
||||
}
|
||||
|
||||
// Create user cache directory (non-fatal if it fails)
|
||||
//nolint:gosec // G301: 0755 is appropriate for cache directory
|
||||
_ = os.MkdirAll(pm.cacheDir, 0755)
|
||||
|
||||
// Create project directories
|
||||
//nolint:gosec // G301: 0755 is appropriate for task directory
|
||||
if err := os.MkdirAll(pm.TaskDir(), 0755); err != nil {
|
||||
return fmt.Errorf("create task directory %s: %w", pm.TaskDir(), err)
|
||||
}
|
||||
|
||||
//nolint:gosec // G301: 0755 is appropriate for doki directory
|
||||
if err := os.MkdirAll(pm.DokiDir(), 0755); err != nil {
|
||||
return fmt.Errorf("create doki directory %s: %w", pm.DokiDir(), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Package-level singleton with lazy initialization
|
||||
var (
|
||||
pathManager *PathManager
|
||||
pathManagerOnce sync.Once
|
||||
pathManagerErr error
|
||||
pathManagerMu sync.RWMutex // Protects pathManager for reset operations
|
||||
)
|
||||
|
||||
// getPathManager returns the global PathManager, initializing it on first call
|
||||
func getPathManager() (*PathManager, error) {
|
||||
pathManagerMu.RLock()
|
||||
if pathManager != nil {
|
||||
defer pathManagerMu.RUnlock()
|
||||
return pathManager, pathManagerErr
|
||||
}
|
||||
pathManagerMu.RUnlock()
|
||||
|
||||
pathManagerMu.Lock()
|
||||
defer pathManagerMu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if pathManager != nil {
|
||||
return pathManager, pathManagerErr
|
||||
}
|
||||
|
||||
pathManagerOnce.Do(func() {
|
||||
pathManager, pathManagerErr = newPathManager()
|
||||
})
|
||||
return pathManager, pathManagerErr
|
||||
}
|
||||
|
||||
// InitPaths initializes the path manager. Must be called early in application startup.
|
||||
// Returns an error if path initialization fails (e.g., cannot determine home directory).
|
||||
func InitPaths() error {
|
||||
_, err := getPathManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrPathManagerInit, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetPathManager resets the path manager singleton for testing purposes.
|
||||
// This allows tests to reinitialize paths with different environment variables.
|
||||
func ResetPathManager() {
|
||||
pathManagerMu.Lock()
|
||||
defer pathManagerMu.Unlock()
|
||||
pathManager = nil
|
||||
pathManagerErr = nil
|
||||
pathManagerOnce = sync.Once{}
|
||||
}
|
||||
|
||||
// mustGetPathManager returns the global PathManager or panics if not initialized.
|
||||
// Callers should ensure InitPaths() was called successfully before using accessor functions.
|
||||
func mustGetPathManager() *PathManager {
|
||||
pm, err := getPathManager()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("path manager not initialized: %v (call InitPaths() first)", err))
|
||||
}
|
||||
return pm
|
||||
}
|
||||
|
||||
// Exported accessor functions
|
||||
// Note: These functions panic if InitPaths() has not been called successfully.
|
||||
// The application should call InitPaths() early in main() and handle any error.
|
||||
|
||||
// GetConfigDir returns the user config directory
|
||||
func GetConfigDir() string {
|
||||
return mustGetPathManager().ConfigDir()
|
||||
}
|
||||
|
||||
// GetCacheDir returns the user cache directory
|
||||
func GetCacheDir() string {
|
||||
return mustGetPathManager().CacheDir()
|
||||
}
|
||||
|
||||
// GetConfigFile returns the path to the user config file
|
||||
func GetConfigFile() string {
|
||||
return mustGetPathManager().ConfigFile()
|
||||
}
|
||||
|
||||
// GetTaskDir returns the project-local task directory
|
||||
func GetTaskDir() string {
|
||||
return mustGetPathManager().TaskDir()
|
||||
}
|
||||
|
||||
// GetDokiDir returns the project-local documentation directory
|
||||
func GetDokiDir() string {
|
||||
return mustGetPathManager().DokiDir()
|
||||
}
|
||||
|
||||
// GetProjectConfigFile returns the path to the project-local config file
|
||||
func GetProjectConfigFile() string {
|
||||
return mustGetPathManager().ProjectConfigFile()
|
||||
}
|
||||
|
||||
// GetPluginSearchPaths returns directories to search for plugin files
|
||||
func GetPluginSearchPaths() []string {
|
||||
return mustGetPathManager().PluginSearchPaths()
|
||||
}
|
||||
|
||||
// GetTemplateFile returns the path to the user's custom new.md template
|
||||
func GetTemplateFile() string {
|
||||
return mustGetPathManager().TemplateFile()
|
||||
}
|
||||
|
||||
// EnsureDirs creates all necessary directories with appropriate permissions
|
||||
func EnsureDirs() error {
|
||||
return mustGetPathManager().EnsureDirs()
|
||||
}
|
||||
387
config/paths_test.go
Normal file
387
config/paths_test.go
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetUserConfigDir(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
xdgConfig string
|
||||
goos string
|
||||
expectXDG bool
|
||||
expectMacOS bool
|
||||
}{
|
||||
{
|
||||
name: "XDG_CONFIG_HOME set",
|
||||
xdgConfig: "/custom/config",
|
||||
expectXDG: true,
|
||||
},
|
||||
{
|
||||
name: "macOS without XDG",
|
||||
xdgConfig: "",
|
||||
goos: "darwin",
|
||||
expectMacOS: true,
|
||||
},
|
||||
{
|
||||
name: "Linux without XDG",
|
||||
xdgConfig: "",
|
||||
goos: "linux",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Save and restore environment
|
||||
origXDG := os.Getenv("XDG_CONFIG_HOME")
|
||||
defer func() {
|
||||
if origXDG != "" {
|
||||
_ = os.Setenv("XDG_CONFIG_HOME", origXDG)
|
||||
} else {
|
||||
_ = os.Unsetenv("XDG_CONFIG_HOME")
|
||||
}
|
||||
}()
|
||||
|
||||
if tt.xdgConfig != "" {
|
||||
_ = os.Setenv("XDG_CONFIG_HOME", tt.xdgConfig)
|
||||
} else {
|
||||
_ = os.Unsetenv("XDG_CONFIG_HOME")
|
||||
}
|
||||
|
||||
dir, err := getUserConfigDir()
|
||||
if err != nil {
|
||||
t.Fatalf("getUserConfigDir() error = %v", err)
|
||||
}
|
||||
|
||||
if tt.expectXDG {
|
||||
expected := filepath.Join(tt.xdgConfig, "tiki")
|
||||
if dir != expected {
|
||||
t.Errorf("getUserConfigDir() = %q, want %q", dir, expected)
|
||||
}
|
||||
} else if tt.expectMacOS && runtime.GOOS == "darwin" {
|
||||
// On macOS, should contain "Library/Application Support/tiki" or ".config/tiki"
|
||||
if !filepath.IsAbs(dir) {
|
||||
t.Errorf("getUserConfigDir() returned non-absolute path: %q", dir)
|
||||
}
|
||||
if filepath.Base(dir) != "tiki" {
|
||||
t.Errorf("getUserConfigDir() = %q, want basename 'tiki'", dir)
|
||||
}
|
||||
} else {
|
||||
// Should be absolute and end with /tiki
|
||||
if !filepath.IsAbs(dir) {
|
||||
t.Errorf("getUserConfigDir() returned non-absolute path: %q", dir)
|
||||
}
|
||||
if filepath.Base(dir) != "tiki" {
|
||||
t.Errorf("getUserConfigDir() = %q, want basename 'tiki'", dir)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserCacheDir(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
xdgCache string
|
||||
expectXDG bool
|
||||
}{
|
||||
{
|
||||
name: "XDG_CACHE_HOME set",
|
||||
xdgCache: "/custom/cache",
|
||||
expectXDG: true,
|
||||
},
|
||||
{
|
||||
name: "without XDG",
|
||||
xdgCache: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Save and restore environment
|
||||
origXDG := os.Getenv("XDG_CACHE_HOME")
|
||||
defer func() {
|
||||
if origXDG != "" {
|
||||
_ = os.Setenv("XDG_CACHE_HOME", origXDG)
|
||||
} else {
|
||||
_ = os.Unsetenv("XDG_CACHE_HOME")
|
||||
}
|
||||
}()
|
||||
|
||||
if tt.xdgCache != "" {
|
||||
_ = os.Setenv("XDG_CACHE_HOME", tt.xdgCache)
|
||||
} else {
|
||||
_ = os.Unsetenv("XDG_CACHE_HOME")
|
||||
}
|
||||
|
||||
dir, err := getUserCacheDir()
|
||||
if err != nil {
|
||||
t.Fatalf("getUserCacheDir() error = %v", err)
|
||||
}
|
||||
|
||||
if tt.expectXDG {
|
||||
expected := filepath.Join(tt.xdgCache, "tiki")
|
||||
if dir != expected {
|
||||
t.Errorf("getUserCacheDir() = %q, want %q", dir, expected)
|
||||
}
|
||||
} else {
|
||||
// Should be absolute and end with /tiki
|
||||
if !filepath.IsAbs(dir) {
|
||||
t.Errorf("getUserCacheDir() returned non-absolute path: %q", dir)
|
||||
}
|
||||
if filepath.Base(dir) != "tiki" {
|
||||
t.Errorf("getUserCacheDir() = %q, want basename 'tiki'", dir)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProjectRoot(t *testing.T) {
|
||||
root, err := getProjectRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("getProjectRoot() error = %v", err)
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(root) {
|
||||
t.Errorf("getProjectRoot() = %q, want absolute path", root)
|
||||
}
|
||||
|
||||
// Verify the directory exists
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
t.Errorf("getProjectRoot() returned path that doesn't exist: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathManagerPaths(t *testing.T) {
|
||||
pm, err := newPathManager()
|
||||
if err != nil {
|
||||
t.Fatalf("newPathManager() error = %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
getter func() string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ConfigDir",
|
||||
getter: pm.ConfigDir,
|
||||
},
|
||||
{
|
||||
name: "CacheDir",
|
||||
getter: pm.CacheDir,
|
||||
},
|
||||
{
|
||||
name: "ConfigFile",
|
||||
getter: pm.ConfigFile,
|
||||
},
|
||||
{
|
||||
name: "TaskDir",
|
||||
getter: pm.TaskDir,
|
||||
},
|
||||
{
|
||||
name: "DokiDir",
|
||||
getter: pm.DokiDir,
|
||||
},
|
||||
{
|
||||
name: "ProjectConfigFile",
|
||||
getter: pm.ProjectConfigFile,
|
||||
},
|
||||
{
|
||||
name: "TemplateFile",
|
||||
getter: pm.TemplateFile,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.getter()
|
||||
if result == "" {
|
||||
t.Errorf("%s() returned empty string", tt.name)
|
||||
}
|
||||
if !filepath.IsAbs(result) {
|
||||
t.Errorf("%s() = %q, want absolute path", tt.name, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathManagerPluginSearchPaths(t *testing.T) {
|
||||
pm, err := newPathManager()
|
||||
if err != nil {
|
||||
t.Fatalf("newPathManager() error = %v", err)
|
||||
}
|
||||
|
||||
paths := pm.PluginSearchPaths()
|
||||
if len(paths) != 2 {
|
||||
t.Errorf("PluginSearchPaths() returned %d paths, want 2", len(paths))
|
||||
}
|
||||
|
||||
// First should be project config dir (TaskDir)
|
||||
if paths[0] != pm.TaskDir() {
|
||||
t.Errorf("PluginSearchPaths()[0] = %q, want %q", paths[0], pm.TaskDir())
|
||||
}
|
||||
|
||||
// Second should be user config dir
|
||||
if paths[1] != pm.ConfigDir() {
|
||||
t.Errorf("PluginSearchPaths()[1] = %q, want %q", paths[1], pm.ConfigDir())
|
||||
}
|
||||
|
||||
// All paths should be absolute
|
||||
for i, path := range paths {
|
||||
if !filepath.IsAbs(path) {
|
||||
t.Errorf("PluginSearchPaths()[%d] = %q, want absolute path", i, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathManagerEnsureDirs(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create a PathManager with temporary paths
|
||||
pm := &PathManager{
|
||||
configDir: filepath.Join(tmpDir, "config"),
|
||||
cacheDir: filepath.Join(tmpDir, "cache"),
|
||||
projectRoot: tmpDir,
|
||||
}
|
||||
|
||||
// Call EnsureDirs
|
||||
if err := pm.EnsureDirs(); err != nil {
|
||||
t.Fatalf("EnsureDirs() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify directories were created
|
||||
dirs := []string{
|
||||
pm.ConfigDir(),
|
||||
pm.CacheDir(),
|
||||
pm.TaskDir(),
|
||||
pm.DokiDir(),
|
||||
}
|
||||
|
||||
for _, dir := range dirs {
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
t.Errorf("directory %q was not created: %v", dir, err)
|
||||
continue
|
||||
}
|
||||
if !info.IsDir() {
|
||||
t.Errorf("%q is not a directory", dir)
|
||||
}
|
||||
// Check permissions (should be 0755) - skip on Windows as it uses ACL-based permissions
|
||||
if runtime.GOOS != "windows" {
|
||||
if info.Mode().Perm() != 0755 {
|
||||
t.Errorf("directory %q has permissions %o, want 0755", dir, info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalAccessorFunctions(t *testing.T) {
|
||||
// Test that all global accessor functions return non-empty absolute paths
|
||||
tests := []struct {
|
||||
name string
|
||||
getter func() string
|
||||
}{
|
||||
{"GetConfigDir", GetConfigDir},
|
||||
{"GetCacheDir", GetCacheDir},
|
||||
{"GetConfigFile", GetConfigFile},
|
||||
{"GetTaskDir", GetTaskDir},
|
||||
{"GetDokiDir", GetDokiDir},
|
||||
{"GetProjectConfigFile", GetProjectConfigFile},
|
||||
{"GetTemplateFile", GetTemplateFile},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.getter()
|
||||
if result == "" {
|
||||
t.Errorf("%s() returned empty string", tt.name)
|
||||
}
|
||||
if !filepath.IsAbs(result) {
|
||||
t.Errorf("%s() = %q, want absolute path", tt.name, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPluginSearchPaths(t *testing.T) {
|
||||
paths := GetPluginSearchPaths()
|
||||
if len(paths) != 2 {
|
||||
t.Errorf("GetPluginSearchPaths() returned %d paths, want 2", len(paths))
|
||||
}
|
||||
|
||||
for i, path := range paths {
|
||||
if path == "" {
|
||||
t.Errorf("GetPluginSearchPaths()[%d] is empty", i)
|
||||
}
|
||||
if !filepath.IsAbs(path) {
|
||||
t.Errorf("GetPluginSearchPaths()[%d] = %q, want absolute path", i, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitPaths(t *testing.T) {
|
||||
// Reset to test initialization
|
||||
ResetPathManager()
|
||||
defer ResetPathManager() // Clean up after test
|
||||
|
||||
err := InitPaths()
|
||||
if err != nil {
|
||||
t.Fatalf("InitPaths() error = %v", err)
|
||||
}
|
||||
|
||||
// After InitPaths, all accessors should work
|
||||
if GetConfigDir() == "" {
|
||||
t.Error("GetConfigDir() returned empty after InitPaths()")
|
||||
}
|
||||
if GetTaskDir() == "" {
|
||||
t.Error("GetTaskDir() returned empty after InitPaths()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPathManager(t *testing.T) {
|
||||
// Save original XDG
|
||||
origXDG := os.Getenv("XDG_CONFIG_HOME")
|
||||
defer func() {
|
||||
if origXDG != "" {
|
||||
_ = os.Setenv("XDG_CONFIG_HOME", origXDG)
|
||||
} else {
|
||||
_ = os.Unsetenv("XDG_CONFIG_HOME")
|
||||
}
|
||||
ResetPathManager() // Clean up
|
||||
}()
|
||||
|
||||
// First initialization
|
||||
ResetPathManager()
|
||||
_ = os.Setenv("XDG_CONFIG_HOME", "/first/config")
|
||||
if err := InitPaths(); err != nil {
|
||||
t.Fatalf("first InitPaths() error = %v", err)
|
||||
}
|
||||
first := GetConfigDir()
|
||||
expected1 := filepath.Join("/first/config", "tiki")
|
||||
if first != expected1 {
|
||||
t.Errorf("first GetConfigDir() = %q, want %q", first, expected1)
|
||||
}
|
||||
|
||||
// Reset and reinitialize with different env
|
||||
ResetPathManager()
|
||||
_ = os.Setenv("XDG_CONFIG_HOME", "/second/config")
|
||||
if err := InitPaths(); err != nil {
|
||||
t.Fatalf("second InitPaths() error = %v", err)
|
||||
}
|
||||
second := GetConfigDir()
|
||||
expected2 := filepath.Join("/second/config", "tiki")
|
||||
if second != expected2 {
|
||||
t.Errorf("second GetConfigDir() = %q, want %q", second, expected2)
|
||||
}
|
||||
|
||||
// Verify they're different (reset worked)
|
||||
if first == second {
|
||||
t.Error("ResetPathManager() did not allow re-initialization with different config")
|
||||
}
|
||||
}
|
||||
|
|
@ -37,16 +37,16 @@ func GenerateRandomID() string {
|
|||
|
||||
// BootstrapSystem creates the task storage and seeds the initial tiki.
|
||||
func BootstrapSystem() error {
|
||||
//nolint:gosec // G301: 0755 is appropriate for task directory
|
||||
if err := os.MkdirAll(TaskDir, 0755); err != nil {
|
||||
return fmt.Errorf("create task directory: %w", err)
|
||||
// Create all necessary directories
|
||||
if err := EnsureDirs(); err != nil {
|
||||
return fmt.Errorf("ensure directories: %w", err)
|
||||
}
|
||||
|
||||
// Generate random ID for initial task
|
||||
randomID := GenerateRandomID()
|
||||
taskID := fmt.Sprintf("TIKI-%s", randomID)
|
||||
taskFilename := fmt.Sprintf("tiki-%s.md", randomID)
|
||||
taskPath := filepath.Join(TaskDir, taskFilename)
|
||||
taskPath := filepath.Join(GetTaskDir(), taskFilename)
|
||||
|
||||
// Replace placeholder in template
|
||||
taskContent := strings.Replace(initialTaskTemplate, "TIKI-XXXXXX", taskID, 1)
|
||||
|
|
@ -54,13 +54,8 @@ func BootstrapSystem() error {
|
|||
return fmt.Errorf("write initial task: %w", err)
|
||||
}
|
||||
|
||||
// Create doki directory and documentation files
|
||||
dokiDir := filepath.Join(".doc", "doki")
|
||||
//nolint:gosec // G301: 0755 is appropriate for doki documentation directory
|
||||
if err := os.MkdirAll(dokiDir, 0755); err != nil {
|
||||
return fmt.Errorf("create doki directory: %w", err)
|
||||
}
|
||||
|
||||
// Write doki documentation files
|
||||
dokiDir := GetDokiDir()
|
||||
indexPath := filepath.Join(dokiDir, "index.md")
|
||||
if err := os.WriteFile(indexPath, []byte(dokiEntryPoint), 0644); err != nil {
|
||||
return fmt.Errorf("write doki index: %w", err)
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ func (tc *TaskController) handleEditSource() bool {
|
|||
|
||||
// Construct the file path for this task
|
||||
filename := strings.ToLower(task.ID) + ".md"
|
||||
filePath := filepath.Join(config.TaskDir, filename)
|
||||
filePath := filepath.Join(config.GetTaskDir(), filename)
|
||||
|
||||
// Suspend the tview app and open the editor
|
||||
tc.navController.SuspendAndEdit(filePath)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"fmt"
|
||||
|
||||
"github.com/rivo/tview"
|
||||
|
||||
|
|
@ -14,11 +13,12 @@ func NewApp() *tview.Application {
|
|||
return tview.NewApplication()
|
||||
}
|
||||
|
||||
// Run runs the tview application or terminates the process if it errors.
|
||||
func Run(app *tview.Application, rootLayout *view.RootLayout) {
|
||||
// Run runs the tview application.
|
||||
// Returns an error if the application fails to run.
|
||||
func Run(app *tview.Application, rootLayout *view.RootLayout) error {
|
||||
app.SetRoot(rootLayout.GetPrimitive(), true).EnableMouse(false)
|
||||
if err := app.Run(); err != nil {
|
||||
slog.Error("application error", "error", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("run application: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ func StartBurndownHistoryBuilder(
|
|||
return
|
||||
}
|
||||
|
||||
history := store.NewTaskHistory(config.TaskDir, gitUtil)
|
||||
history := store.NewTaskHistory(config.GetTaskDir(), gitUtil)
|
||||
if history == nil {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
package bootstrap
|
||||
|
||||
import (
|
||||
"log"
|
||||
"fmt"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
)
|
||||
|
||||
// LoadConfigOrExit loads the application configuration.
|
||||
// If configuration loading fails, it logs a fatal error and exits.
|
||||
func LoadConfigOrExit() *config.Config {
|
||||
// LoadConfig loads the application configuration.
|
||||
// Returns an error if configuration loading fails.
|
||||
func LoadConfig() (*config.Config, error) {
|
||||
cfg, err := config.LoadConfig()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load configuration: %v", err)
|
||||
return nil, fmt.Errorf("load configuration: %w", err)
|
||||
}
|
||||
return cfg
|
||||
return cfg, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,19 @@
|
|||
package bootstrap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"errors"
|
||||
|
||||
"github.com/boolean-maybe/tiki/store/tikistore"
|
||||
)
|
||||
|
||||
// EnsureGitRepoOrExit validates that the current directory is a git repository.
|
||||
// If not, it prints an error message and exits the program.
|
||||
func EnsureGitRepoOrExit() {
|
||||
// ErrNotGitRepo indicates the current directory is not a git repository
|
||||
var ErrNotGitRepo = errors.New("not a git repository")
|
||||
|
||||
// EnsureGitRepo validates that the current directory is a git repository.
|
||||
// Returns ErrNotGitRepo if the current directory is not a git repository.
|
||||
func EnsureGitRepo() error {
|
||||
if tikistore.IsGitRepo("") {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
_, err := fmt.Fprintln(os.Stderr, "Not a git repository")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
os.Exit(1)
|
||||
return ErrNotGitRepo
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,20 +45,31 @@ type BootstrapResult struct {
|
|||
// It takes the embedded AI skill content and returns all initialized components.
|
||||
func Bootstrap(tikiSkillContent, dokiSkillContent string) (*BootstrapResult, error) {
|
||||
// Phase 1: Pre-flight checks
|
||||
EnsureGitRepoOrExit()
|
||||
if err := EnsureGitRepo(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Phase 2: Configuration and logging
|
||||
cfg := LoadConfigOrExit()
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logLevel := InitLogging(cfg)
|
||||
|
||||
// Phase 3: Project initialization
|
||||
proceed := EnsureProjectInitialized(tikiSkillContent, dokiSkillContent)
|
||||
proceed, err := EnsureProjectInitialized(tikiSkillContent, dokiSkillContent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !proceed {
|
||||
return nil, nil // User chose not to proceed
|
||||
}
|
||||
|
||||
// Phase 4: Store initialization
|
||||
tikiStore, taskStore := InitStores()
|
||||
tikiStore, taskStore, err := InitStores()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Phase 5: Model initialization
|
||||
headerConfig, layoutModel := InitHeaderAndLayoutModels()
|
||||
|
|
|
|||
|
|
@ -1,20 +1,18 @@
|
|||
package bootstrap
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"fmt"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
)
|
||||
|
||||
// EnsureProjectInitialized ensures the project is properly initialized.
|
||||
// It takes the embedded skill content for tiki and doki and returns whether to proceed.
|
||||
// If initialization fails, it logs an error and exits.
|
||||
func EnsureProjectInitialized(tikiSkillContent, dokiSkillContent string) (proceed bool) {
|
||||
// It takes the embedded skill content for tiki and doki.
|
||||
// Returns (proceed, error) where proceed indicates if the user wants to continue.
|
||||
func EnsureProjectInitialized(tikiSkillContent, dokiSkillContent string) (bool, error) {
|
||||
proceed, err := config.EnsureProjectInitialized(tikiSkillContent, dokiSkillContent)
|
||||
if err != nil {
|
||||
slog.Error("failed to initialize project", "error", err)
|
||||
os.Exit(1)
|
||||
return false, fmt.Errorf("initialize project: %w", err)
|
||||
}
|
||||
return proceed
|
||||
return proceed, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,19 @@
|
|||
package bootstrap
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"fmt"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/store/tikistore"
|
||||
)
|
||||
|
||||
// InitStores initializes the task stores or terminates the process on failure.
|
||||
// Returns the tikiStore and a generic store interface reference to it.
|
||||
func InitStores() (*tikistore.TikiStore, store.Store) {
|
||||
tikiStore, err := tikistore.NewTikiStore(config.TaskDir)
|
||||
// InitStores initializes the task stores.
|
||||
// Returns the tikiStore, a generic store interface, and any error.
|
||||
func InitStores() (*tikistore.TikiStore, store.Store, error) {
|
||||
tikiStore, err := tikistore.NewTikiStore(config.GetTaskDir())
|
||||
if err != nil {
|
||||
slog.Error("failed to initialize task store", "error", err)
|
||||
os.Exit(1)
|
||||
return nil, nil, fmt.Errorf("initialize task store: %w", err)
|
||||
}
|
||||
return tikiStore, tikiStore
|
||||
return tikiStore, tikiStore, nil
|
||||
}
|
||||
|
|
|
|||
14
main.go
14
main.go
|
|
@ -28,6 +28,12 @@ func main() {
|
|||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Initialize paths early - this must succeed for the application to function
|
||||
if err := config.InitPaths(); err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Handle viewer mode (standalone markdown viewer)
|
||||
viewerInput, runViewer, err := viewer.ParseViewerInput(os.Args[1:], map[string]struct{}{})
|
||||
if err != nil {
|
||||
|
|
@ -49,7 +55,8 @@ func main() {
|
|||
// Bootstrap application
|
||||
result, err := bootstrap.Bootstrap(tikiSkillMdContent, dokiSkillMdContent)
|
||||
if err != nil {
|
||||
return
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if result == nil {
|
||||
// User chose not to proceed with project initialization
|
||||
|
|
@ -63,7 +70,10 @@ func main() {
|
|||
defer result.CancelFunc()
|
||||
|
||||
// Run application
|
||||
app.Run(result.App, result.RootLayout)
|
||||
if err := app.Run(result.App, result.RootLayout); err != nil {
|
||||
slog.Error("application error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Save user preferences on shutdown
|
||||
if err := config.SaveHeaderVisible(result.HeaderConfig.GetUserPreference()); err != nil {
|
||||
|
|
|
|||
|
|
@ -3,18 +3,35 @@ package plugin
|
|||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
)
|
||||
|
||||
// findPluginFile searches for the plugin file in various locations
|
||||
func findPluginFile(filename string, baseDir string) string {
|
||||
// List of directories to search
|
||||
searchPaths := []string{
|
||||
filename, // As provided (absolute or relative)
|
||||
filepath.Join(".", filename), // Current directory
|
||||
filepath.Join(baseDir, filename), // Binary directory
|
||||
// Search order: absolute path → project config dir → user config dir
|
||||
func findPluginFile(filename string) string {
|
||||
// If filename is absolute, try it directly
|
||||
if filepath.IsAbs(filename) {
|
||||
if _, err := os.Stat(filename); err == nil {
|
||||
return filename
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, path := range searchPaths {
|
||||
// Get search paths from PathManager
|
||||
// Search order: project config dir → user config dir
|
||||
searchPaths := config.GetPluginSearchPaths()
|
||||
|
||||
// Build full list of paths to check
|
||||
var paths []string
|
||||
paths = append(paths, filename) // Try as-is first (relative to cwd)
|
||||
|
||||
for _, dir := range searchPaths {
|
||||
paths = append(paths, filepath.Join(dir, filename))
|
||||
}
|
||||
|
||||
// Search for the file
|
||||
for _, path := range paths {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,35 +34,24 @@ func TestFindPluginFile(t *testing.T) {
|
|||
tests := []struct {
|
||||
name string
|
||||
filename string
|
||||
baseDir string
|
||||
wantPath string
|
||||
wantFound bool
|
||||
}{
|
||||
{
|
||||
name: "absolute path",
|
||||
filename: testFilePath,
|
||||
baseDir: "",
|
||||
wantPath: testFilePath,
|
||||
wantFound: true,
|
||||
},
|
||||
{
|
||||
name: "relative path in current dir",
|
||||
filename: testFile,
|
||||
baseDir: "",
|
||||
wantPath: testFile,
|
||||
wantFound: true,
|
||||
},
|
||||
{
|
||||
name: "file in base directory",
|
||||
filename: testFile,
|
||||
baseDir: currentDir,
|
||||
wantPath: testFile,
|
||||
wantFound: true,
|
||||
},
|
||||
{
|
||||
name: "non-existent file",
|
||||
filename: "nonexistent.yaml",
|
||||
baseDir: currentDir,
|
||||
wantPath: "",
|
||||
wantFound: false,
|
||||
},
|
||||
|
|
@ -70,12 +59,12 @@ func TestFindPluginFile(t *testing.T) {
|
|||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := findPluginFile(tt.filename, tt.baseDir)
|
||||
got := findPluginFile(tt.filename)
|
||||
|
||||
if tt.wantFound {
|
||||
if got == "" {
|
||||
t.Errorf("findPluginFile(%q, %q) = empty, want non-empty path",
|
||||
tt.filename, tt.baseDir)
|
||||
t.Errorf("findPluginFile(%q) = empty, want non-empty path",
|
||||
tt.filename)
|
||||
}
|
||||
// Verify the file exists at the returned path
|
||||
if _, err := os.Stat(got); err != nil {
|
||||
|
|
@ -84,8 +73,8 @@ func TestFindPluginFile(t *testing.T) {
|
|||
}
|
||||
} else {
|
||||
if got != "" {
|
||||
t.Errorf("findPluginFile(%q, %q) = %q, want empty string",
|
||||
tt.filename, tt.baseDir, got)
|
||||
t.Errorf("findPluginFile(%q) = %q, want empty string",
|
||||
tt.filename, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -125,8 +114,8 @@ func TestFindPluginFile_SearchOrder(t *testing.T) {
|
|||
t.Fatalf("Failed to change to temp directory: %v", err)
|
||||
}
|
||||
|
||||
// Test that current directory is preferred over base directory
|
||||
got := findPluginFile(testFile, subDir)
|
||||
// Test that current directory is preferred in search order
|
||||
got := findPluginFile(testFile)
|
||||
if got == "" {
|
||||
t.Fatal("findPluginFile returned empty path")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import (
|
|||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
|
@ -24,12 +23,6 @@ func loadConfiguredPlugins() []Plugin {
|
|||
return nil // no plugins configured
|
||||
}
|
||||
|
||||
// Determine base directory (where binary is, or current dir for development)
|
||||
baseDir := ""
|
||||
if exePath, err := os.Executable(); err == nil {
|
||||
baseDir = filepath.Dir(exePath)
|
||||
}
|
||||
|
||||
var plugins []Plugin
|
||||
|
||||
for i, ref := range refs {
|
||||
|
|
@ -39,7 +32,7 @@ func loadConfiguredPlugins() []Plugin {
|
|||
continue
|
||||
}
|
||||
|
||||
plugin, err := loadPluginFromRef(ref, baseDir)
|
||||
plugin, err := loadPluginFromRef(ref)
|
||||
if err != nil {
|
||||
slog.Warn("failed to load plugin", "name", ref.Name, "file", ref.File, "error", err)
|
||||
continue // Skip failed plugins, continue with others
|
||||
|
|
@ -110,13 +103,13 @@ func LoadPlugins() ([]Plugin, error) {
|
|||
// 1. Fully inline (no file): all fields in config.yaml
|
||||
// 2. File-based (file only): reference external YAML
|
||||
// 3. Hybrid (file + overrides): file provides base, inline overrides
|
||||
func loadPluginFromRef(ref PluginRef, baseDir string) (Plugin, error) {
|
||||
func loadPluginFromRef(ref PluginRef) (Plugin, error) {
|
||||
var cfg pluginFileConfig
|
||||
var source string
|
||||
|
||||
if ref.File != "" {
|
||||
// File-based or hybrid mode
|
||||
pluginPath := findPluginFile(ref.File, baseDir)
|
||||
pluginPath := findPluginFile(ref.File)
|
||||
if pluginPath == "" {
|
||||
return nil, fmt.Errorf("plugin file not found: %s", ref.File)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ func TestLoadPluginFromRef_FullyInline(t *testing.T) {
|
|||
View: "expanded",
|
||||
}
|
||||
|
||||
def, err := loadPluginFromRef(ref, "")
|
||||
def, err := loadPluginFromRef(ref)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
|
@ -71,7 +71,7 @@ func TestLoadPluginFromRef_InlineMinimal(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
def, err := loadPluginFromRef(ref, "")
|
||||
def, err := loadPluginFromRef(ref)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
|
@ -109,10 +109,10 @@ view: compact
|
|||
}
|
||||
|
||||
ref := PluginRef{
|
||||
File: "test-plugin.yaml",
|
||||
File: pluginFile, // Use absolute path
|
||||
}
|
||||
|
||||
def, err := loadPluginFromRef(ref, tmpDir)
|
||||
def, err := loadPluginFromRef(ref)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
|
@ -155,12 +155,12 @@ view: compact
|
|||
|
||||
// Override view and key
|
||||
ref := PluginRef{
|
||||
File: "base-plugin.yaml",
|
||||
File: pluginFile, // Use absolute path
|
||||
View: "expanded",
|
||||
Key: "H",
|
||||
}
|
||||
|
||||
def, err := loadPluginFromRef(ref, tmpDir)
|
||||
def, err := loadPluginFromRef(ref)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
|
@ -209,7 +209,7 @@ view: compact
|
|||
|
||||
// Override multiple fields
|
||||
ref := PluginRef{
|
||||
File: "multi-plugin.yaml",
|
||||
File: pluginFile, // Use absolute path
|
||||
Key: "X",
|
||||
Panes: []PluginPaneConfig{
|
||||
{Name: "In Progress", Filter: "status = 'in_progress'"},
|
||||
|
|
@ -219,7 +219,7 @@ view: compact
|
|||
Foreground: "#00ff00",
|
||||
}
|
||||
|
||||
def, err := loadPluginFromRef(ref, tmpDir)
|
||||
def, err := loadPluginFromRef(ref)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
|
@ -264,7 +264,7 @@ func TestLoadPluginFromRef_MissingFile(t *testing.T) {
|
|||
File: "nonexistent.yaml",
|
||||
}
|
||||
|
||||
_, err := loadPluginFromRef(ref, "")
|
||||
_, err := loadPluginFromRef(ref)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing file")
|
||||
}
|
||||
|
|
@ -282,7 +282,7 @@ func TestLoadPluginFromRef_NoName(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
_, err := loadPluginFromRef(ref, "")
|
||||
_, err := loadPluginFromRef(ref)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for plugin without name")
|
||||
}
|
||||
|
|
@ -303,7 +303,7 @@ func TestPluginTypeExplicit(t *testing.T) {
|
|||
Text: "some text",
|
||||
}
|
||||
|
||||
def, err := loadPluginFromRef(ref, "")
|
||||
def, err := loadPluginFromRef(ref)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
|
@ -329,10 +329,10 @@ url: http://example.com/resource
|
|||
}
|
||||
|
||||
refFile := PluginRef{
|
||||
File: "type-doki.yaml",
|
||||
File: pluginFile, // Use absolute path
|
||||
}
|
||||
|
||||
defFile, err := loadPluginFromRef(refFile, tmpDir)
|
||||
defFile, err := loadPluginFromRef(refFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
|
@ -358,7 +358,7 @@ type: tiki
|
|||
}
|
||||
|
||||
ref := PluginRef{
|
||||
File: "type-override.yaml",
|
||||
File: pluginFile, // Use absolute path
|
||||
Type: "doki",
|
||||
Fetcher: "internal",
|
||||
Text: "override text",
|
||||
|
|
@ -369,7 +369,7 @@ type: tiki
|
|||
// parsePluginConfig then creates the struct.
|
||||
// So this test checks mergePluginConfigs logic + parsing logic.
|
||||
|
||||
def, err := loadPluginFromRef(ref, tmpDir)
|
||||
def, err := loadPluginFromRef(ref)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
|
@ -382,7 +382,3 @@ type: tiki
|
|||
t.Errorf("Expected DokiPlugin type assertion to succeed")
|
||||
}
|
||||
}
|
||||
|
||||
// Tests for parser functions moved to parser_test.go
|
||||
// Tests for key parsing moved to keyparser_test.go
|
||||
// Tests for merger functions moved to merger_test.go
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ func TestDokiValidation(t *testing.T) {
|
|||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := loadPluginFromRef(tc.ref, "")
|
||||
_, err := loadPluginFromRef(tc.ref)
|
||||
if tc.wantError != "" {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error containing '%s', got nil", tc.wantError)
|
||||
|
|
@ -127,7 +127,7 @@ func TestTikiValidation(t *testing.T) {
|
|||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := loadPluginFromRef(tc.ref, "")
|
||||
_, err := loadPluginFromRef(tc.ref)
|
||||
if tc.wantError != "" {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error containing '%s', got nil", tc.wantError)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import (
|
|||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -25,22 +24,15 @@ type templateFrontmatter struct {
|
|||
Points int `yaml:"points"`
|
||||
}
|
||||
|
||||
// loadTemplateTask reads new.md next to the executable, or falls back to embedded template.
|
||||
// loadTemplateTask reads new.md from user config directory, or falls back to embedded template.
|
||||
func loadTemplateTask() *taskpkg.Task {
|
||||
// Try to load from binary directory first
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
slog.Warn("failed to get executable path for template", "error", err)
|
||||
return loadEmbeddedTemplate()
|
||||
}
|
||||
|
||||
binaryDir := filepath.Dir(exePath)
|
||||
templatePath := filepath.Join(binaryDir, "new.md")
|
||||
// Try to load from user config directory first
|
||||
templatePath := config.GetTemplateFile()
|
||||
|
||||
data, err := os.ReadFile(templatePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
slog.Debug("new.md not found in binary dir, using embedded template")
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ func (dv *DokiView) build() {
|
|||
|
||||
switch dv.pluginDef.Fetcher {
|
||||
case "file":
|
||||
searchRoots := []string{config.GetDokiRoot()}
|
||||
searchRoots := []string{config.GetDokiDir()}
|
||||
provider := &loaders.FileHTTP{SearchRoots: searchRoots}
|
||||
|
||||
// Fetch initial content (no source context yet; rely on searchRoots)
|
||||
|
|
@ -149,7 +149,7 @@ func (dv *DokiView) build() {
|
|||
// Display initial content with source context (don't push to history - this is the first page)
|
||||
if dv.pluginDef.Fetcher == "file" {
|
||||
// Try to resolve the initial URL so subsequent relative navigation has a stable source path.
|
||||
sourcePath, rerr := nav.ResolveMarkdownPath(dv.pluginDef.URL, "", []string{config.GetDokiRoot()})
|
||||
sourcePath, rerr := nav.ResolveMarkdownPath(dv.pluginDef.URL, "", []string{config.GetDokiDir()})
|
||||
if rerr != nil || sourcePath == "" {
|
||||
sourcePath = dv.pluginDef.URL
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue