Skip to main content

purple_ssh/app/
containers_overview.rs

1//! State for the global Containers tab (top_page = Containers).
2
3use std::collections::{HashMap, HashSet, VecDeque};
4
5use super::host_state::ViewMode;
6use crate::containers::{ContainerInspect, ContainerRuntime};
7
8/// One queued host in a `R` batch refresh: everything the listing
9/// thread needs to spawn an SSH `docker ps` for that alias.
10#[derive(Debug, Clone)]
11pub struct RefreshQueueItem {
12    pub alias: String,
13    pub askpass: Option<String>,
14    pub cached_runtime: Option<ContainerRuntime>,
15    pub has_tunnel: bool,
16}
17
18/// State of a `R` batch refresh. None when no batch is active.
19/// Drives a windowed concurrency: at most `MAX_PARALLEL` listings are
20/// in flight at any time. Each `ContainerListing` event decrements
21/// `in_flight` and pops the next queued item; the batch ends when
22/// `queue` is empty and `in_flight` drops to zero.
23#[derive(Debug, Default)]
24pub struct RefreshBatch {
25    pub queue: VecDeque<RefreshQueueItem>,
26    pub in_flight: usize,
27    /// Total host count when the batch started. used for the
28    /// `Refreshing N/M` progress readout in the status footer.
29    pub total: usize,
30    /// Hosts whose listing has already returned (success or error).
31    pub completed: usize,
32    /// Aliases that the batch has spawned but not yet seen complete.
33    /// Listings whose alias is NOT in this set are non-batch traffic
34    /// (host-list `C`, action-complete refresh, `a`-add) and must not
35    /// touch the counters. Without this guard the in-flight counter
36    /// gets corrupted whenever a parallel non-batch fetch completes
37    /// during a `R` run.
38    pub in_flight_aliases: HashSet<String>,
39}
40
41/// Cap on parallel SSH connections during a `R` batch refresh. Picked
42/// to keep load on the local SSH agent and remote sshd reasonable
43/// while still amortising connection setup.
44pub const REFRESH_MAX_PARALLEL: usize = 4;
45
46/// Pending request to drop the user into a remote container shell. Set
47/// by the handler when Enter is pressed on a running container; drained
48/// by `handle_pending_container_exec` in the main loop, which suspends
49/// the TUI, runs `ssh -t <alias> <runtime> exec -it <id> sh -c
50/// 'bash || sh'`, then restores the TUI on exit.
51///
52/// `command` is the full remote command. `None` runs the default
53/// shell (`sh -c 'bash || sh'`); `Some` runs the user-typed exec
54/// prompt verbatim (validated upstream. must not contain newlines).
55#[derive(Debug, Clone)]
56pub struct ContainerExecRequest {
57    pub alias: String,
58    pub askpass: Option<String>,
59    pub runtime: ContainerRuntime,
60    pub container_id: String,
61    /// Human-readable container name (for the log line and the toast
62    /// the user sees when the session ends). Not used to build the
63    /// command. the validated `container_id` is what addresses the
64    /// container.
65    pub container_name: String,
66    /// User-supplied command. `None` falls back to the interactive
67    /// shell (`sh -c 'bash || sh'`) used by the default Enter binding.
68    /// `Some` is the verbatim payload from the `e` exec prompt.
69    pub command: Option<String>,
70}
71
72/// Pending request to fetch container logs from a remote host. Drained
73/// by the main loop into a background SSH thread; the result returns
74/// via `AppEvent::ContainerLogsComplete` and lands on the
75/// `Screen::ContainerLogs` overlay the user opened with `l`.
76#[derive(Debug, Clone)]
77pub struct ContainerLogsRequest {
78    pub alias: String,
79    pub askpass: Option<String>,
80    pub runtime: ContainerRuntime,
81    pub container_id: String,
82    pub container_name: String,
83}
84
85/// Pending non-interactive container action (restart, stop). Same shape
86/// as `ContainerExecRequest` but plus an action tag and minus the askpass
87/// handling for an interactive shell. these run in a worker thread, not
88/// the foreground TUI. Reuses `containers::ContainerAction` so the
89/// command formatter (`container_action_command`) is shared.
90#[derive(Debug, Clone)]
91pub struct ContainerActionRequest {
92    pub alias: String,
93    pub askpass: Option<String>,
94    pub runtime: ContainerRuntime,
95    pub container_id: String,
96    pub container_name: String,
97    pub action: crate::containers::ContainerAction,
98}
99
100/// Sort order for the containers overview screen. Cycled with `s`.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
102pub enum ContainersSortMode {
103    /// Alphabetical by host alias, then container name.
104    #[default]
105    AlphaHost,
106    /// Alphabetical by container name, then host alias.
107    AlphaContainer,
108}
109
110impl ContainersSortMode {
111    pub fn next(self) -> Self {
112        match self {
113            ContainersSortMode::AlphaHost => ContainersSortMode::AlphaContainer,
114            ContainersSortMode::AlphaContainer => ContainersSortMode::AlphaHost,
115        }
116    }
117
118    pub fn label(self) -> &'static str {
119        match self {
120            ContainersSortMode::AlphaHost => "A-Z host",
121            ContainersSortMode::AlphaContainer => "A-Z container",
122        }
123    }
124
125    pub fn to_key(self) -> &'static str {
126        match self {
127            ContainersSortMode::AlphaHost => "alpha_host",
128            ContainersSortMode::AlphaContainer => "alpha_container",
129        }
130    }
131
132    pub fn from_key(s: &str) -> Self {
133        match s {
134            "alpha_container" => ContainersSortMode::AlphaContainer,
135            _ => ContainersSortMode::AlphaHost,
136        }
137    }
138}
139
140/// One cached `docker inspect` result, paired with the wall-clock seconds
141/// at which it was fetched. The detail panel treats entries older than
142/// `INSPECT_CACHE_TTL_SECS` as stale and re-fires the SSH call.
143#[derive(Debug, Clone)]
144pub struct InspectCacheEntry {
145    pub timestamp: u64,
146    pub result: Result<ContainerInspect, String>,
147}
148
149/// Detail-panel inspect cache and in-flight tracking. Keyed on the full
150/// container ID rather than `(alias, container_id)` because `docker`
151/// container IDs are globally unique 64-char hex strings. collisions
152/// across hosts are practically impossible. Keeps the key shape simple.
153#[derive(Debug, Default)]
154pub struct InspectCache {
155    pub entries: HashMap<String, InspectCacheEntry>,
156    /// Container IDs with a pending background `inspect` thread. Prevents
157    /// re-firing while a previous fetch is still in flight.
158    pub in_flight: HashSet<String>,
159}
160
161/// One cached `docker logs --tail N` result. Same TTL semantics as
162/// `InspectCacheEntry`: the LOGS card on the detail panel re-fires the
163/// SSH call once the entry is older than `LOGS_CACHE_TTL_SECS`.
164#[derive(Debug, Clone)]
165pub struct LogsCacheEntry {
166    pub timestamp: u64,
167    pub result: Result<Vec<String>, String>,
168}
169
170/// Detail-panel logs cache and in-flight tracking. Mirrors `InspectCache`
171/// so the LOGS card and the inspect cards refresh on the same rhythm.
172#[derive(Debug, Default)]
173pub struct LogsCache {
174    pub entries: HashMap<String, LogsCacheEntry>,
175    pub in_flight: HashSet<String>,
176}
177
178/// Cache TTL in seconds. Kept short so resource-y fields like
179/// `RestartCount` and `Health.Status` do not lag too far behind reality
180/// while still avoiding an SSH storm when the user scrolls a long list.
181pub const INSPECT_CACHE_TTL_SECS: u64 = 30;
182
183/// TTL for the per-container `docker logs --tail` cache feeding the
184/// LOGS card. Same value as the inspect cache so a single user-driven
185/// refresh re-fires both streams in lockstep.
186pub const LOGS_CACHE_TTL_SECS: u64 = 30;
187
188/// How many log lines the LOGS card requests via `--tail`. Sized for
189/// the worst-case panel height we expect (a tall terminal can fit
190/// dozens of lines inside the card); the renderer slices the trailing
191/// `inner_capacity` rows so a short panel only paints what fits.
192pub const LOGS_TAIL: usize = 50;
193
194/// TTL for the per-host `docker ps` cache used by the auto-list-refresh
195/// helper. When the user scrolls to a row whose host has a stale (or
196/// missing) entry in `container_cache`, we re-fire the listing so the
197/// running/exited counts and uptime in the visible row reflect reality.
198/// Same value as the inspect TTL so the two refresh streams stay
199/// loosely in lockstep.
200pub const LIST_CACHE_TTL_SECS: u64 = 30;
201
202/// Which bulk-action confirm dialog is open. Mirrored by the
203/// corresponding `Screen::Confirm*` variant (now data-less) so the
204/// renderer knows which header to draw.
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub enum BulkConfirmKind {
207    /// `Ctrl-K` on a container row: restart every running member of
208    /// the selected container's compose stack.
209    StackRestart,
210    /// `K` on a host-divider row: restart every running container on
211    /// that host, ignoring compose-project boundaries.
212    HostRestartAll,
213    /// `S` on a host-divider row: stop every running container on
214    /// that host.
215    HostStopAll,
216}
217
218/// Payload of the three bulk container confirm dialogs. The Screen
219/// variant carries only the kind tag; the alias/project/members live
220/// here so screen transitions never clone the member vec.
221#[derive(Debug, Clone)]
222pub struct BulkConfirmContext {
223    pub kind: BulkConfirmKind,
224    pub alias: String,
225    /// Compose project name. Always `Some` for `StackRestart`, `None`
226    /// for the host-wide variants.
227    pub project: Option<String>,
228    pub members: Vec<crate::app::StackMember>,
229}
230
231#[derive(Debug)]
232pub struct ContainersOverviewState {
233    pub(in crate::app) sort_mode: ContainersSortMode,
234    pub(in crate::app) inspect_cache: InspectCache,
235    pub(in crate::app) logs_cache: LogsCache,
236    /// Currently-running `R` batch, if any. `None` when idle.
237    pub(in crate::app) refresh_batch: Option<RefreshBatch>,
238    /// Aliases whose `docker ps` listing was kicked off by the
239    /// scroll-driven auto-refresh helper and has not yet returned.
240    /// Lets the helper skip a re-spawn while one is already pending.
241    /// Cleared by `handle_container_listing` on arrival.
242    pub(in crate::app) auto_list_in_flight: HashSet<String>,
243    /// Toggle for the per-row detail panel on the right. Mirrors the
244    /// host-list `v` toggle. Default `Detailed` so the panel is visible
245    /// whenever the terminal is wide enough.
246    pub(in crate::app) view_mode: ViewMode,
247    /// Aliases whose container group is currently collapsed in the
248    /// AlphaHost rendering. Persisted across sessions via preferences
249    /// so a folded group stays folded after restart.
250    pub(in crate::app) collapsed_hosts: HashSet<String>,
251    /// Memoized render list. The render and handler paths call
252    /// `visible_items` repeatedly (24 call sites, several per key
253    /// event) and each call cloned 6 String fields per container. The
254    /// cache stores the built `Vec<ContainerListItem>` keyed on a
255    /// content fingerprint over the inputs (sort_mode, search query,
256    /// collapsed_hosts, per-host (timestamp, container_count)). On a
257    /// hit we skip the collect/sort/intersperse step entirely and
258    /// return a clone of the cached vec. The fingerprint walks all
259    /// hosts but only reads a few fields each, so it is dramatically
260    /// cheaper than rebuilding the row set.
261    pub(in crate::app) view_cache:
262        std::cell::RefCell<Option<(u64, Vec<crate::ui::containers_overview::ContainerListItem>)>>,
263    /// Payload of the currently-open bulk container confirm dialog
264    /// (`ConfirmStackRestart` / `ConfirmHostRestartAll` /
265    /// `ConfirmHostStopAll`). `None` when no such dialog is open.
266    pub(in crate::app) pending_bulk_confirm: Option<BulkConfirmContext>,
267}
268
269impl Default for ContainersOverviewState {
270    fn default() -> Self {
271        Self {
272            sort_mode: ContainersSortMode::default(),
273            inspect_cache: InspectCache::default(),
274            logs_cache: LogsCache::default(),
275            refresh_batch: None,
276            auto_list_in_flight: HashSet::new(),
277            view_mode: ViewMode::Detailed,
278            collapsed_hosts: HashSet::new(),
279            view_cache: std::cell::RefCell::new(None),
280            pending_bulk_confirm: None,
281        }
282    }
283}
284
285impl ContainersOverviewState {
286    /// Install a fresh refresh batch. Caller is responsible for spawning
287    /// the initial parallel listings after this call; this method only
288    /// owns the state slot.
289    pub fn start_refresh(&mut self, batch: RefreshBatch) {
290        self.refresh_batch = Some(batch);
291    }
292
293    /// Drop the active refresh batch. Called when the queue drains and
294    /// in-flight count returns to zero.
295    pub fn clear_refresh(&mut self) {
296        self.refresh_batch = None;
297    }
298
299    // Sealed-field accessors. Fields are `pub(in crate::app)`; callers
300    // outside the app module reach state through these.
301
302    pub fn sort_mode(&self) -> ContainersSortMode {
303        self.sort_mode
304    }
305
306    /// Set the sort mode without persisting to preferences. Demo mode and
307    /// tests only; the persisting path is `set_sort_mode`.
308    pub fn set_sort_mode_ephemeral(&mut self, mode: ContainersSortMode) {
309        self.sort_mode = mode;
310    }
311
312    pub fn view_mode(&self) -> ViewMode {
313        self.view_mode
314    }
315
316    /// Set the view mode without persisting to preferences. Demo mode and
317    /// tests only; the persisting path is `set_view_mode`.
318    pub fn set_view_mode_ephemeral(&mut self, mode: ViewMode) {
319        self.view_mode = mode;
320    }
321
322    pub fn collapsed_hosts(&self) -> &HashSet<String> {
323        &self.collapsed_hosts
324    }
325
326    /// Read the active bulk confirm payload (`None` when no bulk
327    /// container confirm dialog is open).
328    pub fn pending_bulk_confirm(&self) -> Option<&BulkConfirmContext> {
329        self.pending_bulk_confirm.as_ref()
330    }
331
332    /// Install a fresh bulk confirm payload. Caller is responsible for
333    /// transitioning the screen to the matching `Screen::Confirm*`
334    /// variant.
335    pub fn set_pending_bulk_confirm(&mut self, ctx: BulkConfirmContext) {
336        self.pending_bulk_confirm = Some(ctx);
337    }
338
339    /// Drop the active bulk confirm payload, returning it for use by
340    /// the confirm-yes handler.
341    pub fn take_pending_bulk_confirm(&mut self) -> Option<BulkConfirmContext> {
342        self.pending_bulk_confirm.take()
343    }
344
345    /// Fold or unfold a host group; returns the new collapsed state.
346    pub fn toggle_host_collapsed(&mut self, alias: &str) -> bool {
347        if self.collapsed_hosts.remove(alias) {
348            false
349        } else {
350            self.collapsed_hosts.insert(alias.to_string());
351            true
352        }
353    }
354
355    pub fn refresh_batch(&self) -> Option<&RefreshBatch> {
356        self.refresh_batch.as_ref()
357    }
358
359    pub fn refresh_batch_mut(&mut self) -> Option<&mut RefreshBatch> {
360        self.refresh_batch.as_mut()
361    }
362
363    pub fn auto_list_in_flight(&self) -> &HashSet<String> {
364        &self.auto_list_in_flight
365    }
366
367    /// True when a scroll-driven auto-listing is already in flight for
368    /// `alias`, so the helper skips a re-spawn.
369    pub fn auto_list_pending(&self, alias: &str) -> bool {
370        self.auto_list_in_flight.contains(alias)
371    }
372
373    /// Mark a scroll-driven auto-listing as in flight for `alias`.
374    pub fn mark_auto_list_pending(&mut self, alias: String) {
375        self.auto_list_in_flight.insert(alias);
376    }
377
378    /// Clear the in-flight marker once an auto-listing arrives.
379    pub fn clear_auto_list_pending(&mut self, alias: &str) {
380        self.auto_list_in_flight.remove(alias);
381    }
382
383    pub fn inspect_cache(&self) -> &InspectCache {
384        &self.inspect_cache
385    }
386
387    pub fn inspect_cache_mut(&mut self) -> &mut InspectCache {
388        &mut self.inspect_cache
389    }
390
391    pub fn logs_cache(&self) -> &LogsCache {
392        &self.logs_cache
393    }
394
395    pub fn logs_cache_mut(&mut self) -> &mut LogsCache {
396        &mut self.logs_cache
397    }
398
399    pub fn view_cache(
400        &self,
401    ) -> &std::cell::RefCell<Option<(u64, Vec<crate::ui::containers_overview::ContainerListItem>)>>
402    {
403        &self.view_cache
404    }
405
406    /// Load the persisted overview fields from `preferences`. Startup only:
407    /// clobbers any in-memory state with whatever is on disk. `pub(crate)`
408    /// so a stray mid-session caller cannot quietly revert user edits.
409    pub(crate) fn hydrate_from_prefs(&mut self, paths: Option<&crate::runtime::env::Paths>) {
410        self.view_mode = crate::preferences::load_containers_view_mode(paths);
411        self.sort_mode = crate::preferences::load_containers_sort_mode(paths);
412        self.collapsed_hosts = crate::preferences::load_containers_collapsed_hosts(paths);
413    }
414
415    /// Update `view_mode` and persist. Returns the persist error so the
416    /// caller can surface it (current call site discards it intentionally
417    /// to match the pre-encapsulation behavior where view-mode persist
418    /// failures only logged).
419    pub fn set_view_mode(
420        &mut self,
421        paths: Option<&crate::runtime::env::Paths>,
422        mode: ViewMode,
423    ) -> std::io::Result<()> {
424        self.view_mode = mode;
425        crate::preferences::save_containers_view_mode(paths, mode).inspect_err(|e| {
426            log::warn!("[config] Failed to persist containers view mode: {e}");
427        })
428    }
429
430    /// Update `sort_mode` and persist. Same contract as `set_view_mode`;
431    /// the call site does surface the error via a toast.
432    pub fn set_sort_mode(
433        &mut self,
434        paths: Option<&crate::runtime::env::Paths>,
435        mode: ContainersSortMode,
436    ) -> std::io::Result<()> {
437        self.sort_mode = mode;
438        crate::preferences::save_containers_sort_mode(paths, mode).inspect_err(|e| {
439            log::warn!("[config] Failed to persist containers sort mode: {e}");
440        })
441    }
442
443    /// Rename an alias across every alias-keyed set in this state.
444    /// Returns `true` when `collapsed_hosts` changed so the caller can
445    /// persist; `auto_list_in_flight` and `refresh_batch.in_flight_aliases`
446    /// are also migrated but are not persistent so they do not affect
447    /// the return value. No-op (returns `false`) when `old == new`.
448    pub fn migrate_alias(&mut self, old: &str, new: &str) -> bool {
449        if old == new {
450            return false;
451        }
452        if self.auto_list_in_flight.remove(old) {
453            debug_assert!(
454                !self.auto_list_in_flight.contains(new),
455                "auto_list_in_flight collision on rename {old} -> {new}"
456            );
457            self.auto_list_in_flight.insert(new.to_string());
458        }
459        if let Some(batch) = self.refresh_batch.as_mut() {
460            if batch.in_flight_aliases.remove(old) {
461                debug_assert!(
462                    !batch.in_flight_aliases.contains(new),
463                    "refresh_batch.in_flight_aliases collision on rename {old} -> {new}"
464                );
465                batch.in_flight_aliases.insert(new.to_string());
466            }
467        }
468        if let Some(ctx) = self.pending_bulk_confirm.as_mut() {
469            if ctx.alias == old {
470                ctx.alias = new.to_string();
471            }
472        }
473        if self.collapsed_hosts.remove(old) {
474            debug_assert!(
475                !self.collapsed_hosts.contains(new),
476                "collapsed_hosts collision on rename {old} -> {new}"
477            );
478            self.collapsed_hosts.insert(new.to_string());
479            true
480        } else {
481            false
482        }
483    }
484
485    /// Drop inspect-cache and logs-cache entries whose container ID is
486    /// not in `valid_container_ids`. Called from `App::reload_hosts`
487    /// after the per-host container cache has been pruned: container
488    /// IDs come from the just-pruned cache, so this keeps inspect/logs
489    /// aligned with the surviving hosts.
490    pub fn prune_by_container_ids(&mut self, valid_container_ids: &HashSet<String>) {
491        let pre_inspect = self.inspect_cache.entries.len();
492        self.inspect_cache
493            .entries
494            .retain(|id, _| valid_container_ids.contains(id));
495        self.inspect_cache
496            .in_flight
497            .retain(|id| valid_container_ids.contains(id));
498        self.logs_cache
499            .entries
500            .retain(|id, _| valid_container_ids.contains(id));
501        self.logs_cache
502            .in_flight
503            .retain(|id| valid_container_ids.contains(id));
504        let dropped = pre_inspect.saturating_sub(self.inspect_cache.entries.len());
505        if dropped > 0 {
506            log::debug!("[purple] reload_hosts: dropped {dropped} orphan inspect_cache entrie(s)");
507        }
508    }
509
510    /// Drop per-alias overview state (auto-list in-flight, refresh batch
511    /// in-flight aliases, collapsed-hosts) whose alias is no longer in
512    /// `valid_aliases`. Returns `true` when `collapsed_hosts` shrank, so
513    /// the caller can persist the trimmed set via
514    /// `preferences::save_containers_collapsed_hosts`.
515    pub fn prune_orphans(&mut self, valid_aliases: &HashSet<&str>) -> bool {
516        // Auto-list in-flight markers for deleted hosts. The listing
517        // thread still posts a result and the race guard in
518        // `handle_container_listing` removes it; pruning here keeps
519        // debug state clean and avoids a false-positive dedup hit if
520        // the same alias is re-added before the stray listing returns.
521        self.auto_list_in_flight
522            .retain(|alias| valid_aliases.contains(alias.as_str()));
523
524        // Container-overview refresh batch (R). Tracks in-flight aliases
525        // to gate counter updates against non-batch listings; prune so a
526        // host removed mid-batch cannot linger.
527        if let Some(batch) = self.refresh_batch.as_mut() {
528            let pre = batch.in_flight_aliases.len();
529            batch
530                .in_flight_aliases
531                .retain(|alias| valid_aliases.contains(alias.as_str()));
532            let dropped = pre.saturating_sub(batch.in_flight_aliases.len());
533            if dropped > 0 {
534                log::debug!(
535                    "[purple] reload_hosts: dropped {dropped} orphan refresh_batch in_flight alias(es)"
536                );
537            }
538        }
539
540        // Bulk-confirm payload aimed at a deleted host: clear it; the
541        // matching Screen::Confirm* variant would render an empty body
542        // and a y press would no-op.
543        if let Some(ctx) = self.pending_bulk_confirm.as_ref() {
544            if !valid_aliases.contains(ctx.alias.as_str()) {
545                self.pending_bulk_confirm = None;
546            }
547        }
548
549        // Containers-overview collapsed groups are persisted to disk via
550        // preferences, so leftover aliases survive restart. Rename is
551        // already handled by `apply_alias_renames`; this covers delete.
552        let pre_collapsed = self.collapsed_hosts.len();
553        self.collapsed_hosts
554            .retain(|alias| valid_aliases.contains(alias.as_str()));
555        let dropped_collapsed = pre_collapsed.saturating_sub(self.collapsed_hosts.len());
556        if dropped_collapsed > 0 {
557            log::debug!(
558                "[purple] reload_hosts: dropped {dropped_collapsed} orphan collapsed_hosts entrie(s)"
559            );
560            true
561        } else {
562            false
563        }
564    }
565}
566
567impl InspectCache {
568    /// Returns `Some(entry)` only when the cache holds a *fresh* entry
569    /// for `container_id` (`now - timestamp < TTL`). Stale entries
570    /// behave like absent ones so the trigger code re-fetches.
571    pub fn fresh(&self, container_id: &str, now: u64) -> Option<&InspectCacheEntry> {
572        self.entries
573            .get(container_id)
574            .filter(|e| now.saturating_sub(e.timestamp) < INSPECT_CACHE_TTL_SECS)
575    }
576}
577
578impl LogsCache {
579    /// Same fresh-window contract as `InspectCache::fresh`, against
580    /// `LOGS_CACHE_TTL_SECS`.
581    pub fn fresh(&self, container_id: &str, now: u64) -> Option<&LogsCacheEntry> {
582        self.entries
583            .get(container_id)
584            .filter(|e| now.saturating_sub(e.timestamp) < LOGS_CACHE_TTL_SECS)
585    }
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591    use crate::runtime::env::Paths;
592
593    fn batch_with_aliases(aliases: &[&str]) -> RefreshBatch {
594        RefreshBatch {
595            queue: VecDeque::new(),
596            in_flight: aliases.len(),
597            total: aliases.len(),
598            completed: 0,
599            in_flight_aliases: aliases.iter().map(|a| a.to_string()).collect(),
600        }
601    }
602
603    #[test]
604    fn start_refresh_installs_batch() {
605        let mut state = ContainersOverviewState::default();
606        assert!(state.refresh_batch.is_none());
607        state.start_refresh(batch_with_aliases(&["host-a", "host-b"]));
608        let batch = state.refresh_batch.as_ref().unwrap();
609        assert_eq!(batch.total, 2);
610        assert_eq!(batch.in_flight, 2);
611        assert!(batch.in_flight_aliases.contains("host-a"));
612    }
613
614    #[test]
615    fn clear_refresh_drops_batch() {
616        let mut state = ContainersOverviewState::default();
617        state.start_refresh(batch_with_aliases(&["host-a"]));
618        state.clear_refresh();
619        assert!(state.refresh_batch.is_none());
620    }
621
622    #[test]
623    fn hydrate_from_prefs_reads_persisted_values() {
624        let dir = tempfile::tempdir().unwrap();
625        let paths = Paths::new(dir.path());
626        crate::preferences::save_containers_view_mode(Some(&paths), ViewMode::Compact).unwrap();
627        crate::preferences::save_containers_sort_mode(
628            Some(&paths),
629            ContainersSortMode::AlphaContainer,
630        )
631        .unwrap();
632        let mut collapsed = std::collections::HashSet::new();
633        collapsed.insert("folded-host".to_string());
634        crate::preferences::save_containers_collapsed_hosts(Some(&paths), &collapsed).unwrap();
635
636        let mut state = ContainersOverviewState::default();
637        state.hydrate_from_prefs(Some(&paths));
638        assert_eq!(state.view_mode, ViewMode::Compact);
639        assert_eq!(state.sort_mode, ContainersSortMode::AlphaContainer);
640        assert!(state.collapsed_hosts.contains("folded-host"));
641    }
642
643    #[test]
644    fn set_view_mode_updates_field_and_persists() {
645        let dir = tempfile::tempdir().unwrap();
646        let paths = Paths::new(dir.path());
647        let mut state = ContainersOverviewState::default();
648        state
649            .set_view_mode(Some(&paths), ViewMode::Compact)
650            .unwrap();
651        assert_eq!(state.view_mode, ViewMode::Compact);
652        assert_eq!(
653            crate::preferences::load_containers_view_mode(Some(&paths)),
654            ViewMode::Compact
655        );
656    }
657
658    #[test]
659    fn set_sort_mode_updates_field_and_persists() {
660        let dir = tempfile::tempdir().unwrap();
661        let paths = Paths::new(dir.path());
662        let mut state = ContainersOverviewState::default();
663        state
664            .set_sort_mode(Some(&paths), ContainersSortMode::AlphaContainer)
665            .unwrap();
666        assert_eq!(state.sort_mode, ContainersSortMode::AlphaContainer);
667        assert_eq!(
668            crate::preferences::load_containers_sort_mode(Some(&paths)),
669            ContainersSortMode::AlphaContainer
670        );
671    }
672
673    #[test]
674    fn migrate_alias_renames_auto_list_in_flight() {
675        let mut state = ContainersOverviewState::default();
676        state.auto_list_in_flight.insert("old".to_string());
677        state.migrate_alias("old", "new");
678        assert!(state.auto_list_in_flight.contains("new"));
679        assert!(!state.auto_list_in_flight.contains("old"));
680    }
681
682    #[test]
683    fn migrate_alias_renames_refresh_batch_in_flight() {
684        let mut state = ContainersOverviewState::default();
685        state.start_refresh(batch_with_aliases(&["old"]));
686        // Non-persistent change: collapsed_hosts is untouched so the
687        // return value must be false even though the in-flight set
688        // was migrated. Pins the contract that drives the persist
689        // call in app::hosts::migrate_alias_keyed_caches.
690        assert!(!state.migrate_alias("old", "new"));
691        let batch = state.refresh_batch.as_ref().unwrap();
692        assert!(batch.in_flight_aliases.contains("new"));
693        assert!(!batch.in_flight_aliases.contains("old"));
694    }
695
696    #[test]
697    fn migrate_alias_self_rename_is_noop() {
698        let mut state = ContainersOverviewState::default();
699        state.collapsed_hosts.insert("same".to_string());
700        state.auto_list_in_flight.insert("same".to_string());
701        assert!(!state.migrate_alias("same", "same"));
702        assert!(state.collapsed_hosts.contains("same"));
703        assert!(state.auto_list_in_flight.contains("same"));
704    }
705
706    #[test]
707    fn migrate_alias_renames_collapsed_hosts_and_returns_true() {
708        let mut state = ContainersOverviewState::default();
709        state.collapsed_hosts.insert("old".to_string());
710        assert!(state.migrate_alias("old", "new"));
711        assert!(state.collapsed_hosts.contains("new"));
712        assert!(!state.collapsed_hosts.contains("old"));
713    }
714
715    #[test]
716    fn migrate_alias_returns_false_when_collapsed_unchanged() {
717        let mut state = ContainersOverviewState::default();
718        state.auto_list_in_flight.insert("old".to_string());
719        assert!(!state.migrate_alias("old", "new"));
720        assert!(state.auto_list_in_flight.contains("new"));
721    }
722
723    #[test]
724    fn migrate_alias_is_noop_when_nothing_matches() {
725        let mut state = ContainersOverviewState::default();
726        assert!(!state.migrate_alias("missing", "new"));
727    }
728
729    #[test]
730    fn prune_by_container_ids_drops_unknown_id_from_in_flight_sets() {
731        let mut state = ContainersOverviewState::default();
732        state.inspect_cache.in_flight.insert("id-keep".to_string());
733        state.inspect_cache.in_flight.insert("id-drop".to_string());
734        state.logs_cache.in_flight.insert("id-keep".to_string());
735        state.logs_cache.in_flight.insert("id-drop".to_string());
736
737        let valid: HashSet<String> = ["id-keep".to_string()].into_iter().collect();
738        state.prune_by_container_ids(&valid);
739
740        assert!(state.inspect_cache.in_flight.contains("id-keep"));
741        assert!(!state.inspect_cache.in_flight.contains("id-drop"));
742        assert!(state.logs_cache.in_flight.contains("id-keep"));
743        assert!(!state.logs_cache.in_flight.contains("id-drop"));
744    }
745
746    #[test]
747    fn prune_by_container_ids_drops_unknown_id_from_entries_maps() {
748        // Distinct from the in_flight test: this one populates the
749        // `.entries` HashMaps so the retain() calls covering them
750        // cannot be removed without a test failure.
751        let mut state = ContainersOverviewState::default();
752        state.inspect_cache.entries.insert(
753            "id-keep".to_string(),
754            InspectCacheEntry {
755                timestamp: 0,
756                result: Err("placeholder".to_string()),
757            },
758        );
759        state.inspect_cache.entries.insert(
760            "id-drop".to_string(),
761            InspectCacheEntry {
762                timestamp: 0,
763                result: Err("placeholder".to_string()),
764            },
765        );
766        state.logs_cache.entries.insert(
767            "id-keep".to_string(),
768            LogsCacheEntry {
769                timestamp: 0,
770                result: Ok(vec!["line".to_string()]),
771            },
772        );
773        state.logs_cache.entries.insert(
774            "id-drop".to_string(),
775            LogsCacheEntry {
776                timestamp: 0,
777                result: Ok(vec!["line".to_string()]),
778            },
779        );
780
781        let valid: HashSet<String> = ["id-keep".to_string()].into_iter().collect();
782        state.prune_by_container_ids(&valid);
783
784        assert!(state.inspect_cache.entries.contains_key("id-keep"));
785        assert!(!state.inspect_cache.entries.contains_key("id-drop"));
786        assert!(state.logs_cache.entries.contains_key("id-keep"));
787        assert!(!state.logs_cache.entries.contains_key("id-drop"));
788    }
789
790    #[test]
791    fn prune_orphans_drops_unknown_and_signals_collapsed_change() {
792        let mut state = ContainersOverviewState::default();
793        state.auto_list_in_flight.insert("keep".to_string());
794        state.auto_list_in_flight.insert("drop".to_string());
795        state.collapsed_hosts.insert("keep".to_string());
796        state.collapsed_hosts.insert("drop".to_string());
797
798        let valid: HashSet<&str> = ["keep"].into_iter().collect();
799        let collapsed_changed = state.prune_orphans(&valid);
800
801        assert!(
802            collapsed_changed,
803            "returns true so caller persists collapsed_hosts"
804        );
805        assert!(state.auto_list_in_flight.contains("keep"));
806        assert!(!state.auto_list_in_flight.contains("drop"));
807        assert!(state.collapsed_hosts.contains("keep"));
808        assert!(!state.collapsed_hosts.contains("drop"));
809    }
810
811    #[test]
812    fn prune_orphans_returns_false_when_collapsed_unchanged() {
813        let mut state = ContainersOverviewState::default();
814        state.auto_list_in_flight.insert("only".to_string());
815        let valid: HashSet<&str> = ["only"].into_iter().collect();
816        assert!(!state.prune_orphans(&valid));
817    }
818}