Skip to main content

putzen_cli/caches/tui/
update.rs

1//! Pure `update(State, Msg) -> (State, Command<Effect, Msg>)`.
2
3use super::command::Command;
4use super::effect::Effect;
5use super::filter::Filter;
6use super::msg::Msg;
7use super::state::{Loading, Modal, Overlay, RunOutcome, State};
8
9/// Pure state-transition function.  Each arm returns the next `State` and a
10/// `Command` that the runtime drains (synchronous events re-fed through
11/// `update`; effects handed to `EffectRunner`).  This function spawns no
12/// threads and performs no IO; `state.now` is the runtime's clock for any
13/// score/age math.
14///
15/// **One pragmatic compromise:** the three arms that open the spinner modal
16/// (`DrillIn`, `DrillOut` when dirty, `ConfirmDelete`) call
17/// `std::time::Instant::now()` to seed `Loading::started`. That field only
18/// drives the "Ns elapsed" line in the modal — no control flow depends on
19/// it, so the function is observably pure for routing and assertions, but
20/// `Loading::started` itself is non-deterministic across runs.
21pub fn update(mut state: State, msg: Msg) -> (State, Command<Effect, Msg>) {
22    match msg {
23        Msg::MoveUp => {
24            if state.focus_right {
25                state.files_cursor = state.files_cursor.saturating_sub(1);
26            } else if state.cursor > 0 {
27                state.cursor -= 1;
28            }
29            (state, Command::done())
30        }
31        Msg::MoveDown => {
32            if state.focus_right {
33                let len = state
34                    .sorted_indices()
35                    .get(state.cursor)
36                    .and_then(|&i| state.all.get(i))
37                    .map(|c| c.top_files.len())
38                    .unwrap_or(0);
39                if state.files_cursor + 1 < len {
40                    state.files_cursor += 1;
41                }
42            } else {
43                let n = state.sorted_indices().len();
44                if state.cursor + 1 < n {
45                    state.cursor += 1;
46                }
47            }
48            (state, Command::done())
49        }
50        Msg::ToggleMark => {
51            let visible = state.sorted_indices();
52            if let Some(&underlying) = visible.get(state.cursor) {
53                let is_active = state.floor.is_active(state.all[underlying].age(state.now));
54                if is_active && !state.marks.is_marked(underlying) {
55                    state.modal = Modal::ActiveMark(vec![underlying]);
56                } else {
57                    state.marks.toggle(underlying);
58                    if state.cursor + 1 < visible.len() {
59                        state.cursor += 1;
60                    }
61                }
62            }
63            (state, Command::done())
64        }
65        Msg::MarkDownToCursor => {
66            let visible = state.sorted_indices();
67            let take = (state.cursor + 1).min(visible.len());
68            let mut active_in_range = Vec::new();
69            let mut benign = Vec::new();
70            for &underlying in visible.iter().take(take) {
71                if state.marks.is_marked(underlying) {
72                    continue;
73                }
74                if state.floor.is_active(state.all[underlying].age(state.now)) {
75                    active_in_range.push(underlying);
76                } else {
77                    benign.push(underlying);
78                }
79            }
80            for i in benign {
81                state.marks.marked.insert(i);
82            }
83            if !active_in_range.is_empty() {
84                state.modal = Modal::ActiveMark(active_in_range);
85            }
86            (state, Command::done())
87        }
88        Msg::CycleSort => {
89            // Pin the cursor to the underlying cache so a sort change feels
90            // like a re-ordering of the same list, not a jump back to row 0
91            // (which made --root entries look like they had disappeared).
92            //
93            // Exception: when the user is sitting on row 0 they're typically
94            // eyeballing "the worst offender by this metric" — keep them
95            // there across sorts so the cursor follows the ranking head,
96            // not the cache that happened to be the head a sort ago.
97            let was_top = state.cursor == 0;
98            let pinned = state.sorted_indices().get(state.cursor).copied();
99            state.sort = state.sort.next();
100            state.cursor = if was_top {
101                0
102            } else {
103                let visible = state.sorted_indices();
104                pinned
105                    .and_then(|i| visible.iter().position(|&v| v == i))
106                    .unwrap_or(0)
107            };
108            (state, Command::done())
109        }
110        Msg::DrillIn => {
111            // Ignore drill-in while a background scan/refresh/delete is in
112            // flight — overwriting `loading` here would orphan the prior
113            // worker's `ScanCompleted` and double-spawn IO.
114            if state.loading.is_some() {
115                return (state, Command::done());
116            }
117            let visible = state.sorted_indices();
118            let Some(&idx) = visible.get(state.cursor) else {
119                return (state, Command::done());
120            };
121            let parent_label = state.all[idx].label.clone();
122            let parent_path = state.all[idx].path.clone();
123            state.loading = Some(Loading {
124                label: format!("scanning {parent_label}"),
125                frame: 0,
126                started: std::time::Instant::now(),
127                folders: Some(0),
128            });
129            (
130                state,
131                Command::effect(Effect::SpawnScan {
132                    parent_label,
133                    parent_path,
134                }),
135            )
136        }
137        Msg::ScanCompleted {
138            parent_label,
139            parent_path,
140            children,
141        } => {
142            if !children.is_empty() {
143                state.stack_labels.push(parent_label);
144                state.drill_paths.push(parent_path);
145                state.drill_into(children);
146            }
147            state.loading = None;
148            (state, Command::done())
149        }
150        Msg::ScanProgress { folders } => {
151            if let Some(l) = state.loading.as_mut() {
152                l.folders = Some(folders);
153            }
154            (state, Command::done())
155        }
156        Msg::SeedsLoaded { caches } => {
157            // Top-level scan finished.  We are always at the root level when
158            // this arrives (it's only fired once at startup), so replace
159            // `state.all` directly rather than going through drill_into.
160            state.all = caches;
161            state.cursor = 0;
162            state.loading = None;
163            (state, Command::done())
164        }
165        Msg::DrillOut => {
166            // Mirror DrillIn's guard. Drilling out while a delete/scan/refresh
167            // is in flight would swap `state.all` for the parent level, and the
168            // in-flight worker's `DeleteCompleted`/`RefreshCompleted` would
169            // then index into the wrong list.
170            if state.loading.is_some() {
171                return (state, Command::done());
172            }
173            let was_dirty = state.level_dirty;
174            let popped_path = state.drill_out_with_path();
175            if was_dirty {
176                if let Some(path) = popped_path {
177                    let path_label = path
178                        .file_name()
179                        .map(|s| s.to_string_lossy().to_string())
180                        .unwrap_or_else(|| path.display().to_string());
181                    state.loading = Some(Loading {
182                        label: format!("refreshing {path_label}"),
183                        frame: 0,
184                        started: std::time::Instant::now(),
185                        folders: None,
186                    });
187                    // Propagate the dirty signal up the stack: the size we're
188                    // about to refresh is a child of the level we just
189                    // exposed, so any level above it is now stale too.  Set
190                    // before returning so the next DrillOut sees it.
191                    state.level_dirty = true;
192                    return (state, Command::effect(Effect::SpawnRefresh { path }));
193                }
194            }
195            (state, Command::done())
196        }
197        Msg::RefreshCompleted { path, cache } => {
198            if let Some(slot) = state.all.iter_mut().find(|c| c.path == path) {
199                *slot = cache;
200            }
201            state.loading = None;
202            (state, Command::done())
203        }
204        Msg::ToggleFocus => {
205            state.focus_right = !state.focus_right;
206            state.files_cursor = 0;
207            (state, Command::done())
208        }
209        Msg::RequestQuit => {
210            state.quit = true;
211            (state, Command::done())
212        }
213        Msg::DeletePressed => {
214            if state.marks.count() == 0 {
215                return (state, Command::done());
216            }
217            state.modal = Modal::DeleteConfirm;
218            if state.yes_mode {
219                (state, Command::event(Msg::ConfirmDelete))
220            } else {
221                (state, Command::done())
222            }
223        }
224        Msg::CancelDelete => {
225            state.modal = Modal::None;
226            (state, Command::done())
227        }
228        Msg::ConfirmDelete => {
229            let to_delete: Vec<(usize, std::path::PathBuf, u64)> = state
230                .marks
231                .marked
232                .iter()
233                .filter_map(|&i| state.all.get(i).map(|c| (i, c.path.clone(), c.size_bytes)))
234                .collect();
235            state.modal = Modal::None;
236            state.marks.clear();
237            if to_delete.is_empty() {
238                return (state, Command::done());
239            }
240            let count = to_delete.len();
241            state.loading = Some(Loading {
242                label: format!(
243                    "deleting {count} {}",
244                    crate::caches::format::pluralize(count as u64, "folder", "folders")
245                ),
246                frame: 0,
247                started: std::time::Instant::now(),
248                folders: None,
249            });
250            let dry_run = state.dry_run;
251            (
252                state,
253                Command::effect(Effect::SpawnDelete {
254                    items: to_delete,
255                    dry_run,
256                }),
257            )
258        }
259        Msg::DeleteCompleted {
260            freed,
261            deleted_count,
262            failed_count,
263            deleted_indices,
264        } => {
265            state.total_freed += freed;
266            if !state.dry_run && deleted_count > 0 {
267                state.level_dirty = true;
268            }
269            if !state.dry_run {
270                let mut idxs = deleted_indices;
271                idxs.sort_unstable_by(|a, b| b.cmp(a));
272                for i in idxs {
273                    if i < state.all.len() {
274                        state.all.remove(i);
275                    }
276                }
277                // Clamp against the visible (filtered) set — `cursor` indexes
278                // `sorted_indices()`, not `state.all` directly.
279                state.clamp_cursor_to_visible();
280            }
281            state.loading = None;
282            state.overlay = Some(Overlay {
283                outcome: RunOutcome {
284                    freed,
285                    deleted: deleted_count,
286                    failed: failed_count,
287                    dry_run: state.dry_run,
288                },
289            });
290            (
291                state,
292                Command::effect(Effect::EmitAfter {
293                    dur: std::time::Duration::from_secs(2),
294                    msg: Msg::OverlayDismiss,
295                }),
296            )
297        }
298        Msg::ConfirmActiveMark => {
299            if let Modal::ActiveMark(indices) = std::mem::replace(&mut state.modal, Modal::None) {
300                for i in indices {
301                    state.marks.marked.insert(i);
302                }
303                let visible_len = state.sorted_indices().len();
304                if state.cursor + 1 < visible_len {
305                    state.cursor += 1;
306                }
307            }
308            (state, Command::done())
309        }
310        Msg::CancelActiveMark => {
311            state.modal = Modal::None;
312            (state, Command::done())
313        }
314        Msg::FilterStart => {
315            if state.filter.is_none() {
316                state.filter = Some(Filter::default());
317            }
318            state.modal = Modal::FilterEdit;
319            (state, Command::done())
320        }
321        Msg::FilterChar(c) => {
322            if matches!(state.modal, Modal::FilterEdit) {
323                if let Some(f) = state.filter.as_mut() {
324                    f.input.push(c);
325                }
326            }
327            state.clamp_cursor_to_visible();
328            (state, Command::done())
329        }
330        Msg::FilterBackspace => {
331            if matches!(state.modal, Modal::FilterEdit) {
332                if let Some(f) = state.filter.as_mut() {
333                    f.input.pop();
334                }
335            }
336            state.clamp_cursor_to_visible();
337            (state, Command::done())
338        }
339        Msg::FilterApply => {
340            state.modal = Modal::None;
341            if let Some(f) = state.filter.as_ref() {
342                if f.input.is_empty() {
343                    state.filter = None;
344                }
345            }
346            state.clamp_cursor_to_visible();
347            (state, Command::done())
348        }
349        Msg::FilterCancel => {
350            state.filter = None;
351            state.modal = Modal::None;
352            state.clamp_cursor_to_visible();
353            (state, Command::done())
354        }
355        Msg::MarkAllVisible => {
356            let visible = state.sorted_indices();
357            let mut active_in_range = Vec::new();
358            for &underlying in &visible {
359                if state.marks.is_marked(underlying) {
360                    continue;
361                }
362                if state.floor.is_active(state.all[underlying].age(state.now)) {
363                    active_in_range.push(underlying);
364                } else {
365                    state.marks.marked.insert(underlying);
366                }
367            }
368            if !active_in_range.is_empty() {
369                state.modal = Modal::ActiveMark(active_in_range);
370            }
371            (state, Command::done())
372        }
373        Msg::Tick => {
374            if let Some(l) = state.loading.as_mut() {
375                l.update_frame();
376            }
377            (state, Command::done())
378        }
379        Msg::OverlayDismiss => {
380            state.overlay = None;
381            (state, Command::done())
382        }
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use crate::caches::model::*;
390    use std::path::PathBuf;
391    use std::time::{Duration, SystemTime};
392
393    fn cache(label: &str, size: u64, mtime_secs: u64) -> Cache {
394        Cache {
395            label: label.into(),
396            path: PathBuf::from(format!("/x/{label}")),
397            size_bytes: size,
398            newest_mtime: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(mtime_secs)),
399            file_count: 1,
400            dir_count: 0,
401            top_files: Vec::new(),
402            unreadable: 0,
403        }
404    }
405
406    fn state(items: Vec<Cache>) -> State {
407        State {
408            now: SystemTime::UNIX_EPOCH + Duration::from_secs(10_000_000),
409            all: items,
410            sort: Sort::Score,
411            marks: MarkSet::default(),
412            cursor: 0,
413            files_cursor: 0,
414            floor: FloorPolicy {
415                floor: Duration::from_secs(7 * 86_400),
416            },
417            focus_right: false,
418            stack: Vec::new(),
419            stack_labels: Vec::new(),
420            quit: false,
421            dry_run: false,
422            yes_mode: false,
423            total_freed: 0,
424            modal: Modal::None,
425            filter: None,
426            loading: None,
427            overlay: None,
428            level_dirty: false,
429            drill_paths: Vec::new(),
430            cursor_stack: Vec::new(),
431        }
432    }
433
434    #[test]
435    fn move_up_decrements_and_floors_at_zero() {
436        let mut s = state(vec![cache("a", 1, 0), cache("b", 1, 0), cache("c", 1, 0)]);
437        s.cursor = 2;
438        let (s, c) = update(s, Msg::MoveUp);
439        assert!(c.is_done());
440        assert_eq!(s.cursor, 1);
441        let (s, _) = update(s, Msg::MoveUp);
442        assert_eq!(s.cursor, 0);
443        let (s, _) = update(s, Msg::MoveUp);
444        assert_eq!(s.cursor, 0, "cursor must not underflow");
445    }
446
447    #[test]
448    fn mark_down_to_cursor_marks_benign_range() {
449        // All caches very old (mtime 0, now=10M) → benign per default floor.
450        let mut s = state(vec![cache("a", 1, 0), cache("b", 1, 0), cache("c", 1, 0)]);
451        s.cursor = 1;
452        let (s, c) = update(s, Msg::MarkDownToCursor);
453        assert!(c.is_done());
454        assert!(s.marks.is_marked(0));
455        assert!(s.marks.is_marked(1));
456        assert!(!s.marks.is_marked(2));
457        assert!(matches!(s.modal, Modal::None));
458    }
459
460    #[test]
461    fn mark_down_to_cursor_defers_active_rows_to_modal() {
462        // Mix of active (recent) and benign (old) rows.
463        let mut s = state(vec![
464            cache("recent", 1, NOW_SECS - 86_400), // active
465            cache("old", 1, 0),                    // benign
466        ]);
467        s.cursor = 1;
468        let (s, c) = update(s, Msg::MarkDownToCursor);
469        assert!(c.is_done());
470        assert!(s.marks.is_marked(1), "benign row marked immediately");
471        assert!(!s.marks.is_marked(0), "active row deferred to modal");
472        assert!(matches!(s.modal, Modal::ActiveMark(_)));
473    }
474
475    #[test]
476    fn filter_backspace_pops_last_char_while_editing() {
477        let s = state(vec![cache("a", 1, 0)]);
478        let (s, _) = update(s, Msg::FilterStart);
479        let (s, _) = update(s, Msg::FilterChar('y'));
480        let (s, _) = update(s, Msg::FilterChar('a'));
481        let (s, _) = update(s, Msg::FilterChar('r'));
482        let (s, _) = update(s, Msg::FilterBackspace);
483        let (s, c) = update(s, Msg::FilterBackspace);
484        assert!(c.is_done());
485        assert_eq!(s.filter.as_ref().unwrap().input, "y");
486    }
487
488    #[test]
489    fn filter_backspace_is_noop_when_not_in_edit_mode() {
490        // FilterApply with input drops the filter from edit mode but keeps it
491        // applied.  A backspace in `Modal::None` must NOT mutate input.
492        let s = state(vec![cache("a", 1, 0)]);
493        let (s, _) = update(s, Msg::FilterStart);
494        let (s, _) = update(s, Msg::FilterChar('a'));
495        let (s, _) = update(s, Msg::FilterApply);
496        assert!(matches!(s.modal, Modal::None));
497        let (s, _) = update(s, Msg::FilterBackspace);
498        assert_eq!(s.filter.as_ref().unwrap().input, "a", "guarded by modal");
499    }
500
501    #[test]
502    fn move_down_advances_until_last() {
503        let s = state(vec![cache("a", 1, 0), cache("b", 1, 0)]);
504        let (s, c) = update(s, Msg::MoveDown);
505        assert!(c.is_done());
506        assert_eq!(s.cursor, 1);
507        let (s, c) = update(s, Msg::MoveDown);
508        assert!(c.is_done());
509        assert_eq!(s.cursor, 1);
510    }
511
512    #[test]
513    fn move_down_respects_active_filter_bound() {
514        // 3 rows; filter to only "yarn" — `sorted_indices` shrinks to one row.
515        // `MoveDown` must not advance the cursor past the visible set.
516        let s = state(vec![
517            cache("npm", 1, 0),
518            cache("yarn", 1, 0),
519            cache("bun", 1, 0),
520        ]);
521        let (s, _) = update(s, Msg::FilterStart);
522        let (s, _) = update(s, Msg::FilterChar('y'));
523        let (s, _) = update(s, Msg::FilterChar('a'));
524        let (s, _) = update(s, Msg::FilterApply);
525        assert_eq!(s.cursor, 0);
526        let (s, c) = update(s, Msg::MoveDown);
527        assert!(c.is_done());
528        assert_eq!(s.cursor, 0, "cursor must stay inside the visible set");
529    }
530
531    #[test]
532    fn cycle_sort_pins_cursor_to_underlying_cache() {
533        // Two caches with different scores so Score-sort and Size-sort
534        // produce different row orders.  Cursor is on "small" in the
535        // Score view; after CycleSort the cursor must still point at
536        // "small" (now at a different visible row), not snap to row 0.
537        let s = state(vec![
538            cache("small", 1024, 9_000_000),
539            cache("huge", 1_000_000_000, 9_999_990),
540        ]);
541        // Score sort (default): huge wins → visible = [huge, small].
542        let mut s = s;
543        s.cursor = 1; // pointing at "small"
544        let underlying_small = s.sorted_indices()[1];
545        let (s, c) = update(s, Msg::CycleSort);
546        assert!(c.is_done());
547        assert_eq!(s.sort, Sort::Size);
548        // Size sort: huge still wins → visible = [huge, small].
549        let visible = s.sorted_indices();
550        assert_eq!(
551            visible[s.cursor], underlying_small,
552            "cursor must still point at 'small' after sort change"
553        );
554    }
555
556    #[test]
557    fn cycle_sort_keeps_top_row_pinned() {
558        // Cursor on row 0 should stay on row 0 across sort cycles —
559        // following the ranking head, not the cache that happened to lead
560        // under the previous sort.  Tuned so Score and Size genuinely pick
561        // different leaders:
562        //   A: 10 MiB,  10 days old → score ≈ 100
563        //   B: 100 MiB, 0.5 days old → score ≈ 50, but Size leader.
564        let mut s = state(vec![
565            cache("old_smaller", 10 * 1_048_576, 10_000_000 - 864_000),
566            cache("recent_huge", 100 * 1_048_576, 10_000_000 - 43_200),
567        ]);
568        s.cursor = 0;
569        let head_under_score = s.sorted_indices()[0];
570        let (s, _) = update(s, Msg::CycleSort); // Score → Size
571        let head_under_size = s.sorted_indices()[0];
572        assert_ne!(
573            head_under_score, head_under_size,
574            "fixture must make the two metrics disagree, else the test is vacuous"
575        );
576        assert_eq!(s.cursor, 0, "row 0 must stay row 0 across sort cycles");
577    }
578
579    #[test]
580    fn cycle_sort_resets_cursor_when_pin_unreachable() {
581        // Sanity: when the previous cursor was out of bounds, fall back to 0.
582        let mut s = state(vec![cache("a", 1, 0), cache("b", 1, 0)]);
583        s.cursor = 7;
584        let (s, _) = update(s, Msg::CycleSort);
585        assert_eq!(s.cursor, 0);
586    }
587
588    #[test]
589    fn quit_without_marks_quits_immediately() {
590        let s = state(vec![cache("a", 1, 0)]);
591        let (s, c) = update(s, Msg::RequestQuit);
592        assert!(c.is_done());
593        assert!(s.quit);
594        assert!(matches!(s.modal, Modal::None));
595    }
596
597    #[test]
598    fn quit_with_marks_also_quits_immediately() {
599        let mut s = state(vec![cache("a", 1, 0)]);
600        s.marks.toggle(0);
601        let (s, c) = update(s, Msg::RequestQuit);
602        assert!(c.is_done());
603        assert!(s.quit);
604        assert!(matches!(s.modal, Modal::None));
605    }
606
607    #[test]
608    fn sorted_indices_score_descending() {
609        let s = state(vec![
610            cache("small", 1024, 9_000_000),
611            cache("huge", 1_000_000_000, 9_999_990),
612        ]);
613        let idx = s.sorted_indices();
614        // size_MB * cold_days: huge wins by a wide margin
615        assert_eq!(s.all[idx[0]].label, "huge");
616    }
617
618    #[test]
619    fn drill_in_replaces_list_pushes_stack() {
620        let mut s = state(vec![cache("npm", 100, 0)]);
621        s.cursor = 0;
622        let children = vec![cache("registry", 10, 0), cache("cache", 5, 0)];
623        s.drill_into(children);
624        assert_eq!(s.all.len(), 2);
625        assert_eq!(s.stack.len(), 1);
626        assert_eq!(s.cursor, 0);
627    }
628
629    #[test]
630    fn drill_out_restores_parent() {
631        let mut s = state(vec![cache("npm", 100, 0)]);
632        s.drill_into(vec![cache("registry", 10, 0)]);
633        s.drill_out();
634        assert_eq!(s.all.len(), 1);
635        assert_eq!(s.stack.len(), 0);
636        assert_eq!(s.all[0].label, "npm");
637    }
638
639    #[test]
640    fn drill_out_at_top_is_noop() {
641        let mut s = state(vec![cache("npm", 100, 0)]);
642        s.drill_out();
643        assert_eq!(s.all.len(), 1);
644        assert_eq!(s.stack.len(), 0);
645    }
646
647    #[test]
648    fn delete_pressed_opens_modal_when_marks_present() {
649        let mut s = state(vec![cache("a", 1, 0)]);
650        s.marks.toggle(0);
651        let (s, c) = update(s, Msg::DeletePressed);
652        assert!(c.is_done());
653        assert!(matches!(s.modal, Modal::DeleteConfirm));
654    }
655
656    #[test]
657    fn delete_pressed_noop_when_no_marks() {
658        let s = state(vec![cache("a", 1, 0)]);
659        let (s, c) = update(s, Msg::DeletePressed);
660        assert!(c.is_done());
661        assert!(matches!(s.modal, Modal::None));
662    }
663
664    #[test]
665    fn cancel_delete_closes_modal() {
666        let mut s = state(vec![cache("a", 1, 0)]);
667        s.marks.toggle(0);
668        let (s, c) = update(s, Msg::DeletePressed);
669        assert!(c.is_done());
670        let (s, c) = update(s, Msg::CancelDelete);
671        assert!(c.is_done());
672        assert!(matches!(s.modal, Modal::None));
673    }
674
675    #[test]
676    fn confirm_delete_with_no_marks_returns_done() {
677        let s = state(vec![cache("a", 1, 0)]);
678        let (s, cmd) = update(s, Msg::ConfirmDelete);
679        assert!(s.loading.is_none());
680        assert!(cmd.is_done());
681        assert!(matches!(s.modal, Modal::None));
682    }
683
684    #[test]
685    fn confirm_delete_with_marks_emits_spawn_delete_and_sets_loading() {
686        let mut s = state(vec![cache("a", 1, 0)]);
687        s.marks.marked.insert(0);
688        let (s, cmd) = update(s, Msg::ConfirmDelete);
689        assert!(s.loading.is_some());
690        assert_eq!(s.marks.count(), 0, "marks cleared on confirm");
691        assert!(matches!(
692            cmd.effects.as_slice(),
693            [Effect::SpawnDelete { .. }]
694        ));
695    }
696
697    #[test]
698    fn delete_completed_real_run_removes_rows_and_accumulates_freed() {
699        let mut s = state(vec![cache("a", 1, 0), cache("b", 2, 0), cache("c", 3, 0)]);
700        s.dry_run = false;
701        s.cursor = 2;
702        let (s, cmd) = update(
703            s,
704            Msg::DeleteCompleted {
705                freed: 5,
706                deleted_count: 2,
707                failed_count: 0,
708                deleted_indices: vec![0, 2],
709            },
710        );
711        assert_eq!(s.all.len(), 1);
712        assert_eq!(s.all[0].label, "b");
713        assert_eq!(
714            s.cursor, 0,
715            "cursor parked at min(deleted_indices), clamped to survivor"
716        );
717        assert_eq!(s.total_freed, 5);
718        assert!(s.level_dirty);
719        assert!(s.overlay.is_some());
720        assert!(matches!(
721            cmd.effects.as_slice(),
722            [Effect::EmitAfter {
723                msg: Msg::OverlayDismiss,
724                ..
725            }]
726        ));
727    }
728
729    #[test]
730    fn delete_completed_dry_run_keeps_rows_intact() {
731        let mut s = state(vec![cache("a", 1, 0), cache("b", 2, 0)]);
732        s.dry_run = true;
733        let (s, cmd) = update(
734            s,
735            Msg::DeleteCompleted {
736                freed: 3,
737                deleted_count: 2,
738                failed_count: 0,
739                deleted_indices: vec![0, 1],
740            },
741        );
742        assert_eq!(s.all.len(), 2, "dry-run leaves rows in view");
743        assert_eq!(s.total_freed, 3);
744        assert!(!s.level_dirty);
745        assert!(s.overlay.is_some());
746        assert!(matches!(
747            cmd.effects.as_slice(),
748            [Effect::EmitAfter {
749                msg: Msg::OverlayDismiss,
750                ..
751            }]
752        ));
753    }
754
755    #[test]
756    fn delete_completed_clamps_cursor_against_visible_under_filter() {
757        // 3 rows; filter to only "yarn" — visible set has 1 row, cursor=0.
758        // Delete that row from `state.all` (real run). After removal the
759        // visible set is empty; cursor must clamp to 0, not point past it.
760        let s = state(vec![
761            cache("npm", 1, 0),
762            cache("yarn", 1, 0),
763            cache("bun", 1, 0),
764        ]);
765        let (s, _) = update(s, Msg::FilterStart);
766        let (s, _) = update(s, Msg::FilterChar('y'));
767        let (s, _) = update(s, Msg::FilterChar('a'));
768        let (mut s, _) = update(s, Msg::FilterApply);
769        s.dry_run = false;
770        let (s, _) = update(
771            s,
772            Msg::DeleteCompleted {
773                freed: 1,
774                deleted_count: 1,
775                failed_count: 0,
776                deleted_indices: vec![1],
777            },
778        );
779        assert_eq!(s.all.len(), 2);
780        assert_eq!(s.sorted_indices().len(), 0, "filter still matches nothing");
781        assert_eq!(s.cursor, 0, "cursor clamped against visible bound");
782    }
783
784    #[test]
785    fn delete_completed_carries_failed_count_to_overlay() {
786        let mut s = state(vec![cache("a", 1, 0), cache("b", 1, 0)]);
787        s.dry_run = false;
788        let (s, _) = update(
789            s,
790            Msg::DeleteCompleted {
791                freed: 1,
792                deleted_count: 1,
793                failed_count: 1,
794                deleted_indices: vec![0],
795            },
796        );
797        let outcome = &s.overlay.as_ref().unwrap().outcome;
798        assert_eq!(outcome.failed, 1);
799        assert_eq!(outcome.deleted, 1);
800    }
801
802    #[test]
803    fn delete_completed_sets_overlay_and_emits_dismiss_after_2s() {
804        let mut s = state(vec![cache("a", 1, 0)]);
805        s.dry_run = false;
806        let (s, cmd) = update(
807            s,
808            Msg::DeleteCompleted {
809                freed: 100,
810                deleted_count: 1,
811                failed_count: 0,
812                deleted_indices: vec![0],
813            },
814        );
815        assert!(s.overlay.is_some());
816        assert_eq!(s.overlay.as_ref().unwrap().outcome.freed, 100);
817        assert!(matches!(
818            cmd.effects.as_slice(),
819            [Effect::EmitAfter { dur, msg: Msg::OverlayDismiss }] if *dur == std::time::Duration::from_secs(2)
820        ));
821    }
822
823    #[test]
824    fn overlay_dismiss_clears_overlay() {
825        let mut s = state(vec![cache("a", 1, 0)]);
826        s.overlay = Some(Overlay {
827            outcome: RunOutcome {
828                freed: 1,
829                deleted: 1,
830                failed: 0,
831                dry_run: false,
832            },
833        });
834        let (s, cmd) = update(s, Msg::OverlayDismiss);
835        assert!(s.overlay.is_none());
836        assert!(cmd.is_done());
837    }
838
839    #[test]
840    fn mark_survives_sort_change() {
841        // Two caches; their relative score order differs from name order.
842        // size_MB * cold_days:
843        //   "huge"  = 1000MB * 0.0001d ≈ 0.1
844        //   "small" = 0.001MB * 11.57d ≈ 0.01
845        // Sorted by Score (default): huge, small.
846        // Sorted by Size: huge, small (same).
847        // Sorted by Age: small, huge (small is older).
848        let s = state(vec![
849            cache("small", 1024, 9_000_000),
850            cache("huge", 1_000_000_000, 9_999_990),
851        ]);
852        // Cursor on row 0 = "huge" in Score sort.
853        let idx_huge_before = s.sorted_indices()[0];
854        let (s, c) = update(s, Msg::ToggleMark);
855        assert!(c.is_done());
856        // "huge" is ACTIVE (mtime 10s before NOW), so a confirm modal opens.
857        let s = if matches!(s.modal, Modal::ActiveMark(_)) {
858            let (s, c) = update(s, Msg::ConfirmActiveMark);
859            assert!(c.is_done());
860            s
861        } else {
862            s
863        };
864        assert!(
865            s.marks.is_marked(idx_huge_before),
866            "after toggling cursor on huge, marks must store huge's underlying index"
867        );
868
869        // Switch to Age sort — huge moves to row 1, but should remain marked.
870        let (s, c) = update(s, Msg::CycleSort); // Score -> Size
871        assert!(c.is_done());
872        let (s, c) = update(s, Msg::CycleSort); // Size  -> Age
873        assert!(c.is_done());
874        let visible = s.sorted_indices();
875        let row_of_huge = visible
876            .iter()
877            .position(|&i| s.all[i].label == "huge")
878            .unwrap();
879        assert!(
880            s.marks.is_marked(visible[row_of_huge]),
881            "after sort change, the SAME underlying cache should still be marked"
882        );
883    }
884
885    #[test]
886    fn drill_out_msg_pops_stack() {
887        let mut s = state(vec![cache("parent", 100, 0)]);
888        s.drill_into(vec![cache("child", 10, 0)]);
889        assert_eq!(s.all[0].label, "child");
890
891        let (s, c) = update(s, Msg::DrillOut);
892        assert!(c.is_done());
893        assert_eq!(s.all.len(), 1);
894        assert_eq!(s.all[0].label, "parent");
895    }
896
897    #[test]
898    fn drill_in_via_scan_enumerates_children() {
899        use std::fs;
900        let tmp = tempfile::tempdir().unwrap();
901        let cache = tmp.path().join("npm");
902        fs::create_dir_all(cache.join("a")).unwrap();
903        fs::create_dir_all(cache.join("b")).unwrap();
904
905        // Build a State whose cursor points at the cache, then simulate drill-in.
906        let mut s = state(vec![Cache {
907            label: "npm".into(),
908            path: cache.clone(),
909            size_bytes: 0,
910            newest_mtime: None,
911            file_count: 0,
912            dir_count: 0,
913            top_files: Vec::new(),
914            unreadable: 0,
915        }]);
916        let children = crate::caches::scan::enumerate_seed(&cache);
917        s.drill_into(children);
918        assert_eq!(s.all.len(), 2);
919        assert!(s.stack.len() == 1);
920    }
921
922    #[test]
923    fn empty_caches_sort_last_under_age() {
924        let mut s = state(vec![
925            cache("populated", 1024, 0), // very old
926            cache("empty", 0, 0),        // we'll null its mtime below
927        ]);
928        s.all[1].newest_mtime = None;
929        s.sort = Sort::Age;
930        let idx = s.sorted_indices();
931        assert_eq!(
932            s.all[idx.last().copied().unwrap()].label,
933            "empty",
934            "empty caches must land at the bottom under Age sort"
935        );
936    }
937
938    const NOW_SECS: u64 = 10_000_000;
939
940    #[test]
941    fn marking_active_row_opens_active_confirm() {
942        let s = state(vec![cache("recent", 1_000_000, NOW_SECS - 86_400)]);
943        let (s, c) = update(s, Msg::ToggleMark);
944        assert!(c.is_done());
945        assert!(matches!(s.modal, Modal::ActiveMark(_)));
946        assert_eq!(s.marks.count(), 0);
947    }
948
949    #[test]
950    fn confirm_active_mark_inserts_and_closes() {
951        let s = state(vec![cache("recent", 1_000_000, NOW_SECS - 86_400)]);
952        let (s, c) = update(s, Msg::ToggleMark);
953        assert!(c.is_done());
954        let (s, c) = update(s, Msg::ConfirmActiveMark);
955        assert!(c.is_done());
956        assert_eq!(s.marks.count(), 1);
957        assert!(matches!(s.modal, Modal::None));
958    }
959
960    #[test]
961    fn cancel_active_mark_closes_without_inserting() {
962        let s = state(vec![cache("recent", 1_000_000, NOW_SECS - 86_400)]);
963        let (s, c) = update(s, Msg::ToggleMark);
964        assert!(c.is_done());
965        let (s, c) = update(s, Msg::CancelActiveMark);
966        assert!(c.is_done());
967        assert_eq!(s.marks.count(), 0);
968        assert!(matches!(s.modal, Modal::None));
969    }
970
971    #[test]
972    fn filter_start_creates_editing_filter() {
973        let s = state(vec![cache("a", 1, 0)]);
974        let (s, c) = update(s, Msg::FilterStart);
975        assert!(c.is_done());
976        let f = s.filter.as_ref().unwrap();
977        assert!(matches!(s.modal, Modal::FilterEdit));
978        assert_eq!(f.input, "");
979    }
980
981    #[test]
982    fn filter_chars_accumulate() {
983        let s = state(vec![cache("a", 1, 0)]);
984        let (s, c) = update(s, Msg::FilterStart);
985        assert!(c.is_done());
986        let (s, c) = update(s, Msg::FilterChar('n'));
987        assert!(c.is_done());
988        let (s, c) = update(s, Msg::FilterChar('p'));
989        assert!(c.is_done());
990        let (s, c) = update(s, Msg::FilterChar('m'));
991        assert!(c.is_done());
992        assert_eq!(s.filter.as_ref().unwrap().input, "npm");
993    }
994
995    #[test]
996    fn filter_apply_closes_editing() {
997        let s = state(vec![cache("a", 1, 0)]);
998        let (s, c) = update(s, Msg::FilterStart);
999        assert!(c.is_done());
1000        let (s, c) = update(s, Msg::FilterChar('a'));
1001        assert!(c.is_done());
1002        let (s, c) = update(s, Msg::FilterApply);
1003        assert!(c.is_done());
1004        assert!(matches!(s.modal, Modal::None));
1005        let f = s.filter.as_ref().unwrap();
1006        assert_eq!(f.input, "a");
1007    }
1008
1009    #[test]
1010    fn filter_cancel_drops_filter() {
1011        let s = state(vec![cache("a", 1, 0)]);
1012        let (s, c) = update(s, Msg::FilterStart);
1013        assert!(c.is_done());
1014        let (s, c) = update(s, Msg::FilterChar('a'));
1015        assert!(c.is_done());
1016        let (s, c) = update(s, Msg::FilterCancel);
1017        assert!(c.is_done());
1018        assert!(s.filter.is_none());
1019    }
1020
1021    #[test]
1022    fn empty_filter_apply_drops_filter() {
1023        let s = state(vec![cache("a", 1, 0)]);
1024        let (s, c) = update(s, Msg::FilterStart);
1025        assert!(c.is_done());
1026        let (s, c) = update(s, Msg::FilterApply);
1027        assert!(c.is_done());
1028        assert!(s.filter.is_none());
1029    }
1030
1031    #[test]
1032    fn filter_hides_non_matching_rows() {
1033        let s = state(vec![
1034            cache("npm", 1, 0),
1035            cache("yarn", 1, 0),
1036            cache("bun", 1, 0),
1037        ]);
1038        let (s, c) = update(s, Msg::FilterStart);
1039        assert!(c.is_done());
1040        let (s, c) = update(s, Msg::FilterChar('y'));
1041        assert!(c.is_done());
1042        let visible = s.sorted_indices();
1043        let labels: Vec<&str> = visible.iter().map(|&i| s.all[i].label.as_str()).collect();
1044        assert_eq!(labels, ["yarn"]);
1045    }
1046
1047    #[test]
1048    fn mark_all_visible_marks_filtered_rows() {
1049        let s = state(vec![
1050            cache("npm", 1, 0),
1051            cache("yarn", 1, 0),
1052            cache("bun", 1, 0),
1053        ]);
1054        // Filter for "rn" — only "yarn" contains it.
1055        let (s, c) = update(s, Msg::FilterStart);
1056        assert!(c.is_done());
1057        let (s, c) = update(s, Msg::FilterChar('r'));
1058        assert!(c.is_done());
1059        let (s, c) = update(s, Msg::FilterChar('n'));
1060        assert!(c.is_done());
1061        let (s, c) = update(s, Msg::FilterApply);
1062        assert!(c.is_done());
1063        let (s, c) = update(s, Msg::MarkAllVisible);
1064        assert!(c.is_done());
1065        assert_eq!(s.marks.count(), 1);
1066        // Clear filter (Cancel drops the whole Filter struct); now MarkAllVisible
1067        // covers every row. yarn is already marked, so two new marks land.
1068        let (s, c) = update(s, Msg::FilterCancel);
1069        assert!(c.is_done());
1070        let (s, c) = update(s, Msg::MarkAllVisible);
1071        assert!(c.is_done());
1072        assert_eq!(s.marks.count(), 3);
1073    }
1074
1075    #[test]
1076    fn scrolling_right_pane_advances_files_selection() {
1077        let s = state(vec![Cache {
1078            label: "x".into(),
1079            path: PathBuf::from("/x"),
1080            size_bytes: 0,
1081            newest_mtime: None,
1082            file_count: 0,
1083            dir_count: 0,
1084            top_files: vec![
1085                TopFile {
1086                    name: "a".into(),
1087                    size_bytes: 1,
1088                    mtime: None,
1089                },
1090                TopFile {
1091                    name: "b".into(),
1092                    size_bytes: 1,
1093                    mtime: None,
1094                },
1095                TopFile {
1096                    name: "c".into(),
1097                    size_bytes: 1,
1098                    mtime: None,
1099                },
1100            ],
1101            unreadable: 0,
1102        }]);
1103        let (s, c) = update(s, Msg::ToggleFocus);
1104        assert!(c.is_done());
1105        assert!(s.focus_right);
1106        assert_eq!(s.files_cursor, 0);
1107        let (s, c) = update(s, Msg::MoveDown);
1108        assert!(c.is_done());
1109        assert_eq!(s.files_cursor, 1);
1110        let (s, c) = update(s, Msg::MoveDown);
1111        assert!(c.is_done());
1112        assert_eq!(s.files_cursor, 2);
1113        let (s, c) = update(s, Msg::MoveDown);
1114        assert!(c.is_done());
1115        assert_eq!(s.files_cursor, 2);
1116    }
1117
1118    #[test]
1119    fn toggle_focus_resets_scroll() {
1120        let mut s = state(vec![cache("x", 1, 0)]);
1121        s.focus_right = true;
1122        s.files_cursor = 5;
1123        let (s, c) = update(s, Msg::ToggleFocus);
1124        assert!(c.is_done());
1125        assert_eq!(s.files_cursor, 0);
1126        assert!(!s.focus_right);
1127    }
1128
1129    #[test]
1130    fn drill_in_is_noop_while_loading() {
1131        let mut s = state(vec![cache("npm", 100, 0)]);
1132        let started = std::time::Instant::now();
1133        s.loading = Some(Loading {
1134            label: "scanning previous".into(),
1135            frame: 7,
1136            started,
1137            folders: None,
1138        });
1139        let (s, cmd) = update(s, Msg::DrillIn);
1140        assert!(cmd.is_done(), "no second scan must be emitted");
1141        let l = s.loading.as_ref().expect("loading preserved");
1142        assert_eq!(l.label, "scanning previous");
1143        assert_eq!(l.frame, 7);
1144        assert_eq!(l.started, started);
1145    }
1146
1147    #[test]
1148    fn loading_frame_advances() {
1149        let mut l = Loading {
1150            label: "x".into(),
1151            frame: 0,
1152            started: std::time::Instant::now(),
1153            folders: None,
1154        };
1155        l.update_frame();
1156        assert_eq!(l.frame, 1);
1157        for _ in 0..super::super::SPINNER_FRAMES.len() {
1158            l.update_frame();
1159        }
1160        // Wraps around back to 1 after one full cycle from 1.
1161        assert_eq!(l.frame, 1);
1162    }
1163
1164    #[test]
1165    fn tick_advances_spinner_frame_when_loading() {
1166        let mut s = state(vec![cache("a", 1, 0)]);
1167        s.loading = Some(Loading {
1168            label: "x".into(),
1169            frame: 0,
1170            started: std::time::Instant::now(),
1171            folders: None,
1172        });
1173        let (s, c) = update(s, Msg::Tick);
1174        assert!(c.is_done());
1175        assert_eq!(s.loading.as_ref().unwrap().frame, 1);
1176    }
1177
1178    #[test]
1179    fn tick_is_noop_when_not_loading() {
1180        let s = state(vec![cache("a", 1, 0)]);
1181        let (s, c) = update(s, Msg::Tick);
1182        assert!(c.is_done());
1183        assert!(s.loading.is_none());
1184    }
1185
1186    #[test]
1187    fn space_toggle_advances_cursor() {
1188        let s = state(vec![cache("a", 1, 0), cache("b", 1, 0), cache("c", 1, 0)]);
1189        let (s, c) = update(s, Msg::ToggleMark);
1190        assert!(c.is_done());
1191        assert_eq!(s.cursor, 1, "cursor should advance after Space");
1192        let (s, c) = update(s, Msg::ToggleMark);
1193        assert!(c.is_done());
1194        assert_eq!(s.cursor, 2);
1195        let (s, c) = update(s, Msg::ToggleMark);
1196        assert!(c.is_done());
1197        // Already on last row — should not overflow.
1198        assert_eq!(s.cursor, 2);
1199    }
1200
1201    #[test]
1202    fn drill_out_with_path_returns_popped_path() {
1203        let mut s = state(vec![cache("npm", 100, 0)]);
1204        s.drill_paths.push(std::path::PathBuf::from("/x/npm"));
1205        s.drill_into(vec![cache("registry", 10, 0)]);
1206        let popped = s.drill_out_with_path();
1207        assert_eq!(popped, Some(std::path::PathBuf::from("/x/npm")));
1208    }
1209
1210    #[test]
1211    fn drill_out_with_path_at_top_returns_none() {
1212        let mut s = state(vec![cache("npm", 100, 0)]);
1213        assert_eq!(s.drill_out_with_path(), None);
1214    }
1215
1216    #[test]
1217    fn level_dirty_resets_on_drill_in() {
1218        let mut s = state(vec![cache("npm", 100, 0)]);
1219        s.level_dirty = true;
1220        s.drill_into(vec![cache("registry", 10, 0)]);
1221        assert!(!s.level_dirty);
1222    }
1223
1224    #[test]
1225    fn level_dirty_resets_on_drill_out() {
1226        let mut s = state(vec![cache("npm", 100, 0)]);
1227        s.drill_into(vec![cache("registry", 10, 0)]);
1228        s.level_dirty = true;
1229        s.drill_out_with_path();
1230        assert!(!s.level_dirty);
1231    }
1232
1233    #[test]
1234    fn drilldown_delete_drillout_refreshes_parent() {
1235        // End-to-end: drill into npm, delete a child, drill out — the parent
1236        // entry in state.all must trigger a refresh against its original path.
1237        let mut s = state(vec![cache("npm", 100, 0), cache("cargo", 50, 0)]);
1238        s.cursor = 0;
1239        s.dry_run = false;
1240
1241        // 1. DrillIn on "npm" — emits SpawnScan with the cache's path.
1242        let (s, cmd) = update(s, Msg::DrillIn);
1243        let parent_path = match cmd.effects.as_slice() {
1244            [Effect::SpawnScan { parent_path, .. }] => parent_path.clone(),
1245            other => panic!("expected SpawnScan, got {other:?}"),
1246        };
1247        assert_eq!(parent_path, std::path::PathBuf::from("/x/npm"));
1248
1249        // 2. ScanCompleted with two children — drills in, pushes drill_paths.
1250        let (s, _) = update(
1251            s,
1252            Msg::ScanCompleted {
1253                parent_label: "npm".into(),
1254                parent_path: parent_path.clone(),
1255                children: vec![cache("registry", 60, 0), cache("logs", 40, 0)],
1256            },
1257        );
1258        assert_eq!(s.all.len(), 2);
1259        assert_eq!(s.stack.len(), 1);
1260        assert_eq!(s.drill_paths.last(), Some(&parent_path));
1261        assert!(!s.level_dirty, "fresh level starts clean");
1262
1263        // 3. Mark + confirm-delete one child.
1264        let mut s = s;
1265        s.marks.marked.insert(0);
1266        let (s, cmd) = update(s, Msg::ConfirmDelete);
1267        assert!(matches!(
1268            cmd.effects.as_slice(),
1269            [Effect::SpawnDelete { .. }]
1270        ));
1271
1272        // 4. Worker comes back — delete succeeded, level_dirty must be set.
1273        let (s, _) = update(
1274            s,
1275            Msg::DeleteCompleted {
1276                freed: 60,
1277                deleted_count: 1,
1278                failed_count: 0,
1279                deleted_indices: vec![0],
1280            },
1281        );
1282        assert!(
1283            s.level_dirty,
1284            "DeleteCompleted on a real run must mark the level dirty"
1285        );
1286        assert!(s.loading.is_none(), "delete spinner cleared");
1287        assert!(s.overlay.is_some(), "overlay shown");
1288
1289        // 5. DrillOut while loading is None — must emit SpawnRefresh for the
1290        //    original parent path so the top-level npm row gets re-stat'd.
1291        let (s, cmd) = update(s, Msg::DrillOut);
1292        match cmd.effects.as_slice() {
1293            [Effect::SpawnRefresh { path }] => {
1294                assert_eq!(*path, parent_path, "refresh target must be /x/npm");
1295            }
1296            other => panic!("expected SpawnRefresh, got {other:?}"),
1297        }
1298        assert_eq!(s.all[0].label, "npm");
1299        assert!(s.loading.is_some(), "refresh spinner shown");
1300
1301        // 6. RefreshCompleted with the new (smaller) cache.
1302        let updated = Cache {
1303            label: "npm".into(),
1304            path: parent_path.clone(),
1305            size_bytes: 40,
1306            newest_mtime: None,
1307            file_count: 0,
1308            dir_count: 0,
1309            top_files: Vec::new(),
1310            unreadable: 0,
1311        };
1312        let (s, _) = update(
1313            s,
1314            Msg::RefreshCompleted {
1315                path: parent_path,
1316                cache: updated,
1317            },
1318        );
1319        assert_eq!(
1320            s.all[0].size_bytes, 40,
1321            "parent row must reflect post-delete size"
1322        );
1323        assert!(s.loading.is_none());
1324    }
1325
1326    #[test]
1327    fn drilldown_delete_propagates_dirty_up_the_full_stack() {
1328        // 3 levels deep, delete at the bottom, drill out twice — both
1329        // drill-outs must fire a refresh.  Before the fix, level_dirty was
1330        // reset by drill_out_with_path on the way up, so the second
1331        // drill-out (back to the top) silently skipped the re-stat.
1332        let mut s = state(vec![cache("npm", 100, 0)]);
1333        // Drill into L1.
1334        s.drill_paths.push(std::path::PathBuf::from("/x/npm"));
1335        s.drill_into(vec![cache("registry", 60, 0)]);
1336        // Drill into L2.
1337        s.drill_paths
1338            .push(std::path::PathBuf::from("/x/npm/registry"));
1339        s.drill_into(vec![cache("v1", 30, 0), cache("v2", 30, 0)]);
1340        // Delete at L2 — marks level_dirty on this level.
1341        s.level_dirty = true;
1342
1343        // L2 → L1: must emit SpawnRefresh for /x/npm/registry.
1344        let (mut s, cmd) = update(s, Msg::DrillOut);
1345        assert!(matches!(
1346            cmd.effects.as_slice(),
1347            [Effect::SpawnRefresh { path }] if path == &std::path::PathBuf::from("/x/npm/registry")
1348        ));
1349        assert!(s.level_dirty, "L1 inherits dirtiness from the propagation");
1350        assert!(s.loading.is_some());
1351        // Worker reply — clears loading but keeps level_dirty true.
1352        s.loading = None;
1353
1354        // L1 → L0: must ALSO emit SpawnRefresh, now for /x/npm.
1355        let (s, cmd) = update(s, Msg::DrillOut);
1356        assert!(matches!(
1357            cmd.effects.as_slice(),
1358            [Effect::SpawnRefresh { path }] if path == &std::path::PathBuf::from("/x/npm")
1359        ));
1360        assert!(s.loading.is_some());
1361    }
1362
1363    #[test]
1364    fn drill_out_restores_cursor_to_pre_drill_position() {
1365        // Cursor was on row 3 at the top level; drill in, then back out —
1366        // we should land back on row 3, not row 0.
1367        let mut s = state(vec![
1368            cache("a", 1, 0),
1369            cache("b", 1, 0),
1370            cache("c", 1, 0),
1371            cache("npm", 100, 0),
1372            cache("e", 1, 0),
1373        ]);
1374        s.cursor = 3; // pointing at "npm" (idx 3 in state.all, sort=Score)
1375        s.drill_into(vec![cache("registry", 10, 0), cache("logs", 5, 0)]);
1376        assert_eq!(s.cursor, 0, "drill_into resets cursor to 0 in the child");
1377        s.drill_out();
1378        assert_eq!(
1379            s.cursor, 3,
1380            "drill_out must restore the cursor the user had on the parent"
1381        );
1382    }
1383
1384    #[test]
1385    fn drill_out_clamps_restored_cursor_to_visible() {
1386        // Parent had 5 rows, cursor on row 4. While drilled in, a refresh
1387        // could in principle shrink the parent (we simulate by replacing
1388        // state.all before drilling out). The restore must clamp instead
1389        // of leaving cursor out of bounds.
1390        let mut s = state(vec![cache("a", 1, 0), cache("b", 1, 0)]);
1391        s.cursor = 1;
1392        s.drill_into(vec![cache("x", 1, 0)]);
1393        // Pretend something replaced the parent vec mid-drill (e.g. external
1394        // edit). When we drill out, the saved cursor (1) is valid against
1395        // the 1-row replacement only after clamping.
1396        if let Some(parent) = s.stack.last_mut() {
1397            *parent = vec![cache("a", 1, 0)];
1398        }
1399        s.drill_out();
1400        assert_eq!(s.cursor, 0, "cursor must clamp into the restored vec");
1401    }
1402
1403    #[test]
1404    fn drill_in_clears_marks() {
1405        let mut s = state(vec![cache("npm", 100, 0)]);
1406        s.marks.toggle(0);
1407        s.drill_into(vec![cache("a", 1, 0)]);
1408        assert_eq!(s.marks.count(), 0);
1409    }
1410
1411    #[test]
1412    fn drill_in_emits_scan_effect_and_sets_loading() {
1413        let s = state(vec![cache("npm", 100, 0)]);
1414        let (s, cmd) = update(s, Msg::DrillIn);
1415        let l = s.loading.as_ref().expect("loading set");
1416        assert_eq!(
1417            l.folders,
1418            Some(0),
1419            "drill-in spinner seeds the same folder-count UX as LoadSeeds"
1420        );
1421        assert!(matches!(cmd.effects.as_slice(), [Effect::SpawnScan { .. }]));
1422    }
1423
1424    #[test]
1425    fn drill_in_with_empty_list_is_noop() {
1426        let s = state(Vec::new());
1427        let (s, cmd) = update(s, Msg::DrillIn);
1428        assert!(s.loading.is_none());
1429        assert!(cmd.is_done());
1430    }
1431
1432    #[test]
1433    fn scan_completed_drills_into_children() {
1434        let s = state(vec![cache("npm", 100, 0)]);
1435        let (s, cmd) = update(
1436            s,
1437            Msg::ScanCompleted {
1438                parent_label: "npm".into(),
1439                parent_path: std::path::PathBuf::from("/x/npm"),
1440                children: vec![cache("registry", 10, 0), cache("cache", 5, 0)],
1441            },
1442        );
1443        assert_eq!(s.all.len(), 2);
1444        assert_eq!(s.stack.len(), 1);
1445        assert_eq!(
1446            s.drill_paths.last().unwrap(),
1447            &std::path::PathBuf::from("/x/npm")
1448        );
1449        assert!(s.loading.is_none());
1450        assert!(cmd.is_done());
1451    }
1452
1453    #[test]
1454    fn scan_completed_empty_children_just_clears_loading() {
1455        let mut s = state(vec![cache("npm", 100, 0)]);
1456        s.loading = Some(Loading {
1457            label: "scanning npm".into(),
1458            frame: 0,
1459            started: std::time::Instant::now(),
1460            folders: None,
1461        });
1462        let (s, cmd) = update(
1463            s,
1464            Msg::ScanCompleted {
1465                parent_label: "npm".into(),
1466                parent_path: std::path::PathBuf::from("/x/npm"),
1467                children: vec![],
1468            },
1469        );
1470        assert_eq!(s.all.len(), 1);
1471        assert!(s.loading.is_none());
1472        assert!(cmd.is_done());
1473    }
1474
1475    #[test]
1476    fn scan_progress_updates_loading_folder_count() {
1477        let mut s = state(Vec::new());
1478        s.loading = Some(Loading {
1479            label: "scanning caches".into(),
1480            frame: 0,
1481            started: std::time::Instant::now(),
1482            folders: Some(0),
1483        });
1484        let (s, cmd) = update(s, Msg::ScanProgress { folders: 1234 });
1485        assert!(cmd.is_done());
1486        assert_eq!(s.loading.as_ref().unwrap().folders, Some(1234));
1487    }
1488
1489    #[test]
1490    fn scan_progress_is_noop_when_not_loading() {
1491        let s = state(vec![cache("a", 1, 0)]);
1492        let (s, cmd) = update(s, Msg::ScanProgress { folders: 5 });
1493        assert!(cmd.is_done());
1494        assert!(s.loading.is_none());
1495    }
1496
1497    #[test]
1498    fn seeds_loaded_replaces_all_and_clears_loading() {
1499        // Simulates startup: empty list + spinner, then the LoadSeeds worker
1500        // returns the scanned caches.  state.all is replaced wholesale, the
1501        // spinner clears, cursor resets to 0.
1502        let mut s = state(Vec::new());
1503        s.loading = Some(Loading {
1504            label: "scanning caches".into(),
1505            frame: 3,
1506            started: std::time::Instant::now(),
1507            folders: None,
1508        });
1509        s.cursor = 7; // would be invalid against an empty list
1510        let (s, cmd) = update(
1511            s,
1512            Msg::SeedsLoaded {
1513                caches: vec![cache("npm", 100, 0), cache("cargo", 50, 0)],
1514            },
1515        );
1516        assert_eq!(s.all.len(), 2);
1517        assert_eq!(s.cursor, 0);
1518        assert!(s.loading.is_none());
1519        assert!(cmd.is_done());
1520    }
1521
1522    #[test]
1523    fn drill_out_when_clean_returns_done() {
1524        let mut s = state(vec![cache("npm", 100, 0)]);
1525        s.drill_into(vec![cache("registry", 10, 0)]);
1526        // level_dirty defaults false after drill_into
1527        let (s, cmd) = update(s, Msg::DrillOut);
1528        assert!(cmd.is_done());
1529        assert_eq!(s.all[0].label, "npm");
1530    }
1531
1532    #[test]
1533    fn drill_out_is_noop_while_loading() {
1534        // Pressing Esc/Backspace while a scan/delete/refresh is in flight must
1535        // NOT swap `state.all` — the worker's result Msg would then index into
1536        // the wrong list.
1537        let mut s = state(vec![cache("npm", 100, 0)]);
1538        s.drill_into(vec![cache("registry", 10, 0)]);
1539        s.loading = Some(Loading {
1540            label: "deleting 1 cache".into(),
1541            frame: 0,
1542            started: std::time::Instant::now(),
1543            folders: None,
1544        });
1545        let (s, cmd) = update(s, Msg::DrillOut);
1546        assert!(cmd.is_done(), "no refresh effect must be emitted");
1547        assert_eq!(s.all[0].label, "registry", "stack must not be popped");
1548        assert_eq!(s.stack.len(), 1);
1549    }
1550
1551    #[test]
1552    fn drill_out_when_dirty_emits_refresh_effect() {
1553        let mut s = state(vec![cache("npm", 100, 0)]);
1554        s.drill_paths.push(std::path::PathBuf::from("/x/npm"));
1555        s.drill_into(vec![cache("registry", 10, 0)]);
1556        s.level_dirty = true;
1557        let (s, cmd) = update(s, Msg::DrillOut);
1558        assert!(s.loading.is_some());
1559        assert!(matches!(
1560            cmd.effects.as_slice(),
1561            [Effect::SpawnRefresh { .. }]
1562        ));
1563    }
1564
1565    #[test]
1566    fn refresh_completed_replaces_matching_cache() {
1567        let mut s = state(vec![cache("a", 100, 0), cache("b", 200, 0)]);
1568        s.loading = Some(Loading {
1569            label: "x".into(),
1570            frame: 0,
1571            started: std::time::Instant::now(),
1572            folders: None,
1573        });
1574        let updated = Cache {
1575            label: "b".into(),
1576            path: std::path::PathBuf::from("/x/b"),
1577            size_bytes: 999,
1578            newest_mtime: None,
1579            file_count: 0,
1580            dir_count: 0,
1581            top_files: Vec::new(),
1582            unreadable: 0,
1583        };
1584        let (s, cmd) = update(
1585            s,
1586            Msg::RefreshCompleted {
1587                path: std::path::PathBuf::from("/x/b"),
1588                cache: updated,
1589            },
1590        );
1591        assert_eq!(s.all[1].size_bytes, 999);
1592        assert!(s.loading.is_none());
1593        assert!(cmd.is_done());
1594    }
1595
1596    #[test]
1597    fn refresh_completed_unknown_path_clears_loading() {
1598        let mut s = state(vec![cache("a", 100, 0)]);
1599        s.loading = Some(Loading {
1600            label: "x".into(),
1601            frame: 0,
1602            started: std::time::Instant::now(),
1603            folders: None,
1604        });
1605        let (s, cmd) = update(
1606            s,
1607            Msg::RefreshCompleted {
1608                path: std::path::PathBuf::from("/x/gone"),
1609                cache: cache("gone", 1, 0),
1610            },
1611        );
1612        assert_eq!(s.all[0].size_bytes, 100);
1613        assert!(s.loading.is_none());
1614        assert!(cmd.is_done());
1615    }
1616
1617    #[test]
1618    fn delete_pressed_with_yes_mode_chains_confirm_event() {
1619        let mut s = state(vec![cache("a", 1, 0)]);
1620        s.marks.toggle(0);
1621        s.yes_mode = true;
1622        let (s, cmd) = update(s, Msg::DeletePressed);
1623        assert!(matches!(s.modal, Modal::DeleteConfirm));
1624        assert!(matches!(cmd.events.as_slice(), [Msg::ConfirmDelete]));
1625    }
1626
1627    #[test]
1628    fn delete_pressed_without_yes_mode_just_opens_modal() {
1629        let mut s = state(vec![cache("a", 1, 0)]);
1630        s.marks.toggle(0);
1631        s.yes_mode = false;
1632        let (s, cmd) = update(s, Msg::DeletePressed);
1633        assert!(matches!(s.modal, Modal::DeleteConfirm));
1634        assert!(cmd.events.is_empty());
1635        assert!(cmd.effects.is_empty());
1636    }
1637}