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