Skip to content
Merged
56 changes: 40 additions & 16 deletions src/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -876,12 +876,25 @@ impl Middleware {
effective_args: &ValueMap,
identity: &str,
) -> Result<Option<MiddlewareOutput>> {
if self.schema
&& let Some(schema) = self.schema_registry.get_by_path(command_path)
{
if self.schema {
// Registered schema: dump it. Otherwise don't silently run the
// command — report that no schema exists. (We deliberately don't
// suggest "run it with --fields all" here: that would execute the
// command, which is exactly wrong for a mutation.)
let envelope = match self.schema_registry.get_by_path(command_path) {
Some(schema) => Envelope::success(schema, self.app_id.clone()),
None => Envelope::success(
json!({
"command": command_path,
"schema": Value::Null,
"message": "No output schema is registered for this command.",
}),
Comment thread
jpage-godaddy marked this conversation as resolved.
Outdated
self.app_id.clone(),
),
Comment thread
jpage-godaddy marked this conversation as resolved.
};
return self
.render_envelope(
Envelope::success(schema, self.app_id.clone()),
envelope,
"",
command_path,
start,
Expand Down Expand Up @@ -917,14 +930,31 @@ impl Middleware {
);
}
let output_format = self.output_format.parse::<OutputFormat>()?;
let mut fields = if self.fields.is_empty() {
default_fields
// The command's system/schema id (set when the envelope was built;
// `with_context` below doesn't touch it), used both for field selection
// and to pick a registered human view.
let system = envelope
Comment thread
jpage-godaddy marked this conversation as resolved.
Outdated
.metadata
.as_ref()
.map(|metadata| metadata.system.as_str())
.unwrap_or_default()
.to_owned();
// Field selection precedence: an explicit `--fields` always wins.
// Otherwise the command's `default_fields` curate the output — in every
// format, including Human, so an interactive table shows the curated
// columns instead of dumping every field. The one exception: Human
// output for a command with a registered HumanView, which selects its
// own columns from the full payload — pre-projecting there would strip
// fields the view needs, so leave projection off and let the view
// curate. Commands with no `default_fields` project to "" (all fields),
// as do `--fields all`/`*`, so the full payload is always one flag away.
let fields = if !self.fields.is_empty() {
self.fields.as_str()
} else if output_format == OutputFormat::Human && self.human_views.has_view(&system) {
""
} else {
&self.fields
default_fields
};
if output_format == OutputFormat::Human && self.fields.is_empty() {
fields = "";
}
if let Some(data) = &mut envelope.data {
let pagination = apply_pipeline(
data,
Expand All @@ -950,12 +980,6 @@ impl Middleware {
Some(Value::Object(user_args.clone())),
Some(Value::Object(effective_args.clone())),
);
let system = envelope
.metadata
.as_ref()
.map(|metadata| metadata.system.as_str())
.unwrap_or_default()
.to_owned();
let prepared = envelope.prepare_for_render(&self.verbose);
let rendered = if output_format == OutputFormat::Human {
render_human_with_registry_for_schema(&prepared, &self.human_views, &system)
Expand Down
9 changes: 9 additions & 0 deletions src/output/human.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,15 @@ impl HumanViewRegistry {
pub fn custom(&self, schema_id: &str) -> Option<&HumanViewRenderer> {
self.custom_by_schema_id.get(schema_id)
}

/// Whether any human view (column-based or custom) is registered for a
/// schema id. Such a view selects its own columns from the full payload, so
/// callers must not pre-project the data before handing it to the renderer.
#[must_use]
pub fn has_view(&self, schema_id: &str) -> bool {
self.by_schema_id.contains_key(schema_id)
|| self.custom_by_schema_id.contains_key(schema_id)
}
}

static GLOBAL_HUMAN_VIEW_REGISTRY: OnceLock<RwLock<HumanViewRegistry>> = OnceLock::new();
Expand Down
55 changes: 55 additions & 0 deletions tests/consumer_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,3 +288,58 @@ async fn bare_invocation_without_hook_falls_back_to_long_help() {
bare_json.rendered
);
}

// A command whose `default_fields` is a strict *subset* of its data keys, with
// no registered HumanViewDef (so the table auto-derives columns from the data).
// This makes the field projection directly observable in human output.
fn fields_demo_cli() -> Cli {
Cli::new(
CliConfig::new("my-cli", "Team CLI", "my-cli")
.with_build(BuildInfo::new("0.1.0"))
.with_module(Module::new("Demo", |_context| {
RuntimeGroupSpec::new(GroupSpec::new("widget", "Manage widgets")).with_command(
RuntimeCommandSpec::new(
CommandSpec::new("list", "List widgets")
.with_system("widgets-api")
.with_default_fields("id,name")
.no_auth(true),
async |_credential, _args| {
Ok(CommandResult::new(json!([
{"id": "w1", "name": "alpha", "secret": "hidden"}
])))
},
),
)
})),
)
}

#[tokio::test]
async fn human_output_projects_to_default_fields_when_no_fields_flag() {
let cli = fields_demo_cli();
let human = cli
.run(["my-cli", "widget", "list", "--output", "human"])
.await;
assert_eq!(human.exit_code, 0, "{}", human.rendered);
assert!(human.rendered.contains("NAME"), "{}", human.rendered);
// `default_fields` is "id,name", so the `secret` column must be projected
// out of the human table even though no `--fields` flag was passed.
assert!(
!human.rendered.contains("SECRET") && !human.rendered.contains("hidden"),
"human output should honor default_fields and omit `secret`: {}",
human.rendered
);
}

#[tokio::test]
async fn human_output_with_fields_all_overrides_default_fields() {
// The escape hatch: `--fields all` shows every column in human mode.
let cli = fields_demo_cli();
let human = cli
.run([
"my-cli", "widget", "list", "--output", "human", "--fields", "all",
])
.await;
assert_eq!(human.exit_code, 0, "{}", human.rendered);
assert!(human.rendered.contains("hidden"), "{}", human.rendered);
}
47 changes: 46 additions & 1 deletion tests/foundation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6686,6 +6686,47 @@ async fn middleware_schema_short_circuit_precedes_no_auth_authorizer_and_dry_run
assert_eq!(rendered["data"]["fields"][0]["name"], "name");
}

#[tokio::test]
async fn middleware_schema_without_registration_reports_no_schema_and_skips_command() {
// `--schema` on a command with no registered output schema must NOT silently
// run the command. It should report that no schema exists and point at how to
// list the available fields.
let mut middleware = Middleware::new();
middleware.output_format = "json".to_owned();
middleware.schema = true;
let called = Arc::new(AtomicUsize::new(0));
let called_for_handler = Arc::clone(&called);

let output = middleware
.run_no_auth(
CommandMeta::default(),
"things:list",
value_map([]),
value_map([]),
"",
async || {
called_for_handler.fetch_add(1, Ordering::SeqCst);
Ok(CommandResult::new(json!([{"name": "alpha"}])))
},
)
.await
.expect("schema request should render");

assert_eq!(
called.load(Ordering::SeqCst),
0,
"the command must not run under --schema"
);
let rendered: serde_json::Value = serde_json::from_str(&output.rendered).expect("valid json");
assert_eq!(rendered["data"]["command"], "things:list");
let message = rendered["data"]["message"].as_str().unwrap_or_default();
assert!(
message.contains("No output schema is registered"),
"expected a no-schema message, got: {}",
output.rendered
);
Comment thread
jpage-godaddy marked this conversation as resolved.
}

#[tokio::test]
async fn middleware_human_output_skips_default_fields_for_registered_views() {
let mut middleware = Middleware::new();
Expand All @@ -6694,8 +6735,12 @@ async fn middleware_human_output_skips_default_fields_for_registered_views() {
.register(Arc::new(FakeProvider::new("primary", "tester")));
middleware.default_auth_provider = "primary".to_owned();
middleware.output_format = "human".to_owned();
// Register the view under the command's *system* (`things:list` -> `things`)
// so the renderer actually resolves it. A registered view selects its own
// columns, so even though `default_fields` is just "name", the human output
// must keep `status` — the view is the curation, not `default_fields`.
middleware.human_views.register(HumanViewDef {
schema_id: "things-api".to_owned(),
schema_id: "things".to_owned(),
columns: vec![
TableColumn {
field: "name".to_owned(),
Expand Down