1use crate::app::{ContainerActionRequest, ContainerExecRequest, ContainerLogsRequest};
4
5pub 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 pub confirm_action: Option<(crate::containers::ContainerAction, String, String)>,
20}
21
22#[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 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 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 pub fn queue_action(&mut self, req: ContainerActionRequest) {
158 self.pending_actions.push_back(req);
159 }
160
161 pub fn queue_fetch(&mut self, alias: String) {
164 self.pending_fetch_aliases.push(alias);
165 }
166
167 pub fn drain_pending_fetches(&mut self) -> Vec<String> {
169 std::mem::take(&mut self.pending_fetch_aliases)
170 }
171
172 pub fn prune_orphans(&mut self, valid_aliases: &std::collections::HashSet<&str>) -> bool {
178 let pre = self.cache.len();
179 self.cache
180 .retain(|alias, _| valid_aliases.contains(alias.as_str()));
181 let dropped = pre.saturating_sub(self.cache.len());
182 if dropped > 0 {
183 log::debug!("[purple] reload_hosts: dropped {dropped} orphan container_cache host(s)");
184 true
185 } else {
186 false
187 }
188 }
189
190 pub fn migrate_alias(&mut self, old: &str, new: &str) -> bool {
194 if old == new {
195 return false;
196 }
197 if let Some(v) = self.cache.remove(old) {
198 debug_assert!(
199 !self.cache.contains_key(new),
200 "container_state.cache collision on rename {old} -> {new}"
201 );
202 self.cache.insert(new.to_string(), v);
203 true
204 } else {
205 false
206 }
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use crate::containers::{ContainerCacheEntry, ContainerRuntime};
214
215 fn make_logs_request(alias: &str) -> ContainerLogsRequest {
216 ContainerLogsRequest {
217 alias: alias.to_string(),
218 askpass: None,
219 runtime: ContainerRuntime::Docker,
220 container_id: "abc123".to_string(),
221 container_name: "nginx".to_string(),
222 }
223 }
224
225 fn make_cache_entry() -> ContainerCacheEntry {
226 ContainerCacheEntry {
227 timestamp: 1700000000,
228 runtime: ContainerRuntime::Docker,
229 engine_version: Some("28.0.0".to_string()),
230 containers: vec![],
231 }
232 }
233
234 #[test]
235 fn queue_logs_sets_pending() {
236 let mut state = ContainerState::default();
237 assert!(state.pending_logs.is_none());
238 state.queue_logs(make_logs_request("host-a"));
239 assert!(state.pending_logs.is_some());
240 assert_eq!(state.pending_logs.as_ref().unwrap().alias, "host-a");
241 }
242
243 #[test]
244 fn queue_logs_replaces_previous() {
245 let mut state = ContainerState::default();
246 state.queue_logs(make_logs_request("host-a"));
247 state.queue_logs(make_logs_request("host-b"));
248 assert_eq!(state.pending_logs.as_ref().unwrap().alias, "host-b");
249 }
250
251 #[test]
252 fn queue_exec_sets_pending() {
253 let mut state = ContainerState::default();
254 assert!(state.pending_exec.is_none());
255 state.queue_exec(ContainerExecRequest {
256 alias: "host-a".to_string(),
257 askpass: None,
258 runtime: ContainerRuntime::Docker,
259 container_id: "abc".to_string(),
260 container_name: "nginx".to_string(),
261 command: Some("echo hi".to_string()),
262 });
263 assert!(state.pending_exec.is_some());
264 assert_eq!(state.pending_exec.as_ref().unwrap().alias, "host-a");
265 }
266
267 #[test]
268 fn queue_fetch_pushes_alias() {
269 let mut state = ContainerState::default();
270 state.queue_fetch("host-a".to_string());
271 state.queue_fetch("host-b".to_string());
272 assert_eq!(state.pending_fetch_aliases, vec!["host-a", "host-b"]);
273 }
274
275 #[test]
276 fn drain_pending_fetches_returns_and_clears() {
277 let mut state = ContainerState::default();
278 state.queue_fetch("host-a".to_string());
279 state.queue_fetch("host-b".to_string());
280 let drained = state.drain_pending_fetches();
281 assert_eq!(drained, vec!["host-a", "host-b"]);
282 assert!(state.pending_fetch_aliases.is_empty());
283 }
284
285 #[test]
286 fn drain_pending_fetches_empty_when_no_aliases() {
287 let mut state = ContainerState::default();
288 let drained = state.drain_pending_fetches();
289 assert!(drained.is_empty());
290 assert!(state.pending_fetch_aliases.is_empty());
291 }
292
293 #[test]
294 fn migrate_alias_renames_cache_entry() {
295 let mut state = ContainerState::default();
296 state.cache.insert("old".to_string(), make_cache_entry());
297 assert!(state.migrate_alias("old", "new"));
298 assert!(state.cache.contains_key("new"));
299 assert!(!state.cache.contains_key("old"));
300 }
301
302 #[test]
303 fn migrate_alias_returns_false_when_no_entry() {
304 let mut state = ContainerState::default();
305 assert!(!state.migrate_alias("missing", "new"));
306 assert!(state.cache.is_empty());
307 }
308
309 #[test]
310 fn migrate_alias_self_rename_is_noop() {
311 let mut state = ContainerState::default();
312 state.cache.insert("same".to_string(), make_cache_entry());
313 assert!(!state.migrate_alias("same", "same"));
314 assert!(state.cache.contains_key("same"));
315 }
316
317 #[test]
318 fn queue_action_pushes_back_in_order() {
319 let mut state = ContainerState::default();
320 for id in ["a", "b", "c"] {
321 state.queue_action(ContainerActionRequest {
322 alias: "host".to_string(),
323 askpass: None,
324 runtime: ContainerRuntime::Docker,
325 container_id: id.to_string(),
326 container_name: id.to_string(),
327 action: crate::containers::ContainerAction::Restart,
328 });
329 }
330 assert_eq!(state.pending_actions.len(), 3);
331 let ids: Vec<String> = state
332 .pending_actions
333 .iter()
334 .map(|r| r.container_id.clone())
335 .collect();
336 assert_eq!(ids, vec!["a", "b", "c"]);
337 }
338
339 #[test]
340 fn prune_orphans_drops_unknown_aliases_and_signals_persist() {
341 let mut state = ContainerState::default();
342 state.cache.insert("keep".to_string(), make_cache_entry());
343 state.cache.insert("drop".to_string(), make_cache_entry());
344
345 let valid: std::collections::HashSet<&str> = ["keep"].into_iter().collect();
346 let changed = state.prune_orphans(&valid);
347
348 assert!(changed, "returns true so caller persists the trimmed cache");
349 assert!(state.cache.contains_key("keep"));
350 assert!(!state.cache.contains_key("drop"));
351 }
352
353 #[test]
354 fn prune_orphans_returns_false_when_nothing_dropped() {
355 let mut state = ContainerState::default();
356 state.cache.insert("keep".to_string(), make_cache_entry());
357
358 let valid: std::collections::HashSet<&str> = ["keep"].into_iter().collect();
359 assert!(!state.prune_orphans(&valid));
360 }
361}