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 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    /// Queue a logs request for the main loop to drain. Replaces any
35    /// previous pending logs request and logs the displaced alias so a
36    /// dropped request is traceable.
37    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    /// Queue an exec request for the main loop to drain. Same replace
49    /// and log semantics as `queue_logs`.
50    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    /// Queue a non-interactive container action for the worker thread.
62    /// Actions are FIFO via `VecDeque::push_back`; multiple actions
63    /// against the same alias process in order.
64    pub fn queue_action(&mut self, req: ContainerActionRequest) {
65        self.pending_actions.push_back(req);
66    }
67
68    /// Enqueue an alias for the initial container-cache fetch. Drained by
69    /// the main loop on the next tick via `drain_pending_fetches`.
70    pub fn queue_fetch(&mut self, alias: String) {
71        self.pending_fetch_aliases.push(alias);
72    }
73
74    /// Take the full fetch queue, leaving it empty.
75    pub fn drain_pending_fetches(&mut self) -> Vec<String> {
76        std::mem::take(&mut self.pending_fetch_aliases)
77    }
78
79    /// Move a cache entry from `old` to `new` on host rename. Returns
80    /// `true` when the cache changed so the caller can persist. No-op
81    /// (returns `false`) when `old == new` or no entry exists under `old`.
82    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}