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