1use std::collections::HashMap;
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use log::{error, info};
5
6use serde::{Deserialize, Serialize};
7
8use crate::ssh_context::{OwnedSshContext, SshContext};
9
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct ContainerInfo {
17 #[serde(rename = "ID")]
18 pub id: String,
19 #[serde(rename = "Names")]
20 pub names: String,
21 #[serde(rename = "Image")]
22 pub image: String,
23 #[serde(rename = "State")]
24 pub state: String,
25 #[serde(rename = "Status")]
26 pub status: String,
27 #[serde(rename = "Ports")]
28 pub ports: String,
29}
30
31pub fn parse_container_ps(output: &str) -> Vec<ContainerInfo> {
34 output
35 .lines()
36 .filter_map(|line| {
37 let trimmed = line.trim();
38 if trimmed.is_empty() {
39 return None;
40 }
41 serde_json::from_str(trimmed).ok()
42 })
43 .collect()
44}
45
46#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
52pub enum ContainerRuntime {
53 Docker,
54 Podman,
55}
56
57impl ContainerRuntime {
58 pub fn as_str(&self) -> &'static str {
60 match self {
61 ContainerRuntime::Docker => "docker",
62 ContainerRuntime::Podman => "podman",
63 }
64 }
65}
66
67#[allow(dead_code)]
72pub fn parse_runtime(output: &str) -> Option<ContainerRuntime> {
73 let last = output
74 .lines()
75 .rev()
76 .map(|l| l.trim())
77 .find(|l| !l.is_empty())?;
78 match last {
79 "docker" => Some(ContainerRuntime::Docker),
80 "podman" => Some(ContainerRuntime::Podman),
81 _ => None,
82 }
83}
84
85#[derive(Copy, Clone, Debug, PartialEq)]
91pub enum ContainerAction {
92 Start,
93 Stop,
94 Restart,
95}
96
97impl ContainerAction {
98 pub fn as_str(&self) -> &'static str {
100 match self {
101 ContainerAction::Start => "start",
102 ContainerAction::Stop => "stop",
103 ContainerAction::Restart => "restart",
104 }
105 }
106}
107
108pub fn container_action_command(
110 runtime: ContainerRuntime,
111 action: ContainerAction,
112 container_id: &str,
113) -> String {
114 format!("{} {} {}", runtime.as_str(), action.as_str(), container_id)
115}
116
117pub fn validate_container_id(id: &str) -> Result<(), String> {
125 if id.is_empty() {
126 return Err("Container ID must not be empty.".to_string());
127 }
128 for c in id.chars() {
129 if !c.is_ascii_alphanumeric() && c != '-' && c != '_' && c != '.' {
130 return Err(format!("Container ID contains invalid character: '{c}'"));
131 }
132 }
133 Ok(())
134}
135
136pub fn container_list_command(runtime: Option<ContainerRuntime>) -> String {
145 match runtime {
146 Some(ContainerRuntime::Docker) => "docker ps -a --format '{{json .}}'".to_string(),
147 Some(ContainerRuntime::Podman) => "podman ps -a --format '{{json .}}'".to_string(),
148 None => concat!(
149 "if command -v docker >/dev/null 2>&1; then ",
150 "echo '##purple:docker##' && docker ps -a --format '{{json .}}'; ",
151 "elif command -v podman >/dev/null 2>&1; then ",
152 "echo '##purple:podman##' && podman ps -a --format '{{json .}}'; ",
153 "else echo '##purple:none##'; fi"
154 )
155 .to_string(),
156 }
157}
158
159pub fn parse_container_output(
165 output: &str,
166 caller_runtime: Option<ContainerRuntime>,
167) -> Result<(ContainerRuntime, Vec<ContainerInfo>), String> {
168 if let Some(sentinel_line) = output.lines().find(|l| l.trim().starts_with("##purple:")) {
169 let sentinel = sentinel_line.trim();
170 if sentinel == "##purple:none##" {
171 return Err("No container runtime found. Install Docker or Podman.".to_string());
172 }
173 let runtime = if sentinel == "##purple:docker##" {
174 ContainerRuntime::Docker
175 } else if sentinel == "##purple:podman##" {
176 ContainerRuntime::Podman
177 } else {
178 return Err(format!("Unknown sentinel: {sentinel}"));
179 };
180 let containers: Vec<ContainerInfo> = output
181 .lines()
182 .filter(|l| !l.trim().starts_with("##purple:"))
183 .filter_map(|line| {
184 let t = line.trim();
185 if t.is_empty() {
186 return None;
187 }
188 serde_json::from_str(t).ok()
189 })
190 .collect();
191 return Ok((runtime, containers));
192 }
193
194 match caller_runtime {
195 Some(rt) => Ok((rt, parse_container_ps(output))),
196 None => Err("No sentinel found and no runtime provided.".to_string()),
197 }
198}
199
200#[derive(Debug)]
207pub struct ContainerError {
208 pub runtime: Option<ContainerRuntime>,
209 pub message: String,
210}
211
212impl std::fmt::Display for ContainerError {
213 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214 write!(f, "{}", self.message)
215 }
216}
217
218fn friendly_container_error(stderr: &str, code: Option<i32>) -> String {
220 let lower = stderr.to_lowercase();
221 if lower.contains("command not found") {
222 "Docker or Podman not found on remote host.".to_string()
223 } else if lower.contains("permission denied") || lower.contains("got permission denied") {
224 "Permission denied. Is your user in the docker group?".to_string()
225 } else if lower.contains("cannot connect to the docker daemon")
226 || lower.contains("cannot connect to podman")
227 {
228 "Container daemon is not running.".to_string()
229 } else if lower.contains("connection refused") {
230 "Connection refused.".to_string()
231 } else if lower.contains("no route to host") || lower.contains("network is unreachable") {
232 "Host unreachable.".to_string()
233 } else {
234 format!("Command failed with code {}.", code.unwrap_or(1))
235 }
236}
237
238pub fn fetch_containers(
241 ctx: &SshContext<'_>,
242 cached_runtime: Option<ContainerRuntime>,
243) -> Result<(ContainerRuntime, Vec<ContainerInfo>), ContainerError> {
244 let command = container_list_command(cached_runtime);
245 let result = crate::snippet::run_snippet(
246 ctx.alias,
247 ctx.config_path,
248 &command,
249 ctx.askpass,
250 ctx.bw_session,
251 true,
252 ctx.has_tunnel,
253 );
254 let alias = ctx.alias;
255 match result {
256 Ok(r) if r.status.success() => {
257 parse_container_output(&r.stdout, cached_runtime).map_err(|e| {
258 error!("[external] Container list parse failed: alias={alias}: {e}");
259 ContainerError {
260 runtime: cached_runtime,
261 message: e,
262 }
263 })
264 }
265 Ok(r) => {
266 let stderr = r.stderr.trim().to_string();
267 let msg = friendly_container_error(&stderr, r.status.code());
268 error!("[external] Container fetch failed: alias={alias}: {msg}");
269 Err(ContainerError {
270 runtime: cached_runtime,
271 message: msg,
272 })
273 }
274 Err(e) => {
275 error!("[external] Container fetch failed: alias={alias}: {e}");
276 Err(ContainerError {
277 runtime: cached_runtime,
278 message: e.to_string(),
279 })
280 }
281 }
282}
283
284pub fn spawn_container_listing<F>(
287 ctx: OwnedSshContext,
288 cached_runtime: Option<ContainerRuntime>,
289 send: F,
290) where
291 F: FnOnce(String, Result<(ContainerRuntime, Vec<ContainerInfo>), ContainerError>)
292 + Send
293 + 'static,
294{
295 std::thread::spawn(move || {
296 let borrowed = SshContext {
297 alias: &ctx.alias,
298 config_path: &ctx.config_path,
299 askpass: ctx.askpass.as_deref(),
300 bw_session: ctx.bw_session.as_deref(),
301 has_tunnel: ctx.has_tunnel,
302 };
303 let result = fetch_containers(&borrowed, cached_runtime);
304 send(ctx.alias, result);
305 });
306}
307
308pub fn spawn_container_action<F>(
311 ctx: OwnedSshContext,
312 runtime: ContainerRuntime,
313 action: ContainerAction,
314 container_id: String,
315 send: F,
316) where
317 F: FnOnce(String, ContainerAction, Result<(), String>) + Send + 'static,
318{
319 std::thread::spawn(move || {
320 if let Err(e) = validate_container_id(&container_id) {
321 send(ctx.alias, action, Err(e));
322 return;
323 }
324 let alias = &ctx.alias;
325 info!(
326 "Container action: {} container={container_id} alias={alias}",
327 action.as_str()
328 );
329 let command = container_action_command(runtime, action, &container_id);
330 let result = crate::snippet::run_snippet(
331 alias,
332 &ctx.config_path,
333 &command,
334 ctx.askpass.as_deref(),
335 ctx.bw_session.as_deref(),
336 true,
337 ctx.has_tunnel,
338 );
339 match result {
340 Ok(r) if r.status.success() => send(ctx.alias, action, Ok(())),
341 Ok(r) => {
342 let err = friendly_container_error(r.stderr.trim(), r.status.code());
343 error!(
344 "[external] Container {} failed: alias={alias} container={container_id}: {err}",
345 action.as_str()
346 );
347 send(ctx.alias, action, Err(err));
348 }
349 Err(e) => {
350 error!(
351 "[external] Container {} failed: alias={alias} container={container_id}: {e}",
352 action.as_str()
353 );
354 send(ctx.alias, action, Err(e.to_string()));
355 }
356 }
357 });
358}
359
360#[derive(Debug, Clone)]
366pub struct ContainerCacheEntry {
367 pub timestamp: u64,
368 pub runtime: ContainerRuntime,
369 pub containers: Vec<ContainerInfo>,
370}
371
372#[derive(Serialize, Deserialize)]
374struct CacheLine {
375 alias: String,
376 timestamp: u64,
377 runtime: ContainerRuntime,
378 containers: Vec<ContainerInfo>,
379}
380
381pub fn load_container_cache() -> HashMap<String, ContainerCacheEntry> {
384 let mut map = HashMap::new();
385 let Some(home) = dirs::home_dir() else {
386 return map;
387 };
388 let path = home.join(".purple").join("container_cache.jsonl");
389 let Ok(content) = std::fs::read_to_string(&path) else {
390 return map;
391 };
392 for line in content.lines() {
393 let trimmed = line.trim();
394 if trimmed.is_empty() {
395 continue;
396 }
397 if let Ok(entry) = serde_json::from_str::<CacheLine>(trimmed) {
398 map.insert(
399 entry.alias,
400 ContainerCacheEntry {
401 timestamp: entry.timestamp,
402 runtime: entry.runtime,
403 containers: entry.containers,
404 },
405 );
406 }
407 }
408 map
409}
410
411pub fn parse_container_cache_content(content: &str) -> HashMap<String, ContainerCacheEntry> {
413 let mut map = HashMap::new();
414 for line in content.lines() {
415 let trimmed = line.trim();
416 if trimmed.is_empty() {
417 continue;
418 }
419 if let Ok(entry) = serde_json::from_str::<CacheLine>(trimmed) {
420 map.insert(
421 entry.alias,
422 ContainerCacheEntry {
423 timestamp: entry.timestamp,
424 runtime: entry.runtime,
425 containers: entry.containers,
426 },
427 );
428 }
429 }
430 map
431}
432
433pub fn save_container_cache(cache: &HashMap<String, ContainerCacheEntry>) {
435 if crate::demo_flag::is_demo() {
436 return;
437 }
438 let Some(home) = dirs::home_dir() else {
439 return;
440 };
441 let path = home.join(".purple").join("container_cache.jsonl");
442 let mut lines = Vec::with_capacity(cache.len());
443 for (alias, entry) in cache {
444 let line = CacheLine {
445 alias: alias.clone(),
446 timestamp: entry.timestamp,
447 runtime: entry.runtime,
448 containers: entry.containers.clone(),
449 };
450 if let Ok(s) = serde_json::to_string(&line) {
451 lines.push(s);
452 }
453 }
454 let content = lines.join("\n");
455 if let Err(e) = crate::fs_util::atomic_write(&path, content.as_bytes()) {
456 log::warn!(
457 "[config] Failed to write container cache {}: {e}",
458 path.display()
459 );
460 }
461}
462
463pub fn truncate_str(s: &str, max: usize) -> String {
469 let count = s.chars().count();
470 if count <= max {
471 s.to_string()
472 } else {
473 let cut = max.saturating_sub(2);
474 let end = s.char_indices().nth(cut).map(|(i, _)| i).unwrap_or(s.len());
475 format!("{}..", &s[..end])
476 }
477}
478
479pub fn format_relative_time(timestamp: u64) -> String {
485 let now = SystemTime::now()
486 .duration_since(UNIX_EPOCH)
487 .unwrap_or_default()
488 .as_secs();
489 let diff = now.saturating_sub(timestamp);
490 if diff < 60 {
491 "just now".to_string()
492 } else if diff < 3600 {
493 format!("{}m ago", diff / 60)
494 } else if diff < 86400 {
495 format!("{}h ago", diff / 3600)
496 } else {
497 format!("{}d ago", diff / 86400)
498 }
499}
500
501#[cfg(test)]
506#[path = "containers_tests.rs"]
507mod tests;