Skip to main content

purple_ssh/
containers.rs

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// ---------------------------------------------------------------------------
10// ContainerInfo model
11// ---------------------------------------------------------------------------
12
13/// Metadata for a single container (from `docker ps -a` / `podman ps -a`).
14#[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
30/// Parse NDJSON output from `docker ps --format '{{json .}}'`.
31/// Invalid lines are silently ignored (MOTD lines, blank lines, etc.).
32pub 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// ---------------------------------------------------------------------------
46// ContainerRuntime
47// ---------------------------------------------------------------------------
48
49/// Supported container runtimes.
50#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
51pub enum ContainerRuntime {
52    Docker,
53    Podman,
54}
55
56impl ContainerRuntime {
57    /// Returns the CLI binary name.
58    pub fn as_str(&self) -> &'static str {
59        match self {
60            ContainerRuntime::Docker => "docker",
61            ContainerRuntime::Podman => "podman",
62        }
63    }
64}
65
66/// Detect runtime from command output by matching the LAST non-empty trimmed
67/// line. Only "docker" or "podman" are accepted. MOTD-resilient.
68/// Currently unused (sentinel-based detection handles this inline) but kept
69/// as a public utility for potential future two-step detection paths.
70#[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// ---------------------------------------------------------------------------
85// ContainerAction
86// ---------------------------------------------------------------------------
87
88/// Actions that can be performed on a container.
89#[derive(Copy, Clone, Debug, PartialEq)]
90pub enum ContainerAction {
91    Start,
92    Stop,
93    Restart,
94}
95
96impl ContainerAction {
97    /// Returns the CLI sub-command string.
98    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
107/// Build the shell command to perform an action on a container.
108pub 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
116// ---------------------------------------------------------------------------
117// Container ID validation
118// ---------------------------------------------------------------------------
119
120/// Validate a container ID or name.
121/// Accepts ASCII alphanumeric, hyphen, underscore, dot.
122/// Rejects empty, non-ASCII, shell metacharacters, colon.
123pub 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
135// ---------------------------------------------------------------------------
136// Combined SSH command + output parsing
137// ---------------------------------------------------------------------------
138
139/// Build the SSH command string for listing containers.
140///
141/// - `Some(Docker)` / `Some(Podman)`: direct listing for the known runtime.
142/// - `None`: combined detection + listing with sentinel markers in one SSH call.
143pub 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
158/// Parse the stdout of a container listing command.
159///
160/// When sentinels are present (combined detection run): extract runtime from
161/// the sentinel line, parse remaining lines as NDJSON. When `caller_runtime`
162/// is provided (subsequent run with known runtime): parse all lines as NDJSON.
163pub 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// ---------------------------------------------------------------------------
200// SSH fetch functions
201// ---------------------------------------------------------------------------
202
203/// Error from a container listing operation. Preserves the detected runtime
204/// even when the `ps` command fails so it can be cached for future calls.
205#[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
217/// Translate SSH stderr into a user-friendly error message.
218fn 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/// Fetch container list synchronously via SSH.
238/// Follows the `fetch_remote_listing` pattern.
239#[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/// Spawn a background thread to fetch container listings.
288/// Follows the `spawn_remote_listing` pattern.
289#[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/// Spawn a background thread to perform a container action (start/stop/restart).
317/// Validates the container ID before executing.
318#[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// ---------------------------------------------------------------------------
373// JSON lines cache
374// ---------------------------------------------------------------------------
375
376/// A cached container listing for a single host.
377#[derive(Debug, Clone)]
378pub struct ContainerCacheEntry {
379    pub timestamp: u64,
380    pub runtime: ContainerRuntime,
381    pub containers: Vec<ContainerInfo>,
382}
383
384/// Serde helper for a single JSON line in the cache file.
385#[derive(Serialize, Deserialize)]
386struct CacheLine {
387    alias: String,
388    timestamp: u64,
389    runtime: ContainerRuntime,
390    containers: Vec<ContainerInfo>,
391}
392
393/// Load container cache from `~/.purple/container_cache.jsonl`.
394/// Malformed lines are silently ignored. Duplicate aliases: last-write-wins.
395pub 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
423/// Parse container cache from JSONL content string (for demo/test use).
424pub 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
445/// Save container cache to `~/.purple/container_cache.jsonl` via atomic write.
446pub 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    let _ = crate::fs_util::atomic_write(&path, content.as_bytes());
468}
469
470// ---------------------------------------------------------------------------
471// String truncation
472// ---------------------------------------------------------------------------
473
474/// Truncate a string to at most `max` characters. Appends ".." if truncated.
475pub fn truncate_str(s: &str, max: usize) -> String {
476    let count = s.chars().count();
477    if count <= max {
478        s.to_string()
479    } else {
480        let cut = max.saturating_sub(2);
481        let end = s.char_indices().nth(cut).map(|(i, _)| i).unwrap_or(s.len());
482        format!("{}..", &s[..end])
483    }
484}
485
486// ---------------------------------------------------------------------------
487// Relative time
488// ---------------------------------------------------------------------------
489
490/// Format a Unix timestamp as a human-readable relative time string.
491pub fn format_relative_time(timestamp: u64) -> String {
492    let now = SystemTime::now()
493        .duration_since(UNIX_EPOCH)
494        .unwrap_or_default()
495        .as_secs();
496    let diff = now.saturating_sub(timestamp);
497    if diff < 60 {
498        "just now".to_string()
499    } else if diff < 3600 {
500        format!("{}m ago", diff / 60)
501    } else if diff < 86400 {
502        format!("{}h ago", diff / 3600)
503    } else {
504        format!("{}d ago", diff / 86400)
505    }
506}
507
508// ---------------------------------------------------------------------------
509// Tests
510// ---------------------------------------------------------------------------
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    fn make_json(
517        id: &str,
518        names: &str,
519        image: &str,
520        state: &str,
521        status: &str,
522        ports: &str,
523    ) -> String {
524        serde_json::json!({
525            "ID": id,
526            "Names": names,
527            "Image": image,
528            "State": state,
529            "Status": status,
530            "Ports": ports,
531        })
532        .to_string()
533    }
534
535    // -- parse_container_ps --------------------------------------------------
536
537    #[test]
538    fn parse_ps_empty() {
539        assert!(parse_container_ps("").is_empty());
540        assert!(parse_container_ps("   \n  \n").is_empty());
541    }
542
543    #[test]
544    fn parse_ps_single() {
545        let line = make_json("abc", "web", "nginx:latest", "running", "Up 2h", "80/tcp");
546        let r = parse_container_ps(&line);
547        assert_eq!(r.len(), 1);
548        assert_eq!(r[0].id, "abc");
549        assert_eq!(r[0].names, "web");
550        assert_eq!(r[0].image, "nginx:latest");
551        assert_eq!(r[0].state, "running");
552    }
553
554    #[test]
555    fn parse_ps_multiple() {
556        let lines = [
557            make_json("a", "web", "nginx", "running", "Up", "80/tcp"),
558            make_json("b", "db", "postgres", "exited", "Exited (0)", ""),
559        ];
560        let r = parse_container_ps(&lines.join("\n"));
561        assert_eq!(r.len(), 2);
562    }
563
564    #[test]
565    fn parse_ps_invalid_lines_ignored() {
566        let valid = make_json("x", "c", "i", "running", "Up", "");
567        let input = format!("garbage\n{valid}\nalso bad");
568        assert_eq!(parse_container_ps(&input).len(), 1);
569    }
570
571    #[test]
572    fn parse_ps_all_docker_states() {
573        for state in [
574            "created",
575            "restarting",
576            "running",
577            "removing",
578            "paused",
579            "exited",
580            "dead",
581        ] {
582            let line = make_json("id", "c", "img", state, "s", "");
583            let r = parse_container_ps(&line);
584            assert_eq!(r[0].state, state, "failed for {state}");
585        }
586    }
587
588    #[test]
589    fn parse_ps_compose_names() {
590        let line = make_json("a", "myproject-redis-1", "redis:7", "running", "Up", "");
591        assert_eq!(parse_container_ps(&line)[0].names, "myproject-redis-1");
592    }
593
594    #[test]
595    fn parse_ps_sha256_image() {
596        let line = make_json("a", "app", "sha256:abcdef123456", "running", "Up", "");
597        assert!(parse_container_ps(&line)[0].image.starts_with("sha256:"));
598    }
599
600    #[test]
601    fn parse_ps_long_ports() {
602        let ports = "0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp, :::80->80/tcp";
603        let line = make_json("a", "proxy", "nginx", "running", "Up", ports);
604        assert_eq!(parse_container_ps(&line)[0].ports, ports);
605    }
606
607    // -- parse_runtime -------------------------------------------------------
608
609    #[test]
610    fn runtime_docker() {
611        assert_eq!(parse_runtime("docker"), Some(ContainerRuntime::Docker));
612    }
613
614    #[test]
615    fn runtime_podman() {
616        assert_eq!(parse_runtime("podman"), Some(ContainerRuntime::Podman));
617    }
618
619    #[test]
620    fn runtime_none() {
621        assert_eq!(parse_runtime(""), None);
622        assert_eq!(parse_runtime("   "), None);
623        assert_eq!(parse_runtime("unknown"), None);
624        assert_eq!(parse_runtime("Docker"), None); // case sensitive
625    }
626
627    #[test]
628    fn runtime_motd_prepended() {
629        let input = "Welcome to Ubuntu 22.04\nSystem info\ndocker";
630        assert_eq!(parse_runtime(input), Some(ContainerRuntime::Docker));
631    }
632
633    #[test]
634    fn runtime_trailing_whitespace() {
635        assert_eq!(parse_runtime("docker  "), Some(ContainerRuntime::Docker));
636        assert_eq!(parse_runtime("podman\t"), Some(ContainerRuntime::Podman));
637    }
638
639    #[test]
640    fn runtime_motd_after_output() {
641        let input = "docker\nSystem update available.";
642        // Last non-empty line is "System update available." which is not a runtime
643        assert_eq!(parse_runtime(input), None);
644    }
645
646    // -- ContainerAction x ContainerRuntime ----------------------------------
647
648    #[test]
649    fn action_command_all_combinations() {
650        let cases = [
651            (
652                ContainerRuntime::Docker,
653                ContainerAction::Start,
654                "docker start c1",
655            ),
656            (
657                ContainerRuntime::Docker,
658                ContainerAction::Stop,
659                "docker stop c1",
660            ),
661            (
662                ContainerRuntime::Docker,
663                ContainerAction::Restart,
664                "docker restart c1",
665            ),
666            (
667                ContainerRuntime::Podman,
668                ContainerAction::Start,
669                "podman start c1",
670            ),
671            (
672                ContainerRuntime::Podman,
673                ContainerAction::Stop,
674                "podman stop c1",
675            ),
676            (
677                ContainerRuntime::Podman,
678                ContainerAction::Restart,
679                "podman restart c1",
680            ),
681        ];
682        for (rt, action, expected) in cases {
683            assert_eq!(container_action_command(rt, action, "c1"), expected);
684        }
685    }
686
687    #[test]
688    fn action_as_str() {
689        assert_eq!(ContainerAction::Start.as_str(), "start");
690        assert_eq!(ContainerAction::Stop.as_str(), "stop");
691        assert_eq!(ContainerAction::Restart.as_str(), "restart");
692    }
693
694    #[test]
695    fn runtime_as_str() {
696        assert_eq!(ContainerRuntime::Docker.as_str(), "docker");
697        assert_eq!(ContainerRuntime::Podman.as_str(), "podman");
698    }
699
700    // -- validate_container_id -----------------------------------------------
701
702    #[test]
703    fn id_valid_hex() {
704        assert!(validate_container_id("a1b2c3d4e5f6").is_ok());
705    }
706
707    #[test]
708    fn id_valid_names() {
709        assert!(validate_container_id("myapp").is_ok());
710        assert!(validate_container_id("my-app").is_ok());
711        assert!(validate_container_id("my_app").is_ok());
712        assert!(validate_container_id("my.app").is_ok());
713        assert!(validate_container_id("myproject-web-1").is_ok());
714    }
715
716    #[test]
717    fn id_empty() {
718        assert!(validate_container_id("").is_err());
719    }
720
721    #[test]
722    fn id_space() {
723        assert!(validate_container_id("my app").is_err());
724    }
725
726    #[test]
727    fn id_newline() {
728        assert!(validate_container_id("app\n").is_err());
729    }
730
731    #[test]
732    fn id_injection_semicolon() {
733        assert!(validate_container_id("app;rm -rf /").is_err());
734    }
735
736    #[test]
737    fn id_injection_pipe() {
738        assert!(validate_container_id("app|cat /etc/passwd").is_err());
739    }
740
741    #[test]
742    fn id_injection_dollar() {
743        assert!(validate_container_id("app$HOME").is_err());
744    }
745
746    #[test]
747    fn id_injection_backtick() {
748        assert!(validate_container_id("app`whoami`").is_err());
749    }
750
751    #[test]
752    fn id_unicode_rejected() {
753        assert!(validate_container_id("app\u{00e9}").is_err());
754        assert!(validate_container_id("\u{0430}pp").is_err()); // Cyrillic а
755    }
756
757    #[test]
758    fn id_colon_rejected() {
759        assert!(validate_container_id("app:latest").is_err());
760    }
761
762    // -- container_list_command ----------------------------------------------
763
764    #[test]
765    fn list_cmd_docker() {
766        assert_eq!(
767            container_list_command(Some(ContainerRuntime::Docker)),
768            "docker ps -a --format '{{json .}}'"
769        );
770    }
771
772    #[test]
773    fn list_cmd_podman() {
774        assert_eq!(
775            container_list_command(Some(ContainerRuntime::Podman)),
776            "podman ps -a --format '{{json .}}'"
777        );
778    }
779
780    #[test]
781    fn list_cmd_none_has_sentinels() {
782        let cmd = container_list_command(None);
783        assert!(cmd.contains("##purple:docker##"));
784        assert!(cmd.contains("##purple:podman##"));
785        assert!(cmd.contains("##purple:none##"));
786    }
787
788    #[test]
789    fn list_cmd_none_docker_first() {
790        let cmd = container_list_command(None);
791        let d = cmd.find("##purple:docker##").unwrap();
792        let p = cmd.find("##purple:podman##").unwrap();
793        assert!(d < p);
794    }
795
796    // -- parse_container_output ----------------------------------------------
797
798    #[test]
799    fn output_docker_sentinel() {
800        let c = make_json("abc", "web", "nginx", "running", "Up", "80/tcp");
801        let out = format!("##purple:docker##\n{c}");
802        let (rt, cs) = parse_container_output(&out, None).unwrap();
803        assert_eq!(rt, ContainerRuntime::Docker);
804        assert_eq!(cs.len(), 1);
805    }
806
807    #[test]
808    fn output_podman_sentinel() {
809        let c = make_json("xyz", "db", "pg", "exited", "Exited", "");
810        let out = format!("##purple:podman##\n{c}");
811        let (rt, _) = parse_container_output(&out, None).unwrap();
812        assert_eq!(rt, ContainerRuntime::Podman);
813    }
814
815    #[test]
816    fn output_none_sentinel() {
817        let r = parse_container_output("##purple:none##", None);
818        assert!(r.is_err());
819        assert!(r.unwrap_err().contains("No container runtime"));
820    }
821
822    #[test]
823    fn output_no_sentinel_with_caller() {
824        let c = make_json("a", "app", "img", "running", "Up", "");
825        let (rt, cs) = parse_container_output(&c, Some(ContainerRuntime::Docker)).unwrap();
826        assert_eq!(rt, ContainerRuntime::Docker);
827        assert_eq!(cs.len(), 1);
828    }
829
830    #[test]
831    fn output_no_sentinel_no_caller() {
832        let c = make_json("a", "app", "img", "running", "Up", "");
833        assert!(parse_container_output(&c, None).is_err());
834    }
835
836    #[test]
837    fn output_motd_before_sentinel() {
838        let c = make_json("a", "app", "img", "running", "Up", "");
839        let out = format!("Welcome to server\nInfo line\n##purple:docker##\n{c}");
840        let (rt, cs) = parse_container_output(&out, None).unwrap();
841        assert_eq!(rt, ContainerRuntime::Docker);
842        assert_eq!(cs.len(), 1);
843    }
844
845    #[test]
846    fn output_empty_container_list() {
847        let (rt, cs) = parse_container_output("##purple:docker##\n", None).unwrap();
848        assert_eq!(rt, ContainerRuntime::Docker);
849        assert!(cs.is_empty());
850    }
851
852    #[test]
853    fn output_multiple_containers() {
854        let c1 = make_json("a", "web", "nginx", "running", "Up", "80/tcp");
855        let c2 = make_json("b", "db", "pg", "exited", "Exited", "");
856        let c3 = make_json("c", "cache", "redis", "running", "Up", "6379/tcp");
857        let out = format!("##purple:podman##\n{c1}\n{c2}\n{c3}");
858        let (_, cs) = parse_container_output(&out, None).unwrap();
859        assert_eq!(cs.len(), 3);
860    }
861
862    // -- friendly_container_error --------------------------------------------
863
864    #[test]
865    fn friendly_error_command_not_found() {
866        let msg = friendly_container_error("bash: docker: command not found", Some(127));
867        assert_eq!(msg, "Docker or Podman not found on remote host.");
868    }
869
870    #[test]
871    fn friendly_error_permission_denied() {
872        let msg = friendly_container_error(
873            "Got permission denied while trying to connect to the Docker daemon socket",
874            Some(1),
875        );
876        assert_eq!(msg, "Permission denied. Is your user in the docker group?");
877    }
878
879    #[test]
880    fn friendly_error_daemon_not_running() {
881        let msg = friendly_container_error(
882            "Cannot connect to the Docker daemon at unix:///var/run/docker.sock",
883            Some(1),
884        );
885        assert_eq!(msg, "Container daemon is not running.");
886    }
887
888    #[test]
889    fn friendly_error_connection_refused() {
890        let msg = friendly_container_error("ssh: connect to host: Connection refused", Some(255));
891        assert_eq!(msg, "Connection refused.");
892    }
893
894    #[test]
895    fn friendly_error_empty_stderr() {
896        let msg = friendly_container_error("", Some(1));
897        assert_eq!(msg, "Command failed with code 1.");
898    }
899
900    #[test]
901    fn friendly_error_unknown_stderr_uses_generic_message() {
902        let msg = friendly_container_error("some unknown error", Some(1));
903        assert_eq!(msg, "Command failed with code 1.");
904    }
905
906    // -- cache serialization -------------------------------------------------
907
908    #[test]
909    fn cache_round_trip() {
910        let line = CacheLine {
911            alias: "web1".to_string(),
912            timestamp: 1_700_000_000,
913            runtime: ContainerRuntime::Docker,
914            containers: vec![ContainerInfo {
915                id: "abc".to_string(),
916                names: "nginx".to_string(),
917                image: "nginx:latest".to_string(),
918                state: "running".to_string(),
919                status: "Up 2h".to_string(),
920                ports: "80/tcp".to_string(),
921            }],
922        };
923        let s = serde_json::to_string(&line).unwrap();
924        let d: CacheLine = serde_json::from_str(&s).unwrap();
925        assert_eq!(d.alias, "web1");
926        assert_eq!(d.runtime, ContainerRuntime::Docker);
927        assert_eq!(d.containers.len(), 1);
928        assert_eq!(d.containers[0].id, "abc");
929    }
930
931    #[test]
932    fn cache_round_trip_podman() {
933        let line = CacheLine {
934            alias: "host2".to_string(),
935            timestamp: 200,
936            runtime: ContainerRuntime::Podman,
937            containers: vec![],
938        };
939        let s = serde_json::to_string(&line).unwrap();
940        let d: CacheLine = serde_json::from_str(&s).unwrap();
941        assert_eq!(d.runtime, ContainerRuntime::Podman);
942    }
943
944    #[test]
945    fn cache_parse_empty() {
946        let map: HashMap<String, ContainerCacheEntry> =
947            "".lines().filter_map(parse_cache_line).collect();
948        assert!(map.is_empty());
949    }
950
951    #[test]
952    fn cache_parse_malformed_ignored() {
953        let valid = serde_json::to_string(&CacheLine {
954            alias: "good".to_string(),
955            timestamp: 1,
956            runtime: ContainerRuntime::Docker,
957            containers: vec![],
958        })
959        .unwrap();
960        let content = format!("garbage\n{valid}\nalso bad");
961        let map: HashMap<String, ContainerCacheEntry> =
962            content.lines().filter_map(parse_cache_line).collect();
963        assert_eq!(map.len(), 1);
964        assert!(map.contains_key("good"));
965    }
966
967    #[test]
968    fn cache_parse_multiple_hosts() {
969        let lines: Vec<String> = ["h1", "h2", "h3"]
970            .iter()
971            .enumerate()
972            .map(|(i, alias)| {
973                serde_json::to_string(&CacheLine {
974                    alias: alias.to_string(),
975                    timestamp: i as u64,
976                    runtime: ContainerRuntime::Docker,
977                    containers: vec![],
978                })
979                .unwrap()
980            })
981            .collect();
982        let content = lines.join("\n");
983        let map: HashMap<String, ContainerCacheEntry> =
984            content.lines().filter_map(parse_cache_line).collect();
985        assert_eq!(map.len(), 3);
986    }
987
988    /// Helper: parse a single cache line (mirrors load_container_cache logic).
989    fn parse_cache_line(line: &str) -> Option<(String, ContainerCacheEntry)> {
990        let t = line.trim();
991        if t.is_empty() {
992            return None;
993        }
994        let entry: CacheLine = serde_json::from_str(t).ok()?;
995        Some((
996            entry.alias,
997            ContainerCacheEntry {
998                timestamp: entry.timestamp,
999                runtime: entry.runtime,
1000                containers: entry.containers,
1001            },
1002        ))
1003    }
1004
1005    // -- truncate_str --------------------------------------------------------
1006
1007    #[test]
1008    fn truncate_short() {
1009        assert_eq!(truncate_str("hi", 10), "hi");
1010    }
1011
1012    #[test]
1013    fn truncate_exact() {
1014        assert_eq!(truncate_str("hello", 5), "hello");
1015    }
1016
1017    #[test]
1018    fn truncate_long() {
1019        assert_eq!(truncate_str("hello world", 7), "hello..");
1020    }
1021
1022    #[test]
1023    fn truncate_empty() {
1024        assert_eq!(truncate_str("", 5), "");
1025    }
1026
1027    #[test]
1028    fn truncate_max_two() {
1029        assert_eq!(truncate_str("hello", 2), "..");
1030    }
1031
1032    #[test]
1033    fn truncate_multibyte() {
1034        assert_eq!(truncate_str("café-app", 6), "café..");
1035    }
1036
1037    #[test]
1038    fn truncate_emoji() {
1039        assert_eq!(truncate_str("🐳nginx", 5), "🐳ng..");
1040    }
1041
1042    // -- format_relative_time ------------------------------------------------
1043
1044    fn now_secs() -> u64 {
1045        SystemTime::now()
1046            .duration_since(UNIX_EPOCH)
1047            .unwrap()
1048            .as_secs()
1049    }
1050
1051    #[test]
1052    fn relative_just_now() {
1053        assert_eq!(format_relative_time(now_secs()), "just now");
1054        assert_eq!(format_relative_time(now_secs() - 30), "just now");
1055        assert_eq!(format_relative_time(now_secs() - 59), "just now");
1056    }
1057
1058    #[test]
1059    fn relative_minutes() {
1060        assert_eq!(format_relative_time(now_secs() - 60), "1m ago");
1061        assert_eq!(format_relative_time(now_secs() - 300), "5m ago");
1062        assert_eq!(format_relative_time(now_secs() - 3599), "59m ago");
1063    }
1064
1065    #[test]
1066    fn relative_hours() {
1067        assert_eq!(format_relative_time(now_secs() - 3600), "1h ago");
1068        assert_eq!(format_relative_time(now_secs() - 7200), "2h ago");
1069    }
1070
1071    #[test]
1072    fn relative_days() {
1073        assert_eq!(format_relative_time(now_secs() - 86400), "1d ago");
1074        assert_eq!(format_relative_time(now_secs() - 7 * 86400), "7d ago");
1075    }
1076
1077    #[test]
1078    fn relative_future_saturates() {
1079        assert_eq!(format_relative_time(now_secs() + 10000), "just now");
1080    }
1081
1082    // -- Additional edge-case tests -------------------------------------------
1083
1084    #[test]
1085    fn parse_ps_whitespace_only_lines_between_json() {
1086        let c1 = make_json("a", "web", "nginx", "running", "Up", "");
1087        let c2 = make_json("b", "db", "pg", "exited", "Exited", "");
1088        let input = format!("{c1}\n   \n\t\n{c2}");
1089        let r = parse_container_ps(&input);
1090        assert_eq!(r.len(), 2);
1091        assert_eq!(r[0].id, "a");
1092        assert_eq!(r[1].id, "b");
1093    }
1094
1095    #[test]
1096    fn id_just_dot() {
1097        assert!(validate_container_id(".").is_ok());
1098    }
1099
1100    #[test]
1101    fn id_just_dash() {
1102        assert!(validate_container_id("-").is_ok());
1103    }
1104
1105    #[test]
1106    fn id_slash_rejected() {
1107        assert!(validate_container_id("my/container").is_err());
1108    }
1109
1110    #[test]
1111    fn list_cmd_none_valid_shell_syntax() {
1112        let cmd = container_list_command(None);
1113        assert!(cmd.contains("if "), "should start with if");
1114        assert!(cmd.contains("fi"), "should end with fi");
1115        assert!(cmd.contains("elif "), "should have elif fallback");
1116        assert!(cmd.contains("else "), "should have else branch");
1117    }
1118
1119    #[test]
1120    fn output_sentinel_on_last_line() {
1121        let r = parse_container_output("some MOTD\n##purple:docker##", None);
1122        let (rt, cs) = r.unwrap();
1123        assert_eq!(rt, ContainerRuntime::Docker);
1124        assert!(cs.is_empty());
1125    }
1126
1127    #[test]
1128    fn output_sentinel_none_on_last_line() {
1129        let r = parse_container_output("MOTD line\n##purple:none##", None);
1130        assert!(r.is_err());
1131        assert!(r.unwrap_err().contains("No container runtime"));
1132    }
1133
1134    #[test]
1135    fn relative_time_unix_epoch() {
1136        // Timestamp 0 is decades ago, should show many days
1137        let result = format_relative_time(0);
1138        assert!(
1139            result.contains("d ago"),
1140            "epoch should be days ago: {result}"
1141        );
1142    }
1143
1144    #[test]
1145    fn truncate_unicode_within_limit() {
1146        // 3-byte chars but total byte len 9 > max 5, yet char count is 3
1147        // truncate_str uses byte length so this string of 3 chars (9 bytes) > max 5
1148        assert_eq!(truncate_str("abc", 5), "abc"); // ASCII fits
1149    }
1150
1151    #[test]
1152    fn truncate_ascii_boundary() {
1153        // Ensure max=0 does not panic
1154        assert_eq!(truncate_str("hello", 0), "..");
1155    }
1156
1157    #[test]
1158    fn truncate_max_one() {
1159        assert_eq!(truncate_str("hello", 1), "..");
1160    }
1161
1162    #[test]
1163    fn cache_serde_unknown_runtime_rejected() {
1164        let json = r#"{"alias":"h","timestamp":1,"runtime":"Containerd","containers":[]}"#;
1165        let result = serde_json::from_str::<CacheLine>(json);
1166        assert!(result.is_err(), "unknown runtime should be rejected");
1167    }
1168
1169    #[test]
1170    fn cache_duplicate_alias_last_wins() {
1171        let line1 = serde_json::to_string(&CacheLine {
1172            alias: "dup".to_string(),
1173            timestamp: 1,
1174            runtime: ContainerRuntime::Docker,
1175            containers: vec![],
1176        })
1177        .unwrap();
1178        let line2 = serde_json::to_string(&CacheLine {
1179            alias: "dup".to_string(),
1180            timestamp: 99,
1181            runtime: ContainerRuntime::Podman,
1182            containers: vec![],
1183        })
1184        .unwrap();
1185        let content = format!("{line1}\n{line2}");
1186        let map: HashMap<String, ContainerCacheEntry> =
1187            content.lines().filter_map(parse_cache_line).collect();
1188        assert_eq!(map.len(), 1);
1189        // HashMap::from_iter keeps last for duplicate keys
1190        assert_eq!(map["dup"].runtime, ContainerRuntime::Podman);
1191        assert_eq!(map["dup"].timestamp, 99);
1192    }
1193
1194    #[test]
1195    fn friendly_error_no_route() {
1196        let msg = friendly_container_error("ssh: No route to host", Some(255));
1197        assert_eq!(msg, "Host unreachable.");
1198    }
1199
1200    #[test]
1201    fn friendly_error_network_unreachable() {
1202        let msg = friendly_container_error("connect: Network is unreachable", Some(255));
1203        assert_eq!(msg, "Host unreachable.");
1204    }
1205
1206    #[test]
1207    fn friendly_error_none_exit_code() {
1208        let msg = friendly_container_error("", None);
1209        assert_eq!(msg, "Command failed with code 1.");
1210    }
1211
1212    #[test]
1213    fn container_error_display() {
1214        let err = ContainerError {
1215            runtime: Some(ContainerRuntime::Docker),
1216            message: "test error".to_string(),
1217        };
1218        assert_eq!(format!("{err}"), "test error");
1219    }
1220
1221    #[test]
1222    fn container_error_display_no_runtime() {
1223        let err = ContainerError {
1224            runtime: None,
1225            message: "no runtime".to_string(),
1226        };
1227        assert_eq!(format!("{err}"), "no runtime");
1228    }
1229
1230    // -- Additional tests: parse_container_ps edge cases ----------------------
1231
1232    #[test]
1233    fn parse_ps_crlf_line_endings() {
1234        let c1 = make_json("a", "web", "nginx", "running", "Up", "");
1235        let c2 = make_json("b", "db", "pg", "exited", "Exited", "");
1236        let input = format!("{c1}\r\n{c2}\r\n");
1237        let r = parse_container_ps(&input);
1238        assert_eq!(r.len(), 2);
1239        assert_eq!(r[0].id, "a");
1240        assert_eq!(r[1].id, "b");
1241    }
1242
1243    #[test]
1244    fn parse_ps_trailing_newline() {
1245        let c = make_json("a", "web", "nginx", "running", "Up", "");
1246        let input = format!("{c}\n");
1247        let r = parse_container_ps(&input);
1248        assert_eq!(
1249            r.len(),
1250            1,
1251            "trailing newline should not create phantom entry"
1252        );
1253    }
1254
1255    #[test]
1256    fn parse_ps_leading_whitespace_json() {
1257        let c = make_json("a", "web", "nginx", "running", "Up", "");
1258        let input = format!("  {c}");
1259        let r = parse_container_ps(&input);
1260        assert_eq!(
1261            r.len(),
1262            1,
1263            "leading whitespace before JSON should be trimmed"
1264        );
1265        assert_eq!(r[0].id, "a");
1266    }
1267
1268    // -- Additional tests: parse_runtime edge cases ---------------------------
1269
1270    #[test]
1271    fn parse_runtime_empty_lines_between_motd() {
1272        let input = "Welcome\n\n\n\ndocker";
1273        assert_eq!(parse_runtime(input), Some(ContainerRuntime::Docker));
1274    }
1275
1276    #[test]
1277    fn parse_runtime_crlf() {
1278        let input = "MOTD\r\npodman\r\n";
1279        assert_eq!(parse_runtime(input), Some(ContainerRuntime::Podman));
1280    }
1281
1282    // -- Additional tests: parse_container_output edge cases ------------------
1283
1284    #[test]
1285    fn output_unknown_sentinel() {
1286        let r = parse_container_output("##purple:unknown##", None);
1287        assert!(r.is_err());
1288        let msg = r.unwrap_err();
1289        assert!(msg.contains("Unknown sentinel"), "got: {msg}");
1290    }
1291
1292    #[test]
1293    fn output_sentinel_with_crlf() {
1294        let c = make_json("a", "web", "nginx", "running", "Up", "");
1295        let input = format!("##purple:docker##\r\n{c}\r\n");
1296        let (rt, cs) = parse_container_output(&input, None).unwrap();
1297        assert_eq!(rt, ContainerRuntime::Docker);
1298        assert_eq!(cs.len(), 1);
1299    }
1300
1301    #[test]
1302    fn output_sentinel_indented() {
1303        let c = make_json("a", "web", "nginx", "running", "Up", "");
1304        let input = format!("  ##purple:docker##\n{c}");
1305        let (rt, cs) = parse_container_output(&input, None).unwrap();
1306        assert_eq!(rt, ContainerRuntime::Docker);
1307        assert_eq!(cs.len(), 1);
1308    }
1309
1310    #[test]
1311    fn output_caller_runtime_podman() {
1312        let c = make_json("a", "app", "img", "running", "Up", "");
1313        let (rt, cs) = parse_container_output(&c, Some(ContainerRuntime::Podman)).unwrap();
1314        assert_eq!(rt, ContainerRuntime::Podman);
1315        assert_eq!(cs.len(), 1);
1316    }
1317
1318    // -- Additional tests: container_action_command ---------------------------
1319
1320    #[test]
1321    fn action_command_long_id() {
1322        let long_id = "a".repeat(64);
1323        let cmd =
1324            container_action_command(ContainerRuntime::Docker, ContainerAction::Start, &long_id);
1325        assert_eq!(cmd, format!("docker start {long_id}"));
1326    }
1327
1328    // -- Additional tests: validate_container_id ------------------------------
1329
1330    #[test]
1331    fn id_full_sha256() {
1332        let id = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
1333        assert_eq!(id.len(), 64);
1334        assert!(validate_container_id(id).is_ok());
1335    }
1336
1337    #[test]
1338    fn id_ampersand_rejected() {
1339        assert!(validate_container_id("app&rm").is_err());
1340    }
1341
1342    #[test]
1343    fn id_parentheses_rejected() {
1344        assert!(validate_container_id("app(1)").is_err());
1345        assert!(validate_container_id("app)").is_err());
1346    }
1347
1348    #[test]
1349    fn id_angle_brackets_rejected() {
1350        assert!(validate_container_id("app<1>").is_err());
1351        assert!(validate_container_id("app>").is_err());
1352    }
1353
1354    // -- Additional tests: friendly_container_error ---------------------------
1355
1356    #[test]
1357    fn friendly_error_podman_daemon() {
1358        let msg = friendly_container_error("cannot connect to podman", Some(125));
1359        assert_eq!(msg, "Container daemon is not running.");
1360    }
1361
1362    #[test]
1363    fn friendly_error_case_insensitive() {
1364        let msg = friendly_container_error("PERMISSION DENIED", Some(1));
1365        assert_eq!(msg, "Permission denied. Is your user in the docker group?");
1366    }
1367
1368    // -- Additional tests: Copy traits ----------------------------------------
1369
1370    #[test]
1371    fn container_runtime_copy() {
1372        let a = ContainerRuntime::Docker;
1373        let b = a; // Copy
1374        assert_eq!(a, b); // both still usable
1375    }
1376
1377    #[test]
1378    fn container_action_copy() {
1379        let a = ContainerAction::Start;
1380        let b = a; // Copy
1381        assert_eq!(a, b); // both still usable
1382    }
1383
1384    // -- Additional tests: truncate_str edge cases ----------------------------
1385
1386    #[test]
1387    fn truncate_multibyte_utf8() {
1388        // "caf\u{00e9}-app" is 8 chars; truncating to 6 keeps "caf\u{00e9}" + ".."
1389        assert_eq!(truncate_str("caf\u{00e9}-app", 6), "caf\u{00e9}..");
1390    }
1391
1392    // -- Additional tests: format_relative_time boundaries --------------------
1393
1394    #[test]
1395    fn format_relative_time_boundary_60s() {
1396        let ts = now_secs() - 60;
1397        assert_eq!(format_relative_time(ts), "1m ago");
1398    }
1399
1400    #[test]
1401    fn format_relative_time_boundary_3600s() {
1402        let ts = now_secs() - 3600;
1403        assert_eq!(format_relative_time(ts), "1h ago");
1404    }
1405
1406    #[test]
1407    fn format_relative_time_boundary_86400s() {
1408        let ts = now_secs() - 86400;
1409        assert_eq!(format_relative_time(ts), "1d ago");
1410    }
1411
1412    // -- Additional tests: ContainerError Debug -------------------------------
1413
1414    #[test]
1415    fn container_error_debug() {
1416        let err = ContainerError {
1417            runtime: Some(ContainerRuntime::Docker),
1418            message: "test".to_string(),
1419        };
1420        let dbg = format!("{err:?}");
1421        assert!(
1422            dbg.contains("Docker"),
1423            "Debug should include runtime: {dbg}"
1424        );
1425        assert!(dbg.contains("test"), "Debug should include message: {dbg}");
1426    }
1427}