purple_ssh/app/
container_state.rs1use 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 pending_exec: Option<ContainerExecRequest>,
27 pub pending_logs: Option<ContainerLogsRequest>,
28 pub pending_actions: std::collections::VecDeque<ContainerActionRequest>,
29 pub pending_fetch_aliases: Vec<String>,
30 pub cache: std::collections::HashMap<String, crate::containers::ContainerCacheEntry>,
31}
32
33impl ContainerState {
34 pub fn queue_logs(&mut self, req: ContainerLogsRequest) {
38 if let Some(prev) = self.pending_logs.as_ref() {
39 log::debug!(
40 "[purple] queue_logs replaced pending request for alias={} id={}",
41 prev.alias,
42 prev.container_id,
43 );
44 }
45 self.pending_logs = Some(req);
46 }
47
48 pub fn queue_exec(&mut self, req: ContainerExecRequest) {
51 if let Some(prev) = self.pending_exec.as_ref() {
52 log::debug!(
53 "[purple] queue_exec replaced pending request for alias={} id={}",
54 prev.alias,
55 prev.container_id,
56 );
57 }
58 self.pending_exec = Some(req);
59 }
60
61 pub fn queue_action(&mut self, req: ContainerActionRequest) {
65 self.pending_actions.push_back(req);
66 }
67
68 pub fn queue_fetch(&mut self, alias: String) {
71 self.pending_fetch_aliases.push(alias);
72 }
73
74 pub fn drain_pending_fetches(&mut self) -> Vec<String> {
76 std::mem::take(&mut self.pending_fetch_aliases)
77 }
78
79 pub fn migrate_alias(&mut self, old: &str, new: &str) -> bool {
83 if old == new {
84 return false;
85 }
86 if let Some(v) = self.cache.remove(old) {
87 debug_assert!(
88 !self.cache.contains_key(new),
89 "container_state.cache collision on rename {old} -> {new}"
90 );
91 self.cache.insert(new.to_string(), v);
92 true
93 } else {
94 false
95 }
96 }
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102 use crate::containers::{ContainerCacheEntry, ContainerRuntime};
103
104 fn make_logs_request(alias: &str) -> ContainerLogsRequest {
105 ContainerLogsRequest {
106 alias: alias.to_string(),
107 askpass: None,
108 runtime: ContainerRuntime::Docker,
109 container_id: "abc123".to_string(),
110 container_name: "nginx".to_string(),
111 }
112 }
113
114 fn make_cache_entry() -> ContainerCacheEntry {
115 ContainerCacheEntry {
116 timestamp: 1700000000,
117 runtime: ContainerRuntime::Docker,
118 engine_version: Some("28.0.0".to_string()),
119 containers: vec![],
120 }
121 }
122
123 #[test]
124 fn queue_logs_sets_pending() {
125 let mut state = ContainerState::default();
126 assert!(state.pending_logs.is_none());
127 state.queue_logs(make_logs_request("host-a"));
128 assert!(state.pending_logs.is_some());
129 assert_eq!(state.pending_logs.as_ref().unwrap().alias, "host-a");
130 }
131
132 #[test]
133 fn queue_logs_replaces_previous() {
134 let mut state = ContainerState::default();
135 state.queue_logs(make_logs_request("host-a"));
136 state.queue_logs(make_logs_request("host-b"));
137 assert_eq!(state.pending_logs.as_ref().unwrap().alias, "host-b");
138 }
139
140 #[test]
141 fn queue_exec_sets_pending() {
142 let mut state = ContainerState::default();
143 assert!(state.pending_exec.is_none());
144 state.queue_exec(ContainerExecRequest {
145 alias: "host-a".to_string(),
146 askpass: None,
147 runtime: ContainerRuntime::Docker,
148 container_id: "abc".to_string(),
149 container_name: "nginx".to_string(),
150 command: Some("echo hi".to_string()),
151 });
152 assert!(state.pending_exec.is_some());
153 assert_eq!(state.pending_exec.as_ref().unwrap().alias, "host-a");
154 }
155
156 #[test]
157 fn queue_fetch_pushes_alias() {
158 let mut state = ContainerState::default();
159 state.queue_fetch("host-a".to_string());
160 state.queue_fetch("host-b".to_string());
161 assert_eq!(state.pending_fetch_aliases, vec!["host-a", "host-b"]);
162 }
163
164 #[test]
165 fn drain_pending_fetches_returns_and_clears() {
166 let mut state = ContainerState::default();
167 state.queue_fetch("host-a".to_string());
168 state.queue_fetch("host-b".to_string());
169 let drained = state.drain_pending_fetches();
170 assert_eq!(drained, vec!["host-a", "host-b"]);
171 assert!(state.pending_fetch_aliases.is_empty());
172 }
173
174 #[test]
175 fn drain_pending_fetches_empty_when_no_aliases() {
176 let mut state = ContainerState::default();
177 let drained = state.drain_pending_fetches();
178 assert!(drained.is_empty());
179 assert!(state.pending_fetch_aliases.is_empty());
180 }
181
182 #[test]
183 fn migrate_alias_renames_cache_entry() {
184 let mut state = ContainerState::default();
185 state.cache.insert("old".to_string(), make_cache_entry());
186 assert!(state.migrate_alias("old", "new"));
187 assert!(state.cache.contains_key("new"));
188 assert!(!state.cache.contains_key("old"));
189 }
190
191 #[test]
192 fn migrate_alias_returns_false_when_no_entry() {
193 let mut state = ContainerState::default();
194 assert!(!state.migrate_alias("missing", "new"));
195 assert!(state.cache.is_empty());
196 }
197
198 #[test]
199 fn migrate_alias_self_rename_is_noop() {
200 let mut state = ContainerState::default();
201 state.cache.insert("same".to_string(), make_cache_entry());
202 assert!(!state.migrate_alias("same", "same"));
203 assert!(state.cache.contains_key("same"));
204 }
205
206 #[test]
207 fn queue_action_pushes_back_in_order() {
208 let mut state = ContainerState::default();
209 for id in ["a", "b", "c"] {
210 state.queue_action(ContainerActionRequest {
211 alias: "host".to_string(),
212 askpass: None,
213 runtime: ContainerRuntime::Docker,
214 container_id: id.to_string(),
215 container_name: id.to_string(),
216 action: crate::containers::ContainerAction::Restart,
217 });
218 }
219 assert_eq!(state.pending_actions.len(), 3);
220 let ids: Vec<String> = state
221 .pending_actions
222 .iter()
223 .map(|r| r.container_id.clone())
224 .collect();
225 assert_eq!(ids, vec!["a", "b", "c"]);
226 }
227}