use std::collections::BTreeMap;
use crate::error::{ComposeError, Result};
use crate::libpod::types::container::ContainerListEntry;
use crate::libpod::{urlencoded, Client, API_PREFIX};
#[derive(Debug, Clone, Default)]
pub struct LsOptions {
pub all: bool,
pub quiet: bool,
pub json: bool,
}
fn split_ls_filters(filters: &[String]) -> (Vec<String>, Vec<String>, Vec<String>) {
let (mut names, mut status, mut unknown) = (Vec::new(), Vec::new(), Vec::new());
for f in filters {
match f.split_once('=') {
Some(("name", v)) => names.push(v.to_string()),
Some(("status", v)) => status.push(v.to_ascii_lowercase()),
_ => unknown.push(f.clone()),
}
}
(names, status, unknown)
}
fn ls_row_matches(name: &str, running: bool, names: &[String], status: &[String]) -> bool {
let name_ok = names.is_empty() || names.iter().any(|n| name.contains(n.as_str()));
let status_word = if running { "running" } else { "exited" };
let status_ok = status.is_empty() || status.iter().any(|s| s == status_word);
name_ok && status_ok
}
fn is_running(status: &str) -> bool {
let s = status.trim();
s.eq_ignore_ascii_case("running") || s.to_ascii_lowercase().starts_with("up")
}
fn is_paused(status: &str) -> bool {
status.trim().to_ascii_lowercase().starts_with("paus")
}
struct Tally {
running: usize,
paused: usize,
total: usize,
}
pub async fn list_projects(client: &Client, opts: LsOptions) -> Result<()> {
list_projects_filtered(client, opts, &[]).await
}
pub async fn list_projects_filtered(
client: &Client,
opts: LsOptions,
filters: &[String],
) -> Result<()> {
let label_filters = serde_json::json!({ "label": ["podup.project"] });
let path = format!(
"{API_PREFIX}/containers/json?all=true&filters={}",
urlencoded(&label_filters.to_string()),
);
let containers = client
.get_json::<Vec<ContainerListEntry>>(&path)
.await
.map_err(ComposeError::Podman)?;
let mut projects: BTreeMap<String, Tally> = BTreeMap::new();
for c in &containers {
let Some(project) = c.labels.get("podup.project") else {
continue;
};
let tally = projects.entry(project.clone()).or_insert(Tally {
running: 0,
paused: 0,
total: 0,
});
tally.total += 1;
if is_running(&c.state) || is_running(&c.status) {
tally.running += 1;
} else if is_paused(&c.state) || is_paused(&c.status) {
tally.paused += 1;
}
}
let (name_filter, status_filter, unknown) = split_ls_filters(filters);
for u in &unknown {
tracing::warn!("ls: ignoring unsupported filter '{u}'");
}
let rows: Vec<(&String, &Tally)> = projects
.iter()
.filter(|(_, t)| opts.all || t.running > 0 || t.paused > 0)
.filter(|(name, t)| ls_row_matches(name, t.running > 0, &name_filter, &status_filter))
.collect();
if opts.quiet {
for (name, _) in &rows {
println!("{name}");
}
return Ok(());
}
if opts.json {
let arr: Vec<_> = rows.iter().map(|(name, t)| project_row(name, t)).collect();
println!("{}", serde_json::to_string_pretty(&arr).unwrap_or_default());
return Ok(());
}
let mut table = crate::ui::Table::new(&["NAME", "STATUS"])
.cap(0, 48)
.status_col(1);
for (name, t) in &rows {
table.push(vec![name.to_string(), status_label(t)]);
}
table.print();
Ok(())
}
fn status_label(t: &Tally) -> String {
let exited = t.total.saturating_sub(t.running).saturating_sub(t.paused);
let mut parts = Vec::new();
if t.running > 0 {
parts.push(format!("running({})", t.running));
}
if t.paused > 0 {
parts.push(format!("paused({})", t.paused));
}
if exited > 0 {
parts.push(format!("exited({exited})"));
}
if parts.is_empty() {
parts.push(format!("exited({})", t.total));
}
parts.join(", ")
}
fn project_row(name: &str, t: &Tally) -> serde_json::Value {
serde_json::json!({
"Name": name,
"Status": status_label(t),
"ConfigFiles": "",
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_running_detects_live_statuses() {
for up in ["running", "Up 2 minutes", "UP", "up about an hour"] {
assert!(is_running(up), "{up} should be running");
}
for down in [
"exited",
"Exited (0) 3s ago",
"created",
"",
"stopped",
"paused",
] {
assert!(!is_running(down), "{down} should not be running");
}
}
#[test]
fn split_ls_filters_buckets_and_flags_unknown() {
let (names, status, unknown) = split_ls_filters(&[
"name=web".to_string(),
"status=RUNNING".to_string(),
"bogus=1".to_string(),
]);
assert_eq!(names, vec!["web".to_string()]);
assert_eq!(status, vec!["running".to_string()]);
assert_eq!(unknown, vec!["bogus=1".to_string()]);
}
#[test]
fn ls_row_matches_applies_name_and_status() {
assert!(ls_row_matches("app", true, &[], &[]));
assert!(ls_row_matches("myapp", true, &["app".to_string()], &[]));
assert!(!ls_row_matches("other", true, &["app".to_string()], &[]));
assert!(ls_row_matches("app", true, &[], &["running".to_string()]));
assert!(!ls_row_matches("app", false, &[], &["running".to_string()]));
assert!(ls_row_matches("app", false, &[], &["exited".to_string()]));
}
#[test]
fn is_paused_detects_paused_statuses() {
for p in ["paused", "Paused", "PAUSED"] {
assert!(is_paused(p), "{p} should be paused");
}
for other in ["running", "exited", "created", ""] {
assert!(!is_paused(other), "{other} should not be paused");
}
}
#[test]
fn status_label_emits_per_state_counts() {
assert_eq!(
status_label(&Tally {
running: 2,
paused: 0,
total: 3
}),
"running(2), exited(1)"
);
assert_eq!(
status_label(&Tally {
running: 0,
paused: 1,
total: 1
}),
"paused(1)"
);
assert_eq!(
status_label(&Tally {
running: 1,
paused: 1,
total: 3
}),
"running(1), paused(1), exited(1)"
);
assert_eq!(
status_label(&Tally {
running: 0,
paused: 0,
total: 3
}),
"exited(3)"
);
}
#[test]
fn project_row_includes_config_files_field() {
let row = project_row(
"web",
&Tally {
running: 1,
paused: 0,
total: 1,
},
);
assert_eq!(row["Name"], "web");
assert_eq!(row["Status"], "running(1)");
assert_eq!(row["ConfigFiles"], "");
}
}