Files
keydr/docs/plans/2026-02-20-import-export-feature-plan.md

189 lines
12 KiB
Markdown

# Import/Export Feature Plan
## Context
Users need a way to back up and transfer their keydr data between machines. Currently, data is spread across `~/.config/keydr/config.toml` (config) and `~/.local/share/keydr/*.json` (profile, key stats, drill history). This feature adds Export and Import actions to the Settings page, producing/consuming a single combined JSON file.
## Export Format
Canonical filename: `keydr-export-2026-02-21.json` (date is `Utc::now()`).
```json
{
"keydr_export_version": 1,
"exported_at": "2026-02-21T12:00:00Z",
"config": { ... },
"profile": { ... },
"key_stats": { ... },
"ranked_key_stats": { ... },
"drill_history": { ... }
}
```
- `exported_at` uses `DateTime<Utc>` (chrono, serialized as RFC3339).
- On import, `keydr_export_version` is checked: if it does not equal the current supported version (1), import is rejected with the error `"Unsupported export version: {v} (expected 1)"`. Future versions can add migration functions as needed.
## Import Scope
Import applies **everything except machine-local path fields**:
- **Imported**: target_wpm, theme, keyboard_layout, word_count, code_language, passage_book, download toggle booleans, snippets_per_repo, paragraphs_per_book, onboarding flags, and all progress data (profile, key stats, drill history).
- **Preserved from current config**: `code_download_dir`, `passage_download_dir` (machine-local paths stay as-is).
- Theme and keyboard_layout are imported as-is. If the imported theme is unavailable on the target machine, `Theme::load()` falls back to `terminal-default` and the success message includes a note: `"Imported successfully (theme '{name}' not found, using default)"`.
## Changes
### 1. Add export data struct (`src/store/schema.rs`)
Add an `ExportData` struct with all the fields above, deriving `Serialize`/`Deserialize`. Include `keydr_export_version: u32` and `exported_at: DateTime<Utc>` metadata fields.
### 2. Add export/import methods to `JsonStore` (`src/store/json_store.rs`)
- `export_all(&self, config: &Config) -> Result<ExportData>` — loads all data files and bundles with config into `ExportData`.
- `import_all(&self, data: &ExportData) -> Result<()>`**transactional two-phase write** with best-effort rollback:
1. **Stage phase**: write each data file to a `.tmp` sibling (profile.json.tmp, key_stats.json.tmp, etc.). If any `.tmp` write fails, delete all `.tmp` files created so far and return an error. Originals are untouched.
2. **Commit phase**: for each file, rename the existing original to `.bak`, then rename `.tmp` to final. If any rename fails mid-sequence, **rollback**: restore all `.bak` files back to their original names and clean up remaining `.tmp` files. After successful commit, delete all `.bak` files.
**Contract**: this is best-effort, not strictly atomic. If the process is killed or the disk fails during the commit phase, `.bak` files may be left behind. On next app startup, if `.bak` files are detected in the data directory, show a warning in the status message: `"Recovery files found from interrupted import. Data may be inconsistent — consider re-importing."` and clean up the `.bak` files.
### 3. Add config validation on import (`src/config.rs`)
Add a `Config::validate(&mut self, valid_language_keys: &[&str])` method that:
- Clamps `target_wpm` to 10..=200
- Clamps `word_count` to 5..=100
- Calls `normalize_code_language()` for code language validation
- Falls back to defaults for unrecognized theme names (via `Theme::load()` fallback, already handled)
This is called after merging imported config fields, before saving.
### 4. Add status message enum and app state fields (`src/app.rs`)
Add a structured status type:
```rust
pub enum StatusKind { Success, Error }
pub struct StatusMessage { pub kind: StatusKind, pub text: String }
```
New fields on `App`:
- `pub settings_confirm_import: bool` — controls the import warning dialog
- `pub settings_export_conflict: bool` — controls the export overwrite conflict dialog
- `pub settings_status_message: Option<StatusMessage>` — transient status, cleared on next keypress
- `pub settings_export_path: String` — editable export destination path
- `pub settings_import_path: String` — editable import source path
- `pub settings_editing_export_path: bool` — whether export path is being edited
- `pub settings_editing_import_path: bool` — whether import path is being edited
**Invariant**: at most one modal/edit state is active at a time. When entering any modal (confirm_import, export_conflict) or edit mode, clear all other modal/edit flags first.
Default export path: `dirs::download_dir()` / `keydr-export-{YYYY-MM-DD}.json`.
Default import path: same canonical filename (`dirs::download_dir()` / `keydr-export-{YYYY-MM-DD}.json`), editable.
If `dirs::download_dir()` returns `None`, fall back to `dirs::home_dir()`, then `"."`. On export, if the parent directory of the target path doesn't exist, return an error `"Directory does not exist: {parent}"` rather than silently creating it.
### 5. Add app methods (`src/app.rs`)
- `export_data()` — builds `ExportData` from current state, writes JSON to `settings_export_path` via **atomic write** (write to `.tmp` in same directory, then rename to final path). If file already exists at that path, sets `settings_export_conflict = true` instead of writing. Sets `StatusMessage` on success/error.
- `export_data_overwrite()` — calls the same atomic-write logic without the existence check. The rename atomically replaces the old file; no pre-delete needed.
- `export_data_rename()` — delegates to `next_available_path()`, a free function that implements **conditional suffix normalization**: strips a trailing `-N` suffix only when the base file (without suffix) exists in the same directory. This prevents accidental stripping of intrinsic name components (e.g. date segments like `-01`). Then scans for the lowest unused `-N` suffix. Works for any filename. E.g. if `my-backup.json` and `my-backup-1.json` exist, picks `my-backup-2.json`. If called with `my-backup-1.json` (and `my-backup.json` exists), normalizes to `my-backup` then picks `-2`. Updates `settings_export_path` and writes via atomic write.
- `import_data()` — reads file at `settings_import_path`, validates `keydr_export_version` (reject if != 1 with error message), calls `store.import_all()`, then reloads all in-memory state (config with path fields preserved, profile, key_stats, ranked_key_stats, drill_history, skill_tree). Calls `Config::validate()` and `Config::save()`. Checks if imported theme loaded successfully and appends fallback note to success message if not. Sets `StatusMessage` on success/error.
### 6. Add settings entries (`src/main.rs` — `render_settings`)
Add four new rows at the bottom of the settings field list:
- **"Export Path"** — editable path field, shows `settings_export_path` (same pattern as Code Download Dir)
- **"Export Data"** — action button, label: `"Export now"`
- **"Import Path"** — editable path field, shows `settings_import_path`
- **"Import Data"** — action button, label: `"Import now"`
Update `MAX_SETTINGS` accordingly in `handle_settings_key`.
### 7. Handle key input (`src/main.rs` — `handle_settings_key`)
**Priority order at top of function:**
1. If `settings_status_message.is_some()` — any keypress clears it and returns (message dismissed).
2. If `settings_export_conflict` — handle conflict dialog:
- `'d'``export_data_overwrite()`, clear conflict flag
- `'r'``export_data_rename()`, clear conflict flag
- `Esc` → clear conflict flag
- Return early.
3. If `settings_confirm_import` — handle import confirmation:
- `'y'``import_data()`, clear flag
- `'n'` / `Esc` → clear flag
- Return early.
4. If editing export/import path — handle typing (same pattern as `settings_editing_download_dir`).
For the Enter handler on the new indices:
- Export Path → enter editing mode (clear other edit/modal flags first)
- Export Data → call `export_data()`
- Import Path → enter editing mode (clear other edit/modal flags first)
- Import Data → set `settings_confirm_import = true` (clear other flags first)
Add new indices to the exclusion lists for left/right cycling.
### 8. Render dialogs (`src/main.rs` — `render_settings`)
**Import confirmation dialog** (when `settings_confirm_import` is true):
- Dialog size: ~52x7, centered
- Border title: `" Confirm Import "`, border color: `colors.error()`
- Line 1: `"This will erase your current data."`
- Line 2: `"Export first if you want to keep it."`
- Line 3: `"Proceed? (y/n)"`
**Export conflict dialog** (when `settings_export_conflict` is true):
- Dialog size: ~52x7, centered
- Border title: `" File Exists "`, border color: `colors.error()`
- Line 1: `"A file already exists at this path."`
- Line 2: `"[d] Overwrite [r] Rename [Esc] Cancel"`
**Status message dialog** (when `settings_status_message` is `Some`):
- Small centered dialog showing the message text
- `StatusKind::Success` → accent color border. `StatusKind::Error` → error color border.
- Footer: `"Press any key"`
Dialog rendering priority: status message > export conflict > import confirmation (only one shown at a time).
### 9. Automated tests (`src/store/json_store.rs` or new test module)
Add tests for:
- **Round-trip**: export then import produces identical data
- **Transactional safety (supplemental)**: use a `tempdir`, write valid data, then import into a read-only tempdir and verify original files are unchanged
- **Staged write failure**: `import_all` with a poisoned `ExportData` (e.g. containing data that serializes but whose target path is manipulated to fail) verifies `.tmp` cleanup and original file preservation — this provides deterministic failure coverage without platform-dependent permission tricks
- **Version rejection**: import with `keydr_export_version: 99` returns error containing `"Unsupported export version"`
- **Config validation**: import with out-of-range values (target_wpm=0, word_count=999) gets clamped to valid ranges
- **Smart rename suffix**: create files `stem.json`, `stem-1.json` in a tempdir, verify rename picks `stem-2.json`; also test with custom (non-canonical) filenames
- **Modal invariant**: verify that setting any modal/edit flag clears all others
## Key Files to Modify
| File | Changes |
|------|---------|
| `src/store/schema.rs` | Add `ExportData` struct |
| `src/store/json_store.rs` | Add `export_all()`, transactional `import_all()` with rollback, `.bak` cleanup on startup, tests |
| `src/app.rs` | Add `StatusKind`/`StatusMessage`, state fields, export/import/rename methods, `.bak` check on init |
| `src/main.rs` | Settings UI entries, key handling, 3 dialog types, path editing |
| `src/config.rs` | Add `validate()` method |
## Deferred / Out of Scope
- **Settings enum refactor**: The hard-coded index pattern is pre-existing across the entire settings system. Refactoring to an enum/action map is worthwhile but out of scope for this feature.
- **Splitting config into portable vs machine-local structs**: Handled pragmatically by preserving path fields during import rather than restructuring Config.
- **IO abstraction for injectable writers**: The existing codebase uses direct `fs` calls throughout. Adding a trait-based abstraction for testability is a larger refactor. We use a poisoned-data test and a supplemental read-only tempdir test instead.
## Verification
1. `cargo build` — compiles without errors
2. `cargo test` — all new tests pass (round-trip, staged failure, version rejection, validation, rename suffix, modal invariant)
3. Launch app → Settings → verify Export Path / Export Data / Import Path / Import Data rows appear
4. Edit export path → verify typing/backspace works
5. Export → verify JSON file created at specified path with correct structure
6. Export again same day → verify conflict dialog appears; `d` overwrites atomically, `r` renames to `-1`
7. Export a third time → verify `r` renames to `-2` (smart suffix increment)
8. Export with custom filename → verify rename appends `-1` correctly
9. Import with bad version → verify error: `"Unsupported export version: 99 (expected 1)"`
10. Import → verify warning dialog appears; `n`/`Esc` cancels without changes
11. Import → `y` → verify data loaded, config preferences updated, paths preserved
12. Import with unavailable theme → verify success message includes fallback note
13. Verify only one modal/edit state can be active: e.g. while editing export path, pressing a key that would open import confirm does not open it
14. Round-trip: export, change settings, do a drill, import the export, verify original state restored