diff --git a/cli-engine/docs/auth.md b/cli-engine/docs/auth.md index d11a264..46ac71e 100644 --- a/cli-engine/docs/auth.md +++ b/cli-engine/docs/auth.md @@ -316,6 +316,18 @@ When a CLI registers auth providers or configures a default provider, `cli_engin These commands are implemented with the same `CommandSpec`, middleware, output envelope, and renderers as application commands. +### Human views + +`auth status` and `auth login` render their human output through the views `auth:status` and `auth:login` (`AUTH_STATUS_VIEW_ID`, `AUTH_LOGIN_VIEW_ID`). The engine registers `default_auth_status_view()` and `default_auth_login_view()` under those ids: identity first, then expiry, environment, provider, and the remaining fields. + +To change which columns show by default, set the command's default fields with `CliConfig::with_auth_status_default_fields` / `with_auth_login_default_fields`. + +```rust +let config = config.with_auth_status_default_fields("identity,expires_at,env,expired,refreshable,scopes"); +``` + +To change the columns themselves, register a `HumanViewDef` under the same id with `CliConfig::with_view` (or `register_global_human_view`). The built-in group is kept, and a consumer view always wins over the default. `default_auth_status_view().columns` is a convenient starting point. + ## Transport Injectors Transport injectors implement [`AuthInjector`](../src/transport/injector.rs) and mutate outbound diff --git a/cli-engine/src/auth/commands.rs b/cli-engine/src/auth/commands.rs index 33d3a8a..ae92255 100644 --- a/cli-engine/src/auth/commands.rs +++ b/cli-engine/src/auth/commands.rs @@ -1,13 +1,65 @@ use clap::{Arg, ArgAction}; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use super::Dispatcher; use crate::{ - CliCoreError, CommandContext, CommandResult, CommandSpec, Credential, GroupSpec, Result, - RuntimeCommandSpec, RuntimeGroupSpec, Tier, + CliCoreError, CommandContext, CommandResult, CommandSpec, Credential, GroupSpec, HumanViewDef, + Result, RuntimeCommandSpec, RuntimeGroupSpec, TableColumn, Tier, }; +/// Human view id the built-in `auth status` command renders with. +/// +/// Register a [`HumanViewDef`] under this id (via +/// [`CliConfig::with_view`](crate::CliConfig::with_view) or +/// [`register_global_human_view`](crate::register_global_human_view)) to +/// replace the engine's [`default_auth_status_view`]. +pub const AUTH_STATUS_VIEW_ID: &str = "auth:status"; + +/// Human view id the built-in `auth login` command renders with. See +/// [`AUTH_STATUS_VIEW_ID`]. +pub const AUTH_LOGIN_VIEW_ID: &str = "auth:login"; + +/// The engine's default human view for `auth status`: identity and expiry +/// first, then the environment and provider they belong to. +/// +/// A consumer overriding [`AUTH_STATUS_VIEW_ID`] can start from this and +/// drop or reorder columns, for example to hide `provider` when only one is +/// registered. +#[must_use] +pub fn default_auth_status_view() -> HumanViewDef { + HumanViewDef::new( + AUTH_STATUS_VIEW_ID, + vec![ + TableColumn::new("identity", "Identity"), + TableColumn::new("expires_at", "Expires"), + TableColumn::new("env", "Env"), + TableColumn::new("provider", "Provider"), + TableColumn::new("expired", "Expired"), + TableColumn::new("refreshable", "Refreshable"), + TableColumn::new("scopes", "Scopes"), + ], + ) +} + +/// The engine's default human view for `auth login`. See +/// [`default_auth_status_view`]. +#[must_use] +pub fn default_auth_login_view() -> HumanViewDef { + HumanViewDef::new( + AUTH_LOGIN_VIEW_ID, + vec![ + TableColumn::new("identity", "Identity"), + TableColumn::new("expires_at", "Expires"), + TableColumn::new("env", "Env"), + TableColumn::new("provider", "Provider"), + TableColumn::new("refreshable", "Refreshable"), + TableColumn::new("scopes", "Scopes"), + ], + ) +} + /// Data rendered after a successful `auth login`. /// /// Built by [`login_and_build`]/[`login_and_build_with_scopes`] from the @@ -15,7 +67,7 @@ use crate::{ /// result and should not construct it directly — `#[non_exhaustive]` lets /// this framework add fields (as it did for [`refreshable`](Self::refreshable)) /// without another breaking release. -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] #[non_exhaustive] pub struct AuthLoginResult { /// Provider used for login. @@ -46,7 +98,7 @@ pub struct AuthLoginResult { /// result and should not construct it directly — `#[non_exhaustive]` lets /// this framework add fields (as it did for [`refreshable`](Self::refreshable)) /// without another breaking release. -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] #[non_exhaustive] pub struct AuthStatusEntry { /// Provider name. @@ -86,6 +138,8 @@ pub fn auth_command_group(default_provider: &str, registered_names: &[String]) - .with_tier(Tier::Mutate) .mutates(true) .no_auth(true) + .with_view_id(AUTH_LOGIN_VIEW_ID) + .with_json_schema::() .with_arg(provider_arg(&effective_default, registered_names)) .with_arg(Arg::new("env").long("env").value_name("ENV")) .with_arg( @@ -115,6 +169,8 @@ pub fn auth_command_group(default_provider: &str, registered_names: &[String]) - CommandSpec::new("status", "Show cached credential status") .with_system("auth") .no_auth(true) + .with_view_id(AUTH_STATUS_VIEW_ID) + .with_json_schema::() .with_arg(provider_arg(&effective_default, registered_names)) .with_arg(Arg::new("env").long("env").value_name("ENV")), async |context| { diff --git a/cli-engine/src/auth/mod.rs b/cli-engine/src/auth/mod.rs index 45fd6d6..ea87688 100644 --- a/cli-engine/src/auth/mod.rs +++ b/cli-engine/src/auth/mod.rs @@ -25,7 +25,8 @@ pub mod storage; use async_trait::async_trait; pub use commands::{ - AuthLoginResult, AuthStatusEntry, auth_command_group, login_and_build, + AUTH_LOGIN_VIEW_ID, AUTH_STATUS_VIEW_ID, AuthLoginResult, AuthStatusEntry, auth_command_group, + default_auth_login_view, default_auth_status_view, login_and_build, login_and_build_with_scopes, logout_result, status_result, to_status_entry, }; pub use credential::{CACHE_TTL, Credential}; diff --git a/cli-engine/src/cli/config.rs b/cli-engine/src/cli/config.rs index 28eebb2..92940ce 100644 --- a/cli-engine/src/cli/config.rs +++ b/cli-engine/src/cli/config.rs @@ -124,6 +124,12 @@ pub struct CliConfig { /// the built-in group is built, so the built-ins are never lost or /// overwritten. pub auth_extra_commands: Vec, + /// Default `--fields` for the built-in `auth status`. Populate via + /// [`CliConfig::with_auth_status_default_fields`]. + pub auth_status_default_fields: Option, + /// Default `--fields` for the built-in `auth login`. Populate via + /// [`CliConfig::with_auth_login_default_fields`]. + pub auth_login_default_fields: Option, /// Global guide entries mounted under `guide`. pub guides: Vec, /// Global human output views. @@ -497,6 +503,24 @@ impl CliConfig { self } + /// Sets the default `--fields` of the built-in `auth status`. + /// + /// Same semantics as [`CommandSpec::with_default_fields`](crate::CommandSpec::with_default_fields): the fields + /// shown (and their order) when `--fields` is absent. + #[must_use] + pub fn with_auth_status_default_fields(mut self, fields: impl Into) -> Self { + self.auth_status_default_fields = Some(fields.into()); + self + } + + /// Sets the default `--fields` of the built-in `auth login`. See + /// [`CliConfig::with_auth_status_default_fields`]. + #[must_use] + pub fn with_auth_login_default_fields(mut self, fields: impl Into) -> Self { + self.auth_login_default_fields = Some(fields.into()); + self + } + /// Adds one global guide. #[must_use] pub fn with_guide(mut self, guide: GuideEntry) -> Self { diff --git a/cli-engine/src/cli/mod.rs b/cli-engine/src/cli/mod.rs index b1707df..ef7212e 100644 --- a/cli-engine/src/cli/mod.rs +++ b/cli-engine/src/cli/mod.rs @@ -179,6 +179,15 @@ impl Cli { .subcommand(Command::new("tree").about("Display full command tree")) .subcommand(completion_command()) .subcommand(search_command()); + // The flag is only a default-on behavior for a CLI that opted into TTY + // auto-detection, so say so only then. + if config.auto_interactive { + root = root.mut_arg("interactive", |arg| { + arg.help( + "Force interactive prompts for missing inputs (default when a TTY is detected)", + ) + }); + } if let Some(register_flags) = &config.register_flags { root = register_flags(root); } diff --git a/cli-engine/src/cli/registration.rs b/cli-engine/src/cli/registration.rs index 9b93492..9c5cbc4 100644 --- a/cli-engine/src/cli/registration.rs +++ b/cli-engine/src/cli/registration.rs @@ -6,7 +6,7 @@ use clap::builder::PossibleValuesParser; use super::{ Cli, - config::{BUILTIN_COMMAND_NAMES, DEFAULT_ADMIN_CATEGORY}, + config::{BUILTIN_COMMAND_NAMES, CliConfig, DEFAULT_ADMIN_CATEGORY}, help::{ModuleHelpEntry, build_root_long}, lookup::has_subcommand, schema_tree::{ @@ -14,7 +14,11 @@ use super::{ runtime_group_clap_command_with_schema_help, }, }; -use crate::{FeatureFlag, RuntimeGroupSpec, auth::commands::auth_command_group}; +use crate::auth::commands::{ + auth_command_group, default_auth_login_view, default_auth_status_view, +}; +use crate::output::HumanViewRegistry; +use crate::{FeatureFlag, RuntimeGroupSpec}; /// Lists the auto-registered `auth` command under the admin help category so /// it is never uncategorized once clap's auto subcommand list is suppressed. @@ -182,6 +186,7 @@ pub(super) fn ensure_auth_command(cli: &mut Cli) { return; } let mut group = auth_command_group(&default_provider, ®istered_names); + apply_auth_default_fields(&mut group, &cli.config); let mut seen_names: std::collections::HashSet = group.commands.iter().map(|c| c.spec.name.clone()).collect(); for extra in cli.config.auth_extra_commands.clone() { @@ -202,6 +207,7 @@ pub(super) fn ensure_auth_command(cli: &mut Cli) { &mut cli.middleware.schema_registry, &mut cli.middleware.human_views, ); + register_default_auth_views(&mut cli.middleware.human_views); let mut prefix = Vec::new(); group.register_commands(&mut prefix, &mut cli.commands); let mut prefix = Vec::new(); @@ -221,6 +227,29 @@ pub(super) fn ensure_auth_command(cli: &mut Cli) { register_auth_help_entry(cli); } +/// Applies the consumer's default `--fields` to the built-in auth commands. +fn apply_auth_default_fields(group: &mut RuntimeGroupSpec, config: &CliConfig) { + for command in &mut group.commands { + let fields = match command.spec.name.as_str() { + "status" => config.auth_status_default_fields.as_ref(), + "login" => config.auth_login_default_fields.as_ref(), + _ => None, + }; + if let Some(fields) = fields { + command.spec = command.spec.clone().with_default_fields(fields.clone()); + } + } +} + +/// Registers the engine's default `auth status`/`auth login` views. +fn register_default_auth_views(views: &mut HumanViewRegistry) { + for default in [default_auth_status_view(), default_auth_login_view()] { + if !views.has_view(&default.schema_id) { + views.register(default); + } + } +} + /// Mounts the built-in `config` command group and files it under the admin /// help category. Idempotent and yields to a consumer-defined `config` /// subcommand if one already exists. diff --git a/cli-engine/src/cli/schema_tree.rs b/cli-engine/src/cli/schema_tree.rs index 86c76db..ea7243d 100644 --- a/cli-engine/src/cli/schema_tree.rs +++ b/cli-engine/src/cli/schema_tree.rs @@ -362,6 +362,11 @@ fn apply_fields_arg( let mut help = String::from( "Comma-separated fields to include in output (use 'all' or '*' for everything)", ); + // Clap appends its own `[default: ...]` after the last line of help, which + // lands on the end of the field table's final row; render it up front instead. + if let Some(default_value) = default_value { + help.push_str(&format!(" [default: {default_value}]")); + } if let Some(table) = &table { help.push_str("\n\n"); help.push_str(table.trim_end()); @@ -377,7 +382,9 @@ fn apply_fields_arg( .display_order(crate::flags::global_flag_order::FIELDS) .help(help); if let Some(default_value) = default_value { - arg = arg.default_value(default_value.to_owned()); + arg = arg + .default_value(default_value.to_owned()) + .hide_default_value(true); } command.arg(arg) } diff --git a/cli-engine/src/flags/register.rs b/cli-engine/src/flags/register.rs index 5f71ce6..71b50b6 100644 --- a/cli-engine/src/flags/register.rs +++ b/cli-engine/src/flags/register.rs @@ -149,7 +149,7 @@ pub fn register_global_flags(command: Command) -> Command { .action(ArgAction::SetTrue) .conflicts_with("non-interactive") .display_order(global_flag_order::INTERACTIVE) - .help("Force interactive prompts for missing inputs (default when TTY is detected)"), + .help("Force interactive prompts for missing inputs"), ) .arg( Arg::new("non-interactive") diff --git a/cli-engine/src/lib.rs b/cli-engine/src/lib.rs index f8f91a0..a679e7c 100644 --- a/cli-engine/src/lib.rs +++ b/cli-engine/src/lib.rs @@ -122,8 +122,9 @@ pub use auth::storage::{ CredentialKey, CredentialStorage, FileStorage, default_storage, storage_for, }; pub use auth::{ - AuthLoginResult, AuthProvider, AuthStatusEntry, CACHE_TTL, Credential, CredentialRequest, - Dispatcher, SingleProvider, StatusEntry, auth_command_group, login_and_build, + AUTH_LOGIN_VIEW_ID, AUTH_STATUS_VIEW_ID, AuthLoginResult, AuthProvider, AuthStatusEntry, + CACHE_TTL, Credential, CredentialRequest, Dispatcher, SingleProvider, StatusEntry, + auth_command_group, default_auth_login_view, default_auth_status_view, login_and_build, login_and_build_with_scopes, logout_result, status_result, to_status_entry, }; pub use cli::{ diff --git a/cli-engine/src/middleware/run.rs b/cli-engine/src/middleware/run.rs index 9fbfad1..0fdb8b0 100644 --- a/cli-engine/src/middleware/run.rs +++ b/cli-engine/src/middleware/run.rs @@ -497,11 +497,21 @@ impl Middleware { // 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. + // `--expr` hands the caller control of the result's shape, so + // neither the command's `default_fields` nor its view describe the + // result any longer. Fall back to no projection (not `default_fields`, + // whose names the expression's result need not contain — which would + // silently project to `{}`), and render generically so an explicit + // `--fields` is validated and projected against the result, as for + // JSON. An explicit `--fields` still applies. `--filter` keeps both. let effective_fields = if self.fields_explicit { self.fields.as_str() - } else { + } else if self.expr.is_empty() { default_fields + } else { + "" }; + let view_id = if self.expr.is_empty() { view_id } else { "" }; 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 diff --git a/cli-engine/tests/auth_views.rs b/cli-engine/tests/auth_views.rs new file mode 100644 index 0000000..4bf6c84 --- /dev/null +++ b/cli-engine/tests/auth_views.rs @@ -0,0 +1,375 @@ +//! Default and overridden human views for the built-in `auth status` and +//! `auth login` commands. + +use std::sync::Arc; + +use async_trait::async_trait; +use cli_engine::{ + AuthProvider, Cli, CliConfig, Credential, HumanViewDef, Result, TableColumn, + default_auth_status_view, +}; +use serde_json::Value; + +#[derive(Debug)] +struct FakeProvider; + +#[async_trait] +impl AuthProvider for FakeProvider { + fn name(&self) -> &str { + "primary" + } + + async fn get_credential(&self, env: &str, _command: &str, _tier: &str) -> Result { + Ok(Credential { + token: "token".to_owned(), + expires_at: "2099-01-01T00:00:00Z".to_owned(), + env: env.to_owned(), + identity: "tester".to_owned(), + ..Credential::default() + }) + } + + async fn status(&self, env: &str) -> Result { + self.get_credential(env, "", "").await + } + + async fn logout(&self, _env: &str) -> Result<()> { + Ok(()) + } + + async fn list_environments(&self) -> Result> { + Ok(vec!["prod".to_owned()]) + } +} + +fn config() -> CliConfig { + CliConfig { + name: "my-cli".to_owned(), + short: "Auth view test CLI".to_owned(), + app_id: "my-cli".to_owned(), + default_auth_provider: Some("primary".to_owned()), + ..CliConfig::default() + } +} + +fn cli_with(config: CliConfig) -> Cli { + let mut cli = Cli::new(config); + cli.register_auth_provider(Arc::new(FakeProvider)); + cli +} + +/// Column headers of the first table-looking line in human output. +fn header_line(rendered: &str) -> &str { + rendered + .lines() + .find(|line| line.contains("IDENTITY")) + .expect("human output has an IDENTITY header line") +} + +fn position(header: &str, column: &str) -> usize { + assert!(header.contains(column), "{column} missing from: {header}"); + header.find(column).expect("checked above") +} + +async fn run(cli: &Cli, args: &[&str]) -> String { + let mut argv = vec!["my-cli"]; + argv.extend_from_slice(args); + let output = cli.run(argv).await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + output.rendered +} + +#[tokio::test] +async fn default_status_view_leads_with_identity_then_expires() { + let cli = cli_with(config()); + let rendered = run(&cli, &["auth", "status", "--output", "human"]).await; + let header = header_line(&rendered); + assert!(position(header, "IDENTITY") < position(header, "EXPIRES")); + assert!(position(header, "EXPIRES") < position(header, "ENV")); + assert!(header.contains("PROVIDER")); +} + +#[tokio::test] +async fn default_login_view_leads_with_identity_then_expires() { + let cli = cli_with(config()); + let rendered = run( + &cli, + &["auth", "login", "--env", "prod", "--output", "human"], + ) + .await; + assert!(rendered.contains("tester"), "{rendered}"); + let identity = position(&rendered, "Identity"); + let expires = position(&rendered, "Expires"); + assert!(identity < expires, "{rendered}"); +} + +#[tokio::test] +async fn config_view_overrides_status_columns_without_replacing_the_group() { + let columns = default_auth_status_view() + .columns + .into_iter() + .filter(|column| column.field != "provider") + .collect::>(); + let cli = cli_with(config().with_view(HumanViewDef::new("auth:status", columns))); + + let rendered = run(&cli, &["auth", "status", "--output", "human"]).await; + assert!(!header_line(&rendered).contains("PROVIDER"), "{rendered}"); + + // The built-in group is intact: login/logout still resolve. + run(&cli, &["auth", "logout", "--env", "prod"]).await; +} + +#[tokio::test] +async fn override_survives_a_later_provider_registration() { + let cli_config = config().with_view(HumanViewDef::new( + "auth:status", + vec![TableColumn::new("identity", "Identity")], + )); + let mut cli = Cli::new(cli_config); + cli.register_auth_provider(Arc::new(FakeProvider)); + + let rendered = run(&cli, &["auth", "status", "--output", "human"]).await; + assert!(!rendered.contains("EXPIRES"), "{rendered}"); +} + +#[tokio::test] +async fn json_output_still_carries_every_field() { + let cli = cli_with(config()); + let rendered = run(&cli, &["auth", "status", "--output", "json"]).await; + let envelope: Value = serde_json::from_str(&rendered).expect("valid json"); + let row = &envelope["data"][0]; + for field in [ + "provider", + "env", + "identity", + "expires_at", + "expired", + "refreshable", + ] { + assert!(row.get(field).is_some(), "missing {field}: {row}"); + } +} + +#[tokio::test] +async fn fields_flag_narrows_the_default_view() { + let cli = cli_with(config()); + let rendered = run( + &cli, + &[ + "auth", "status", "--output", "human", "--fields", "identity", + ], + ) + .await; + assert!(!rendered.contains("EXPIRES"), "{rendered}"); +} + +const NO_PROVIDER: &str = "identity,expires_at,env,expired,refreshable,scopes"; + +#[tokio::test] +async fn default_fields_leave_provider_out_but_keep_it_selectable() { + let cli = cli_with(config().with_auth_status_default_fields(NO_PROVIDER)); + + let default = run(&cli, &["auth", "status", "--output", "human"]).await; + assert!(!header_line(&default).contains("PROVIDER"), "{default}"); + + let explicit = run( + &cli, + &[ + "auth", + "status", + "--output", + "human", + "--fields", + "identity,provider", + ], + ) + .await; + let header = header_line(&explicit); + assert!(position(header, "IDENTITY") < position(header, "PROVIDER")); + assert!(!header.contains("EXPIRES"), "{explicit}"); +} + +#[tokio::test] +async fn login_default_fields_leave_provider_out_but_keep_it_selectable() { + let cli = cli_with(config().with_auth_login_default_fields("identity,expires_at,env")); + let login = ["auth", "login", "--env", "prod", "--output", "human"]; + + let default = run(&cli, &login).await; + assert!(!default.contains("Provider"), "{default}"); + + let mut explicit = login.to_vec(); + explicit.extend(["--fields", "provider"]); + let explicit = run(&cli, &explicit).await; + assert!(explicit.contains("Provider: primary"), "{explicit}"); +} + +#[tokio::test] +async fn default_fields_project_json_like_any_other_command() { + let cli = cli_with(config().with_auth_status_default_fields(NO_PROVIDER)); + + let default = run(&cli, &["auth", "status", "--output", "json"]).await; + let envelope: Value = serde_json::from_str(&default).expect("valid json"); + assert!(envelope["data"][0].get("provider").is_none(), "{default}"); + + let explicit = run( + &cli, + &["auth", "status", "--output", "json", "--fields", "provider"], + ) + .await; + let envelope: Value = serde_json::from_str(&explicit).expect("valid json"); + assert_eq!(envelope["data"][0]["provider"], "primary"); +} + +#[tokio::test] +async fn help_lists_every_output_field_and_marks_the_defaults() { + let cli = cli_with(config().with_auth_status_default_fields(NO_PROVIDER)); + let help = run(&cli, &["auth", "status", "--help"]).await; + assert!(help.contains("Output fields:"), "{help}"); + let provider = help + .lines() + .find(|line| line.trim_start().starts_with("provider")) + .expect("provider is listed"); + assert!(!provider.contains("(default)"), "{provider}"); + let identity = help + .lines() + .find(|line| line.trim_start().starts_with("identity")) + .expect("identity is listed"); + assert!(identity.contains("(default)"), "{identity}"); +} + +#[tokio::test] +async fn fields_default_precedes_the_field_table() { + let cli = cli_with(config().with_auth_status_default_fields(NO_PROVIDER)); + let help = run(&cli, &["auth", "status", "--help"]).await; + let default = format!("[default: {NO_PROVIDER}]"); + assert_eq!(help.matches("[default: identity").count(), 1, "{help}"); + let default_at = help.find(&default).expect("default is shown"); + let table_at = help.find("Output fields:").expect("table is shown"); + assert!(default_at < table_at, "{help}"); +} + +#[tokio::test] +async fn expr_renders_the_result_generically() { + let cli = cli_with(config()); + let status = ["auth", "status", "--provider", "primary", "--env", "prod"]; + let login = ["auth", "login", "--env", "prod"]; + for base in [&status[..], &login[..]] { + let mut args = base.to_vec(); + args.extend(["--expr", "{user: identity}", "--output", "human"]); + let rendered = run(&cli, &args).await; + assert!(rendered.to_lowercase().contains("user"), "{rendered}"); + assert!(rendered.contains("tester"), "{rendered}"); + assert!(!rendered.contains("Provider"), "{rendered}"); + } + + let rows = run( + &cli, + &[ + "auth", + "status", + "--expr", + "[].{user: identity}", + "--output", + "human", + ], + ) + .await; + assert!(rows.contains("USER") && rows.contains("tester"), "{rows}"); +} + +#[tokio::test] +async fn filter_keeps_the_view() { + let cli = cli_with(config()); + let filtered = run( + &cli, + &[ + "auth", + "status", + "--filter", + "env=='prod'", + "--output", + "human", + ], + ) + .await; + let header = header_line(&filtered); + assert!(position(header, "IDENTITY") < position(header, "EXPIRES")); +} + +#[tokio::test] +async fn fields_after_expr_select_from_the_expression_result() { + let cli = cli_with(config()); + let status = ["auth", "status", "--provider", "primary", "--env", "prod"]; + let login = ["auth", "login", "--env", "prod"]; + for base in [&status[..], &login[..]] { + // The expression keeps `identity` (a view column) and adds `user`. + let mut args = base.to_vec(); + args.extend([ + "--expr", + "{identity: identity, user: env}", + "--fields", + "user", + "--output", + "human", + ]); + let rendered = run(&cli, &args).await; + assert!(rendered.to_lowercase().contains("user"), "{rendered}"); + assert!(rendered.contains("prod"), "{rendered}"); + assert!(!rendered.contains("tester"), "{rendered}"); + } +} + +#[tokio::test] +async fn unknown_fields_are_rejected_with_and_without_expr() { + let cli = cli_with(config()); + for extra in [ + vec!["--fields", "nope"], + vec!["--expr", "{user: identity}", "--fields", "uesr"], + vec!["--expr", "@", "--fields", "nope"], + ] { + let mut args = vec![ + "my-cli", + "auth", + "status", + "--provider", + "primary", + "--env", + "prod", + ]; + args.extend(extra.iter().copied()); + args.extend(["--output", "human"]); + let output = cli.run(args).await; + assert_ne!(output.exit_code, 0, "{}", output.rendered); + assert!( + output.rendered.contains("unknown field"), + "{}", + output.rendered + ); + } +} + +#[tokio::test] +async fn expr_ignores_default_fields() { + // With `default_fields` set (how a consumer hides `provider`), a bare + // `--expr` must render its own result, not project the command's default + // fields onto it — otherwise the result's fields are dropped to `{}`. + let cli = cli_with( + config() + .with_auth_status_default_fields(NO_PROVIDER) + .with_auth_login_default_fields("identity,expires_at,env,refreshable,scopes"), + ); + let status = ["auth", "status", "--provider", "primary", "--env", "prod"]; + let login = ["auth", "login", "--env", "prod"]; + for base in [&status[..], &login[..]] { + for out in ["human", "json"] { + let mut args = base.to_vec(); + args.extend(["--expr", "{user: identity}", "--output", out]); + let rendered = run(&cli, &args).await; + assert!( + rendered.to_lowercase().contains("user"), + "{out}: {rendered}" + ); + assert!(rendered.contains("tester"), "{out}: {rendered}"); + } + } +} diff --git a/cli-engine/tests/interactivity.rs b/cli-engine/tests/interactivity.rs index c31d11f..c548a73 100644 --- a/cli-engine/tests/interactivity.rs +++ b/cli-engine/tests/interactivity.rs @@ -136,3 +136,26 @@ async fn interactive_and_non_interactive_conflict() { output.rendered ); } + +/// The `--interactive` help line from `--help` output. +async fn interactive_help(config: CliConfig) -> String { + let output = Cli::new(config).run(["test-cli", "tree", "--help"]).await; + output + .rendered + .lines() + .find(|line| line.contains("--interactive")) + .unwrap_or_default() + .to_owned() +} + +#[tokio::test] +async fn interactive_help_claims_tty_default_only_when_auto_interactive() { + let base = || CliConfig::new("test-cli", "Test CLI", "test-cli"); + + let manual = interactive_help(base()).await; + assert!(manual.contains("Force interactive prompts"), "{manual}"); + assert!(!manual.contains("default"), "{manual}"); + + let auto = interactive_help(base().with_auto_interactive(true)).await; + assert!(auto.contains("default when a TTY is detected"), "{auto}"); +}