Skip to content

feat: add projects to cli tool - #7

Merged
zhravan merged 20 commits into
mainfrom
feat/projects
Sep 3, 2025
Merged

feat: add projects to cli tool#7
zhravan merged 20 commits into
mainfrom
feat/projects

Conversation

@zhravan

@zhravan zhravan commented Sep 3, 2025

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

  • New Features
    • CLI now shows both Concepts and Projects; list, progress, and verify cover both with clearer completion indicators and screen refresh.
    • Added eight new project exercises: Text Analyzer, Shape Calculator, Task Scheduler, HTTP Server, CLI To‑Do List, Simple Chat App, Image Processing Utility, Basic Key‑Value Store (with accompanying tests/templates).
  • Refactor
    • Exercises reorganized into separate Concepts and Projects categories for improved browsing and tracking.

@coderabbitai

coderabbitai Bot commented Sep 3, 2025

Copy link
Copy Markdown

Warning

Rate limit exceeded

@zhravan has exceeded the limit for the number of commits or files that can be reviewed per hour. Please wait 24 minutes and 1 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📥 Commits

Reviewing files that changed from the base of the PR and between 1050838 and 32172ad.

📒 Files selected for processing (34)
  • README.md (2 hunks)
  • internal/cli/commands.go (5 hunks)
  • internal/exercises/catalog.yaml (2 hunks)
  • internal/exercises/exercises.go (3 hunks)
  • internal/exercises/templates/14_closures/closures.go (1 hunks)
  • internal/exercises/templates/15_recursion/recursion.go (1 hunks)
  • internal/exercises/templates/16_range_built_in/range_built_in.go (1 hunks)
  • internal/exercises/templates/17_pointers/pointers.go (1 hunks)
  • internal/exercises/templates/18_strings_runes/strings_runes.go (1 hunks)
  • internal/exercises/templates/19_structs/structs.go (1 hunks)
  • internal/exercises/templates/20_methods/methods.go (1 hunks)
  • internal/exercises/templates/21_interfaces/interfaces.go (1 hunks)
  • internal/exercises/templates/22_enums/enums.go (2 hunks)
  • internal/exercises/templates/23_struct_embedding/struct_embedding.go (2 hunks)
  • internal/exercises/templates/24_generics/generics.go (1 hunks)
  • internal/exercises/templates/25_range_iterators/range_iterators.go (1 hunks)
  • internal/exercises/templates/26_errors/errors.go (1 hunks)
  • internal/exercises/templates/27_custom_errors/custom_errors.go (1 hunks)
  • internal/exercises/templates/28_text_analyzer/text_analyzer.go (1 hunks)
  • internal/exercises/templates/28_text_analyzer/text_analyzer_test.go (1 hunks)
  • internal/exercises/templates/29_shape_calculator/shape_calculator.go (1 hunks)
  • internal/exercises/templates/29_shape_calculator/shape_calculator_test.go (1 hunks)
  • internal/exercises/templates/30_task_scheduler/task_scheduler.go (1 hunks)
  • internal/exercises/templates/30_task_scheduler/task_scheduler_test.go (1 hunks)
  • internal/exercises/templates/31_http_server/http_server.go (1 hunks)
  • internal/exercises/templates/31_http_server/http_server_test.go (1 hunks)
  • internal/exercises/templates/32_cli_todo_list/cli_todo_list.go (1 hunks)
  • internal/exercises/templates/32_cli_todo_list/cli_todo_list_test.go (1 hunks)
  • internal/exercises/templates/33_simple_chat_app/simple_chat_app.go (1 hunks)
  • internal/exercises/templates/33_simple_chat_app/simple_chat_app_test.go (1 hunks)
  • internal/exercises/templates/34_image_processing_utility/image_processing_utility.go (1 hunks)
  • internal/exercises/templates/34_image_processing_utility/image_processing_utility_test.go (1 hunks)
  • internal/exercises/templates/35_basic_key_value_store/key_value_store.go (1 hunks)
  • internal/exercises/templates/35_basic_key_value_store/key_value_store_test.go (1 hunks)

Walkthrough

Introduces a Catalog struct separating exercises into Concepts and Projects, updates CLI to consume ListAll(), restructures catalog.yaml accordingly, and adds eight new project templates (28–35) with tests. Many existing concept templates (14–27) were converted to exercise skeletons/TODO stubs. go.mod gained an indirect duplicate require for testify v1.11.0.

Changes

Cohort / File(s) Summary of Changes
Dependencies
go.mod
Added an indirect require for github.com/stretchr/testify v1.11.0 in the second require block while the first block already contains the same module.
CLI commands
internal/cli/commands.go
Switched to ListAll() returning a categorized Catalog; updated runList, runVerify, and runProgress to handle Concepts and Projects, adjusted headings/status rendering, combined lists for verification/progress, and added screen-clear/tip UI touches.
Exercises core & catalog
internal/exercises/exercises.go, internal/exercises/catalog.yaml
Added Catalog type (Concepts []Exercise, Projects []Exercise); refactored parsing (catalog() → Catalog), listing (ListAll()), lookup (Get), and InitAll() to support projects; reorganized catalog.yaml to top-level concepts and new projects entries (added slugs 28–35).
Existing concept templates converted to skeletons / TODOs
internal/exercises/templates/14_closures/*, .../15_recursion/*, .../16_range_built_in/*, .../17_pointers/*, .../18_strings_runes/*, .../19_structs/*, .../20_methods/*, .../21_interfaces/*, .../22_enums/*, .../23_struct_embedding/*, .../24_generics/*, .../25_range_iterators/*, .../26_errors/*, .../27_custom_errors/*
Replaced concrete example implementations with skeletons/TODOs: many functions now return zero-values or contain placeholder comments. Signatures remain unchanged but runtime logic removed or stubbed. (Notable: 16_range_built_in functions now contain TODOs and return 0.)
New project templates (28–35) + tests
internal/exercises/templates/28_text_analyzer/*, .../29_shape_calculator/*, .../30_task_scheduler/*, .../31_http_server/*, .../32_cli_todo_list/*, .../33_simple_chat_app/*, .../34_image_processing_utility/*, .../35_basic_key_value_store/*
Added eight new project templates and corresponding test files. Each template defines public types/functions (text analyzer, shapes/interface, task scheduler with iterator, HTTP server handler and StartServer, JSON-backed CLI todo list, TCP chat server scaffolding, image transforms, and a thread-safe key-value store). Implementations are skeletons/TODOs; tests specify expected behaviors and assertions.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant CLI as CLI Commands
  participant EX as Exercises API
  participant FS as Filesystem

  rect rgba(200,235,255,0.15)
  note over CLI,EX: Listing / Progress / Verify (new flow)
  User->>CLI: run list / progress / verify
  CLI->>EX: ListAll()
  EX->>FS: read catalog.yaml
  FS-->>EX: catalog data
  EX->>EX: unmarshal into Catalog{Concepts,Projects}
  EX-->>CLI: Catalog
  CLI->>CLI: build allExercises = Concepts + Projects (when needed)
  CLI-->>User: render Concepts and Projects
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

A rabbit in the code burrow, tidy and spry,
Split carrots to Concepts and Projects piled high.
I hopped through templates, left TODOs in the hay,
Added new burrows from 28 up to 35 today.
Tests line my trail like bright carrot glue—hop, run, and try! 🥕🐇

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/projects

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore or @coderabbit ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/exercises/exercises.go (1)

158-166: Guard against path traversal and return a semantic error for missing templates

Avoids writing outside exercises/ if a malformed slug appears; also makes ErrNoTemplates actionable.

 func copyExerciseTemplate(slug string) error {
+	// basic slug sanity check
+	if slug != filepath.Base(slug) {
+		return fmt.Errorf("invalid slug %q: path traversal not allowed", slug)
+	}
+	if !templateExists(slug) {
+		return fmt.Errorf("%w: %s", ErrNoTemplates, slug)
+	}
 	targetDir := filepath.Join("exercises", slug)
 	// Remove and recreate to ensure a clean state
 	_ = os.RemoveAll(targetDir)
 	if err := os.MkdirAll(targetDir, 0o755); err != nil {
 		return err
 	}
🧹 Nitpick comments (46)
go.mod (1)

11-16: Remove duplicate testify entry in indirect require block.

github.com/stretchr/testify v1.11.0 is already a direct dependency (Line 7). The indirect entry (Line 14) is redundant and can confuse dependency intent. Drop it and run go mod tidy.

Apply this diff:

 require (
   github.com/davecgh/go-spew v1.1.1 // indirect
   github.com/pmezard/go-difflib v1.0.0 // indirect
-  github.com/stretchr/testify v1.11.0 // indirect
   golang.org/x/sys v0.4.0 // indirect
 )
internal/exercises/templates/16_range_built_in/range_built_in.go (1)

13-15: Use idiomatic range for maps.

for range m is the idiomatic way when keys/values are unused. The current for _, _ = range m is noisy.

Apply this diff:

-	for _, _ = range m {
+	for range m {
 		count++
 	}
internal/exercises/templates/31_http_server/http_server.go (3)

8-10: Handler currently echoes “hello” for all requests to /hello. Consider supporting name or default.

With the exact route “/hello”, r.URL.Path[1:] will always be “hello”. If you intend “/hello/{name}”, register a subtree route or parse a query param.

Apply this diff to support both /hello → “world” and /hello/{name}:

-import (
-	"fmt"
-	"net/http"
-)
+import (
+	"fmt"
+	"net/http"
+	"strings"
+)
@@
-func helloHandler(w http.ResponseWriter, r *http.Request) {
-	fmt.Fprintf(w, "Hello, %s!\n", r.URL.Path[1:])
+func helloHandler(w http.ResponseWriter, r *http.Request) {
+	name := strings.TrimPrefix(r.URL.Path, "/hello/")
+	if name == "" || name == "/" {
+		name = "world"
+	}
+	fmt.Fprintf(w, "Hello, %s!\n", name)
 }

12-16: Avoid DefaultServeMux and handle server errors.

Using the global DefaultServeMux can cause test flakiness and handler leakage across tests; ignoring ListenAndServe errors hides bind failures (e.g., port in use).

Apply this minimal diff (keeps signature):

-func StartServer() {
-	http.HandleFunc("/hello", helloHandler)
-	fmt.Println("Server listening on :8080")
-	http.ListenAndServe(":8080", nil)
+func StartServer() {
+	mux := http.NewServeMux()
+	mux.HandleFunc("/hello", helloHandler)
+	fmt.Println("Server listening on :8080")
+	// Log the error instead of exiting; callers/tests can observe output.
+	if err := http.ListenAndServe(":8080", mux); err != nil {
+		fmt.Println("server stopped:", err)
+	}
 }

If API changes are acceptable, prefer func StartServer(addr string, mux *http.ServeMux) error and return the error.


18-20: Remove package-level main or move to cmd/.

A main() function inside a non-main package is misleading and unused. Prefer a separate cmd/http_server/main.go that calls http_server.StartServer().

Apply this diff:

-func main() {
-	StartServer()
-}
internal/exercises/catalog.yaml (1)

138-179: Add explicit difficulty metadata and tailor each test_regex

  • Move difficulty out of titles by adding a difficulty: easy|medium|hard field per project.
  • Replace the generic test_regex: ".*" with project-specific patterns to speed targeted test runs.

All slugs have matching template directories.

internal/exercises/templates/28_text_analyzer/text_analyzer.go (1)

17-24: Minor: use struct{} set and preallocate.

Saves a tiny bit of memory and avoids bools.

Apply this diff:

-func CountUniqueWords(text string) int {
-	words := strings.Fields(text)
-	uniqueWords := make(map[string]bool)
-	for _, word := range words {
-		uniqueWords[strings.ToLower(word)] = true
-	}
-	return len(uniqueWords)
-}
+func CountUniqueWords(text string) int {
+	words := strings.Fields(text)
+	unique := make(map[string]struct{}, len(words))
+	for _, w := range words {
+		unique[strings.ToLower(w)] = struct{}{}
+	}
+	return len(unique)
+}
internal/exercises/templates/28_text_analyzer/text_analyzer_test.go (1)

5-9: Avoid repeated calls in assertions

Store function results to prevent double work and to produce consistent error messages.

 func TestCountCharacters(t *testing.T) {
 	text := "Hello, 世界!"
-	if CountCharacters(text) != 9 {
-		t.Errorf("Expected 9 characters, got %d", CountCharacters(text))
+	got := CountCharacters(text)
+	if got != 9 {
+		t.Errorf("Expected 9 characters, got %d", got)
 	}
 }
 
 func TestCountWords(t *testing.T) {
 	text := "Hello world, hello Go"
-	if CountWords(text) != 4 {
-		t.Errorf("Expected 4 words, got %d", CountWords(text))
+	got := CountWords(text)
+	if got != 4 {
+		t.Errorf("Expected 4 words, got %d", got)
 	}
 }
 
 func TestCountUniqueWords(t *testing.T) {
 	text := "Hello world, hello Go, world"
-	if CountUniqueWords(text) != 3 {
-		t.Errorf("Expected 3 unique words, got %d", CountUniqueWords(text))
+	got := CountUniqueWords(text)
+	if got != 3 {
+		t.Errorf("Expected 3 unique words, got %d", got)
 	}
 }

Also applies to: 12-16, 19-23

internal/exercises/templates/29_shape_calculator/shape_calculator.go (1)

5-23: LGTM; consider optional guards for invalid dimensions

Implementation is correct. Optionally add constructors or validation to prevent negative Radius/Width/Height (returning error) for clearer API guarantees.

internal/exercises/templates/29_shape_calculator/shape_calculator_test.go (4)

3-3: Use math and compare with tolerance for floats

Avoid exact float equality; import math and use an epsilon.

-package shape_calculator
+package shape_calculator
+
+import (
+	"math"
+	"testing"
+)
-
-import "testing"

5-11: Circle area: compare within epsilon

 func TestCircleArea(t *testing.T) {
 	c := Circle{Radius: 5}
-	expectedArea := 78.53981633974483
-	if c.Area() != expectedArea {
-		t.Errorf("Expected area %f, got %f", expectedArea, c.Area())
+	got := c.Area()
+	want := math.Pi * c.Radius * c.Radius
+	if math.Abs(got-want) > 1e-9 {
+		t.Errorf("Expected area %f, got %f", want, got)
 	}
 }

13-19: Rectangle area: compare within epsilon

 func TestRectangleArea(t *testing.T) {
 	r := Rectangle{Width: 10, Height: 5}
-	expectedArea := 50.0
-	if r.Area() != expectedArea {
-		t.Errorf("Expected area %f, got %f", expectedArea, r.Area())
+	got := r.Area()
+	want := 50.0
+	if math.Abs(got-want) > 1e-9 {
+		t.Errorf("Expected area %f, got %f", want, got)
 	}
 }

21-35: Strengthen interface-based assertions

Assert expected positives using epsilon instead of non-zero checks.

 func TestShapeInterface(t *testing.T) {
 	var s Shape
 
 	c := Circle{Radius: 1}
 	s = c
-	if s.Area() == 0 {
-		t.Errorf("Circle area through interface is 0")
+	if math.Abs(s.Area()-(math.Pi*1*1)) > 1e-9 {
+		t.Errorf("Circle area through interface mismatch")
 	}
 
 	r := Rectangle{Width: 1, Height: 1}
 	s = r
-	if s.Area() == 0 {
-		t.Errorf("Rectangle area through interface is 0")
+	if math.Abs(s.Area()-1.0) > 1e-9 {
+		t.Errorf("Rectangle area through interface mismatch")
 	}
 }
internal/exercises/templates/31_http_server/http_server_test.go (1)

3-9: Clean imports

Remove time since it’s unused after refactor.

 import (
 	"io"
 	"net/http"
 	"net/http/httptest"
-	"testing"
-	"time"
+	"testing"
 )
internal/exercises/templates/32_cli_todo_list/cli_todo_list.go (4)

3-9: Prefer os.ReadFile/WriteFile over deprecated ioutil; ensure dir exists

Switch to os.ReadFile/os.WriteFile and create parent dir before saving.

 import (
 	"encoding/json"
 	"errors"
 	"fmt"
-	"io/ioutil"
+	"path/filepath"
 	"os"
 )

36-40: Use os.ReadFile

-	data, err := ioutil.ReadFile(tl.filepath)
+	data, err := os.ReadFile(tl.filepath)

57-64: MkdirAll before save and use os.WriteFile

 func (tl *TodoList) Save() error {
 	data, err := json.MarshalIndent(tl.Todos, "", "  ")
 	if err != nil {
 		return fmt.Errorf("failed to marshal todos: %w", err)
 	}
 
-	return ioutil.WriteFile(tl.filepath, data, 0644)
+	if err := os.MkdirAll(filepath.Dir(tl.filepath), 0o755); err != nil {
+		return fmt.Errorf("failed to create directories: %w", err)
+	}
+	return os.WriteFile(tl.filepath, data, 0o644)
 }

77-85: Optional: validate ID and task

Consider guarding against id <= 0 and empty/whitespace-only tasks in Add/Complete to keep data clean. Low impact but improves UX.

internal/exercises/templates/32_cli_todo_list/cli_todo_list_test.go (4)

3-6: Use t.TempDir and filepath for deterministic filesystem tests.

Temp dirs avoid collisions and handle cleanup automatically. You’ll need filepath in imports.

 import (
-	"os"
+	"os"
+	"path/filepath"
 	"testing"
 )

8-17: Prefer black-box testing; avoid asserting unexported fields.

Asserting tl.filepath ties tests to internals. Validate via behavior (e.g., Save then check file exists) or move tests to package cli_todo_list_test.


19-27: Switch to t.TempDir for isolation; avoid manual os.Remove.

This prevents leftover files if a test fails midway.

-filename := "test_add_complete.json"
-defer os.Remove(filename)
-
-tl := NewTodoList(filepath)
+dir := t.TempDir()
+filename := filepath.Join(dir, "todos.json")
+tl := NewTodoList(filename)

51-59: Check error from Complete and use t.TempDir for this test too.

Don’t ignore the error in case indices change; also isolate filesystem writes.

-filepath := "test_load_save.json"
-defer os.Remove(filepath)
-
-// Create a list and save it
-tl1 := NewTodoList(filepath)
+dir := t.TempDir()
+filepath := filepath.Join(dir, "todos.json")
+
+// Create a list and save it
+tl1 := NewTodoList(filepath)
 tl1.Add("Task A")
 tl1.Add("Task B")
-_ = tl1.Complete(1)
+if err := tl1.Complete(1); err != nil {
+	t.Fatalf("Complete(1) failed: %v", err)
+}
internal/exercises/templates/35_basic_key_value_store/key_value_store.go (2)

56-74: Avoid holding read lock during file I/O; wrap Flush() error for consistency.

Snapshot map under RLock, then write without locks. Also return StoreError on Flush for consistent error type.

 func (s *KeyValueStore) Save() error {
-	s.mu.RLock()
-	defer s.mu.RUnlock()
-
-	file, err := os.Create(s.filepath)
+	// Snapshot data
+	s.mu.RLock()
+	snap := make(map[string]string, len(s.data))
+	for k, v := range s.data {
+		snap[k] = v
+	}
+	s.mu.RUnlock()
+
+	file, err := os.Create(s.filepath)
 	if err != nil {
 		return &StoreError{Message: fmt.Sprintf("failed to create store file: %v", err)}
 	}
 	defer file.Close()
 
 	writer := bufio.NewWriter(file)
-	for k, v := range s.data {
+	for k, v := range snap {
 		_, err := writer.WriteString(fmt.Sprintf("%s=%s\n", k, v))
 		if err != nil {
 			return &StoreError{Message: fmt.Sprintf("failed to write to store file: %v", err)}
 		}
 	}
-	return writer.Flush()
+	if err := writer.Flush(); err != nil {
+		return &StoreError{Message: fmt.Sprintf("failed to flush store file: %v", err)}
+	}
+	return nil
 }

11-23: Unify error strategy and consider escaping.

Mixed usage of plain error and *StoreError complicates callers. Either return error everywhere or export sentinels. Also, key/value lines aren’t escaped—‘=’ or newlines will corrupt parsing; consider JSON or escaping.

internal/exercises/templates/35_basic_key_value_store/key_value_store_test.go (4)

3-6: Import filepath and prefer t.TempDir across tests.

 import (
-	"os"
+	"os"
+	"path/filepath"
 	"testing"
 )

22-29: Use a temp dir; drop manual Remove.

-filepath := "test_set_get.txt"
-defer os.Remove(filepath)
-
-s := NewKeyValueStore(filepath)
+dir := t.TempDir()
+fp := filepath.Join(dir, "kv.txt")
+s := NewKeyValueStore(fp)

46-53: Same here: isolate with t.TempDir.

-filepath := "test_delete.txt"
-defer os.Remove(filepath)
-
-s := NewKeyValueStore(filepath)
+dir := t.TempDir()
+fp := filepath.Join(dir, "kv.txt")
+s := NewKeyValueStore(fp)

69-77: And here for Load/Save.

-filepath := "test_load_save.txt"
-defer os.Remove(filepath)
-
-// Create a store and save it
-s1 := NewKeyValueStore(filepath)
+dir := t.TempDir()
+fp := filepath.Join(dir, "kv.txt")
+
+// Create a store and save it
+s1 := NewKeyValueStore(fp)
@@
-// Load into a new store
-s2 := NewKeyValueStore(filepath)
+// Load into a new store
+s2 := NewKeyValueStore(fp)
internal/exercises/templates/30_task_scheduler/task_scheduler.go (5)

15-22: Introduce named error codes for clarity.

Replace magic numbers with constants to keep tests and implementation aligned.

 type SchedulerError struct {
 	Code    int
 	Message string
 }
 
 func (e *SchedulerError) Error() string {
 	return fmt.Sprintf("Scheduler Error %d: %s", e.Code, e.Message)
 }
+
+const (
+	ErrEmptyName = 1
+	ErrNotFound  = 2
+)

36-39: Use the named code for empty name.

-	if name == "" {
-		return nil, &SchedulerError{Code: 1, Message: "Task name cannot be empty"}
-	}
+	if name == "" {
+		return nil, &SchedulerError{Code: ErrEmptyName, Message: "Task name cannot be empty"}
+	}

58-59: Use the named code for not found.

-	return nil, &SchedulerError{Code: 2, Message: fmt.Sprintf("Task with ID %d not found", id)}
+	return nil, &SchedulerError{Code: ErrNotFound, Message: fmt.Sprintf("Task with ID %d not found", id)}

79-86: Consider executing tasks scheduled at exactly now.

Current check excludes equality. If “due” includes equality, use !After.

-	if task.Scheduled.Before(now) {
+	if !task.Scheduled.After(now) {
 		fmt.Printf("Executing task %s (ID: %d) at %s\n", task.Name, task.ID, now.Format(time.RFC3339))
 		task.Execute()
 	}

79-86: Avoid printing in library code; accept an io.Writer or logger.

Reduce side effects and improve testability by injecting an output sink.

internal/exercises/templates/30_task_scheduler/task_scheduler_test.go (1)

59-80: Add a test for RunScheduledTasks behavior.

Verify that past-due tasks run and future tasks don’t; also define expected equality semantics.

+func TestRunScheduledTasks(t *testing.T) {
+	s := NewTaskScheduler()
+	var ranPast, ranFuture, ranNow int
+	now := time.Now()
+	s.AddTask("past", now.Add(-time.Minute), func() { ranPast++ })
+	s.AddTask("now", now, func() { ranNow++ })
+	s.AddTask("future", now.Add(time.Minute), func() { ranFuture++ })
+	s.RunScheduledTasks()
+	if ranPast != 1 {
+		t.Fatalf("expected past to run once, got %d", ranPast)
+	}
+	// Adjust assertion depending on chosen equality semantics.
+	if ranNow != 1 {
+		t.Fatalf("expected now to run once, got %d", ranNow)
+	}
+	if ranFuture != 0 {
+		t.Fatalf("expected future not to run, got %d", ranFuture)
+	}
+}
internal/exercises/templates/34_image_processing_utility/image_processing_utility_test.go (1)

17-26: Add bounds and type assertions to harden the grayscale test

Also assert that output bounds are preserved and alpha is fully opaque, so regressions (e.g., non-zero Min bounds) are caught early.

 func TestGrayscale(t *testing.T) {
   img := createTestImage()
   grayImg := Grayscale(img)

+  if !grayImg.Bounds().Eq(img.Bounds()) {
+    t.Fatalf("grayscale bounds changed: got %v, want %v", grayImg.Bounds(), img.Bounds())
+  }
+
   c := grayImg.At(0, 0)
   r, g, b, a := c.RGBA()
   if r != g || g != b || a != 0xFFFF {
     t.Errorf("Expected grayscale color, got %v", c)
   }
 }
internal/cli/commands.go (3)

29-57: DRY up duplicate listing logic for Concepts and Projects

Both loops are identical aside from the slice. Factor into a small helper to reduce drift.

- if h := theme.Heading("Concepts"); h != "" {
-   fmt.Println(h)
- }
- for _, ex := range cat.Concepts {
-   status := "pending"
-   done, _ := progress.IsCompleted(ex.Slug)
-   if done {
-     status = theme.Success("done")
-   }
-   if !done {
-     status = theme.Muted(status)
-   }
-   fmt.Printf("%s - %s [%s]\n", ex.Slug, ex.Title, status)
- }
+ printSection := func(title string, xs []exercises.Exercise) {
+   fmt.Println(theme.Heading(title))
+   for _, ex := range xs {
+     status := "pending"
+     if done, _ := progress.IsCompleted(ex.Slug); done {
+       status = theme.Success("done")
+     } else {
+       status = theme.Muted(status)
+     }
+     fmt.Printf("%s - %s [%s]\n", ex.Slug, ex.Title, status)
+   }
+ }
+ printSection("Concepts", cat.Concepts)
@@
- if h := theme.Heading("Projects"); h != "" {
-   fmt.Println(h)
- }
- for _, ex := range cat.Projects {
-   status := "pending"
-   done, _ := progress.IsCompleted(ex.Slug)
-   if done {
-     status = theme.Success("done")
-   }
-   if !done {
-     status = theme.Muted(status)
-   }
-   fmt.Printf("%s - %s [%s]\n", ex.Slug, ex.Title, status)
- }
+ printSection("Projects", cat.Projects)

235-248: Avoid double IsCompleted() calls and unused statuses slice

You compute statuses and doneCount, then re-query completion for printing. Cache results once and reuse.

- var allExercises []exercises.Exercise
+ var allExercises []exercises.Exercise
  allExercises = append(allExercises, cat.Concepts...)
  allExercises = append(allExercises, cat.Projects...)
 
  sort.Slice(allExercises, func(i, j int) bool { return allExercises[i].Slug < allExercises[j].Slug })
- doneCount := 0
- statuses := make([]bool, len(allExercises))
- for i, ex := range allExercises {
-   done, _ := progress.IsCompleted(ex.Slug)
-   statuses[i] = done
-   if done {
-     doneCount++
-   }
- }
+ done := map[string]bool{}
+ doneCount := 0
+ for _, ex := range allExercises {
+   ok, _ := progress.IsCompleted(ex.Slug)
+   done[ex.Slug] = ok
+   if ok {
+     doneCount++
+   }
+ }
@@
- for _, ex := range cat.Concepts {
+ for _, ex := range cat.Concepts {
   box := "[ ]"
-  done, _ := progress.IsCompleted(ex.Slug)
-  if done {
+  if done[ex.Slug] {
     box = theme.Success("[x]")
   }
   fmt.Printf(" %s %s - %s\n", box, ex.Slug, ex.Title)
 }
@@
- for _, ex := range cat.Projects {
+ for _, ex := range cat.Projects {
   box := "[ ]"
-  done, _ := progress.IsCompleted(ex.Slug)
-  if done {
+  if done[ex.Slug] {
     box = theme.Success("[x]")
   }
   fmt.Printf(" %s %s - %s\n", box, ex.Slug, ex.Title)
 }

Also applies to: 261-268, 271-278


239-239: Sort only if used for display, or also sort sections

You sort allExercises (used solely for counts). Either remove the sort or also sort cat.Concepts and cat.Projects before printing for consistent UX.

internal/exercises/exercises.go (5)

28-31: Prefer omitempty on YAML arrays to avoid noisy empty sections

Keeps serialized catalog cleaner when one section is empty.

-type Catalog struct {
-	Concepts []Exercise `yaml:"concepts"`
-	Projects []Exercise `yaml:"projects"`
-}
+type Catalog struct {
+	Concepts []Exercise `yaml:"concepts,omitempty"`
+	Projects []Exercise `yaml:"projects,omitempty"`
+}

38-68: Deduplicate fallback catalog logic

Define a single defaultCatalog() to remove duplication and ease future edits.

 		b, err := catalogFS.ReadFile("catalog.yaml")
 		if err != nil {
-			// Fallback minimal catalog
-			catalogData = Catalog{
-				Concepts: []Exercise{{
-					Slug:      "01_hello",
-					Title:     "Hello, Go!",
-					TestRegex: ".*",
-					Hints:     []string{"Implement Hello() to return 'Hello, Go!'"},
-				}},
-			}
+			catalogData = defaultCatalog()
 			return
 		}
-		var cat Catalog
-		if err := yaml.Unmarshal(b, &cat); err != nil {
-			catalogData = Catalog{
-				Concepts: []Exercise{{
-					Slug:      "01_hello",
-					Title:     "Hello, Go!",
-					TestRegex: ".*",
-					Hints:     []string{"Implement Hello() to return 'Hello, Go!'"},
-				}},
-			}
+		var cat Catalog
+		if err := yaml.Unmarshal(b, &cat); err != nil {
+			catalogData = defaultCatalog()
 			return
 		}
 		catalogData = cat

Add this helper (place anywhere in the file outside this hunk):

func defaultCatalog() Catalog {
	return Catalog{
		Concepts: []Exercise{{
			Slug:      "01_hello",
			Title:     "Hello, Go!",
			TestRegex: ".*",
			Hints:     []string{"Implement Hello() to return 'Hello, Go!'"},
		}},
	}
}

144-156: Fail fast with a clearer error when a cataloged template isn’t embedded

Pre-check prevents a generic WalkDir error and surfaces the missing slug.

 func InitAll() error {
-	for _, ex := range catalog().Concepts {
-		if err := copyExerciseTemplate(ex.Slug); err != nil {
+	for _, ex := range catalog().Concepts {
+		if !templateExists(ex.Slug) {
+			return fmt.Errorf("%w: %s", ErrNoTemplates, ex.Slug)
+		}
+		if err := copyExerciseTemplate(ex.Slug); err != nil {
 			return err
 		}
 	}
-	for _, ex := range catalog().Projects {
-		if err := copyExerciseTemplate(ex.Slug); err != nil {
+	for _, ex := range catalog().Projects {
+		if !templateExists(ex.Slug) {
+			return fmt.Errorf("%w: %s", ErrNoTemplates, ex.Slug)
+		}
+		if err := copyExerciseTemplate(ex.Slug); err != nil {
 			return err
 		}
 	}
 	return nil
 }

190-191: Make ErrNoTemplates meaningful (now used) or remove it

With the above pre-check, this sentinel error becomes useful. If you choose not to add that pre-check, delete this var to avoid dead code.


93-105: Merge local and catalog exercises in ListAll to preserve Projects
Current implementation returns only locals when any are found, hiding Projects. Change ListAll to always call catalog(), then prepend locals (dedupe by Slug) into catalog.Concepts:

-func ListAll() (Catalog, error) {
-	locals, err := discoverLocal()
-	if err != nil {
-		return Catalog{}, err
-	}
-
-	if len(locals) > 0 {
-		// For simplicity, if local exercises are present, we'll only return them for now.
-		// A more robust solution might merge local and catalog exercises.
-		return Catalog{Concepts: locals}, nil
-	}
-	return catalog(), nil
-}
+func ListAll() (Catalog, error) {
+	locals, err := discoverLocal()
+	if err != nil {
+		return Catalog{}, err
+	}
+	cat := catalog()
+	if len(locals) == 0 {
+		return cat, nil
+	}
+	// Dedup by slug; locals win.
+	seen := map[string]struct{}{}
+	merged := make([]Exercise, 0, len(locals)+len(cat.Concepts))
+	for _, ex := range locals {
+		merged = append(merged, ex)
+		seen[ex.Slug] = struct{}{}
+	}
+	for _, ex := range cat.Concepts {
+		if _, ok := seen[ex.Slug]; !ok {
+			merged = append(merged, ex)
+		}
+	}
+	cat.Concepts = merged
+	return cat, nil
+}

Optionally confirm callers aren’t relying on the old locals-only behavior.

internal/exercises/templates/33_simple_chat_app/simple_chat_app.go (3)

50-70: Handle temporary Accept errors; exit only on listener close

Prevents the accept loop from dying on transient errors.

 	for {
 		conn, err := s.listener.Accept()
 		if err != nil {
-			// Listener closed or other error
-			return
+			// Exit on listener close; otherwise continue on transient errors.
+			if errors.Is(err, net.ErrClosed) {
+				return
+			}
+			if ne, ok := err.(net.Error); ok && ne.Temporary() {
+				fmt.Printf("Temporary accept error: %v\n", err)
+				continue
+			}
+			fmt.Printf("Accept error: %v\n", err)
+			continue
 		}

Add import:

import "errors"

72-88: Log Scanner error after loop (optional) and consider raising the token limit

Helps diagnose disconnects and oversized lines.

 	for scanner.Scan() {
 		message := scanner.Text()
 		fmt.Printf("[%s]: %s\n", c.name, message)
 		c.server.Broadcast(c, message)
 	}
+	if err := scanner.Err(); err != nil {
+		fmt.Printf("Read error for %s: %v\n", c.name, err)
+	}

31-41: Optional: guard against double Start

Return an error if Start is called while already listening.

 func (s *Server) Start(port string) error {
+	if s.listener != nil {
+		return fmt.Errorf("server already started")
+	}
 	listener, err := net.Listen("tcp", ":"+port)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 4ff648d and 93bf74f.

📒 Files selected for processing (21)
  • go.mod (1 hunks)
  • internal/cli/commands.go (5 hunks)
  • internal/exercises/catalog.yaml (2 hunks)
  • internal/exercises/exercises.go (3 hunks)
  • internal/exercises/templates/16_range_built_in/range_built_in.go (1 hunks)
  • internal/exercises/templates/28_text_analyzer/text_analyzer.go (1 hunks)
  • internal/exercises/templates/28_text_analyzer/text_analyzer_test.go (1 hunks)
  • internal/exercises/templates/29_shape_calculator/shape_calculator.go (1 hunks)
  • internal/exercises/templates/29_shape_calculator/shape_calculator_test.go (1 hunks)
  • internal/exercises/templates/30_task_scheduler/task_scheduler.go (1 hunks)
  • internal/exercises/templates/30_task_scheduler/task_scheduler_test.go (1 hunks)
  • internal/exercises/templates/31_http_server/http_server.go (1 hunks)
  • internal/exercises/templates/31_http_server/http_server_test.go (1 hunks)
  • internal/exercises/templates/32_cli_todo_list/cli_todo_list.go (1 hunks)
  • internal/exercises/templates/32_cli_todo_list/cli_todo_list_test.go (1 hunks)
  • internal/exercises/templates/33_simple_chat_app/simple_chat_app.go (1 hunks)
  • internal/exercises/templates/33_simple_chat_app/simple_chat_app_test.go (1 hunks)
  • internal/exercises/templates/34_image_processing_utility/image_processing_utility.go (1 hunks)
  • internal/exercises/templates/34_image_processing_utility/image_processing_utility_test.go (1 hunks)
  • internal/exercises/templates/35_basic_key_value_store/key_value_store.go (1 hunks)
  • internal/exercises/templates/35_basic_key_value_store/key_value_store_test.go (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (8)
internal/exercises/templates/33_simple_chat_app/simple_chat_app_test.go (1)
internal/exercises/templates/33_simple_chat_app/simple_chat_app.go (1)
  • NewServer (24-29)
internal/exercises/templates/34_image_processing_utility/image_processing_utility_test.go (1)
internal/exercises/templates/34_image_processing_utility/image_processing_utility.go (2)
  • Grayscale (10-24)
  • Invert (27-41)
internal/exercises/templates/30_task_scheduler/task_scheduler_test.go (1)
internal/exercises/templates/30_task_scheduler/task_scheduler.go (1)
  • NewTaskScheduler (29-34)
internal/exercises/templates/35_basic_key_value_store/key_value_store_test.go (1)
internal/exercises/templates/35_basic_key_value_store/key_value_store.go (1)
  • NewKeyValueStore (25-30)
internal/cli/commands.go (3)
internal/exercises/exercises.go (2)
  • ListAll (93-105)
  • Exercise (21-26)
internal/cli/theme/theme.go (4)
  • Heading (93-93)
  • Success (94-94)
  • Muted (97-97)
  • Emph (98-98)
internal/progress/store.go (1)
  • IsCompleted (79-85)
internal/exercises/templates/28_text_analyzer/text_analyzer_test.go (1)
internal/exercises/templates/28_text_analyzer/text_analyzer.go (3)
  • CountCharacters (8-10)
  • CountWords (12-15)
  • CountUniqueWords (17-24)
internal/exercises/templates/29_shape_calculator/shape_calculator_test.go (1)
internal/exercises/templates/29_shape_calculator/shape_calculator.go (3)
  • Circle (9-11)
  • Rectangle (17-19)
  • Shape (5-7)
internal/exercises/templates/32_cli_todo_list/cli_todo_list_test.go (1)
internal/exercises/templates/32_cli_todo_list/cli_todo_list.go (1)
  • NewTodoList (23-29)
🔇 Additional comments (8)
internal/exercises/catalog.yaml (1)

1-137: Restructure looks consistent with the new Catalog model.

Concepts block reads clean and unchanged semantically. Ensure the loader in internal/exercises/exercises.go maps the top-level key exactly as "concepts".

internal/exercises/templates/28_text_analyzer/text_analyzer.go (1)

8-10: LGTM: correct Unicode-aware character count.

internal/exercises/templates/31_http_server/http_server_test.go (1)

34-64: Use httptest.NewServer with a ServeMux to test the /hello endpoint

  • In TestStartServer, replace the raw go http.ListenAndServe(":8081", nil) (which never registers helloHandler) with a http.NewServeMux(), call mux.HandleFunc("/hello/", helloHandler), then start ts := httptest.NewServer(mux) and request ts.URL+"/hello/test" to exercise the handler deterministically.
  • Remove the unused time import.
internal/exercises/templates/32_cli_todo_list/cli_todo_list_test.go (1)

35-41: Confirm Complete() uses 1-based indexing.

Test assumes 1-based (Complete(1)). If API is 0-based, this will mask an off-by-one.

internal/exercises/templates/34_image_processing_utility/image_processing_utility.go (1)

35-37: Bug: writing non-premultiplied bytes via SetRGBA corrupts semi-transparent pixels

image.RGBA stores premultiplied pixels. SetRGBA writes raw bytes and expects premultiplied inputs. Switch to Set with color.RGBA (which handles premultiplication), or premultiply manually.

-      r, g, b, a := rgbaImg.At(x, y).RGBA()
-      r, g, b, a = 0xFFFF-r, 0xFFFF-g, 0xFFFF-b, a
-      rgbaImg.SetRGBA(x, y, color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)})
+      r, g, b, a := rgbaImg.At(x, y).RGBA()
+      r, g, b = 0xFFFF-r, 0xFFFF-g, 0xFFFF-b
+      // Use Set to handle alpha-premultiplication correctly.
+      rgbaImg.Set(x, y, color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: uint8(a >> 8)})

Likely an incorrect or invalid review comment.

internal/cli/commands.go (2)

24-24: LGTM: switched to Catalog via ListAll()

Using the new Catalog source is appropriate and aligns with the data model.


71-89: LGTM: verify across Concepts+Projects

Combining both groups before verification keeps behavior consistent with the new model.

internal/exercises/exercises.go (1)

108-117: LGTM: Get now searches Concepts then Projects, then locals

Order is sensible; early returns avoid unnecessary work.

Comment on lines +5 to +10
func TestCountCharacters(t *testing.T) {
text := "Hello, 世界!"
if CountCharacters(text) != 9 {
t.Errorf("Expected 9 characters, got %d", CountCharacters(text))
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

Test expectation mismatches implementation for character count

CountCharacters currently counts all runes (including spaces). The test expects 9 for "Hello, 世界!" (i.e., excluding the space). Either change the expected to 10 or update the implementation to ignore whitespace. I recommend updating the implementation to match the intent.

Outside this file (text_analyzer.go), consider:

// text_analyzer.go
import (
	"strings"
	"unicode"
)

func CountCharacters(text string) int {
	n := 0
	for _, r := range text {
		if !unicode.IsSpace(r) {
			n++
		}
	}
	return n
}

func CountWords(text string) int {
	// treat punctuation as separators too
	words := strings.FieldsFunc(text, func(r rune) bool {
		return unicode.IsSpace(r) || unicode.IsPunct(r)
	})
	return len(words)
}

func CountUniqueWords(text string) int {
	words := strings.FieldsFunc(text, func(r rune) bool {
		return unicode.IsSpace(r) || unicode.IsPunct(r)
	})
	seen := make(map[string]struct{}, len(words))
	for _, w := range words {
		seen[strings.ToLower(w)] = struct{}{}
	}
	return len(seen)
}
🤖 Prompt for AI Agents
In internal/exercises/templates/28_text_analyzer/text_analyzer_test.go around
lines 5-10 the test expects 9 characters for "Hello, 世界!" (excluding the space)
but the current CountCharacters implementation counts all runes; update the
implementation in text_analyzer.go to skip whitespace (use unicode.IsSpace to
filter runes) so spaces are not counted, and add the unicode import if missing;
no test changes required.

Comment on lines +19 to +24
func TestCountUniqueWords(t *testing.T) {
text := "Hello world, hello Go, world"
if CountUniqueWords(text) != 3 {
t.Errorf("Expected 3 unique words, got %d", CountUniqueWords(text))
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

Unique-word expectation mismatches current tokenization

Test expects punctuation-insensitive uniqueness (3), but implementation uses strings.Fields and keeps commas, yielding 4. Adopt FieldsFunc with unicode.IsPunct as above to align behavior.

🤖 Prompt for AI Agents
internal/exercises/templates/28_text_analyzer/text_analyzer_test.go lines 19-24:
the test expects punctuation-insensitive unique-word counting but current
implementation uses strings.Fields which leaves punctuation (commas) and yields
4; update the tokenization to use strings.FieldsFunc that splits on
unicode.IsPunct and unicode.IsSpace (or checks runes with unicode.IsPunct ||
unicode.IsSpace), normalize tokens to lower-case (strings.ToLower) and ignore
empty tokens before counting unique words so the function returns 3 for "Hello
world, hello Go, world".

Comment on lines +66 to +40
func (tl *TodoList) Add(task string) *Todo {
todo := Todo{
ID: tl.nextID,
Task: task,
Complete: false,
}
tl.Todos = append(tl.Todos, todo)
tl.nextID++
return &todo
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Return pointer to stored slice element

Returning &todo points to a copy, not the slice element. Return the address of the element in tl.Todos.

 func (tl *TodoList) Add(task string) *Todo {
 	todo := Todo{
 		ID:       tl.nextID,
 		Task:     task,
 		Complete: false,
 	}
 	tl.Todos = append(tl.Todos, todo)
 	tl.nextID++
-	return &todo
+	return &tl.Todos[len(tl.Todos)-1]
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (tl *TodoList) Add(task string) *Todo {
todo := Todo{
ID: tl.nextID,
Task: task,
Complete: false,
}
tl.Todos = append(tl.Todos, todo)
tl.nextID++
return &todo
}
func (tl *TodoList) Add(task string) *Todo {
todo := Todo{
ID: tl.nextID,
Task: task,
Complete: false,
}
tl.Todos = append(tl.Todos, todo)
tl.nextID++
return &tl.Todos[len(tl.Todos)-1]
}
🤖 Prompt for AI Agents
In internal/exercises/templates/32_cli_todo_list/cli_todo_list.go around lines
66-75, the Add method creates a local todo and returns &todo (a pointer to a
stack copy) instead of the address of the element stored in the slice; change
the flow to record the current slice length into an index variable, append the
struct to tl.Todos, increment tl.nextID, and return &tl.Todos[index] so the
returned pointer refers to the stored slice element.

Comment on lines +11 to +26
func TestServerStartStop(t *testing.T) {
server := NewServer()

err := server.Start("8082")
if err != nil {
t.Fatalf("Server failed to start: %v", err)
}

// Give server a moment to start
time.Sleep(10 * time.Millisecond)

err = server.Stop()
if err != nil {
t.Fatalf("Server failed to stop: %v", err)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

❓ Verification inconclusive

Avoid fixed port and sleep-based readiness; make start/stop tests robust

Hard-coding 8082 and sleeping 10ms can flake on CI. Prefer waiting until the port is accepting connections (with a timeout).

-err := server.Start("8082")
+err := server.Start("8082")
 if err != nil {
   t.Fatalf("Server failed to start: %v", err)
 }
-
-// Give server a moment to start
-time.Sleep(10 * time.Millisecond)
+waitUntilDialable(t, "localhost:8082", 2*time.Second)

Add this helper (outside the tests):

func waitUntilDialable(t *testing.T, addr string, d time.Duration) {
  t.Helper()
  deadline := time.Now().Add(d)
  for {
    if time.Now().After(deadline) {
      t.Fatalf("server not dialable at %s within %v", addr, d)
    }
    c, err := net.DialTimeout("tcp", addr, 100*time.Millisecond)
    if err == nil {
      _ = c.Close()
      return
    }
    time.Sleep(25 * time.Millisecond)
  }
}

Use a dial-based readiness check instead of a fixed sleep

Replace the 10 ms time.Sleep with a loop that waits until the port is accepting connections:

-// Give server a moment to start
-time.Sleep(10 * time.Millisecond)
+waitUntilDialable(t, "localhost:8082", 2*time.Second)

And add this helper:

func waitUntilDialable(t *testing.T, addr string, timeout time.Duration) {
  t.Helper()
  deadline := time.Now().Add(timeout)
  for time.Now().Before(deadline) {
    conn, err := net.DialTimeout("tcp", addr, 100*time.Millisecond)
    if err == nil {
      conn.Close()
      return
    }
    time.Sleep(25 * time.Millisecond)
  }
  t.Fatalf("server not dialable at %s within %v", addr, timeout)
}

This ensures the test only proceeds once the server is actually listening, preventing CI flakiness.

🤖 Prompt for AI Agents
internal/exercises/templates/33_simple_chat_app/simple_chat_app_test.go around
lines 11-26: the test uses a fixed 10ms time.Sleep to wait for the server to
start which causes flakiness; replace the sleep with a dial-based readiness loop
that repeatedly attempts a TCP connection to the server port until successful or
a timeout is reached, and add the helper function waitUntilDialable(t, addr,
timeout) (t.Helper(), loop calling net.DialTimeout with short per-try timeout,
closing the connection on success, sleeping briefly between attempts, and
calling t.Fatalf if the deadline expires); call waitUntilDialable(t,
"localhost:8082", a sensible timeout) instead of time.Sleep.

Comment on lines +28 to +83
func TestClientConnectionAndBroadcast(t *testing.T) {
server := NewServer()
err := server.Start("8083")
if err != nil {
t.Fatalf("Server failed to start: %v", err)
}
defer server.Stop()

time.Sleep(10 * time.Millisecond)

// Client 1
conn1, err := net.Dial("tcp", "localhost:8083")
if err != nil {
t.Fatalf("Client 1 failed to connect: %v", err)
}
defer conn1.Close()

// Client 2
conn2, err := net.Dial("tcp", "localhost:8083")
if err != nil {
t.Fatalf("Client 2 failed to connect: %v", err)
}
defer conn2.Close()

time.Sleep(10 * time.Millisecond) // Give clients a moment to register

// Client 1 sends message
message1 := "Hello from client 1"
fmt.Fprintf(conn1, message1+"\n")

// Client 2 should receive message from client 1
scanner2 := bufio.NewScanner(conn2)
if !scanner2.Scan() {
t.Fatalf("Client 2 did not receive message")
}
received2 := scanner2.Text()
expected2Prefix := "[Guest1]: " + message1 // Assuming Guest1 is client 1
if received2 != expected2Prefix {
t.Errorf("Client 2 received unexpected message: got %q, want prefix %q", received2, expected2Prefix)
}

// Client 2 sends message
message2 := "Hello from client 2"
fmt.Fprintf(conn2, message2+"\n")

// Client 1 should receive message from client 2
scanner1 := bufio.NewScanner(conn1)
if !scanner1.Scan() {
t.Fatalf("Client 1 did not receive message")
}
received1 := scanner1.Text()
expected1Prefix := "[Guest2]: " + message2 // Assuming Guest2 is client 2
if received1 != expected1Prefix {
t.Errorf("Client 1 received unexpected message: got %q, want prefix %q", received1, expected1Prefix)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Harden broadcast test: handle greetings, use prefixes, and add read deadlines

  • Avoid assuming no greeting; scan until expected prefix.
  • Use HasPrefix instead of strict equality (your error text already says “prefix”).
  • Add read deadlines to prevent hangs.
 import (
   "bufio"
   "fmt"
   "net"
+  "strings"
   "testing"
   "time"
 )
@@
-// Client 1 sends message
+// Client 1 sends message
 message1 := "Hello from client 1"
-fmt.Fprintf(conn1, message1+"\n")
+_ = conn1.SetWriteDeadline(time.Now().Add(1 * time.Second))
+fmt.Fprintf(conn1, message1+"\n")
@@
-// Client 2 should receive message from client 1
-scanner2 := bufio.NewScanner(conn2)
-if !scanner2.Scan() {
-  t.Fatalf("Client 2 did not receive message")
-}
-received2 := scanner2.Text()
-expected2Prefix := "[Guest1]: " + message1 // Assuming Guest1 is client 1
-if received2 != expected2Prefix {
-  t.Errorf("Client 2 received unexpected message: got %q, want prefix %q", received2, expected2Prefix)
-}
+// Client 2 should receive message from client 1
+_ = conn2.SetReadDeadline(time.Now().Add(2 * time.Second))
+scanner2 := bufio.NewScanner(conn2)
+expected2Prefix := "[Guest1]: " + message1
+received2 := scanUntilPrefix(t, scanner2, expected2Prefix, 2*time.Second)
+if !strings.HasPrefix(received2, expected2Prefix) {
+  t.Errorf("Client 2 received unexpected message: got %q, want prefix %q", received2, expected2Prefix)
+}
@@
-// Client 2 sends message
+// Client 2 sends message
 message2 := "Hello from client 2"
-fmt.Fprintf(conn2, message2+"\n")
+_ = conn2.SetWriteDeadline(time.Now().Add(1 * time.Second))
+fmt.Fprintf(conn2, message2+"\n")
@@
-// Client 1 should receive message from client 2
-scanner1 := bufio.NewScanner(conn1)
-if !scanner1.Scan() {
-  t.Fatalf("Client 1 did not receive message")
-}
-received1 := scanner1.Text()
-expected1Prefix := "[Guest2]: " + message2 // Assuming Guest2 is client 2
-if received1 != expected1Prefix {
-  t.Errorf("Client 1 received unexpected message: got %q, want prefix %q", received1, expected1Prefix)
-}
+// Client 1 should receive message from client 2
+_ = conn1.SetReadDeadline(time.Now().Add(2 * time.Second))
+scanner1 := bufio.NewScanner(conn1)
+expected1Prefix := "[Guest2]: " + message2
+received1 := scanUntilPrefix(t, scanner1, expected1Prefix, 2*time.Second)
+if !strings.HasPrefix(received1, expected1Prefix) {
+  t.Errorf("Client 1 received unexpected message: got %q, want prefix %q", received1, expected1Prefix)
+}

Add this helper (outside the tests):

func scanUntilPrefix(t *testing.T, sc *bufio.Scanner, prefix string, d time.Duration) string {
  t.Helper()
  deadline := time.Now().Add(d)
  for {
    if time.Now().After(deadline) {
      t.Fatalf("timed out waiting for line with prefix %q", prefix)
    }
    if sc.Scan() {
      line := sc.Text()
      if strings.HasPrefix(line, prefix) {
        return line
      }
      // continue scanning to skip greetings/other broadcasts
      continue
    }
    if err := sc.Err(); err != nil {
      t.Fatalf("scanner error: %v", err)
    }
    time.Sleep(10 * time.Millisecond)
  }
}
🤖 Prompt for AI Agents
In internal/exercises/templates/33_simple_chat_app/simple_chat_app_test.go
around lines 28 to 83, the broadcast test assumes no greeting, uses strict
equality and can hang; fix by adding the suggested scanUntilPrefix helper
(placed outside tests) that scans until a line with the given prefix or times
out, use strings.HasPrefix in assertions, replace direct scanner.Scan()/Text()
reads with calls to scanUntilPrefix supplying a read deadline (e.g., 1s), and
set read deadlines on the connections before scanning to avoid hangs.

func (s *Server) Stop() error {
if s.listener != nil {
return s.listener.Close()
}
return nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Stop should also close active client connections

Prevents goroutines from hanging in Scanner and frees resources.

 func (s *Server) Stop() error {
-	if s.listener != nil {
-		return s.listener.Close()
-	}
-	return nil
+	if s.listener != nil {
+		_ = s.listener.Close()
+	}
+	s.mu.Lock()
+	for _, c := range s.clients {
+		_ = c.conn.Close()
+	}
+	s.clients = make(map[int]*Client)
+	s.mu.Unlock()
+	return nil
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (s *Server) Stop() error {
if s.listener != nil {
return s.listener.Close()
}
return nil
}
func (s *Server) Stop() error {
if s.listener != nil {
_ = s.listener.Close()
}
s.mu.Lock()
for _, c := range s.clients {
_ = c.conn.Close()
}
s.clients = make(map[int]*Client)
s.mu.Unlock()
return nil
}

Comment on lines +89 to +54
func (s *Server) Broadcast(sender *Client, message string) {
s.mu.Lock()
defer s.mu.Unlock()

for _, client := range s.clients {
if client.id != sender.id {
_, err := fmt.Fprintf(client.conn, "[%s]: %s\n", sender.name, message)
if err != nil {
fmt.Printf("Error broadcasting to client %s: %v\n", client.name, err)
}
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Don’t hold the server mutex while writing to sockets; drop dead clients

Avoids head-of-line blocking and cleans up stale connections.

 func (s *Server) Broadcast(sender *Client, message string) {
-	s.mu.Lock()
-	defer s.mu.Unlock()
-
-	for _, client := range s.clients {
-		if client.id != sender.id {
-			_, err := fmt.Fprintf(client.conn, "[%s]: %s\n", sender.name, message)
-			if err != nil {
-				fmt.Printf("Error broadcasting to client %s: %v\n", client.name, err)
-			}
-		}
-	}
+	// Snapshot recipients under lock
+	s.mu.Lock()
+	recipients := make([]*Client, 0, len(s.clients))
+	for _, client := range s.clients {
+		if client.id != sender.id {
+			recipients = append(recipients, client)
+		}
+	}
+	s.mu.Unlock()
+
+	// Write outside the lock
+	for _, client := range recipients {
+		if _, err := fmt.Fprintf(client.conn, "[%s]: %s\n", sender.name, message); err != nil {
+			fmt.Printf("Error broadcasting to client %s: %v\n", client.name, err)
+			// prune failed client
+			s.mu.Lock()
+			delete(s.clients, client.id)
+			s.mu.Unlock()
+			_ = client.conn.Close()
+		}
+	}
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (s *Server) Broadcast(sender *Client, message string) {
s.mu.Lock()
defer s.mu.Unlock()
for _, client := range s.clients {
if client.id != sender.id {
_, err := fmt.Fprintf(client.conn, "[%s]: %s\n", sender.name, message)
if err != nil {
fmt.Printf("Error broadcasting to client %s: %v\n", client.name, err)
}
}
}
}
func (s *Server) Broadcast(sender *Client, message string) {
// Snapshot recipients under lock
s.mu.Lock()
recipients := make([]*Client, 0, len(s.clients))
for _, client := range s.clients {
if client.id != sender.id {
recipients = append(recipients, client)
}
}
s.mu.Unlock()
// Write outside the lock
for _, client := range recipients {
if _, err := fmt.Fprintf(client.conn, "[%s]: %s\n", sender.name, message); err != nil {
fmt.Printf("Error broadcasting to client %s: %v\n", client.name, err)
// Prune failed client
s.mu.Lock()
delete(s.clients, client.id)
s.mu.Unlock()
_ = client.conn.Close()
}
}
}
🤖 Prompt for AI Agents
In internal/exercises/templates/33_simple_chat_app/simple_chat_app.go around
lines 89 to 101, the Broadcast method currently holds s.mu while writing to
client sockets which can block the server and prevents cleaning up dead clients;
instead, copy the target clients under the lock, release the lock, then iterate
and write to each socket; if a write returns an error, close the connection and
remove that client from s.clients while holding the lock (or enqueue removals
and apply them under the lock) so stale connections are cleaned up without
holding the mutex during I/O.

Comment on lines +28 to +41
func TestInvert(t *testing.T) {
img := createTestImage()
invertedImg := Invert(img)

c := invertedImg.At(0, 0)
r, g, b, a := c.RGBA()
// Original color was red (255,0,0,255) in RGBA terms (0xFFFF, 0x0000, 0x0000, 0xFFFF)
// Inverted should be (0, 255, 255, 255) in RGBA terms (0x0000, 0xFFFF, 0xFFFF, 0xFFFF)

// Since the image/color.RGBA.RGBA() returns values scaled to 0-0xFFFF, we check against that.
if r != 0 || g != 0xFFFF || b != 0xFFFF || a != 0xFFFF {
t.Errorf("Expected inverted color (0, 0xFFFF, 0xFFFF, 0xFFFF), got (%d, %d, %d, %d)", r, g, b, a)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Cover semi-transparent pixels to catch premult/inversion bugs

Current tests use only fully-opaque pixels; add a case with alpha < 255 so incorrect use of image.RGBA.SetRGBA (premult) would be detected.

 func TestInvert(t *testing.T) {
   img := createTestImage()
   invertedImg := Invert(img)
@@
   if r != 0 || g != 0xFFFF || b != 0xFFFF || a != 0xFFFF {
     t.Errorf("Expected inverted color (0, 0xFFFF, 0xFFFF, 0xFFFF), got (%d, %d, %d, %d)", r, g, b, a)
   }
 }
+
+func TestInvert_PartialAlpha(t *testing.T) {
+  rect := image.Rect(0, 0, 1, 1)
+  src := image.NewNRGBA(rect)
+  // Semi-transparent red: (128, 0, 0, 128)
+  src.SetNRGBA(0, 0, color.NRGBA{128, 0, 0, 128})
+
+  inv := Invert(src)
+  r, g, b, a := inv.At(0, 0).RGBA() // 0..0xFFFF un-premultiplied
+  if r != 0x7F7F || g != 0xFFFF || b != 0xFFFF || a != 0x8080 {
+    t.Fatalf("unexpected inverted RGBA (16-bit): got (%#04x,%#04x,%#04x,%#04x)", r, g, b, a)
+  }
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func TestInvert(t *testing.T) {
img := createTestImage()
invertedImg := Invert(img)
c := invertedImg.At(0, 0)
r, g, b, a := c.RGBA()
// Original color was red (255,0,0,255) in RGBA terms (0xFFFF, 0x0000, 0x0000, 0xFFFF)
// Inverted should be (0, 255, 255, 255) in RGBA terms (0x0000, 0xFFFF, 0xFFFF, 0xFFFF)
// Since the image/color.RGBA.RGBA() returns values scaled to 0-0xFFFF, we check against that.
if r != 0 || g != 0xFFFF || b != 0xFFFF || a != 0xFFFF {
t.Errorf("Expected inverted color (0, 0xFFFF, 0xFFFF, 0xFFFF), got (%d, %d, %d, %d)", r, g, b, a)
}
}
func TestInvert(t *testing.T) {
img := createTestImage()
invertedImg := Invert(img)
c := invertedImg.At(0, 0)
r, g, b, a := c.RGBA()
// Original color was red (255,0,0,255) in RGBA terms (0xFFFF, 0x0000, 0x0000, 0xFFFF)
// Inverted should be (0, 255, 255, 255) in RGBA terms (0x0000, 0xFFFF, 0xFFFF, 0xFFFF)
// Since the image/color.RGBA.RGBA() returns values scaled to 0-0xFFFF, we check against that.
if r != 0 || g != 0xFFFF || b != 0xFFFF || a != 0xFFFF {
t.Errorf("Expected inverted color (0, 0xFFFF, 0xFFFF, 0xFFFF), got (%d, %d, %d, %d)", r, g, b, a)
}
}
func TestInvert_PartialAlpha(t *testing.T) {
rect := image.Rect(0, 0, 1, 1)
src := image.NewNRGBA(rect)
// Semi-transparent red: (128, 0, 0, 128)
src.SetNRGBA(0, 0, color.NRGBA{128, 0, 0, 128})
inv := Invert(src)
r, g, b, a := inv.At(0, 0).RGBA() // 0..0xFFFF un-premultiplied
if r != 0x7F7F || g != 0xFFFF || b != 0xFFFF || a != 0x8080 {
t.Fatalf("unexpected inverted RGBA (16-bit): got (%#04x,%#04x,%#04x,%#04x)", r, g, b, a)
}
}

Comment on lines +16 to +22
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
oldColor := img.At(x, y)
grayColor := color.GrayModel.Convert(oldColor).(color.Gray)
grayImg.Set(x, y, grayColor)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Bug: loop ignores non-zero bounds (incorrect for images with Min != 0,0)

Iterating from 0 to width/height breaks for sub-images or images with non-zero origins. Use bounds.Min/Max.

- for y := 0; y < h; y++ {
-   for x := 0; x < w; x++ {
+ for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
+   for x := bounds.Min.X; x < bounds.Max.X; x++ {
      oldColor := img.At(x, y)
      grayColor := color.GrayModel.Convert(oldColor).(color.Gray)
      grayImg.Set(x, y, grayColor)
    }
  }
🤖 Prompt for AI Agents
In
internal/exercises/templates/34_image_processing_utility/image_processing_utility.go
around lines 16 to 22, the nested loops iterate from 0 to w/h which fails for
images whose origin is not (0,0); use the image bounds instead. Replace the
0..w-1 and 0..h-1 loops with iteration from bounds.Min.X to bounds.Max.X and
bounds.Min.Y to bounds.Max.Y (obtain bounds := img.Bounds()), and construct the
destination gray image with the same bounds (image.NewGray(bounds)) so Set/At
use the correct coordinates.

Comment on lines +32 to +36
func (s *KeyValueStore) Load() error {
s.mu.Lock()
defer s.mu.Unlock()

file, err := os.Open(s.filepath)
if os.IsNotExist(err) {
return nil // No file, nothing to load
}
if err != nil {
return &StoreError{Message: fmt.Sprintf("failed to open store file: %v", err)}
}
defer file.Close()

scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
s.data[parts[0]] = parts[1]
}
}
return nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Handle scanner errors and reset map before load to avoid stale keys.

Currently, I/O errors during Scan() are ignored, and successive loads can merge old and new keys.

 func (s *KeyValueStore) Load() error {
-	s.mu.Lock()
-	defer s.mu.Unlock()
-
-	file, err := os.Open(s.filepath)
+	// Parse without holding the lock, then swap in atomically.
+	file, err := os.Open(s.filepath)
 	if os.IsNotExist(err) {
 		return nil // No file, nothing to load
 	}
 	if err != nil {
 		return &StoreError{Message: fmt.Sprintf("failed to open store file: %v", err)}
 	}
 	defer file.Close()
 
-	scanner := bufio.NewScanner(file)
-	for scanner.Scan() {
-		line := scanner.Text()
-		parts := strings.SplitN(line, "=", 2)
-		if len(parts) == 2 {
-			s.data[parts[0]] = parts[1]
-		}
-	}
-	return nil
+	tmp := make(map[string]string)
+	scanner := bufio.NewScanner(file)
+	for scanner.Scan() {
+		line := scanner.Text()
+		parts := strings.SplitN(line, "=", 2)
+		if len(parts) == 2 {
+			tmp[parts[0]] = parts[1]
+		}
+	}
+	if err := scanner.Err(); err != nil {
+		return &StoreError{Message: fmt.Sprintf("failed to read store file: %v", err)}
+	}
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.data = tmp
+	return nil
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (s *KeyValueStore) Load() error {
s.mu.Lock()
defer s.mu.Unlock()
file, err := os.Open(s.filepath)
if os.IsNotExist(err) {
return nil // No file, nothing to load
}
if err != nil {
return &StoreError{Message: fmt.Sprintf("failed to open store file: %v", err)}
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
s.data[parts[0]] = parts[1]
}
}
return nil
}
func (s *KeyValueStore) Load() error {
// Parse without holding the lock, then swap in atomically.
file, err := os.Open(s.filepath)
if os.IsNotExist(err) {
return nil // No file, nothing to load
}
if err != nil {
return &StoreError{Message: fmt.Sprintf("failed to open store file: %v", err)}
}
defer file.Close()
tmp := make(map[string]string)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
tmp[parts[0]] = parts[1]
}
}
if err := scanner.Err(); err != nil {
return &StoreError{Message: fmt.Sprintf("failed to read store file: %v", err)}
}
s.mu.Lock()
defer s.mu.Unlock()
s.data = tmp
return nil
}
🤖 Prompt for AI Agents
In internal/exercises/templates/35_basic_key_value_store/key_value_store.go
around lines 32 to 54, the Load method currently ignores scanner errors and
merges new file contents with stale in-memory keys; to fix, reset s.data to an
empty map at the start of the load (after acquiring the lock) so previous keys
are cleared, and after the scanner loop check scanner.Err() and if non-nil
return a StoreError wrapping that error (e.g., fmt.Sprintf("failed scanning
store file: %v", err)); preserve existing file open/close handling and locked
section when making these changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant