xbp 10.14.2

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! Feature-gated Docker helpers. When the `docker` feature is enabled, the
//! `xbp -l` / `xbp list` view can include a snapshot of running containers.

use crate::logging::{log_info, log_warn};
use crate::utils::command_exists;
use colored::Colorize;
use serde::Deserialize;
use std::process::Stdio;
use tokio::process::Command;

const COMMAND_NAME: &str = "docker";

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DockerContainer {
    pub id: String,
    pub names: String,
    pub status: Option<String>,
    pub ports: Option<String>,
}

#[derive(Debug, Deserialize)]
struct DockerPsRow {
    #[serde(rename = "ID")]
    id: String,
    #[serde(rename = "Names")]
    names: String,
    #[serde(rename = "Status")]
    status: Option<String>,
    #[serde(rename = "Ports")]
    ports: Option<String>,
}

/// Print a concise `docker ps` table if Docker is available.
/// Tries non-sudo first, then a non-interactive sudo fallback when possible.
pub async fn print_docker_ps(debug: bool) -> Result<(), String> {
    if !command_exists("docker") {
        if debug {
            println!("{}", "Docker not detected; skipping docker ps.".dimmed());
        }
        return Ok(());
    }

    let has_sudo = command_exists("sudo");
    match list_containers(has_sudo, debug).await {
        Ok(containers) => {
            render_docker_table(&containers);
            let _ = log_info(COMMAND_NAME, "Rendered docker ps snapshot", None).await;
            Ok(())
        }
        Err(err) => {
            println!("{} {}", "docker ps failed:".yellow(), err.trim());
            println!(
                "{}",
                "Tip: try `sudo xbp -l` if Docker requires root.".bright_blue()
            );
            let _ = log_warn(COMMAND_NAME, "Unable to render docker ps", Some(&err)).await;
            Ok(())
        }
    }
}

/// Attempt to stream `docker logs` for a container whose name or id matches `target`.
/// Returns `Ok(Some(()))` when a Docker container was matched and logs were streamed,
/// `Ok(None)` when no matching container exists, and `Err` when Docker is available
/// but an error occurred while resolving or streaming logs.
pub async fn try_stream_docker_logs(target: &str, debug: bool) -> Result<Option<()>, String> {
    if target.trim().is_empty() {
        return Ok(None);
    }

    let has_sudo = command_exists("sudo");
    let containers = list_containers(has_sudo, debug).await?;
    if containers.is_empty() {
        return Ok(None);
    }

    if let Some(container) = containers
        .iter()
        .find(|c| container_matches_target(c, target))
        .cloned()
    {
        stream_docker_logs(&container.id, has_sudo, debug).await?;
        return Ok(Some(()));
    }

    Ok(None)
}

fn render_docker_table(containers: &[DockerContainer]) {
    println!("\n{}", "Docker containers".bright_blue().bold());

    if containers.is_empty() {
        println!("{}", "No running containers.".dimmed());
        return;
    }

    let display_rows: Vec<[String; 4]> = containers
        .iter()
        .map(|c| {
            let id = c.id.chars().take(12).collect::<String>();
            let status = c.status.clone().unwrap_or_else(|| "Unknown".to_string());
            let ports = c.ports.clone().unwrap_or_else(|| "".to_string());
            [id, c.names.clone(), status, ports]
        })
        .collect();

    let headers = ["CONTAINER", "NAMES", "STATUS", "PORTS"];
    let mut widths: [usize; 4] = [0; 4];
    for (i, head) in headers.iter().enumerate() {
        widths[i] = head.len();
    }
    for row in &display_rows {
        for i in 0..4 {
            widths[i] = widths[i].max(row[i].len());
        }
    }

    let top = make_line('', '', '', &widths);
    let mid = make_line('', '', '', &widths);
    let bottom = make_line('', '', '', &widths);

    println!("{}", top);
    println!(
        "{}{}{}{}",
        pad(headers[0], widths[0]).bold().white(),
        pad(headers[1], widths[1]).bold().white(),
        pad(headers[2], widths[2]).bold().white(),
        pad(headers[3], widths[3]).bold().white()
    );
    println!("{}", mid);

    for row in &display_rows {
        println!(
            "{}{}{}{}",
            pad(&row[0], widths[0]).cyan(),
            pad(&row[1], widths[1]).green(),
            color_status(&row[2], widths[2]),
            pad(&row[3], widths[3]).bright_black()
        );
    }

    println!("{}", bottom);
}

fn candidate_ps_commands_with_format(has_sudo: bool, format: &str) -> Vec<Vec<String>> {
    let mut cmds: Vec<Vec<String>> = vec![vec![
        "docker".to_string(),
        "ps".to_string(),
        "--format".to_string(),
        format.to_string(),
    ]];

    if has_sudo {
        cmds.push(vec![
            "sudo".to_string(),
            "-n".to_string(),
            "docker".to_string(),
            "ps".to_string(),
            "--format".to_string(),
            format.to_string(),
        ]);
    }

    cmds
}

async fn list_containers(has_sudo: bool, debug: bool) -> Result<Vec<DockerContainer>, String> {
    if !command_exists("docker") {
        if debug {
            println!(
                "{}",
                "Docker not detected; skipping docker container lookup.".dimmed()
            );
        }
        return Ok(vec![]);
    }

    let candidates = candidate_ps_commands_with_format(has_sudo, "{{json .}}");
    let mut last_error: Option<String> = None;

    for candidate in candidates {
        let (binary, args) = candidate
            .split_first()
            .ok_or_else(|| "docker command args were empty".to_string())?;

        let output = Command::new(binary)
            .args(args)
            .output()
            .await
            .map_err(|e| format!("Failed to run {}: {}", binary, e))?;

        if output.status.success() {
            let stdout = String::from_utf8_lossy(&output.stdout);
            let mut containers = Vec::new();

            for line in stdout.lines() {
                if line.trim().is_empty() {
                    continue;
                }

                match serde_json::from_str::<DockerPsRow>(line) {
                    Ok(row) => containers.push(DockerContainer {
                        id: row.id,
                        names: row.names,
                        status: row.status,
                        ports: row.ports,
                    }),
                    Err(err) => {
                        if debug {
                            println!(
                                "{} {}",
                                "Failed to parse docker ps output line:".yellow(),
                                line
                            );
                            println!("{}", err);
                        }
                    }
                }
            }

            return Ok(containers);
        } else {
            last_error = Some(String::from_utf8_lossy(&output.stderr).to_string());
            if debug {
                println!(
                    "{} {}",
                    "docker ps failed:".yellow(),
                    last_error.as_deref().unwrap_or_default()
                );
            }
        }
    }

    if let Some(err) = last_error {
        return Err(err);
    }

    Ok(vec![])
}

fn make_line(left: char, mid: char, right: char, widths: &[usize; 4]) -> String {
    let mut parts = Vec::new();
    for (idx, w) in widths.iter().enumerate() {
        let fill = "".repeat(*w + 2);
        if idx == 0 {
            parts.push(format!("{}{}", left, fill));
        } else {
            parts.push(format!("{}{}", mid, fill));
        }
    }
    parts.push(right.to_string());
    parts.join("")
}

fn pad(value: impl AsRef<str>, width: usize) -> String {
    format!("{:width$}", value.as_ref(), width = width)
}

fn color_status(status: &str, width: usize) -> colored::ColoredString {
    let base = pad(status, width);
    if status.starts_with("Up") {
        base.green()
    } else if status.contains("Exited") || status.contains("Dead") {
        base.red()
    } else {
        base.yellow()
    }
}

fn container_matches_target(container: &DockerContainer, target: &str) -> bool {
    if target.trim().is_empty() {
        return false;
    }

    if container.id.starts_with(target) {
        return true;
    }

    container
        .names
        .split(',')
        .map(|name| name.trim())
        .any(|name| name == target)
}

fn candidate_log_commands(container_id: &str, has_sudo: bool) -> Vec<Vec<String>> {
    let mut cmds: Vec<Vec<String>> = vec![vec![
        "docker".to_string(),
        "logs".to_string(),
        "-f".to_string(),
        container_id.to_string(),
    ]];

    if has_sudo {
        cmds.push(vec![
            "sudo".to_string(),
            "-n".to_string(),
            "docker".to_string(),
            "logs".to_string(),
            "-f".to_string(),
            container_id.to_string(),
        ]);
    }

    cmds
}

async fn stream_docker_logs(container_id: &str, has_sudo: bool, debug: bool) -> Result<(), String> {
    let candidates = candidate_log_commands(container_id, has_sudo);
    let mut last_error: Option<String> = None;

    for candidate in candidates {
        let (binary, args) = candidate
            .split_first()
            .ok_or_else(|| "docker command args were empty".to_string())?;

        if debug {
            println!("running {} {}", binary, args.join(" "));
        }

        let status = Command::new(binary)
            .args(args)
            .stdout(Stdio::inherit())
            .stderr(Stdio::inherit())
            .status()
            .await
            .map_err(|e| format!("Failed to run {}: {}", binary, e))?;

        if status.success() {
            return Ok(());
        } else {
            last_error = Some(status.to_string());
        }
    }

    Err(format!(
        "docker logs failed for container {}: {}",
        container_id,
        last_error.unwrap_or_else(|| "unknown error".to_string())
    ))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn builds_non_sudo_first() {
        let cmds = candidate_ps_commands_with_format(true, "{{json .}}");
        assert_eq!(cmds[0][0], "docker");
        assert_eq!(cmds[0][1], "ps");
    }

    #[test]
    fn sudo_variant_appended_when_available() {
        let cmds = candidate_ps_commands_with_format(true, "{{json .}}");
        assert!(cmds
            .iter()
            .any(|c| c.first().map(|s| s.as_str()) == Some("sudo")));
    }

    #[test]
    fn no_sudo_variant_when_not_available() {
        let cmds = candidate_ps_commands_with_format(false, "{{json .}}");
        assert!(cmds
            .iter()
            .all(|c| c.first().map(|s| s.as_str()) != Some("sudo")));
    }

    #[test]
    fn render_handles_empty_output() {
        // Should not panic with empty input
        render_docker_table(&[]);
    }

    #[test]
    fn custom_format_builder() {
        let cmds = candidate_ps_commands_with_format(false, "{{json .}}");
        assert_eq!(cmds[0][0], "docker");
        assert_eq!(cmds[0][3], "{{json .}}");
    }

    #[test]
    fn matches_id_or_name() {
        let container = DockerContainer {
            id: "123456789abc".to_string(),
            names: "web,api".to_string(),
            status: Some("Up".to_string()),
            ports: None,
        };

        assert!(container_matches_target(&container, "123456"));
        assert!(container_matches_target(&container, "web"));
        assert!(container_matches_target(&container, "api"));
        assert!(!container_matches_target(&container, "missing"));
    }
}