diff --git a/README.md b/README.md index fb706a2..8bce9ec 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,72 @@ lnk pull # Get updates (work config won't affe - `--no-bootstrap` - Skip automatic execution of bootstrap script after cloning - `--force` - Force initialization even if directory contains managed files (WARNING: overwrites existing content) +### Output Formatting + +Lnk provides flexible output formatting options to suit different environments and preferences: + +#### Color Output + +Control when ANSI colors are used in output: + +```bash +# Default: auto-detect based on TTY +lnk init + +# Force colors regardless of environment +lnk init --colors=always + +# Disable colors completely +lnk init --colors=never + +# Environment variable support +NO_COLOR=1 lnk init # Disables colors (acts like --colors=never) +``` + +**Color modes:** +- `auto` (default): Use colors only when stdout is a TTY +- `always`: Force color output regardless of TTY +- `never`: Disable color output regardless of TTY + +The `NO_COLOR` environment variable acts like `--colors=never` when set, but explicit `--colors` flags take precedence. + +#### Emoji Output + +Control emoji usage in output messages: + +```bash +# Default: emojis enabled +lnk init + +# Disable emojis +lnk init --no-emoji + +# Explicitly enable emojis +lnk init --emoji +``` + +**Emoji flags:** +- `--emoji` (default: true): Enable emoji in output +- `--no-emoji`: Disable emoji in output + +The `--emoji` and `--no-emoji` flags are mutually exclusive. + +#### Examples + +```bash +# Clean output for scripts/pipes +lnk init --colors=never --no-emoji + +# Force colorful output in non-TTY environments +lnk init --colors=always + +# Disable colors but keep emojis +lnk init --colors=never + +# Disable emojis but keep colors +lnk init --no-emoji +``` + ### Add Command Examples ```bash diff --git a/cmd/add.go b/cmd/add.go index 34bad9e..9ce2eda 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -1,9 +1,11 @@ package cmd import ( + "fmt" "path/filepath" "github.com/spf13/cobra" + "github.com/yarlson/lnk/internal/core" ) @@ -33,6 +35,7 @@ changes to your system - perfect for verification before bulk operations.`, recursive, _ := cmd.Flags().GetBool("recursive") dryRun, _ := cmd.Flags().GetBool("dry-run") lnk := core.NewLnk(core.WithHost(host)) + w := GetWriter(cmd) // Handle dry-run mode if dryRun { @@ -43,19 +46,22 @@ changes to your system - perfect for verification before bulk operations.`, // Display preview output if recursive { - printf(cmd, "🔍 \033[1mWould add %d files recursively:\033[0m\n", len(files)) + w.Writeln(Message{Text: fmt.Sprintf("Would add %d files recursively:", len(files)), Emoji: "🔍", Bold: true}) } else { - printf(cmd, "🔍 \033[1mWould add %d files:\033[0m\n", len(files)) + w.Writeln(Message{Text: fmt.Sprintf("Would add %d files:", len(files)), Emoji: "🔍", Bold: true}) } // List files that would be added for _, file := range files { basename := filepath.Base(file) - printf(cmd, " 📄 \033[90m%s\033[0m\n", basename) + w.WriteString(" "). + Writeln(Message{Text: basename, Emoji: "📄"}) } - printf(cmd, "\n💡 \033[33mTo proceed:\033[0m run without --dry-run flag\n") - return nil + w.WritelnString(""). + Writeln(Info("To proceed: run without --dry-run flag")) + + return w.Err() } // Handle recursive mode @@ -68,7 +74,7 @@ changes to your system - perfect for verification before bulk operations.`, // Create progress callback for CLI display progressCallback := func(current, total int, currentFile string) { - printf(cmd, "\r⏳ Processing %d/%d: %s", current, total, currentFile) + w.WriteString(fmt.Sprintf("\r⏳ Processing %d/%d: %s", current, total, currentFile)) } if err := lnk.AddRecursiveWithProgress(args, progressCallback); err != nil { @@ -76,7 +82,7 @@ changes to your system - perfect for verification before bulk operations.`, } // Clear progress line and show completion - printf(cmd, "\r") + w.WriteString("\r") // Store processed file count for display args = previewFiles // Replace args with actual files for display @@ -99,9 +105,9 @@ changes to your system - perfect for verification before bulk operations.`, if recursive { // Recursive mode - show enhanced message with count if host != "" { - printf(cmd, "✨ \033[1mAdded %d files recursively to lnk (host: %s)\033[0m\n", len(args), host) + w.Writeln(Sparkles(fmt.Sprintf("Added %d files recursively to lnk (host: %s)", len(args), host))) } else { - printf(cmd, "✨ \033[1mAdded %d files recursively to lnk\033[0m\n", len(args)) + w.Writeln(Sparkles(fmt.Sprintf("Added %d files recursively to lnk", len(args)))) } // Show some of the files that were added (limit to first few for readability) @@ -113,47 +119,70 @@ changes to your system - perfect for verification before bulk operations.`, for i := 0; i < filesToShow; i++ { basename := filepath.Base(args[i]) if host != "" { - printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/...\033[0m\n", basename, host) + w.WriteString(" "). + Write(Link(basename)). + WriteString(" → "). + Writeln(Colored(fmt.Sprintf("~/.config/lnk/%s.lnk/...", host), ColorCyan)) } else { - printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/...\033[0m\n", basename) + w.WriteString(" "). + Write(Link(basename)). + WriteString(" → "). + Writeln(Colored("~/.config/lnk/...", ColorCyan)) } } if len(args) > 5 { - printf(cmd, " \033[90m... and %d more files\033[0m\n", len(args)-5) + w.WriteString(" "). + Writeln(Colored(fmt.Sprintf("... and %d more files", len(args)-5), ColorGray)) } } else if len(args) == 1 { // Single file - maintain existing output format for backward compatibility filePath := args[0] basename := filepath.Base(filePath) if host != "" { - printf(cmd, "✨ \033[1mAdded %s to lnk (host: %s)\033[0m\n", basename, host) - printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/%s\033[0m\n", filePath, host, filePath) + w.Writeln(Sparkles(fmt.Sprintf("Added %s to lnk (host: %s)", basename, host))) + w.WriteString(" "). + Write(Link(filePath)). + WriteString(" → "). + Writeln(Colored(fmt.Sprintf("~/.config/lnk/%s.lnk/%s", host, filePath), ColorCyan)) } else { - printf(cmd, "✨ \033[1mAdded %s to lnk\033[0m\n", basename) - printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, filePath) + w.Writeln(Sparkles(fmt.Sprintf("Added %s to lnk", basename))) + w.WriteString(" "). + Write(Link(filePath)). + WriteString(" → "). + Writeln(Colored(fmt.Sprintf("~/.config/lnk/%s", filePath), ColorCyan)) } } else { // Multiple files - show summary if host != "" { - printf(cmd, "✨ \033[1mAdded %d items to lnk (host: %s)\033[0m\n", len(args), host) + w.Writeln(Sparkles(fmt.Sprintf("Added %d items to lnk (host: %s)", len(args), host))) } else { - printf(cmd, "✨ \033[1mAdded %d items to lnk\033[0m\n", len(args)) + w.Writeln(Sparkles(fmt.Sprintf("Added %d items to lnk", len(args)))) } // List each added file for _, filePath := range args { basename := filepath.Base(filePath) if host != "" { - printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/...\033[0m\n", basename, host) + w.WriteString(" "). + Write(Link(basename)). + WriteString(" → "). + Writeln(Colored(fmt.Sprintf("~/.config/lnk/%s.lnk/...", host), ColorCyan)) } else { - printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/...\033[0m\n", basename) + w.WriteString(" "). + Write(Link(basename)). + WriteString(" → "). + Writeln(Colored("~/.config/lnk/...", ColorCyan)) } } } - printf(cmd, " 📝 Use \033[1mlnk push\033[0m to sync to remote\n") - return nil + w.WriteString(" "). + Write(Message{Text: "Use ", Emoji: "📝"}). + Write(Bold("lnk push")). + WritelnString(" to sync to remote") + + return w.Err() }, } diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index 6fbebe7..32d7c8e 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -2,6 +2,7 @@ package cmd import ( "github.com/spf13/cobra" + "github.com/yarlson/lnk/internal/core" ) @@ -14,6 +15,7 @@ func newBootstrapCmd() *cobra.Command { SilenceErrors: true, RunE: func(cmd *cobra.Command, args []string) error { lnk := core.NewLnk() + w := GetWriter(cmd) scriptPath, err := lnk.FindBootstrapScript() if err != nil { @@ -21,25 +23,40 @@ func newBootstrapCmd() *cobra.Command { } if scriptPath == "" { - printf(cmd, "💡 \033[33mNo bootstrap script found\033[0m\n") - printf(cmd, " 📝 Create a \033[1mbootstrap.sh\033[0m file in your dotfiles repository:\n") - printf(cmd, " \033[90m#!/bin/bash\033[0m\n") - printf(cmd, " \033[90mecho \"Setting up environment...\"\033[0m\n") - printf(cmd, " \033[90m# Your setup commands here\033[0m\n") - return nil + w.Writeln(Info("No bootstrap script found")). + WriteString(" "). + Write(Message{Text: "Create a ", Emoji: "📝"}). + Write(Bold("bootstrap.sh")). + WritelnString(" file in your dotfiles repository:"). + WriteString(" "). + Writeln(Colored("#!/bin/bash", ColorGray)). + WriteString(" "). + Writeln(Colored("echo \"Setting up environment...\"", ColorGray)). + WriteString(" "). + Writeln(Colored("# Your setup commands here", ColorGray)) + return w.Err() } - printf(cmd, "🚀 \033[1mRunning bootstrap script\033[0m\n") - printf(cmd, " 📄 Script: \033[36m%s\033[0m\n", scriptPath) - printf(cmd, "\n") + w.Writeln(Rocket("Running bootstrap script")). + WriteString(" "). + Write(Message{Text: "Script: ", Emoji: "📄"}). + Writeln(Colored(scriptPath, ColorCyan)). + WritelnString("") + + if err := w.Err(); err != nil { + return err + } if err := lnk.RunBootstrapScript(scriptPath); err != nil { return err } - printf(cmd, "\n✅ \033[1;32mBootstrap completed successfully!\033[0m\n") - printf(cmd, " 🎉 Your environment is ready to use\n") - return nil + w.WritelnString(""). + Writeln(Success("Bootstrap completed successfully!")). + WriteString(" "). + Writeln(Message{Text: "Your environment is ready to use", Emoji: "🎉"}) + + return w.Err() }, } } diff --git a/cmd/init.go b/cmd/init.go index 2433445..74d33e6 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -2,6 +2,7 @@ package cmd import ( "github.com/spf13/cobra" + "github.com/yarlson/lnk/internal/core" ) @@ -18,11 +19,17 @@ func newInitCmd() *cobra.Command { force, _ := cmd.Flags().GetBool("force") lnk := core.NewLnk() + w := GetWriter(cmd) // Show warning when force is used and there are managed files to overwrite if force && remote != "" && lnk.HasUserContent() { - printf(cmd, "⚠️ \033[33mUsing --force flag: This will overwrite existing managed files\033[0m\n") - printf(cmd, " 💡 Only use this if you understand the risks\n\n") + w.Writeln(Warning("Using --force flag: This will overwrite existing managed files")). + WriteString(" "). + Writeln(Info("Only use this if you understand the risks")). + WritelnString("") + if err := w.Err(); err != nil { + return err + } } if err := lnk.InitWithRemoteForce(remote, force); err != nil { @@ -30,13 +37,26 @@ func newInitCmd() *cobra.Command { } if remote != "" { - printf(cmd, "🎯 \033[1mInitialized lnk repository\033[0m\n") - printf(cmd, " 📦 Cloned from: \033[36m%s\033[0m\n", remote) - printf(cmd, " 📁 Location: \033[90m~/.config/lnk\033[0m\n") + w.Writeln(Target("Initialized lnk repository")). + WriteString(" "). + Write(Message{Text: "Cloned from: ", Emoji: "📦"}). + Writeln(Colored(remote, ColorCyan)). + WriteString(" "). + Write(Message{Text: "Location: ", Emoji: "📁"}). + Writeln(Colored("~/.config/lnk", ColorGray)) + + if err := w.Err(); err != nil { + return err + } // Try to run bootstrap script if not disabled if !noBootstrap { - printf(cmd, "\n🔍 \033[1mLooking for bootstrap script...\033[0m\n") + w.WritelnString(""). + Writeln(Message{Text: "Looking for bootstrap script...", Emoji: "🔍", Bold: true}) + + if err := w.Err(); err != nil { + return err + } scriptPath, err := lnk.FindBootstrapScript() if err != nil { @@ -44,34 +64,68 @@ func newInitCmd() *cobra.Command { } if scriptPath != "" { - printf(cmd, " ✅ Found bootstrap script: \033[36m%s\033[0m\n", scriptPath) - printf(cmd, "\n🚀 \033[1mRunning bootstrap script...\033[0m\n") - printf(cmd, "\n") + w.WriteString(" "). + Write(Success("Found bootstrap script: ")). + Writeln(Colored(scriptPath, ColorCyan)). + WritelnString(""). + Writeln(Rocket("Running bootstrap script...")). + WritelnString("") + + if err := w.Err(); err != nil { + return err + } if err := lnk.RunBootstrapScript(scriptPath); err != nil { - printf(cmd, "\n⚠️ \033[33mBootstrap script failed, but repository was initialized successfully\033[0m\n") - printf(cmd, " 💡 You can run it manually with: \033[1mlnk bootstrap\033[0m\n") - printf(cmd, " 🔧 Error: %v\n", err) + w.WritelnString(""). + Writeln(Warning("Bootstrap script failed, but repository was initialized successfully")). + WriteString(" "). + Write(Info("You can run it manually with: ")). + Writeln(Bold("lnk bootstrap")). + WriteString(" "). + Write(Message{Text: "Error: ", Emoji: "🔧"}). + Writeln(Plain(err.Error())) } else { - printf(cmd, "\n✅ \033[1;32mBootstrap completed successfully!\033[0m\n") + w.WritelnString(""). + Writeln(Success("Bootstrap completed successfully!")) + } + + if err := w.Err(); err != nil { + return err } } else { - printf(cmd, " 💡 No bootstrap script found\n") + w.WriteString(" "). + Writeln(Info("No bootstrap script found")) + if err := w.Err(); err != nil { + return err + } } } - printf(cmd, "\n💡 \033[33mNext steps:\033[0m\n") - printf(cmd, " • Run \033[1mlnk pull\033[0m to restore symlinks\n") - printf(cmd, " • Use \033[1mlnk add \033[0m to manage new files\n") - } else { - printf(cmd, "🎯 \033[1mInitialized empty lnk repository\033[0m\n") - printf(cmd, " 📁 Location: \033[90m~/.config/lnk\033[0m\n") - printf(cmd, "\n💡 \033[33mNext steps:\033[0m\n") - printf(cmd, " • Run \033[1mlnk add \033[0m to start managing dotfiles\n") - printf(cmd, " • Add a remote with: \033[1mgit remote add origin \033[0m\n") - } + w.WritelnString(""). + Writeln(Info("Next steps:")). + WriteString(" • Run "). + Write(Bold("lnk pull")). + Writeln(Plain(" to restore symlinks")). + WriteString(" • Use "). + Write(Bold("lnk add ")). + Writeln(Plain(" to manage new files")) - return nil + return w.Err() + } else { + w.Writeln(Target("Initialized empty lnk repository")). + WriteString(" "). + Write(Message{Text: "Location: ", Emoji: "📁"}). + Writeln(Colored("~/.config/lnk", ColorGray)). + WritelnString(""). + Writeln(Info("Next steps:")). + WriteString(" • Run "). + Write(Bold("lnk add ")). + Writeln(Plain(" to start managing dotfiles")). + WriteString(" • Add a remote with: "). + Writeln(Bold("git remote add origin ")) + + return w.Err() + } }, } diff --git a/cmd/list.go b/cmd/list.go index 811537d..c23241f 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -1,11 +1,13 @@ package cmd import ( + "fmt" "os" "path/filepath" "strings" "github.com/spf13/cobra" + "github.com/yarlson/lnk/internal/core" ) @@ -42,61 +44,88 @@ func newListCmd() *cobra.Command { func listCommonConfig(cmd *cobra.Command) error { lnk := core.NewLnk() + w := GetWriter(cmd) + managedItems, err := lnk.List() if err != nil { return err } if len(managedItems) == 0 { - printf(cmd, "📋 \033[1mNo files currently managed by lnk (common)\033[0m\n") - printf(cmd, " 💡 Use \033[1mlnk add \033[0m to start managing files\n") - return nil + w.Writeln(Message{Text: "No files currently managed by lnk (common)", Emoji: "📋", Bold: true}). + WriteString(" "). + Write(Info("Use ")). + Write(Bold("lnk add ")). + WritelnString(" to start managing files") + return w.Err() } - printf(cmd, "📋 \033[1mFiles managed by lnk (common)\033[0m (\033[36m%d item", len(managedItems)) + countText := fmt.Sprintf("Files managed by lnk (common) (%d item", len(managedItems)) if len(managedItems) > 1 { - printf(cmd, "s") + countText += "s" } - printf(cmd, "\033[0m):\n\n") + countText += "):" + + w.Writeln(Message{Text: countText, Emoji: "📋", Bold: true}). + WritelnString("") for _, item := range managedItems { - printf(cmd, " 🔗 \033[36m%s\033[0m\n", item) + w.WriteString(" "). + Writeln(Link(item)) } - printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n") - return nil + w.WritelnString(""). + Write(Info("Use ")). + Write(Bold("lnk status")). + WritelnString(" to check sync status") + return w.Err() } func listHostConfig(cmd *cobra.Command, host string) error { lnk := core.NewLnk(core.WithHost(host)) + w := GetWriter(cmd) + managedItems, err := lnk.List() if err != nil { return err } if len(managedItems) == 0 { - printf(cmd, "📋 \033[1mNo files currently managed by lnk (host: %s)\033[0m\n", host) - printf(cmd, " 💡 Use \033[1mlnk add --host %s \033[0m to start managing files\n", host) - return nil + w.Writeln(Message{Text: fmt.Sprintf("No files currently managed by lnk (host: %s)", host), Emoji: "📋", Bold: true}). + WriteString(" "). + Write(Info("Use ")). + Write(Bold(fmt.Sprintf("lnk add --host %s ", host))). + WritelnString(" to start managing files") + return w.Err() } - printf(cmd, "📋 \033[1mFiles managed by lnk (host: %s)\033[0m (\033[36m%d item", host, len(managedItems)) + countText := fmt.Sprintf("Files managed by lnk (host: %s) (%d item", host, len(managedItems)) if len(managedItems) > 1 { - printf(cmd, "s") + countText += "s" } - printf(cmd, "\033[0m):\n\n") + countText += "):" + + w.Writeln(Message{Text: countText, Emoji: "📋", Bold: true}). + WritelnString("") for _, item := range managedItems { - printf(cmd, " 🔗 \033[36m%s\033[0m\n", item) + w.WriteString(" "). + Writeln(Link(item)) } - printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n") - return nil + w.WritelnString(""). + Write(Info("Use ")). + Write(Bold("lnk status")). + WritelnString(" to check sync status") + return w.Err() } func listAllConfigs(cmd *cobra.Command) error { + w := GetWriter(cmd) + // List common configuration - printf(cmd, "📋 \033[1mAll configurations managed by lnk\033[0m\n\n") + w.Writeln(Message{Text: "All configurations managed by lnk", Emoji: "📋", Bold: true}). + WritelnString("") lnk := core.NewLnk() commonItems, err := lnk.List() @@ -104,17 +133,21 @@ func listAllConfigs(cmd *cobra.Command) error { return err } - printf(cmd, "🌐 \033[1mCommon configuration\033[0m (\033[36m%d item", len(commonItems)) + countText := fmt.Sprintf("Common configuration (%d item", len(commonItems)) if len(commonItems) > 1 { - printf(cmd, "s") + countText += "s" } - printf(cmd, "\033[0m):\n") + countText += "):" + + w.Writeln(Message{Text: countText, Emoji: "🌐", Bold: true}) if len(commonItems) == 0 { - printf(cmd, " \033[90m(no files)\033[0m\n") + w.WriteString(" "). + Writeln(Colored("(no files)", ColorGray)) } else { for _, item := range commonItems { - printf(cmd, " 🔗 \033[36m%s\033[0m\n", item) + w.WriteString(" "). + Writeln(Link(item)) } } @@ -125,32 +158,42 @@ func listAllConfigs(cmd *cobra.Command) error { } for _, host := range hosts { - printf(cmd, "\n🖥️ \033[1mHost: %s\033[0m", host) + w.WritelnString(""). + Write(Message{Text: fmt.Sprintf("Host: %s", host), Emoji: "🖥️", Bold: true}) hostLnk := core.NewLnk(core.WithHost(host)) hostItems, err := hostLnk.List() if err != nil { - printf(cmd, " \033[31m(error: %v)\033[0m\n", err) + w.WriteString(" "). + Writeln(Colored(fmt.Sprintf("(error: %v)", err), ColorRed)) continue } - printf(cmd, " (\033[36m%d item", len(hostItems)) + countText := fmt.Sprintf(" (%d item", len(hostItems)) if len(hostItems) > 1 { - printf(cmd, "s") + countText += "s" } - printf(cmd, "\033[0m):\n") + countText += "):" + + w.WriteString(countText). + WritelnString("") if len(hostItems) == 0 { - printf(cmd, " \033[90m(no files)\033[0m\n") + w.WriteString(" "). + Writeln(Colored("(no files)", ColorGray)) } else { for _, item := range hostItems { - printf(cmd, " 🔗 \033[36m%s\033[0m\n", item) + w.WriteString(" "). + Writeln(Link(item)) } } } - printf(cmd, "\n💡 Use \033[1mlnk list --host \033[0m to see specific host configuration\n") - return nil + w.WritelnString(""). + Write(Info("Use ")). + Write(Bold("lnk list --host ")). + WritelnString(" to see specific host configuration") + return w.Err() } func findHostConfigs() ([]string, error) { diff --git a/cmd/output.go b/cmd/output.go new file mode 100644 index 0000000..277d5fe --- /dev/null +++ b/cmd/output.go @@ -0,0 +1,230 @@ +package cmd + +import ( + "fmt" + "io" + "os" + + "github.com/spf13/cobra" +) + +// OutputConfig controls formatting behavior +type OutputConfig struct { + Colors bool + Emoji bool +} + +// Writer provides formatted output with configurable styling +type Writer struct { + out io.Writer + config OutputConfig + err error // first error encountered +} + +// NewWriter creates a new Writer with the given configuration +func NewWriter(out io.Writer, config OutputConfig) *Writer { + return &Writer{ + out: out, + config: config, + } +} + +// Message represents a structured message with optional formatting +type Message struct { + Text string + Color string + Emoji string + Bold bool +} + +// Write outputs a message according to the writer's configuration +func (w *Writer) Write(msg Message) *Writer { + if w.err != nil { + return w + } + + var output string + + // Add emoji if enabled + if w.config.Emoji && msg.Emoji != "" { + output = msg.Emoji + " " + } + + // Add color/bold if enabled + if w.config.Colors { + if msg.Bold { + output += "\033[1m" + } + if msg.Color != "" { + output += msg.Color + } + } + + output += msg.Text + + // Close formatting if enabled + if w.config.Colors && (msg.Bold || msg.Color != "") { + output += "\033[0m" + } + + _, w.err = fmt.Fprint(w.out, output) + return w +} + +// Printf is like Write but with format string +func (w *Writer) Printf(msg Message, args ...any) *Writer { + newMsg := msg + newMsg.Text = fmt.Sprintf(msg.Text, args...) + return w.Write(newMsg) +} + +// Writeln writes a message followed by a newline +func (w *Writer) Writeln(msg Message) *Writer { + return w.Write(msg).WriteString("\n") +} + +// WriteString outputs plain text (no formatting) +func (w *Writer) WriteString(text string) *Writer { + if w.err != nil { + return w + } + _, w.err = fmt.Fprint(w.out, text) + return w +} + +// WritelnString outputs plain text followed by a newline +func (w *Writer) WritelnString(text string) *Writer { + if w.err != nil { + return w + } + + _, w.err = fmt.Fprintln(w.out, text) + return w +} + +// ANSI color codes +const ( + ColorRed = "\033[31m" + ColorYellow = "\033[33m" + ColorCyan = "\033[36m" + ColorGray = "\033[90m" + ColorBrightGreen = "\033[1;32m" + ColorBrightYellow = "\033[1;33m" + ColorBrightRed = "\033[1;31m" +) + +// Predefined message constructors for common patterns + +func Success(text string) Message { + return Message{Text: text, Color: ColorBrightGreen, Emoji: "✅", Bold: true} +} + +func Error(text string) Message { + return Message{Text: text, Emoji: "❌"} +} + +func Warning(text string) Message { + return Message{Text: text, Color: ColorBrightYellow, Emoji: "⚠️", Bold: true} +} + +func Info(text string) Message { + return Message{Text: text, Color: ColorYellow, Emoji: "💡"} +} + +func Target(text string) Message { + return Message{Text: text, Emoji: "🎯", Bold: true} +} + +func Rocket(text string) Message { + return Message{Text: text, Emoji: "🚀", Bold: true} +} + +func Sparkles(text string) Message { + return Message{Text: text, Emoji: "✨", Bold: true} +} + +func Link(text string) Message { + return Message{Text: text, Color: ColorCyan, Emoji: "🔗"} +} + +func Plain(text string) Message { + return Message{Text: text} +} + +func Bold(text string) Message { + return Message{Text: text, Bold: true} +} + +func Colored(text, color string) Message { + return Message{Text: text, Color: color} +} + +// Global output configuration +var ( + globalConfig = OutputConfig{ + Colors: true, // auto-detect on first use + Emoji: true, + } + autoDetected bool +) + +// SetGlobalConfig updates the global output configuration +func SetGlobalConfig(colors string, emoji bool) error { + switch colors { + case "auto": + globalConfig.Colors = isTerminal() + case "always": + globalConfig.Colors = true + case "never": + globalConfig.Colors = false + default: + return fmt.Errorf("invalid color mode: %s (valid: auto, always, never)", colors) + } + + // Check NO_COLOR environment variable (explicit flag takes precedence) + if os.Getenv("NO_COLOR") != "" && colors == "auto" { + globalConfig.Colors = false + } + + globalConfig.Emoji = emoji + autoDetected = true + return nil +} + +// isTerminal checks if stdout is a terminal +func isTerminal() bool { + fileInfo, err := os.Stdout.Stat() + if err != nil { + return false + } + return (fileInfo.Mode() & os.ModeCharDevice) != 0 +} + +// autoDetectConfig performs one-time auto-detection if not explicitly configured +func autoDetectConfig() { + if !autoDetected { + if os.Getenv("NO_COLOR") != "" { + globalConfig.Colors = false + } else { + globalConfig.Colors = isTerminal() + } + autoDetected = true + } +} + +// GetWriter returns a writer for the given cobra command +func GetWriter(cmd *cobra.Command) *Writer { + autoDetectConfig() + return NewWriter(cmd.OutOrStdout(), globalConfig) +} + +// GetErrorWriter returns a writer for stderr +func GetErrorWriter() *Writer { + autoDetectConfig() + return NewWriter(os.Stderr, globalConfig) +} + +// Err returns the first error encountered during writing +func (w *Writer) Err() error { + return w.err +} diff --git a/cmd/output_test.go b/cmd/output_test.go new file mode 100644 index 0000000..9cc2df3 --- /dev/null +++ b/cmd/output_test.go @@ -0,0 +1,271 @@ +package cmd + +import ( + "bytes" + "os" + "strings" + "testing" + + "github.com/yarlson/lnk/internal/core" +) + +func TestOutputConfig(t *testing.T) { + tests := []struct { + name string + colors string + emoji bool + expectError bool + expectedColors bool + expectedEmoji bool + }{ + { + name: "auto mode", + colors: "auto", + emoji: true, + expectError: false, + expectedColors: false, // TTY detection will return false in tests + expectedEmoji: true, + }, + { + name: "always mode", + colors: "always", + emoji: false, + expectError: false, + expectedColors: true, + expectedEmoji: false, + }, + { + name: "never mode", + colors: "never", + emoji: true, + expectError: false, + expectedColors: false, + expectedEmoji: true, + }, + { + name: "invalid mode", + colors: "invalid", + emoji: true, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear NO_COLOR for consistent testing + _ = os.Unsetenv("NO_COLOR") + + err := SetGlobalConfig(tt.colors, tt.emoji) + + if tt.expectError && err == nil { + t.Errorf("expected error but got none") + } + if !tt.expectError && err != nil { + t.Errorf("unexpected error: %v", err) + } + + if !tt.expectError { + if globalConfig.Colors != tt.expectedColors { + t.Errorf("expected colors %v, got %v", tt.expectedColors, globalConfig.Colors) + } + if globalConfig.Emoji != tt.expectedEmoji { + t.Errorf("expected emoji %v, got %v", tt.expectedEmoji, globalConfig.Emoji) + } + } + }) + } +} + +func TestNOCOLOREnvironmentVariable(t *testing.T) { + // Test NO_COLOR environment variable with auto mode + _ = os.Setenv("NO_COLOR", "1") + defer func() { _ = os.Unsetenv("NO_COLOR") }() + + err := SetGlobalConfig("auto", true) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if globalConfig.Colors != false { + t.Errorf("expected colors disabled when NO_COLOR is set, got %v", globalConfig.Colors) + } +} + +func TestWriterOutput(t *testing.T) { + tests := []struct { + name string + config OutputConfig + message Message + expectedOutput string + }{ + { + name: "full formatting", + config: OutputConfig{Colors: true, Emoji: true}, + message: Message{ + Text: "test message", + Color: ColorRed, + Emoji: "✅", + Bold: true, + }, + expectedOutput: "✅ \033[1m\033[31mtest message\033[0m", + }, + { + name: "colors only", + config: OutputConfig{Colors: true, Emoji: false}, + message: Message{ + Text: "test message", + Color: ColorRed, + Emoji: "✅", + Bold: true, + }, + expectedOutput: "\033[1m\033[31mtest message\033[0m", + }, + { + name: "emoji only", + config: OutputConfig{Colors: false, Emoji: true}, + message: Message{ + Text: "test message", + Color: ColorRed, + Emoji: "✅", + Bold: true, + }, + expectedOutput: "✅ test message", + }, + { + name: "no formatting", + config: OutputConfig{Colors: false, Emoji: false}, + message: Message{ + Text: "test message", + Color: ColorRed, + Emoji: "✅", + Bold: true, + }, + expectedOutput: "test message", + }, + { + name: "plain message", + config: OutputConfig{Colors: true, Emoji: true}, + message: Plain("plain text"), + expectedOutput: "plain text", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + writer := NewWriter(&buf, tt.config) + + writer.Write(tt.message) + if err := writer.Err(); err != nil { + t.Errorf("unexpected error: %v", err) + } + + if buf.String() != tt.expectedOutput { + t.Errorf("expected %q, got %q", tt.expectedOutput, buf.String()) + } + }) + } +} + +func TestPredefinedMessages(t *testing.T) { + tests := []struct { + name string + creator func(string) Message + text string + }{ + {"Success", Success, "operation succeeded"}, + {"Error", Error, "something failed"}, + {"Warning", Warning, "be careful"}, + {"Info", Info, "useful information"}, + {"Target", Target, "target reached"}, + {"Rocket", Rocket, "launching"}, + {"Sparkles", Sparkles, "amazing"}, + {"Link", Link, "connected"}, + {"Plain", Plain, "no formatting"}, + {"Bold", Bold, "emphasis"}, + } + + var buf bytes.Buffer + writer := NewWriter(&buf, OutputConfig{Colors: true, Emoji: true}) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf.Reset() + msg := tt.creator(tt.text) + + writer.Write(msg) + if err := writer.Err(); err != nil { + t.Errorf("unexpected error: %v", err) + } + + output := buf.String() + if !strings.Contains(output, tt.text) { + t.Errorf("output should contain text %q, got %q", tt.text, output) + } + }) + } +} + +func TestStructuredErrors(t *testing.T) { + tests := []struct { + name string + err *core.LnkError + config OutputConfig + contains []string + notContains []string + }{ + { + name: "structured error with full formatting", + err: &core.LnkError{ + Message: "Something went wrong", + Suggestion: "Try this instead", + Path: "/some/path", + ErrorType: "test_error", + }, + config: OutputConfig{Colors: true, Emoji: true}, + contains: []string{"❌", "Something went wrong", "/some/path", "💡", "Try this instead"}, + }, + { + name: "structured error without emojis", + err: &core.LnkError{ + Message: "Something went wrong", + Suggestion: "Try this instead", + Path: "/some/path", + ErrorType: "test_error", + }, + config: OutputConfig{Colors: true, Emoji: false}, + contains: []string{"Something went wrong", "/some/path", "Try this instead"}, + notContains: []string{"❌", "💡"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + w := NewWriter(&buf, tt.config) + + // Test the component messages directly + _ = w.Write(Error(tt.err.Message)) + if tt.err.Path != "" { + _ = w.WriteString("\n ") + _ = w.Write(Colored(tt.err.Path, ColorRed)) + } + if tt.err.Suggestion != "" { + _ = w.WriteString("\n ") + _ = w.Write(Info(tt.err.Suggestion)) + } + + output := buf.String() + for _, expected := range tt.contains { + if !strings.Contains(output, expected) { + t.Errorf("output should contain %q, got %q", expected, output) + } + } + for _, notExpected := range tt.notContains { + if strings.Contains(output, notExpected) { + t.Errorf("output should not contain %q, got %q", notExpected, output) + } + } + }) + } +} diff --git a/cmd/pull.go b/cmd/pull.go index ccf722e..82f2f00 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -1,7 +1,10 @@ package cmd import ( + "fmt" + "github.com/spf13/cobra" + "github.com/yarlson/lnk/internal/core" ) @@ -14,8 +17,8 @@ func newPullCmd() *cobra.Command { SilenceErrors: true, RunE: func(cmd *cobra.Command, args []string) error { host, _ := cmd.Flags().GetString("host") - lnk := core.NewLnk(core.WithHost(host)) + w := GetWriter(cmd) restored, err := lnk.Pull() if err != nil { @@ -23,31 +26,47 @@ func newPullCmd() *cobra.Command { } if len(restored) > 0 { + var successMsg string if host != "" { - printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes (host: %s)\033[0m\n", host) + successMsg = fmt.Sprintf("Successfully pulled changes (host: %s)", host) } else { - printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n") + successMsg = "Successfully pulled changes" } - printf(cmd, " 🔗 Restored \033[1m%d symlink", len(restored)) + + symlinkText := fmt.Sprintf("Restored %d symlink", len(restored)) if len(restored) > 1 { - printf(cmd, "s") + symlinkText += "s" } - printf(cmd, "\033[0m:\n") + symlinkText += ":" + + w.Writeln(Message{Text: successMsg, Emoji: "⬇️", Color: ColorBrightGreen, Bold: true}). + WriteString(" "). + Writeln(Link(symlinkText)) + for _, file := range restored { - printf(cmd, " ✨ \033[36m%s\033[0m\n", file) + w.WriteString(" "). + Writeln(Sparkles(file)) } - printf(cmd, "\n 🎉 Your dotfiles are synced and ready!\n") + + w.WritelnString(""). + WriteString(" "). + Writeln(Message{Text: "Your dotfiles are synced and ready!", Emoji: "🎉"}) } else { + var successMsg string if host != "" { - printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes (host: %s)\033[0m\n", host) + successMsg = fmt.Sprintf("Successfully pulled changes (host: %s)", host) } else { - printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n") + successMsg = "Successfully pulled changes" } - printf(cmd, " ✅ All symlinks already in place\n") - printf(cmd, " 🎉 Everything is up to date!\n") + + w.Writeln(Message{Text: successMsg, Emoji: "⬇️", Color: ColorBrightGreen, Bold: true}). + WriteString(" "). + Writeln(Success("All symlinks already in place")). + WriteString(" "). + Writeln(Message{Text: "Everything is up to date!", Emoji: "🎉"}) } - return nil + return w.Err() }, } diff --git a/cmd/push.go b/cmd/push.go index 47cbb09..e7ce562 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -2,6 +2,7 @@ package cmd import ( "github.com/spf13/cobra" + "github.com/yarlson/lnk/internal/core" ) @@ -20,15 +21,22 @@ func newPushCmd() *cobra.Command { } lnk := core.NewLnk() + w := GetWriter(cmd) + if err := lnk.Push(message); err != nil { return err } - printf(cmd, "🚀 \033[1;32mSuccessfully pushed changes\033[0m\n") - printf(cmd, " 💾 Commit: \033[90m%s\033[0m\n", message) - printf(cmd, " 📡 Synced to remote\n") - printf(cmd, " ✨ Your dotfiles are up to date!\n") - return nil + w.Writeln(Rocket("Successfully pushed changes")). + WriteString(" "). + Write(Message{Text: "Commit: ", Emoji: "💾"}). + Writeln(Colored(message, ColorGray)). + WriteString(" "). + Writeln(Message{Text: "Synced to remote", Emoji: "📡"}). + WriteString(" "). + Writeln(Sparkles("Your dotfiles are up to date!")) + + return w.Err() }, } } diff --git a/cmd/rm.go b/cmd/rm.go index 26acf3f..03c472b 100644 --- a/cmd/rm.go +++ b/cmd/rm.go @@ -1,9 +1,11 @@ package cmd import ( + "fmt" "path/filepath" "github.com/spf13/cobra" + "github.com/yarlson/lnk/internal/core" ) @@ -18,8 +20,8 @@ func newRemoveCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { filePath := args[0] host, _ := cmd.Flags().GetString("host") - lnk := core.NewLnk(core.WithHost(host)) + w := GetWriter(cmd) if err := lnk.Remove(filePath); err != nil { return err @@ -27,14 +29,23 @@ func newRemoveCmd() *cobra.Command { basename := filepath.Base(filePath) if host != "" { - printf(cmd, "🗑️ \033[1mRemoved %s from lnk (host: %s)\033[0m\n", basename, host) - printf(cmd, " ↩️ \033[90m~/.config/lnk/%s.lnk/%s\033[0m → \033[36m%s\033[0m\n", host, basename, filePath) + w.Writeln(Message{Text: fmt.Sprintf("Removed %s from lnk (host: %s)", basename, host), Emoji: "🗑️", Bold: true}). + WriteString(" "). + Write(Message{Text: fmt.Sprintf("~/.config/lnk/%s.lnk/%s", host, basename), Emoji: "↩️"}). + WriteString(" → "). + Writeln(Colored(filePath, ColorCyan)) } else { - printf(cmd, "🗑️ \033[1mRemoved %s from lnk\033[0m\n", basename) - printf(cmd, " ↩️ \033[90m~/.config/lnk/%s\033[0m → \033[36m%s\033[0m\n", basename, filePath) + w.Writeln(Message{Text: fmt.Sprintf("Removed %s from lnk", basename), Emoji: "🗑️", Bold: true}). + WriteString(" "). + Write(Message{Text: fmt.Sprintf("~/.config/lnk/%s", basename), Emoji: "↩️"}). + WriteString(" → "). + Writeln(Colored(filePath, ColorCyan)) } - printf(cmd, " 📄 Original file restored\n") - return nil + + w.WriteString(" "). + Writeln(Message{Text: "Original file restored", Emoji: "📄"}) + + return w.Err() }, } diff --git a/cmd/root.go b/cmd/root.go index a5a5257..d8d1894 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,10 +1,15 @@ package cmd import ( + "errors" "fmt" "os" "github.com/spf13/cobra" + + "github.com/yarlson/lnk/internal/core" + "github.com/yarlson/lnk/internal/fs" + "github.com/yarlson/lnk/internal/git" ) var ( @@ -14,6 +19,12 @@ var ( // NewRootCommand creates a new root command (testable) func NewRootCommand() *cobra.Command { + var ( + colors string + emoji bool + noEmoji bool + ) + rootCmd := &cobra.Command{ Use: "lnk", Short: "🔗 Dotfiles, linked. No fluff.", @@ -42,8 +53,29 @@ Supports both common configurations, host-specific setups, and bulk operations f SilenceUsage: true, SilenceErrors: true, Version: fmt.Sprintf("%s (built %s)", version, buildTime), + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + // Handle emoji flag logic + emojiEnabled := emoji + if noEmoji { + emojiEnabled = false + } + err := SetGlobalConfig(colors, emojiEnabled) + if err != nil { + return err + } + + return nil + }, } + // Add global flags for output formatting + rootCmd.PersistentFlags().StringVar(&colors, "colors", "auto", "when to use colors (auto, always, never)") + rootCmd.PersistentFlags().BoolVar(&emoji, "emoji", true, "enable emoji in output") + rootCmd.PersistentFlags().BoolVar(&noEmoji, "no-emoji", false, "disable emoji in output") + + // Mark emoji flags as mutually exclusive + rootCmd.MarkFlagsMutuallyExclusive("emoji", "no-emoji") + // Add subcommands rootCmd.AddCommand(newInitCmd()) rootCmd.AddCommand(newAddCmd()) @@ -66,7 +98,98 @@ func SetVersion(v, bt string) { func Execute() { rootCmd := NewRootCommand() if err := rootCmd.Execute(); err != nil { - _, _ = fmt.Fprintln(os.Stderr, err) + DisplayError(err) os.Exit(1) } } + +// DisplayError formats and displays an error with appropriate styling +func DisplayError(err error) { + w := GetErrorWriter() + + // Handle structured errors from core package + var lnkErr *core.LnkError + if errors.As(err, &lnkErr) { + w.Write(Error(lnkErr.Message)) + if lnkErr.Path != "" { + w.WritelnString(""). + WriteString(" "). + Write(Colored(lnkErr.Path, ColorRed)) + } + if lnkErr.Suggestion != "" { + w.WritelnString(""). + WriteString(" "). + Write(Info(lnkErr.Suggestion)) + } + w.WritelnString("") + return + } + + // Handle structured errors from fs package + var pathErr fs.ErrorWithPath + if errors.As(err, &pathErr) { + w.Write(Error(err.Error())) + if pathErr.GetPath() != "" { + w.WritelnString(""). + WriteString(" "). + Write(Colored(pathErr.GetPath(), ColorRed)) + } + var suggErr fs.ErrorWithSuggestion + if errors.As(err, &suggErr) { + w.WritelnString(""). + WriteString(" "). + Write(Info(suggErr.GetSuggestion())) + } + w.WritelnString("") + return + } + + // Handle fs errors that only have suggestions + var suggErr fs.ErrorWithSuggestion + if errors.As(err, &suggErr) { + w.Write(Error(err.Error())). + WritelnString(""). + WriteString(" "). + Write(Info(suggErr.GetSuggestion())). + WritelnString("") + return + } + + // Handle git errors with paths + var gitPathErr git.ErrorWithPath + if errors.As(err, &gitPathErr) { + w.Write(Error(err.Error())). + WritelnString(""). + WriteString(" "). + Write(Colored(gitPathErr.GetPath(), ColorRed)). + WritelnString("") + return + } + + // Handle git errors with remotes + var gitRemoteErr git.ErrorWithRemote + if errors.As(err, &gitRemoteErr) { + w.Write(Error(err.Error())). + WritelnString(""). + WriteString(" Remote: "). + Write(Colored(gitRemoteErr.GetRemote(), ColorCyan)). + WritelnString("") + return + } + + // Handle git errors with reasons + var gitReasonErr git.ErrorWithReason + if errors.As(err, &gitReasonErr) { + w.Write(Error(err.Error())) + if gitReasonErr.GetReason() != "" { + w.WritelnString(""). + WriteString(" Reason: "). + Write(Colored(gitReasonErr.GetReason(), ColorYellow)) + } + w.WritelnString("") + return + } + + // Handle generic errors + w.Writeln(Error(err.Error())) +} diff --git a/cmd/status.go b/cmd/status.go index 46415d3..4ccab04 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -1,7 +1,10 @@ package cmd import ( + "fmt" + "github.com/spf13/cobra" + "github.com/yarlson/lnk/internal/core" ) @@ -36,51 +39,93 @@ func newStatusCmd() *cobra.Command { } func displayDirtyStatus(cmd *cobra.Command, status *core.StatusInfo) { - printf(cmd, "⚠️ \033[1;33mRepository has uncommitted changes\033[0m\n") - printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", status.Remote) + w := GetWriter(cmd) + + w.Writeln(Warning("Repository has uncommitted changes")). + WriteString(" "). + Write(Message{Text: "Remote: ", Emoji: "📡"}). + Writeln(Colored(status.Remote, ColorCyan)) if status.Ahead == 0 && status.Behind == 0 { - printf(cmd, "\n💡 Run \033[1mgit add && git commit\033[0m in \033[36m~/.config/lnk\033[0m or \033[1mlnk push\033[0m to commit changes\n") + w.WritelnString(""). + Write(Info("Run ")). + Write(Bold("git add && git commit")). + WriteString(" in "). + Write(Colored("~/.config/lnk", ColorCyan)). + WriteString(" or "). + Write(Bold("lnk push")). + WritelnString(" to commit changes") return } - printf(cmd, "\n") + w.WritelnString("") displayAheadBehindInfo(cmd, status, true) - printf(cmd, "\n💡 Run \033[1mgit add && git commit\033[0m in \033[36m~/.config/lnk\033[0m or \033[1mlnk push\033[0m to commit changes\n") + w.WritelnString(""). + Write(Info("Run ")). + Write(Bold("git add && git commit")). + WriteString(" in "). + Write(Colored("~/.config/lnk", ColorCyan)). + WriteString(" or "). + Write(Bold("lnk push")). + WritelnString(" to commit changes") } func displayUpToDateStatus(cmd *cobra.Command, status *core.StatusInfo) { - printf(cmd, "✅ \033[1;32mRepository is up to date\033[0m\n") - printf(cmd, " 📡 Synced with \033[36m%s\033[0m\n", status.Remote) + w := GetWriter(cmd) + + w.Writeln(Success("Repository is up to date")). + WriteString(" "). + Write(Message{Text: "Synced with ", Emoji: "📡"}). + Writeln(Colored(status.Remote, ColorCyan)) } func displaySyncStatus(cmd *cobra.Command, status *core.StatusInfo) { - printf(cmd, "📊 \033[1mRepository Status\033[0m\n") - printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", status.Remote) - printf(cmd, "\n") + w := GetWriter(cmd) + + w.Writeln(Message{Text: "Repository Status", Emoji: "📊", Bold: true}). + WriteString(" "). + Write(Message{Text: "Remote: ", Emoji: "📡"}). + Writeln(Colored(status.Remote, ColorCyan)). + WritelnString("") displayAheadBehindInfo(cmd, status, false) if status.Ahead > 0 && status.Behind == 0 { - printf(cmd, "\n💡 Run \033[1mlnk push\033[0m to sync your changes\n") + w.WritelnString(""). + Write(Info("Run ")). + Write(Bold("lnk push")). + WritelnString(" to sync your changes") } else if status.Behind > 0 { - printf(cmd, "\n💡 Run \033[1mlnk pull\033[0m to get latest changes\n") + w.WritelnString(""). + Write(Info("Run ")). + Write(Bold("lnk pull")). + WritelnString(" to get latest changes") } } func displayAheadBehindInfo(cmd *cobra.Command, status *core.StatusInfo, isDirty bool) { + w := GetWriter(cmd) + if status.Ahead > 0 { commitText := getCommitText(status.Ahead) if isDirty { - printf(cmd, " ⬆️ \033[1;33m%d %s ahead\033[0m (excluding uncommitted changes)\n", status.Ahead, commitText) + w.WriteString(" "). + Write(Message{Text: fmt.Sprintf("%d %s ahead", status.Ahead, commitText), Emoji: "⬆️", Color: ColorBrightYellow, Bold: true}). + WritelnString(" (excluding uncommitted changes)") } else { - printf(cmd, " ⬆️ \033[1;33m%d %s ahead\033[0m - ready to push\n", status.Ahead, commitText) + w.WriteString(" "). + Write(Message{Text: fmt.Sprintf("%d %s ahead", status.Ahead, commitText), Emoji: "⬆️", Color: ColorBrightYellow, Bold: true}). + WritelnString(" - ready to push") } } if status.Behind > 0 { commitText := getCommitText(status.Behind) - printf(cmd, " ⬇️ \033[1;31m%d %s behind\033[0m - run \033[1mlnk pull\033[0m\n", status.Behind, commitText) + w.WriteString(" "). + Write(Message{Text: fmt.Sprintf("%d %s behind", status.Behind, commitText), Emoji: "⬇️", Color: ColorBrightRed, Bold: true}). + WriteString(" - run "). + Write(Bold("lnk pull")). + WritelnString("") } } diff --git a/cmd/utils.go b/cmd/utils.go deleted file mode 100644 index e4ed7d8..0000000 --- a/cmd/utils.go +++ /dev/null @@ -1,12 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -// printf is a helper function to simplify output formatting in commands -func printf(cmd *cobra.Command, format string, args ...interface{}) { - _, _ = fmt.Fprintf(cmd.OutOrStdout(), format, args...) -} diff --git a/go.mod b/go.mod index 52ad0c0..53b142a 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,6 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/spf13/pflag v1.0.6 // indirect + github.com/spf13/pflag v1.0.7 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 4695b18..9888b4c 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= +github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/internal/core/errors.go b/internal/core/errors.go new file mode 100644 index 0000000..032e8ea --- /dev/null +++ b/internal/core/errors.go @@ -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", + } +} diff --git a/internal/core/lnk.go b/internal/core/lnk.go index 90bdbe3..ac4049c 100644 --- a/internal/core/lnk.go +++ b/internal/core/lnk.go @@ -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) } } diff --git a/internal/fs/errors.go b/internal/fs/errors.go index 850a388..8c90ee3 100644 --- a/internal/fs/errors.go +++ b/internal/fs/errors.go @@ -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 +} diff --git a/internal/git/errors.go b/internal/git/errors.go index dba7929..fac446a 100644 --- a/internal/git/errors.go +++ b/internal/git/errors.go @@ -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 +}