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, 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) => {
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;
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() {
return Err(format!(
"docker info failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
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() {
return Err(format!(
"docker compose config failed for {}: {}",
compose_file.display(),
String::from_utf8_lossy(&output.stderr)
));
}
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 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 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);
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)
}
}
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() {
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()),
}
);
}
}