Skip to content

fix: code-review findings (bugs, robustness, refactors)#1

Merged
jrollin merged 11 commits into
mainfrom
fix/code-review-findings
Jun 21, 2026
Merged

fix: code-review findings (bugs, robustness, refactors)#1
jrollin merged 11 commits into
mainfrom
fix/code-review-findings

Conversation

@jrollin

@jrollin jrollin commented Jun 21, 2026

Copy link
Copy Markdown
Owner

Summary

Addresses findings from a whole-codebase review, then implements the suggested follow-up improvements. 11 commits, grouped by concern.

Verified: cargo fmt --check clean · 186 tests pass · cargo clippy --all-targets -- -D warnings clean.

Bug fixes

  • Empty-menu panic (app.rs): navigating Down in an empty lesson menu (Custom with no files) evaluated filtered_count - 1 on a usize — panicked in debug, wrapped in release. Now guarded via next_menu_index().
  • Per-key timing (engine/analytics.rs): recorded the absolute cumulative timestamp instead of the per-keystroke delta, skewing slow-key detection. Now records the interval; removed a no-op checked_sub.
  • Byte-vs-char generation (content/*_generator.rs): loops used byte length but truncated by char count, so accented French lessons came out short. Standardized on chars().count().
  • wrap_text (ui/render): column budgeting was byte-based; accented words wrapped too early.

Robustness

  • Atomic persistence (data/storage.rs): save/save_config write to a temp file then rename; corrupt stats.json/config.json is backed up to .bak and defaults returned so the app always launches. Error messages now include the file path; config_path no longer uses expect().
  • custom.rs warns on unreadable lesson directories instead of silently returning empty.

Quality / refactors

  • DrillItem trait (content/ngram_generator.rs): removed ~150 lines of duplicated bigram/trigram drill logic.
  • O(1) char indexing (engine/types.rs): parallel Vec<char> removes the per-keystroke O(n) walk.
  • No per-frame clone (ui/render_menu takes &[&Lesson]): stopped deep-copying lesson content every frame.
  • render.rs split: 1862-line file → render/ module by screen; extracted a pure, unit-tested keys_for_level() aggregation.
  • Misc: dedup calculate_accuracy, clamp progress bar against >100%, is_complete uses cached count, removed dead VisibleWindow fields and 5 duplicated menu-item blocks.

Tests added

Multibyte input (déjà/café/ré), identify_slow_keys exact-set + boundary tie, custom parser edges (missing ---, CRLF, FileTooLarge), update_analytics accumulation, storage atomic write + corrupt recovery, progress-bar overflow.

Not changed (decisions, not code)

  • AltGr/accented-key analytics behavior — worth a manual check, no code change.
  • ParseError::InvalidFrontMatter — kept as a documented strategic placeholder.

🤖 Generated with Claude Code

jrollin added 11 commits June 21, 2026 07:39
Two correctness bugs found in code review:

- app.rs: navigating Down in an empty lesson menu (e.g. Custom with no
  markdown files) evaluated `filtered_count - 1` on a usize, panicking in
  debug and wrapping to usize::MAX in release. Extracted next_menu_index()
  returning Option to guard the empty case.
- analytics.rs: per-key timings pushed the absolute cumulative timestamp
  instead of the per-keystroke delta, so average key time grew with
  position in the lesson and skewed slow-key detection. Now records the
  interval from the previous input. Also removed a no-op
  checked_sub(Duration::from_secs(0)) in the bigram path.

Adds regression tests for both.
Generation loops used `result.len() < length` (byte length) but truncated
with `chars().take(length)` (char count). French content is multibyte
(é, è, à, ç...), so accented lessons came out shorter than requested.
Standardized all generators on chars().count().

Also extracts the shared drill-assembly loop from finger_generator's base
and shift variants into assemble_drill(), and updates stale byte-length
test assertions to char counts. Adds an accented-content regression test.
- save()/save_config() now write to a temp file and rename over the target,
  so an interrupted write can't truncate the existing file.
- load()/load_config() back up a corrupt JSON file to .bak and return
  defaults instead of propagating an error that made the app unlaunchable.
- Error messages now include the file path.
- config_path() falls back to "." instead of expect(); HOME-unset error
  now explains how to fix it.

Adds tests for atomic write and corrupt-file recovery; replaces the stale
"corrupt config returns error" test with the new graceful behavior.
- render.rs: extract render_lesson_item() (collapses 5 duplicated blocks);
  remove 3 dead VisibleWindow fields; wrap_text counts chars not bytes.
- scoring.rs: calculate_results() now calls calculate_accuracy() instead of
  duplicating the formula; drops the dead_code annotation.
- types.rs: is_complete() uses the cached content_buffer_size instead of
  rescanning chars() on every keystroke.
- custom.rs: warn on stderr when a custom-lesson directory can't be read.
- ui/analytics.rs: clamp progress bar fill so >100% can't underflow; use
  !is_empty() over len() > 0.
- stats.rs: add update_analytics accumulation regression test.
Test count 145 -> 178; dead-code annotations 29 -> 22 after removing the
scoring duplicate and the unused VisibleWindow fields.
- types/analytics: accented content (déjà, café, ré bigram) through
  add_input, is_complete, and SessionAnalyzer.
- adaptive: pin the exact identify_slow_keys result and add a boundary-tie
  case so an off-by-one in the percentile index is caught.
- custom: missing closing ---, CRLF front matter, and FileTooLarge guard.
bigram_generator and trigram_generator duplicated the drill/word/mixed-mode
loops verbatim (differing only in item type). Introduces a DrillItem trait
(pattern/examples) implemented by Bigram and Trigram, and generic
generate_drill_mode/word_mode/mixed_mode in a new ngram_generator module.
Both generators now delegate, removing ~150 lines of copy-paste. No
behavior change.
add_input, is_complete, and the per-frame render lookups used
content.chars().nth()/count(), an O(n) walk that made a session O(n^2) as
append_content grew the buffer. TypingSession now keeps a `chars: Vec<char>`
in sync with `content` and exposes char_at() for O(1) indexing.
The LessonMenu draw closure cloned every filtered Lesson each frame, deep-
copying LessonType::Custom content (up to 1MB). render_menu now takes
&[&Lesson] and receives filtered_lessons() references directly.
…tion

render.rs (1862 lines) is now a render/ directory module split by screen:
session, menu, results, statistics, analytics_screen. mod.rs is a thin
aggregator (shared imports + re-exports); the public API is unchanged.

Extracts keys_for_level() — the mastery grouping/sorting previously buried
in render_mastery_breakdown — as a pure function with a unit test.
- Test count 178 -> 186.
- ui/render is now a directory module; note ngram_generator.rs.
@jrollin jrollin merged commit c680756 into main Jun 21, 2026
4 checks passed
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