use serde_json::{json, Value};
use std::process::Command;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct DockerInfo {
pub running: u32,
pub total: u32,
pub images: u32,
}
impl DockerInfo {
pub fn stopped(&self) -> u32 {
self.total.saturating_sub(self.running)
}
}
pub fn read_docker_info(cli: &str) -> Option<DockerInfo> {
let running = count(cli, &["ps", "-q"])?;
let total = count(cli, &["ps", "-aq"])?;
let images = count(cli, &["images", "-q"])?;
Some(DockerInfo {
running,
total,
images,
})
}
fn count(cli: &str, args: &[&str]) -> Option<u32> {
let out = Command::new(cli).args(args).output().ok()?;
if !out.status.success() {
return None;
}
let text = std::str::from_utf8(&out.stdout).ok()?;
Some(text.lines().filter(|l| !l.is_empty()).count() as u32)
}
pub fn containers(cli: &str, format: &str, show_when_zero: bool) -> Option<Vec<Value>> {
let info = read_docker_info(cli)?;
if !show_when_zero && info.total == 0 && info.images == 0 {
return None;
}
let contents = render_format(format, &info);
let gradient = if info.total == 0 {
0.0
} else {
100.0 * info.running as f64 / info.total as f64
};
Some(vec![json!({
"contents": contents,
"gradient_level": gradient,
"highlight_groups": [
"docker_containers_gradient",
"docker_containers",
"docker",
],
"divider_highlight_group": "background:divider",
})])
}
fn render_format(fmt: &str, info: &DockerInfo) -> String {
let mut s = fmt.to_string();
s = s.replace("{running}", &info.running.to_string());
s = s.replace("{total}", &info.total.to_string());
s = s.replace("{images}", &info.images.to_string());
s = s.replace("{stopped}", &info.stopped().to_string());
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stopped_is_total_minus_running() {
let info = DockerInfo {
running: 3,
total: 10,
images: 0,
};
assert_eq!(info.stopped(), 7);
}
#[test]
fn stopped_saturates_at_zero_when_running_exceeds_total() {
let info = DockerInfo {
running: 5,
total: 2,
images: 0,
};
assert_eq!(info.stopped(), 0);
}
#[test]
fn render_format_substitutes_all_tokens() {
let info = DockerInfo {
running: 2,
total: 5,
images: 7,
};
let out = render_format("R{running} T{total} I{images} S{stopped}", &info);
assert_eq!(out, "R2 T5 I7 S3");
}
#[test]
fn render_format_leaves_unknown_tokens_intact() {
let info = DockerInfo::default();
let out = render_format("{running}/{total} {cpu}", &info);
assert_eq!(out, "0/0 {cpu}");
}
#[test]
fn read_docker_info_missing_binary_returns_none() {
assert!(read_docker_info("/nonexistent/docker-binary-xyz").is_none());
}
#[test]
fn containers_unreachable_daemon_returns_none() {
let result = containers("/nonexistent/docker-xyz", "{running}/{total}", true);
assert!(result.is_none());
}
#[test]
fn count_handles_empty_output() {
let n = count("true", &[]).expect("true should succeed");
assert_eq!(n, 0);
}
#[test]
fn count_returns_lines_for_multiline_output() {
let n = count("printf", &["a\nb\nc\n"]).expect("printf should succeed");
assert_eq!(n, 3);
}
#[test]
fn count_strips_blank_lines() {
let n = count("printf", &["a\n\nb\n"]).expect("printf");
assert_eq!(n, 2);
}
#[test]
fn containers_zero_state_respects_show_when_zero_false() {
let info = DockerInfo::default();
let s = render_format("{running}/{total}", &info);
assert_eq!(s, "0/0");
}
}