Skip to main content

purple_ssh/app/
container_state.rs

1//! Containers overlay state.
2
3use crate::app::{ContainerActionRequest, ContainerExecRequest, ContainerLogsRequest};
4
5/// Per-host overlay session state; only valid while the containers overlay is open.
6///
7/// No `Default` impl: construction always requires an alias and runtime
8/// metadata, so a default-constructed value would be meaningless.
9pub struct ContainerSession {
10    pub alias: String,
11    pub askpass: Option<String>,
12    pub runtime: Option<crate::containers::ContainerRuntime>,
13    pub containers: Vec<crate::containers::ContainerInfo>,
14    pub list_state: ratatui::widgets::ListState,
15    pub loading: bool,
16    pub error: Option<String>,
17    pub action_in_progress: Option<String>,
18    /// Pending confirmation for stop/restart actions: (action, container_name, container_id).
19    pub confirm_action: Option<(crate::containers::ContainerAction, String, String)>,
20}
21
22/// Always-present container-domain state: cache and cross-host pending operations.
23/// Separate from `ContainerSession`, which is the per-host overlay session state.
24#[derive(Debug, Default)]
25pub struct ContainerState {
26    pub(in crate::app) pending_exec: Option<ContainerExecRequest>,
27    pub(in crate::app) pending_logs: Option<ContainerLogsRequest>,
28    pub(in crate::app) pending_actions: std::collections::VecDeque<ContainerActionRequest>,
29    pub(in crate::app) pending_fetch_aliases: Vec<String>,
30    pub(in crate::app) cache:
31        std::collections::HashMap<String, crate::containers::ContainerCacheEntry>,
32}
33
34impl ContainerState {
35    pub fn cache(
36        &self,
37    ) -> &std::collections::HashMap<String, crate::containers::ContainerCacheEntry> {
38        &self.cache
39    }
40
41    pub fn set_cache(
42        &mut self,
43        cache: std::collections::HashMap<String, crate::containers::ContainerCacheEntry>,
44    ) {
45        self.cache = cache;
46    }
47
48    pub fn cache_entry(&self, alias: &str) -> Option<&crate::containers::ContainerCacheEntry> {
49        self.cache.get(alias)
50    }
51
52    pub fn cache_entry_mut(
53        &mut self,
54        alias: &str,
55    ) -> Option<&mut crate::containers::ContainerCacheEntry> {
56        self.cache.get_mut(alias)
57    }
58
59    pub fn cache_contains(&self, alias: &str) -> bool {
60        self.cache.contains_key(alias)
61    }
62
63    pub fn cache_len(&self) -> usize {
64        self.cache.len()
65    }
66
67    pub fn insert_cache_entry(
68        &mut self,
69        alias: String,
70        entry: crate::containers::ContainerCacheEntry,
71    ) {
72        self.cache.insert(alias, entry);
73    }
74
75    pub fn remove_cache_entry(&mut self, alias: &str) {
76        self.cache.remove(alias);
77    }
78
79    pub fn clear_cache(&mut self) {
80        self.cache.clear();
81    }
82
83    pub fn pending_exec_request(&self) -> Option<&ContainerExecRequest> {
84        self.pending_exec.as_ref()
85    }
86
87    pub fn pending_logs_request(&self) -> Option<&ContainerLogsRequest> {
88        self.pending_logs.as_ref()
89    }
90
91    pub fn has_pending_fetches(&self) -> bool {
92        !self.pending_fetch_aliases.is_empty()
93    }
94
95    pub fn pending_actions_len(&self) -> usize {
96        self.pending_actions.len()
97    }
98
99    pub fn take_pending_exec(&mut self) -> Option<ContainerExecRequest> {
100        self.pending_exec.take()
101    }
102
103    pub fn take_pending_logs(&mut self) -> Option<ContainerLogsRequest> {
104        self.pending_logs.take()
105    }
106
107    pub fn pop_next_action(&mut self) -> Option<ContainerActionRequest> {
108        self.pending_actions.pop_front()
109    }
110
111    pub fn pending_actions_iter(&self) -> impl Iterator<Item = &ContainerActionRequest> {
112        self.pending_actions.iter()
113    }
114
115    pub fn pending_actions_at(&self, idx: usize) -> Option<&ContainerActionRequest> {
116        self.pending_actions.get(idx)
117    }
118
119    pub fn pending_fetch_aliases(&self) -> &[String] {
120        &self.pending_fetch_aliases
121    }
122
123    pub fn extend_pending_fetches<I: IntoIterator<Item = String>>(&mut self, iter: I) {
124        self.pending_fetch_aliases.extend(iter);
125    }
126
127    /// Queue a logs request for the main loop to drain. Replaces any
128    /// previous pending logs request and logs the displaced alias so a
129    /// dropped request is traceable.
130    pub fn queue_logs(&mut self, req: ContainerLogsRequest) {
131        if let Some(prev) = self.pending_logs.as_ref() {
132            log::debug!(
133                "[purple] queue_logs replaced pending request for alias={} id={}",
134                prev.alias,
135                prev.container_id,
136            );
137        }
138        self.pending_logs = Some(req);
139    }
140
141    /// Queue an exec request for the main loop to drain. Same replace
142    /// and log semantics as `queue_logs`.
143    pub fn queue_exec(&mut self, req: ContainerExecRequest) {
144        if let Some(prev) = self.pending_exec.as_ref() {
145            log::debug!(
146                "[purple] queue_exec replaced pending request for alias={} id={}",
147                prev.alias,
148                prev.container_id,
149            );
150        }
151        self.pending_exec = Some(req);
152    }
153
154    /// Queue a non-interactive container action for the worker thread.
155    /// Actions are FIFO via `VecDeque::push_back`; multiple actions
156    /// against the same alias process in order.
157    pub fn queue_action(&mut self, req: ContainerActionRequest) {
158        self.pending_actions.push_back(req);
159    }
160
161    /// Enqueue an alias for the initial container-cache fetch. Drained by
162    /// the main loop on the next tick via `drain_pending_fetches`.
163    pub fn queue_fetch(&mut self, alias: String) {
164        self.pending_fetch_aliases.push(alias);
165    }
166
167    /// Take the full fetch queue, leaving it empty.
168    pub fn drain_pending_fetches(&mut self) -> Vec<String> {
169        std::mem::take(&mut self.pending_fetch_aliases)
170    }
171
172    /// Move a cache entry from `old` to `new` on host rename. Returns
173    /// `true` when the cache changed so the caller can persist. No-op
174    /// (returns `false`) when `old == new` or no entry exists under `old`.
175    pub fn migrate_alias(&mut self, old: &str, new: &str) -> bool {
176        if old == new {
177            return false;
178        }
179        if let Some(v) = self.cache.remove(old) {
180            debug_assert!(
181                !self.cache.contains_key(new),
182                "container_state.cache collision on rename {old} -> {new}"
183            );
184            self.cache.insert(new.to_string(), v);
185            true
186        } else {
187            false
188        }
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::containers::{ContainerCacheEntry, ContainerRuntime};
196
197    fn make_logs_request(alias: &str) -> ContainerLogsRequest {
198        ContainerLogsRequest {
199            alias: alias.to_string(),
200            askpass: None,
201            runtime: ContainerRuntime::Docker,
202            container_id: "abc123".to_string(),
203            container_name: "nginx".to_string(),
204        }
205    }
206
207    fn make_cache_entry() -> ContainerCacheEntry {
208        ContainerCacheEntry {
209            timestamp: 1700000000,
210            runtime: ContainerRuntime::Docker,
211            engine_version: Some("28.0.0".to_string()),
212            containers: vec![],
213        }
214    }
215
216    #[test]
217    fn queue_logs_sets_pending() {
218        let mut state = ContainerState::default();
219        assert!(state.pending_logs.is_none());
220        state.queue_logs(make_logs_request("host-a"));
221        assert!(state.pending_logs.is_some());
222        assert_eq!(state.pending_logs.as_ref().unwrap().alias, "host-a");
223    }
224
225    #[test]
226    fn queue_logs_replaces_previous() {
227        let mut state = ContainerState::default();
228        state.queue_logs(make_logs_request("host-a"));
229        state.queue_logs(make_logs_request("host-b"));
230        assert_eq!(state.pending_logs.as_ref().unwrap().alias, "host-b");
231    }
232
233    #[test]
234    fn queue_exec_sets_pending() {
235        let mut state = ContainerState::default();
236        assert!(state.pending_exec.is_none());
237        state.queue_exec(ContainerExecRequest {
238            alias: "host-a".to_string(),
239            askpass: None,
240            runtime: ContainerRuntime::Docker,
241            container_id: "abc".to_string(),
242            container_name: "nginx".to_string(),
243            command: Some("echo hi".to_string()),
244        });
245        assert!(state.pending_exec.is_some());
246        assert_eq!(state.pending_exec.as_ref().unwrap().alias, "host-a");
247    }
248
249    #[test]
250    fn queue_fetch_pushes_alias() {
251        let mut state = ContainerState::default();
252        state.queue_fetch("host-a".to_string());
253        state.queue_fetch("host-b".to_string());
254        assert_eq!(state.pending_fetch_aliases, vec!["host-a", "host-b"]);
255    }
256
257    #[test]
258    fn drain_pending_fetches_returns_and_clears() {
259        let mut state = ContainerState::default();
260        state.queue_fetch("host-a".to_string());
261        state.queue_fetch("host-b".to_string());
262        let drained = state.drain_pending_fetches();
263        assert_eq!(drained, vec!["host-a", "host-b"]);
264        assert!(state.pending_fetch_aliases.is_empty());
265    }
266
267    #[test]
268    fn drain_pending_fetches_empty_when_no_aliases() {
269        let mut state = ContainerState::default();
270        let drained = state.drain_pending_fetches();
271        assert!(drained.is_empty());
272        assert!(state.pending_fetch_aliases.is_empty());
273    }
274
275    #[test]
276    fn migrate_alias_renames_cache_entry() {
277        let mut state = ContainerState::default();
278        state.cache.insert("old".to_string(), make_cache_entry());
279        assert!(state.migrate_alias("old", "new"));
280        assert!(state.cache.contains_key("new"));
281        assert!(!state.cache.contains_key("old"));
282    }
283
284    #[test]
285    fn migrate_alias_returns_false_when_no_entry() {
286        let mut state = ContainerState::default();
287        assert!(!state.migrate_alias("missing", "new"));
288        assert!(state.cache.is_empty());
289    }
290
291    #[test]
292    fn migrate_alias_self_rename_is_noop() {
293        let mut state = ContainerState::default();
294        state.cache.insert("same".to_string(), make_cache_entry());
295        assert!(!state.migrate_alias("same", "same"));
296        assert!(state.cache.contains_key("same"));
297    }
298
299    #[test]
300    fn queue_action_pushes_back_in_order() {
301        let mut state = ContainerState::default();
302        for id in ["a", "b", "c"] {
303            state.queue_action(ContainerActionRequest {
304                alias: "host".to_string(),
305                askpass: None,
306                runtime: ContainerRuntime::Docker,
307                container_id: id.to_string(),
308                container_name: id.to_string(),
309                action: crate::containers::ContainerAction::Restart,
310            });
311        }
312        assert_eq!(state.pending_actions.len(), 3);
313        let ids: Vec<String> = state
314            .pending_actions
315            .iter()
316            .map(|r| r.container_id.clone())
317            .collect();
318        assert_eq!(ids, vec!["a", "b", "c"]);
319    }
320}