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