use crate::commands::terminal_table::{render_table, TableStyle};
use crate::logging::{log_info, log_warn};
use crate::utils::command_exists;
use colored::Colorize;
use serde::Deserialize;
use std::path::PathBuf;
use std::process::Stdio;
use tokio::process::Command;
const COMMAND_NAME: &str = "docker";
const DEFAULT_COMPOSE_FILES: [&str; 2] = ["docker-compose.yml", "compose.yml"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DockerFailureKind {
DaemonUnavailable,
PermissionDenied,
Generic,
}
#[derive(Debug, Clone)]
struct DockerFailureDisplay {
summary: String,
hint: Option<String>,
raw: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DockerContainer {
pub id: String,
pub names: String,
pub status: Option<String>,
pub ports: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PortMapping {
host: Option<String>,
container: String,
proto: 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>,
}
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);
for container in &containers {
crate::data::athena::persist_docker_container_snapshot(
&container.id,
&container.names,
container.status.as_deref(),
container.ports.as_deref(),
serde_json::json!({
"source": "print_docker_ps"
}),
)
.await;
}
let _ = log_info(COMMAND_NAME, "Rendered docker ps snapshot", None).await;
Ok(())
}
Err(err) => {
let failure = render_docker_failure(&err);
println!("{} {}", "docker ps:".yellow().bold(), failure.summary);
if let Some(hint) = failure.hint {
println!("{}", hint.bright_blue());
}
if debug {
println!(
" {}",
format!("Details: {}", failure.raw.trim()).bright_black()
);
}
let _ = log_warn(COMMAND_NAME, "Unable to render docker ps", Some(&err)).await;
crate::data::athena::persist_docker_log(
None,
Some("docker ps"),
"stderr",
&err,
serde_json::json!({ "source": "print_docker_ps" }),
)
.await;
Ok(())
}
}
}
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)
}
pub async fn validate_docker_env(debug: bool) -> Result<(), String> {
if !command_exists("docker") {
return Err("docker not installed or not in PATH".into());
}
let output = Command::new("docker")
.arg("info")
.arg("--format")
.arg("{{.ServerVersion}}")
.output()
.await
.map_err(|e| format!("failed to run docker info: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let failure = render_docker_failure(&stderr);
if debug {
println!(" {}", format!("Details: {}", stderr.trim()).bright_black());
}
return Err(failure.summary);
}
if debug {
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
println!("Docker daemon reachable (server version: {})", version);
}
Ok(())
}
pub async fn validate_compose(path: Option<&str>, debug: bool) -> Result<(), String> {
let compose_file = match resolve_compose_file(path) {
Some(p) => p,
None => return Ok(()),
};
if compose_file.as_os_str().is_empty() || !compose_file.exists() {
return Ok(()); }
let output = Command::new("docker")
.arg("compose")
.arg("-f")
.arg(&compose_file)
.arg("config")
.arg("--quiet")
.output()
.await
.map_err(|e| format!("failed to run docker compose config: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if debug {
println!(" {}", format!("Details: {}", stderr.trim()).bright_black());
}
return Err(format!(
"docker compose config failed for {}.",
compose_file.display()
));
}
if debug {
println!(
"docker compose validated: {}",
compose_file.display().to_string().cyan()
);
}
Ok(())
}
fn resolve_compose_file(path: Option<&str>) -> Option<PathBuf> {
if let Some(p) = path {
let pbuf = PathBuf::from(p);
if pbuf.exists() {
return Some(pbuf);
}
}
for candidate in DEFAULT_COMPOSE_FILES {
let p = PathBuf::from(candidate);
if p.exists() {
return Some(p);
}
}
None
}
#[cfg(test)]
mod compose_tests {
use super::resolve_compose_file;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_path(name: &str) -> std::path::PathBuf {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis();
std::env::temp_dir().join(format!("{}-{}", name, ts))
}
#[test]
fn picks_explicit_compose_file() {
let path = temp_path("compose-test");
fs::write(&path, "services: {}").unwrap();
let resolved = resolve_compose_file(Some(path.to_str().unwrap()));
assert_eq!(resolved.unwrap(), path);
let _ = fs::remove_file(path);
}
#[test]
fn falls_back_to_defaults() {
let dir = temp_path("compose-dir");
fs::create_dir_all(&dir).unwrap();
let default = dir.join("docker-compose.yml");
fs::write(&default, "services: {}").unwrap();
let cwd_guard = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir).unwrap();
let resolved = resolve_compose_file(None);
std::env::set_current_dir(cwd_guard).unwrap();
let _ = fs::remove_file(&default);
let _ = fs::remove_dir_all(&dir);
assert_eq!(resolved.unwrap().file_name().unwrap(), "docker-compose.yml");
}
#[test]
fn returns_none_when_missing() {
let resolved = resolve_compose_file(None);
assert!(resolved.is_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 rows: Vec<Vec<String>> = display_rows
.into_iter()
.map(|row| {
vec![
row[0].cyan().to_string(),
row[1].green().to_string(),
match row[2].to_ascii_lowercase().as_str() {
status if status.starts_with("up") => row[2].green().to_string(),
status if status.contains("exited") || status.contains("dead") => {
row[2].red().to_string()
}
_ => row[2].yellow().to_string(),
},
row[3].bright_black().to_string(),
]
})
.collect();
print!(
"{}",
render_table(
&["CONTAINER", "NAMES", "STATUS", "PORTS"],
&rows,
TableStyle::Box,
"",
)
);
render_port_graph(containers);
}
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
}
fn render_port_graph(containers: &[DockerContainer]) {
println!("\n{}", "Port graph (host ➜ container)".bright_blue().bold());
for container in containers {
println!("{} {}", "•".cyan(), container.names.green());
let mappings = parse_port_mappings(container.ports.as_deref().unwrap_or(""));
if mappings.is_empty() {
println!(" {}", "no published/exposed ports".dimmed());
continue;
}
let host_width = mappings
.iter()
.map(|m| m.host.as_deref().unwrap_or("container-only").len())
.max()
.unwrap_or(0);
for mapping in mappings {
let host = mapping.host.unwrap_or_else(|| "container-only".to_string());
let host_label = format!("{:width$}", host, width = host_width);
let target = match mapping.proto {
Some(proto) => format!("{}/{}", mapping.container, proto),
None => mapping.container,
};
println!(
" {} {} {}",
host_label.yellow(),
"─▶".bright_blue(),
target.cyan()
);
}
}
}
fn parse_port_mappings(port_field: &str) -> Vec<PortMapping> {
if port_field.trim().is_empty() {
return Vec::new();
}
port_field
.split(',')
.filter_map(|entry| {
let entry = entry.trim();
if entry.is_empty() {
return None;
}
if let Some((left, right)) = entry.split_once("->") {
let (container, proto) = split_target(right.trim());
Some(PortMapping {
host: Some(left.trim().to_string()),
container,
proto,
})
} else {
let (container, proto) = split_target(entry);
Some(PortMapping {
host: None,
container,
proto,
})
}
})
.collect()
}
fn split_target(target: &str) -> (String, Option<String>) {
if let Some((port, proto)) = target.rsplit_once('/') {
(port.trim().to_string(), Some(proto.trim().to_string()))
} else {
(target.trim().to_string(), None)
}
}
fn classify_docker_failure(stderr: &str) -> DockerFailureKind {
let lower = stderr.to_ascii_lowercase();
if lower.contains("permission denied")
|| lower.contains("got permission denied")
|| lower.contains("access denied")
{
DockerFailureKind::PermissionDenied
} else if lower.contains("failed to connect to the docker api")
|| lower.contains("failed to connect to the docker daemon")
|| lower.contains("cannot connect to the docker daemon")
|| lower.contains("docker desktop")
|| lower.contains("npipe")
|| lower.contains("dockerdesktoplinuxengine")
|| lower.contains("the system cannot find the file specified")
|| lower.contains("no such file or directory")
{
DockerFailureKind::DaemonUnavailable
} else {
DockerFailureKind::Generic
}
}
fn render_docker_failure(stderr: &str) -> DockerFailureDisplay {
let raw = stderr.trim().to_string();
let kind = classify_docker_failure(&raw);
let summary = match kind {
DockerFailureKind::DaemonUnavailable => {
"Docker daemon or socket is unavailable.".to_string()
}
DockerFailureKind::PermissionDenied => {
"Permission denied while contacting Docker.".to_string()
}
DockerFailureKind::Generic => "Docker ps failed.".to_string(),
};
let hint = match kind {
DockerFailureKind::DaemonUnavailable => {
if cfg!(windows) {
Some("Start Docker Desktop and confirm the Linux engine is running.".to_string())
} else {
Some("Start the Docker daemon or confirm the socket is available.".to_string())
}
}
DockerFailureKind::PermissionDenied => {
if cfg!(windows) {
Some("Make sure this shell is allowed to talk to Docker Desktop.".to_string())
} else if crate::utils::command_exists("sudo") {
Some("Try rerunning with `sudo`, or fix Docker socket permissions.".to_string())
} else {
Some(
"Fix Docker socket permissions or add your user to the docker group."
.to_string(),
)
}
}
DockerFailureKind::Generic => None,
};
DockerFailureDisplay { summary, hint, raw }
}
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 {
let failure = render_docker_failure(last_error.as_deref().unwrap_or_default());
println!("{} {}", "docker ps failed:".yellow(), failure.summary);
if let Some(hint) = failure.hint {
println!("{}", hint.bright_blue());
}
println!(
" {}",
format!("Details: {}", failure.raw.trim()).bright_black()
);
}
}
}
if let Some(err) = last_error {
return Err(err);
}
Ok(vec![])
}
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() {
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"));
}
#[test]
fn parse_host_and_container_port_mappings() {
let input = "0.0.0.0:80-81->80-81/tcp, [::]:443->443/tcp, 5432/tcp";
let mappings = parse_port_mappings(input);
assert_eq!(mappings.len(), 3);
assert_eq!(
mappings[0],
PortMapping {
host: Some("0.0.0.0:80-81".to_string()),
container: "80-81".to_string(),
proto: Some("tcp".to_string()),
}
);
assert_eq!(
mappings[1],
PortMapping {
host: Some("[::]:443".to_string()),
container: "443".to_string(),
proto: Some("tcp".to_string()),
}
);
assert_eq!(
mappings[2],
PortMapping {
host: None,
container: "5432".to_string(),
proto: Some("tcp".to_string()),
}
);
}
#[test]
fn classifies_windows_engine_missing_as_daemon_unavailable() {
let stderr =
"failed to connect to the docker API at npipe:////./pipe/dockerDesktopLinuxEngine";
assert_eq!(
classify_docker_failure(stderr),
DockerFailureKind::DaemonUnavailable
);
let failure = render_docker_failure(stderr);
assert!(failure.summary.contains("unavailable"));
assert!(failure.hint.is_some());
}
#[test]
fn classifies_permission_denied_separately() {
let stderr = "Got permission denied while trying to connect to the Docker daemon socket";
assert_eq!(
classify_docker_failure(stderr),
DockerFailureKind::PermissionDenied
);
let failure = render_docker_failure(stderr);
assert!(failure.summary.contains("Permission denied"));
assert!(failure.hint.is_some());
}
#[test]
fn generic_failures_stay_short() {
let failure = render_docker_failure("some unexpected docker failure");
assert_eq!(
classify_docker_failure("some unexpected docker failure"),
DockerFailureKind::Generic
);
assert!(failure.summary.contains("Docker ps failed"));
assert!(failure.hint.is_none());
}
}