Skip to content

Commit 7595ada

Browse files
committed
feat(cli): output flags for ps and images (--format/-q, ps -a)
ps and images had no flags. Add docker compose's output controls: - ps: -a/--all (default now running-only, matching docker), -q/--quiet (container IDs), --format table|json - images: -q/--quiet (image IDs), --format table|json Non-breaking: existing Engine::ps/images kept as default-option wrappers; overrides go through ps_with_options/images_with_options. Adds an Id field to ContainerListEntry for -q. Closes #412. Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
1 parent 5c933a3 commit 7595ada

8 files changed

Lines changed: 194 additions & 19 deletions

File tree

internal/cli.rs

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,20 @@
22
33
use std::path::PathBuf;
44

5-
use clap::{Parser, Subcommand};
5+
use clap::{Parser, Subcommand, ValueEnum};
66
#[cfg(feature = "completions")]
77
use clap_complete::Shell;
88

9+
/// Output rendering for list commands (`ps`, `images`).
10+
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, ValueEnum)]
11+
pub(crate) enum OutputFormat {
12+
/// Aligned columns for human reading.
13+
#[default]
14+
Table,
15+
/// Machine-readable JSON array.
16+
Json,
17+
}
18+
919
#[derive(Parser)]
1020
#[command(name = "podup", version)]
1121
pub(crate) struct Cli {
@@ -187,7 +197,17 @@ pub(crate) enum Commands {
187197
dst: String,
188198
},
189199
/// List containers.
190-
Ps,
200+
Ps {
201+
/// Show all containers, including stopped ones.
202+
#[arg(short, long)]
203+
all: bool,
204+
/// Only display container IDs.
205+
#[arg(short, long)]
206+
quiet: bool,
207+
/// Output format.
208+
#[arg(long, value_enum, default_value_t = OutputFormat::Table)]
209+
format: OutputFormat,
210+
},
191211
/// Display the running processes of service containers.
192212
Top {
193213
/// Show only these services.
@@ -206,7 +226,14 @@ pub(crate) enum Commands {
206226
},
207227
/// List images used by services.
208228
#[command(alias = "image")]
209-
Images,
229+
Images {
230+
/// Only display image IDs.
231+
#[arg(short, long)]
232+
quiet: bool,
233+
/// Output format.
234+
#[arg(long, value_enum, default_value_t = OutputFormat::Table)]
235+
format: OutputFormat,
236+
},
210237
/// View output from containers.
211238
#[command(alias = "log")]
212239
Logs {

internal/engine/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ mod copy;
88
pub use build::BuildOptions;
99
pub use lifecycle::RunOptions;
1010
pub use lock::ProjectLock;
11-
pub use query::ExecOptions;
11+
pub use query::{ExecOptions, ImagesOptions, PsOptions};
1212
mod container_config;
1313
mod container_fields;
1414
mod health;

internal/engine/query/inspect.rs

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,21 @@ impl Engine {
9898
Ok(())
9999
}
100100

101-
/// List images used by each service.
101+
/// List images used by each service as a table (default options).
102102
pub async fn images(&self, file: &ComposeFile) -> Result<()> {
103-
println!(
104-
"{:<30} {:<25} {:<15} {:<20}",
105-
"SERVICE", "REPOSITORY", "TAG", "IMAGE ID"
106-
);
103+
self.images_with_options(file, super::ImagesOptions::default())
104+
.await
105+
}
106+
107+
/// List service images with `docker compose images`-style options:
108+
/// `-q/--quiet` (IDs only) and `--format` (table | json).
109+
pub async fn images_with_options(
110+
&self,
111+
file: &ComposeFile,
112+
opts: super::ImagesOptions,
113+
) -> Result<()> {
114+
// Collect rows first so quiet/json modes can render without the header.
115+
let mut rows: Vec<(String, String, String, String)> = Vec::new();
107116
for (name, service) in &file.services {
108117
let image_ref = match &service.image {
109118
Some(img) => img.clone(),
@@ -118,11 +127,41 @@ impl Engine {
118127
.map(|(r, t)| (r.to_string(), t.to_string()))
119128
.unwrap_or_else(|| (image_ref.clone(), "latest".to_string()));
120129
let id = img.id.trim_start_matches("sha256:").get(..12).unwrap_or("");
121-
println!("{name:<30} {repo:<25} {tag:<15} {id:<20}");
130+
rows.push((name.clone(), repo, tag, id.to_string()));
122131
}
123132
Err(e) => tracing::warn!("images {name}: {e}"),
124133
}
125134
}
135+
136+
if opts.quiet {
137+
for (_, _, _, id) in &rows {
138+
println!("{id}");
139+
}
140+
return Ok(());
141+
}
142+
if opts.json {
143+
let json: Vec<_> = rows
144+
.iter()
145+
.map(|(svc, repo, tag, id)| {
146+
serde_json::json!({
147+
"Service": svc, "Repository": repo, "Tag": tag, "ID": id,
148+
})
149+
})
150+
.collect();
151+
println!(
152+
"{}",
153+
serde_json::to_string_pretty(&json).unwrap_or_default()
154+
);
155+
return Ok(());
156+
}
157+
158+
println!(
159+
"{:<30} {:<25} {:<15} {:<20}",
160+
"SERVICE", "REPOSITORY", "TAG", "IMAGE ID"
161+
);
162+
for (svc, repo, tag, id) in &rows {
163+
println!("{svc:<30} {repo:<25} {tag:<15} {id:<20}");
164+
}
126165
Ok(())
127166
}
128167

internal/engine/query/mod.rs

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,40 @@ pub struct ExecOptions {
3030
pub index: Option<u32>,
3131
}
3232

33+
/// Options for [`Engine::ps_with_options`].
34+
#[derive(Default)]
35+
pub struct PsOptions {
36+
/// Include stopped containers, `-a/--all` (default: running only).
37+
pub all: bool,
38+
/// Print only container IDs, `-q/--quiet`.
39+
pub quiet: bool,
40+
/// Emit JSON instead of the table, `--format json`.
41+
pub json: bool,
42+
}
43+
44+
/// Options for [`Engine::images_with_options`].
45+
#[derive(Default)]
46+
pub struct ImagesOptions {
47+
/// Print only image IDs, `-q/--quiet`.
48+
pub quiet: bool,
49+
/// Emit JSON instead of the table, `--format json`.
50+
pub json: bool,
51+
}
52+
3353
impl Engine {
34-
/// List containers for this project: name, image, command, state, and port bindings.
35-
pub async fn ps(&self, _file: &ComposeFile) -> Result<()> {
54+
/// List running containers for this project as a table (default options).
55+
pub async fn ps(&self, file: &ComposeFile) -> Result<()> {
56+
self.ps_with_options(file, PsOptions::default()).await
57+
}
58+
59+
/// List containers with `docker compose ps`-style options: `-a/--all`
60+
/// (include stopped), `-q/--quiet` (IDs only), and `--format` (table | json).
61+
pub async fn ps_with_options(&self, _file: &ComposeFile, opts: PsOptions) -> Result<()> {
3662
let label = format!("podup.project={}", self.project);
3763
let filters = serde_json::json!({ "label": [label] });
3864
let path = format!(
39-
"{API_PREFIX}/containers/json?all=true&filters={}",
65+
"{API_PREFIX}/containers/json?all={}&filters={}",
66+
opts.all,
4067
urlencoded(&filters.to_string()),
4168
);
4269

@@ -46,9 +73,39 @@ impl Engine {
4673
.await
4774
.map_err(ComposeError::Podman)?;
4875

76+
let name_of = |c: &crate::libpod::types::container::ContainerListEntry| {
77+
c.names.join(", ").trim_start_matches('/').to_string()
78+
};
79+
80+
if opts.quiet {
81+
for c in &containers {
82+
let id = c.id.get(..12).unwrap_or(&c.id);
83+
println!("{id}");
84+
}
85+
return Ok(());
86+
}
87+
88+
if opts.json {
89+
let rows: Vec<_> = containers
90+
.iter()
91+
.map(|c| {
92+
serde_json::json!({
93+
"Name": name_of(c),
94+
"Image": c.image,
95+
"Status": c.status,
96+
"ID": c.id,
97+
})
98+
})
99+
.collect();
100+
println!(
101+
"{}",
102+
serde_json::to_string_pretty(&rows).unwrap_or_default()
103+
);
104+
return Ok(());
105+
}
106+
49107
println!("{:<40} {:<30} {:<20}", "NAME", "IMAGE", "STATUS");
50-
for c in containers {
51-
let names = c.names.join(", ").trim_start_matches('/').to_string();
108+
for c in &containers {
52109
let ports = c
53110
.ports
54111
.iter()
@@ -62,7 +119,12 @@ impl Engine {
62119
})
63120
.collect::<Vec<_>>()
64121
.join(", ");
65-
println!("{names:<40} {:<30} {:<20} {ports}", c.image, c.status);
122+
println!(
123+
"{:<40} {:<30} {:<20} {ports}",
124+
name_of(c),
125+
c.image,
126+
c.status
127+
);
66128
}
67129

68130
Ok(())

internal/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ pub use compose::{
2929
parse_str, parse_str_raw, resolve_levels, resolve_order,
3030
};
3131
pub use engine::{
32-
is_safe_project_name, BuildOptions, Engine, ExecOptions, ProjectLock, RunOptions,
32+
is_safe_project_name, BuildOptions, Engine, ExecOptions, ImagesOptions, ProjectLock, PsOptions,
33+
RunOptions,
3334
};
3435
pub use error::{ComposeError, Result};
3536
pub use libpod::Client;

internal/libpod/types/container/response.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ where
1919
/// Entry in the `GET /libpod/containers/json` response array.
2020
#[derive(Deserialize)]
2121
pub struct ContainerListEntry {
22+
#[serde(rename = "Id", default)]
23+
pub id: String,
24+
2225
#[serde(rename = "Names", default, deserialize_with = "null_default")]
2326
pub names: Vec<String>,
2427

internal/main.rs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -384,14 +384,35 @@ async fn run() -> podup::Result<()> {
384384
.await?
385385
}
386386
Commands::Cp { src, dst } => engine.cp(&file, &src, &dst).await?,
387-
Commands::Ps => engine.ps(&file).await?,
387+
Commands::Ps { all, quiet, format } => {
388+
engine
389+
.ps_with_options(
390+
&file,
391+
podup::PsOptions {
392+
all,
393+
quiet,
394+
json: format == OutputFormat::Json,
395+
},
396+
)
397+
.await?
398+
}
388399
Commands::Top { services } => engine.top(&file, &services).await?,
389400
Commands::Port {
390401
service,
391402
private_port,
392403
proto,
393404
} => engine.port(&file, &service, private_port, &proto).await?,
394-
Commands::Images => engine.images(&file).await?,
405+
Commands::Images { quiet, format } => {
406+
engine
407+
.images_with_options(
408+
&file,
409+
podup::ImagesOptions {
410+
quiet,
411+
json: format == OutputFormat::Json,
412+
},
413+
)
414+
.await?
415+
}
395416
Commands::Logs { service, follow } => {
396417
engine.logs(&file, service.as_deref(), follow).await?
397418
}

tests/cli_aliases.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,25 @@ fn build_accepts_override_flags() {
127127
);
128128
}
129129
}
130+
131+
/// `ps` and `images` expose output flags (`--format`, `-q/--quiet`; `ps` also
132+
/// `-a/--all`) so their output can be scripted against.
133+
#[test]
134+
fn ps_and_images_expose_output_flags() {
135+
let ps = Command::new(bin()).args(["ps", "--help"]).output().unwrap();
136+
let ps_out = String::from_utf8_lossy(&ps.stdout);
137+
for flag in ["--all", "--quiet", "--format"] {
138+
assert!(ps_out.contains(flag), "`ps` missing {flag}:\n{ps_out}");
139+
}
140+
let img = Command::new(bin())
141+
.args(["images", "--help"])
142+
.output()
143+
.unwrap();
144+
let img_out = String::from_utf8_lossy(&img.stdout);
145+
for flag in ["--quiet", "--format"] {
146+
assert!(
147+
img_out.contains(flag),
148+
"`images` missing {flag}:\n{img_out}"
149+
);
150+
}
151+
}

0 commit comments

Comments
 (0)