- Use csv crate for robust CSV parsing (handles quoted fields, empty values, \r\n)
- Extend --import command to auto-detect format by file extension (.csv or .json)
- Reuse existing ImportPipeline and analyzer for field type detection
- Categories detected automatically (string fields), measures for numeric fields
- Updated help text and welcome screen to mention CSV support
All 201 tests pass.
Builds out two half-finished view features:
Group collapse:
- AxisEntry enum distinguishes GroupHeader from DataItem on grid axes
- expand_category() emits group headers and filters collapsed items
- Grid renders inline group header rows with ▼/▶ indicator
- `z` keybinding toggles collapse of nearest group above cursor
Hide/show item:
- Restore show_item() (was commented out alongside hide_item)
- Add HideItem / ShowItem commands and dispatch
- `H` keybinding hides the current row item
- `:show-item <cat> <item>` command to restore hidden items
- Restore silenced test assertions for hide/show round-trip
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The two tests were previously silenced when item_by_name was removed.
Rewrites them using category.items.get() directly, restoring coverage
of item-name and item-group serialization round-trips.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes unused methods (sum_matching, get_mut, item_by_name, item_index,
top_level_groups, is_group_collapsed, show_item) and unused constants
(LABEL_THRESHOLD, MIN_COL_WIDTH).
The sum_matching tests in model.rs were bypassing the formula evaluator
entirely. Replaced them with equivalent tests that call evaluate() against
the existing Total = SUM(Revenue) formula, exercising the real aggregation
code path.
Also fixes a compile error in view.rs prop_tests where View/Axis imports
and a doc comment were incorrectly commented out.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
musl tooling is Linux-only, so guard it behind an isLinux check in
flake.nix and remove the hardcoded musl build target from .cargo/config.toml
(the nix devShell already sets CARGO_BUILD_TARGET on Linux).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously --import ran ImportJson headless before the TUI started,
hitting the category limit and printing the error to stderr where it
was invisible. Now it parses the JSON and opens the ImportWizard on
startup, matching :import behavior.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removed the post-execution mode reset from the caller. execute_command
now sets mode = Normal at the top as the default; commands that open
a new mode (ImportWizard, Quit) simply override it. The caller no
longer needs a special-case exclusion list.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
execute_command set mode to ImportWizard, but the caller immediately
reset it to Normal for any non-Quit mode. Added ImportWizard to the
exclusion list so the wizard survives the reset.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause: set_axis silently ignores unregistered categories, so a
view section appearing before its categories would produce wrong axis
assignments when on_category_added later ran and assigned defaults.
Fix: collect all raw data in pass 1, then build the model in the
correct dependency order in pass 2 (categories → views → data/formulas).
The file can now list sections in any order.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New format is diffable plain text with categories, items, formulas,
data cells, and views all readable without tooling. Legacy JSON files
(detected by leading '{') still load correctly for backwards compat.
Format overview:
# Model Name
## Category: Type
- Food
- Gas [Essentials]
## Data
Month=Jan, Type=Food = 100
## View: Default (active)
Type: row
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pressing 't' swaps all Row-axis categories to Column and all
Column-axis categories to Row, leaving Page categories unchanged.
Selection and scroll offsets are reset to (0,0).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Column headers now render one row per column category instead of
joining with '/'. Row headers render one sub-column per row category.
Repeat suppression hides labels when the prefix is unchanged from
the previous row/column.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The CSV export used Box<dyn Iterator<Item = Option<usize>>> to unify
empty and non-empty row iteration, violating the CLAUDE.md rule that
Box/Rc container management should be split from logic. Replaced with a
direct for loop over row indices, removing both the Box and the Option
sentinels used to represent "placeholder empty row/col".
Also removes unused pub use cell::CellKey re-export and an unused
import in cell.rs tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both functions previously returned Option despite their invariants
guaranteeing a value: active_view always names an existing view
(maintained by new/switch_view/delete_view), and axis_of only returns
None for categories never registered with the view (a programming error).
Callers no longer need to handle the impossible None case, eliminating
~15 match/if-let Option guards across app.rs, dispatch.rs, grid.rs,
tile_bar.rs, and category_panel.rs.
Also adds Model::evaluate_f64 (returns 0.0 for empty cells) and collapses
the double match-on-axis pattern in tile_bar/category_panel into a single
axis_display(Axis) helper.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously CellValue had three variants: Number, Text, and Empty.
The Empty variant acted as a null sentinel, but the compiler could not
distinguish between "this is a real value" and "this might be empty".
Code that received a CellValue could use it without checking for Empty,
because there was no type-level enforcement.
Now CellValue has only Number and Text. The absence of a value is
represented as None at every API boundary:
DataStore::get() → Option<&CellValue> (was &CellValue / Empty)
Model::get_cell() → Option<&CellValue> (was &CellValue / Empty)
Model::evaluate() → Option<CellValue> (was CellValue::Empty)
eval_formula() → Option<CellValue> (was CellValue::Empty)
Model gains clear_cell() for explicit key removal; ClearCell dispatch
calls it instead of set_cell(key, CellValue::Empty).
The compiler now forces every caller of evaluate/get_cell to handle
the None case explicitly — accidental use of an empty value as if it
were real is caught at compile time rather than silently computing
wrong results.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Axis::Unassigned served two purposes that Option already covers:
1. "this category has no assignment yet" → None
2. "this category doesn't exist" → None
By removing the variant and changing axis_of to return Option<Axis>,
callers are forced by the compiler to handle the absent-category case
explicitly (via match or unwrap_or), rather than silently treating it
like a real axis value.
SetAxis { axis: String } also upgraded to SetAxis { axis: Axis }.
Previously, constructing SetAxis with an invalid string (e.g. "diagonal")
would compile and then silently fail at dispatch. Now the type only admits
valid axis values; the dispatch string-parser is gone.
Axis gains #[serde(rename_all = "lowercase")] so existing JSON command
files (smoke.jsonl, etc.) using "row"/"column"/"page" continue to work.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously Expr::BinOp(String, ...) accepted any string as an operator.
Invalid operators (e.g. "diagonal") would compile fine and silently
return CellValue::Empty at eval time.
Now BinOp is an enum with variants Add/Sub/Mul/Div/Pow/Eq/Ne/Lt/Gt/Le/Ge.
The parser produces enum variants directly; the evaluator pattern-matches
exhaustively with no fallback branch. An invalid operator is now a
compile error at the call site, and the compiler ensures every variant
is handled in both eval_expr and eval_bool.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously `pub formulas: Vec<Formula>` allowed any code to call
`model.formulas.push(formula)` directly, bypassing the dedup logic in
`add_formula` that enforces the (target, target_category) uniqueness
invariant.
Making the field private means the only mutation paths are
`add_formula` and `remove_formula`, both of which maintain the invariant.
A `pub fn formulas(&self) -> &[Formula]` accessor preserves read access
for the UI and tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three copies of cross_product existed (grid.rs, app.rs, persistence/mod.rs)
with slightly different signatures. Extracted into GridLayout in
src/view/layout.rs, which is now the single canonical mapping from a View
to a 2-D grid: row/col counts, labels, and cell_key(row, col) → CellKey.
All consumers updated to use GridLayout::new(model, view):
- grid.rs: render_grid, total-row computation, page bar
- persistence/mod.rs: export_csv
- app.rs: move_selection, jump_to_last_row/col, scroll_rows,
search_navigate, selected_cell_key
Also includes two app.rs UI bug fixes that were discovered while
refactoring:
- Ctrl+Arrow tile movement was unreachable (shadowed by plain arrow arms);
moved before plain arrow handlers
- RemoveFormula dispatch now passes target_category (required by the
formula management fix in the previous commit)
GridLayout has 6 unit tests covering counts, label formatting, cell_key
correctness, out-of-bounds, page coord inclusion, and evaluate round-trip.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three bugs fixed, each with a failing regression test added first:
1. WHERE filter fallthrough: when the filter's category was absent from the
cell key, the formula was applied unconditionally. Now returns the raw
stored value (no formula applied) when the category is missing.
2. Agg inner/filter ignored: SUM(Revenue) was summing ALL cells in the
partial slice rather than constraining to the Revenue item. Now resolves
the inner Ref to its category and pins that coordinate before scanning.
3. Formula dedup by target only: add_formula and remove_formula keyed on
target name alone, so two formulas with the same item name in different
categories would collide. Both now key on (target, target_category).
RemoveFormula command updated to carry target_category.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- app.rs: scroll_rows (Ctrl+D/U) now clamps to the cross-product row
count and follows the viewport, matching move_selection's behaviour.
Previously it could push selected past the last row, causing
selected_cell_key to return None and silently ignoring edits.
- model.rs: add normalize_view_state() which resets row/col offsets to
zero on all views.
- main.rs, dispatch.rs, app.rs: call normalize_view_state() after every
model replacement (initial load, :Load command, wizard import) so
stale offsets from a previous session can't hide the grid.
- app.rs: clamp formula_cursor to the current formula list length at the
top of handle_formula_panel_key so a model reload with fewer formulas
can't leave the cursor pointing past the end.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- model.rs: division by zero now returns Empty instead of 0 so the cell
visually signals the error rather than silently showing a wrong value.
Updated the test to assert Empty.
- parser.rs: missing closing ')' in aggregate functions (SUM, AVG, etc.)
and IF(...) now returns a parse error instead of silently succeeding,
preventing malformed formulas from evaluating with unexpected results.
- dispatch.rs: SetCell validates that every category in the coords
exists before mutating anything; previously a typo in a category name
silently wrote an orphaned cell (stored but never visible in any grid)
and returned ok. Now returns an error immediately.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
[/] previously broke after the first page category due to a hard-coded
`break`. Replaced with odometer-style navigation: ] advances the last
page category, carrying into the previous when it wraps (like digit
incrementing). [ decrements the same way. Single-category behaviour is
unchanged except it now wraps around instead of clamping at the end.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>