feat(output): implement configurable color and emoji output

Add new output formatting system with flags for color and emoji control:
- Introduce OutputConfig and Writer structs for flexible output handling
- Add --colors and --emoji/--no-emoji global flags
- Refactor commands to use new Writer for consistent formatting
- Separate error content from presentation for better flexibility
This commit is contained in:
Yar Kravtsov
2025-08-03 14:33:44 +03:00
parent 57839c795e
commit 7f10e1ce8a
19 changed files with 1269 additions and 237 deletions

84
internal/core/errors.go Normal file
View File

@@ -0,0 +1,84 @@
package core
import "fmt"
// LnkError represents a structured error with separate content and formatting hints
type LnkError struct {
Message string
Suggestion string
Path string
ErrorType string
}
func (e *LnkError) Error() string {
if e.Suggestion != "" {
return fmt.Sprintf("%s\n %s", e.Message, e.Suggestion)
}
return e.Message
}
// Error constructors that separate content from presentation
func ErrDirectoryContainsManagedFiles(path string) error {
return &LnkError{
Message: fmt.Sprintf("Directory %s already contains managed files", path),
Suggestion: "Use 'lnk pull' to update from remote instead of 'lnk init -r'",
Path: path,
ErrorType: "managed_files_exist",
}
}
func ErrDirectoryContainsGitRepo(path string) error {
return &LnkError{
Message: fmt.Sprintf("Directory %s contains an existing Git repository", path),
Suggestion: "Please backup or move the existing repository before initializing lnk",
Path: path,
ErrorType: "git_repo_exists",
}
}
func ErrFileAlreadyManaged(path string) error {
return &LnkError{
Message: fmt.Sprintf("File is already managed by lnk: %s", path),
Path: path,
ErrorType: "already_managed",
}
}
func ErrFileNotManaged(path string) error {
return &LnkError{
Message: fmt.Sprintf("File is not managed by lnk: %s", path),
Path: path,
ErrorType: "not_managed",
}
}
func ErrRepositoryNotInitialized() error {
return &LnkError{
Message: "Lnk repository not initialized",
Suggestion: "Run 'lnk init' first",
ErrorType: "not_initialized",
}
}
func ErrBootstrapScriptNotFound(script string) error {
return &LnkError{
Message: fmt.Sprintf("Bootstrap script not found: %s", script),
Path: script,
ErrorType: "script_not_found",
}
}
func ErrBootstrapScriptFailed(err error) error {
return &LnkError{
Message: fmt.Sprintf("Bootstrap script failed with error: %v", err),
ErrorType: "script_failed",
}
}
func ErrBootstrapScriptNotExecutable(err error) error {
return &LnkError{
Message: fmt.Sprintf("Failed to make bootstrap script executable: %v", err),
ErrorType: "script_permissions",
}
}

View File

@@ -156,7 +156,7 @@ func (l *Lnk) InitWithRemoteForce(remoteURL string, force bool) error {
// Safety check: prevent data loss by checking for existing managed files
if l.HasUserContent() {
if !force {
return fmt.Errorf("❌ Directory \033[31m%s\033[0m already contains managed files\n 💡 Use 'lnk pull' to update from remote instead of 'lnk init -r'", l.repoPath)
return ErrDirectoryContainsManagedFiles(l.repoPath)
}
}
// Clone from remote
@@ -176,7 +176,7 @@ func (l *Lnk) InitWithRemoteForce(remoteURL string, force bool) error {
return nil
} else {
// It's not a lnk repository, error to prevent data loss
return fmt.Errorf("❌ Directory \033[31m%s\033[0m contains an existing Git repository\n 💡 Please backup or move the existing repository before initializing lnk", l.repoPath)
return ErrDirectoryContainsGitRepo(l.repoPath)
}
}
@@ -230,7 +230,7 @@ func (l *Lnk) Add(filePath string) error {
}
for _, item := range managedItems {
if item == relativePath {
return fmt.Errorf("❌ File is already managed by lnk: \033[31m%s\033[0m", relativePath)
return ErrFileAlreadyManaged(relativePath)
}
}
@@ -332,7 +332,7 @@ func (l *Lnk) AddMultiple(paths []string) error {
}
for _, item := range managedItems {
if item == relativePath {
return fmt.Errorf("❌ File is already managed by lnk: \033[31m%s\033[0m", relativePath)
return ErrFileAlreadyManaged(relativePath)
}
}
@@ -476,7 +476,7 @@ func (l *Lnk) Remove(filePath string) error {
}
}
if !found {
return fmt.Errorf("❌ File is not managed by lnk: \033[31m%s\033[0m", relativePath)
return ErrFileNotManaged(relativePath)
}
// Get the target path in the repository
@@ -551,7 +551,7 @@ type StatusInfo struct {
func (l *Lnk) Status() (*StatusInfo, error) {
// Check if repository is initialized
if !l.git.IsGitRepository() {
return nil, fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
return nil, ErrRepositoryNotInitialized()
}
gitStatus, err := l.git.GetStatus()
@@ -571,7 +571,7 @@ func (l *Lnk) Status() (*StatusInfo, error) {
func (l *Lnk) Push(message string) error {
// Check if repository is initialized
if !l.git.IsGitRepository() {
return fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
return ErrRepositoryNotInitialized()
}
// Check if there are any changes
@@ -601,7 +601,7 @@ func (l *Lnk) Push(message string) error {
func (l *Lnk) Pull() ([]string, error) {
// Check if repository is initialized
if !l.git.IsGitRepository() {
return nil, fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
return nil, ErrRepositoryNotInitialized()
}
// Pull changes from remote (this will be a no-op in tests since we don't have real remotes)
@@ -622,7 +622,7 @@ func (l *Lnk) Pull() ([]string, error) {
func (l *Lnk) List() ([]string, error) {
// Check if repository is initialized
if !l.git.IsGitRepository() {
return nil, fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
return nil, ErrRepositoryNotInitialized()
}
// Get managed items from .lnk file
@@ -822,7 +822,7 @@ func (l *Lnk) writeManagedItems(items []string) error {
func (l *Lnk) FindBootstrapScript() (string, error) {
// Check if repository is initialized
if !l.git.IsGitRepository() {
return "", fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
return "", ErrRepositoryNotInitialized()
}
// Look for bootstrap.sh - simple, opinionated choice
@@ -840,12 +840,12 @@ func (l *Lnk) RunBootstrapScript(scriptName string) error {
// Verify the script exists
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
return fmt.Errorf("❌ Bootstrap script not found: \033[31m%s\033[0m", scriptName)
return ErrBootstrapScriptNotFound(scriptName)
}
// Make sure it's executable
if err := os.Chmod(scriptPath, 0755); err != nil {
return fmt.Errorf("❌ Failed to make bootstrap script executable: %w", err)
return ErrBootstrapScriptNotExecutable(err)
}
// Run with bash (since we only support bootstrap.sh)
@@ -861,7 +861,7 @@ func (l *Lnk) RunBootstrapScript(scriptName string) error {
// Run the script
if err := cmd.Run(); err != nil {
return fmt.Errorf("❌ Bootstrap script failed with error: %w", err)
return ErrBootstrapScriptFailed(err)
}
return nil
@@ -988,7 +988,7 @@ func (l *Lnk) addMultipleWithProgress(paths []string, progress ProgressCallback)
}
for _, item := range managedItems {
if item == relativePath {
return fmt.Errorf("❌ File is already managed by lnk: \033[31m%s\033[0m", relativePath)
return ErrFileAlreadyManaged(relativePath)
}
}

View File

@@ -1,28 +1,7 @@
package fs
import "fmt"
// ANSI color codes for consistent formatting
const (
colorReset = "\033[0m"
colorRed = "\033[31m"
colorBold = "\033[1m"
)
// formatError creates a consistently formatted error message with ❌ prefix
func formatError(message string, args ...interface{}) string {
return fmt.Sprintf("❌ "+message, args...)
}
// formatPath formats a file path with red color
func formatPath(path string) string {
return fmt.Sprintf("%s%s%s", colorRed, path, colorReset)
}
// formatCommand formats a command with bold styling
func formatCommand(command string) string {
return fmt.Sprintf("%s%s%s", colorBold, command, colorReset)
}
// Structured errors that separate content from presentation
// These will be formatted by the cmd package based on user preferences
// FileNotExistsError represents an error when a file does not exist
type FileNotExistsError struct {
@@ -31,20 +10,25 @@ type FileNotExistsError struct {
}
func (e *FileNotExistsError) Error() string {
return formatError("File or directory not found: %s", formatPath(e.Path))
return "File or directory not found: " + e.Path
}
func (e *FileNotExistsError) Unwrap() error {
return e.Err
}
// GetPath returns the path for formatting purposes
func (e *FileNotExistsError) GetPath() string {
return e.Path
}
// FileCheckError represents an error when failing to check a file
type FileCheckError struct {
Err error
}
func (e *FileCheckError) Error() string {
return formatError("Unable to access file. Please check file permissions and try again.")
return "Unable to access file. Please check file permissions and try again."
}
func (e *FileCheckError) Unwrap() error {
@@ -57,7 +41,15 @@ type UnsupportedFileTypeError struct {
}
func (e *UnsupportedFileTypeError) Error() string {
return formatError("Cannot manage this type of file: %s\n 💡 lnk can only manage regular files and directories", formatPath(e.Path))
return "Cannot manage this type of file: " + e.Path
}
func (e *UnsupportedFileTypeError) GetPath() string {
return e.Path
}
func (e *UnsupportedFileTypeError) GetSuggestion() string {
return "lnk can only manage regular files and directories"
}
func (e *UnsupportedFileTypeError) Unwrap() error {
@@ -70,8 +62,15 @@ type NotManagedByLnkError struct {
}
func (e *NotManagedByLnkError) Error() string {
return formatError("File is not managed by lnk: %s\n 💡 Use %s to manage this file first",
formatPath(e.Path), formatCommand("lnk add"))
return "File is not managed by lnk: " + e.Path
}
func (e *NotManagedByLnkError) GetPath() string {
return e.Path
}
func (e *NotManagedByLnkError) GetSuggestion() string {
return "Use 'lnk add' to manage this file first"
}
func (e *NotManagedByLnkError) Unwrap() error {
@@ -84,7 +83,7 @@ type SymlinkReadError struct {
}
func (e *SymlinkReadError) Error() string {
return formatError("Unable to read symlink. The file may be corrupted or have invalid permissions.")
return "Unable to read symlink. The file may be corrupted or have invalid permissions."
}
func (e *SymlinkReadError) Unwrap() error {
@@ -98,7 +97,7 @@ type DirectoryCreationError struct {
}
func (e *DirectoryCreationError) Error() string {
return formatError("Failed to create directory. Please check permissions and available disk space.")
return "Failed to create directory. Please check permissions and available disk space."
}
func (e *DirectoryCreationError) Unwrap() error {
@@ -111,9 +110,21 @@ type RelativePathCalculationError struct {
}
func (e *RelativePathCalculationError) Error() string {
return formatError("Unable to create symlink due to path configuration issues. Please check file locations.")
return "Unable to create symlink due to path configuration issues. Please check file locations."
}
func (e *RelativePathCalculationError) Unwrap() error {
return e.Err
}
// ErrorWithPath is an interface for errors that have an associated file path
type ErrorWithPath interface {
error
GetPath() string
}
// ErrorWithSuggestion is an interface for errors that provide helpful suggestions
type ErrorWithSuggestion interface {
error
GetSuggestion() string
}

View File

@@ -1,29 +1,7 @@
package git
import "fmt"
// ANSI color codes for consistent formatting
const (
colorReset = "\033[0m"
colorBold = "\033[1m"
colorGreen = "\033[32m"
colorYellow = "\033[33m"
)
// formatError creates a consistently formatted error message with ❌ prefix
func formatError(message string, args ...interface{}) string {
return fmt.Sprintf("❌ "+message, args...)
}
// formatURL formats a URL with styling
func formatURL(url string) string {
return fmt.Sprintf("%s%s%s", colorBold, url, colorReset)
}
// formatRemote formats a remote name with styling
func formatRemote(remote string) string {
return fmt.Sprintf("%s%s%s", colorGreen, remote, colorReset)
}
// Structured errors that separate content from presentation
// These will be formatted by the cmd package based on user preferences
// GitInitError represents an error during git initialization
type GitInitError struct {
@@ -32,7 +10,7 @@ type GitInitError struct {
}
func (e *GitInitError) Error() string {
return formatError("Failed to initialize git repository. Please ensure git is installed and try again.")
return "Failed to initialize git repository. Please ensure git is installed and try again."
}
func (e *GitInitError) Unwrap() error {
@@ -45,7 +23,7 @@ type BranchSetupError struct {
}
func (e *BranchSetupError) Error() string {
return formatError("Failed to set up the default branch. Please check your git installation.")
return "Failed to set up the default branch. Please check your git installation."
}
func (e *BranchSetupError) Unwrap() error {
@@ -60,8 +38,19 @@ type RemoteExistsError struct {
}
func (e *RemoteExistsError) Error() string {
return formatError("Remote %s is already configured with a different repository (%s). Cannot add %s.",
formatRemote(e.Remote), formatURL(e.ExistingURL), formatURL(e.NewURL))
return "Remote " + e.Remote + " is already configured with a different repository (" + e.ExistingURL + "). Cannot add " + e.NewURL + "."
}
func (e *RemoteExistsError) GetRemote() string {
return e.Remote
}
func (e *RemoteExistsError) GetExistingURL() string {
return e.ExistingURL
}
func (e *RemoteExistsError) GetNewURL() string {
return e.NewURL
}
func (e *RemoteExistsError) Unwrap() error {
@@ -79,24 +68,28 @@ func (e *GitCommandError) Error() string {
// Provide user-friendly messages based on common command types
switch e.Command {
case "add":
return formatError("Failed to stage files for commit. Please check file permissions and try again.")
return "Failed to stage files for commit. Please check file permissions and try again."
case "commit":
return formatError("Failed to create commit. Please ensure you have staged changes and try again.")
return "Failed to create commit. Please ensure you have staged changes and try again."
case "remote add":
return formatError("Failed to add remote repository. Please check the repository URL and try again.")
return "Failed to add remote repository. Please check the repository URL and try again."
case "rm":
return formatError("Failed to remove file from git tracking. Please check if the file exists and try again.")
return "Failed to remove file from git tracking. Please check if the file exists and try again."
case "log":
return formatError("Failed to retrieve commit history.")
return "Failed to retrieve commit history."
case "remote":
return formatError("Failed to retrieve remote repository information.")
return "Failed to retrieve remote repository information."
case "clone":
return formatError("Failed to clone repository. Please check the repository URL and your network connection.")
return "Failed to clone repository. Please check the repository URL and your network connection."
default:
return formatError("Git operation failed. Please check your repository state and try again.")
return "Git operation failed. Please check your repository state and try again."
}
}
func (e *GitCommandError) GetCommand() string {
return e.Command
}
func (e *GitCommandError) Unwrap() error {
return e.Err
}
@@ -105,7 +98,7 @@ func (e *GitCommandError) Unwrap() error {
type NoRemoteError struct{}
func (e *NoRemoteError) Error() string {
return formatError("No remote repository is configured. Please add a remote repository first.")
return "No remote repository is configured. Please add a remote repository first."
}
func (e *NoRemoteError) Unwrap() error {
@@ -119,7 +112,11 @@ type RemoteNotFoundError struct {
}
func (e *RemoteNotFoundError) Error() string {
return formatError("Remote repository %s is not configured.", formatRemote(e.Remote))
return "Remote repository " + e.Remote + " is not configured."
}
func (e *RemoteNotFoundError) GetRemote() string {
return e.Remote
}
func (e *RemoteNotFoundError) Unwrap() error {
@@ -133,7 +130,7 @@ type GitConfigError struct {
}
func (e *GitConfigError) Error() string {
return formatError("Failed to configure git settings. Please check your git installation.")
return "Failed to configure git settings. Please check your git installation."
}
func (e *GitConfigError) Unwrap() error {
@@ -146,7 +143,7 @@ type UncommittedChangesError struct {
}
func (e *UncommittedChangesError) Error() string {
return formatError("Failed to check repository status. Please verify your git repository is valid.")
return "Failed to check repository status. Please verify your git repository is valid."
}
func (e *UncommittedChangesError) Unwrap() error {
@@ -160,7 +157,11 @@ type DirectoryRemovalError struct {
}
func (e *DirectoryRemovalError) Error() string {
return formatError("Failed to prepare directory for operation. Please check directory permissions.")
return "Failed to prepare directory for operation. Please check directory permissions."
}
func (e *DirectoryRemovalError) GetPath() string {
return e.Path
}
func (e *DirectoryRemovalError) Unwrap() error {
@@ -174,7 +175,11 @@ type DirectoryCreationError struct {
}
func (e *DirectoryCreationError) Error() string {
return formatError("Failed to create directory. Please check permissions and available disk space.")
return "Failed to create directory. Please check permissions and available disk space."
}
func (e *DirectoryCreationError) GetPath() string {
return e.Path
}
func (e *DirectoryCreationError) Unwrap() error {
@@ -190,9 +195,13 @@ type PushError struct {
func (e *PushError) Error() string {
if e.Reason != "" {
return formatError("Cannot push changes: %s", e.Reason)
return "Cannot push changes: " + e.Reason
}
return formatError("Failed to push changes to remote repository. Please check your network connection and repository permissions.")
return "Failed to push changes to remote repository. Please check your network connection and repository permissions."
}
func (e *PushError) GetReason() string {
return e.Reason
}
func (e *PushError) Unwrap() error {
@@ -208,11 +217,33 @@ type PullError struct {
func (e *PullError) Error() string {
if e.Reason != "" {
return formatError("Cannot pull changes: %s", e.Reason)
return "Cannot pull changes: " + e.Reason
}
return formatError("Failed to pull changes from remote repository. Please check your network connection and resolve any conflicts.")
return "Failed to pull changes from remote repository. Please check your network connection and resolve any conflicts."
}
func (e *PullError) GetReason() string {
return e.Reason
}
func (e *PullError) Unwrap() error {
return e.Err
}
// ErrorWithPath is an interface for git errors that have an associated file path
type ErrorWithPath interface {
error
GetPath() string
}
// ErrorWithRemote is an interface for git errors that involve a remote
type ErrorWithRemote interface {
error
GetRemote() string
}
// ErrorWithReason is an interface for git errors that have a specific reason
type ErrorWithReason interface {
error
GetReason() string
}