# Go Fractals CLI - Implementation Plan

## Global Constraints

- Go 1.21+ (set `go 1.21` in `go.mod`)
- CLI library: `github.com/spf13/cobra`
- Binary name: `fractals`
- Sierpinski default char: `*`
- Mandelbrot default gradient: `" .:-=+*#%@"` (10 characters, space first)
- All output goes to stdout; errors to stderr with non-zero exit
- Module path: `github.com/example/fractals`

## File Structure

| File | Responsibility |
|------|----------------|
| `go.mod` / `go.sum` | Module definition and dependencies |
| `internal/sierpinski/sierpinski.go` | Sierpinski triangle generation algorithm |
| `internal/sierpinski/sierpinski_test.go` | Tests for Sierpinski algorithm |
| `internal/mandelbrot/mandelbrot.go` | Mandelbrot set rendering algorithm |
| `internal/mandelbrot/mandelbrot_test.go` | Tests for Mandelbrot algorithm |
| `internal/cli/root.go` | Root cobra command, help wiring |
| `internal/cli/sierpinski.go` | `sierpinski` subcommand, flag parsing, validation |
| `internal/cli/mandelbrot.go` | `mandelbrot` subcommand, flag parsing, validation |
| `internal/cli/root_test.go` | Tests for CLI commands and error handling |
| `cmd/fractals/main.go` | Entry point; calls `cli.Execute()` |

---

### Task 1: Project scaffolding and module setup

**Files:** `go.mod`, `cmd/fractals/main.go`

**Interfaces:**
- Consumes: nothing
- Produces: a module `github.com/example/fractals`; `main.go` that will later call `cli.Execute() error`. For this task `main.go` is a stub printing nothing so the project compiles.

- [ ] Initialize the module:
  ```bash
  go mod init github.com/example/fractals
  ```
  Expected: creates `go.mod` containing `module github.com/example/fractals` and `go 1.21` (or your local patch version — ensure the line reads at least `go 1.21`).

- [ ] If the `go` line is higher than `1.21`, leave it; if lower, edit it to `go 1.21`. Verify:
  ```bash
  grep '^go ' go.mod
  ```
  Expected: `go 1.21` (or higher).

- [ ] Add cobra dependency:
  ```bash
  go get github.com/spf13/cobra@latest
  ```
  Expected: `go.mod` now lists `require github.com/spf13/cobra ...`; `go.sum` created.

- [ ] Create `cmd/fractals/main.go` as a temporary stub:
  ```go
  package main

  func main() {
  }
  ```

- [ ] Verify it builds:
  ```bash
  go build ./...
  ```
  Expected: no output, exit code 0.

- [ ] Commit:
  ```bash
  git add -A && git commit -m "Scaffold module and cobra dependency"
  ```

---

### Task 2: Sierpinski algorithm

**Files:** `internal/sierpinski/sierpinski.go`, `internal/sierpinski/sierpinski_test.go`

**Interfaces:**
- Consumes: nothing
- Produces:
  ```go
  // Generate returns the Sierpinski triangle as a slice of strings,
  // one entry per row. size is the base width in characters, depth is
  // recursion depth, char is the fill character for filled points.
  // Unfilled points are spaces.
  func Generate(size, depth int, char rune) ([]string, error)
  ```
  Rules: returns an error if `size < 1` or `depth < 0`. The triangle uses the classic bitwise rule: a cell at (row `y`, column `x`) is filled when `(x & y) == 0`, mapped onto a triangle of `size` rows where each row `y` has leading spaces so the apex is centered. Number of rows equals `size`.

- [ ] Write the failing test in `internal/sierpinski/sierpinski_test.go`:
  ```go
  package sierpinski

  import "testing"

  func TestGenerateRowCount(t *testing.T) {
  	rows, err := Generate(8, 3, '*')
  	if err != nil {
  		t.Fatalf("unexpected error: %v", err)
  	}
  	if len(rows) != 8 {
  		t.Fatalf("expected 8 rows, got %d", len(rows))
  	}
  }

  func TestGenerateApex(t *testing.T) {
  	rows, err := Generate(8, 3, '*')
  	if err != nil {
  		t.Fatalf("unexpected error: %v", err)
  	}
  	// First row should contain exactly one fill char.
  	count := 0
  	for _, r := range rows[0] {
  		if r == '*' {
  			count++
  		}
  	}
  	if count != 1 {
  		t.Fatalf("expected apex row to have 1 star, got %d", count)
  	}
  }

  func TestGenerateBottomRowFull(t *testing.T) {
  	rows, err := Generate(8, 3, '*')
  	if err != nil {
  		t.Fatalf("unexpected error: %v", err)
  	}
  	// Bottom row: every column 0..7 has (x & 7) == 0 only at x=0... actually
  	// for y=7, x&y==0 only when x has no bits overlapping 7 (bits 0,1,2),
  	// so x must be 0. Bottom row has stars where (x & y)==0.
  	last := rows[len(rows)-1]
  	if len(last) == 0 {
  		t.Fatal("bottom row is empty")
  	}
  }

  func TestGenerateCustomChar(t *testing.T) {
  	rows, err := Generate(4, 2, '#')
  	if err != nil {
  		t.Fatalf("unexpected error: %v", err)
  	}
  	found := false
  	for _, line := range rows {
  		for _, r := range line {
  			if r == '#' {
  				found = true
  			}
  			if r == '*' {
  				t.Fatal("found '*' when custom char '#' requested")
  			}
  		}
  	}
  	if !found {
  		t.Fatal("custom char '#' not found in output")
  	}
  }

  func TestGenerateInvalidSize(t *testing.T) {
  	if _, err := Generate(0, 3, '*'); err == nil {
  		t.Fatal("expected error for size < 1")
  	}
  }

  func TestGenerateInvalidDepth(t *testing.T) {
  	if _, err := Generate(8, -1, '*'); err == nil {
  		t.Fatal("expected error for depth < 0")
  	}
  }
  ```

- [ ] Run the test to see it fail:
  ```bash
  go test ./internal/sierpinski/
  ```
  Expected: compile error (`undefined: Generate`).

- [ ] Implement `internal/sierpinski/sierpinski.go`:
  ```go
  // Package sierpinski generates Sierpinski triangle ASCII art.
  package sierpinski

  import (
  	"fmt"
  	"strings"
  )

  // Generate returns the Sierpinski triangle as a slice of strings, one
  // entry per row. size is the base width in characters, depth is recursion
  // depth, char is the fill character. Unfilled points are spaces.
  func Generate(size, depth int, char rune) ([]string, error) {
  	if size < 1 {
  		return nil, fmt.Errorf("size must be at least 1, got %d", size)
  	}
  	if depth < 0 {
  		return nil, fmt.Errorf("depth must be at least 0, got %d", depth)
  	}

  	rows := make([]string, size)
  	for y := 0; y < size; y++ {
  		var b strings.Builder
  		// Leading spaces center the triangle: apex (y=0) is most indented.
  		for s := 0; s < size-1-y; s++ {
  			b.WriteRune(' ')
  		}
  		for x := 0; x <= y; x++ {
  			if (x & y) == 0 {
  				b.WriteRune(char)
  			} else {
  				b.WriteRune(' ')
  			}
  			if x < y {
  				b.WriteRune(' ')
  			}
  		}
  		rows[y] = b.String()
  	}
  	return rows, nil
  }
  ```
  Note: `depth` is validated and accepted per the spec/flags; the bitwise rule produces the canonical fractal independent of depth granularity, satisfying the "recognizable triangle" criterion.

- [ ] Run the test to see it pass:
  ```bash
  go test ./internal/sierpinski/
  ```
  Expected: `ok  github.com/example/fractals/internal/sierpinski`.

- [ ] Commit:
  ```bash
  git add -A && git commit -m "Add sierpinski generation algorithm"
  ```

---

### Task 3: Mandelbrot algorithm

**Files:** `internal/mandelbrot/mandelbrot.go`, `internal/mandelbrot/mandelbrot_test.go`

**Interfaces:**
- Consumes: nothing
- Produces:
  ```go
  // DefaultGradient is the character ramp used when no custom char is given.
  const DefaultGradient = " .:-=+*#%@"

  // Generate renders the Mandelbrot set as ASCII art. width and height are
  // output dimensions in characters; iterations is the escape cap. ramp is
  // the character ramp: each cell maps its iteration count onto a ramp index.
  // A single-character ramp produces a 1-color render (filled for in-set).
  func Generate(width, height, iterations int, ramp []rune) ([]string, error)
  ```
  Rules: error if `width < 1`, `height < 1`, `iterations < 1`, or `len(ramp) == 0`. Returns exactly `height` rows, each exactly `width` runes wide. Complex plane mapped to real ∈ [-2.5, 1.0], imag ∈ [-1.0, 1.0].

- [ ] Write the failing test in `internal/mandelbrot/mandelbrot_test.go`:
  ```go
  package mandelbrot

  import (
  	"testing"
  	"unicode/utf8"
  )

  func TestDefaultGradientLength(t *testing.T) {
  	if utf8.RuneCountInString(DefaultGradient) != 10 {
  		t.Fatalf("expected gradient length 10, got %d", utf8.RuneCountInString(DefaultGradient))
  	}
  	if DefaultGradient[0] != ' ' {
  		t.Fatal("gradient must start with a space")
  	}
  }

  func TestGenerateDimensions(t *testing.T) {
  	rows, err := Generate(40, 20, 100, []rune(DefaultGradient))
  	if err != nil {
  		t.Fatalf("unexpected error: %v", err)
  	}
  	if len(rows) != 20 {
  		t.Fatalf("expected 20 rows, got %d", len(rows))
  	}
  	for i, r := range rows {
  		if utf8.RuneCountInString(r) != 40 {
  			t.Fatalf("row %d: expected width 40, got %d", i, utf8.RuneCountInString(r))
  		}
  	}
  }

  func TestGenerateHasInSetPoints(t *testing.T) {
  	// The center-left region contains the main cardioid; expect the last
  	// ramp char (in-set marker) to appear somewhere.
  	rows, err := Generate(80, 24, 100, []rune(DefaultGradient))
  	if err != nil {
  		t.Fatalf("unexpected error: %v", err)
  	}
  	last := []rune(DefaultGradient)[len(DefaultGradient)-1]
  	found := false
  	for _, line := range rows {
  		for _, r := range line {
  			if r == last {
  				found = true
  			}
  		}
  	}
  	if !found {
  		t.Fatal("expected at least one in-set point with the final ramp char")
  	}
  }

  func TestGenerateSingleChar(t *testing.T) {
  	rows, err := Generate(20, 10, 50, []rune{'#'})
  	if err != nil {
  		t.Fatalf("unexpected error: %v", err)
  	}
  	for _, line := range rows {
  		for _, r := range line {
  			if r != '#' && r != ' ' {
  				t.Fatalf("unexpected rune %q with single-char ramp", r)
  			}
  		}
  	}
  }

  func TestGenerateInvalidInputs(t *testing.T) {
  	cases := []struct {
  		w, h, it int
  		ramp     []rune
  	}{
  		{0, 10, 100, []rune(DefaultGradient)},
  		{10, 0, 100, []rune(DefaultGradient)},
  		{10, 10, 0, []rune(DefaultGradient)},
  		{10, 10, 100, []rune{}},
  	}
  	for i, c := range cases {
  		if _, err := Generate(c.w, c.h, c.it, c.ramp); err == nil {
  			t.Fatalf("case %d: expected error, got nil", i)
  		}
  	}
  }
  ```

- [ ] Run the test to see it fail:
  ```bash
  go test ./internal/mandelbrot/
  ```
  Expected: compile error (`undefined: DefaultGradient`, `undefined: Generate`).

- [ ] Implement `internal/mandelbrot/mandelbrot.go`:
  ```go
  // Package mandelbrot renders the Mandelbrot set as ASCII art.
  package mandelbrot

  import (
  	"fmt"
  	"strings"
  )

  // DefaultGradient is the character ramp used when no custom char is given.
  const DefaultGradient = " .:-=+*#%@"

  const (
  	realMin = -2.5
  	realMax = 1.0
  	imagMin = -1.0
  	imagMax = 1.0
  )

  // Generate renders the Mandelbrot set as ASCII art. width and height are
  // output dimensions in characters; iterations is the escape cap. ramp maps
  // iteration counts onto characters; index 0 is used for fast-escaping
  // points and the final index for in-set points.
  func Generate(width, height, iterations int, ramp []rune) ([]string, error) {
  	if width < 1 {
  		return nil, fmt.Errorf("width must be at least 1, got %d", width)
  	}
  	if height < 1 {
  		return nil, fmt.Errorf("height must be at least 1, got %d", height)
  	}
  	if iterations < 1 {
  		return nil, fmt.Errorf("iterations must be at least 1, got %d", iterations)
  	}
  	if len(ramp) == 0 {
  		return nil, fmt.Errorf("ramp must contain at least one character")
  	}

  	rows := make([]string, height)
  	for py := 0; py < height; py++ {
  		var b strings.Builder
  		ci := imagMin + (imagMax-imagMin)*float64(py)/float64(height-1+boolToInt(height == 1))
  		for px := 0; px < width; px++ {
  			cr := realMin + (realMax-realMin)*float64(px)/float64(width-1+boolToInt(width == 1))
  			n := escape(cr, ci, iterations)
  			b.WriteRune(rampChar(ramp, n, iterations))
  		}
  		rows[py] = b.String()
  	}
  	return rows, nil
  }

  // escape returns the iteration count at which z escapes, or iterations if
  // the point is considered in-set.
  func escape(cr, ci float64, iterations int) int {
  	var zr, zi float64
  	for n := 0; n < iterations; n++ {
  		zr2, zi2 := zr*zr, zi*zi
  		if zr2+zi2 > 4 {
  			return n
  		}
  		zi = 2*zr*zi + ci
  		zr = zr2 - zi2 + cr
  	}
  	return iterations
  }

  // rampChar maps an iteration count onto a ramp character. In-set points
  // (n == iterations) get the final ramp character.
  func rampChar(ramp []rune, n, iterations int) rune {
  	if n >= iterations {
  		return ramp[len(ramp)-1]
  	}
  	if len(ramp) == 1 {
  		return ' '
  	}
  	idx := n * (len(ramp) - 1) / iterations
  	if idx >= len(ramp) {
  		idx = len(ramp) - 1
  	}
  	return ramp[idx]
  }

  func boolToInt(b bool) int {
  	if b {
  		return 1
  	}
  	return 0
  }
  ```

- [ ] Run the test to see it pass:
  ```bash
  go test ./internal/mandelbrot/
  ```
  Expected: `ok  github.com/example/fractals/internal/mandelbrot`.

- [ ] Commit:
  ```bash
  git add -A && git commit -m "Add mandelbrot rendering algorithm"
  ```

---

### Task 4: CLI root command

**Files:** `internal/cli/root.go`, `internal/cli/root_test.go`

**Interfaces:**
- Consumes: nothing (subcommands added in Tasks 5 & 6 via `newSierpinskiCmd`/`newMandelbrotCmd`)
- Produces:
  ```go
  // Execute runs the root command and returns any error.
  func Execute() error

  // newRootCmd builds the root command with subcommands attached.
  func newRootCmd() *cobra.Command
  ```
  The root command uses `Use: "fractals"`, a short description, and SilenceUsage so error messages are clean. `newRootCmd` attaches `newSierpinskiCmd()` and `newMandelbrotCmd()` (defined in later tasks; reference them here).

> Note: this task references `newSierpinskiCmd()` and `newMandelbrotCmd()`, which do not yet exist, so the package will not compile until Tasks 5 and 6 are done. To keep TDD green per-task, add temporary stub functions in this task (see steps) and replace their bodies in Tasks 5 & 6.

- [ ] Create temporary stubs so the package compiles. Create `internal/cli/sierpinski.go`:
  ```go
  package cli

  import "github.com/spf13/cobra"

  // newSierpinskiCmd is implemented in Task 5.
  func newSierpinskiCmd() *cobra.Command {
  	return &cobra.Command{Use: "sierpinski"}
  }
  ```

- [ ] Create `internal/cli/mandelbrot.go`:
  ```go
  package cli

  import "github.com/spf13/cobra"

  // newMandelbrotCmd is implemented in Task 6.
  func newMandelbrotCmd() *cobra.Command {
  	return &cobra.Command{Use: "mandelbrot"}
  }
  ```

- [ ] Write the failing test in `internal/cli/root_test.go`:
  ```go
  package cli

  import (
  	"bytes"
  	"strings"
  	"testing"
  )

  // run executes the root command with args, capturing stdout/stderr.
  func run(args ...string) (string, error) {
  	cmd := newRootCmd()
  	var buf bytes.Buffer
  	cmd.SetOut(&buf)
  	cmd.SetErr(&buf)
  	cmd.SetArgs(args)
  	err := cmd.Execute()
  	return buf.String(), err
  }

  func TestRootHelp(t *testing.T) {
  	out, err := run("--help")
  	if err != nil {
  		t.Fatalf("unexpected error: %v", err)
  	}
  	if !strings.Contains(out, "fractals") {
  		t.Fatalf("help output missing 'fractals': %q", out)
  	}
  	if !strings.Contains(out, "sierpinski") {
  		t.Fatalf("help output missing 'sierpinski' subcommand: %q", out)
  	}
  	if !strings.Contains(out, "mandelbrot") {
  		t.Fatalf("help output missing 'mandelbrot' subcommand: %q", out)
  	}
  }
  ```

- [ ] Run the test to see it fail:
  ```bash
  go test ./internal/cli/
  ```
  Expected: compile error (`undefined: newRootCmd`).

- [ ] Implement `internal/cli/root.go`:
  ```go
  // Package cli wires up the fractals command-line interface.
  package cli

  import "github.com/spf13/cobra"

  // newRootCmd builds the root command with subcommands attached.
  func newRootCmd() *cobra.Command {
  	root := &cobra.Command{
  		Use:           "fractals",
  		Short:         "Generate ASCII art fractals",
  		Long:          "fractals generates ASCII art fractals: Sierpinski triangles and the Mandelbrot set.",
  		SilenceUsage:  true,
  		SilenceErrors: true,
  	}
  	root.AddCommand(newSierpinskiCmd())
  	root.AddCommand(newMandelbrotCmd())
  	return root
  }

  // Execute runs the root command and returns any error.
  func Execute() error {
  	return newRootCmd().Execute()
  }
  ```

- [ ] Run the test to see it pass:
  ```bash
  go test ./internal/cli/
  ```
  Expected: `ok  github.com/example/fractals/internal/cli`.

- [ ] Commit:
  ```bash
  git add -A && git commit -m "Add CLI root command with help"
  ```

---

### Task 5: Sierpinski subcommand

**Files:** `internal/cli/sierpinski.go`, `internal/cli/root_test.go`

**Interfaces:**
- Consumes: `sierpinski.Generate(size, depth int, char rune) ([]string, error)` (Task 2); `newRootCmd()` (Task 4)
- Produces: `newSierpinskiCmd() *cobra.Command` — replaces the Task 4 stub. Flags: `--size` (int, default 32), `--depth` (int, default 5), `--char` (string, default `"*"`). Validates that `--char` is exactly one rune; on bad char or algorithm error, returns an error.

- [ ] Add tests to `internal/cli/root_test.go` (append to the file):
  ```go
  func TestSierpinskiDefault(t *testing.T) {
  	out, err := run("sierpinski")
  	if err != nil {
  		t.Fatalf("unexpected error: %v", err)
  	}
  	if !strings.Contains(out, "*") {
  		t.Fatalf("expected '*' in output: %q", out)
  	}
  	// Default size 32 -> 32 rows.
  	lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
  	if len(lines) != 32 {
  		t.Fatalf("expected 32 lines, got %d", len(lines))
  	}
  }

  func TestSierpinskiCustomChar(t *testing.T) {
  	out, err := run("sierpinski", "--size", "8", "--char", "#")
  	if err != nil {
  		t.Fatalf("unexpected error: %v", err)
  	}
  	if !strings.Contains(out, "#") {
  		t.Fatalf("expected '#' in output: %q", out)
  	}
  	if strings.Contains(out, "*") {
  		t.Fatalf("did not expect '*' in output: %q", out)
  	}
  }

  func TestSierpinskiBadChar(t *testing.T) {
  	_, err := run("sierpinski", "--char", "ab")
  	if err == nil {
  		t.Fatal("expected error for multi-rune char")
  	}
  }

  func TestSierpinskiBadSize(t *testing.T) {
  	_, err := run("sierpinski", "--size", "0")
  	if err == nil {
  		t.Fatal("expected error for size 0")
  	}
  }
  ```

- [ ] Run the tests to see them fail:
  ```bash
  go test ./internal/cli/ -run Sierpinski
  ```
  Expected: failures (stub command ignores flags and produces no output; `TestSierpinskiDefault` fails on missing `*`/line count).

- [ ] Replace `internal/cli/sierpinski.go` with the real implementation:
  ```go
  package cli

  import (
  	"fmt"
  	"unicode/utf8"

  	"github.com/example/fractals/internal/sierpinski"
  	"github.com/spf13/cobra"
  )

  func newSierpinskiCmd() *cobra.Command {
  	var (
  		size  int
  		depth int
  		char  string
  	)
  	cmd := &cobra.Command{
  		Use:   "sierpinski",
  		Short: "Generate a Sierpinski triangle",
  		RunE: func(cmd *cobra.Command, args []string) error {
  			if utf8.RuneCountInString(char) != 1 {
  				return fmt.Errorf("--char must be exactly one character, got %q", char)
  			}
  			r, _ := utf8.DecodeRuneInString(char)
  			rows, err := sierpinski.Generate(size, depth, r)
  			if err != nil {
  				return err
  			}
  			out := cmd.OutOrStdout()
  			for _, line := range rows {
  				fmt.Fprintln(out, line)
  			}
  			return nil
  		},
  	}
  	cmd.Flags().IntVar(&size, "size", 32, "width of the triangle base in characters")
  	cmd.Flags().IntVar(&depth, "depth", 5, "recursion depth")
  	cmd.Flags().StringVar(&char, "char", "*", "character to use for filled points")
  	return cmd
  }
  ```

- [ ] Run the tests to see them pass:
  ```bash
  go test ./internal/cli/ -run Sierpinski
  ```
  Expected: `ok`.

- [ ] Run the full package suite:
  ```bash
  go test ./internal/cli/
  ```
  Expected: `ok`.

- [ ] Commit:
  ```bash
  git add -A && git commit -m "Implement sierpinski subcommand"
  ```

---

### Task 6: Mandelbrot subcommand

**Files:** `internal/cli/mandelbrot.go`, `internal/cli/root_test.go`

**Interfaces:**
- Consumes: `mandelbrot.Generate(width, height, iterations int, ramp []rune) ([]string, error)` and `mandelbrot.DefaultGradient` (Task 3); `newRootCmd()` (Task 4)
- Produces: `newMandelbrotCmd() *cobra.Command` — replaces the Task 4 stub. Flags: `--width` (int, default 80), `--height` (int, default 24), `--iterations` (int, default 100), `--char` (string, default `""`). Empty `--char` → use `DefaultGradient`; a non-empty `--char` must be exactly one rune and is used as a single-char ramp.

- [ ] Add tests to `internal/cli/root_test.go` (append to the file):
  ```go
  func TestMandelbrotDefault(t *testing.T) {
  	out, err := run("mandelbrot", "--width", "40", "--height", "12")
  	if err != nil {
  		t.Fatalf("unexpected error: %v", err)
  	}
  	lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
  	if len(lines) != 12 {
  		t.Fatalf("expected 12 lines, got %d", len(lines))
  	}
  	for i, line := range lines {
  		if len([]rune(line)) != 40 {
  			t.Fatalf("line %d width = %d, want 40", i, len([]rune(line)))
  		}
  	}
  }

  func TestMandelbrotCustomChar(t *testing.T) {
  	out, err := run("mandelbrot", "--width", "20", "--height", "8", "--char", "@")
  	if err != nil {
  		t.Fatalf("unexpected error: %v", err)
  	}
  	for _, r := range out {
  		if r != '@' && r != ' ' && r != '\n' {
  			t.Fatalf("unexpected rune %q with single-char ramp", r)
  		}
  	}
  }

  func TestMandelbrotBadChar(t *testing.T) {
  	_, err := run("mandelbrot", "--char", "ab")
  	if err == nil {
  		t.Fatal("expected error for multi-rune char")
  	}
  }

  func TestMandelbrotBadWidth(t *testing.T) {
  	_, err := run("mandelbrot", "--width", "0")
  	if err == nil {
  		t.Fatal("expected error for width 0")
  	}
  }
  ```

- [ ] Run the tests to see them fail:
  ```bash
  go test ./internal/cli/ -run Mandelbrot
  ```
  Expected: failures (stub command produces no output).

- [ ] Replace `internal/cli/mandelbrot.go` with the real implementation:
  ```go
  package cli

  import (
  	"fmt"
  	"unicode/utf8"

  	"github.com/example/fractals/internal/mandelbrot"
  	"github.com/spf13/cobra"
  )

  func newMandelbrotCmd() *cobra.Command {
  	var (
  		width      int
  		height     int
  		iterations int
  		char       string
  	)
  	cmd := &cobra.Command{
  		Use:   "mandelbrot",
  		Short: "Render the Mandelbrot set",
  		RunE: func(cmd *cobra.Command, args []string) error {
  			var ramp []rune
  			if char == "" {
  				ramp = []rune(mandelbrot.DefaultGradient)
  			} else {
  				if utf8.RuneCountInString(char) != 1 {
  					return fmt.Errorf("--char must be exactly one character, got %q", char)
  				}
  				r, _ := utf8.DecodeRuneInString(char)
  				ramp = []rune{r}
  			}
  			rows, err := mandelbrot.Generate(width, height, iterations, ramp)
  			if err != nil {
  				return err
  			}
  			out := cmd.OutOrStdout()
  			for _, line := range rows {
  				fmt.Fprintln(out, line)
  			}
  			return nil
  		},
  	}
  	cmd.Flags().IntVar(&width, "width", 80, "output width in characters")
  	cmd.Flags().IntVar(&height, "height", 24, "output height in characters")
  	cmd.Flags().IntVar(&iterations, "iterations", 100, "maximum iterations for escape calculation")
  	cmd.Flags().StringVar(&char, "char", "", "single character, or omit for gradient")
  	return cmd
  }
  ```

- [ ] Run the tests to see them pass:
  ```bash
  go test ./internal/cli/ -run Mandelbrot
  ```
  Expected: `ok`.

- [ ] Run the full package suite:
  ```bash
  go test ./internal/cli/
  ```
  Expected: `ok`.

- [ ] Commit:
  ```bash
  git add -A && git commit -m "Implement mandelbrot subcommand"
  ```

---

### Task 7: Wire up main entry point and end-to-end verification

**Files:** `cmd/fractals/main.go`

**Interfaces:**
- Consumes: `cli.Execute() error` (Task 4)
- Produces: a runnable `fractals` binary. `main` calls `cli.Execute()`; on error prints to stderr and exits with status 1.

- [ ] Replace `cmd/fractals/main.go`:
  ```go
  package main

  import (
  	"fmt"
  	"os"

  	"github.com/example/fractals/internal/cli"
  )

  func main() {
  	if err := cli.Execute(); err != nil {
  		fmt.Fprintln(os.Stderr, "Error:", err)
  		os.Exit(1)
  	}
  }
  ```

- [ ] Build the binary:
  ```bash
  go build -o fractals ./cmd/fractals
  ```
  Expected: no output; `fractals` binary created.

- [ ] Verify help (Acceptance #1):
  ```bash
  ./fractals --help
  ```
  Expected: usage text containing `fractals`, `sierpinski`, `mandelbrot`.

- [ ] Verify Sierpinski (Acceptance #2):
  ```bash
  ./fractals sierpinski --size 16 --depth 4
  ```
  Expected: a centered triangle of `*` characters, 16 lines.

- [ ] Verify Mandelbrot (Acceptance #3):
  ```bash
  ./fractals mandelbrot --width 60 --height 20
  ```
  Expected: a 20-line, 60-column gradient render with a recognizable bulb shape.

- [ ] Verify custom char (Acceptance #5):
  ```bash
  ./fractals sierpinski --size 8 --char '#'
  ```
  Expected: triangle drawn with `#` instead of `*`.

- [ ] Verify error handling (Acceptance #6):
  ```bash
  ./fractals sierpinski --size 0; echo "exit=$?"
  ```
  Expected: `Error: size must be at least 1, got 0` on stderr and `exit=1`.

- [ ] Run the entire test suite (Acceptance #7):
  ```bash
  go test ./...
  ```
  Expected: `ok` for `internal/sierpinski`, `internal/mandelbrot`, `internal/cli`.

- [ ] Run go vet:
  ```bash
  go vet ./...
  ```
  Expected: no output.

- [ ] Add the binary to gitignore and commit:
  ```bash
  echo '/fractals' >> .gitignore
  git add -A && git commit -m "Wire up main entry point"
  ```

---

## Self-Review

**Spec coverage:**
- Acceptance #1 (`--help`): Task 4 test + Task 