// Package workspace provides workspace detection and management. package workspace import ( "errors" "fmt" "os" "path/filepath" ) // ErrNotFound indicates no workspace was found. var ErrNotFound = errors.New("not in a Gas Town workspace") // Markers used to detect a Gas Town workspace. const ( // PrimaryMarker is the main config file that identifies a workspace. PrimaryMarker = "config/town.json" // AlternativePrimaryMarker is the town-level mayor config file. // This distinguishes a town mayor from a rig-level mayor clone. AlternativePrimaryMarker = "mayor/config.json" // SecondaryMarker is an alternative indicator at the town level. // Note: This can match rig-level mayors too, so we continue searching // upward after finding this to look for primary markers. SecondaryMarker = "mayor" ) // Find locates the town root by walking up from the given directory. // It looks for config/town.json or mayor/config.json (primary markers) // or mayor/ directory (secondary marker). // // To avoid matching rig-level mayor directories, we continue searching // upward after finding a secondary marker, preferring primary matches. func Find(startDir string) (string, error) { // Resolve to absolute path and follow symlinks absDir, err := filepath.Abs(startDir) if err != nil { return "", fmt.Errorf("resolving path: %w", err) } absDir, err = filepath.EvalSymlinks(absDir) if err != nil { return "", fmt.Errorf("evaluating symlinks: %w", err) } // Track the first secondary match in case no primary is found var secondaryMatch string // Walk up the directory tree current := absDir for { // Check for primary marker (config/town.json) primaryPath := filepath.Join(current, PrimaryMarker) if _, err := os.Stat(primaryPath); err == nil { return current, nil } // Check for alternative primary marker (mayor/config.json) // This distinguishes a town-level mayor from a rig-level mayor clone altPrimaryPath := filepath.Join(current, AlternativePrimaryMarker) if _, err := os.Stat(altPrimaryPath); err == nil { return current, nil } // Check for secondary marker (mayor/ directory) // Don't return immediately - continue searching for primary markers if secondaryMatch == "" { secondaryPath := filepath.Join(current, SecondaryMarker) info, err := os.Stat(secondaryPath) if err == nil && info.IsDir() { secondaryMatch = current } } // Move to parent directory parent := filepath.Dir(current) if parent == current { // Reached filesystem root - return secondary match if found return secondaryMatch, nil } current = parent } } // FindOrError is like Find but returns a user-friendly error if not found. func FindOrError(startDir string) (string, error) { root, err := Find(startDir) if err != nil { return "", err } if root == "" { return "", ErrNotFound } return root, nil } // FindFromCwd locates the town root from the current working directory. func FindFromCwd() (string, error) { cwd, err := os.Getwd() if err != nil { return "", fmt.Errorf("getting current directory: %w", err) } return Find(cwd) } // FindFromCwdOrError is like FindFromCwd but returns an error if not found. func FindFromCwdOrError() (string, error) { cwd, err := os.Getwd() if err != nil { return "", fmt.Errorf("getting current directory: %w", err) } return FindOrError(cwd) } // IsWorkspace checks if the given directory is a Gas Town workspace root. // A directory is a workspace if it has primary markers (config/town.json // or mayor/config.json) or a secondary marker (mayor/ directory). func IsWorkspace(dir string) (bool, error) { absDir, err := filepath.Abs(dir) if err != nil { return false, fmt.Errorf("resolving path: %w", err) } // Check for primary marker primaryPath := filepath.Join(absDir, PrimaryMarker) if _, err := os.Stat(primaryPath); err == nil { return true, nil } // Check for alternative primary marker altPrimaryPath := filepath.Join(absDir, AlternativePrimaryMarker) if _, err := os.Stat(altPrimaryPath); err == nil { return true, nil } // Check for secondary marker secondaryPath := filepath.Join(absDir, SecondaryMarker) info, err := os.Stat(secondaryPath) if err == nil && info.IsDir() { return true, nil } return false, nil }