Skip to main content

purple_ssh/
containers.rs

1use std::collections::HashMap;
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use log::{error, info};
5
6use serde::{Deserialize, Serialize};
7
8use crate::ssh_context::{OwnedSshContext, SshContext};
9
10// ---------------------------------------------------------------------------
11// ContainerInfo model
12// ---------------------------------------------------------------------------
13
14/// Metadata for a single container (from `docker ps -a` / `podman ps -a`).
15///
16/// Deserialization is tolerant of both docker and podman JSON shapes.
17/// Docker uses `ID` plus scalar `Names`/`Ports`; podman uses `Id` plus
18/// `Names` as an array and `Ports` as an array of objects. The custom
19/// helpers below coerce both into the docker-shaped scalar fields the
20/// rest of purple (UI, cache, MCP) already understands.
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub struct ContainerInfo {
23    #[serde(rename = "ID", alias = "Id")]
24    pub id: String,
25    #[serde(rename = "Names", deserialize_with = "deserialize_names_field")]
26    pub names: String,
27    #[serde(rename = "Image")]
28    pub image: String,
29    #[serde(rename = "State")]
30    pub state: String,
31    #[serde(rename = "Status", default)]
32    pub status: String,
33    // `default` covers the missing-key case directly via Default::default()
34    // and bypasses `deserialize_with`. The custom deserializer therefore
35    // only runs when `Ports` is present (scalar, array or explicit null).
36    #[serde(
37        rename = "Ports",
38        deserialize_with = "deserialize_ports_field",
39        default
40    )]
41    pub ports: String,
42}
43
44/// Accept `Names` as either a scalar string (docker) or an array of
45/// strings (podman). Multiple names join with `,` to match docker's
46/// own comma-joined rendering. Unexpected shapes (number, object,
47/// null) propagate as serde errors; `parse_container_ps` drops the
48/// offending row via `.ok()`, which is the right behaviour for a row
49/// that has lost its identity.
50fn deserialize_names_field<'de, D>(deserializer: D) -> Result<String, D::Error>
51where
52    D: serde::Deserializer<'de>,
53{
54    #[derive(Deserialize)]
55    #[serde(untagged)]
56    enum NamesField {
57        Scalar(String),
58        Array(Vec<String>),
59    }
60    match NamesField::deserialize(deserializer)? {
61        NamesField::Scalar(s) => Ok(s),
62        NamesField::Array(arr) => Ok(arr.join(",")),
63    }
64}
65
66/// Accept `Ports` as either a scalar string (docker) or an array of
67/// port objects (podman). Podman entries are rendered into the same
68/// `host_ip:host_port->container_port/proto` form docker emits, so
69/// downstream UI rendering stays uniform. An explicit JSON null is
70/// tolerated and produces an empty string: podman uses `null` to mean
71/// "no ports published", which is semantically valid and the row must
72/// remain visible.
73fn deserialize_ports_field<'de, D>(deserializer: D) -> Result<String, D::Error>
74where
75    D: serde::Deserializer<'de>,
76{
77    #[derive(Deserialize)]
78    #[serde(untagged)]
79    enum PortsField {
80        Scalar(String),
81        Array(Vec<PodmanPort>),
82    }
83    match Option::<PortsField>::deserialize(deserializer)? {
84        Some(PortsField::Scalar(s)) => Ok(s),
85        Some(PortsField::Array(arr)) => Ok(format_podman_ports(&arr)),
86        None => Ok(String::new()),
87    }
88}
89
90#[derive(Deserialize)]
91struct PodmanPort {
92    #[serde(default)]
93    host_ip: String,
94    #[serde(default)]
95    container_port: u32,
96    #[serde(default)]
97    host_port: u32,
98    #[serde(default = "podman_port_default_range")]
99    range: u32,
100    #[serde(default)]
101    protocol: String,
102}
103
104fn podman_port_default_range() -> u32 {
105    1
106}
107
108fn format_podman_ports(ports: &[PodmanPort]) -> String {
109    // ~24 chars per typical port entry. Pre-allocating avoids the
110    // intermediate Vec<String> + repeated re-allocations that the prior
111    // map/collect/join chain produced for compose stacks with many
112    // published ports.
113    let mut out = String::with_capacity(ports.len().saturating_mul(24));
114    for (i, p) in ports.iter().enumerate() {
115        if i > 0 {
116            out.push_str(", ");
117        }
118        write_podman_port(p, &mut out);
119    }
120    out
121}
122
123fn write_podman_port(p: &PodmanPort, out: &mut String) {
124    use std::fmt::Write as _;
125    let protocol = if p.protocol.is_empty() {
126        "tcp"
127    } else {
128        p.protocol.as_str()
129    };
130    if p.host_port != 0 {
131        // Podman emits an empty `host_ip` for both IPv4 wildcard and IPv6
132        // wildcard binds. Omit the prefix when unknown rather than
133        // mis-claim IPv4. Concrete addresses (e.g. 127.0.0.1, ::1) render
134        // verbatim with the docker `addr:port->...` form.
135        if !p.host_ip.is_empty() {
136            let _ = write!(out, "{}:", p.host_ip);
137        }
138        if p.range > 1 {
139            let _ = write!(
140                out,
141                "{}-{}->",
142                p.host_port,
143                p.host_port.saturating_add(p.range.saturating_sub(1))
144            );
145        } else {
146            let _ = write!(out, "{}->", p.host_port);
147        }
148    }
149    if p.range > 1 {
150        let _ = write!(
151            out,
152            "{}-{}",
153            p.container_port,
154            p.container_port.saturating_add(p.range.saturating_sub(1))
155        );
156    } else {
157        let _ = write!(out, "{}", p.container_port);
158    }
159    let _ = write!(out, "/{protocol}");
160}
161
162/// Try to parse one NDJSON line into `ContainerInfo`. Returns `None`
163/// for blank/non-JSON lines (MOTD/banner) without logging. JSON-shaped
164/// lines that fail to match the schema log at debug level so missing
165/// containers can be correlated to a concrete parse error rather than
166/// guessed from a shrunken list.
167fn try_parse_container_line(trimmed: &str) -> Option<ContainerInfo> {
168    if trimmed.is_empty() {
169        return None;
170    }
171    match serde_json::from_str(trimmed) {
172        Ok(c) => Some(c),
173        Err(e) if trimmed.starts_with('{') => {
174            log::debug!(
175                "[external] container parse: dropped JSON line: {} (err: {})",
176                &trimmed[..trimmed.len().min(120)],
177                e
178            );
179            None
180        }
181        Err(_) => None,
182    }
183}
184
185/// Parse NDJSON output from `docker ps --format '{{json .}}'` or
186/// `podman ps --format '{{json .}}'`. Used by tests and the public
187/// crate API exposed via `lib.rs`; the live SSH path streams through
188/// `parse_container_output` directly, so the binary build sees this
189/// helper as unused and the lint must be silenced.
190#[allow(dead_code)]
191pub fn parse_container_ps(output: &str) -> Vec<ContainerInfo> {
192    output
193        .lines()
194        .filter_map(|line| try_parse_container_line(line.trim()))
195        .collect()
196}
197
198// ---------------------------------------------------------------------------
199// ContainerRuntime
200// ---------------------------------------------------------------------------
201
202/// Supported container runtimes.
203#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
204pub enum ContainerRuntime {
205    Docker,
206    Podman,
207}
208
209impl ContainerRuntime {
210    /// Returns the CLI binary name.
211    pub fn as_str(&self) -> &'static str {
212        match self {
213            ContainerRuntime::Docker => "docker",
214            ContainerRuntime::Podman => "podman",
215        }
216    }
217}
218
219/// Detect runtime from command output by matching the LAST non-empty trimmed
220/// line. Only "docker" or "podman" are accepted. MOTD-resilient.
221/// Currently unused (sentinel-based detection handles this inline) but kept
222/// as a public utility for potential future two-step detection paths.
223#[allow(dead_code)]
224pub fn parse_runtime(output: &str) -> Option<ContainerRuntime> {
225    let last = output
226        .lines()
227        .rev()
228        .map(|l| l.trim())
229        .find(|l| !l.is_empty())?;
230    match last {
231        "docker" => Some(ContainerRuntime::Docker),
232        "podman" => Some(ContainerRuntime::Podman),
233        _ => None,
234    }
235}
236
237// ---------------------------------------------------------------------------
238// ContainerAction
239// ---------------------------------------------------------------------------
240
241/// Actions that can be performed on a container.
242#[derive(Copy, Clone, Debug, PartialEq)]
243pub enum ContainerAction {
244    Start,
245    Stop,
246    Restart,
247}
248
249impl ContainerAction {
250    /// Returns the CLI sub-command string.
251    pub fn as_str(&self) -> &'static str {
252        match self {
253            ContainerAction::Start => "start",
254            ContainerAction::Stop => "stop",
255            ContainerAction::Restart => "restart",
256        }
257    }
258}
259
260/// Build the shell command to perform an action on a container.
261pub fn container_action_command(
262    runtime: ContainerRuntime,
263    action: ContainerAction,
264    container_id: &str,
265) -> String {
266    format!("{} {} {}", runtime.as_str(), action.as_str(), container_id)
267}
268
269// ---------------------------------------------------------------------------
270// Container ID validation
271// ---------------------------------------------------------------------------
272
273/// Validate a container ID or name.
274/// Accepts ASCII alphanumeric, hyphen, underscore, dot.
275/// Rejects empty, non-ASCII, shell metacharacters, colon.
276pub fn validate_container_id(id: &str) -> Result<(), String> {
277    if id.is_empty() {
278        return Err(crate::messages::CONTAINER_ID_EMPTY.to_string());
279    }
280    for c in id.chars() {
281        if !c.is_ascii_alphanumeric() && c != '-' && c != '_' && c != '.' {
282            return Err(crate::messages::container_id_invalid_char(c));
283        }
284    }
285    Ok(())
286}
287
288// ---------------------------------------------------------------------------
289// Combined SSH command + output parsing
290// ---------------------------------------------------------------------------
291
292/// Build the SSH command string for listing containers. Output is the
293/// container NDJSON, then the `##purple:engine##` sentinel, then the
294/// daemon version on its own line. The version subcall is suffixed with
295/// `|| true` so its failure cannot mask a `docker ps` error: the chain
296/// surfaces ps's exit code, while a missing version line just yields
297/// `engine_version: None` downstream.
298///
299/// - `Some(Docker)` / `Some(Podman)`: direct listing for the known runtime.
300/// - `None`: combined detection + listing with sentinel markers in one SSH call.
301pub fn container_list_command(runtime: Option<ContainerRuntime>) -> String {
302    match runtime {
303        Some(ContainerRuntime::Docker) => concat!(
304            "docker ps -a --format '{{json .}}' && ",
305            "echo '##purple:engine##' && ",
306            "{ docker version --format '{{.Server.Version}}' 2>/dev/null || true; }"
307        )
308        .to_string(),
309        Some(ContainerRuntime::Podman) => concat!(
310            "podman ps -a --format '{{json .}}' && ",
311            "echo '##purple:engine##' && ",
312            "{ podman version --format '{{.Server.Version}}' 2>/dev/null || true; }"
313        )
314        .to_string(),
315        None => concat!(
316            "if command -v docker >/dev/null 2>&1; then ",
317            "echo '##purple:docker##' && docker ps -a --format '{{json .}}' && ",
318            "echo '##purple:engine##' && ",
319            "{ docker version --format '{{.Server.Version}}' 2>/dev/null || true; }; ",
320            "elif command -v podman >/dev/null 2>&1; then ",
321            "echo '##purple:podman##' && podman ps -a --format '{{json .}}' && ",
322            "echo '##purple:engine##' && ",
323            "{ podman version --format '{{.Server.Version}}' 2>/dev/null || true; }; ",
324            "else echo '##purple:none##'; fi"
325        )
326        .to_string(),
327    }
328}
329
330/// Parsed result of a container listing command. `engine_version` is the
331/// daemon's `Server.Version` (best-effort, `None` when the version sub-call
332/// failed or the remote runtime predates the engine sentinel).
333#[derive(Debug, Clone, PartialEq)]
334pub struct ContainerListing {
335    pub runtime: ContainerRuntime,
336    pub engine_version: Option<String>,
337    pub containers: Vec<ContainerInfo>,
338}
339
340/// Parse the stdout of a container listing command.
341///
342/// When sentinels are present (combined detection run): extract runtime from
343/// the sentinel line, parse remaining lines as NDJSON. When `caller_runtime`
344/// is provided (subsequent run with known runtime): parse all lines as NDJSON.
345/// In both cases, `##purple:engine##` splits the listing from the optional
346/// trailing daemon version line.
347pub fn parse_container_output(
348    output: &str,
349    caller_runtime: Option<ContainerRuntime>,
350) -> Result<ContainerListing, String> {
351    let runtime = match output
352        .lines()
353        .map(str::trim)
354        .find(|l| l.starts_with("##purple:") && (*l != "##purple:engine##"))
355    {
356        Some("##purple:none##") => {
357            return Err(crate::messages::CONTAINER_RUNTIME_MISSING.to_string());
358        }
359        Some("##purple:docker##") => ContainerRuntime::Docker,
360        Some("##purple:podman##") => ContainerRuntime::Podman,
361        Some(other) => return Err(crate::messages::container_unknown_sentinel(other)),
362        None => match caller_runtime {
363            Some(rt) => rt,
364            None => return Err("No sentinel found and no runtime provided.".to_string()),
365        },
366    };
367
368    // Bound the version capture to the first non-empty post-sentinel line.
369    // A trailing logout banner or MOTD after `docker version` would
370    // otherwise concat into the cached engine_version and surface as
371    // "25.0.3\n-- session closed --" in the Runtime field.
372    let mut engine_version: Option<String> = None;
373    let mut after_engine = false;
374    let mut containers: Vec<ContainerInfo> = Vec::new();
375    // Stream-parse each NDJSON line during the sentinel sweep so we never
376    // build an intermediate copy of the listing block. At 1000 containers
377    // that intermediate buffer would cost ~300 KB and an extra `.lines()`
378    // walk; this loop is O(lines) with zero auxiliary allocation.
379    for line in output.lines() {
380        let trimmed = line.trim();
381        if trimmed == "##purple:engine##" {
382            after_engine = true;
383            continue;
384        }
385        if trimmed.starts_with("##purple:") {
386            continue;
387        }
388        if after_engine {
389            if !trimmed.is_empty() && engine_version.is_none() {
390                engine_version = Some(trimmed.to_string());
391            }
392            continue;
393        }
394        if let Some(c) = try_parse_container_line(trimmed) {
395            containers.push(c);
396        }
397    }
398
399    // Fedora CoreOS, podman-machine and other distros symlink `docker` to
400    // `podman`. Detection picks the docker branch but the JSON shape is
401    // pure podman (array `Names`, lowercase `Id`). When that happens we
402    // relabel the runtime so downstream consumers (MCP runtime field, host
403    // detail label, sort/filter by runtime) match reality.
404    let runtime = if matches!(runtime, ContainerRuntime::Docker) && looks_like_podman(output) {
405        log::debug!(
406            "[external] container detection: docker sentinel emitted podman-shaped JSON, relabeling runtime to Podman"
407        );
408        ContainerRuntime::Podman
409    } else {
410        runtime
411    };
412
413    log::debug!(
414        "[external] container listing parsed: runtime={:?} version={:?} containers={}",
415        runtime,
416        engine_version,
417        containers.len()
418    );
419    Ok(ContainerListing {
420        runtime,
421        engine_version,
422        containers,
423    })
424}
425
426/// Heuristic: does the raw `ps` output look like podman JSON?
427/// Podman emits `"Names":[` (array) and `"Id":` (lowercase d) for every row.
428/// Docker emits `"Names":"` (string) and `"ID":` (uppercase D). We sample the
429/// first JSON-shaped non-sentinel line. The check is fast (substring scan)
430/// and only matters when the docker sentinel was emitted by a podman shim.
431/// Accepts both `"Names":[` and `"Names": [` (pretty-printed) so handwritten
432/// test fixtures and any intermediate JSON formatter cannot defeat the
433/// detector silently.
434fn looks_like_podman(output: &str) -> bool {
435    for line in output.lines() {
436        let trimmed = line.trim();
437        if trimmed.is_empty() || trimmed.starts_with("##purple:") || !trimmed.starts_with('{') {
438            continue;
439        }
440        return trimmed.contains("\"Names\":[") || trimmed.contains("\"Names\": [");
441    }
442    false
443}
444
445// ---------------------------------------------------------------------------
446// SSH fetch functions
447// ---------------------------------------------------------------------------
448
449/// Error from a container listing operation. Preserves the detected runtime
450/// even when the `ps` command fails so it can be cached for future calls.
451#[derive(Debug)]
452pub struct ContainerError {
453    pub runtime: Option<ContainerRuntime>,
454    pub message: String,
455}
456
457impl std::fmt::Display for ContainerError {
458    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
459        write!(f, "{}", self.message)
460    }
461}
462
463/// Translate SSH stderr into a user-friendly error message.
464fn friendly_container_error(stderr: &str, code: Option<i32>) -> String {
465    let lower = stderr.to_lowercase();
466    if lower.contains("remote host identification has changed")
467        || (lower.contains("host key for") && lower.contains("has changed"))
468    {
469        log::debug!("[external] Host key CHANGED detected; returning HOST_KEY_CHANGED toast");
470        crate::messages::HOST_KEY_CHANGED.to_string()
471    } else if lower.contains("host key verification failed")
472        || lower.contains("no matching host key")
473        || lower.contains("no ed25519 host key is known")
474        || lower.contains("no rsa host key is known")
475        || lower.contains("no ecdsa host key is known")
476        || lower.contains("host key is not known")
477    {
478        log::debug!("[external] Host key UNKNOWN detected; returning HOST_KEY_UNKNOWN toast");
479        crate::messages::HOST_KEY_UNKNOWN.to_string()
480    } else if lower.contains("command not found") {
481        crate::messages::CONTAINER_RUNTIME_NOT_FOUND.to_string()
482    } else if lower.contains("permission denied") || lower.contains("got permission denied") {
483        crate::messages::CONTAINER_PERMISSION_DENIED.to_string()
484    } else if lower.contains("cannot connect to the docker daemon")
485        || lower.contains("cannot connect to podman")
486    {
487        crate::messages::CONTAINER_DAEMON_NOT_RUNNING.to_string()
488    } else if lower.contains("connection refused") {
489        crate::messages::CONTAINER_CONNECTION_REFUSED.to_string()
490    } else if lower.contains("no route to host") || lower.contains("network is unreachable") {
491        crate::messages::CONTAINER_HOST_UNREACHABLE.to_string()
492    } else {
493        crate::messages::container_command_failed(code.unwrap_or(1))
494    }
495}
496
497/// Fetch container list synchronously via SSH.
498/// Follows the `fetch_remote_listing` pattern.
499pub fn fetch_containers(
500    ctx: &SshContext<'_>,
501    cached_runtime: Option<ContainerRuntime>,
502) -> Result<ContainerListing, ContainerError> {
503    let command = container_list_command(cached_runtime);
504    let result = crate::snippet::run_snippet(
505        ctx.alias,
506        ctx.config_path,
507        &command,
508        ctx.askpass,
509        ctx.bw_session,
510        true,
511        ctx.has_tunnel,
512    );
513    let alias = ctx.alias;
514    match result {
515        Ok(r) if r.status.success() => {
516            parse_container_output(&r.stdout, cached_runtime).map_err(|e| {
517                error!("[external] Container list parse failed: alias={alias}: {e}");
518                ContainerError {
519                    runtime: cached_runtime,
520                    message: e,
521                }
522            })
523        }
524        Ok(r) => {
525            let stderr = r.stderr.trim().to_string();
526            let msg = friendly_container_error(&stderr, r.status.code());
527            error!("[external] Container fetch failed: alias={alias}: {msg}");
528            Err(ContainerError {
529                runtime: cached_runtime,
530                message: msg,
531            })
532        }
533        Err(e) => {
534            error!("[external] Container fetch failed: alias={alias}: {e}");
535            Err(ContainerError {
536                runtime: cached_runtime,
537                message: e.to_string(),
538            })
539        }
540    }
541}
542
543/// Spawn a background thread to fetch container listings.
544/// Follows the `spawn_remote_listing` pattern.
545pub fn spawn_container_listing<F>(
546    ctx: OwnedSshContext,
547    cached_runtime: Option<ContainerRuntime>,
548    send: F,
549) where
550    F: FnOnce(String, Result<ContainerListing, ContainerError>) + Send + 'static,
551{
552    std::thread::spawn(move || {
553        let borrowed = SshContext {
554            alias: &ctx.alias,
555            config_path: &ctx.config_path,
556            askpass: ctx.askpass.as_deref(),
557            bw_session: ctx.bw_session.as_deref(),
558            has_tunnel: ctx.has_tunnel,
559        };
560        let result = fetch_containers(&borrowed, cached_runtime);
561        send(ctx.alias, result);
562    });
563}
564
565/// Spawn a background thread to perform a container action (start/stop/restart).
566/// Validates the container ID before executing.
567pub fn spawn_container_action<F>(
568    ctx: OwnedSshContext,
569    runtime: ContainerRuntime,
570    action: ContainerAction,
571    container_id: String,
572    send: F,
573) where
574    F: FnOnce(String, ContainerAction, Result<(), String>) + Send + 'static,
575{
576    std::thread::spawn(move || {
577        if let Err(e) = validate_container_id(&container_id) {
578            log::debug!(
579                "[purple] container action {} blocked on alias={}: invalid container_id: {}",
580                action.as_str(),
581                ctx.alias,
582                e
583            );
584            send(ctx.alias, action, Err(e));
585            return;
586        }
587        let alias = &ctx.alias;
588        info!(
589            "Container action: {} container={container_id} alias={alias}",
590            action.as_str()
591        );
592        let command = container_action_command(runtime, action, &container_id);
593        let result = crate::snippet::run_snippet(
594            alias,
595            &ctx.config_path,
596            &command,
597            ctx.askpass.as_deref(),
598            ctx.bw_session.as_deref(),
599            true,
600            ctx.has_tunnel,
601        );
602        match result {
603            Ok(r) if r.status.success() => send(ctx.alias, action, Ok(())),
604            Ok(r) => {
605                let err = friendly_container_error(r.stderr.trim(), r.status.code());
606                error!(
607                    "[external] Container {} failed: alias={alias} container={container_id}: {err}",
608                    action.as_str()
609                );
610                send(ctx.alias, action, Err(err));
611            }
612            Err(e) => {
613                error!(
614                    "[external] Container {} failed: alias={alias} container={container_id}: {e}",
615                    action.as_str()
616                );
617                send(ctx.alias, action, Err(e.to_string()));
618            }
619        }
620    });
621}
622
623// ---------------------------------------------------------------------------
624// ContainerInspect: subset of `docker inspect` output we surface in the UI
625// ---------------------------------------------------------------------------
626
627/// Parsed subset of `docker inspect <id>` (or `podman inspect`). Only the
628/// fields purple's container detail panel renders are extracted; the rest
629/// of the JSON document is discarded so cache size stays bounded.
630#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
631pub struct ContainerInspect {
632    pub exit_code: i32,
633    pub oom_killed: bool,
634    pub started_at: String,
635    pub finished_at: String,
636    pub created_at: String,
637    /// `Some("healthy" | "unhealthy" | "starting")` when the image defines
638    /// a HEALTHCHECK. `None` when no healthcheck is configured.
639    pub health: Option<String>,
640    pub restart_count: u32,
641    pub command: Option<Vec<String>>,
642    pub entrypoint: Option<Vec<String>>,
643    pub env_count: usize,
644    pub mount_count: usize,
645    pub networks: Vec<NetworkInfo>,
646    // Audit-relevant fields surfaced in the right-side detail panel.
647    pub image_digest: Option<String>,
648    pub restart_policy: Option<String>,
649    pub user: Option<String>,
650    pub privileged: bool,
651    pub readonly_rootfs: bool,
652    pub apparmor_profile: Option<String>,
653    pub seccomp_profile: Option<String>,
654    pub cap_add: Vec<String>,
655    pub cap_drop: Vec<String>,
656    pub mounts: Vec<MountInfo>,
657    pub compose_project: Option<String>,
658    pub compose_service: Option<String>,
659    // Lifecycle / runtime details surfaced in the LIFECYCLE card.
660    pub pid: Option<u32>,
661    pub stop_signal: Option<String>,
662    pub stop_timeout: Option<u32>,
663    // App identity from OCI image labels (visible in APP card).
664    pub image_version: Option<String>,
665    pub image_revision: Option<String>,
666    pub image_source: Option<String>,
667    pub working_dir: Option<String>,
668    pub hostname: Option<String>,
669    // Resource constraints (RESOURCES card). 0 / None means unlimited.
670    pub memory_limit: Option<u64>,
671    pub cpu_limit_nanos: Option<u64>,
672    pub pids_limit: Option<i64>,
673    pub log_driver: Option<String>,
674    // Network mode (NETWORK card): bridge / host / none / container:xyz.
675    pub network_mode: Option<String>,
676    // Healthcheck definition + recent stats (HEALTH card when present).
677    pub health_test: Option<Vec<String>>,
678    pub health_interval_ns: Option<u64>,
679    pub health_failing_streak: Option<u32>,
680}
681
682#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
683pub struct NetworkInfo {
684    pub name: String,
685    pub ip_address: String,
686}
687
688#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
689pub struct MountInfo {
690    pub source: String,
691    pub destination: String,
692    pub read_only: bool,
693}
694
695/// Build the SSH command string for inspecting a single container.
696///
697/// Callers MUST validate `container_id` with `validate_container_id` before
698/// reaching this builder. Restricted to crate visibility so a future caller
699/// outside this crate cannot accidentally skip the guard.
700pub(crate) fn container_inspect_command(runtime: ContainerRuntime, container_id: &str) -> String {
701    format!("{} inspect {}", runtime.as_str(), container_id)
702}
703
704/// Translate a non-zero docker/podman exit code into a short
705/// human-readable hint. Returns `None` for codes without a well-known
706/// meaning so the UI can fall back to the bare number. Exit 0 has no
707/// entry because the detail panel only annotates failed exits.
708/// Sources: docker docs + Linux signal table.
709pub fn exit_code_meaning(code: i32) -> Option<&'static str> {
710    match code {
711        1 => Some("application error"),
712        125 => Some("docker run failed"),
713        126 => Some("command not executable"),
714        127 => Some("command not found"),
715        130 => Some("interrupted (SIGINT)"),
716        137 => Some("killed (SIGKILL / OOM)"),
717        139 => Some("segfault (SIGSEGV)"),
718        143 => Some("terminated (SIGTERM)"),
719        _ => None,
720    }
721}
722
723/// Parse `docker inspect <id>` stdout into `ContainerInspect`. The command
724/// always returns a JSON array; we take the first element. Missing fields
725/// degrade to defaults rather than fail so a partial response still
726/// renders something useful.
727pub fn parse_container_inspect(output: &str) -> Result<ContainerInspect, String> {
728    let trimmed = output.trim();
729    if trimmed.is_empty() {
730        return Err(crate::messages::CONTAINER_INSPECT_EMPTY.to_string());
731    }
732    let value: serde_json::Value = serde_json::from_str(trimmed)
733        .map_err(|e| crate::messages::container_inspect_parse_failed(&e.to_string()))?;
734    let entry = value
735        .as_array()
736        .and_then(|a| a.first())
737        .ok_or_else(|| crate::messages::CONTAINER_INSPECT_EMPTY.to_string())?;
738
739    let state = &entry["State"];
740    let config = &entry["Config"];
741    let network_settings = &entry["NetworkSettings"];
742
743    let exit_code = state["ExitCode"].as_i64().unwrap_or(0) as i32;
744    // Podman 5.x and docker both emit `OOMKilled`. Podman 3.x (still the
745    // packaged default on Ubuntu 22.04 LTS) emits `OomKilled`. Try both so
746    // OOM-killed containers surface in the ATTENTION card regardless of
747    // remote runtime version.
748    let oom_killed = state["OOMKilled"]
749        .as_bool()
750        .or_else(|| state["OomKilled"].as_bool())
751        .unwrap_or(false);
752    let started_at = state["StartedAt"].as_str().unwrap_or("").to_string();
753    let finished_at = state["FinishedAt"].as_str().unwrap_or("").to_string();
754    let health = state
755        .get("Health")
756        .and_then(|h| h.get("Status"))
757        .and_then(|s| s.as_str())
758        .map(|s| s.to_string());
759    let restart_count = entry["RestartCount"].as_u64().unwrap_or(0) as u32;
760
761    let command = config["Cmd"].as_array().map(|arr| {
762        arr.iter()
763            .filter_map(|v| v.as_str().map(|s| s.to_string()))
764            .collect()
765    });
766    let entrypoint = config["Entrypoint"].as_array().map(|arr| {
767        arr.iter()
768            .filter_map(|v| v.as_str().map(|s| s.to_string()))
769            .collect()
770    });
771    let env_count = config["Env"].as_array().map(|arr| arr.len()).unwrap_or(0);
772    let mount_count = entry["Mounts"].as_array().map(|arr| arr.len()).unwrap_or(0);
773
774    let networks = network_settings
775        .get("Networks")
776        .and_then(|n| n.as_object())
777        .map(|map| {
778            map.iter()
779                .map(|(name, cfg)| NetworkInfo {
780                    name: name.clone(),
781                    ip_address: cfg
782                        .get("IPAddress")
783                        .and_then(|v| v.as_str())
784                        .unwrap_or("")
785                        .to_string(),
786                })
787                .collect::<Vec<_>>()
788        })
789        .unwrap_or_default();
790
791    let host_config = &entry["HostConfig"];
792
793    let image_digest = entry["Image"]
794        .as_str()
795        .filter(|s| !s.is_empty())
796        .map(|s| s.to_string());
797    let restart_policy = host_config
798        .get("RestartPolicy")
799        .and_then(|p| p.get("Name"))
800        .and_then(|s| s.as_str())
801        .filter(|s| !s.is_empty() && *s != "no")
802        .map(|s| s.to_string());
803    let user = config["User"]
804        .as_str()
805        .filter(|s| !s.is_empty())
806        .map(|s| s.to_string());
807    let privileged = host_config["Privileged"].as_bool().unwrap_or(false);
808    let readonly_rootfs = host_config["ReadonlyRootfs"].as_bool().unwrap_or(false);
809    let apparmor_profile = host_config["AppArmorProfile"]
810        .as_str()
811        .or_else(|| entry["AppArmorProfile"].as_str())
812        .filter(|s| !s.is_empty())
813        .map(|s| s.to_string());
814    let seccomp_profile = host_config["SecurityOpt"].as_array().and_then(|arr| {
815        arr.iter()
816            .filter_map(|v| v.as_str())
817            .find_map(|s| s.strip_prefix("seccomp=").map(|v| v.to_string()))
818    });
819    let cap_add = host_config["CapAdd"]
820        .as_array()
821        .map(|arr| {
822            arr.iter()
823                .filter_map(|v| v.as_str().map(|s| s.to_string()))
824                .collect()
825        })
826        .unwrap_or_default();
827    let cap_drop = host_config["CapDrop"]
828        .as_array()
829        .map(|arr| {
830            arr.iter()
831                .filter_map(|v| v.as_str().map(|s| s.to_string()))
832                .collect()
833        })
834        .unwrap_or_default();
835    let mounts = entry["Mounts"]
836        .as_array()
837        .map(|arr| {
838            arr.iter()
839                .map(|m| MountInfo {
840                    source: m["Source"].as_str().unwrap_or("").to_string(),
841                    destination: m["Destination"].as_str().unwrap_or("").to_string(),
842                    read_only: !m["RW"].as_bool().unwrap_or(true),
843                })
844                .collect()
845        })
846        .unwrap_or_default();
847    let labels = config.get("Labels").and_then(|l| l.as_object());
848    let label = |key: &str| {
849        labels
850            .and_then(|l| l.get(key))
851            .and_then(|v| v.as_str())
852            .filter(|s| !s.is_empty())
853            .map(|s| s.to_string())
854    };
855    let compose_project = label("com.docker.compose.project");
856    let compose_service = label("com.docker.compose.service");
857    let image_version = label("org.opencontainers.image.version");
858    let image_revision = label("org.opencontainers.image.revision");
859    let image_source = label("org.opencontainers.image.source");
860
861    let created_at = entry["Created"].as_str().unwrap_or("").to_string();
862    // State.Pid is `0` when the container is not running. Drop the zero so
863    // the UI does not render a misleading "pid 0" row for exited rows.
864    let pid = state["Pid"].as_u64().filter(|n| *n > 0).map(|n| n as u32);
865    let hostname = config["Hostname"]
866        .as_str()
867        .filter(|s| !s.is_empty())
868        .map(|s| s.to_string());
869    let working_dir = config["WorkingDir"]
870        .as_str()
871        .filter(|s| !s.is_empty())
872        .map(|s| s.to_string());
873    let stop_signal = config["StopSignal"]
874        .as_str()
875        .filter(|s| !s.is_empty())
876        .map(|s| s.to_string());
877    let stop_timeout = config["StopTimeout"].as_u64().map(|n| n as u32);
878
879    let network_mode = host_config["NetworkMode"]
880        .as_str()
881        .filter(|s| !s.is_empty() && *s != "default")
882        .map(|s| s.to_string());
883    // HostConfig.Memory is bytes, 0 = unlimited (drop). Same for NanoCpus.
884    let memory_limit = host_config["Memory"].as_u64().filter(|n| *n > 0);
885    let cpu_limit_nanos = host_config["NanoCpus"].as_u64().filter(|n| *n > 0);
886    // PidsLimit is i64. 0 or -1 means unlimited; drop both.
887    let pids_limit = host_config["PidsLimit"].as_i64().filter(|n| *n > 0);
888    // LogConfig.Type defaults to "json-file" on docker. Always carry it
889    // so the renderer can decide whether to surface "Logs" only when
890    // non-default.
891    let log_driver = host_config
892        .get("LogConfig")
893        .and_then(|l| l.get("Type"))
894        .and_then(|v| v.as_str())
895        .filter(|s| !s.is_empty())
896        .map(|s| s.to_string());
897
898    let healthcheck = config.get("Healthcheck");
899    let health_test = healthcheck
900        .and_then(|h| h.get("Test"))
901        .and_then(|t| t.as_array())
902        .map(|arr| {
903            arr.iter()
904                .filter_map(|v| v.as_str().map(|s| s.to_string()))
905                .collect::<Vec<_>>()
906        })
907        .filter(|v| !v.is_empty());
908    let health_interval_ns = healthcheck
909        .and_then(|h| h.get("Interval"))
910        .and_then(|v| v.as_u64())
911        .filter(|n| *n > 0);
912    let health_failing_streak = state
913        .get("Health")
914        .and_then(|h| h.get("FailingStreak"))
915        .and_then(|v| v.as_u64())
916        .map(|n| n as u32);
917
918    Ok(ContainerInspect {
919        exit_code,
920        oom_killed,
921        started_at,
922        finished_at,
923        created_at,
924        health,
925        restart_count,
926        command,
927        entrypoint,
928        env_count,
929        mount_count,
930        networks,
931        image_digest,
932        restart_policy,
933        user,
934        privileged,
935        readonly_rootfs,
936        apparmor_profile,
937        seccomp_profile,
938        cap_add,
939        cap_drop,
940        mounts,
941        compose_project,
942        compose_service,
943        pid,
944        stop_signal,
945        stop_timeout,
946        image_version,
947        image_revision,
948        image_source,
949        working_dir,
950        hostname,
951        memory_limit,
952        cpu_limit_nanos,
953        pids_limit,
954        log_driver,
955        network_mode,
956        health_test,
957        health_interval_ns,
958        health_failing_streak,
959    })
960}
961
962/// Parse a Docker `Up …` status string into a compact uptime label.
963/// Returns `None` for any non-running state (Exited, Created, Restarting,
964/// Paused without an `Up` prefix, empty). Cells render `<1m` for
965/// sub-minute uptimes, `1m` / `5m` / `12h` / `5w` / `3mo` / `2y` otherwise.
966/// Format follows Docker's `units.HumanDuration`.
967pub fn parse_uptime_from_status(s: &str) -> Option<String> {
968    let body = s.strip_prefix("Up ")?;
969    let body = body.split('(').next()?.trim();
970    if body == "Less than a second" {
971        return Some("<1m".to_string());
972    }
973    if body == "About a minute" {
974        return Some("1m".to_string());
975    }
976    if body == "About an hour" {
977        return Some("1h".to_string());
978    }
979    let mut parts = body.split_whitespace();
980    let count: u64 = parts.next()?.parse().ok()?;
981    let unit = parts.next()?;
982    let suffix = match unit {
983        "second" | "seconds" => return Some("<1m".to_string()),
984        "minute" | "minutes" => "m",
985        "hour" | "hours" => "h",
986        "day" | "days" => "d",
987        "week" | "weeks" => "w",
988        "month" | "months" => "mo",
989        "year" | "years" => "y",
990        _ => return None,
991    };
992    Some(format!("{count}{suffix}"))
993}
994
995/// Synchronously fetch + parse `container inspect`. Validates the
996/// container ID before issuing the SSH call.
997pub fn fetch_container_inspect(
998    ctx: &SshContext<'_>,
999    runtime: ContainerRuntime,
1000    container_id: &str,
1001) -> Result<ContainerInspect, String> {
1002    validate_container_id(container_id)?;
1003    let command = container_inspect_command(runtime, container_id);
1004    let result = crate::snippet::run_snippet(
1005        ctx.alias,
1006        ctx.config_path,
1007        &command,
1008        ctx.askpass,
1009        ctx.bw_session,
1010        true,
1011        ctx.has_tunnel,
1012    );
1013    match result {
1014        Ok(r) if r.status.success() => parse_container_inspect(&r.stdout),
1015        Ok(r) => Err(crate::messages::container_command_failed(
1016            r.status.code().unwrap_or(1),
1017        )),
1018        Err(e) => Err(e.to_string()),
1019    }
1020}
1021
1022/// Spawn a background thread to run `container inspect`. Mirrors the
1023/// `spawn_container_listing` pattern so the call site looks identical.
1024pub fn spawn_container_inspect_listing<F>(
1025    ctx: OwnedSshContext,
1026    runtime: ContainerRuntime,
1027    container_id: String,
1028    send: F,
1029) where
1030    F: FnOnce(String, String, Result<ContainerInspect, String>) + Send + 'static,
1031{
1032    std::thread::spawn(move || {
1033        let borrowed = SshContext {
1034            alias: &ctx.alias,
1035            config_path: &ctx.config_path,
1036            askpass: ctx.askpass.as_deref(),
1037            bw_session: ctx.bw_session.as_deref(),
1038            has_tunnel: ctx.has_tunnel,
1039        };
1040        let result = fetch_container_inspect(&borrowed, runtime, &container_id);
1041        send(ctx.alias, container_id, result);
1042    });
1043}
1044
1045/// Build the `<runtime> logs --tail <n> <id>` command. The
1046/// `--tail` cap is enforced server-side so the SSH stream stays
1047/// bounded even on a noisy container.
1048pub(crate) fn container_logs_command(
1049    runtime: ContainerRuntime,
1050    container_id: &str,
1051    tail: usize,
1052) -> String {
1053    format!("{} logs --tail {} {}", runtime.as_str(), tail, container_id)
1054}
1055
1056/// Synchronously fetch logs and split into lines. Returns the raw
1057/// captured stdout split on `\n` so the renderer does not have to
1058/// re-parse. Empty trailing lines are dropped.
1059pub fn fetch_container_logs(
1060    ctx: &SshContext<'_>,
1061    runtime: ContainerRuntime,
1062    container_id: &str,
1063    tail: usize,
1064) -> Result<Vec<String>, String> {
1065    validate_container_id(container_id)?;
1066    let command = container_logs_command(runtime, container_id, tail);
1067    let result = crate::snippet::run_snippet(
1068        ctx.alias,
1069        ctx.config_path,
1070        &command,
1071        ctx.askpass,
1072        ctx.bw_session,
1073        true,
1074        ctx.has_tunnel,
1075    );
1076    match result {
1077        Ok(r) if r.status.success() => Ok(parse_log_output(&r.stdout, &r.stderr)),
1078        Ok(r) => Err(crate::messages::container_command_failed(
1079            r.status.code().unwrap_or(1),
1080        )),
1081        Err(e) => Err(e.to_string()),
1082    }
1083}
1084
1085/// Merge stdout (app logs) and stderr (errors) into a single chronological
1086/// stream. Many container runtimes split levels across the two streams;
1087/// re-interleaving them is closer to what `docker logs` shows on a TTY.
1088/// Trailing blank lines are stripped from each stream before merging so a
1089/// stdout block that ends in a newline does not introduce a phantom empty
1090/// row between the two streams.
1091pub(crate) fn parse_log_output(stdout: &str, stderr: &str) -> Vec<String> {
1092    let mut lines: Vec<String> = stdout.lines().map(|s| s.to_string()).collect();
1093    while lines.last().map(|s| s.is_empty()).unwrap_or(false) {
1094        lines.pop();
1095    }
1096    for s in stderr.lines() {
1097        lines.push(s.to_string());
1098    }
1099    while lines.last().map(|s| s.is_empty()).unwrap_or(false) {
1100        lines.pop();
1101    }
1102    lines
1103}
1104
1105/// Spawn a background thread to run `container logs`. Same shape as
1106/// `spawn_container_inspect_listing`. In demo mode the SSH call is
1107/// short-circuited with a deterministic synthetic log stream so the
1108/// logs viewer (and its `/` search) is exercisable without a remote.
1109pub fn spawn_container_logs_fetch<F>(
1110    ctx: OwnedSshContext,
1111    runtime: ContainerRuntime,
1112    container_id: String,
1113    container_name: String,
1114    tail: usize,
1115    send: F,
1116) where
1117    F: FnOnce(String, String, String, Result<Vec<String>, String>) + Send + 'static,
1118{
1119    if crate::demo_flag::is_demo() {
1120        let lines = demo_log_lines(&container_name, tail);
1121        log::debug!(
1122            "[purple] container_logs_fetch: demo short-circuit alias={} id={} lines={}",
1123            ctx.alias,
1124            container_id,
1125            lines.len()
1126        );
1127        send(ctx.alias, container_id, container_name, Ok(lines));
1128        return;
1129    }
1130    std::thread::spawn(move || {
1131        let borrowed = SshContext {
1132            alias: &ctx.alias,
1133            config_path: &ctx.config_path,
1134            askpass: ctx.askpass.as_deref(),
1135            bw_session: ctx.bw_session.as_deref(),
1136            has_tunnel: ctx.has_tunnel,
1137        };
1138        let result = fetch_container_logs(&borrowed, runtime, &container_id, tail);
1139        send(ctx.alias, container_id, container_name, result);
1140    });
1141}
1142
1143/// Generate a deterministic stream of fake log lines for demo mode.
1144/// Mixes INFO / WARN / ERROR / DEBUG with realistic-looking content
1145/// (HTTP requests, DB pings, retries, timeouts) so the user can
1146/// usefully press `/` and find matches under `--demo`. Anchored to
1147/// `demo_flag::now_secs()` so timestamps stay stable across renders.
1148pub(crate) fn demo_log_lines(container_name: &str, tail: usize) -> Vec<String> {
1149    use std::time::{Duration, UNIX_EPOCH};
1150    // Cheap hash to fan out per-container variation without bringing
1151    // `rand` into the binary.
1152    let seed: u32 = container_name
1153        .bytes()
1154        .fold(0u32, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u32));
1155
1156    // Templates rotate every line. Index 0 is the freshest log line.
1157    let templates: &[&str] = &[
1158        "INFO  [{}] handled GET /api/v1/health 200 in 14ms",
1159        "INFO  [{}] handled POST /api/v1/orders 201 in 38ms (user_id={user})",
1160        "DEBUG [{}] cache hit key=session:{user} ttl=3600",
1161        "INFO  [{}] handled GET /api/v1/users/{user} 200 in 11ms",
1162        "WARN  [{}] slow query detected duration=812ms statement=SELECT FROM orders",
1163        "INFO  [{}] connection pool size=12 idle=8 in_use=4",
1164        "DEBUG [{}] flushing metrics batch size=64",
1165        "INFO  [{}] handled GET /api/v1/inventory 200 in 22ms",
1166        "ERROR [{}] upstream timeout after 5000ms target=payments retry=1",
1167        "WARN  [{}] retrying request attempt=2 backoff=250ms",
1168        "INFO  [{}] handled POST /api/v1/login 200 in 31ms",
1169        "DEBUG [{}] gc cycle reclaimed=42MB took=18ms",
1170        "INFO  [{}] heartbeat ok rss=128MB cpu=4%",
1171        "ERROR [{}] failed to acquire lock resource=cache_warmer waiter=3",
1172        "INFO  [{}] handled DELETE /api/v1/sessions/{user} 204 in 9ms",
1173        "WARN  [{}] disk usage at 78% mount=/data threshold=80%",
1174        "INFO  [{}] handled GET /api/v1/search?q=widget 200 in 47ms",
1175        "DEBUG [{}] websocket ping rtt=12ms",
1176    ];
1177
1178    // Always use `demo_flag::now_secs()` even outside demo mode: the
1179    // helper's OnceLock caches the first wallclock value, so repeated
1180    // calls within a process return the same instant. Branching on
1181    // `is_demo()` would let tests cross a second boundary and flake.
1182    let now = crate::demo_flag::now_secs();
1183
1184    // Map each line to a timestamp working backwards from now. One log
1185    // every 3 seconds keeps the time range realistic for a 200-line tail.
1186    let mut lines = Vec::with_capacity(tail);
1187    for i in 0..tail {
1188        let template = templates[(i + seed as usize) % templates.len()];
1189        let user = 1000 + ((seed as usize + i * 7) % 50);
1190        let secs_back = (i as u64) * 3;
1191        let line_time = UNIX_EPOCH + Duration::from_secs(now.saturating_sub(secs_back));
1192        let ts = format_demo_timestamp(line_time);
1193        let body = template
1194            .replace("{}", container_name)
1195            .replace("{user}", &user.to_string());
1196        lines.push(format!("{} {}", ts, body));
1197    }
1198    // Render flush-top with the newest line at the bottom of the
1199    // viewport, matching real `docker logs --tail` output.
1200    lines.reverse();
1201    lines
1202}
1203
1204fn format_demo_timestamp(t: std::time::SystemTime) -> String {
1205    use std::time::UNIX_EPOCH;
1206    let secs = t
1207        .duration_since(UNIX_EPOCH)
1208        .map(|d| d.as_secs())
1209        .unwrap_or(0);
1210    // Fast UTC breakdown without dragging in chrono. Good enough for a
1211    // demo timestamp where leap seconds / DST are irrelevant.
1212    let days_since_epoch = (secs / 86_400) as i64;
1213    let seconds_in_day = (secs % 86_400) as u32;
1214    let h = seconds_in_day / 3600;
1215    let m = (seconds_in_day % 3600) / 60;
1216    let s = seconds_in_day % 60;
1217    let (y, mo, d) = civil_from_days(days_since_epoch);
1218    format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", y, mo, d, h, m, s)
1219}
1220
1221/// Convert days-since-1970-01-01 to (year, month, day). Howard Hinnant's
1222/// civil_from_days algorithm — proleptic Gregorian, 1970 = day 0.
1223fn civil_from_days(z: i64) -> (i32, u32, u32) {
1224    let z = z + 719_468;
1225    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
1226    let doe = (z - era * 146_097) as u64;
1227    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
1228    let y = yoe as i64 + era * 400;
1229    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1230    let mp = (5 * doy + 2) / 153;
1231    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
1232    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
1233    let y = if m <= 2 { y + 1 } else { y };
1234    (y as i32, m, d)
1235}
1236
1237// ---------------------------------------------------------------------------
1238// JSON lines cache
1239// ---------------------------------------------------------------------------
1240
1241/// A cached container listing for a single host. `engine_version` is the
1242/// daemon's `Server.Version` captured during the last refresh, surfaced in
1243/// the host detail panel; `None` means the version sub-call did not return
1244/// or the cache was written by an older purple build.
1245#[derive(Debug, Clone)]
1246pub struct ContainerCacheEntry {
1247    pub timestamp: u64,
1248    pub runtime: ContainerRuntime,
1249    pub engine_version: Option<String>,
1250    pub containers: Vec<ContainerInfo>,
1251}
1252
1253/// Serde helper for a single JSON line in the cache file. `engine_version`
1254/// uses `serde(default)` so cache files written before this field existed
1255/// still deserialize cleanly.
1256#[derive(Serialize, Deserialize)]
1257struct CacheLine {
1258    alias: String,
1259    timestamp: u64,
1260    runtime: ContainerRuntime,
1261    #[serde(default, skip_serializing_if = "Option::is_none")]
1262    engine_version: Option<String>,
1263    containers: Vec<ContainerInfo>,
1264}
1265
1266// Test-only thread-local override for the cache file path.
1267// Mirrors `preferences::set_path_override` so unit tests can write
1268// to a tempdir instead of polluting the real `~/.purple/`.
1269#[cfg(test)]
1270thread_local! {
1271    static PATH_OVERRIDE: std::cell::RefCell<Option<std::path::PathBuf>> =
1272        const { std::cell::RefCell::new(None) };
1273}
1274
1275#[cfg(test)]
1276pub fn set_path_override(path: std::path::PathBuf) {
1277    PATH_OVERRIDE.with(|p| *p.borrow_mut() = Some(path));
1278}
1279
1280#[cfg(test)]
1281#[allow(dead_code)]
1282pub fn clear_path_override() {
1283    PATH_OVERRIDE.with(|p| *p.borrow_mut() = None);
1284}
1285
1286fn cache_path() -> Option<std::path::PathBuf> {
1287    // Tests MUST opt in via `set_path_override` before any code
1288    // path that loads or saves the cache. Falling through to the
1289    // production path lets a forgotten override pollute (and in
1290    // the orphan-prune branch of `reload_hosts`, wipe) the user's
1291    // real `~/.purple/container_cache.jsonl`.
1292    #[cfg(test)]
1293    {
1294        PATH_OVERRIDE.with(|p| p.borrow().clone())
1295    }
1296    #[cfg(not(test))]
1297    {
1298        dirs::home_dir().map(|h| h.join(".purple").join("container_cache.jsonl"))
1299    }
1300}
1301
1302/// Load container cache from `~/.purple/container_cache.jsonl`.
1303/// Malformed lines are silently ignored. Duplicate aliases: last-write-wins.
1304pub fn load_container_cache() -> HashMap<String, ContainerCacheEntry> {
1305    let mut map = HashMap::new();
1306    let Some(path) = cache_path() else {
1307        return map;
1308    };
1309    let Ok(content) = std::fs::read_to_string(&path) else {
1310        return map;
1311    };
1312    for line in content.lines() {
1313        let trimmed = line.trim();
1314        if trimmed.is_empty() {
1315            continue;
1316        }
1317        if let Ok(entry) = serde_json::from_str::<CacheLine>(trimmed) {
1318            map.insert(
1319                entry.alias,
1320                ContainerCacheEntry {
1321                    timestamp: entry.timestamp,
1322                    runtime: entry.runtime,
1323                    engine_version: entry.engine_version,
1324                    containers: entry.containers,
1325                },
1326            );
1327        }
1328    }
1329    map
1330}
1331
1332/// Parse container cache from JSONL content string (for demo/test use).
1333pub fn parse_container_cache_content(content: &str) -> HashMap<String, ContainerCacheEntry> {
1334    let mut map = HashMap::new();
1335    for line in content.lines() {
1336        let trimmed = line.trim();
1337        if trimmed.is_empty() {
1338            continue;
1339        }
1340        if let Ok(entry) = serde_json::from_str::<CacheLine>(trimmed) {
1341            map.insert(
1342                entry.alias,
1343                ContainerCacheEntry {
1344                    timestamp: entry.timestamp,
1345                    runtime: entry.runtime,
1346                    engine_version: entry.engine_version,
1347                    containers: entry.containers,
1348                },
1349            );
1350        }
1351    }
1352    map
1353}
1354
1355/// Save container cache to `~/.purple/container_cache.jsonl` via atomic write.
1356pub fn save_container_cache(cache: &HashMap<String, ContainerCacheEntry>) {
1357    if crate::demo_flag::is_demo() {
1358        return;
1359    }
1360    let Some(path) = cache_path() else {
1361        return;
1362    };
1363    let mut lines = Vec::with_capacity(cache.len());
1364    for (alias, entry) in cache {
1365        let line = CacheLine {
1366            alias: alias.clone(),
1367            timestamp: entry.timestamp,
1368            runtime: entry.runtime,
1369            engine_version: entry.engine_version.clone(),
1370            containers: entry.containers.clone(),
1371        };
1372        if let Ok(s) = serde_json::to_string(&line) {
1373            lines.push(s);
1374        }
1375    }
1376    let content = lines.join("\n");
1377    log::debug!(
1378        "[purple] save_container_cache: {} host entries, {} bytes -> {}",
1379        cache.len(),
1380        content.len(),
1381        path.display()
1382    );
1383    if let Err(e) = crate::fs_util::atomic_write(&path, content.as_bytes()) {
1384        log::warn!(
1385            "[config] Failed to write container cache {}: {e}",
1386            path.display()
1387        );
1388    }
1389}
1390
1391// ---------------------------------------------------------------------------
1392// String truncation
1393// ---------------------------------------------------------------------------
1394
1395/// Truncate a string to at most `max` characters. Appends ".." if truncated.
1396pub fn truncate_str(s: &str, max: usize) -> String {
1397    let count = s.chars().count();
1398    if count <= max {
1399        s.to_string()
1400    } else {
1401        let cut = max.saturating_sub(2);
1402        let end = s.char_indices().nth(cut).map(|(i, _)| i).unwrap_or(s.len());
1403        format!("{}..", &s[..end])
1404    }
1405}
1406
1407// ---------------------------------------------------------------------------
1408// Relative time
1409// ---------------------------------------------------------------------------
1410
1411/// Format a duration in seconds as a compact label (`12s`, `5m`,
1412/// `2h`, `3d`). Used for the in-border staleness badge where width
1413/// is precious and the surrounding label (`synced`) already says
1414/// "ago" without the suffix.
1415pub fn format_uptime_short(seconds: u64) -> String {
1416    if seconds < 60 {
1417        format!("{seconds}s")
1418    } else if seconds < 3600 {
1419        format!("{}m", seconds / 60)
1420    } else if seconds < 86400 {
1421        format!("{}h", seconds / 3600)
1422    } else {
1423        format!("{}d", seconds / 86400)
1424    }
1425}
1426
1427/// Format a Unix timestamp as a human-readable relative time string.
1428/// Honours `demo_flag::now_secs()` when demo mode is active so visual
1429/// regression goldens stay byte-stable across long-running test
1430/// processes (same pattern as `history::format_time_ago`).
1431pub fn format_relative_time(timestamp: u64) -> String {
1432    let now = if crate::demo_flag::is_demo() {
1433        crate::demo_flag::now_secs()
1434    } else {
1435        SystemTime::now()
1436            .duration_since(UNIX_EPOCH)
1437            .unwrap_or_default()
1438            .as_secs()
1439    };
1440    let diff = now.saturating_sub(timestamp);
1441    if diff < 60 {
1442        "just now".to_string()
1443    } else if diff < 3600 {
1444        format!("{}m ago", diff / 60)
1445    } else if diff < 86400 {
1446        format!("{}h ago", diff / 3600)
1447    } else {
1448        format!("{}d ago", diff / 86400)
1449    }
1450}
1451
1452// ---------------------------------------------------------------------------
1453// Tests
1454// ---------------------------------------------------------------------------
1455
1456#[cfg(test)]
1457#[path = "containers_tests.rs"]
1458mod tests;