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 serde::{Deserialize, Serialize};
6
7// ---------------------------------------------------------------------------
8// ContainerInfo model
9// ---------------------------------------------------------------------------
10
11/// Metadata for a single container (from `docker ps -a` / `podman ps -a`).
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13pub struct ContainerInfo {
14    #[serde(rename = "ID")]
15    pub id: String,
16    #[serde(rename = "Names")]
17    pub names: String,
18    #[serde(rename = "Image")]
19    pub image: String,
20    #[serde(rename = "State")]
21    pub state: String,
22    #[serde(rename = "Status")]
23    pub status: String,
24    #[serde(rename = "Ports")]
25    pub ports: String,
26}
27
28/// Parse NDJSON output from `docker ps --format '{{json .}}'`.
29/// Invalid lines are silently ignored (MOTD lines, blank lines, etc.).
30pub fn parse_container_ps(output: &str) -> Vec<ContainerInfo> {
31    output
32        .lines()
33        .filter_map(|line| {
34            let trimmed = line.trim();
35            if trimmed.is_empty() {
36                return None;
37            }
38            serde_json::from_str(trimmed).ok()
39        })
40        .collect()
41}
42
43// ---------------------------------------------------------------------------
44// ContainerRuntime
45// ---------------------------------------------------------------------------
46
47/// Supported container runtimes.
48#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
49pub enum ContainerRuntime {
50    Docker,
51    Podman,
52}
53
54impl ContainerRuntime {
55    /// Returns the CLI binary name.
56    pub fn as_str(&self) -> &'static str {
57        match self {
58            ContainerRuntime::Docker => "docker",
59            ContainerRuntime::Podman => "podman",
60        }
61    }
62}
63
64/// Detect runtime from command output by matching the LAST non-empty trimmed
65/// line. Only "docker" or "podman" are accepted. MOTD-resilient.
66/// Currently unused (sentinel-based detection handles this inline) but kept
67/// as a public utility for potential future two-step detection paths.
68#[allow(dead_code)]
69pub fn parse_runtime(output: &str) -> Option<ContainerRuntime> {
70    let last = output
71        .lines()
72        .rev()
73        .map(|l| l.trim())
74        .find(|l| !l.is_empty())?;
75    match last {
76        "docker" => Some(ContainerRuntime::Docker),
77        "podman" => Some(ContainerRuntime::Podman),
78        _ => None,
79    }
80}
81
82// ---------------------------------------------------------------------------
83// ContainerAction
84// ---------------------------------------------------------------------------
85
86/// Actions that can be performed on a container.
87#[derive(Copy, Clone, Debug, PartialEq)]
88pub enum ContainerAction {
89    Start,
90    Stop,
91    Restart,
92}
93
94impl ContainerAction {
95    /// Returns the CLI sub-command string.
96    pub fn as_str(&self) -> &'static str {
97        match self {
98            ContainerAction::Start => "start",
99            ContainerAction::Stop => "stop",
100            ContainerAction::Restart => "restart",
101        }
102    }
103}
104
105/// Build the shell command to perform an action on a container.
106pub fn container_action_command(
107    runtime: ContainerRuntime,
108    action: ContainerAction,
109    container_id: &str,
110) -> String {
111    format!("{} {} {}", runtime.as_str(), action.as_str(), container_id)
112}
113
114// ---------------------------------------------------------------------------
115// Container ID validation
116// ---------------------------------------------------------------------------
117
118/// Validate a container ID or name.
119/// Accepts ASCII alphanumeric, hyphen, underscore, dot.
120/// Rejects empty, non-ASCII, shell metacharacters, colon.
121pub fn validate_container_id(id: &str) -> Result<(), String> {
122    if id.is_empty() {
123        return Err("Container ID must not be empty.".to_string());
124    }
125    for c in id.chars() {
126        if !c.is_ascii_alphanumeric() && c != '-' && c != '_' && c != '.' {
127            return Err(format!("Container ID contains invalid character: '{c}'"));
128        }
129    }
130    Ok(())
131}
132
133// ---------------------------------------------------------------------------
134// Combined SSH command + output parsing
135// ---------------------------------------------------------------------------
136
137/// Build the SSH command string for listing containers.
138///
139/// - `Some(Docker)` / `Some(Podman)`: direct listing for the known runtime.
140/// - `None`: combined detection + listing with sentinel markers in one SSH call.
141pub fn container_list_command(runtime: Option<ContainerRuntime>) -> String {
142    match runtime {
143        Some(ContainerRuntime::Docker) => "docker ps -a --format '{{json .}}'".to_string(),
144        Some(ContainerRuntime::Podman) => "podman ps -a --format '{{json .}}'".to_string(),
145        None => concat!(
146            "if command -v docker >/dev/null 2>&1; then ",
147            "echo '##purple:docker##' && docker ps -a --format '{{json .}}'; ",
148            "elif command -v podman >/dev/null 2>&1; then ",
149            "echo '##purple:podman##' && podman ps -a --format '{{json .}}'; ",
150            "else echo '##purple:none##'; fi"
151        )
152        .to_string(),
153    }
154}
155
156/// Parse the stdout of a container listing command.
157///
158/// When sentinels are present (combined detection run): extract runtime from
159/// the sentinel line, parse remaining lines as NDJSON. When `caller_runtime`
160/// is provided (subsequent run with known runtime): parse all lines as NDJSON.
161pub fn parse_container_output(
162    output: &str,
163    caller_runtime: Option<ContainerRuntime>,
164) -> Result<(ContainerRuntime, Vec<ContainerInfo>), String> {
165    if let Some(sentinel_line) = output.lines().find(|l| l.trim().starts_with("##purple:")) {
166        let sentinel = sentinel_line.trim();
167        if sentinel == "##purple:none##" {
168            return Err("No container runtime found. Install Docker or Podman.".to_string());
169        }
170        let runtime = if sentinel == "##purple:docker##" {
171            ContainerRuntime::Docker
172        } else if sentinel == "##purple:podman##" {
173            ContainerRuntime::Podman
174        } else {
175            return Err(format!("Unknown sentinel: {sentinel}"));
176        };
177        let containers: Vec<ContainerInfo> = output
178            .lines()
179            .filter(|l| !l.trim().starts_with("##purple:"))
180            .filter_map(|line| {
181                let t = line.trim();
182                if t.is_empty() {
183                    return None;
184                }
185                serde_json::from_str(t).ok()
186            })
187            .collect();
188        return Ok((runtime, containers));
189    }
190
191    match caller_runtime {
192        Some(rt) => Ok((rt, parse_container_ps(output))),
193        None => Err("No sentinel found and no runtime provided.".to_string()),
194    }
195}
196
197// ---------------------------------------------------------------------------
198// SSH fetch functions
199// ---------------------------------------------------------------------------
200
201/// Error from a container listing operation. Preserves the detected runtime
202/// even when the `ps` command fails so it can be cached for future calls.
203#[derive(Debug)]
204pub struct ContainerError {
205    pub runtime: Option<ContainerRuntime>,
206    pub message: String,
207}
208
209impl std::fmt::Display for ContainerError {
210    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211        write!(f, "{}", self.message)
212    }
213}
214
215/// Translate SSH stderr into a user-friendly error message.
216fn friendly_container_error(stderr: &str, code: Option<i32>) -> String {
217    let lower = stderr.to_lowercase();
218    if lower.contains("command not found") {
219        "Docker or Podman not found on remote host.".to_string()
220    } else if lower.contains("permission denied") || lower.contains("got permission denied") {
221        "Permission denied. Is your user in the docker group?".to_string()
222    } else if lower.contains("cannot connect to the docker daemon")
223        || lower.contains("cannot connect to podman")
224    {
225        "Container daemon is not running.".to_string()
226    } else if lower.contains("connection refused") {
227        "Connection refused.".to_string()
228    } else if lower.contains("no route to host") || lower.contains("network is unreachable") {
229        "Host unreachable.".to_string()
230    } else {
231        format!("Command failed with code {}.", code.unwrap_or(1))
232    }
233}
234
235/// Fetch container list synchronously via SSH.
236/// Follows the `fetch_remote_listing` pattern.
237#[allow(clippy::too_many_arguments)]
238pub fn fetch_containers(
239    alias: &str,
240    config_path: &Path,
241    askpass: Option<&str>,
242    bw_session: Option<&str>,
243    has_tunnel: bool,
244    cached_runtime: Option<ContainerRuntime>,
245) -> Result<(ContainerRuntime, Vec<ContainerInfo>), ContainerError> {
246    let command = container_list_command(cached_runtime);
247    let result = crate::snippet::run_snippet(
248        alias,
249        config_path,
250        &command,
251        askpass,
252        bw_session,
253        true,
254        has_tunnel,
255    );
256    match result {
257        Ok(r) if r.status.success() => {
258            parse_container_output(&r.stdout, cached_runtime).map_err(|e| ContainerError {
259                runtime: cached_runtime,
260                message: e,
261            })
262        }
263        Ok(r) => {
264            let stderr = r.stderr.trim().to_string();
265            let msg = friendly_container_error(&stderr, r.status.code());
266            Err(ContainerError {
267                runtime: cached_runtime,
268                message: msg,
269            })
270        }
271        Err(e) => Err(ContainerError {
272            runtime: cached_runtime,
273            message: e.to_string(),
274        }),
275    }
276}
277
278/// Spawn a background thread to fetch container listings.
279/// Follows the `spawn_remote_listing` pattern.
280#[allow(clippy::too_many_arguments)]
281pub fn spawn_container_listing<F>(
282    alias: String,
283    config_path: PathBuf,
284    askpass: Option<String>,
285    bw_session: Option<String>,
286    has_tunnel: bool,
287    cached_runtime: Option<ContainerRuntime>,
288    send: F,
289) where
290    F: FnOnce(String, Result<(ContainerRuntime, Vec<ContainerInfo>), ContainerError>)
291        + Send
292        + 'static,
293{
294    std::thread::spawn(move || {
295        let result = fetch_containers(
296            &alias,
297            &config_path,
298            askpass.as_deref(),
299            bw_session.as_deref(),
300            has_tunnel,
301            cached_runtime,
302        );
303        send(alias, result);
304    });
305}
306
307/// Spawn a background thread to perform a container action (start/stop/restart).
308/// Validates the container ID before executing.
309#[allow(clippy::too_many_arguments)]
310pub fn spawn_container_action<F>(
311    alias: String,
312    config_path: PathBuf,
313    runtime: ContainerRuntime,
314    action: ContainerAction,
315    container_id: String,
316    askpass: Option<String>,
317    bw_session: Option<String>,
318    has_tunnel: bool,
319    send: F,
320) where
321    F: FnOnce(String, ContainerAction, Result<(), String>) + Send + 'static,
322{
323    std::thread::spawn(move || {
324        if let Err(e) = validate_container_id(&container_id) {
325            send(alias, action, Err(e));
326            return;
327        }
328        let command = container_action_command(runtime, action, &container_id);
329        let result = crate::snippet::run_snippet(
330            &alias,
331            &config_path,
332            &command,
333            askpass.as_deref(),
334            bw_session.as_deref(),
335            true,
336            has_tunnel,
337        );
338        match result {
339            Ok(r) if r.status.success() => send(alias, action, Ok(())),
340            Ok(r) => {
341                let msg = friendly_container_error(r.stderr.trim(), r.status.code());
342                send(alias, action, Err(msg));
343            }
344            Err(e) => send(alias, action, Err(e.to_string())),
345        }
346    });
347}
348
349// ---------------------------------------------------------------------------
350// JSON lines cache
351// ---------------------------------------------------------------------------
352
353/// A cached container listing for a single host.
354#[derive(Debug, Clone)]
355pub struct ContainerCacheEntry {
356    pub timestamp: u64,
357    pub runtime: ContainerRuntime,
358    pub containers: Vec<ContainerInfo>,
359}
360
361/// Serde helper for a single JSON line in the cache file.
362#[derive(Serialize, Deserialize)]
363struct CacheLine {
364    alias: String,
365    timestamp: u64,
366    runtime: ContainerRuntime,
367    containers: Vec<ContainerInfo>,
368}
369
370/// Load container cache from `~/.purple/container_cache.jsonl`.
371/// Malformed lines are silently ignored. Duplicate aliases: last-write-wins.
372pub fn load_container_cache() -> HashMap<String, ContainerCacheEntry> {
373    let mut map = HashMap::new();
374    let Some(home) = dirs::home_dir() else {
375        return map;
376    };
377    let path = home.join(".purple").join("container_cache.jsonl");
378    let Ok(content) = std::fs::read_to_string(&path) else {
379        return map;
380    };
381    for line in content.lines() {
382        let trimmed = line.trim();
383        if trimmed.is_empty() {
384            continue;
385        }
386        if let Ok(entry) = serde_json::from_str::<CacheLine>(trimmed) {
387            map.insert(
388                entry.alias,
389                ContainerCacheEntry {
390                    timestamp: entry.timestamp,
391                    runtime: entry.runtime,
392                    containers: entry.containers,
393                },
394            );
395        }
396    }
397    map
398}
399
400/// Parse container cache from JSONL content string (for demo/test use).
401pub fn parse_container_cache_content(content: &str) -> HashMap<String, ContainerCacheEntry> {
402    let mut map = HashMap::new();
403    for line in content.lines() {
404        let trimmed = line.trim();
405        if trimmed.is_empty() {
406            continue;
407        }
408        if let Ok(entry) = serde_json::from_str::<CacheLine>(trimmed) {
409            map.insert(
410                entry.alias,
411                ContainerCacheEntry {
412                    timestamp: entry.timestamp,
413                    runtime: entry.runtime,
414                    containers: entry.containers,
415                },
416            );
417        }
418    }
419    map
420}
421
422/// Save container cache to `~/.purple/container_cache.jsonl` via atomic write.
423pub fn save_container_cache(cache: &HashMap<String, ContainerCacheEntry>) {
424    if crate::demo_flag::is_demo() {
425        return;
426    }
427    let Some(home) = dirs::home_dir() else {
428        return;
429    };
430    let path = home.join(".purple").join("container_cache.jsonl");
431    let mut lines = Vec::with_capacity(cache.len());
432    for (alias, entry) in cache {
433        let line = CacheLine {
434            alias: alias.clone(),
435            timestamp: entry.timestamp,
436            runtime: entry.runtime,
437            containers: entry.containers.clone(),
438        };
439        if let Ok(s) = serde_json::to_string(&line) {
440            lines.push(s);
441        }
442    }
443    let content = lines.join("\n");
444    let _ = crate::fs_util::atomic_write(&path, content.as_bytes());
445}
446
447// ---------------------------------------------------------------------------
448// String truncation
449// ---------------------------------------------------------------------------
450
451/// Truncate a string to at most `max` characters. Appends ".." if truncated.
452pub fn truncate_str(s: &str, max: usize) -> String {
453    let count = s.chars().count();
454    if count <= max {
455        s.to_string()
456    } else {
457        let cut = max.saturating_sub(2);
458        let end = s.char_indices().nth(cut).map(|(i, _)| i).unwrap_or(s.len());
459        format!("{}..", &s[..end])
460    }
461}
462
463// ---------------------------------------------------------------------------
464// Relative time
465// ---------------------------------------------------------------------------
466
467/// Format a Unix timestamp as a human-readable relative time string.
468pub fn format_relative_time(timestamp: u64) -> String {
469    let now = SystemTime::now()
470        .duration_since(UNIX_EPOCH)
471        .unwrap_or_default()
472        .as_secs();
473    let diff = now.saturating_sub(timestamp);
474    if diff < 60 {
475        "just now".to_string()
476    } else if diff < 3600 {
477        format!("{}m ago", diff / 60)
478    } else if diff < 86400 {
479        format!("{}h ago", diff / 3600)
480    } else {
481        format!("{}d ago", diff / 86400)
482    }
483}
484
485// ---------------------------------------------------------------------------
486// Tests
487// ---------------------------------------------------------------------------
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492
493    fn make_json(
494        id: &str,
495        names: &str,
496        image: &str,
497        state: &str,
498        status: &str,
499        ports: &str,
500    ) -> String {
501        serde_json::json!({
502            "ID": id,
503            "Names": names,
504            "Image": image,
505            "State": state,
506            "Status": status,
507            "Ports": ports,
508        })
509        .to_string()
510    }
511
512    // -- parse_container_ps --------------------------------------------------
513
514    #[test]
515    fn parse_ps_empty() {
516        assert!(parse_container_ps("").is_empty());
517        assert!(parse_container_ps("   \n  \n").is_empty());
518    }
519
520    #[test]
521    fn parse_ps_single() {
522        let line = make_json("abc", "web", "nginx:latest", "running", "Up 2h", "80/tcp");
523        let r = parse_container_ps(&line);
524        assert_eq!(r.len(), 1);
525        assert_eq!(r[0].id, "abc");
526        assert_eq!(r[0].names, "web");
527        assert_eq!(r[0].image, "nginx:latest");
528        assert_eq!(r[0].state, "running");
529    }
530
531    #[test]
532    fn parse_ps_multiple() {
533        let lines = [
534            make_json("a", "web", "nginx", "running", "Up", "80/tcp"),
535            make_json("b", "db", "postgres", "exited", "Exited (0)", ""),
536        ];
537        let r = parse_container_ps(&lines.join("\n"));
538        assert_eq!(r.len(), 2);
539    }
540
541    #[test]
542    fn parse_ps_invalid_lines_ignored() {
543        let valid = make_json("x", "c", "i", "running", "Up", "");
544        let input = format!("garbage\n{valid}\nalso bad");
545        assert_eq!(parse_container_ps(&input).len(), 1);
546    }
547
548    #[test]
549    fn parse_ps_all_docker_states() {
550        for state in [
551            "created",
552            "restarting",
553            "running",
554            "removing",
555            "paused",
556            "exited",
557            "dead",
558        ] {
559            let line = make_json("id", "c", "img", state, "s", "");
560            let r = parse_container_ps(&line);
561            assert_eq!(r[0].state, state, "failed for {state}");
562        }
563    }
564
565    #[test]
566    fn parse_ps_compose_names() {
567        let line = make_json("a", "myproject-redis-1", "redis:7", "running", "Up", "");
568        assert_eq!(parse_container_ps(&line)[0].names, "myproject-redis-1");
569    }
570
571    #[test]
572    fn parse_ps_sha256_image() {
573        let line = make_json("a", "app", "sha256:abcdef123456", "running", "Up", "");
574        assert!(parse_container_ps(&line)[0].image.starts_with("sha256:"));
575    }
576
577    #[test]
578    fn parse_ps_long_ports() {
579        let ports = "0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp, :::80->80/tcp";
580        let line = make_json("a", "proxy", "nginx", "running", "Up", ports);
581        assert_eq!(parse_container_ps(&line)[0].ports, ports);
582    }
583
584    // -- parse_runtime -------------------------------------------------------
585
586    #[test]
587    fn runtime_docker() {
588        assert_eq!(parse_runtime("docker"), Some(ContainerRuntime::Docker));
589    }
590
591    #[test]
592    fn runtime_podman() {
593        assert_eq!(parse_runtime("podman"), Some(ContainerRuntime::Podman));
594    }
595
596    #[test]
597    fn runtime_none() {
598        assert_eq!(parse_runtime(""), None);
599        assert_eq!(parse_runtime("   "), None);
600        assert_eq!(parse_runtime("unknown"), None);
601        assert_eq!(parse_runtime("Docker"), None); // case sensitive
602    }
603
604    #[test]
605    fn runtime_motd_prepended() {
606        let input = "Welcome to Ubuntu 22.04\nSystem info\ndocker";
607        assert_eq!(parse_runtime(input), Some(ContainerRuntime::Docker));
608    }
609
610    #[test]
611    fn runtime_trailing_whitespace() {
612        assert_eq!(parse_runtime("docker  "), Some(ContainerRuntime::Docker));
613        assert_eq!(parse_runtime("podman\t"), Some(ContainerRuntime::Podman));
614    }
615
616    #[test]
617    fn runtime_motd_after_output() {
618        let input = "docker\nSystem update available.";
619        // Last non-empty line is "System update available." which is not a runtime
620        assert_eq!(parse_runtime(input), None);
621    }
622
623    // -- ContainerAction x ContainerRuntime ----------------------------------
624
625    #[test]
626    fn action_command_all_combinations() {
627        let cases = [
628            (
629                ContainerRuntime::Docker,
630                ContainerAction::Start,
631                "docker start c1",
632            ),
633            (
634                ContainerRuntime::Docker,
635                ContainerAction::Stop,
636                "docker stop c1",
637            ),
638            (
639                ContainerRuntime::Docker,
640                ContainerAction::Restart,
641                "docker restart c1",
642            ),
643            (
644                ContainerRuntime::Podman,
645                ContainerAction::Start,
646                "podman start c1",
647            ),
648            (
649                ContainerRuntime::Podman,
650                ContainerAction::Stop,
651                "podman stop c1",
652            ),
653            (
654                ContainerRuntime::Podman,
655                ContainerAction::Restart,
656                "podman restart c1",
657            ),
658        ];
659        for (rt, action, expected) in cases {
660            assert_eq!(container_action_command(rt, action, "c1"), expected);
661        }
662    }
663
664    #[test]
665    fn action_as_str() {
666        assert_eq!(ContainerAction::Start.as_str(), "start");
667        assert_eq!(ContainerAction::Stop.as_str(), "stop");
668        assert_eq!(ContainerAction::Restart.as_str(), "restart");
669    }
670
671    #[test]
672    fn runtime_as_str() {
673        assert_eq!(ContainerRuntime::Docker.as_str(), "docker");
674        assert_eq!(ContainerRuntime::Podman.as_str(), "podman");
675    }
676
677    // -- validate_container_id -----------------------------------------------
678
679    #[test]
680    fn id_valid_hex() {
681        assert!(validate_container_id("a1b2c3d4e5f6").is_ok());
682    }
683
684    #[test]
685    fn id_valid_names() {
686        assert!(validate_container_id("myapp").is_ok());
687        assert!(validate_container_id("my-app").is_ok());
688        assert!(validate_container_id("my_app").is_ok());
689        assert!(validate_container_id("my.app").is_ok());
690        assert!(validate_container_id("myproject-web-1").is_ok());
691    }
692
693    #[test]
694    fn id_empty() {
695        assert!(validate_container_id("").is_err());
696    }
697
698    #[test]
699    fn id_space() {
700        assert!(validate_container_id("my app").is_err());
701    }
702
703    #[test]
704    fn id_newline() {
705        assert!(validate_container_id("app\n").is_err());
706    }
707
708    #[test]
709    fn id_injection_semicolon() {
710        assert!(validate_container_id("app;rm -rf /").is_err());
711    }
712
713    #[test]
714    fn id_injection_pipe() {
715        assert!(validate_container_id("app|cat /etc/passwd").is_err());
716    }
717
718    #[test]
719    fn id_injection_dollar() {
720        assert!(validate_container_id("app$HOME").is_err());
721    }
722
723    #[test]
724    fn id_injection_backtick() {
725        assert!(validate_container_id("app`whoami`").is_err());
726    }
727
728    #[test]
729    fn id_unicode_rejected() {
730        assert!(validate_container_id("app\u{00e9}").is_err());
731        assert!(validate_container_id("\u{0430}pp").is_err()); // Cyrillic а
732    }
733
734    #[test]
735    fn id_colon_rejected() {
736        assert!(validate_container_id("app:latest").is_err());
737    }
738
739    // -- container_list_command ----------------------------------------------
740
741    #[test]
742    fn list_cmd_docker() {
743        assert_eq!(
744            container_list_command(Some(ContainerRuntime::Docker)),
745            "docker ps -a --format '{{json .}}'"
746        );
747    }
748
749    #[test]
750    fn list_cmd_podman() {
751        assert_eq!(
752            container_list_command(Some(ContainerRuntime::Podman)),
753            "podman ps -a --format '{{json .}}'"
754        );
755    }
756
757    #[test]
758    fn list_cmd_none_has_sentinels() {
759        let cmd = container_list_command(None);
760        assert!(cmd.contains("##purple:docker##"));
761        assert!(cmd.contains("##purple:podman##"));
762        assert!(cmd.contains("##purple:none##"));
763    }
764
765    #[test]
766    fn list_cmd_none_docker_first() {
767        let cmd = container_list_command(None);
768        let d = cmd.find("##purple:docker##").unwrap();
769        let p = cmd.find("##purple:podman##").unwrap();
770        assert!(d < p);
771    }
772
773    // -- parse_container_output ----------------------------------------------
774
775    #[test]
776    fn output_docker_sentinel() {
777        let c = make_json("abc", "web", "nginx", "running", "Up", "80/tcp");
778        let out = format!("##purple:docker##\n{c}");
779        let (rt, cs) = parse_container_output(&out, None).unwrap();
780        assert_eq!(rt, ContainerRuntime::Docker);
781        assert_eq!(cs.len(), 1);
782    }
783
784    #[test]
785    fn output_podman_sentinel() {
786        let c = make_json("xyz", "db", "pg", "exited", "Exited", "");
787        let out = format!("##purple:podman##\n{c}");
788        let (rt, _) = parse_container_output(&out, None).unwrap();
789        assert_eq!(rt, ContainerRuntime::Podman);
790    }
791
792    #[test]
793    fn output_none_sentinel() {
794        let r = parse_container_output("##purple:none##", None);
795        assert!(r.is_err());
796        assert!(r.unwrap_err().contains("No container runtime"));
797    }
798
799    #[test]
800    fn output_no_sentinel_with_caller() {
801        let c = make_json("a", "app", "img", "running", "Up", "");
802        let (rt, cs) = parse_container_output(&c, Some(ContainerRuntime::Docker)).unwrap();
803        assert_eq!(rt, ContainerRuntime::Docker);
804        assert_eq!(cs.len(), 1);
805    }
806
807    #[test]
808    fn output_no_sentinel_no_caller() {
809        let c = make_json("a", "app", "img", "running", "Up", "");
810        assert!(parse_container_output(&c, None).is_err());
811    }
812
813    #[test]
814    fn output_motd_before_sentinel() {
815        let c = make_json("a", "app", "img", "running", "Up", "");
816        let out = format!("Welcome to server\nInfo line\n##purple:docker##\n{c}");
817        let (rt, cs) = parse_container_output(&out, None).unwrap();
818        assert_eq!(rt, ContainerRuntime::Docker);
819        assert_eq!(cs.len(), 1);
820    }
821
822    #[test]
823    fn output_empty_container_list() {
824        let (rt, cs) = parse_container_output("##purple:docker##\n", None).unwrap();
825        assert_eq!(rt, ContainerRuntime::Docker);
826        assert!(cs.is_empty());
827    }
828
829    #[test]
830    fn output_multiple_containers() {
831        let c1 = make_json("a", "web", "nginx", "running", "Up", "80/tcp");
832        let c2 = make_json("b", "db", "pg", "exited", "Exited", "");
833        let c3 = make_json("c", "cache", "redis", "running", "Up", "6379/tcp");
834        let out = format!("##purple:podman##\n{c1}\n{c2}\n{c3}");
835        let (_, cs) = parse_container_output(&out, None).unwrap();
836        assert_eq!(cs.len(), 3);
837    }
838
839    // -- friendly_container_error --------------------------------------------
840
841    #[test]
842    fn friendly_error_command_not_found() {
843        let msg = friendly_container_error("bash: docker: command not found", Some(127));
844        assert_eq!(msg, "Docker or Podman not found on remote host.");
845    }
846
847    #[test]
848    fn friendly_error_permission_denied() {
849        let msg = friendly_container_error(
850            "Got permission denied while trying to connect to the Docker daemon socket",
851            Some(1),
852        );
853        assert_eq!(msg, "Permission denied. Is your user in the docker group?");
854    }
855
856    #[test]
857    fn friendly_error_daemon_not_running() {
858        let msg = friendly_container_error(
859            "Cannot connect to the Docker daemon at unix:///var/run/docker.sock",
860            Some(1),
861        );
862        assert_eq!(msg, "Container daemon is not running.");
863    }
864
865    #[test]
866    fn friendly_error_connection_refused() {
867        let msg = friendly_container_error("ssh: connect to host: Connection refused", Some(255));
868        assert_eq!(msg, "Connection refused.");
869    }
870
871    #[test]
872    fn friendly_error_empty_stderr() {
873        let msg = friendly_container_error("", Some(1));
874        assert_eq!(msg, "Command failed with code 1.");
875    }
876
877    #[test]
878    fn friendly_error_unknown_stderr_uses_generic_message() {
879        let msg = friendly_container_error("some unknown error", Some(1));
880        assert_eq!(msg, "Command failed with code 1.");
881    }
882
883    // -- cache serialization -------------------------------------------------
884
885    #[test]
886    fn cache_round_trip() {
887        let line = CacheLine {
888            alias: "web1".to_string(),
889            timestamp: 1_700_000_000,
890            runtime: ContainerRuntime::Docker,
891            containers: vec![ContainerInfo {
892                id: "abc".to_string(),
893                names: "nginx".to_string(),
894                image: "nginx:latest".to_string(),
895                state: "running".to_string(),
896                status: "Up 2h".to_string(),
897                ports: "80/tcp".to_string(),
898            }],
899        };
900        let s = serde_json::to_string(&line).unwrap();
901        let d: CacheLine = serde_json::from_str(&s).unwrap();
902        assert_eq!(d.alias, "web1");
903        assert_eq!(d.runtime, ContainerRuntime::Docker);
904        assert_eq!(d.containers.len(), 1);
905        assert_eq!(d.containers[0].id, "abc");
906    }
907
908    #[test]
909    fn cache_round_trip_podman() {
910        let line = CacheLine {
911            alias: "host2".to_string(),
912            timestamp: 200,
913            runtime: ContainerRuntime::Podman,
914            containers: vec![],
915        };
916        let s = serde_json::to_string(&line).unwrap();
917        let d: CacheLine = serde_json::from_str(&s).unwrap();
918        assert_eq!(d.runtime, ContainerRuntime::Podman);
919    }
920
921    #[test]
922    fn cache_parse_empty() {
923        let map: HashMap<String, ContainerCacheEntry> =
924            "".lines().filter_map(parse_cache_line).collect();
925        assert!(map.is_empty());
926    }
927
928    #[test]
929    fn cache_parse_malformed_ignored() {
930        let valid = serde_json::to_string(&CacheLine {
931            alias: "good".to_string(),
932            timestamp: 1,
933            runtime: ContainerRuntime::Docker,
934            containers: vec![],
935        })
936        .unwrap();
937        let content = format!("garbage\n{valid}\nalso bad");
938        let map: HashMap<String, ContainerCacheEntry> =
939            content.lines().filter_map(parse_cache_line).collect();
940        assert_eq!(map.len(), 1);
941        assert!(map.contains_key("good"));
942    }
943
944    #[test]
945    fn cache_parse_multiple_hosts() {
946        let lines: Vec<String> = ["h1", "h2", "h3"]
947            .iter()
948            .enumerate()
949            .map(|(i, alias)| {
950                serde_json::to_string(&CacheLine {
951                    alias: alias.to_string(),
952                    timestamp: i as u64,
953                    runtime: ContainerRuntime::Docker,
954                    containers: vec![],
955                })
956                .unwrap()
957            })
958            .collect();
959        let content = lines.join("\n");
960        let map: HashMap<String, ContainerCacheEntry> =
961            content.lines().filter_map(parse_cache_line).collect();
962        assert_eq!(map.len(), 3);
963    }
964
965    /// Helper: parse a single cache line (mirrors load_container_cache logic).
966    fn parse_cache_line(line: &str) -> Option<(String, ContainerCacheEntry)> {
967        let t = line.trim();
968        if t.is_empty() {
969            return None;
970        }
971        let entry: CacheLine = serde_json::from_str(t).ok()?;
972        Some((
973            entry.alias,
974            ContainerCacheEntry {
975                timestamp: entry.timestamp,
976                runtime: entry.runtime,
977                containers: entry.containers,
978            },
979        ))
980    }
981
982    // -- truncate_str --------------------------------------------------------
983
984    #[test]
985    fn truncate_short() {
986        assert_eq!(truncate_str("hi", 10), "hi");
987    }
988
989    #[test]
990    fn truncate_exact() {
991        assert_eq!(truncate_str("hello", 5), "hello");
992    }
993
994    #[test]
995    fn truncate_long() {
996        assert_eq!(truncate_str("hello world", 7), "hello..");
997    }
998
999    #[test]
1000    fn truncate_empty() {
1001        assert_eq!(truncate_str("", 5), "");
1002    }
1003
1004    #[test]
1005    fn truncate_max_two() {
1006        assert_eq!(truncate_str("hello", 2), "..");
1007    }
1008
1009    #[test]
1010    fn truncate_multibyte() {
1011        assert_eq!(truncate_str("café-app", 6), "café..");
1012    }
1013
1014    #[test]
1015    fn truncate_emoji() {
1016        assert_eq!(truncate_str("🐳nginx", 5), "🐳ng..");
1017    }
1018
1019    // -- format_relative_time ------------------------------------------------
1020
1021    fn now_secs() -> u64 {
1022        SystemTime::now()
1023            .duration_since(UNIX_EPOCH)
1024            .unwrap()
1025            .as_secs()
1026    }
1027
1028    #[test]
1029    fn relative_just_now() {
1030        assert_eq!(format_relative_time(now_secs()), "just now");
1031        assert_eq!(format_relative_time(now_secs() - 30), "just now");
1032        assert_eq!(format_relative_time(now_secs() - 59), "just now");
1033    }
1034
1035    #[test]
1036    fn relative_minutes() {
1037        assert_eq!(format_relative_time(now_secs() - 60), "1m ago");
1038        assert_eq!(format_relative_time(now_secs() - 300), "5m ago");
1039        assert_eq!(format_relative_time(now_secs() - 3599), "59m ago");
1040    }
1041
1042    #[test]
1043    fn relative_hours() {
1044        assert_eq!(format_relative_time(now_secs() - 3600), "1h ago");
1045        assert_eq!(format_relative_time(now_secs() - 7200), "2h ago");
1046    }
1047
1048    #[test]
1049    fn relative_days() {
1050        assert_eq!(format_relative_time(now_secs() - 86400), "1d ago");
1051        assert_eq!(format_relative_time(now_secs() - 7 * 86400), "7d ago");
1052    }
1053
1054    #[test]
1055    fn relative_future_saturates() {
1056        assert_eq!(format_relative_time(now_secs() + 10000), "just now");
1057    }
1058
1059    // -- Additional edge-case tests -------------------------------------------
1060
1061    #[test]
1062    fn parse_ps_whitespace_only_lines_between_json() {
1063        let c1 = make_json("a", "web", "nginx", "running", "Up", "");
1064        let c2 = make_json("b", "db", "pg", "exited", "Exited", "");
1065        let input = format!("{c1}\n   \n\t\n{c2}");
1066        let r = parse_container_ps(&input);
1067        assert_eq!(r.len(), 2);
1068        assert_eq!(r[0].id, "a");
1069        assert_eq!(r[1].id, "b");
1070    }
1071
1072    #[test]
1073    fn id_just_dot() {
1074        assert!(validate_container_id(".").is_ok());
1075    }
1076
1077    #[test]
1078    fn id_just_dash() {
1079        assert!(validate_container_id("-").is_ok());
1080    }
1081
1082    #[test]
1083    fn id_slash_rejected() {
1084        assert!(validate_container_id("my/container").is_err());
1085    }
1086
1087    #[test]
1088    fn list_cmd_none_valid_shell_syntax() {
1089        let cmd = container_list_command(None);
1090        assert!(cmd.contains("if "), "should start with if");
1091        assert!(cmd.contains("fi"), "should end with fi");
1092        assert!(cmd.contains("elif "), "should have elif fallback");
1093        assert!(cmd.contains("else "), "should have else branch");
1094    }
1095
1096    #[test]
1097    fn output_sentinel_on_last_line() {
1098        let r = parse_container_output("some MOTD\n##purple:docker##", None);
1099        let (rt, cs) = r.unwrap();
1100        assert_eq!(rt, ContainerRuntime::Docker);
1101        assert!(cs.is_empty());
1102    }
1103
1104    #[test]
1105    fn output_sentinel_none_on_last_line() {
1106        let r = parse_container_output("MOTD line\n##purple:none##", None);
1107        assert!(r.is_err());
1108        assert!(r.unwrap_err().contains("No container runtime"));
1109    }
1110
1111    #[test]
1112    fn relative_time_unix_epoch() {
1113        // Timestamp 0 is decades ago, should show many days
1114        let result = format_relative_time(0);
1115        assert!(
1116            result.contains("d ago"),
1117            "epoch should be days ago: {result}"
1118        );
1119    }
1120
1121    #[test]
1122    fn truncate_unicode_within_limit() {
1123        // 3-byte chars but total byte len 9 > max 5, yet char count is 3
1124        // truncate_str uses byte length so this string of 3 chars (9 bytes) > max 5
1125        assert_eq!(truncate_str("abc", 5), "abc"); // ASCII fits
1126    }
1127
1128    #[test]
1129    fn truncate_ascii_boundary() {
1130        // Ensure max=0 does not panic
1131        assert_eq!(truncate_str("hello", 0), "..");
1132    }
1133
1134    #[test]
1135    fn truncate_max_one() {
1136        assert_eq!(truncate_str("hello", 1), "..");
1137    }
1138
1139    #[test]
1140    fn cache_serde_unknown_runtime_rejected() {
1141        let json = r#"{"alias":"h","timestamp":1,"runtime":"Containerd","containers":[]}"#;
1142        let result = serde_json::from_str::<CacheLine>(json);
1143        assert!(result.is_err(), "unknown runtime should be rejected");
1144    }
1145
1146    #[test]
1147    fn cache_duplicate_alias_last_wins() {
1148        let line1 = serde_json::to_string(&CacheLine {
1149            alias: "dup".to_string(),
1150            timestamp: 1,
1151            runtime: ContainerRuntime::Docker,
1152            containers: vec![],
1153        })
1154        .unwrap();
1155        let line2 = serde_json::to_string(&CacheLine {
1156            alias: "dup".to_string(),
1157            timestamp: 99,
1158            runtime: ContainerRuntime::Podman,
1159            containers: vec![],
1160        })
1161        .unwrap();
1162        let content = format!("{line1}\n{line2}");
1163        let map: HashMap<String, ContainerCacheEntry> =
1164            content.lines().filter_map(parse_cache_line).collect();
1165        assert_eq!(map.len(), 1);
1166        // HashMap::from_iter keeps last for duplicate keys
1167        assert_eq!(map["dup"].runtime, ContainerRuntime::Podman);
1168        assert_eq!(map["dup"].timestamp, 99);
1169    }
1170
1171    #[test]
1172    fn friendly_error_no_route() {
1173        let msg = friendly_container_error("ssh: No route to host", Some(255));
1174        assert_eq!(msg, "Host unreachable.");
1175    }
1176
1177    #[test]
1178    fn friendly_error_network_unreachable() {
1179        let msg = friendly_container_error("connect: Network is unreachable", Some(255));
1180        assert_eq!(msg, "Host unreachable.");
1181    }
1182
1183    #[test]
1184    fn friendly_error_none_exit_code() {
1185        let msg = friendly_container_error("", None);
1186        assert_eq!(msg, "Command failed with code 1.");
1187    }
1188
1189    #[test]
1190    fn container_error_display() {
1191        let err = ContainerError {
1192            runtime: Some(ContainerRuntime::Docker),
1193            message: "test error".to_string(),
1194        };
1195        assert_eq!(format!("{err}"), "test error");
1196    }
1197
1198    #[test]
1199    fn container_error_display_no_runtime() {
1200        let err = ContainerError {
1201            runtime: None,
1202            message: "no runtime".to_string(),
1203        };
1204        assert_eq!(format!("{err}"), "no runtime");
1205    }
1206
1207    // -- Additional tests: parse_container_ps edge cases ----------------------
1208
1209    #[test]
1210    fn parse_ps_crlf_line_endings() {
1211        let c1 = make_json("a", "web", "nginx", "running", "Up", "");
1212        let c2 = make_json("b", "db", "pg", "exited", "Exited", "");
1213        let input = format!("{c1}\r\n{c2}\r\n");
1214        let r = parse_container_ps(&input);
1215        assert_eq!(r.len(), 2);
1216        assert_eq!(r[0].id, "a");
1217        assert_eq!(r[1].id, "b");
1218    }
1219
1220    #[test]
1221    fn parse_ps_trailing_newline() {
1222        let c = make_json("a", "web", "nginx", "running", "Up", "");
1223        let input = format!("{c}\n");
1224        let r = parse_container_ps(&input);
1225        assert_eq!(
1226            r.len(),
1227            1,
1228            "trailing newline should not create phantom entry"
1229        );
1230    }
1231
1232    #[test]
1233    fn parse_ps_leading_whitespace_json() {
1234        let c = make_json("a", "web", "nginx", "running", "Up", "");
1235        let input = format!("  {c}");
1236        let r = parse_container_ps(&input);
1237        assert_eq!(
1238            r.len(),
1239            1,
1240            "leading whitespace before JSON should be trimmed"
1241        );
1242        assert_eq!(r[0].id, "a");
1243    }
1244
1245    // -- Additional tests: parse_runtime edge cases ---------------------------
1246
1247    #[test]
1248    fn parse_runtime_empty_lines_between_motd() {
1249        let input = "Welcome\n\n\n\ndocker";
1250        assert_eq!(parse_runtime(input), Some(ContainerRuntime::Docker));
1251    }
1252
1253    #[test]
1254    fn parse_runtime_crlf() {
1255        let input = "MOTD\r\npodman\r\n";
1256        assert_eq!(parse_runtime(input), Some(ContainerRuntime::Podman));
1257    }
1258
1259    // -- Additional tests: parse_container_output edge cases ------------------
1260
1261    #[test]
1262    fn output_unknown_sentinel() {
1263        let r = parse_container_output("##purple:unknown##", None);
1264        assert!(r.is_err());
1265        let msg = r.unwrap_err();
1266        assert!(msg.contains("Unknown sentinel"), "got: {msg}");
1267    }
1268
1269    #[test]
1270    fn output_sentinel_with_crlf() {
1271        let c = make_json("a", "web", "nginx", "running", "Up", "");
1272        let input = format!("##purple:docker##\r\n{c}\r\n");
1273        let (rt, cs) = parse_container_output(&input, None).unwrap();
1274        assert_eq!(rt, ContainerRuntime::Docker);
1275        assert_eq!(cs.len(), 1);
1276    }
1277
1278    #[test]
1279    fn output_sentinel_indented() {
1280        let c = make_json("a", "web", "nginx", "running", "Up", "");
1281        let input = format!("  ##purple:docker##\n{c}");
1282        let (rt, cs) = parse_container_output(&input, None).unwrap();
1283        assert_eq!(rt, ContainerRuntime::Docker);
1284        assert_eq!(cs.len(), 1);
1285    }
1286
1287    #[test]
1288    fn output_caller_runtime_podman() {
1289        let c = make_json("a", "app", "img", "running", "Up", "");
1290        let (rt, cs) = parse_container_output(&c, Some(ContainerRuntime::Podman)).unwrap();
1291        assert_eq!(rt, ContainerRuntime::Podman);
1292        assert_eq!(cs.len(), 1);
1293    }
1294
1295    // -- Additional tests: container_action_command ---------------------------
1296
1297    #[test]
1298    fn action_command_long_id() {
1299        let long_id = "a".repeat(64);
1300        let cmd =
1301            container_action_command(ContainerRuntime::Docker, ContainerAction::Start, &long_id);
1302        assert_eq!(cmd, format!("docker start {long_id}"));
1303    }
1304
1305    // -- Additional tests: validate_container_id ------------------------------
1306
1307    #[test]
1308    fn id_full_sha256() {
1309        let id = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
1310        assert_eq!(id.len(), 64);
1311        assert!(validate_container_id(id).is_ok());
1312    }
1313
1314    #[test]
1315    fn id_ampersand_rejected() {
1316        assert!(validate_container_id("app&rm").is_err());
1317    }
1318
1319    #[test]
1320    fn id_parentheses_rejected() {
1321        assert!(validate_container_id("app(1)").is_err());
1322        assert!(validate_container_id("app)").is_err());
1323    }
1324
1325    #[test]
1326    fn id_angle_brackets_rejected() {
1327        assert!(validate_container_id("app<1>").is_err());
1328        assert!(validate_container_id("app>").is_err());
1329    }
1330
1331    // -- Additional tests: friendly_container_error ---------------------------
1332
1333    #[test]
1334    fn friendly_error_podman_daemon() {
1335        let msg = friendly_container_error("cannot connect to podman", Some(125));
1336        assert_eq!(msg, "Container daemon is not running.");
1337    }
1338
1339    #[test]
1340    fn friendly_error_case_insensitive() {
1341        let msg = friendly_container_error("PERMISSION DENIED", Some(1));
1342        assert_eq!(msg, "Permission denied. Is your user in the docker group?");
1343    }
1344
1345    // -- Additional tests: Copy traits ----------------------------------------
1346
1347    #[test]
1348    fn container_runtime_copy() {
1349        let a = ContainerRuntime::Docker;
1350        let b = a; // Copy
1351        assert_eq!(a, b); // both still usable
1352    }
1353
1354    #[test]
1355    fn container_action_copy() {
1356        let a = ContainerAction::Start;
1357        let b = a; // Copy
1358        assert_eq!(a, b); // both still usable
1359    }
1360
1361    // -- Additional tests: truncate_str edge cases ----------------------------
1362
1363    #[test]
1364    fn truncate_multibyte_utf8() {
1365        // "caf\u{00e9}-app" is 8 chars; truncating to 6 keeps "caf\u{00e9}" + ".."
1366        assert_eq!(truncate_str("caf\u{00e9}-app", 6), "caf\u{00e9}..");
1367    }
1368
1369    // -- Additional tests: format_relative_time boundaries --------------------
1370
1371    #[test]
1372    fn format_relative_time_boundary_60s() {
1373        let ts = now_secs() - 60;
1374        assert_eq!(format_relative_time(ts), "1m ago");
1375    }
1376
1377    #[test]
1378    fn format_relative_time_boundary_3600s() {
1379        let ts = now_secs() - 3600;
1380        assert_eq!(format_relative_time(ts), "1h ago");
1381    }
1382
1383    #[test]
1384    fn format_relative_time_boundary_86400s() {
1385        let ts = now_secs() - 86400;
1386        assert_eq!(format_relative_time(ts), "1d ago");
1387    }
1388
1389    // -- Additional tests: ContainerError Debug -------------------------------
1390
1391    #[test]
1392    fn container_error_debug() {
1393        let err = ContainerError {
1394            runtime: Some(ContainerRuntime::Docker),
1395            message: "test".to_string(),
1396        };
1397        let dbg = format!("{err:?}");
1398        assert!(
1399            dbg.contains("Docker"),
1400            "Debug should include runtime: {dbg}"
1401        );
1402        assert!(dbg.contains("test"), "Debug should include message: {dbg}");
1403    }
1404}