tiki/config/paths.go
2026-04-15 21:01:00 -04:00

451 lines
13 KiB
Go

package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"sync"
"gopkg.in/yaml.v3"
)
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")
}
// ProjectConfigDir returns the project-level config directory (.doc/)
func (pm *PathManager) ProjectConfigDir() string {
return filepath.Join(pm.projectRoot, ".doc")
}
// ProjectConfigFile returns the path to the project-local config file
func (pm *PathManager) ProjectConfigFile() string {
return filepath.Join(pm.ProjectConfigDir(), "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.ProjectConfigDir(), // Project config directory (for project-specific plugins)
pm.configDir, // User config directory
}
}
// UserConfigWorkflowFile returns the path to workflow.yaml in the user config directory
func (pm *PathManager) UserConfigWorkflowFile() string {
return filepath.Join(pm.configDir, defaultWorkflowFilename)
}
// 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()
}
// GetProjectConfigDir returns the project-level config directory (.doc/)
func GetProjectConfigDir() string {
return mustGetPathManager().ProjectConfigDir()
}
// 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()
}
// GetUserConfigWorkflowFile returns the path to workflow.yaml in the user config directory
func GetUserConfigWorkflowFile() string {
return mustGetPathManager().UserConfigWorkflowFile()
}
// configFilename is the default name for the configuration file
const configFilename = "config.yaml"
// 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.
func FindWorkflowFiles() []string {
pm := mustGetPathManager()
// Candidate paths in discovery order: user config (base) → project config → cwd
candidates := []string{
pm.UserConfigWorkflowFile(),
filepath.Join(pm.ProjectConfigDir(), defaultWorkflowFilename),
defaultWorkflowFilename, // relative to cwd
}
var result []string
seen := make(map[string]bool)
for _, path := range candidates {
// Resolve to absolute for dedup
abs, err := filepath.Abs(path)
if err != nil {
abs = path
}
if seen[abs] {
continue
}
if _, err := os.Stat(path); err != nil {
continue
}
// Check if the file has non-empty views
if hasEmptyViews(path) {
continue
}
seen[abs] = true
result = append(result, path)
}
return result
}
// hasEmptyViews returns true if the workflow file has an explicit empty views section.
// Handles both old list format (views: []) and new map format (views: {plugins: []}).
func hasEmptyViews(path string) bool {
data, err := os.ReadFile(path)
if err != nil {
return false
}
var raw struct {
Views interface{} `yaml:"views"`
}
if err := yaml.Unmarshal(data, &raw); err != nil {
return false
}
switch v := raw.Views.(type) {
case []interface{}:
return len(v) == 0
case map[string]interface{}:
plugins, _ := v["plugins"].([]interface{})
return plugins != nil && len(plugins) == 0
default:
return false
}
}
// FindWorkflowFile searches for workflow.yaml in config search paths.
// Returns the first found path with non-empty views, or empty string if not found.
// Convenience wrapper over FindWorkflowFiles for code that needs a single path.
func FindWorkflowFile() string {
files := FindWorkflowFiles()
if len(files) == 0 {
return ""
}
return files[0]
}
// 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
func EnsureDirs() error {
return mustGetPathManager().EnsureDirs()
}