diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28a6062..2361c59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: go-version: '1.22' - name: Cache Go modules - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} @@ -80,10 +80,14 @@ jobs: - name: Test verify command with solutions (should pass) run: | echo "Testing that solution exercises pass..." - # Test a few key exercises with their solutions - ./bin/golearn verify 01_hello --solution - ./bin/golearn verify 36_json --solution - ./bin/golearn verify 37_xml --solution + for ex in 01_hello 36_json 37_xml; do + if [ -d "./internal/exercises/solutions/$ex" ]; then + echo "Running verify --solution for $ex" + ./bin/golearn verify "$ex" --solution + else + echo "Skipping $ex: no solution directory present" + fi + done - name: Test specific template exercises (should fail) run: | @@ -193,4 +197,4 @@ jobs: - name: Test watch command (timeout after 5 seconds) run: | - timeout 5s ./bin/golearn watch || true + timeout 5s ./bin/golearn watch || true \ No newline at end of file diff --git a/internal/exercises/Catalog/Concepts/01_hello.yaml b/internal/exercises/Catalog/Concepts/01_hello.yaml new file mode 100644 index 0000000..489bd01 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/01_hello.yaml @@ -0,0 +1,5 @@ +slug: 01_hello +title: Hello, Go! +test_regex: ".*" +hints: + - Implement Hello() to return 'Hello, Go!' diff --git a/internal/exercises/Catalog/Concepts/02_values.yaml b/internal/exercises/Catalog/Concepts/02_values.yaml new file mode 100644 index 0000000..89f8676 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/02_values.yaml @@ -0,0 +1,5 @@ +slug: 02_values +title: Values +test_regex: ".*" +hints: + - Use fmt.Sprintf to format values. diff --git a/internal/exercises/Catalog/Concepts/03_variables.yaml b/internal/exercises/Catalog/Concepts/03_variables.yaml new file mode 100644 index 0000000..bba4d07 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/03_variables.yaml @@ -0,0 +1,5 @@ +slug: 03_variables +title: Variables +test_regex: ".*" +hints: + - Use short declarations (:=) and return multiple values. diff --git a/internal/exercises/Catalog/Concepts/04_constants.yaml b/internal/exercises/Catalog/Concepts/04_constants.yaml new file mode 100644 index 0000000..05f46fa --- /dev/null +++ b/internal/exercises/Catalog/Concepts/04_constants.yaml @@ -0,0 +1,5 @@ +slug: 04_constants +title: Constants +test_regex: ".*" +hints: + - Use math.Pi and constant expressions. diff --git a/internal/exercises/Catalog/Concepts/05_for.yaml b/internal/exercises/Catalog/Concepts/05_for.yaml new file mode 100644 index 0000000..04376c8 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/05_for.yaml @@ -0,0 +1,5 @@ +slug: 05_for +title: For +test_regex: ".*" +hints: + - Accumulate a sum with a for loop. diff --git a/internal/exercises/Catalog/Concepts/06_if_else.yaml b/internal/exercises/Catalog/Concepts/06_if_else.yaml new file mode 100644 index 0000000..666da6f --- /dev/null +++ b/internal/exercises/Catalog/Concepts/06_if_else.yaml @@ -0,0 +1,5 @@ +slug: 06_if_else +title: If/Else +test_regex: ".*" +hints: + - Handle negative, zero, and positive cases. diff --git a/internal/exercises/Catalog/Concepts/07_switch.yaml b/internal/exercises/Catalog/Concepts/07_switch.yaml new file mode 100644 index 0000000..2efaace --- /dev/null +++ b/internal/exercises/Catalog/Concepts/07_switch.yaml @@ -0,0 +1,5 @@ +slug: 07_switch +title: Switch +test_regex: ".*" +hints: + - Match multiple cases for weekend days. diff --git a/internal/exercises/Catalog/Concepts/08_arrays.yaml b/internal/exercises/Catalog/Concepts/08_arrays.yaml new file mode 100644 index 0000000..0e140d9 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/08_arrays.yaml @@ -0,0 +1,5 @@ +slug: 08_arrays +title: Arrays +test_regex: ".*" +hints: + - Iterate with range over a fixed-size array. diff --git a/internal/exercises/Catalog/Concepts/09_slices.yaml b/internal/exercises/Catalog/Concepts/09_slices.yaml new file mode 100644 index 0000000..ec57aed --- /dev/null +++ b/internal/exercises/Catalog/Concepts/09_slices.yaml @@ -0,0 +1,5 @@ +slug: 09_slices +title: Slices +test_regex: ".*" +hints: + - Append values then compute a sum. diff --git a/internal/exercises/Catalog/Concepts/10_maps.yaml b/internal/exercises/Catalog/Concepts/10_maps.yaml new file mode 100644 index 0000000..6ebc4ed --- /dev/null +++ b/internal/exercises/Catalog/Concepts/10_maps.yaml @@ -0,0 +1,5 @@ +slug: 10_maps +title: Maps +test_regex: ".*" +hints: + - Use strings.Fields and map[string]int for word counts. diff --git a/internal/exercises/Catalog/Concepts/11_functions.yaml b/internal/exercises/Catalog/Concepts/11_functions.yaml new file mode 100644 index 0000000..92dc548 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/11_functions.yaml @@ -0,0 +1,5 @@ +slug: 11_functions +title: Functions +test_regex: ".*" +hints: + - Pass a function and call it. diff --git a/internal/exercises/Catalog/Concepts/12_multi_return.yaml b/internal/exercises/Catalog/Concepts/12_multi_return.yaml new file mode 100644 index 0000000..1ac2478 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/12_multi_return.yaml @@ -0,0 +1,5 @@ +slug: 12_multi_return +title: Multiple Return Values +test_regex: ".*" +hints: + - Return quotient, remainder, and an error for divide-by-zero. diff --git a/internal/exercises/Catalog/Concepts/13_variadic.yaml b/internal/exercises/Catalog/Concepts/13_variadic.yaml new file mode 100644 index 0000000..e3c070a --- /dev/null +++ b/internal/exercises/Catalog/Concepts/13_variadic.yaml @@ -0,0 +1,5 @@ +slug: 13_variadic +title: Variadic Functions +test_regex: ".*" +hints: + - Use '...' to accept any number of ints and sum them. diff --git a/internal/exercises/Catalog/Concepts/14_closures.yaml b/internal/exercises/Catalog/Concepts/14_closures.yaml new file mode 100644 index 0000000..9446718 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/14_closures.yaml @@ -0,0 +1,5 @@ +slug: 14_closures +title: Closures +test_regex: ".*" +hints: + - Implement a function that returns a closure, capturing an outer variable. diff --git a/internal/exercises/Catalog/Concepts/15_recursion.yaml b/internal/exercises/Catalog/Concepts/15_recursion.yaml new file mode 100644 index 0000000..ad48bfc --- /dev/null +++ b/internal/exercises/Catalog/Concepts/15_recursion.yaml @@ -0,0 +1,5 @@ +slug: 15_recursion +title: Recursion +test_regex: ".*" +hints: + - Implement a recursive factorial function. diff --git a/internal/exercises/Catalog/Concepts/16_range_built_in.yaml b/internal/exercises/Catalog/Concepts/16_range_built_in.yaml new file mode 100644 index 0000000..8b33ddf --- /dev/null +++ b/internal/exercises/Catalog/Concepts/16_range_built_in.yaml @@ -0,0 +1,5 @@ +slug: 16_range_built_in +title: Range over Built-in Types +test_regex: ".*" +hints: + - Use range to iterate over a slice and a map. diff --git a/internal/exercises/Catalog/Concepts/17_pointers.yaml b/internal/exercises/Catalog/Concepts/17_pointers.yaml new file mode 100644 index 0000000..07818a2 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/17_pointers.yaml @@ -0,0 +1,5 @@ +slug: 17_pointers +title: Pointers +test_regex: ".*" +hints: + - Write a function that takes a pointer, modifies the value, and returns the pointer. diff --git a/internal/exercises/Catalog/Concepts/18_strings_runes.yaml b/internal/exercises/Catalog/Concepts/18_strings_runes.yaml new file mode 100644 index 0000000..dc24276 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/18_strings_runes.yaml @@ -0,0 +1,5 @@ +slug: 18_strings_runes +title: Strings and Runes +test_regex: ".*" +hints: + - Iterate over a string with range to count runes, and demonstrate string manipulation. diff --git a/internal/exercises/Catalog/Concepts/19_structs.yaml b/internal/exercises/Catalog/Concepts/19_structs.yaml new file mode 100644 index 0000000..8512d76 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/19_structs.yaml @@ -0,0 +1,5 @@ +slug: 19_structs +title: Structs +test_regex: ".*" +hints: + - Define a struct with fields for name and age, then create an instance. diff --git a/internal/exercises/Catalog/Concepts/20_methods.yaml b/internal/exercises/Catalog/Concepts/20_methods.yaml new file mode 100644 index 0000000..cabc1c9 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/20_methods.yaml @@ -0,0 +1,5 @@ +slug: 20_methods +title: Methods +test_regex: ".*" +hints: + - Add a method to a struct that calculates something based on its fields. diff --git a/internal/exercises/Catalog/Concepts/21_interfaces.yaml b/internal/exercises/Catalog/Concepts/21_interfaces.yaml new file mode 100644 index 0000000..6c3eedf --- /dev/null +++ b/internal/exercises/Catalog/Concepts/21_interfaces.yaml @@ -0,0 +1,5 @@ +slug: 21_interfaces +title: Interfaces +test_regex: ".*" +hints: + - Define an interface and implement it for a struct. diff --git a/internal/exercises/Catalog/Concepts/22_enums.yaml b/internal/exercises/Catalog/Concepts/22_enums.yaml new file mode 100644 index 0000000..ba10471 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/22_enums.yaml @@ -0,0 +1,5 @@ +slug: 22_enums +title: Enums +test_regex: ".*" +hints: + - Use iota to create a set of related constants as an enumeration. diff --git a/internal/exercises/Catalog/Concepts/23_struct_embedding.yaml b/internal/exercises/Catalog/Concepts/23_struct_embedding.yaml new file mode 100644 index 0000000..38116c7 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/23_struct_embedding.yaml @@ -0,0 +1,5 @@ +slug: 23_struct_embedding +title: Struct Embedding +test_regex: ".*" +hints: + - Embed one struct within another and access the inner struct's fields directly. diff --git a/internal/exercises/Catalog/Concepts/24_generics.yaml b/internal/exercises/Catalog/Concepts/24_generics.yaml new file mode 100644 index 0000000..2f0d5e4 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/24_generics.yaml @@ -0,0 +1,5 @@ +slug: 24_generics +title: Generics +test_regex: ".*" +hints: + - Write a generic function that works with different types. diff --git a/internal/exercises/Catalog/Concepts/25_range_iterators.yaml b/internal/exercises/Catalog/Concepts/25_range_iterators.yaml new file mode 100644 index 0000000..0687a91 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/25_range_iterators.yaml @@ -0,0 +1,5 @@ +slug: 25_range_iterators +title: Range over Iterators +test_regex: ".*" +hints: + - Implement a custom iterator and use range over it. diff --git a/internal/exercises/Catalog/Concepts/26_errors.yaml b/internal/exercises/Catalog/Concepts/26_errors.yaml new file mode 100644 index 0000000..d597ff1 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/26_errors.yaml @@ -0,0 +1,5 @@ +slug: 26_errors +title: Errors +test_regex: ".*" +hints: + - Write a function that returns an error and handle it. diff --git a/internal/exercises/Catalog/Concepts/27_custom_errors.yaml b/internal/exercises/Catalog/Concepts/27_custom_errors.yaml new file mode 100644 index 0000000..b6f4a56 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/27_custom_errors.yaml @@ -0,0 +1,5 @@ +slug: 27_custom_errors +title: Custom Errors +test_regex: ".*" +hints: + - Define a custom error type and return it from a function. diff --git a/internal/exercises/Catalog/Concepts/28_defer.yaml b/internal/exercises/Catalog/Concepts/28_defer.yaml new file mode 100644 index 0000000..925ed82 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/28_defer.yaml @@ -0,0 +1,5 @@ +slug: 28_defer +title: Defer +test_regex: ".*" +hints: + - Use `f.Close()` with `defer` keyword. diff --git a/internal/exercises/Catalog/Concepts/29_go_routines.yaml b/internal/exercises/Catalog/Concepts/29_go_routines.yaml new file mode 100644 index 0000000..bb231f9 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/29_go_routines.yaml @@ -0,0 +1,5 @@ +slug: 29_go_routines +title: Go Routines +test_regex: ".*" +hints: + - Use `go` keyword to execute functions concurrently using go routines. diff --git a/internal/exercises/Catalog/Concepts/30_channels.yaml b/internal/exercises/Catalog/Concepts/30_channels.yaml new file mode 100644 index 0000000..256f78b --- /dev/null +++ b/internal/exercises/Catalog/Concepts/30_channels.yaml @@ -0,0 +1,5 @@ +slug: 30_channels +title: Channels +test_regex: ".*" +hints: + - Use `make(chan T)` to create a channel and `<-` to send and receive on it. diff --git a/internal/exercises/Catalog/Concepts/31_mutexes.yaml b/internal/exercises/Catalog/Concepts/31_mutexes.yaml new file mode 100644 index 0000000..762dae8 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/31_mutexes.yaml @@ -0,0 +1,5 @@ +slug: 31_mutexes +title: Mutexes +test_regex: ".*" +hints: + - Use `sync.Mutex` to synchronize access to a shared resource. diff --git a/internal/exercises/Catalog/Concepts/32_sorting.yaml b/internal/exercises/Catalog/Concepts/32_sorting.yaml new file mode 100644 index 0000000..0e031f0 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/32_sorting.yaml @@ -0,0 +1,5 @@ +slug: 32_sorting +title: Sorting +test_regex: ".*" +hints: + - Use `slice.Sort` for sorting slices. diff --git a/internal/exercises/Catalog/Concepts/33_string_formatting.yaml b/internal/exercises/Catalog/Concepts/33_string_formatting.yaml new file mode 100644 index 0000000..09dd089 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/33_string_formatting.yaml @@ -0,0 +1,5 @@ +slug: 33_string_formatting +title: String Formatting +test_regex: ".*" +hints: + - Use `fmt.Sprintf` and `%s`, `%d`, `%f` formatting directives to format strings, integers, and floats. diff --git a/internal/exercises/Catalog/Concepts/34_channel_buffering.yaml b/internal/exercises/Catalog/Concepts/34_channel_buffering.yaml new file mode 100644 index 0000000..ddaea60 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/34_channel_buffering.yaml @@ -0,0 +1,5 @@ +slug: 34_channel_buffering +title: Channel Buffering +test_regex: ".*" +hints: + - Use `make(chan T, N)` to create a buffered channel and `<-` to send and receive on it. diff --git a/internal/exercises/Catalog/Concepts/35_channel_sync.yaml b/internal/exercises/Catalog/Concepts/35_channel_sync.yaml new file mode 100644 index 0000000..9d5db2c --- /dev/null +++ b/internal/exercises/Catalog/Concepts/35_channel_sync.yaml @@ -0,0 +1,5 @@ +slug: 35_channel_sync +title: Channel Synchronization +test_regex: ".*" +hints: + - Use a buffered boolean channel and wait till go routine completes. diff --git a/internal/exercises/Catalog/Concepts/36_json.yaml b/internal/exercises/Catalog/Concepts/36_json.yaml new file mode 100644 index 0000000..70242cc --- /dev/null +++ b/internal/exercises/Catalog/Concepts/36_json.yaml @@ -0,0 +1,9 @@ +slug: 36_json +title: JSON +test_regex: ".*" +hints: + - Use the encoding/json package to work with JSON data. + - Implement MarshalPerson to convert a struct into JSON using json.Marshal. + - Implement UnmarshalPerson to convert a JSON string into a struct using json.Unmarshal. + - Handle and return errors properly in both functions. + diff --git a/internal/exercises/Catalog/Concepts/37_sorting_by_functions.yaml b/internal/exercises/Catalog/Concepts/37_sorting_by_functions.yaml new file mode 100644 index 0000000..765900c --- /dev/null +++ b/internal/exercises/Catalog/Concepts/37_sorting_by_functions.yaml @@ -0,0 +1,7 @@ +slug: 37_sorting_by_functions +title: "Sorting by Functions" +hints: + - Implement sort.Interface with Len(), Less(), and Swap() methods + - Create custom slice types like ByName and ByAge (e.g., type ByName []Person) + - Use sort.Sort() to sort slices with your custom comparison logic + - Remember to make copies of slices to avoid modifying the original \ No newline at end of file diff --git a/internal/exercises/Catalog/Concepts/37_xml.yaml b/internal/exercises/Catalog/Concepts/37_xml.yaml new file mode 100644 index 0000000..6c97c6b --- /dev/null +++ b/internal/exercises/Catalog/Concepts/37_xml.yaml @@ -0,0 +1,8 @@ +slug: 37_xml +title: XML Encoding and Decoding +test_regex: ".*" +hints: + - Use encoding/xml package for marshaling and unmarshaling XML data. + - Add XML struct tags using `xml:"fieldname"` to map struct fields to XML elements. + - Use xml.Marshal to convert structs to XML bytes. + - Use xml.Unmarshal to parse XML bytes into structs. diff --git a/internal/exercises/Catalog/Concepts/38_time_formatting.yaml b/internal/exercises/Catalog/Concepts/38_time_formatting.yaml new file mode 100644 index 0000000..80d611e --- /dev/null +++ b/internal/exercises/Catalog/Concepts/38_time_formatting.yaml @@ -0,0 +1,7 @@ +slug: 38_time_formatting +title: "Time Formatting" +hints: + - Use time.Now().Format() to format current time with a specific layout + - Use time.Parse() to parse time strings with known layouts + - Use time.LoadLocation() to work with different timezones + - Extract time components using .Date() and .Clock() methods \ No newline at end of file diff --git a/internal/exercises/Catalog/Concepts/39_channel_directions.yaml b/internal/exercises/Catalog/Concepts/39_channel_directions.yaml new file mode 100644 index 0000000..83eeb5d --- /dev/null +++ b/internal/exercises/Catalog/Concepts/39_channel_directions.yaml @@ -0,0 +1,9 @@ +slug: 39_channel_directions +title: Channel Directions +test_regex: ".*" +hints: + - Create two channels, one for sending jobs and one for receiving results. + - Start 5 workers using go routines. + - Send 50 jobs to the worker pool. + - Receive results from the worker pool and store them in a `rs` slice. + - Enforce type safety by specifying the channel directions in worker function. diff --git a/internal/exercises/Catalog/Concepts/39_panic.yaml b/internal/exercises/Catalog/Concepts/39_panic.yaml new file mode 100644 index 0000000..1971e39 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/39_panic.yaml @@ -0,0 +1,7 @@ +slug: 39_panic +title: Panic and Recover +test_regex: ".*" +hints: + - Use `panic()` to simulate runtime errors when appropriate. + - Use `defer` with `recover()` to catch and handle panics. + - Recovering from panics allows graceful handling of unexpected situations. \ No newline at end of file diff --git a/internal/exercises/Catalog/Concepts/40_channel_select.yaml b/internal/exercises/Catalog/Concepts/40_channel_select.yaml new file mode 100644 index 0000000..276a4a2 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/40_channel_select.yaml @@ -0,0 +1,6 @@ +slug: 40_channel_select +title: Channel Select +test_regex: ".*" +hints: + - Use `select` to handle multiple channel operations concurrently. + - Use `time.After` to simulate a 5 microsecond timeout. diff --git a/internal/exercises/Catalog/Concepts/41_time_delay.yaml b/internal/exercises/Catalog/Concepts/41_time_delay.yaml new file mode 100644 index 0000000..78b4e3e --- /dev/null +++ b/internal/exercises/Catalog/Concepts/41_time_delay.yaml @@ -0,0 +1,8 @@ +slug: 41_time_delay +title: "Delays and Timers in Go" +test_regex: ".*" +hints: + - Use time.Sleep() to pause execution for a duration. + - Convert milliseconds to time.Duration using time.Millisecond. + - Use a buffered channel to send a signal after waiting. + - Use time.After or time.Sleep to trigger events after delays. diff --git a/internal/exercises/Catalog/Concepts/42_wait_group.yaml b/internal/exercises/Catalog/Concepts/42_wait_group.yaml new file mode 100644 index 0000000..a7b473b --- /dev/null +++ b/internal/exercises/Catalog/Concepts/42_wait_group.yaml @@ -0,0 +1,9 @@ +slug: 42_wait_group +title: WaitGroups +test_regex: ".*" +hints: + - Use sync.WaitGroup to wait for all launched goroutines. + - Call wg.Add(1) before starting a goroutine and wg.Done() when it finishes. + - Close result channels after wg.Wait() so collectors can range over them. + - Capture loop variables correctly inside goroutines (e.g., `n := n`). + - Return results as a slice — order does not need to match input order. \ No newline at end of file diff --git a/internal/exercises/Catalog/Concepts/43_worker_pools.yaml b/internal/exercises/Catalog/Concepts/43_worker_pools.yaml new file mode 100644 index 0000000..0557da8 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/43_worker_pools.yaml @@ -0,0 +1,10 @@ +slug: 43_worker_pools +title: Worker Pools +test_regex: ".*" +hints: + - Create separate channels for jobs (send work) and results (receive processed data). + - Launch multiple worker goroutines that read from jobs channel and write to results channel. + - Use channel directions (<-chan for receive-only, chan<- for send-only) in worker function signature. + - Close the jobs channel after sending all work to signal workers to finish. + - Use strings.TrimSpace() to clean message strings by removing leading/trailing whitespace. + - Collect exactly len(logs) results to ensure all work is processed. diff --git a/internal/exercises/Catalog/Concepts/44_atomic_counters.yaml b/internal/exercises/Catalog/Concepts/44_atomic_counters.yaml new file mode 100644 index 0000000..1c39610 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/44_atomic_counters.yaml @@ -0,0 +1,7 @@ +slug: 44_atomic_counters +title: Atomic Counters +test_regex: ".*" +hints: + - Use `sync/atomic` package to create and increment atomic counter. + - Launch 10,000 anonymous go routines that simultaneously increment the counter. + - Use WaitGroups to wait for all go routines to finish. diff --git a/internal/exercises/Catalog/Concepts/45_range_over_channels.yaml b/internal/exercises/Catalog/Concepts/45_range_over_channels.yaml new file mode 100644 index 0000000..1ac2e11 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/45_range_over_channels.yaml @@ -0,0 +1,5 @@ +slug: 45_range_over_channels +title: Range over channels +test_regex: ".*" +hints: + - Use `for i := range ch {...}` syntax to range over a channels. diff --git a/internal/exercises/Catalog/Concepts/46_non_blocking_channel_operations.yaml b/internal/exercises/Catalog/Concepts/46_non_blocking_channel_operations.yaml new file mode 100644 index 0000000..3394cb2 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/46_non_blocking_channel_operations.yaml @@ -0,0 +1,7 @@ +slug: 46_non_blocking_channel_operations +title: Non-blocking channel operations +test_regex: ".*" +hints: + - Use `default` case in select to handle non-blocking channel operations. + - Safely increment `dropped` using mutex `Lock()` and `Unlock()`. + diff --git a/internal/exercises/Catalog/Concepts/64_timers.yaml b/internal/exercises/Catalog/Concepts/64_timers.yaml new file mode 100644 index 0000000..9ab1914 --- /dev/null +++ b/internal/exercises/Catalog/Concepts/64_timers.yaml @@ -0,0 +1,13 @@ +slug: 64_timers +title: "Timers" +test_regex: ".*" +hints: + - Use a map[string]*time.Timer to track active timers by key. + - Use a mutex to safely access the timers map concurrently. + - Start a timer with time.AfterFunc(d, fn) to run a callback after duration d. + - If Start is called again for an existing key, stop and replace the old timer. + - Stop should remove the timer from the map and prevent the callback from firing. + - Reset can call timer.Reset(d) to reschedule the same callback. + - Remember to remove timers from the map after they fire to avoid memory leaks. + - Write tests to verify Start, Stop, and Reset behavior, including concurrency scenarios. + diff --git a/internal/exercises/Catalog/Concepts/68_rate_limiting.yaml b/internal/exercises/Catalog/Concepts/68_rate_limiting.yaml new file mode 100644 index 0000000..9975b1a --- /dev/null +++ b/internal/exercises/Catalog/Concepts/68_rate_limiting.yaml @@ -0,0 +1,11 @@ +slug: 68_rate_limiting +title: Rate Limiting +test_regex: ".*" +hints: + - Implement a RateLimiter struct that tracks request timestamps per key using a map[string][]time.Time. + - Use a mutex to safely handle concurrent access to the map. + - In the Allow method, remove timestamps older than the interval and check if the number of recent requests exceeds the limit. + - Reset should clear the request history for a key; if the key does not exist, do nothing. + - Consider edge cases:` multiple keys, concurrent access, and requests exactly at the interval boundary. + - In tests, use time.Sleep with a small buffer above the interval to avoid flakiness. + - Initialize the timestamps map in NewRateLimiter to prevent nil pointer errors. diff --git a/internal/exercises/Catalog/Projects/01_text_analyzer.yaml b/internal/exercises/Catalog/Projects/01_text_analyzer.yaml new file mode 100644 index 0000000..7220644 --- /dev/null +++ b/internal/exercises/Catalog/Projects/01_text_analyzer.yaml @@ -0,0 +1,6 @@ +slug: 01_text_analyzer +dir: 101_text_analyzer +title: Text Analyzer (Easy) +test_regex: ".*" +hints: + - Implement functions to count characters, words, and unique words in a given text. diff --git a/internal/exercises/Catalog/Projects/02_shape_calculator.yaml b/internal/exercises/Catalog/Projects/02_shape_calculator.yaml new file mode 100644 index 0000000..5ecd111 --- /dev/null +++ b/internal/exercises/Catalog/Projects/02_shape_calculator.yaml @@ -0,0 +1,6 @@ +slug: 02_shape_calculator +dir: 102_shape_calculator +title: Shape Area Calculator (Medium) +test_regex: ".*" +hints: + - Define structs for different shapes, implement methods to calculate their areas, and use an interface for common shape behavior. diff --git a/internal/exercises/Catalog/Projects/03_task_scheduler.yaml b/internal/exercises/Catalog/Projects/03_task_scheduler.yaml new file mode 100644 index 0000000..63bd470 --- /dev/null +++ b/internal/exercises/Catalog/Projects/03_task_scheduler.yaml @@ -0,0 +1,6 @@ +slug: 03_task_scheduler +dir: 103_task_scheduler +title: Task Scheduler (Hard) +test_regex: ".*" +hints: + - Create a task scheduler with features like adding/removing tasks, a custom iterator, closures for task execution, and custom error handling. diff --git a/internal/exercises/Catalog/Projects/04_http_server.yaml b/internal/exercises/Catalog/Projects/04_http_server.yaml new file mode 100644 index 0000000..fff04d1 --- /dev/null +++ b/internal/exercises/Catalog/Projects/04_http_server.yaml @@ -0,0 +1,6 @@ +slug: 04_http_server +dir: 104_http_server +title: HTTP Server (Easy) +test_regex: ".*" +hints: + - Implement a basic HTTP server that responds to GET requests. diff --git a/internal/exercises/Catalog/Projects/05_cli_todo_list.yaml b/internal/exercises/Catalog/Projects/05_cli_todo_list.yaml new file mode 100644 index 0000000..7c46c4c --- /dev/null +++ b/internal/exercises/Catalog/Projects/05_cli_todo_list.yaml @@ -0,0 +1,6 @@ +slug: 05_cli_todo_list +dir: 105_cli_todo_list +title: CLI Todo List (Medium) +test_regex: ".*" +hints: + - Build a command-line tool to manage a todo list, including adding, listing, and completing tasks. diff --git a/internal/exercises/Catalog/Projects/06_simple_chat_app.yaml b/internal/exercises/Catalog/Projects/06_simple_chat_app.yaml new file mode 100644 index 0000000..a2bf5f9 --- /dev/null +++ b/internal/exercises/Catalog/Projects/06_simple_chat_app.yaml @@ -0,0 +1,6 @@ +slug: 06_simple_chat_app +dir: 106_simple_chat_app +title: Simple Chat Application (Medium) +test_regex: ".*" +hints: + - Create a basic client-server chat application. diff --git a/internal/exercises/Catalog/Projects/07_image_processing_utility.yaml b/internal/exercises/Catalog/Projects/07_image_processing_utility.yaml new file mode 100644 index 0000000..d722391 --- /dev/null +++ b/internal/exercises/Catalog/Projects/07_image_processing_utility.yaml @@ -0,0 +1,6 @@ +slug: 07_image_processing_utility +dir: 107_image_processing_utility +title: Image Processing Utility (Hard) +test_regex: ".*" +hints: + - Develop a command-line utility for basic image transformations like resizing or converting to grayscale. diff --git a/internal/exercises/Catalog/Projects/08_basic_key_value_store.yaml b/internal/exercises/Catalog/Projects/08_basic_key_value_store.yaml new file mode 100644 index 0000000..3758aed --- /dev/null +++ b/internal/exercises/Catalog/Projects/08_basic_key_value_store.yaml @@ -0,0 +1,6 @@ +slug: 08_basic_key_value_store +dir: 108_basic_key_value_store +title: Basic Key-Value Store (Hard) +test_regex: ".*" +hints: + - Implement an in-memory key-value store with basic CRUD operations and optional persistence. diff --git a/internal/exercises/Catalog/Projects/09_epoch.yaml b/internal/exercises/Catalog/Projects/09_epoch.yaml new file mode 100644 index 0000000..1b129e4 --- /dev/null +++ b/internal/exercises/Catalog/Projects/09_epoch.yaml @@ -0,0 +1,7 @@ +slug: 09_epoch +dir: 109_epoch +title: "Epoch Conversion" +hints: + - Use Go's `time.Unix()` to convert an epoch to time. + - Use `t.Unix()` to convert time back to epoch. + - Remember Go’s `time.Parse` can help parse date strings. diff --git a/internal/exercises/catalog.yaml b/internal/exercises/catalog.yaml deleted file mode 100644 index 37b779b..0000000 --- a/internal/exercises/catalog.yaml +++ /dev/null @@ -1,366 +0,0 @@ -concepts: -- slug: 01_hello - title: Hello, Go! - test_regex: ".*" - hints: - - Implement Hello() to return 'Hello, Go!' -- slug: 02_values - title: Values - test_regex: ".*" - hints: - - Use fmt.Sprintf to format values. -- slug: 03_variables - title: Variables - test_regex: ".*" - hints: - - Use short declarations (:=) and return multiple values. -- slug: 04_constants - title: Constants - test_regex: ".*" - hints: - - Use math.Pi and constant expressions. -- slug: 05_for - title: For - test_regex: ".*" - hints: - - Accumulate a sum with a for loop. -- slug: 06_if_else - title: If/Else - test_regex: ".*" - hints: - - Handle negative, zero, and positive cases. -- slug: 07_switch - title: Switch - test_regex: ".*" - hints: - - Match multiple cases for weekend days. -- slug: 08_arrays - title: Arrays - test_regex: ".*" - hints: - - Iterate with range over a fixed-size array. -- slug: 09_slices - title: Slices - test_regex: ".*" - hints: - - Append values then compute a sum. -- slug: 10_maps - title: Maps - test_regex: ".*" - hints: - - Use strings.Fields and map[string]int for word counts. -- slug: 11_functions - title: Functions - test_regex: ".*" - hints: - - Pass a function and call it. -- slug: 12_multi_return - title: Multiple Return Values - test_regex: ".*" - hints: - - Return quotient, remainder, and an error for divide-by-zero. -- slug: 13_variadic - title: Variadic Functions - test_regex: ".*" - hints: - - Use '...' to accept any number of ints and sum them. -- slug: 14_closures - title: Closures - test_regex: ".*" - hints: - - Implement a function that returns a closure, capturing an outer variable. -- slug: 15_recursion - title: Recursion - test_regex: ".*" - hints: - - Implement a recursive factorial function. -- slug: 16_range_built_in - title: Range over Built-in Types - test_regex: ".*" - hints: - - Use range to iterate over a slice and a map. -- slug: 17_pointers - title: Pointers - test_regex: ".*" - hints: - - Write a function that takes a pointer, modifies the value, and returns the pointer. -- slug: 18_strings_runes - title: Strings and Runes - test_regex: ".*" - hints: - - Iterate over a string with range to count runes, and demonstrate string manipulation. -- slug: 19_structs - title: Structs - test_regex: ".*" - hints: - - Define a struct with fields for name and age, then create an instance. -- slug: 20_methods - title: Methods - test_regex: ".*" - hints: - - Add a method to a struct that calculates something based on its fields. -- slug: 21_interfaces - title: Interfaces - test_regex: ".*" - hints: - - Define an interface and implement it for a struct. -- slug: 22_enums - title: Enums - test_regex: ".*" - hints: - - Use iota to create a set of related constants as an enumeration. -- slug: 23_struct_embedding - title: Struct Embedding - test_regex: ".*" - hints: - - Embed one struct within another and access the inner struct's fields directly. -- slug: 24_generics - title: Generics - test_regex: ".*" - hints: - - Write a generic function that works with different types. -- slug: 25_range_iterators - title: Range over Iterators - test_regex: ".*" - hints: - - Implement a custom iterator and use range over it. -- slug: 26_errors - title: Errors - test_regex: ".*" - hints: - - Write a function that returns an error and handle it. -- slug: 27_custom_errors - title: Custom Errors - test_regex: ".*" - hints: - - Define a custom error type and return it from a function. -- slug: 28_defer - title: Defer - test_regex: ".*" - hints: - - Use `f.Close()` with `defer` keyword. -- slug: 29_go_routines - title: Go Routines - test_regex: ".*" - hints: - - Use `go` keyword to execute functions concurrently using go routines. -- slug: 30_channels - title: Channels - test_regex: ".*" - hints: - - Use `make(chan T)` to create a channel and `<-` to send and receive on it. -- slug: 31_mutexes - title: Mutexes - test_regex: ".*" - hints: - - Use `sync.Mutex` to synchronize access to a shared resource. -- slug: 32_sorting - title: Sorting - test_regex: ".*" - hints: - - Use `slice.Sort` for sorting slices. -- slug: 33_string_formatting - title: String Formatting - test_regex: ".*" - hints: - - Use `fmt.Sprintf` and `%s`, `%d`, `%f` formatting directives to format strings, integers, and floats. -- slug: 34_channel_buffering - title: Channel Buffering - test_regex: ".*" - hints: - - Use `make(chan T, N)` to create a buffered channel and `<-` to send and receive on it. -- slug: 39_channel_directions - title: Channel Directions - test_regex: ".*" - hints: - - Create two channels, one for sending jobs and one for receiving results. - - Start 5 workers using go routines. - - Send 50 jobs to the worker pool. - - Receive results from the worker pool and store them in a `rs` slice. - - Enforce type safety by specifying the channel directions in worker function. -- slug: 35_channel_sync - title: Channel Synchronization - test_regex: ".*" - hints: - - Use a buffered boolean channel and wait till go routine completes. -- slug: 37_xml - title: XML Encoding and Decoding - test_regex: ".*" - hints: - - Use encoding/xml package for marshaling and unmarshaling XML data. - - Add XML struct tags using `xml:"fieldname"` to map struct fields to XML elements. - - Use xml.Marshal to convert structs to XML bytes. - - Use xml.Unmarshal to parse XML bytes into structs. -- slug: 40_channel_select - title: Channel Select - test_regex: ".*" - hints: - - Use `select` to handle multiple channel operations concurrently. - - Use `time.After` to simulate a 5 microsecond timeout. -- slug: 41_time_delay - title: "Delays and Timers in Go" - difficulty: beginner - topics: ["time", "sleep", "channels", "timer"] - test_regex: ".*" - hints: - - "Use time.Sleep() to pause execution for a duration." - - "Convert milliseconds to time.Duration using time.Millisecond." - - "Use a buffered channel to send a signal after waiting." - - "Use time.After or time.Sleep to trigger events after delays." -- slug: 42_wait_group - title: WaitGroups - test_regex: ".*" - difficulty: beginner - topics: ["concurrency", "sync", "goroutines"] - hints: - - "Use sync.WaitGroup to wait for all launched goroutines." - - "Call wg.Add(1) before starting a goroutine and wg.Done() when it finishes." - - "Close result channels after wg.Wait() so collectors can range over them." - - "Capture loop variables correctly inside goroutines (e.g., `n := n`)." - - "Return results as a slice — order does not need to match input order" - -- slug: 43_worker_pools - title: Worker Pools - test_regex: ".*" - hints: - - Create separate channels for jobs (send work) and results (receive processed data). - - Launch multiple worker goroutines that read from jobs channel and write to results channel. - - Use channel directions (<-chan for receive-only, chan<- for send-only) in worker function signature. - - Close the jobs channel after sending all work to signal workers to finish. - - Use strings.TrimSpace() to clean message strings by removing leading/trailing whitespace. - - Collect exactly len(logs) results to ensure all work is processed. -- slug: 44_atomic_counters - title: Atomic Counters - test_regex: ".*" - hints: - - Use `sync/atomic` package to create and increment atomic counter. - - Launch 10,000 anonymous go routines that simultaneously increment the counter. - - Use WaitGroups to wait for all go routines to finish. -- slug: 45_range_over_channels - title: Range over channels - test_regex: ".*" - hints: - - Use `for i := range ch {...}` syntax to range over a channels. -- slug: 46_non_blocking_channel_operations - title: Non-blocking channel operations - test_regex: ".*" - hints: - - Use `default` case in select to handle non-blocking channel operations. - - Safely increment `dropped` using mutex `Lock()` and `Unlock()`. - -projects: -- slug: 101_text_analyzer - title: Text Analyzer (Easy) - test_regex: ".*" - hints: - - Implement functions to count characters, words, and unique words in a given text. -- slug: 102_shape_calculator - title: Shape Area Calculator (Medium) - test_regex: ".*" - hints: - - Define structs for different shapes, implement methods to calculate their areas, and use an interface for common shape behavior. -- slug: 103_task_scheduler - title: Task Scheduler (Hard) - test_regex: ".*" - hints: - - Create a task scheduler with features like adding/removing tasks, a custom iterator, closures for task execution, and custom error handling. -- slug: 104_http_server - title: HTTP Server (Easy) - test_regex: ".*" - hints: - - Implement a basic HTTP server that responds to GET requests. -- slug: 105_cli_todo_list - title: CLI Todo List (Medium) - test_regex: ".*" - hints: - - Build a command-line tool to manage a todo list, including adding, listing, and completing tasks. -- slug: 106_simple_chat_app - title: Simple Chat Application (Medium) - test_regex: ".*" - hints: - - Create a basic client-server chat application. -- slug: 107_image_processing_utility - title: Image Processing Utility (Hard) - test_regex: ".*" - hints: - - Develop a command-line utility for basic image transformations like resizing or converting to grayscale. -- slug: 108_basic_key_value_store - title: Basic Key-Value Store (Hard) - test_regex: ".*" - hints: - - Implement an in-memory key-value store with basic CRUD operations and optional persistence. -- slug: 36_json - title: JSON - test_regex: ".*" - hints: - - Use the encoding/json package to work with JSON data. - - Implement MarshalPerson to convert a struct into JSON using json.Marshal. - - Implement UnmarshalPerson to convert a JSON string into a struct using json.Unmarshal. - - Handle and return errors properly in both functions. - -- slug: 109_epoch - title: "Epoch Conversion" - difficulty: beginner - topics: ["time", "epoch", "unix"] - hints: - - "Use Go's `time.Unix()` to convert an epoch to time." - - "Use `t.Unix()` to convert time back to epoch." - - "Remember Go’s `time.Parse` can help parse date strings." - -- slug: 37_sorting_by_functions - title: "Sorting by Functions" - difficulty: beginner - topics: ["slices", "sorting", "functions"] - hints: - - "Implement sort.Interface with Len(), Less(), and Swap() methods" - - "Create custom slice types like ByName and ByAge (e.g., type ByName []Person)" - - "Use sort.Sort() to sort slices with your custom comparison logic" - - "Remember to make copies of slices to avoid modifying the original" - -- slug: 38_time_formatting - title: "Time Formatting" - difficulty: beginner - topics: ["time", "formatting", "parsing"] - hints: - - "Use time.Now().Format() to format current time with a specific layout" - - "Use time.Parse() to parse time strings with known layouts" - - "Use time.LoadLocation() to work with different timezones" - - "Extract time components using .Date() and .Clock() methods" - -- slug: 39_panic - title: Panic and Recover - test_regex: ".*" - hints: - - "Use `panic()` to simulate runtime errors when appropriate." - - "Use `defer` with `recover()` to catch and handle panics." - - "Recovering from panics allows graceful handling of unexpected situations." - - "Remember: `recover()` only works inside a deferred function." - -- slug: 64_timers - title: "Timers" - difficulty: medium - topics: ["time", "goroutines", "concurrency", "synchronization"] - test_regex: ".*" - hints: - - "Use a map[string]*time.Timer to track active timers by key." - - "Use a mutex to safely access the timers map concurrently." - - "Start a timer with time.AfterFunc(d, fn) to run a callback after duration d." - - "If Start is called again for an existing key, stop and replace the old timer." - - "Stop should remove the timer from the map and prevent the callback from firing." - - "Reset can call timer.Reset(d) to reschedule the same callback." - - "Remember to remove timers from the map after they fire to avoid memory leaks." - - "Write tests to verify Start, Stop, and Reset behavior, including concurrency scenarios." - -- slug: 68_rate_limiting - title: Rate Limiting - test_regex: ".*" - difficulty: medium - topics: ["time", "concurrency", "maps"] - hints: - - "Implement a RateLimiter struct that tracks request timestamps per key using a map[string][]time.Time." - - "Use a mutex to safely handle concurrent access to the map." - - "In the Allow method, remove timestamps older than the interval and check if the number of recent requests exceeds the limit." - - "Reset should clear the request history for a key; if the key does not exist, do nothing." - - "Consider edge cases: multiple keys, concurrent access, and requests exactly at the interval boundary." - - "In tests, use time.Sleep with a small buffer above the interval to avoid flakiness." - - "Initialize the timestamps map in NewRateLimiter to prevent nil pointer errors." diff --git a/internal/exercises/exercises.go b/internal/exercises/exercises.go index 7f588d6..4ea1004 100644 --- a/internal/exercises/exercises.go +++ b/internal/exercises/exercises.go @@ -6,7 +6,10 @@ import ( "fmt" "io/fs" "os" + "path" "path/filepath" + "sort" + "strings" "sync" "gopkg.in/yaml.v3" @@ -15,58 +18,154 @@ import ( //go:embed templates/** var templatesFS embed.FS -//go:embed catalog.yaml +//go:embed Catalog/** var catalogFS embed.FS type Exercise struct { Slug string `yaml:"slug"` + Dir string `yaml:"dir,omitempty"` // Maps to folder name if different from slug Title string `yaml:"title"` TestRegex string `yaml:"test_regex"` Hints []string `yaml:"hints"` } +// Path returns the directory name in templates/solutions. +func (e Exercise) Path() string { + if e.Dir != "" { + return e.Dir + } + return e.Slug +} + type Catalog struct { Concepts []Exercise `yaml:"concepts"` Projects []Exercise `yaml:"projects"` } var ( - catalogOnce sync.Once - catalogData Catalog + catalogMu sync.Mutex + catalogData *Catalog + + catalogLoader = func() (Catalog, error) { + return loadCatalogFromFS(catalogFS) + } ) +// catalog returns the singleton Catalog instance. func catalog() Catalog { - catalogOnce.Do(func() { - b, err := catalogFS.ReadFile("catalog.yaml") + catalogMu.Lock() + defer catalogMu.Unlock() + + if catalogData != nil { + return *catalogData + } + + c, err := catalogLoader() + if err != nil { + // If loading fails, use fallback + f := fallbackCatalog() + catalogData = &f + return *catalogData + } + + catalogData = &c + return *catalogData +} + +// loadCatalogFromFS scans the provided filesystem for exercise definitions. +// It accepts fs.FS to allow testing with fstest.MapFS. +func loadCatalogFromFS(fsys fs.FS) (Catalog, error) { + concepts, err := loadExercisesDir(fsys, "Catalog/Concepts") + if err != nil { + return Catalog{}, err + } + projects, err := loadExercisesDir(fsys, "Catalog/Projects") + if err != nil { + return Catalog{}, err + } + if len(concepts) == 0 && len(projects) == 0 { + return Catalog{}, errors.New("no exercises found in Catalog/") + } + seen := map[string]string{} // key -> origin + check := func(origin string, ex Exercise) error { + for _, k := range []string{ex.Slug, ex.Dir} { + if k == "" { + continue + } + if prev, ok := seen[k]; ok { + return fmt.Errorf("duplicate exercise identifier %q: %s vs %s", k, prev, origin) + } + seen[k] = origin + } + return nil + } + for _, ex := range concepts { + if err := check("Catalog/Concepts/"+ex.Slug, ex); err != nil { + return Catalog{}, err + } + } + for _, ex := range projects { + if err := check("Catalog/Projects/"+ex.Slug, ex); err != nil { + return Catalog{}, err + } + } + + return Catalog{Concepts: concepts, Projects: projects}, nil +} + +// loadExercisesDir reads all .yaml/.yml files in a directory and returns sorted exercises. +func loadExercisesDir(fsys fs.FS, dir string) ([]Exercise, error) { + var exercises []Exercise + entries, err := fs.ReadDir(fsys, dir) + if errors.Is(err, fs.ErrNotExist) { + return []Exercise{}, nil + } + if err != nil { + return nil, err + } + for _, e := range entries { + if e.IsDir() || (!strings.HasSuffix(e.Name(), ".yaml") && !strings.HasSuffix(e.Name(), ".yml")) { + continue + } + data, err := fs.ReadFile(fsys, path.Join(dir, e.Name())) 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!'"}, - }}, + return nil, fmt.Errorf("read %s: %w", e.Name(), err) + } + var ex Exercise + if err := yaml.Unmarshal(data, &ex); err != nil { + return nil, fmt.Errorf("parse %s: %w", e.Name(), err) + } + if ex.Slug == "" { + return nil, fmt.Errorf("exercise in %s missing slug", e.Name()) + } + // Hardening: keep identifiers as simple directory names. + for fieldName, v := range map[string]string{"slug": ex.Slug, "dir": ex.Dir} { + if v == "" { + continue } - 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!'"}, - }}, + if strings.Contains(v, "/") || strings.Contains(v, `\`) || strings.Contains(v, "..") || path.Clean(v) != v { + return nil, fmt.Errorf("%s in %s is not a safe directory name: %q", fieldName, e.Name(), v) } - return } - catalogData = cat - }) - return catalogData + exercises = append(exercises, ex) + } + sort.Slice(exercises, func(i, j int) bool { return exercises[i].Slug < exercises[j].Slug }) + return exercises, nil +} + +// fallbackCatalog provides a minimal hardcoded catalog if loading fails. +func fallbackCatalog() Catalog { + return Catalog{ + Concepts: []Exercise{{ + Slug: "01_hello", + Title: "Hello, Go!", + TestRegex: ".*", + Hints: []string{"Implement Hello() to return 'Hello, Go!'"}, + }}, + } } +// discoverLocal checks the local exercises/ directory for any user-added exercises. func discoverLocal() ([]Exercise, error) { var items []Exercise entries, err := os.ReadDir("exercises") @@ -79,39 +178,33 @@ func discoverLocal() ([]Exercise, error) { for _, e := range entries { if e.IsDir() { slug := e.Name() - items = append(items, Exercise{ - Slug: slug, - Title: slug, - TestRegex: ".*", - Hints: nil, - }) + items = append(items, Exercise{Slug: slug, Title: slug, TestRegex: ".*"}) } } return items, nil } +// ListAll returns all exercises from the catalog or local directory if present. 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 } +// Get looks up an exercise by its slug or directory name. func Get(slug string) (Exercise, error) { for _, ex := range catalog().Concepts { - if ex.Slug == slug { + if ex.Slug == slug || ex.Dir == slug { return ex, nil } } for _, ex := range catalog().Projects { - if ex.Slug == slug { + if ex.Slug == slug || ex.Dir == slug { return ex, nil } } @@ -127,59 +220,61 @@ func Get(slug string) (Exercise, error) { return Exercise{}, fmt.Errorf("exercise not found: %s", slug) } +// Reset restores the exercise files from the embedded template. func Reset(ex Exercise) error { - // Only supported for built-in embedded templates - if !templateExists(ex.Slug) { + if !templateExists(ex.Path()) { return fmt.Errorf("reset unsupported for non-embedded exercise '%s'", ex.Slug) } - return copyExerciseTemplate(ex.Slug) + return copyExerciseTemplate(ex) } -func templateExists(slug string) bool { - root := filepath.Join("templates", slug) +// templateExists reports whether a template directory exists in the embedded templatesFS. +func templateExists(dirName string) bool { + root := path.Join("templates", dirName) _, err := fs.Stat(templatesFS, root) return err == nil } +// InitAll initializes all exercises from the embedded templates. func InitAll() error { for _, ex := range catalog().Concepts { - if err := copyExerciseTemplate(ex.Slug); err != nil { + if err := copyExerciseTemplate(ex); err != nil { return err } } for _, ex := range catalog().Projects { - if err := copyExerciseTemplate(ex.Slug); err != nil { + if err := copyExerciseTemplate(ex); err != nil { return err } } return nil } -func copyExerciseTemplate(slug string) error { - targetDir := filepath.Join("exercises", slug) - // Remove and recreate to ensure a clean state +// copyExerciseTemplate copies the template files for an exercise into the local exercises/ directory. +func copyExerciseTemplate(ex Exercise) error { + targetDir := filepath.Join("exercises", ex.Slug) _ = os.RemoveAll(targetDir) if err := os.MkdirAll(targetDir, 0o755); err != nil { return err } - root := filepath.Join("templates", slug) - return fs.WalkDir(templatesFS, root, func(path string, d fs.DirEntry, err error) error { + root := path.Join("templates", ex.Path()) + return fs.WalkDir(templatesFS, root, func(fpath string, d fs.DirEntry, err error) error { if err != nil { return err } - rel, err := filepath.Rel(root, path) + rel, err := filepath.Rel(root, filepath.FromSlash(fpath)) if err != nil { - return err + rel = strings.TrimPrefix(fpath, root+"/") } if rel == "." { return nil } - dest := filepath.Join(targetDir, rel) + dest := filepath.Join(targetDir, filepath.FromSlash(rel)) if d.IsDir() { return os.MkdirAll(dest, 0o755) } - data, err := fs.ReadFile(templatesFS, path) + data, err := fs.ReadFile(templatesFS, fpath) if err != nil { return err } diff --git a/internal/exercises/exercises_test.go b/internal/exercises/exercises_test.go new file mode 100644 index 0000000..a982e1c --- /dev/null +++ b/internal/exercises/exercises_test.go @@ -0,0 +1,190 @@ +package exercises + +import ( + "strings" + "testing" + "testing/fstest" + + "gopkg.in/yaml.v3" +) + +// Helper to build YAML quickly +func y(v any) []byte { + b, _ := yaml.Marshal(v) + return b +} + +func TestLoadExercisesDir_LoadsValidYAML(t *testing.T) { + fsys := fstest.MapFS{ + "Catalog/Concepts/a.yaml": &fstest.MapFile{ + Data: y(Exercise{ + Slug: "02_vars", + Title: "Variables", + TestRegex: ".*", + Hints: []string{"x := 1"}, + }), + }, + "Catalog/Concepts/b.yaml": &fstest.MapFile{ + Data: y(Exercise{ + Slug: "01_hello", + Title: "Hello", + TestRegex: ".*", + }), + }, + } + + items, err := loadExercisesDir(fsys, "Catalog/Concepts") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } + + // Unsorted read → sorted in loadCatalogFromFS, but loadExercisesDir returns them sorted by slug. + if items[0].Slug != "01_hello" || items[1].Slug != "02_vars" { + t.Fatalf("items not sorted properly: %v", items) + } +} + +func TestLoadExercisesDir_RejectsEmptySlug(t *testing.T) { + fsys := fstest.MapFS{ + "Catalog/Concepts/bad.yaml": &fstest.MapFile{ + Data: []byte("title: NoSlug"), + }, + } + + _, err := loadExercisesDir(fsys, "Catalog/Concepts") + if err == nil || !strings.Contains(err.Error(), "missing slug") { + t.Fatalf("expected missing slug error, got: %v", err) + } +} + +func TestLoadExercisesDir_IgnoresNonYAML(t *testing.T) { + fsys := fstest.MapFS{ + "Catalog/Concepts/readme.txt": &fstest.MapFile{Data: []byte("ignore me")}, + "Catalog/Concepts/x.yaml": &fstest.MapFile{Data: y(Exercise{Slug: "01_x"})}, + } + + items, err := loadExercisesDir(fsys, "Catalog/Concepts") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 YAML item, got %d", len(items)) + } +} + +func TestLoadCatalogFromFS_LoadsConceptsAndProjects(t *testing.T) { + fsys := fstest.MapFS{ + "Catalog/Concepts/a.yaml": &fstest.MapFile{ + Data: y(Exercise{Slug: "02_b"}), + }, + "Catalog/Concepts/b.yaml": &fstest.MapFile{ + Data: y(Exercise{Slug: "01_a"}), + }, + "Catalog/Projects/c.yaml": &fstest.MapFile{ + Data: y(Exercise{Slug: "10_p"}), + }, + } + + cat, err := loadCatalogFromFS(fsys) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(cat.Concepts) != 2 { + t.Fatalf("expected 2 concepts, got %d", len(cat.Concepts)) + } + if len(cat.Projects) != 1 { + t.Fatalf("expected 1 project, got %d", len(cat.Projects)) + } + + // Check sorted order + if cat.Concepts[0].Slug != "01_a" || cat.Concepts[1].Slug != "02_b" { + t.Fatalf("concepts not sorted: %+v", cat.Concepts) + } +} + +func TestLoadCatalogFromFS_MissingDirsAreIgnored(t *testing.T) { + fsys := fstest.MapFS{ + "Catalog/Concepts/x.yaml": &fstest.MapFile{ + Data: y(Exercise{Slug: "01_a"}), + }, + } + + cat, err := loadCatalogFromFS(fsys) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(cat.Projects) != 0 { + t.Fatalf("expected zero projects") + } + if len(cat.Concepts) != 1 { + t.Fatalf("expected one concept") + } +} + +func TestLoadCatalogFromFS_InvalidYAML(t *testing.T) { + fsys := fstest.MapFS{ + "Catalog/Concepts/bad.yaml": &fstest.MapFile{ + Data: []byte("{invalid yaml} : ???"), + }, + } + + _, err := loadCatalogFromFS(fsys) + if err == nil { + t.Fatal("expected YAML error, got nil") + } +} + +func TestFallbackCatalog(t *testing.T) { + f := fallbackCatalog() + if len(f.Concepts) != 1 { + t.Fatalf("fallback should have exactly 1 concept") + } + if f.Concepts[0].Slug != "01_hello" { + t.Fatalf("fallback wrong slug: %s", f.Concepts[0].Slug) + } +} + +func TestCatalog_UsesDirectoryLoader(t *testing.T) { + // We verify that the singleton `catalog()` function actually calls our custom loader logic. + fsys := fstest.MapFS{ + "Catalog/Concepts/a.yaml": &fstest.MapFile{ + Data: y(Exercise{Slug: "01_test"}), + }, + } + + // Use the exported helper to swap the loader with one that uses our test filesystem + WithTestCatalogLoader(func() (Catalog, error) { + return loadCatalogFromFS(fsys) + }, func() { + cat := catalog() + if len(cat.Concepts) != 1 || cat.Concepts[0].Slug != "01_test" { + t.Fatalf("catalog() did not load from the injected FS") + } + }) +} + +func TestDiscoverLocal_NoDir(t *testing.T) { + _, err := discoverLocal() + if err != nil { + t.Fatalf("should not error if exercises directory is missing") + } +} + +func TestCatalogOverride(t *testing.T) { + WithTestCatalogLoader(func() (Catalog, error) { + return Catalog{ + Concepts: []Exercise{{Slug: "01_mock"}}, + }, nil + }, func() { + c := catalog() + + if len(c.Concepts) != 1 || c.Concepts[0].Slug != "01_mock" { + t.Fatalf("expected mock catalog, got: %+v", c) + } + }) +} diff --git a/internal/exercises/export_test.go b/internal/exercises/export_test.go new file mode 100644 index 0000000..1e0efae --- /dev/null +++ b/internal/exercises/export_test.go @@ -0,0 +1,27 @@ +package exercises + +// WithTestCatalogLoader replaces the catalog loader temporarily for a test. +// This allows you to inject mock data and reset the singleton state. +func WithTestCatalogLoader(mockLoader func() (Catalog, error), testFunc func()) { + catalogMu.Lock() + + // Save old state + oldLoader := catalogLoader + oldData := catalogData + + // Swap in mock and force reload + catalogLoader = mockLoader + catalogData = nil + + catalogMu.Unlock() + + defer func() { + catalogMu.Lock() + // Restore old state + catalogLoader = oldLoader + catalogData = oldData + catalogMu.Unlock() + }() + + testFunc() +} diff --git a/internal/exercises/solutions.go b/internal/exercises/solutions.go index 7dd669a..5b8a882 100644 --- a/internal/exercises/solutions.go +++ b/internal/exercises/solutions.go @@ -5,30 +5,38 @@ import ( "errors" "io/fs" "os" + pathpkg "path" "path/filepath" "strings" ) -// Embed the canonical solutions alongside templates. We do not expose these via CLI directly. -// //go:embed all:solutions/** var solutionsFS embed.FS -// SolutionExists reports whether we have an embedded solution for the given slug. -func SolutionExists(slug string) bool { - root := filepath.Join("solutions", slug) +// SolutionExists reports whether a solution directory exists in the embedded solutionsFS. +// dirName is the directory name under solutions/ (usually the exercise's Path). +func SolutionExists(dirName string) bool { + root := pathpkg.Join("solutions", dirName) _, err := fs.Stat(solutionsFS, root) return err == nil } -// CreateSolutionSandbox creates a temporary module containing the solution implementation -// files and the original tests from templates for the given exercise slug. -// The returned directory can be used as the working directory for `go test`. +// CreateSolutionSandbox creates a temporary sandbox directory for the solution of the given exercise slug. +// It copies the solution implementation and test files into the sandbox. +// It returns the path to the sandbox, a cleanup function to remove it, and any error encountered. func CreateSolutionSandbox(slug string) (string, func(), error) { - if !templateExists(slug) { + // Look up exercise to get the correct source Directory + ex, err := Get(slug) + if err != nil { + return "", func() {}, err + } + + srcDir := ex.Path() // Uses Dir if set, else Slug + + if !templateExists(srcDir) { return "", func() {}, errors.New("no template found for exercise") } - if !SolutionExists(slug) { + if !SolutionExists(srcDir) { return "", func() {}, errors.New("no embedded solution available") } @@ -38,7 +46,7 @@ func CreateSolutionSandbox(slug string) (string, func(), error) { } cleanup := func() { _ = os.RemoveAll(workDir) } - // 1) Create a minimal go.mod. We pin testify which is used by some tests. + // Create go.mod using the SLUG (01) for the module name goMod := "module golearn/tmp/" + strings.ReplaceAll(slug, "_", "-") + "\n\n" + "go 1.22.0\n\n" + "require github.com/stretchr/testify v1.11.0\n" @@ -47,8 +55,8 @@ func CreateSolutionSandbox(slug string) (string, func(), error) { return "", func() {}, err } - // 2) Copy tests from embedded templates - templateRoot := filepath.Join("templates", slug) + // Copy tests from templates (using srcDir 101) + templateRoot := filepath.Join("templates", srcDir) if err := fs.WalkDir(templatesFS, templateRoot, func(path string, d fs.DirEntry, err error) error { if err != nil { return err @@ -59,26 +67,19 @@ func CreateSolutionSandbox(slug string) (string, func(), error) { if !strings.HasSuffix(path, "_test.go") { return nil } - rel, err := filepath.Rel(templateRoot, path) - if err != nil { - return err - } + + rel, _ := filepath.Rel(templateRoot, path) dest := filepath.Join(workDir, rel) - if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { - return err - } - data, err := fs.ReadFile(templatesFS, path) - if err != nil { - return err - } + _ = os.MkdirAll(filepath.Dir(dest), 0o755) + data, _ := fs.ReadFile(templatesFS, path) return os.WriteFile(dest, data, 0o644) }); err != nil { cleanup() return "", func() {}, err } - // 3) Copy solution implementation files (exclude *_test.go just in case) - solutionRoot := filepath.Join("solutions", slug) + // Copy solution implementation from solutions (using srcDir 101) + solutionRoot := filepath.Join("solutions", srcDir) if err := fs.WalkDir(solutionsFS, solutionRoot, func(path string, d fs.DirEntry, err error) error { if err != nil { return err @@ -89,18 +90,11 @@ func CreateSolutionSandbox(slug string) (string, func(), error) { if strings.HasSuffix(path, "_test.go") { return nil } - rel, err := filepath.Rel(solutionRoot, path) - if err != nil { - return err - } + + rel, _ := filepath.Rel(solutionRoot, path) dest := filepath.Join(workDir, rel) - if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { - return err - } - data, err := fs.ReadFile(solutionsFS, path) - if err != nil { - return err - } + _ = os.MkdirAll(filepath.Dir(dest), 0o755) + data, _ := fs.ReadFile(solutionsFS, path) return os.WriteFile(dest, data, 0o644) }); err != nil { cleanup()