From b872c459476aa28d5fe49e84a4a548f7018d9030 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Fri, 28 Aug 2026 14:36:58 -0700 Subject: [PATCH 1/5] fix(output): reject unknown --fields names instead of rendering empty rows Selecting a field that doesn't exist in the response data (e.g. a typo or wrong case) silently projected to nothing, producing rows with no data instead of a clear error. `apply_pipeline` now validates requested field names against the response data's actual top-level keys and errors with a "did you mean" suggestion and the list of valid fields. --- cli-engine/src/output/fields.rs | 7 ++ cli-engine/src/output/pipeline.rs | 150 +++++++++++++++++++++++++++++- cli-engine/tests/consumer_cli.rs | 47 ++++++++++ 3 files changed, 202 insertions(+), 2 deletions(-) diff --git a/cli-engine/src/output/fields.rs b/cli-engine/src/output/fields.rs index dac391f..7931bc0 100644 --- a/cli-engine/src/output/fields.rs +++ b/cli-engine/src/output/fields.rs @@ -8,6 +8,13 @@ pub struct FieldTree { children: BTreeMap>>, } +impl FieldTree { + /// Iterates the top-level field names requested at this level of the tree. + pub(crate) fn top_level_names(&self) -> impl Iterator { + self.children.keys().map(String::as_str) + } +} + /// Parses comma-separated field paths. #[must_use] pub fn parse_fields(fields: &str) -> FieldTree { diff --git a/cli-engine/src/output/pipeline.rs b/cli-engine/src/output/pipeline.rs index b805319..3d5d661 100644 --- a/cli-engine/src/output/pipeline.rs +++ b/cli-engine/src/output/pipeline.rs @@ -1,8 +1,10 @@ +use std::collections::BTreeSet; + use serde_json::Value; use crate::{CliCoreError, Result}; -use super::{PaginationMeta, filter_fields}; +use super::{PaginationMeta, filter_fields, parse_fields}; /// Options for the output pipeline. #[derive(Clone, Debug, Default, Eq, PartialEq)] @@ -33,11 +35,99 @@ pub fn apply_pipeline(data: &mut Value, opts: &PipelineOpts) -> Result Result<()> { + let fields = fields.trim(); + if fields.is_empty() || fields == "all" || fields == "*" { + return Ok(()); + } + let Some(known) = known_top_level_keys(data) else { + return Ok(()); + }; + let requested = parse_fields(fields); + let unknown: Vec<&str> = requested + .top_level_names() + .filter(|name| !known.contains(*name)) + .collect(); + if unknown.is_empty() { + return Ok(()); + } + Err(CliCoreError::message(unknown_fields_message( + &unknown, &known, + ))) +} + +fn known_top_level_keys(data: &Value) -> Option> { + match data { + Value::Object(map) => Some(map.keys().cloned().collect()), + Value::Array(items) => { + let mut keys = BTreeSet::new(); + let mut saw_object = false; + for item in items { + match item { + Value::Object(map) => { + saw_object = true; + keys.extend(map.keys().cloned()); + } + Value::Null => {} + _ => return None, + } + } + saw_object.then_some(keys) + } + _ => None, + } +} + +fn unknown_fields_message(unknown: &[&str], known: &BTreeSet) -> String { + let plural = if unknown.len() > 1 { "s" } else { "" }; + let quoted = unknown + .iter() + .map(|name| format!("\"{name}\"")) + .collect::>() + .join(", "); + let mut message = format!("fields: unknown field{plural} {quoted}"); + if let Some(first) = unknown.first() + && let Some(suggestion) = nearest_field(first, known) + { + message.push_str(&format!(" (did you mean \"{suggestion}\"?)")); + } + if !known.is_empty() { + let valid = known.iter().cloned().collect::>().join(", "); + message.push_str(&format!("; valid fields: {valid}")); + } + message +} + +/// Finds the closest known field name within edit-distance `max(1, name_len / +/// 3)`, mirroring `nearest_subcommand`'s tolerance in `cli.rs`. Ties break +/// alphabetically. +fn nearest_field(name: &str, known: &BTreeSet) -> Option { + let name = name.to_ascii_lowercase(); + let max_distance = 1.max(name.chars().count() / 3); + known + .iter() + .map(|candidate| { + ( + strsim::osa_distance(&name, &candidate.to_ascii_lowercase()), + candidate, + ) + }) + .filter(|(distance, _)| *distance <= max_distance) + .min_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1))) + .map(|(_, candidate)| candidate.clone()) +} + fn apply_pagination(data: &mut Value, offset: i64, limit: i64) -> Result> { let Value::Array(items) = data else { return Ok(None); @@ -133,7 +223,7 @@ fn search_query(expression: &jmespath::Expression<'_>, data: &Value) -> Result(&unknown.rendered) + .expect("error envelope should be json")["error"]["message"] + .as_str() + .expect("message should be a string") + .to_owned(); + assert_eq!( + message, + "fields: unknown field \"ID\" (did you mean \"id\"?); valid fields: id, name, status" + ); + + let known = cli + .run([ + "my-cli", + "project", + "list", + "--team", + "platform", + "--fields", + "id,status", + ]) + .await; + assert_eq!(known.exit_code, 0); + assert_eq!( + serde_json::from_str::(&known.rendered).expect("json"), + json!({"data": [ + {"id": "p1", "status": "active"}, + {"id": "p2", "status": "disabled"} + ]}) + ); +} + fn consumer_cli_with_root_actions() -> Cli { Cli::new( CliConfig::new("my-cli", "Team CLI", "my-cli") From d144f0ccdc988bee84fec76036405247e4749815 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Fri, 28 Aug 2026 14:45:17 -0700 Subject: [PATCH 2/5] refactor(output): parse --fields once for both validation and projection Addresses Copilot review feedback: apply_pipeline previously parsed the --fields string twice (once in validate_fields, once inside filter_fields) and scanned the response data on each pass. Extract project_fields (the tree-consuming core of filter_fields) and parse the FieldTree once in apply_pipeline, reusing it for both validation and projection. --- cli-engine/src/output/fields.rs | 13 ++++-- cli-engine/src/output/mod.rs | 1 + cli-engine/src/output/pipeline.rs | 76 +++++++++++++++++++++---------- 3 files changed, 63 insertions(+), 27 deletions(-) diff --git a/cli-engine/src/output/fields.rs b/cli-engine/src/output/fields.rs index 7931bc0..ba08c16 100644 --- a/cli-engine/src/output/fields.rs +++ b/cli-engine/src/output/fields.rs @@ -36,7 +36,14 @@ pub fn filter_fields(data: &Value, fields: &str) -> Value { if fields.is_empty() || fields == "all" || fields == "*" { return data.clone(); } - let allowed = parse_fields(fields); + project_fields(data, &parse_fields(fields)) +} + +/// Applies an already-parsed field-selection tree, so a caller that also +/// needs the tree (e.g. to validate requested names) can parse the +/// `--fields` string once and reuse it here instead of paying for +/// [`parse_fields`] a second time. +pub(crate) fn project_fields(data: &Value, allowed: &FieldTree) -> Value { match data { Value::Array(items) => { if items @@ -49,13 +56,13 @@ pub fn filter_fields(data: &Value, fields: &str) -> Value { items .iter() .map(|item| match item { - Value::Object(map) => Value::Object(filter_map(map, &allowed)), + Value::Object(map) => Value::Object(filter_map(map, allowed)), other => other.clone(), }) .collect(), ) } - Value::Object(map) => Value::Object(filter_map(map, &allowed)), + Value::Object(map) => Value::Object(filter_map(map, allowed)), other => other.clone(), } } diff --git a/cli-engine/src/output/mod.rs b/cli-engine/src/output/mod.rs index d2056a3..8492a12 100644 --- a/cli-engine/src/output/mod.rs +++ b/cli-engine/src/output/mod.rs @@ -22,6 +22,7 @@ pub use envelope::{ Envelope, ErrorEnvelope, Metadata, NextAction, NextActionParam, PaginationMeta, build_detailed_error_envelope, build_error_envelope, }; +pub(crate) use fields::project_fields; pub use fields::{FieldTree, filter_fields, parse_fields}; pub(crate) use human::terminal_width; pub use human::{ diff --git a/cli-engine/src/output/pipeline.rs b/cli-engine/src/output/pipeline.rs index 3d5d661..f1f54b4 100644 --- a/cli-engine/src/output/pipeline.rs +++ b/cli-engine/src/output/pipeline.rs @@ -4,7 +4,7 @@ use serde_json::Value; use crate::{CliCoreError, Result}; -use super::{PaginationMeta, filter_fields, parse_fields}; +use super::{FieldTree, PaginationMeta, parse_fields, project_fields}; /// Options for the output pipeline. #[derive(Clone, Debug, Default, Eq, PartialEq)] @@ -34,9 +34,13 @@ pub fn apply_pipeline(data: &mut Value, opts: &PipelineOpts) -> Result Result Result<()> { - let fields = fields.trim(); - if fields.is_empty() || fields == "all" || fields == "*" { - return Ok(()); - } +fn validate_fields(data: &Value, requested: &FieldTree) -> Result<()> { let Some(known) = known_top_level_keys(data) else { return Ok(()); }; - let requested = parse_fields(fields); let unknown: Vec<&str> = requested .top_level_names() .filter(|name| !known.contains(*name)) @@ -223,7 +222,10 @@ fn search_query(expression: &jmespath::Expression<'_>, data: &Value) -> Result Date: Fri, 28 Aug 2026 15:03:04 -0700 Subject: [PATCH 3/5] fix(output): only validate an explicit --fields flag, not default_fields Addresses Copilot review feedback: a command's default_fields is author-controlled and applied on every invocation, so validating it against the response the same way as a user-typed --fields risked hard-erroring a command for everyone the first time a legitimately optional default field was absent from a particular result set. Distinguishing "explicit --fields" from "clap filled in default_fields as that flag's native default" requires clap's value_source, since middleware's `fields` string is identical in both cases once a command has default_fields configured. Added GlobalFlags::fields_explicit (via matches.value_source("fields") == CommandLine) and threaded it through Middleware and PipelineOpts::fields_are_default. --- cli-engine/src/cli.rs | 1 + cli-engine/src/flags.rs | 9 ++++++ cli-engine/src/middleware.rs | 5 +++ cli-engine/src/output/pipeline.rs | 37 ++++++++++++++++++++++- cli-engine/tests/consumer_cli.rs | 33 ++++++++++++++++++++ cli-engine/tests/exhaustive_public_api.rs | 1 + cli-engine/tests/foundation.rs | 3 ++ 7 files changed, 88 insertions(+), 1 deletion(-) diff --git a/cli-engine/src/cli.rs b/cli-engine/src/cli.rs index 91944f4..ceef2d5 100644 --- a/cli-engine/src/cli.rs +++ b/cli-engine/src/cli.rs @@ -2707,6 +2707,7 @@ fn apply_global_flags(middleware: &mut Middleware, flags: &GlobalFlags, timeout: middleware.verbose = flags.verbose.clone(); middleware.dry_run = flags.dry_run; middleware.fields = flags.fields.clone(); + middleware.fields_explicit = flags.fields_explicit; middleware.filter = flags.filter.clone(); middleware.expr = flags.expr.clone(); middleware.reason = flags.reason.clone(); diff --git a/cli-engine/src/flags.rs b/cli-engine/src/flags.rs index 438f9e3..9d1b66c 100644 --- a/cli-engine/src/flags.rs +++ b/cli-engine/src/flags.rs @@ -66,6 +66,12 @@ pub struct GlobalFlags { pub dry_run: bool, /// Field projection. pub fields: String, + /// Whether `fields` came from an explicit `--fields` flag on the command + /// line, rather than clap filling in a command's `default_fields` (a + /// command with `default_fields` set registers it as that flag's native + /// default, so `fields` is non-empty even when the user never typed + /// `--fields` — this is the only reliable way to tell the two apart). + pub fields_explicit: bool, /// JMESPath per-item filter. pub filter: String, /// JMESPath whole-result expression. @@ -92,6 +98,7 @@ impl Default for GlobalFlags { verbose: String::new(), dry_run: false, fields: String::new(), + fields_explicit: false, filter: String::new(), expr: String::new(), schema: false, @@ -561,6 +568,8 @@ pub fn global_flags_from_matches( .get_one::("fields") .cloned() .unwrap_or_default(), + fields_explicit: matches.value_source("fields") + == Some(clap::parser::ValueSource::CommandLine), filter: matches .get_one::("filter") .cloned() diff --git a/cli-engine/src/middleware.rs b/cli-engine/src/middleware.rs index 3b5ce15..40fede3 100644 --- a/cli-engine/src/middleware.rs +++ b/cli-engine/src/middleware.rs @@ -489,6 +489,10 @@ pub struct Middleware { pub dry_run: bool, /// User field projection. pub fields: String, + /// Whether `fields` came from an explicit `--fields` flag rather than a + /// command's `default_fields` fallback. See + /// [`GlobalFlags::fields_explicit`](crate::GlobalFlags::fields_explicit). + pub fields_explicit: bool, /// JMESPath per-item list predicate. pub filter: String, /// JMESPath whole-result expression. @@ -1067,6 +1071,7 @@ impl Middleware { offset: self.offset, expr: self.expr.clone(), fields: projection_fields.to_owned(), + fields_are_default: !self.fields_explicit, }, )?; if let Some(pagination) = pagination { diff --git a/cli-engine/src/output/pipeline.rs b/cli-engine/src/output/pipeline.rs index f1f54b4..c826fd0 100644 --- a/cli-engine/src/output/pipeline.rs +++ b/cli-engine/src/output/pipeline.rs @@ -19,9 +19,22 @@ pub struct PipelineOpts { pub expr: String, /// Comma-separated field projection. pub fields: String, + /// Whether `fields` came from a command's `default_fields` fallback + /// rather than an explicit `--fields` flag. Default-field selections are + /// author-controlled, not user input, so they're projected but not + /// validated — see [`apply_pipeline`]'s note on why. + pub fields_are_default: bool, } /// Applies filter, pagination, expression, and field projection in framework order. +/// +/// Field validation (rejecting names absent from the response data) only +/// runs for an explicit `--fields` flag, not for a command's +/// `default_fields` fallback (`opts.fields_are_default`): default fields are +/// author-controlled and applied to every invocation of a command, so a +/// legitimate optional field that happens to be absent from every row of one +/// particular response (rather than genuinely misspelled) would otherwise +/// hard-error that command for everyone until the author noticed. pub fn apply_pipeline(data: &mut Value, opts: &PipelineOpts) -> Result> { if !opts.filter.is_empty() { apply_filter(data, &opts.filter)?; @@ -39,7 +52,9 @@ pub fn apply_pipeline(data: &mut Value, opts: &PipelineOpts) -> Result(&output.rendered).expect("json"), + json!({"data": [{"id": "w1"}]}) + ); +} + fn consumer_cli_with_root_actions() -> Cli { Cli::new( CliConfig::new("my-cli", "Team CLI", "my-cli") diff --git a/cli-engine/tests/exhaustive_public_api.rs b/cli-engine/tests/exhaustive_public_api.rs index 9b47da0..191a2eb 100644 --- a/cli-engine/tests/exhaustive_public_api.rs +++ b/cli-engine/tests/exhaustive_public_api.rs @@ -146,6 +146,7 @@ fn parsed_global_flags_cover_defaults_short_aliases_and_optional_values() { verbose: "all".to_owned(), dry_run: true, fields: "id,name".to_owned(), + fields_explicit: true, filter: "active == `true`".to_owned(), expr: "[].id".to_owned(), schema: false, diff --git a/cli-engine/tests/foundation.rs b/cli-engine/tests/foundation.rs index 896fa7e..af6302f 100644 --- a/cli-engine/tests/foundation.rs +++ b/cli-engine/tests/foundation.rs @@ -4666,6 +4666,7 @@ fn global_flag_defaults_and_derived_flag_classes_cover_common_clap_actions() { verbose: String::new(), dry_run: false, fields: String::new(), + fields_explicit: false, filter: String::new(), expr: String::new(), schema: false, @@ -9561,6 +9562,7 @@ fn output_pipeline_applies_filter_pagination_expr_and_fields_in_order() { offset: 1, expr: String::new(), fields: "name,status".to_owned(), + fields_are_default: false, }, ) .expect("pipeline should apply"); @@ -9652,6 +9654,7 @@ fn output_pipeline_defaults_and_non_list_pagination_are_noops() { offset: 0, expr: String::new(), fields: String::new(), + fields_are_default: false, } ); From 124015111ded595354a36b3f13134b3101fef2f0 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Fri, 28 Aug 2026 15:14:25 -0700 Subject: [PATCH 4/5] fix(output): validate explicit --fields against a human view's columns too Addresses Copilot review feedback: apply_pipeline's field validation never runs when a registered human view is active, since projection_fields is forced to "" so the view can narrow its own columns instead of the data being projected. That narrowing (select_columns in human.rs) silently skips a --fields name with no matching column, reproducing the exact "typo produces an empty/partial table instead of an error" bug this PR fixes elsewhere. Check an explicit --fields (never a default_fields fallback, gated on fields_explicit like the response-data validation) against the view's registered column catalog before rendering. Shares unknown_fields_message (now pub(crate)) with the response-data check so both surfaces produce the same message shape. --- cli-engine/src/middleware.rs | 40 +++++++++++++++++++++++++++- cli-engine/src/output/mod.rs | 1 + cli-engine/src/output/pipeline.rs | 7 ++++- cli-engine/tests/consumer_cli.rs | 44 +++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) diff --git a/cli-engine/src/middleware.rs b/cli-engine/src/middleware.rs index 40fede3..f772a67 100644 --- a/cli-engine/src/middleware.rs +++ b/cli-engine/src/middleware.rs @@ -1,5 +1,5 @@ use std::{ - collections::BTreeMap, + collections::{BTreeMap, BTreeSet}, future::Future, sync::Arc, time::{Duration, Instant}, @@ -17,6 +17,7 @@ use crate::{ output::{ Envelope, HumanViewRegistry, NextAction, OutputFormat, PipelineOpts, apply_pipeline, build_error_envelope, is_valid_output_format, render_human_with_registry_selected, + unknown_fields_message, }, }; @@ -1061,6 +1062,43 @@ impl Middleware { self.fields.as_str() }; let human_view = output_format == OutputFormat::Human && self.human_views.has_view(view_id); + // `apply_pipeline` never sees `effective_fields` for a registered + // view (`projection_fields` below is forced to `""` so the view reads + // the full payload), and the view's own column narrowing + // (`select_columns` in `human.rs`) silently skips a name with no + // matching column — the same "typo produces an empty/partial table + // instead of an error" gap `apply_pipeline`'s field validation + // closes elsewhere. So an explicit `--fields` (never a + // `default_fields` fallback — same reasoning as + // `PipelineOpts::fields_are_default`) is checked against the view's + // column catalog here instead. + if human_view + && self.fields_explicit + && let Some(columns) = self.human_views.columns(view_id) + { + let fields = effective_fields.trim(); + if !fields.is_empty() && fields != "all" && fields != "*" { + let known: BTreeSet = + columns.iter().map(|column| column.field.clone()).collect(); + let unknown: BTreeSet<&str> = fields + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty() && !known.contains(*part)) + .collect(); + if !unknown.is_empty() { + let unknown: Vec<&str> = unknown.into_iter().collect(); + let err = CliCoreError::message(unknown_fields_message(&unknown, &known)); + return self.render_error( + &err, + &self.app_id, + start, + user_args, + effective_args, + identity, + ); + } + } + } let projection_fields = if human_view { "" } else { effective_fields }; if let Some(data) = &mut envelope.data { let pagination = apply_pipeline( diff --git a/cli-engine/src/output/mod.rs b/cli-engine/src/output/mod.rs index 8492a12..93fe354 100644 --- a/cli-engine/src/output/mod.rs +++ b/cli-engine/src/output/mod.rs @@ -33,6 +33,7 @@ pub use human::{ render_human_with_registry_selected, render_human_with_view, }; pub use json::render_json; +pub(crate) use pipeline::unknown_fields_message; pub use pipeline::{PipelineOpts, apply_pipeline}; pub use renderer::{ OutputFormat, RendererFactory, is_valid_output_format, render, render_data, render_data_format, diff --git a/cli-engine/src/output/pipeline.rs b/cli-engine/src/output/pipeline.rs index c826fd0..96d6775 100644 --- a/cli-engine/src/output/pipeline.rs +++ b/cli-engine/src/output/pipeline.rs @@ -103,7 +103,12 @@ fn known_top_level_keys(data: &Value) -> Option> { } } -fn unknown_fields_message(unknown: &[&str], known: &BTreeSet) -> String { +/// Formats an "unknown field" error: a quoted list of the bad names, a +/// nearest-match suggestion for the first one, and the valid names — shared +/// by response-data field validation ([`validate_fields`]) and human-view +/// column validation (`middleware`'s explicit-`--fields`-against-a-view- +/// registered-columns check), so both surfaces produce the same message shape. +pub(crate) fn unknown_fields_message(unknown: &[&str], known: &BTreeSet) -> String { let plural = if unknown.len() > 1 { "s" } else { "" }; let quoted = unknown .iter() diff --git a/cli-engine/tests/consumer_cli.rs b/cli-engine/tests/consumer_cli.rs index 99743d8..5d711db 100644 --- a/cli-engine/tests/consumer_cli.rs +++ b/cli-engine/tests/consumer_cli.rs @@ -240,6 +240,50 @@ async fn default_fields_are_projected_without_validation_against_the_response() ); } +#[tokio::test] +async fn human_view_rejects_an_unknown_explicit_field_instead_of_narrowing_to_nothing() { + // With a registered human view, `apply_pipeline`'s field validation never + // runs — the view narrows its own columns instead of projecting the data + // (see the comment in `middleware.rs` above `human_view`'s validation + // check). Column narrowing (`select_columns` in `human.rs`) silently + // skips a name with no matching column, so an explicit `--fields` typo + // here needs its own check against the view's column catalog. + let cli = consumer_cli(); + + let unknown = cli + .run([ + "my-cli", "project", "list", "--team", "platform", "--output", "human", "--fields", + "ID", + ]) + .await; + assert_ne!(unknown.exit_code, 0, "{}", unknown.rendered); + assert!( + unknown.rendered.contains("unknown field \"ID\""), + "{}", + unknown.rendered + ); + assert!( + unknown.rendered.contains("did you mean \"id\"?"), + "{}", + unknown.rendered + ); + assert!( + unknown.rendered.contains("valid fields: id, name, status"), + "{}", + unknown.rendered + ); + + let known = cli + .run([ + "my-cli", "project", "list", "--team", "platform", "--output", "human", "--fields", + "id", + ]) + .await; + assert_eq!(known.exit_code, 0, "{}", known.rendered); + assert!(known.rendered.contains("ID"), "{}", known.rendered); + assert!(!known.rendered.contains("STATUS"), "{}", known.rendered); +} + fn consumer_cli_with_root_actions() -> Cli { Cli::new( CliConfig::new("my-cli", "Team CLI", "my-cli") From d6949c0a7817cae383e9f7f98ee17f51f81f6813 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Fri, 28 Aug 2026 15:24:15 -0700 Subject: [PATCH 5/5] fix(output): honor an explicit --fields "" instead of falling back to default_fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot review feedback: effective_fields branched on self.fields.is_empty(), but once a command has default_fields set, clap fills self.fields with that same non-empty string whether or not the user typed --fields — so emptiness alone can't tell "user explicitly cleared --fields" apart from "user never touched it." Only fields_explicit (value_source) can. Switched the branch to fields_explicit so an explicit --fields "" keeps everything, matching the documented all/*/empty behavior, instead of silently narrowing to default_fields. Updates a pre-existing test that mutates Middleware.fields directly (bypassing clap) to also set the new fields_explicit flag, preserving its intent now that emptiness alone no longer implies "not explicit." --- cli-engine/src/middleware.rs | 27 ++++++++++++++--------- cli-engine/tests/consumer_cli.rs | 38 ++++++++++++++++++++++++++++++++ cli-engine/tests/foundation.rs | 1 + 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/cli-engine/src/middleware.rs b/cli-engine/src/middleware.rs index f772a67..644e197 100644 --- a/cli-engine/src/middleware.rs +++ b/cli-engine/src/middleware.rs @@ -1049,17 +1049,24 @@ impl Middleware { } } let output_format = self.output_format.parse::()?; - // The effective field selection: an explicit `--fields` wins, otherwise - // the command's `default_fields` is the default. The same selection is - // applied two ways. With a registered human view, it narrows which of the - // view's columns show, so the view reads the full payload — the data is - // not projected, which would otherwise blank out the kept columns. - // Everywhere else (JSON/TOON, or generic human output) it projects the - // output data. Empty / `all` / `*` keeps everything. - let effective_fields = if self.fields.is_empty() { - default_fields - } else { + // The effective field selection: an explicit `--fields` wins — + // including an explicit empty string, which keeps everything, same + // as `all`/`*` — otherwise the command's `default_fields` is the + // default. Gated on `fields_explicit` rather than + // `self.fields.is_empty()`: once a command has `default_fields` set, + // clap fills `self.fields` with that same non-empty string whether + // or not the user typed `--fields`, so emptiness can't tell "user + // explicitly cleared it" apart from "user never touched it" — only + // `value_source` (what `fields_explicit` is built from) can. The + // same selection is applied two ways: with a registered human view, + // it narrows which of the view's columns show, so the view reads + // the full payload — the data is not projected, which would + // otherwise blank out the kept columns. Everywhere else (JSON/TOON, + // or generic human output) it projects the output data. + let effective_fields = if self.fields_explicit { self.fields.as_str() + } else { + default_fields }; let human_view = output_format == OutputFormat::Human && self.human_views.has_view(view_id); // `apply_pipeline` never sees `effective_fields` for a registered diff --git a/cli-engine/tests/consumer_cli.rs b/cli-engine/tests/consumer_cli.rs index 5d711db..f1557d9 100644 --- a/cli-engine/tests/consumer_cli.rs +++ b/cli-engine/tests/consumer_cli.rs @@ -240,6 +240,44 @@ async fn default_fields_are_projected_without_validation_against_the_response() ); } +#[tokio::test] +async fn explicit_empty_fields_keeps_everything_instead_of_falling_back_to_default_fields() { + // Once a command has `default_fields` set, clap fills `--fields` with + // that same non-empty string whether or not the user typed the flag — + // so `self.fields.is_empty()` can't tell "user explicitly cleared + // --fields" apart from "user never touched it" (`fields_explicit` can). + // An explicit `--fields ""` means "keep everything," same as `all`/`*`, + // and must not silently narrow to the command's own default instead. + let cli = Cli::new( + CliConfig::new("my-cli", "Team CLI", "my-cli").with_module(Module::new( + "Platform Systems", + |_context| { + RuntimeGroupSpec::new(GroupSpec::new("widget", "Manage widgets")).with_command( + RuntimeCommandSpec::new( + CommandSpec::new("list", "List widgets") + .with_default_fields("id") + .no_auth(true), + async |_credential, _args| { + Ok(CommandResult::new(json!([{"id": "w1", "extra": true}]))) + }, + ), + ) + }, + )), + ); + + let output = cli + .run([ + "my-cli", "widget", "list", "--output", "json", "--fields", "", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + assert_eq!( + serde_json::from_str::(&output.rendered).expect("json"), + json!({"data": [{"id": "w1", "extra": true}]}) + ); +} + #[tokio::test] async fn human_view_rejects_an_unknown_explicit_field_instead_of_narrowing_to_nothing() { // With a registered human view, `apply_pipeline`'s field validation never diff --git a/cli-engine/tests/foundation.rs b/cli-engine/tests/foundation.rs index af6302f..ffdd173 100644 --- a/cli-engine/tests/foundation.rs +++ b/cli-engine/tests/foundation.rs @@ -7316,6 +7316,7 @@ async fn middleware_success_authz_audit_activity_and_fields() { middleware.env = "prod".to_owned(); middleware.verbose = "all".to_owned(); middleware.fields = "name".to_owned(); + middleware.fields_explicit = true; middleware.auditor = Some(audit.clone()); middleware.activity = Some(activity.clone()); middleware.authz = Some(Arc::new(AllowAuthorizer));